はじめに:「とりあえず30秒」が本番を沈没させる日

Web APIやマイクロサービスを実装するとき、あなたはクライアントやサーバーのタイムアウト値をどのように決めているでしょうか?

「フレームワークのデフォルト(無制限または60秒)のまま」「なんとなくキリが良いから30秒」「長めにしておけば一時的な遅延でもエラーにならないから安心」……もしそんな理由で設定しているなら、あなたのシステムは本番環境で一度負荷の波を被った瞬間に、自爆して二度と起き上がれなくなる地雷を抱えています。

タイムアウトの本質は「遅いリクエストを待つこと」ではない。「システム全体の生存のために、終わらない不要な処理を情け容赦なく殺し、貴重な計算資源を即座に回収すること」である。

高負荷時や外部APIの不調時にシステムが死ぬ直接の引き金は、ネットワーク遅延そのものではありません。遅延によって発生した「もはや誰も答えを待っていない無駄なリクエスト」がサーバーやDBに居座り続け、後続の正常なリクエストのリソースまで食い尽くすゾンビ処理の雪だるま式増殖こそが、本番障害の真因なのです。

恐怖のメカニズム①:上流<下流の逆転が生む「ゾンビ処理の増幅」

最も典型的な本番自爆パターンが、「上流(クライアント側)のタイムアウトが、下流(サーバー・DB側)のタイムアウトよりも短い」という逆転現象です。

現場でよく見かける「死のタイムアウト設定」

例えば、次のような多層アーキテクチャを考えてみましょう。

一見すると、「下流に行くほど余裕を持たせている」ように見えて安心するかもしれません。しかし、これこそが最悪の破滅構成です。

1つの遅延がシステムを焼き尽くすまでのプロセス

ここで、DBの負荷が一時的に高まり、あるクエリの実行に 5秒 かかったとします。何が起きるでしょうか?

  1. 0.0秒: モバイルアプリが検索リクエスト [Req-1] を送信。
  2. 3.0秒: モバイルアプリ側でタイムアウト(3秒)が発火。アプリは接続を切断(TCP FIN/RST)し、ユーザーにはエラーを表示するか、ライブラリが自動リトライ [Req-2] を送信する。
  3. 3.1秒: ここが致命的です。API GatewayもBackendも、Req-1のクライアントが既に諦めて立ち去ったことを知らないまま、Req-1の重いDBクエリを実行し続けている。
  4. 5.0秒: DBがReq-1のクエリを完了。しかし、結果を返そうとした時にはクライアントはとうに存在しない。CPUサイクル、メモリ、DBコネクションを丸ごとドブに捨てたことになる。
  5. 連鎖爆発(Amplification): モバイルアプリから送られた [Req-2] や [Req-3] も同様に3秒で切断され、背後ではReq-1、Req-2、Req-3のゾンビ処理が並行してDBとCPUを窒息させていく。

通常時のスループットが100 req/secだった場合、わずか数秒で数百〜数千のゾンビプロセスがキューに滞留します。新規の正常リクエストはコネクションプールの空き待ち(Connection Wait Timeout)で即座にタイムアウトし、システム全体が完全停止に至ります。

恐怖のメカニズム②:クライアント切断を無視する「切断検知の欠落」

「でも、クライアントがTCPコネクションを切断したら、サーバー側も自動的に処理を止めてくれるのではないか?」

そう誤解している開発者が驚くほど多いのが現実です。現実のOSおよびWebサーバーの実装では、アプリケーションコードが明示的に接続の切断シグナルをハンドリングしない限り、背後の処理は最後まで完走します。

Go言語における最悪のアンチパターン

Goの net/http サーバーは、クライアントが接続を切断すると http.Request.Context() をキャンセル(Done() チャネルをクローズ)してくれます。しかし、現場のコードではこのコンテキストが平然と握りつぶされています。

// ❌ 最悪のアンチパターン:クライアント切断を検知できずゾンビ化するコード
func handleSearch(w http.ResponseWriter, r *http.Request) {
    // 致命的ミス①: r.Context() を捨てて context.Background() で独立させてしまう
    ctx := context.Background()

    // 致命的ミス②: QueryContextではなくQueryを使い、DBへのキャンセル伝播を断ち切る
    // クライアントが1秒でタブを閉じても、この重いSQLはDB上で最後まで走り続ける!
    rows, err := db.Query("SELECT id, title, content FROM heavy_articles WHERE status = 1")
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    defer rows.Close()

    // ... レスポンスのJSONエンコード処理(誰も受け取らないのにCPUを使って実行される)
}

上記の実装では、クライアントがブラウザを閉じようが、モバイル回線が切れてタイムアウトしようが、GoのGoroutineはDBにクエリを投げ続け、巨大なデータをメモリに展開し、JSON文字列を生成します。そして最後の w.Write() で初めて broken pipe や connection reset by peer のエラーを吐いて終了します。貴重な本番リソースの完全な浪費です。

正しい実装:リクエストコンテキストの徹底伝播

クライアントの切断を検知し、即座に下流のDBや外部API呼び出しをキルするには、r.Context() を末端のI/O処理まで確実にバケツリレーしなければなりません。

// ⭕️ 正しい実装:クライアントの切断・タイムアウトをDBまで即座に伝播させる
func handleSearch(w http.ResponseWriter, r *http.Request) {
    // r.Context() には、クライアント切断時のキャンセルシグナルが組み込まれている
    ctx := r.Context()

    // サーバー側の安全防壁としてハンドラー最大実行制限(例: 3秒)を合成
    ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
    defer cancel()

    // database/sql に ctx を渡すことで、クライアント切断時に
    // 即座にDB側へキャンセルパケット(pg_cancel_backend等)が送信される
    rows, err := db.QueryContext(ctx, "SELECT id, title, content FROM heavy_articles WHERE status = 1")
    if err != nil {
        if errors.Is(ctx.Err(), context.Canceled) {
            // クライアントが自ら切断した(HTTP 499: Client Closed Request 相当)
            log.Printf("[INFO] クライアントが切断したため処理を中断しました: %v", r.RemoteAddr)
            return
        }
        if errors.Is(ctx.Err(), context.DeadlineExceeded) {
            // サーバー側の制限時間切れ(HTTP 504 または 500)
            http.Error(w, "Gateway Timeout", http.StatusGatewayTimeout)
            return
        }
        http.Error(w, "Internal Server Error", http.StatusInternalServerError)
        return
    }
    defer rows.Close()

    // 処理の合間にも定期的にキャンセルの有無をチェックする
    if err := ctx.Err(); err != nil {
        return
    }

    // レスポンス出力
    renderJSON(w, rows)
}

Goの database/sql ドライバ(PostgreSQLの pgx や MySQLドライバ)は、QueryContext に渡されたコンテキストがキャンセルされると、内部で別コネクションを開いてDBエンジンに対して実行中クエリの中断命令を発行します。これにより、DBサーバー側のCPUと共有バッファも即座に解放されるのです。

分散システムの真実:「Deadline Propagation(締切伝播)」の設計

マイクロサービスやBFFを挟んだ多段通信において、「単に各サービスで独立して timeout: 5s を設定する」だけではシステムを守ることはできません。

「相対時間」の罠:残余時間の消失

サービスAがサービスBを呼び、サービスBがサービスCを呼ぶ構成を考えます。

このとき、サービスAのHTTPクライアントが「固定タイムアウト 3.0秒」でサービスBを呼び出したらどうなるでしょうか?

0.2秒後にクライアント(ユーザー)は画面を閉じて立ち去るにもかかわらず、サービスBは「自分には3秒の猶予がある」と勘違いして重い処理を開始します。既に間に合わないことが数学的に確定している無駄撃ちリクエストが下流に垂れ流されるのです。

絶対時刻(Deadline)をヘッダーで引き継ぐ

この問題を根本から解決するのが、Googleの分散RPC基盤(Stubby/gRPC)などで古くから標準採用されているDeadline Propagation(締切伝播)です。

ネットワーク越しの通信では、「あと何秒待てるか(相対時間)」ではなく、「この時刻までに完了しなければ全破棄せよ(絶対時刻 / 残余タイムアウト)」をHTTPヘッダーに載せて下流へリレーします。

// GoによるHTTPヘッダーへのDeadline注入(上流クライアント側)
func callDownstream(ctx context.Context, client *http.Client, req *http.Request) (*http.Response, error) {
    if deadline, ok := ctx.Deadline(); ok {
        remaining := time.Until(deadline)
        if remaining <= 0 {
            // 呼び出す前に既に締切が過ぎているため、I/Oを発生させずに即座に中断
            return nil, context.DeadlineExceeded
        }

        // 標準的なRequest-Timeoutヘッダーまたはミリ秒精度のX-Request-Timeoutを付与
        req.Header.Set("X-Request-Timeout-Ms", strconv.FormatInt(remaining.Milliseconds(), 10))
    }

    return client.Do(req.WithContext(ctx))
}

受け取った下流サービス側では、ミドルウェアでヘッダーを読み取り、自コンテキストの締め切りとしてセットします。

// 下流サービス側のDeadline抽出ミドルウェア
func DeadlinePropagationMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        timeoutHeader := r.Header.Get("X-Request-Timeout-Ms")
        if timeoutHeader != "" {
            if ms, err := strconv.ParseInt(timeoutHeader, 10, 64); err == nil {
                // 上流から渡された残り時間(ミリ秒)
                upstreamRemaining := time.Duration(ms) * time.Millisecond

                // 自サービスのネットワーク・処理安全マージン(例: 50ms)を差し引く
                budget := upstreamRemaining - 50*time.Millisecond
                if budget <= 0 {
                    // もはや処理を完走させる時間的予算(Budget)が残っていない
                    w.WriteHeader(http.StatusGatewayTimeout)
                    w.Write([]byte("Deadline Exceeded before processing"))
                    return
                }

                ctx, cancel := context.WithTimeout(r.Context(), budget)
                defer cancel()
                r = r.WithContext(ctx)
            }
        }
        next.ServeHTTP(w, r)
    })
}

この仕組みがあれば、上流で時間が溶けてしまった無駄なリクエストは、下流の門番ミドルウェアで 1ミリ秒の無駄な計算もさせずに即座に遮断(Fail Fast) されます。これが大規模分散システムをカスケードダウンから守る鉄則です。

本番で絶対に死なないためのタイムアウト設計 4つの鉄則

タイムアウトを正しく運用するために、インフラとアプリケーションの両面で遵守すべき4つの黄金律を整理します。

1. 「下流 ≦ 上流」のカスケード減衰を徹底せよ

タイムアウト値の大小関係は、必ず「外側(クライアント)ほど大きく、内側(DB・外部API)ほど小さく」設計しなければなりません。

下流がタイムアウトで失敗した場合、上流は「代替レスポンス(フォールバック)を返す」か「フォールバックキャッシュを使う」か「綺麗にエラー画面を整形する」ための時間的猶予(マージン)を持てます。下流が上流より長い設定は、絶対に許容してはなりません。

2. p99レイテンシに基づく数学的決定

タイムアウト値は「開発者の勘」で決めてはいけません。監視ダッシュボード(Datadog, Prometheus, CloudWatch等)から得られる実測レイテンシに基づいて数理的に決定します。

基本方針:正常時の p99(99パーセンタイル)レイテンシ × 2.5 〜 3.0 + ネットワークRTTマージン

もし通常時のp99が200msであるなら、タイムアウトを30秒に設定するのは狂気の沙汰です。500ms〜800ms程度で十分であり、2秒を超えるものは「既に異常事態」として切り捨てなければなりません。タイムアウトを甘くすることは、障害時に死ぬまでの猶予を長引かせているだけに過ぎないのです。

3. 接続・読み取り・書き込みタイムアウトを分離せよ

http.Client{Timeout: 10 * time.Second} のような「全体丸ごと1本のタイムアウト」だけに頼ってはいけません。障害のフェーズによって必要なタイムアウトは全く異なります。

4. DB側の `statement_timeout` による物理的最終防壁

アプリケーションサーバーのプロセスが万が一ハングアップしたり、バグによってコンテキストが正しくキャンセルされなかった場合に備え、データベース側にも必ずハードリミットを設けておきます。

-- PostgreSQL: ユーザー単位またはセッション単位で最大クエリ実行時間を3秒に制限
ALTER ROLE app_user SET statement_timeout = '3000ms';

-- MySQL: インタラクティブでない読み取りクエリの上限を設定(MySQL 5.7+)
SET GLOBAL max_execution_time = 3000;

どんなにひどいフルテーブルスキャンクエリが投げ込まれようとも、DBエンジン自身が3秒で強制中断してコネクションを解放する。この「多層防御(Defense in Depth)」があって初めて、夜中に枕を高くして眠ることができるのです。

まとめ:タイムアウトはリソース防衛のキルスイッチである

タイムアウトの設計は、普段の平穏な開発では目立ちません。テスト環境では常にレスポンスが数ミリ秒で返ってくるため、タイムアウトが30秒だろうが無制限だろうが、何の問題もなくパスしてしまうからです。

しかし、ネットワークが揺らぎ、DBがディスクI/Oの悲鳴を上げ、トラフィックが急増する「本番の修羅場」において、システムが生還できるか全滅するかを分けるのは、華やかなアーキテクチャパターンではなく、こうした地味で冷徹なタイムアウト制御の積み重ねです。

  1. 上流より下流を短くし、ゾンビ処理の増殖を遮断すること。
  2. コンテキストを握りつぶさず、クライアントの切断シグナルをDBの物理クエリまで伝播させること。
  3. 多段構成ではDeadline Propagationを導入し、時間予算が尽きたリクエストを即座に破棄すること。
  4. DBの物理タイムアウトを最終安全弁として仕掛けておくこと。

「とりあえず30秒」と書かれたコードを見つけたら、即座にPRで指摘しましょう。その1行の修正が、数ヶ月後の本番大規模障害からあなたのシステムとチームを救うことになるはずです。