Goのcontext.Contextの正しい使い方:キャンセル、タイムアウト、および値

Goのcontextは制御フローであり、ストレージではない。

目次

Go の context.Context は、誤用するにはほどほどにシンプルです — そして、そここそが問題の本質です。

ほとんどの Go 開発者は、表面レベルのルールをすぐに習得します。context を第一引数として渡す、ctx.Done() をチェックする、context.WithTimeout を使用する、そして nil を渡さない、ということです。

func DoSomething(ctx context.Context) error {
	// ...
}

これらのルールは有用ですが、容易な部分をカバーしているに過ぎません。本番サービスにおいて、context は単なるパラメータの慣習ではなく、リクエストのライフタイムを管理するコントロールプレーンなのです。

Go context: cancellation propagates through the call chain

Context は、処理がいつ停止すべきか、残り時間がどれほどか、どのキャンセル経路が取られたか、そして API 境界を越えて渡す必要があるリクエストスコープの値が何かを伝達します。適切に使えば、ゴルーチンリークを防ぎ、無駄な作業を避け、期限を伝播させ、サービスのシャットダウンを容易にします。一方で、誤用すれば、隠れた依存関係の袋、偽のグローバル変数、忘れ去られたタイムアウト、リークするタイマー、そして分かりにくいキャンセル挙動につながります。

やや主観的な言い方をすれば、context はキャンセル、期限、リクエストスコープのメタデータのために使用し、依存コンテナとしては使わない、ということです。

context の用途

context パッケージには主に3つの役割があります。キャンセル、期限とタイムアウト、そしてリクエストスコープの値です。これら3つの役割こそが、context の設計意図をすべてカバーしています。

Context は、次のような質問に答えるべきです:

このリクエストはキャンセルされたか?
この操作に残っている時間はどれほどか?
ログに付けるべきリクエスト ID は何か?
このリクエストに関連付けられた認証済みユーザーは誰か?

一方、Context は、次のような質問に答えるべきではありません:

データベース接続はどこにある?
ロガーはどこにある?
設定はどこにある?
どのサービス実装を使うべきか?

これらは依存関係です。関数パラメータを通じて明示的に渡すべきです(クリーンな実装パターンについては Go での依存性注入 を参照してください)。Context はリクエストのライフタイムとリクエストメタデータのためのものであり、アプリケーションの配線(ワイヤリング)のためではありません。

context の基本的な形状

コアインターフェースは小さく設計されています:

type Context interface {
	Deadline() (deadline time.Time, ok bool)
	Done() <-chan struct{}
	Err() error
	Value(key any) any
}

重要な点は以下の通りです:

  • Done() は、context がキャンセルされたか、期限が切れたときにクローズされます。
  • Err() は、context が終了した理由を説明します。
  • Deadline() は、context に期限があるかどうかを教えてくれます。
  • Value() は、リクエストスコープのデータを格納します。

ほとんどのコードは、このインターフェースを実装しません。単に context を受け取り、下層へ渡すだけです。

第一のルール: context を明示的に渡す

リクエストスコープの作業や、キャンセル可能な作業を行う関数では、context を第一引数として渡します。これが Go の標準的な慣習であり、エコシステム内のすべてのライブラリやツールがこれを期待しています。

func GetUser(ctx context.Context, id string) (*User, error) {
	// ...
}

次のような関数にはこれを適用してください:

  • データベースを呼び出す
  • 他のサービスを呼び出す
  • キューを待つ
  • バックグラウンド処理を開始する
  • I/O でブロッキングする
  • タイムアウトを使用する
  • リクエストスコープの値を必要とする
  • キャンセルを必要とする

逆に、context が不要な小さな純粋関数に context を追加しないでください。

このように書くのは問題ありません:

func NormalizeEmail(email string) string {
	return strings.ToLower(strings.TrimSpace(email))
}

すべての関数に context が必要というわけではありません。どこでも context を追加すると、コードがノイジーになります。

struct 内に context を保存しない

struct 内に context を保存することは、Go コードベースにおける最も一般的なミスの一つです。これは明示的に指摘すべきです。次のようなことはしないでください:

type UserService struct {
	ctx context.Context
	db  *sql.DB
}

代わりに、このようにしてください:

type UserService struct {
	db *sql.DB
}

func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
	// ...
}

Context はリクエスト、操作、またはタスクに属するものです。一方、サービス struct は通常、単一のリクエストよりもずっと長く生きます。これらライフタイムを混ぜると、キャンセルのタイミングが不明確になり、どの操作にその context が属しているかを推論するのが難しくなります。

単一操作のライフタイムを本当に表す型に対する稀な例外は存在しますが、それらは十分に希少なので、デフォルトのルールはシンプルであるべきです:

context を渡す。保存しない。

nil context を渡さない

nil を context として決して渡さないでください。

悪い例:

err := svc.DoWork(nil)

既存の context がない場合は context.Background() を使用してください:

err := svc.DoWork(context.Background())

テストでは、可能であればテスト用の context を使用してください:

func TestDoWork(t *testing.T) {
	err := svc.DoWork(t.Context())
	if err != nil {
		t.Fatal(err)
	}
}

nil context は、コードがそのメソッドを呼び出した際にパニックを引き起こす可能性があります。Background context は明示的かつ安全です。

Background、TODO、およびリクエスト context

一般的な開始ポイントは3つあります。

context.Background

親の context が存在しないプログラムの最上位レベルでは context.Background() を使用します。これは、すべての子 context が派生されるためのルート context です。

func main() {
	ctx := context.Background()
	_ = run(ctx)
}

または:

func TestSomething(t *testing.T) {
	ctx := context.Background()
	_ = ctx
}

context.TODO

context を使用する必要があることは分かっているが、まだどれを使うか決まっていない場合は context.TODO() を使用します。

ctx := context.TODO()

これは移行期間中に有用ですが、実在する本物の context があるにもかかわらず、永久に TODO のままにしないでください。

リクエスト context

HTTP サーバーでは、リクエスト context を使用します:

func handler(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	_ = ctx
}

リクエスト context は、クライアント接続が閉じられたとき、リクエストがキャンセルされたとき、またはサーバーがリクエストの処理を完了したときにキャンセルされます。

Web サービスでは、これはアプリケーションコードに渡すべき context が通常です。

context.WithCancel によるキャンセル

明示的に作業を停止したい場合は context.WithCancel を使用します。

ctx, cancel := context.WithCancel(parent)
defer cancel()

返される cancel 関数は、子 context をキャンセルし、それに関連するリソースを解放します。完了時には必ず呼び出すべきです — context が最終的にタイムアウトする場合でも、早めに cancel を呼び出すことで、リソースを必要以上に長く生かし続けるのを避けることができます。

例:

func RunWorker(parent context.Context) error {
	ctx, cancel := context.WithCancel(parent)
	defer cancel()

	done := make(chan error, 1)

	go func() {
		done <- doBackgroundWork(ctx)
	}()

	select {
	case <-ctx.Done():
		return ctx.Err()
	case err := <-done:
		return err
	}
}

パターンはシンプルです:

  • 子 context を派生する。
  • cancel を defer する。
  • 一緒に停止すべき作業に子 context を渡す。
  • ctx.Done() を監視する。

context.WithTimeout によるタイムアウト

操作に最大時間が設定されている場合は context.WithTimeout を使用します。

ctx, cancel := context.WithTimeout(parent, 2*time.Second)
defer cancel()

HTTP クライアントを用いた例:

func FetchUser(ctx context.Context, client *http.Client, url string) (*http.Response, error) {
	ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
	defer cancel()

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return nil, err
	}

	return client.Do(req)
}

これにより、タイムアウトが隠されたグローバル設定ではなく、操作の一部になります。

必ず cancel を呼び出す

WithCancel、WithTimeout、または WithDeadline を呼び出す際は、必ず返された cancel 関数を呼び出してください。これは正当性にとって重要です。

良い例:

ctx, cancel := context.WithTimeout(parent, 5*time.Second)
defer cancel()

悪い例:

ctx, _ := context.WithTimeout(parent, 5*time.Second)

cancel を呼び出さないことにより、タイマーと子 context が必要以上に長生きする可能性があります。

タイマーのチャネル自体も契約の一部です。Go 1.27 以降、time.After と time.NewTimer の背後にあるチャネルは常にバッファなし(unbuffered)となり、以前の asynctimerchan のオプトアウトは 1.23 以前の値ではもうコンパイルできなくなりました。Go 1.27 アップグレードガイド では、ビルドが失敗する他の削除された GODEBUG 値とともに、その設定がリストアップされています。

期限 (Deadline) とタイムアウト (Timeout)

タイムアウトは相対的なものです:

ctx, cancel := context.WithTimeout(parent, 2*time.Second)
defer cancel()

期限は絶対的なものです:

deadline := time.Now().Add(2 * time.Second)
ctx, cancel := context.WithDeadline(parent, deadline)
defer cancel()

ほとんどのアプリケーションコードはタイムアウトを使用します。期限は、複数の操作間で共有すべき固定された終了時間を持つリクエストの場合に有用です。例えば、リクエストに残りが900ミリ秒しかない場合、各下流の呼び出しに新たに1秒のタイムアウトを与えるのではなく、残りの予算を伝播させるべきです。

サービス層間のタイムアウト予算

一般的なミスとして、タイムアウトを盲目的に積み重ねることが挙げられます。

func Handler(w http.ResponseWriter, r *http.Request) {
	ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
	defer cancel()

	_ = service.DoWork(ctx)
}

func (s *Service) DoWork(ctx context.Context) error {
	ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
	defer cancel()

	return s.repo.Query(ctx)
}

これは一見無害に見えますが、実際の予算を隠蔽しています。サービス層は、タイマーを同じ値にリセットするのではなく、呼び出し元の期限を尊重すべきです。

より良いパターンは以下の通りです:

func Handler(w http.ResponseWriter, r *http.Request) {
	ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
	defer cancel()

	if err := service.DoWork(ctx); err != nil {
		// エラーを処理
		return
	}
}

そしてサービス内部では:

func (s *Service) DoWork(ctx context.Context) error {
	return s.repo.Query(ctx)
}

サブ操作がより小さい予算を必要とする場合のみ、子タイムアウトを追加します:

func (s *Service) DoWork(ctx context.Context) error {
	queryCtx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
	defer cancel()

	return s.repo.Query(queryCtx)
}

正しい思考モデルは明快です。リクエスト全体には1つの外側予算があり、特定のサブ操作はその予算から切り出されたより小さい予算を持つことができ、どの層も呼び出し元の意図を超えてリクエストを静かに拡張してはならない、ということです。

ctx.Err() をチェックしてキャンセルとタイムアウトを区別する

context が終了したとき、ctx.Err() はその理由を返します。

通常は次のいずれかです:

context.Canceled
context.DeadlineExceeded

例:

select {
case <-ctx.Done():
	return ctx.Err()
case result := <-resultCh:
	return handle(result)
}

これにより、呼び出し側はキャンセルとタイムアウトを区別できます。そして、その区別は実務上重要です。キャンセルされたリクエストは多くの場合クライアントの切断を意味しますが、期限超過エラーは通常、あなたのサービスが遅すぎたことを意味します。これらは常に同じようにログ記録、リトライ、報告されるべきではありません。

より良いキャンセル理由のために context.Cause を使用する

モダンな Go は、原因(cause)認識のキャンセルもサポートしています。

有用な関数には以下のものがあります:

  • context.WithCancelCause
  • context.WithTimeoutCause
  • context.WithDeadlineCause
  • context.Cause

通常の ctx.Err() は、キャンセルされたか期限超過かという広範な理由を教えてくれます。

context.Cause(ctx) は、より具体的な原因を教えてくれます。

例:

var ErrShutdown = errors.New("server shutting down")

func Run(ctx context.Context) error {
	ctx, cancel := context.WithCancelCause(ctx)
	defer cancel(nil)

	go func() {
		// いくつかのシャットダウンシグナルが到着しました。
		cancel(ErrShutdown)
	}()

	<-ctx.Done()

	return context.Cause(ctx)
}

原因認識のキャンセルは、その理由が呼び出し側、ログ、またはクリーンアップ挙動にとって重要な場合に使用してください。単なる ctx.Err() が十分な場所では避けてください。追加の詳細は、診断が本当にそれを必要とする場合のみ価値があります。

HTTP サーバーの例

通常の HTTP ハンドラーは r.Context() から始めるべきです。Go の HTTP サービスの構造付けについて完全な解説は Go での REST API の構築 を参照してください。Gin、Echo、Fiber はそれぞれ context.Context を独自の request context 型の中にラップしており、スタック間の違いについては 2026 年の Go Web フレームワーク: net/http, chi, Gin, Echo, Fiber 比較 で比較されています。

func GetUserHandler(svc *UserService) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		ctx := r.Context()

		id := r.PathValue("id")

		user, err := svc.GetUser(ctx, id)
		if err != nil {
			writeError(w, err)
			return
		}

		writeJSON(w, http.StatusOK, user)
	}
}

サービスは context を受け取り、伝播させるべきです:

type UserService struct {
	repo *UserRepository
}

func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
	return s.repo.GetUser(ctx, id)
}

リポジトリは、context 対応のデータベースメソッドを使用すべきです:

type UserRepository struct {
	db *sql.DB
}

func (r *UserRepository) GetUser(ctx context.Context, id string) (*User, error) {
	const query = `
		select id, email, name
		from users
		where id = $1
	`

	var user User

	err := r.db.QueryRowContext(ctx, query, id).Scan(
		&user.ID,
		&user.Email,
		&user.Name,
	)
	if err != nil {
		return nil, err
	}

	return &user, nil
}

重要な点はチェーンです — 各層は同じ context を次の層に渡します:

flowchart TD A[HTTP リクエスト context] --> B[ハンドラー] B --> C[サービス] C --> D[リポジトリ] D --> E[データベースクエリ]

途中で context.Background() を作成してチェーンを切断しないでください。

context.Background() のミス: キャンセルチェーンの切断

これは一般的なバグです:

func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
	return s.repo.GetUser(context.Background(), id)
}

これにより、呼び出し元からのすべてのキャンセルおよび期限情報が破棄されます。クライアントが切断しても、データベースクエリは実行を続けます。リクエストがタイムアウトしても、下流の作業はまだ実行中かもしれません。サーバーがシャットダウン中でも、このコードはそれを完全に無視します。ビジネスロジックの中で受け取った context を context.Background() に置き換えることは、ほぼ常に誤りです。

渡された context を使用してください:

func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
	return s.repo.GetUser(ctx, id)
}

context.Background() は、親 context が存在しないエッジ(境界)でのみ使用してください。

HTTP クライアントの例

アウトバウンドの HTTP リクエストでは、リクエストに context を紐付けます。

func CallAPI(ctx context.Context, client *http.Client, endpoint string) (*http.Response, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
	if err != nil {
		return nil, err
	}

	return client.Do(req)
}

次のようにしないでください:

req, err := http.NewRequest(http.MethodGet, endpoint, nil)

これでは、操作の context なしのリクエストが作成されます。

また、http.Client.Timeout のみに依存することも避けてください。安全な制限として有用かもしれませんが、リクエスト context はコールチェーン全体でのより良い伝播を提供します。

一般的なパターンは以下の通りです:

func CallAPI(ctx context.Context, client *http.Client, endpoint string) (*http.Response, error) {
	ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
	defer cancel()

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
	if err != nil {
		return nil, err
	}

	return client.Do(req)
}

これは、下流の API 呼び出しが、より大きなリクエスト内で特定の予算を持つ場合に使用します。

データベースの例

ほとんどの Go データベース API に context 対応のメソッドがあります。Go のデータアクセスライブラリが context をどのように扱うかについて幅広い見方 — GORM, Ent, Bun, sqlc を含む — については、PostgreSQL 向け Go ORM の比較 を参照してください。

それらを使用してください。

良い例:

rows, err := db.QueryContext(ctx, query, args...)

良い例:

err := db.QueryRowContext(ctx, query, id).Scan(&name)

良い例:

result, err := db.ExecContext(ctx, query, args...)

悪い例:

rows, err := db.Query(query, args...)

context 対応の形式により、リクエストがキャンセルされたかタイムアウトしたときにデータベース操作を停止できます。これは、遅いクエリ、負荷のかかったデータベース、そしてレイテンシがユーザー体験に直接影響するユーザー向け API にとって特に重要です。

トランザクションと context

トランザクションには慎重な context 処理が必要です。

トランザクションは通常、操作 context とともに開始されるべきです:

tx, err := db.BeginTx(ctx, nil)
if err != nil {
	return err
}
defer tx.Rollback()

そして、トランザクション操作には同じ context を使用します:

if _, err := tx.ExecContext(ctx, query, args...); err != nil {
	return err
}

if err := tx.Commit(); err != nil {
	return err
}

トランザクション周りのタイムアウトには注意してください。Commit の前に context がキャンセルされた場合、トランザクションはロールバックされる可能性があります。それが望ましい場合もありますが、意図的なものであるべきです。

長いトランザクションに対するより良い回答は、通常、より長いタイムアウトではなく、単位あたりの作業量を減らすためのより短いトランザクションです。

バックグラウンドワーカーと context

バックグラウンドワーカーは、そのライフタイムを表す context を受け取るべきです。

例:

type Worker struct {
	logger *slog.Logger
}

func (w *Worker) Run(ctx context.Context) error {
	ticker := time.NewTicker(10 * time.Second)
	defer ticker.Stop()

	for {
		select {
		case <-ctx.Done():
			return ctx.Err()

		case <-ticker.C:
			if err := w.doOnce(ctx); err != nil {
				w.logger.Error("worker iteration failed", "err", err)
			}
		}
	}
}

このワーカーは、context がキャンセルされたときにクリーンに停止し、そのタイカーは defer ticker.Stop() によって適切にクリーンアップされます。main では、OS シグナルに紐付けられたルート context を作成します:

func main() {
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	worker := &Worker{logger: slog.Default()}

	if err := worker.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
		slog.Error("worker stopped", "err", err)
	}
}

これは context の正しい使い方です。プロセス作業のライフタイムを記述しており、OS がシグナルを送信すると、この context を共有するゴルーチンの木全体が一緒に停止します。

context キャンセルによるゴルーチンリークの防止

ゴルーチンリークは、もはや有用でなくなった後、ゴルーチンが永遠にブロッキングした状態に残ることを指します。

Context はこれを防ぐのに役立ちます。

悪い例:

func StartWorker() {
	go func() {
		for {
			doWork()
			time.Sleep(time.Second)
		}
	}()
}

このゴルーチンには停止経路がありません。

より良い例:

func StartWorker(ctx context.Context) {
	go func() {
		ticker := time.NewTicker(time.Second)
		defer ticker.Stop()

		for {
			select {
			case <-ctx.Done():
				return
			case <-ticker.C:
				doWork()
			}
		}
	}()
}

ループするゴルーチンは、ほぼ常にキャンセル経路を持つべきです。

それは、すべてのゴルーチンが直接 context を受け取るべきという意味ではありませんが、システムにはそれを停止するための明確な方法があるべきです。

context.AfterFunc

context.AfterFunc は、context がキャンセルされた後に関数を実行します。

クリーンアップ、操作のアンブロッキング、またはネイティブに context をサポートしない API 間のブリッジングに有用です。

例:

func waitWithContext(ctx context.Context, ch <-chan struct{}) error {
	stop := context.AfterFunc(ctx, func() {
		// 必要に応じてウェイクアップまたはクリーンアップ。
	})
	defer stop()

	select {
	case <-ctx.Done():
		return ctx.Err()
	case <-ch:
		return nil
	}
}

AfterFunc は慎重に使用してください。キャンセルが発生したときにロジックを開始するため、制御フローを追うのが難しくなる可能性があります。ほとんどのアプリケーションコードでは、ctx.Done() への通常の select の方が明確で、推論しやすいためです。AfterFunc は、すでに context を受け取らない API に context キャンセルを適応させる必要があるときに最も価値があります。

context.WithoutCancel

context.WithoutCancel は、親がキャンセルされた際にキャンセルされない context を作成します。

これは有用ですが、誤用しやすいです。

使用例:

func Handler(audit *AuditLog) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		ctx := r.Context()

		// リクエストを処理...
		_ = ctx

		auditCtx := context.WithoutCancel(ctx)

		go func() {
			ctx, cancel := context.WithTimeout(auditCtx, 2*time.Second)
			defer cancel()

			_ = audit.Write(ctx, "request completed")
		}()
	}
}

アイデアは、監査ログの書き込みが、リクエスト context がキャンセルされた後でも短時間続行する必要がある場合がある、というものです。これは稀で意図的なものでなければなりません — キャンセルを処理することを避けるために WithoutCancel を使用しないでください。子作業が本当に親のキャンセルより長く生き残る必要がある場合にのみ使用し、常に新しいタイムアウトを追加してください。キャンセルを無視するが期限を持たない context は、バックグラウンドのゴルーチンリークを容易に作成しうるためです。

正しく行う Context 値

Context 値は、API 境界を越えるリクエストスコープのデータのためのものです。

良い例:

  • リクエスト ID
  • トレース ID
  • 認証済みユーザー ID
  • テナント ID
  • ロケール
  • セキュリティプリンシパル
  • 相関メタデータ

悪い例:

  • データベース接続
  • 隠れた依存関係としてのロガー
  • 通常の制御フロー用のフィーチャーフラグ
  • 任意の関数パラメータ
  • 設定
  • サービスクライアント

有用なルールは次の通りです。値がリクエストのアイデンティティやオブザーバビリティ context の一部であれば、context に属する可能性があります。コードが仕事を遂行するために必要な依存関係であれば、明示的に渡すべきです。

Context 値に型付きキーを使用する

プレーンな文字列を context キーとして使用しないでください。

悪い例:

ctx = context.WithValue(ctx, "userID", "123")

これは他のパッケージと衝突する可能性があります。

非公開のカスタムキー型を使用してください:

type userIDKey struct{}

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

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

このパターンにより、パッケージ境界での型安全性が得られ、他のパッケージとのキーの衝突を避け、型付きアクセサー関数により context API の表面をクリーンに保つことができます。

Context 値を任意のパラメータに使用しない

これは悪い例です:

ctx = context.WithValue(ctx, "pageSize", 100)
users, err := repo.ListUsers(ctx)

これは関数の契約を隠蔽します。

明示的なパラメータを優先してください:

users, err := repo.ListUsers(ctx, ListUsersOptions{
	PageSize: 100,
})

Context 値は関数引数を置き換えるべきではありません。隠された入力は、コードの理解、テスト、レビューを難しくします — しかも、関数シグネチャを読んでいる人は、そのパラメータがそもそも存在することすら分かりません。

ログ記録と context

Context を使用したログ記録には、2つの一般的なアプローチがあります。ここでの例は Go の log/slog パッケージを使用しています — slog による構造化ログ記録の本番サービスでの詳細については、slog による Go での構造化ログ記録 を参照してください。

アプローチ 1: 値を抽出してログに付ける

func LogRequest(ctx context.Context, logger *slog.Logger, msg string) {
	if requestID, ok := RequestIDFromContext(ctx); ok {
		logger = logger.With("request_id", requestID)
	}

	logger.Info(msg)
}

これにより、ロガーは適切な依存関係として明示的に保たれ、context は API 境界を越える必要がある正当なリクエストスコープの値のみに使用されます。

アプローチ 2: ロガーを context に保存する

一部のコードベースでは、ロガーを context に保存します。

これは便利ですが、デフォルトとしてお勧めしません。context を依存コンテナに変えてしまうためです。

私の好みは以下の通りです:

  • ロガーの依存関係を明示的に渡す。
  • トレース ID とリクエスト ID を context に保存する。
  • そのような値を境界やミドルウェアでログに追加する。

これにより、依存関係が目に見える形で保たれます。

Context とトレース

トレースは context 値の最強のユースケースの一つであり、本当に良いフィットです。OpenTelemetry や同様のシステムは、トレースデータを関数呼び出しやプロセス境界を越えて伝播するために context を使用します。なぜなら、トレースデータはまさに context が運ぶために設計されたリクエストスコープのメタデータであるからです。

典型的なパターンは以下のようになります:

func (s *Service) DoWork(ctx context.Context) error {
	ctx, span := s.tracer.Start(ctx, "Service.DoWork")
	defer span.End()

	return s.repo.Query(ctx)
}

Context はアクティブなトレーススパンを運び、リポジトリはそのスパンから子スパンを作成できます。各層は、トレイサーオブジェクトの明示的な渡し込みなしに、自身のスパンを追加します — context がその仕事を、コールツリー全体で透明に行うのです。

Context とのエラー処理

操作が context キャンセルによって停止したとき、その情報を保持してください。ここでのパターンは、Go エラー処理アーキテクチャ でカバーされるより広範なエラー設計戦略を補完します。

例:

err := svc.DoWork(ctx)
if err != nil {
	if errors.Is(err, context.Canceled) {
		// クライアントがキャンセル、または呼び出し元が作業を停止しました。
		return err
	}

	if errors.Is(err, context.DeadlineExceeded) {
		// タイムアウトです。
		return err
	}

	return err
}

Context エラーを隠すような形で盲目的にラップしないでください。

%w を使用したラッピングは errors.Is を保持するため、呼び出し側はキャンセルやタイムアウトを検出できます。

if err != nil {
	return fmt.Errorf("query user: %w", err)
}

エラー全体を置き換えると、その情報が破棄され、特定の context エラー型をチェックする呼び出し元が壊れます。

if err != nil {
	return errors.New("query user failed")
}

Context エラーを HTTP レスポンスにマッピングする

Context エラーは、異なる HTTP の結果に対応することがよくあります。

例:

func writeError(w http.ResponseWriter, err error) {
	switch {
	case errors.Is(err, context.Canceled):
		// クライアントが離脱した可能性が高いです。
		// いくつかのシステムでは、これらをクライアントクローズドリクエストとしてログに記録します。
		return

	case errors.Is(err, context.DeadlineExceeded):
		http.Error(w, "request timed out", http.StatusGatewayTimeout)
		return

	default:
		http.Error(w, "internal server error", http.StatusInternalServerError)
		return
	}
}

クライアントのキャンセルをアプリケーションの失敗として扱わないでください。ユーザーがブラウザのタブを閉じた場合、それはあなたのサービスの不具合ではなく、それをエラーとしてログに記録するのは、シグナルのないノイズを追加するだけです。

ミドルウェアにおける Context

HTTP ミドルウェアは、リクエストスコープの値を追加する一般的な場所です。

リクエスト ID ミドルウェアの例:

type requestIDKey struct{}

func WithRequestID(ctx context.Context, requestID string) context.Context {
	return context.WithValue(ctx, requestIDKey{}, requestID)
}

func RequestIDFromContext(ctx context.Context) (string, bool) {
	requestID, ok := ctx.Value(requestIDKey{}).(string)
	return requestID, ok
}

func RequestIDMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		requestID := r.Header.Get("X-Request-ID")
		if requestID == "" {
			requestID = newRequestID()
		}

		ctx := WithRequestID(r.Context(), requestID)

		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

これは context の良い使い方です。リクエスト ID はリクエストに属し、完全なコールチェーンを通って移動すべきであり、各層でログやトレースにそれを付けることは、context 値がサポートするように設計された横断的なオブザーバビリティ懸念のまさにそのものです。

テストにおける Context

テストでは、盲目的に context.Background() を使用しないでください。

作業がテストのライフタイムに属する場合は t.Context() を優先してください:

func TestService(t *testing.T) {
	ctx := t.Context()

	err := service.DoWork(ctx)
	if err != nil {
		t.Fatal(err)
	}
}

タイムアウトの挙動については、タイムアウトが小さく意味のある場合のみ、実際のタイムアウトでテストしてください。

並行性や時間依存性のコードについては、testing/synctest の使用を検討してください — synctest を用いた並行性のある Go コードのテスト はこのツールを深くカバーしています:

func TestTimeout(t *testing.T) {
	synctest.Test(t, func(t *testing.T) {
		ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second)
		defer cancel()

		time.Sleep(30 * time.Second)

		if !errors.Is(ctx.Err(), context.DeadlineExceeded) {
			t.Fatalf("got %v, want deadline exceeded", ctx.Err())
		}
	})
}

これにより、実際の時間进行を待たずに実際のタイムアウト値をテストできます。

Context と errgroup

一緒にキャンセルされるべきゴルーチンのグループに対して、errgroup はしばしば良いフィットです。

errgroup はキャンセルを調整しますが、それ自体では何ものにも上限を設定しません。制限なしに、1000件のループは1000個のゴルーチンを開始します。容量側 — SetLimit、セマフォチャネル、ワーカープール、および Wait が生き残るリークの形状 — については、Go における並行性の制限 を参照してください。

例:

func FetchAll(ctx context.Context, ids []string, client *Client) error {
	g, ctx := errgroup.WithContext(ctx)

	for _, id := range ids {
		id := id

		g.Go(func() error {
			_, err := client.Fetch(ctx, id)
			return err
		})
	}

	return g.Wait()
}

1つのゴルーチンがエラーを返すと、グループ context はキャンセルされ、ctx.Done() を尊重する他のゴルーチンは早めに停止できます。これは、複数のゴルーチン、チャネル、キャンセル経路を手動で管理するよりはるかにクリーンです。ここでの重要な表現は「context を尊重する」ことです — errgroup は ctx.Done() を無視する作業を停止させることはできません。

グレースフルシャットダウン

Context はグレースフルシャットダウンの中心です。

典型的なサーバーセットアップには、以下のものがあります:

  • OS シグナルによってキャンセルされるルート context
  • HTTP サーバー
  • バックグラウンドワーカー
  • シャットダウンタイムアウト
  • クリーンアップロジック

例:

func main() {
	root, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	server := &http.Server{
		Addr:    ":8080",
		Handler: routes(),
	}

	go func() {
		<-root.Done()

		shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
		defer cancel()

		if err := server.Shutdown(shutdownCtx); err != nil {
			slog.Error("server shutdown failed", "err", err)
		}
	}()

	if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
		slog.Error("server failed", "err", err)
		os.Exit(1)
	}
}

シャットダウン context はルート context と同じではないことに注意してください — OS シグナルが届いた時点で、ルートはすでにキャンセルされています。別のタイムアウト context により、強制終了する前に進行中のリクエストをドレインするための限界のある時間がシャットダウンプロセスに与えられます。これがグレースフルシャットダウンが実際に機能する、微妙ながらも重要な違いです。

一般的なアンチパターン

アンチパターン 1: context を依存コンテナとして使用する

悪い例:

ctx = context.WithValue(ctx, "db", db)
ctx = context.WithValue(ctx, "logger", logger)
ctx = context.WithValue(ctx, "config", cfg)

依存関係は明示的に渡してください。

アンチパターン 2: ビジネスロジック内で context.Background を作成する

悪い例:

func (s *Service) DoWork(ctx context.Context) error {
	return s.repo.Save(context.Background())
}

これにより、キャンセルの伝播が切断されます。

アンチパターン 3: cancel を忘れる

悪い例:

ctx, _ := context.WithTimeout(parent, time.Second)

良い例:

ctx, cancel := context.WithTimeout(parent, time.Second)
defer cancel()

アンチパターン 4: 任意のパラメータを context に入れる

悪い例:

ctx = context.WithValue(ctx, "includeDeleted", true)

明示的なオプション struct を使用してください。

アンチパターン 5: 純粋なコードに context を深くすぎるレベルで渡す

悪い例:

func Add(ctx context.Context, a, b int) int {
	return a + b
}

純粋な計算は、長時間実行またはキャンセル可能でない限り context を必要としません。

アンチパターン 6: ループ内でキャンセルを無視する

悪い例:

for item := range items {
	process(item)
}

より良い例:

for item := range items {
	select {
	case <-ctx.Done():
		return ctx.Err()
	default:
	}

	if err := process(ctx, item); err != nil {
		return err
	}
}

アンチパターン 7: Context エラーを飲み込む

悪い例:

if err != nil {
	return errors.New("operation failed")
}

良い例:

if err != nil {
	return fmt.Errorf("operation failed: %w", err)
}

キャンセルと期限エラーを保持してください。

実用的な Context チェックリスト

このチェックリストを Go バックエンドコードに使用してください。

関数シグネチャ

  • Context は第一引数です。
  • Context は長寿命の struct に保存されていません。
  • 必要がない限り、Context は純粋なヘルパー関数に渡されていません。
  • nil context は決して使用されていません。

キャンセル

  • 長時間実行のループは ctx.Done() をチェックします。
  • ゴルーチンには停止経路があります。
  • ワーカーのライフタイムは親 context に紐付けられています。
  • Context キャンセルは下流の呼び出しに伝播されています。

タイムアウト

  • 外側のリクエストタイムアウトは境界で設定されます。
  • サブ操作のタイムアウトは外側予算よりも小さいです。
  • Cancel 関数は常に呼び出されています。
  • タイムアウトはすべての層で盲目的に積み重ねられていません。

値

  • Context 値はリクエストスコープです。
  • キーはプレーンな文字列ではなくカスタム型を使用しています。
  • 依存関係は context に保存されていません。
  • 任意のパラメータは context に保存されていません。

エラー

  • context.Canceled と context.DeadlineExceeded は保持されています。
  • Context エラーは API 境界で正しくマッピングされています。
  • 原因認識のキャンセルは、その理由が重要な場合にのみ使用されています。

テスト

  • テストは適切な場所で t.Context() を使用しています。
  • タイムアウトテストは遅い実際のスリープを避けています。
  • 並行タイムアウト挙動は、有用な場合に testing/synctest でテストされています。
  • ゴルーチンリークは、停止経路が存在することを確認することでチェックされています。

Go コードベースにおける context 使用の監査方法

次のパターンを検索してください:

grep -R "context.Background()" .
grep -R "context.TODO()" .
grep -R "WithTimeout" .
grep -R "WithCancel" .
grep -R "WithValue" .
grep -R "type .* struct" .

そして、質問してください:

  • context.Background() は最上位境界でのみ使用されているか?
  • Cancel 関数は常に呼び出されているか?
  • タイムアウトは妥当な境界に配置されているか?
  • Context 値は本当にリクエストスコープか?
  • 依存関係は context 値に隠されていないか?
  • ゴルーチンは停止可能か?
  • Context エラーは保持されているか?

これは良いコードレビューの習慣です。なぜなら、多くの context バグは構文バグではなく、キャンセル、負荷、またはシャットダウン条件下でのみ表面化するライフタイムバグだからです。

私の主観的なルール

これらのルールは退屈ですが、機能します。

ルール 1: Context は制御フローである

Context は、キャンセル、期限、リクエストメタデータを制御するために使用します。

依存関係を密輸するために使用しないでください。

ルール 2: 呼び出し側が予算を所有する

関数は通常、受け取った context を尊重すべきです。

サブ操作が特定の小さな予算を必要とする場合のみ、より短い子タイムアウトを作成します。

ルール 3: Background はエッジに属する

context.Background() は main、テスト、最上位セットアップで使用します。

サービスおよびリポジトリメソッド内で、キャンセルを逃れるために使用しないでください。

ルール 4: 値は退屈であるべき

リクエスト ID、トレース ID、ユーザー ID、テナント ID は context に属します。データベース接続、ロガー、設定 struct、サービスクライアントは属しません — それらは依存関係であり、明示的に渡すべきです。

ルール 5: すべてのゴルーチンにはライフタイムが必要

ゴルーチンが開始された場合、それがどのように停止するかを正確に知るべきです。Context はしばしば正解であり、context ではない場合でも、チャネル、同期プリミティブ、または明示的なシグナルのような、他の明確なメカニズムがあるべきです。

最終的な考察

context.Context が複雑なのは、API が大きいためではなく — API は小さいのです。複雑なのは、ライフタイムを表しているためであり、ライフタイムはアーキテクチャだからです。context がどこを流れ、どこで派生し、どこで止まるかについてのすべての決定は、あなたのサービスが失敗、負荷、シャットダウンをどのように処理するかについての決定です。

適切に使われた context は、Go サービスをキャンセルしやすく、シャットダウンしやすく、観察しやすくし、ゴルーチンリークの発生を低くします。誤用された context は、依存関係を隠し、期限を破棄し、圧力下でのコードの推論を難しくします。

実用的な要点はシンプルです:

Context を下層に渡す。
保存しない。
明示的なパラメータを値に置き換えない。
キャンセルを尊重する。
境界でタイムアウトを使用する。
常に cancel を呼び出す。

これが、正しく行われた Go の context です。

この記事は 本番環境のためのアプリアーキテクチャ クラスタの一部であり、本番 Go および Python システム向けのコード構造、データアクセス、統合パターン、テストアーキテクチャをカバーしています。

出典

購読する

システム、インフラ、AIエンジニアリングの新記事をお届けします。