Goで構築するマイクロサービス向けコンテキストの設計パターン—context.Contextを正しく使うための実践ガイド

Goで構築するマイクロサービス向けコンテキストの設計パターン—context.Contextを正しく使うための実践ガイド | mohablog
目次

Goのcontextパッケージが実務で重要な理由

フリーランスとして複数のプロジェクトに携わるようになって気づいたのが、Goを使うプロジェクトの大半でcontext.Contextの使い方が不統一だということです。ライブラリによって渡すタイミングが違ったり、タイムアウトの設定忘れでリクエストが吊られたり。現場でそういう細かい問題が積み重なると、本番環境での障害につながりやすい。

この記事では、マイクロサービスやAPI開発の現場で実際に役立つcontextの設計パターンを紹介します。Go 1.22時点での標準的な使い方と、よくある落とし穴を避けるためのコツをまとめました。

context.Contextの基本を改めて整理する

context.Contextは、キャンセルシグナル、タイムアウト、deadline、そしてリクエストスコープのデータを管理するための型です。公式ドキュメントを読み直してみると、実は「リクエストスコープを通じて複数のgoroutineに値を渡すための型」として設計されていることがわかります。

多くのエンジニアが陥りやすい間違いは、contextを「万能な値の受け渡し機構」として使ってしまうこと。実際には、リクエストIDやトレースIDといった「リクエストライフサイクル中に必要な値」に限定すべきです。

contextの4つの基本形

contextには、生成のパターンが大きく4つあります。

  • context.Background() — アプリケーション起動時のルートとなるcontext
  • context.TODO() — どのcontextを使うべきか未定のとき用(本番環境ではあまり使わない)
  • context.WithCancel() — キャンセル可能なcontextを作成
  • context.WithDeadline()、context.WithTimeout() — タイムアウト機能付きcontext

よくある間違い:Backgroundからの直接使用

多くの初心者が犯す間違いが、context.Background()をHTTPリクエストハンドラーに直接渡すパターンです。

// ❌ ダメな例
func handleRequest(w http.ResponseWriter, r *http.Request) {
    // r.Context()を使わずに Background() を使用
    ctx := context.Background()
    result, err := callDatabaseWithContext(ctx)
    // ...
}

この方式だと、HTTPリクエストが途中で切られても、データベースクエリは実行され続けます。タイムアウトの設定も効かない。

// ✅ 正しい例
func handleRequest(w http.ResponseWriter, r *http.Request) {
    // リクエストに紐付いたcontextを使う
    ctx := r.Context()
    result, err := callDatabaseWithContext(ctx)
    // ...
}

リクエストオブジェクトが持つr.Context()は、HTTPサーバー側で自動的にリクエストのライフタイム、またはサーバーのシャットダウンまでを考慮したcontextとなります。

タイムアウトの設計パターン

タイムアウトの扱い方は、システム設計の中でも特に重要です。調べてみたら、タイムアウト設定がないAPIが原因で本番環境が止まった事例が相当多い。

パターン1:ハンドラーレベルのタイムアウト

HTTPリクエスト全体に対してタイムアウトを設定する最もシンプルなパターンです。

func handleRequest(w http.ResponseWriter, r *http.Request) {
    // リクエスト全体に5秒のタイムアウトを設定
    ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
    defer cancel()

    result, err := callDatabaseWithContext(ctx)
    if err != nil {
        if errors.Is(err, context.DeadlineExceeded) {
            http.Error(w, "Request timeout", http.StatusGatewayTimeout)
            return
        }
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(result)
}
// リクエストが5秒以内に完了しない場合:
Request timeout
// HTTPステータス: 504 Gateway Timeout

パターン2:操作別のタイムアウト(ネストされたcontext)

複数の外部API呼び出しがある場合、各操作ごとにタイムアウトを設定すると、より細かい制御が可能になります。

func handleRequest(w http.ResponseWriter, r *http.Request) {
    // リクエスト全体には10秒
    ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
    defer cancel()

    // データベースクエリには3秒
    dbCtx, dbCancel := context.WithTimeout(ctx, 3*time.Second)
    user, err := fetchUserFromDB(dbCtx)
    dbCancel()
    if err != nil {
        http.Error(w, "DB timeout", http.StatusGatewayTimeout)
        return
    }

    // 外部APIには5秒
    apiCtx, apiCancel := context.WithTimeout(ctx, 5*time.Second)
    userData, err := callExternalAPI(apiCtx, user.ID)
    apiCancel()
    if err != nil {
        http.Error(w, "API timeout", http.StatusGatewayTimeout)
        return
    }

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(map[string]interface{}{
        "user": user,
        "data": userData,
    })
}

この設計のポイントは、context.WithTimeout(ctx, ...)で既存のcontextから新しいcontextを派生させることです。親のcontextがキャンセルされると、自動的に子のcontextもキャンセルされます。

タイムアウト値の決め方

タイムアウト値をどうするかは、実際のプロジェクトで度々議論になります。経験則として、以下のような層別設定が有効です:

処理層 推奨タイムアウト 理由
HTTPハンドラー全体 30〜60秒 クライアント側のタイムアウト(通常60秒)に合わせる
データベースクエリ 3〜5秒 ローカルネットワーク遅延を考慮
外部API呼び出し 10〜15秒 インターネット遅延を考慮
非同期タスク 設定しない(キャンセルのみ) バックグラウンドでの処理継続を許可

値の受け渡し:context.WithValueの正しい使い方

contextに値を詰めるcontext.WithValue()は、慎重に使う必要があります。型安全性が保証されず、キーの衝突のリスクがあるからです。

アンチパターン:文字列キーでの値埋め込み

// ❌ ダメな例:文字列キーは衝突しやすい
ctx := context.WithValue(r.Context(), "user_id", userID)
ctx = context.WithValue(ctx, "request_id", requestID)

// どこかで同じキー名を使われたら衝突
ctx = context.WithValue(ctx, "user_id", anotherID)

推奨パターン:プライベート型キーの使用

// ✅ 正しい例:パッケージプライベート型でキーを定義
type contextKey string

const (
    userIDKey    contextKey = "user_id"
    requestIDKey contextKey = "request_id"
)

func setUserID(ctx context.Context, userID string) context.Context {
    return context.WithValue(ctx, userIDKey, userID)
}

func getUserID(ctx context.Context) (string, bool) {
    userID, ok := ctx.Value(userIDKey).(string)
    return userID, ok
}

// 使用例
func handleRequest(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    ctx = setUserID(ctx, "user_123")
    
    handleInternalRequest(ctx)
}

func handleInternalRequest(ctx context.Context) {
    userID, ok := getUserID(ctx)
    if !ok {
        // エラーハンドリング
        return
    }
    // userIDを使用
}

このパターンだと、パッケージプライベートな型キーを使うため、他のパッケージからの誤った上書きを防げます。また、getter/setterを通じることで、型安全性も確保されます。

実務での使い分け:なにをcontextに入れるべきか

context.WithValueは「リクエストスコープ内でのみ必要な値」に限定すべきです。良い例と悪い例を比較してみます。

値の種類 contextに入れるべき? 代替案
ユーザーID、リクエストID ✅ YES
ロギングcontext(trace ID) ✅ YES
認可情報(ロール、権限) ✅ YES
データベース接続 ❌ NO 依存注入、グローバル管理
設定値 ❌ NO アプリケーション起動時に読み込み
HTTP客体(http.ResponseWriter) ❌ NO 関数パラメータ

キャンセルの伝播:goroutine群の安全な停止

複数のgoroutineが並行実行される場合、contextのキャンセル機能が活躍します。

パターン:親のキャンセルが子に伝わる

func processUserDataWithWorkers(ctx context.Context, userID string) error {
    // 親のcontextから子のgoroutineにキャンセルが伝わる
    ctx, cancel := context.WithCancel(ctx)
    defer cancel()

    resultCh := make(chan string, 2)
    errCh := make(chan error, 2)

    // ワーカーA:ユーザー情報取得
    go func() {
        result, err := fetchUserInfo(ctx, userID)
        if err != nil {
            errCh <- err
            return
        }
        resultCh <- result
    }()

    // ワーカーB:ユーザーの履歴取得
    go func() {
        result, err := fetchUserHistory(ctx, userID)
        if err != nil {
            errCh <- err
            return
        }
        resultCh <- result
    }()

    // 最初のエラーまたはタイムアウトで、両方のgoroutineをキャンセル
    for i := 0; i < 2; i++ {
        select {
        case err := <-errCh:
            return err
        case result := <-resultCh:
            // 結果処理
            _ = result
        case <-ctx.Done():
            return ctx.Err()
        }
    }
    return nil
}

func fetchUserInfo(ctx context.Context, userID string) (string, error) {
    // ctx.Done()をチェック
    select {
    case <-ctx.Done():
        return "", ctx.Err()
    case <-time.After(2 * time.Second):
        return fmt.Sprintf("User %s Info", userID), nil
    }
}

func fetchUserHistory(ctx context.Context, userID string) (string, error) {
    select {
    case <-ctx.Done():
        return "", ctx.Err()
    case <-time.After(3 * time.Second):
        return fmt.Sprintf("User %s History", userID), nil
    }
}
// 正常系:
User user_123 Info
User user_123 History

// キャンセルされた場合:
context canceled

マイクロサービス設計での活用パターン

複数のマイクロサービスがAPI経由で連携する環境では、contextの扱いが複雑になります。

トレースIDの伝播

リクエスト全体を追跡するため、トレースIDをcontextに格納し、外部API呼び出し時にヘッダーに付与するパターンが標準的です。

type traceIDKey string

const traceIDContextKey traceIDKey = "trace_id"

func withTraceID(ctx context.Context, traceID string) context.Context {
    return context.WithValue(ctx, traceIDContextKey, traceID)
}

func getTraceID(ctx context.Context) string {
    traceID, ok := ctx.Value(traceIDContextKey).(string)
    if !ok {
        return ""
    }
    return traceID
}

// HTTPハンドラー
func handleUserRequest(w http.ResponseWriter, r *http.Request) {
    traceID := generateTraceID() // UUID生成など
    ctx := withTraceID(r.Context(), traceID)
    
    user, err := callUserService(ctx)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(user)
}

// 別のサービスを呼び出す際、トレースIDをヘッダーに付与
func callUserService(ctx context.Context) (map[string]string, error) {
    traceID := getTraceID(ctx)
    
    req, err := http.NewRequestWithContext(ctx, "GET", "http://user-service:8080/users/123", nil)
    if err != nil {
        return nil, err
    }
    req.Header.Set("X-Trace-ID", traceID)
    
    client := &http.Client{Timeout: 5 * time.Second}
    resp, err := client.Do(req)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()
    
    var result map[string]string
    if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
        return nil, err
    }
    return result, nil
}

func generateTraceID() string {
    // 実装では uuid.New().String() など
    return fmt.Sprintf("trace_%d", time.Now().UnixNano())
}
// リクエスト追跡が可能:
X-Trace-ID: trace_1234567890

キャンセル信号の組織的な管理

アプリケーション全体のシャットダウン時に、すべてのgoroutineを安全に終了させるパターンです。

type Server struct {
    httpServer *http.Server
    shutdownCh chan os.Signal
}

func NewServer(addr string) *Server {
    return &Server{
        httpServer: &http.Server{Addr: addr},
        shutdownCh: make(chan os.Signal, 1),
    }
}

func (s *Server) Start(ctx context.Context) error {
    signal.Notify(s.shutdownCh, syscall.SIGINT, syscall.SIGTERM)
    
    // HTTPサーバーをgoroutineで起動
    go func() {
        if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatalf("Server error: %v", err)
        }
    }()
    
    // シグナル待機
    <-s.shutdownCh
    log.Println("Shutdown signal received")
    
    // gracefulな終了:30秒以内に全リクエストを完了させる
    shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
    
    if err := s.httpServer.Shutdown(shutdownCtx); err != nil {
        log.Printf("Shutdown error: %v", err)
        return err
    }
    return nil
}

テストでのcontext活用

contextの動作を検証するテストを書く際のパターンも紹介します。

func TestTimeoutBehavior(t *testing.T) {
    ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
    defer cancel()
    
    // タイムアウトするまで待機
    select {
    case <-ctx.Done():
        if ctx.Err() != context.DeadlineExceeded {
            t.Errorf("expected DeadlineExceeded, got %v", ctx.Err())
        }
    case <-time.After(200 * time.Millisecond):
        t.Error("context did not timeout")
    }
}

func TestCancellationPropagation(t *testing.T) {
    parentCtx, parentCancel := context.WithCancel(context.Background())
    childCtx, childCancel := context.WithCancel(parentCtx)
    defer childCancel()
    
    parentCancel() // 親をキャンセル
    
    // 子も自動的にキャンセルされる
    select {
    case <-childCtx.Done():
        // OK
    case <-time.After(100 * time.Millisecond):
        t.Error("child context was not cancelled when parent was cancelled")
    }
}

func TestContextValue(t *testing.T) {
    type userIDKey string
    const key userIDKey = "user_id"
    
    ctx := context.WithValue(context.Background(), key, "user_123")
    
    val, ok := ctx.Value(key).(string)
    if !ok || val != "user_123" {
        t.Errorf("expected user_123, got %v", val)
    }
}

パフォーマンス面での考慮

大規模なシステムでは、contextの生成やキャンセルチェックのオーバーヘッドも無視できません。公式ドキュメントによると、context自体は比較的軽量ですが、以下の点に注意する必要があります。

  • context.WithCancel()や context.WithTimeout()を頻繁に呼び出すと、メモリ割り当てのオーバーヘッドが増加する
  • 非常に深くネストされたcontext(10階層以上)では、ctx.Value()の検索がO(n)になるため、パフォーマンス低下の可能性がある
  • 本番環境では、contextのライフサイクルを意識した適切な粒度での生成が重要

実際のプロジェクトでベンチマークを取ってみると、リクエスト数が1秒あたり10,000を超える場合、contextの取り扱いが全体のパフォーマンスに影響することもあります。

よくある失敗パターンと対策

これまで関わったプロジェクトで見かけた問題をまとめます。

✗ ループ内でcontext.WithTimeout()を繰り返す

ダメな例:

// ❌ 各イテレーションで新しいcontextを生成→メモリリーク+パフォーマンス低下
for _, id := range userIDs {
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    user, _ := fetchUser(ctx, id)
    users = append(users, user)
    cancel()
}

良い例:

// ✅ ループ外でcontextを生成
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

for _, id := range userIDs {
    user, err := fetchUser(ctx, id)
    if err != nil {
        if errors.Is(err, context.DeadlineExceeded) {
            break // ループ全体がタイムアウト
        }
    }
    users = append(users, user)
}

✗ キャンセルチェックを忘れる

長時間実行される処理でctx.Done()をチェックしないと、リソース消費が続きます。

// ❌ キャンセルされても処理が続く
func processLargeDataset(ctx context.Context, data []Item) {
    for _, item := range data {
        processItem(item) // キャンセルを無視
    }
}

// ✅ 定期的にキャンセルをチェック
func processLargeDataset(ctx context.Context, data []Item) error {
    for _, item := range data {
        select {
        case <-ctx.Done():
            return ctx.Err()
        default:
            processItem(item)
        }
    }
    return nil
}

まとめ

  • Goのcontextは「キャンセル」「タイムアウト」「リクエストスコープ値」の3つの責務を持つ設計になっている
  • HTTPハンドラーでは必ずr.Context()を起点とし、アプリケーション固有のタイムアウトをcontext.WithTimeout()で追加する
  • contextに詰める値はリクエストIDやトレースIDなど「リクエストライフサイクル中のみ必要な値」に限定し、プライベート型キーで衝突を防ぐ
  • 複数のgoroutineを起動する場合、親のcontextキャンセルが自動的に子に伝わることを利用し、安全な終了を実現する
  • マイクロサービス環境ではトレースIDをcontextとHTTPヘッダーの両方で管理し、分散トレーシングを実現する
  • 長時間実行される処理では定期的にctx.Done()をチェックし、キャンセル信号への応答性を確保する

よくある質問(FAQ)

contextはゴルーチン間で共有しても大丈夫ですか?

はい、contextは並行セーフ(goroutine-safe)です。複数のgoroutineから同時にアクセスしても問題ありません。むしろ、複数のgoroutineに同じcontextを渡して、統一したキャンセルやタイムアウトを管理するのが標準的な使い方です。ただし、context.WithValue()で値を詰める場合は、新しいcontextを返すため、元のcontextは変更されません。

context.WithValue()で詰めた値をどうやって取り出しますか?

ctx.Value(key)で取り出します。戻り値はinterface{}型なので、型アサーション(.(string)など)で具体的な型に変換する必要があります。推奨パターンでは、getter関数を用意して、型変換と存在チェックを一度に行うようにします。詳しくは、記事の「値の受け渡し」セクションを参照してください。

タイムアウトとdeadlineの違いは何ですか?

context.WithDeadline() は「特定の時刻」を指定し、context.WithTimeout() は「現在からの経過時間」を指定します。内部的には、WithTimeout()がWithDeadline()を使って実装されています。実務では、WithTimeout()の方が使いやすいため、基本的にはタイムアウトで十分です。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次