エラーメッセージ
本番環境のログに謎めいたメッセージが急増したため、ここを訪れたのではないでしょうか。Goにおいて、このエラーはランタイムが「X時間待機するように指示されたが、処理にX+1時間かかった」と伝えている状態です。通常、ログには以下のように表示されます。
Get "https://api.payments.com/v1/charge": context deadline exceeded
または、負荷の高いマイグレーション中にデータベースクエリがハングした場合:
panic: context deadline exceeded
根本原因:なぜこれが発生するのか?
context deadline exceeded エラーは、タスクが完了する前に context.Context が期限(デッドライン)に達したときに発生します。Goは、アップストリームサービスが停滞した際に「ゾンビ」プロセスがCPUやメモリを消費し続けるのを防ぐために Context を使用します。
これは保護用のキルスイッチと考えてください。一般的な原因には以下が含まれます。
- 外部APIのレイテンシ: サードパーティサービスの状態が悪く、p99のレスポンスタイムが200msから10秒に伸びている。
- インデックスのないDBクエリ: 500万行のテーブルに対する
SELECT文がフルテーブルスキャンを実行している。 - 短すぎるタイムアウト設定: 通常150ms必要な複雑なTLSハンドシェイクに対して、50msのタイムアウトを設定している。
- コールドスタート: 呼び出し側が1秒しか待機しないのに対し、サーバーレス関数(AWS Lambdaなど)の起動に3秒かかっている。
解決策1:現実的なHTTPクライアントのタイムアウト設定
標準ライブラリのデフォルト設定は危険な場合が多いです。例えば、http.DefaultClient にはタイムアウトがありません。リモートサーバーが接続を受け入れたもののデータを送信してこない場合、ゴルーチンは永遠にハングし、最終的にメモリリークを引き起こします。
リスク:厳しすぎる設定
// 公開インターネット経由のラウンドトリップに10msで十分なことは滅多にありません
ctx, cancel := context.WithTimeout(context.Background(), 10 * time.Millisecond)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, "GET", "https://api.github.com", nil)
res, err := http.DefaultClient.Do(req) // ほとんどの環境で確実に失敗します
堅牢なアプローチ
グローバルなガードレールとしてカスタムクライアントを定義し、リクエストごとの詳細な制御にはコンテキストを使用します。
client := &http.Client{
Timeout: time.Second * 30, // 絶対的な上限
}
// この特定のリクエストに完了まで5秒の猶予を与えます
ctx, cancel := context.WithTimeout(context.Background(), 5 * time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", "https://api.example.com/data", nil)
if err != nil {
log.Fatal(err)
}
resp, err := client.Do(req)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
fmt.Println("The upstream API failed to respond within 5 seconds.")
}
return
}
解決策2:データベースクエリ実行時間の最適化
pgx や database/sql などのデータベースドライバはコンテキストを尊重します。データベースレイヤーでこのエラーが発生する場合、クエリがロックの競合を起こしているか、処理するデータ量が多すぎる可能性があります。
SQLコンテキストの例
ctx, cancel := context.WithTimeout(context.Background(), 3 * time.Second)
defer cancel()
// QueryContextを使用することで、ロックされた行を永遠に待ち続けるのを防ぎます
query := "SELECT email FROM users WHERE last_login < $1"
rows, err := db.QueryContext(ctx, query, "2023-01-01")
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
log.Println("Query aborted: Execution exceeded 3-second limit. Check indexes on 'last_login'.")
}
return err
}
プロのヒント: Webハンドラーでは r.Context() を使用してください。ユーザーがブラウザをリフレッシュすると、コンテキストは自動的にキャンセルされます。これにより、ユーザーが二度と見ることのない結果のために、データベースがリソースを浪費するのを防げます。
解決策3:環境の検証
コードが正しく見えるのにエラーが解消されない場合、ボトルネックは外部にあります。以下の手順で遅延の原因を特定してください。
- 制限を増やす: 一時的にタイムアウトを60秒に伸ばします。タスクが45秒で完了するなら、コードは問題なく、単にダウンストリームサービスが遅いだけです。
- レイテンシを測定する:
curl -o /dev/null -s -w "Connect: %{time_connect} TTFB: %{time_starttransfer} Total: %{time_total}\n" https://api.urlを実行します。 - SQLを分析する: 失敗しているクエリに対して
EXPLAIN ANALYZEを実行し、不足しているインデックスやシーケンシャルスキャンを探します。
検証手順
- レイテンシをシミュレートする:
Toxiproxyなどのツールを使用してローカル環境に5秒の遅延を加え、エラーハンドリングが正しく動作するか確認します。 - エラーラッピングの確認: 文字列マッチングではなく、常に
errors.Is(err, context.DeadlineExceeded)を使用してください。 - ログの監査: どの URLやクエリがタイムアウトしたかログに記録されていることを確認します。文脈のない汎用的な「deadline exceeded」メッセージは役に立ちません。
ベストプラクティス
-
ハードコーディングを避ける: 期間は設定ファイル(例:
API_TIMEOUT=5s)に保存し、再デプロイなしでパフォーマンスを調整できるようにします。 -
ミドルウェアによるガードレール: GinやEchoのタイムアウトミドルウェアを使用して、すべての受信リクエストにグローバルな10秒の制限を設定します。これにより、単一の遅いエンドポイントがサービス全体をダウンさせるのを防げます。
// 例:すべてのルートに対してグローバルな5秒の制限を設定する
router.Use(timeout.New(
timeout.WithTimeout(5 * time.Second),
timeout.WithHandler(func(c *gin.Context) {
c.Next()
}),
))

