API向けGoのcontextタイムアウト:HTTPハンドラからSQLまで
API向けのGoのcontextタイムアウトは、HTTPハンドラからSQL呼び出しまでデッドラインを伝播させ、リクエストの滞留を防ぎ、負荷時にもサービスを安定させます。

なぜリクエストが滞留するのか(負荷時に悪化する理由)
リクエストが「滞留する」とは、戻ってこない何かを待っている状態です。遅いデータベースクエリ、プールからの接続のブロック、DNSの一時的障害、あるいは呼び出しを受け付けたまま応答しない上流サービスなどが原因です。
症状は分かりやすく見えます:一部のリクエストがいつまでも終わらず、その後ろに次々と積み重なります。メモリが増え、ゴルーチン数が増え、終わらないコネクションのキューが見られることが多いでしょう。
負荷がかかると、滞留は二重に悪影響を与えます。遅いリクエストがワーカーを占有し、データベース接続やロックなどの貴重なリソースを保持します。そうなると普通は速いリクエストも遅くなり、重なりが増えてさらに待ちが発生します。
リトライやトラフィックの急増はこの悪循環を加速させます。クライアントがタイムアウトして再試行すると、元のリクエストがまだ動いている場合は同じ処理を二重に払うことになります。多くのクライアントが短時間に再試行すると、平均トラフィックが問題なかったとしてもデータベースを過負荷にしたり接続制限に達したりします。
タイムアウトとは単純に「Xより長くは待たない」という約束です。早く失敗してリソースを解放するのに役に立ちますが、処理自体を早く終わらせるわけではありません。
また、タイムアウトが発生した瞬間に作業が即座に止まることを保証するわけでもありません。例えばデータベース側は処理を続けるかもしれませんし、上流サービスがキャンセルを無視するかもしれません。自分のコードがキャンセル時に安全でないこともあります。
タイムアウトが保証するのは、ハンドラが待ちをやめて明確なエラーを返し、保持していたリソースを解放できるということです。その「待ち時間に上限を付ける」ことで、少数の遅い呼び出しが全体の障害につながるのを防げます。
Goのcontextタイムアウトでの目標は、エッジから最深部の呼び出しまで一つの共有デッドラインを持つことです。HTTPの境界で一度設定し、同じコンテキストをサービスコード全体に渡し、database/sqlの呼び出しでも使えば、データベースにもいつ待ちをやめるべきか伝えられます。
Goのcontextを平たく説明すると
context.Contextは現在進行中の作業を説明する小さなオブジェクトです。次のような問いに答えます:"このリクエストはまだ有効か?"、"いつ諦めるべきか?"、"リクエストスコープの値(ログ用のリクエストIDなど)を渡すか?"。
大きな利点は、システムの端で一度決めた方針(ハンドラの判断)が、同じコンテキストを渡す限り下流のすべてのステップを保護できる点です。
contextが運ぶもの
Contextはビジネスデータを置く場所ではありません。キャンセル、デッドライン/タイムアウト、ログ用のリクエストIDのような小さなメタデータといった制御信号のためのものです。
タイムアウトとキャンセルの関係は単純です:タイムアウトはキャンセルの一因です。たとえば2秒のタイムアウトを設定すれば、2秒経つとコンテキストはキャンセルされます。さらに、ユーザーがタブを閉じた、ロードバランサが接続を切った、またはコードが早めに処理を止めるといった理由で早期にキャンセルされることもあります。
Contextは関数呼び出しを通じて明示的なパラメータとして流れます。通常は最初の引数です:func DoThing(ctx context.Context, ...)。各呼び出しで見えるようにするのが目的で、呼び忘れが起きにくくなります。
デッドラインが切れると、それを監視している処理は速やかに止まるべきです。例えば、QueryContextを使ったデータベースクエリはcontext deadline exceededのようなエラーで早めに戻るべきで、ハンドラはワーカーが枯渇するまでハングするのではなくタイムアウトを返せます。
良い頭の中のモデルはこうです:1リクエスト、1コンテキスト、どこでも渡す。リクエストが死んだら、作業も止まるべきです。
HTTPの境界で明確なデッドラインを設定する
エンドツーエンドのタイムアウトを機能させたいなら、時計をどこで始めるかを決めてください。一番安全なのはHTTPのエッジ、つまりハンドラ近辺です。そうすれば下流のすべて(ビジネスロジック、SQL、他サービス)が同じデッドラインを継承します。
このデッドラインは複数の場所で設定できます。サーバー全体のタイムアウトは基礎として有効で、遅いクライアントから保護します。ミドルウェアはルート群全体で一貫させるのに便利です。ハンドラ内で明示的に設定するのも、ローカルで分かりやすい場合は問題ありません。
多くのAPIでは、ミドルウェアかハンドラでのリクエスト単位のタイムアウトが最も分かりやすいです。現実的な値にしてください:ユーザーは遅くて不明瞭なハングよりも、速く明確に失敗する方を好みます。多くのチームは読み取りを短め(1〜2秒)、書き込みはやや長め(3〜10秒)にするなど、エンドポイントの役割で予算を分けます。
シンプルなハンドラパターンの例:
func (s *Server) getReport(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
report, err := s.reports.Generate(ctx, r.URL.Query().Get("id"))
if err != nil {
http.Error(w, err.Error(), http.StatusGatewayTimeout)
return
}
json.NewEncoder(w).Encode(report)
}
このやり方が効果的であるための2つのルール:
- 常に
cancel()を呼んでタイマーやリソースを速やかに解放すること。 - ハンドラ内で
context.Background()やcontext.TODO()に置き換えないこと。チェーンが切れてしまい、クライアントが去った後もDB呼び出しや外向きリクエストが永久に走り続ける可能性があります。
コードベース全体でcontextを伝播させる
HTTPの境界でデッドラインを設定したら、同じデッドラインがブロックし得るすべての層に到達するようにするのが本番の作業です。ポイントは一つの時計をハンドラ、サービスコード、ネットワークやディスクに触れるすべての場所で共有することです。
一つの単純なルールで整合性を保てます:待ちが発生し得る関数はすべてcontext.Contextを受け取り、かつそれを最初の引数にすること。呼び出し箇所で明白になり、習慣化されます。
実用的なシグネチャパターン
サービスやリポジトリではDoThing(ctx context.Context, ...)のようなシグネチャを好んでください。コンテキストを構造体に隠したり、下位層でcontext.Background()で再生成したりするのは避けてください。呼び出し元のデッドラインが見えなくなってしまいます。
func (h *Handler) CreateOrder(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if err := h.svc.CreateOrder(ctx, r.Body); err != nil {
// map context errors to a clear client response elsewhere
http.Error(w, err.Error(), http.StatusRequestTimeout)
return
}
}
func (s *Service) CreateOrder(ctx context.Context, body io.Reader) error {
// parsing or validation can still respect cancellation
select {
case <-ctx.Done():
return ctx.Err()
default:
}
return s.repo.InsertOrder(ctx, /* data */)
}
早期終了をきれいに扱う
ctx.Done()を通常の制御経路として扱ってください。役に立つ習慣は二つあります:
- 高コストな作業を始める前や長いループの後に
ctx.Err()をチェックする。 ctx.Err()をそのまま上位に返し、ハンドラが速やかに応答してリソースの無駄を止められるようにする。
すべての層が同じctxを渡していれば、1つのタイムアウトでパース、ビジネスロジック、DB待ちを一括で切ることができます。
database/sql クエリへのデッドライン適用
HTTPハンドラにデッドラインがあるなら、データベースの処理もそれを聞くようにしてください。database/sqlでは、文脈に対応したメソッドを常に使うことを意味します。Query()やExec()をcontextなしで呼ぶと、APIはクライアントが諦めた後も遅いクエリを待ち続ける可能性があります。
一貫して使うべきもの:db.QueryContext、db.QueryRowContext、db.ExecContext、db.PrepareContext(戻り値のステートメントでQueryContext/ExecContextを使う)。
func (s *Store) GetUser(ctx context.Context, id int64) (*User, error) {
row := s.db.QueryRowContext(ctx,
`SELECT id, email FROM users WHERE id = $1`, id,
)
var u User
if err := row.Scan(&u.ID, &u.Email); err != nil {
return nil, err
}
return &u, nil
}
func (s *Store) UpdateEmail(ctx context.Context, id int64, email string) error {
_, err := s.db.ExecContext(ctx,
`UPDATE users SET email = $1 WHERE id = $2`, email, id,
)
return err
}
見落としやすい点が二つあります。
まず、あなたのSQLドライバがコンテキストのキャンセルを尊重するか確認してください。多くはそうですが、遅いクエリを意図的に実行してデッドライン超過時に素早くキャンセルされるかテストするのが確実です。
次に、データベース側のタイムアウトをバックストップとして検討してください。例えばPostgresはステートメントごとのタイムアウト(statement timeout)を設定できます。アプリのどこかがcontextを渡し忘れた場合でも、DBが保護してくれます。
操作がタイムアウトで止まったときは、通常のSQLエラーとは別に扱ってください。errors.Is(err, context.DeadlineExceeded)やerrors.Is(err, context.Canceled)を確認し、504のような明確なレスポンスを返すなど、"データベースが壊れている"扱いにしないでください。AppMaster のようにGoバックエンドを生成する場合は、これらのエラー経路を区別しておくとログやリトライの理解がしやすくなります。
下流呼び出し:HTTPクライアント、キャッシュ、その他のサービス
ハンドラとSQLクエリがcontextを尊重していても、下流呼び出しがいつまでも待ち続けるとリクエストはハングします。負荷時には数個の滞留ゴルーチンが積み重なり、接続プールを食いつぶして小さな遅延を大きな障害に変えてしまいます。対策は一貫した伝播とハードなバックストップです。
アウトバウンドHTTP
他のAPIを呼ぶときは、同じコンテキストでリクエストを作り、デッドラインとキャンセルを自動的に流してください。
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil { /* handle */ }
resp, err := httpClient.Do(req)
コンテキストだけに頼らないでください。コードが誤ってbackgroundコンテキストを使った場合やDNS/TLS/アイドル接続で停滞した場合に備えて、HTTPクライアントとトランスポートにもタイムアウトを設定してください。http.Client.Timeoutで全体の上限を設け、ダイヤルやTLSハンドシェイク、レスポンスヘッダのタイムアウトも設定し、リクエストごとに新しいクライアントを作らず再利用するのが望ましいです。
キャッシュやキュー
キャッシュ、メッセージブローカー、RPCクライアントには接続取得、返信待ち、満杯のキューでブロック、ロック待ちなど独自の待ちポイントがあります。これらの操作がctxを受け取ることを確認し、ライブラリレベルのタイムアウトがあればそれも使ってください。
実用的なルール:もしユーザーリクエストに残り800msしかないなら、2秒かかる可能性のある下流呼び出しは始めないでください。スキップする、劣化させる、部分レスポンスを返すなどの方針を先に決めておいてください。
タイムアウトの意味を事前に決めておきましょう。速いエラーを返すのが正解なこともあれば、オプションのフィールドについては部分データを返すべきこともあります。キャッシュの古いデータを明示して返すのも一つの手です。
AppMaster のようなツールでGoバックエンド(生成されたものを含む)を作る場合、この違いは「タイムアウトが存在する」ことと「タイムアウトがトラフィックスパイク時に一貫してシステムを守る」ことの差になります。
実践手順:エンドツーエンドのタイムアウトに向けてAPIをリファクタする
タイムアウト対応のためのリファクタは一つの習慣に尽きます:HTTPエッジからすべてのブロッキング可能な呼び出しまで同じcontext.Contextを渡すこと。
実用的な進め方(トップダウン):
- ハンドラとコアサービスメソッドを
ctx context.Contextを受けるように変更する。 - すべてのDB呼び出しを
QueryContextやExecContextに更新する。 - 外部呼び出し(HTTPクライアント、キャッシュ、キュー)でも同様にする。ライブラリが
ctxを受け取らないならラップするか置き換える。 - タイムアウトの所有者(who owns the timeout)を決める。一般的にはハンドラが全体のデッドラインを設定し、下位層は必要な場合にのみ短いデッドラインを作る。
- エッジでのエラーを予測可能にする:
context.DeadlineExceededやcontext.Canceledを明確なHTTPレスポンスにマップする。
各層の形は次のようになります:
func (h *Handler) GetOrder(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
order, err := h.svc.GetOrder(ctx, r.PathValue("id"))
if errors.Is(err, context.DeadlineExceeded) {
http.Error(w, "request timed out", http.StatusGatewayTimeout)
return
}
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
_ = json.NewEncoder(w).Encode(order)
}
func (r *Repo) GetOrder(ctx context.Context, id string) (Order, error) {
row := r.db.QueryRowContext(ctx, `SELECT id,total FROM orders WHERE id=$1`, id)
// scan...
}
タイムアウトの値は退屈なくらい一貫しているべきです。ハンドラが合計で2秒持っているなら、DBクエリは1秒以内に収めてJSONエンコードなどの余裕を残すと良いでしょう。
動作を証明するために、タイムアウトを強制するテストを追加してください。簡単な方法は、ctx.Done()までブロックしてその後ctx.Err()を返すフェイクリポジトリメソッドを作ることです。ハンドラが偽の遅延後ではなく素早く504を返すことをテストで確認してください。
AppMaster のようなジェネレータでGoバックエンドを作る場合もルールは同じ:リクエストごとに一つのコンテキストを通し、デッドラインの所有権を明確にすること。
可観測性:タイムアウトが機能していることを証明する
タイムアウトは、実際に起きていることを見られるようにして初めて役に立ちます。目標は簡単です:すべてのリクエストにデッドラインがあり、失敗したときに時間がどこで使われたか分かること。
まずはログを有用かつ安全にしましょう。リクエストボディを全部吐くのではなく、リクエストID(またはトレースID)、キー時点でデッドラインが設定されているかと残り時間、操作名(ハンドラ、SQLクエリ名、外向き呼び出し名)、結果のカテゴリ(ok、timeout、canceled、other error)などをログに残します。
いくつかのフォーカスしたメトリクスを追加して、負荷時の挙動が明らかになるようにします:
- エンドポイントと依存先ごとのタイムアウト数
- リクエストレイテンシ(p50/p95/p99)
- インフライトリクエスト数
- DBクエリレイテンシ(p95/p99)
- エラー率(種類別)
エラーを扱う際はタグ付けを正確に。context.DeadlineExceededは通常予算を超えたことを意味し、context.Canceledはクライアントが去ったか上流のタイムアウトが先に起きたことを示す場合が多いです。両者を分けておくと対処法が違うので役に立ちます。
トレース:時間のムダを見つける
トレースのスパンはハンドラのコンテキストからdatabase/sqlのQueryContextまで同じコンテキストに従うべきです。例えば、リクエストが2秒でタイムアウトし、トレースがDB接続待ちに1.8秒費やしていることを示したら、クエリ文の問題ではなくプールサイズやトランザクションの長さを疑うべきです。
エンドポイント別のタイムアウトやトップの遅いクエリを示す内部ダッシュボードを作るなら、AppMasterのようなノーコードツールを使うと観測性を別プロジェクトにせずに素早く提供できます。
タイムアウトを無効にするよくある間違い
「まだ時々ハングする」バグの多くは小さなミスから来ます。
- 時計を途中でリセットする。ハンドラが2秒のデッドラインを設定したのに、リポジトリが別の新しいコンテキスト(あるいはタイムアウトなし)を作ると、データベースはクライアントが去った後も走り続けます。受け取った
ctxを渡し、必要なら短くするに留めてください。 - 停止しないゴルーチンを起動する。
context.Background()で作業をスピンアップすると、リクエストがキャンセルされても止まりません。ゴルーチンにリクエストのctxを渡し、selectでctx.Done()を扱ってください。 - 実運用に対して短すぎるデッドライン。50msのタイムアウトはローカルでは動くかもしれませんが、本番の小さなスパイクで失敗し、再試行が増えて自分でミニ障害を作ることになります。通常のレイテンシに余裕分を足して決めてください。
- 本当のエラーを隠す。
context.DeadlineExceededを汎用の500にしてしまうとデバッグやクライアントの挙動が悪化します。"キャンセルされた"と"タイムアウトした"を区別してログに残してください。 - 早期終了時にリソースを開放しない。早く返す際にも
defer rows.Close()を忘れず、context.WithTimeoutのcancelも呼んでください。行や残った作業がリークすると負荷時に接続を枯渇させます。
短い例:レポートクエリをトリガするエンドポイントがあったとします。ユーザーがタブを閉じるとハンドラのctxはキャンセルされます。もしSQL呼び出しが新しいbackgroundコンテキストを使っていたら、クエリはまだ実行を続けて接続を占有します。QueryContextで同じctxを渡せばDB呼び出しが中断され、システムは速やかに回復します。
信頼できるタイムアウトのためのクイックチェックリスト
タイムアウトは一貫して使われて初めて効果を発揮します。1回でも見逃すとゴルーチンが忙しくなり、DB接続を保持し、次のリクエストを遅くします。
- エッジ(通常はHTTPハンドラ)で一つの明確なデッドラインを設定する。リクエスト内部のすべてはそれを継承する。
- サービス層やリポジトリ層に同じ
ctxを渡す。リクエストコード内でcontext.Background()を使わない。 - DBでは常にコンテキスト対応メソッドを使う:
QueryContext、QueryRowContext、ExecContext。 - アウトバウンド呼び出し(HTTPクライアント、キャッシュ、キュー)にも同じ
ctxを付ける。子コンテキストを作るなら元より短くする。 - キャンセルとタイムアウトを一貫して扱う:明確なエラーを返し、作業を止め、キャンセル済みリクエスト内で再試行ループを回さない。
その後、負荷をかけて挙動を検証してください。タイムアウトが発生してもリソースを十分に素早く解放できないと信頼性は損なわれます。
ダッシュボードはタイムアウトを平均値の中に隠さないようにしてください。少なくとも次のシグナルを追跡すると"デッドラインが実際に適用されているか"が分かります:リクエストタイムアウト数とDBタイムアウト数(分けて)、レイテンシのパーセンタイル(p95、p99)、DBプール統計(使用中コネクション、待ち数、待ち時間)、エラー原因の内訳(context deadline exceeded vs その他)。
AppMaster のようなプラットフォームで内部ツールを作る場合も同じチェックリストが当てはまります:境界でデッドラインを定義し、伝播させ、タイムアウトが滞留リクエストを速やかな失敗に変えていることをメトリクスで確認してください。
例と次のステップ
この手法が効く典型的な例は検索エンドポイントです。例えばGET /search?q=printerが、大きなレポートクエリでデータベースが忙しいときに遅くなるとします。デッドラインがないと、着信する各リクエストが長いSQLクエリを待ってしまいます。負荷がかかると滞留リクエストが積み重なり、ワーカーや接続を占有してAPI全体が固まってしまいます。
HTTPハンドラで明確なデッドラインを設定し、同じctxをリポジトリまで渡していれば、予算が尽きた時点で待ちをやめられます。デッドライン到達時にDBドライバが(サポートされていれば)クエリをキャンセルし、ハンドラは返ってきてサーバーは新しいリクエストに応答できるようになります。古いリクエストの後ろで新しいリクエストが待たされることはありません。
ユーザーに見える振る舞いも改善します。30〜120秒もスピンして不明瞭に失敗する代わりに、クライアントは素早く予測可能なエラー(多くは504や503で短いメッセージ"request timed out")を受け取り、新しいリクエストが古いものの後ろで詰まることも減ります。
これを組織全体で定着させるための次のステップ:
- エンドポイント種別ごとに標準的なタイムアウトを決める(検索、書き込み、エクスポートなど)。
- コードレビューで
QueryContextやExecContextを必須にする。 - エッジでタイムアウトエラーを明示的に返す(明確なステータスコード、簡潔なメッセージ)。
- タイムアウトやキャンセルのメトリクスを追加して回帰を早期に検出する。
- ハンドラのコンテキスト作成とログ出力をラップするヘルパーを一つ作り、すべてのハンドラが同じ振る舞いをするようにする。
AppMaster を使ってサービスや内部ツールを構築しているなら、これらのタイムアウトルールを生成されたGoバックエンド、API連携、ダッシュボードに一貫して適用できます。AppMaster は appmaster.io で提供されており(no-codeでGoソースコード生成も可能)、手作業で管理ツールを全て作ることなく一貫したリクエスト処理と観測性を実現するのに便利です。
よくある質問
リクエストが「滞留」するのは、遅いSQLクエリ、プール接続のブロック、DNSの問題、あるいは呼び出しを受け付けたまま応答しない上流サービスなど、戻ってこない何かを待っている状態を指します。負荷が高くなると、滞留したリクエストが積み重なり、ワーカーや接続が枯渇して小さな遅延が大きな障害につながります。
全体のデッドラインはHTTP境界(エッジ)で設定し、そのままブロックし得るすべての層に同じctxを渡してください。その共有されたデッドラインが、いくつかの遅い操作がリソースを長時間占有して他を遅くするのを防ぎます。
ハンドラ内でctx, cancel := context.WithTimeout(r.Context(), d)のように作成したら、必ずdefer cancel()を呼んでください。タイムアウト自体が発火する場合でも、cancel()はタイマーやリソースを早く解放するのに役立ちます。
リクエストコードでcontext.Background()やcontext.TODO()に置き換えると、キャンセルやデッドラインが切れても下流処理が止まらなくなり、タイムアウトが無効になります。これは最も致命的なミスの一つです。
context.DeadlineExceededとcontext.Canceledは通常の制御結果として扱い、上位にそのまま返してください。エッジではこれらを明確なHTTPレスポンス(多くの場合タイムアウトは504)にマッピングし、クライアントがランダムな500で無駄に再試行しないようにします。
すべてのdatabase/sql呼び出しでコンテキスト対応メソッドを使ってください:QueryContext、QueryRowContext、ExecContext、PrepareContext。Query()やExec()を使うと、ハンドラがタイムアウトしてもDB呼び出しがゴルーチンや接続を保持し続ける可能性があります。
多くのドライバはキャンセルを尊重しますが、スタックで確認することが重要です。意図的に遅いクエリを実行して、デッドラインの後に素早く戻るかをテストしてください。アプリ側でcontextが渡されない経路があっても、DB側のstatement timeoutをバックストップとして設定するのが賢明です。
アウトバウンドのリクエストはhttp.NewRequestWithContext(ctx, ...)で作成し、同じデッドラインとキャンセルを流用してください。さらに、コードが誤ってbackgroundコンテキストを使った場合やDNS/TLSで停滞した場合に備えて、http.Client.Timeoutやトランスポートのタイムアウトも設定しておくべきです。
下位層がリクエストの時間枠を延長するような新しいコンテキストを作らないでください。子コンテキストを作る場合は、元の残り時間より短くするのが原則です。残り時間が少ないなら、オプションの呼び出しはスキップするか、部分レスポンスを返すか、早めに失敗させてください。
エンドポイントや依存先ごとにタイムアウトとキャンセルを別々に集計し、レイテンシのパーセンタイルやインフライトリクエストも追跡してください。トレースではハンドラからQueryContextまで同じコンテキストを辿り、どこで時間が使われたかを把握します。


