アーキテクチャ●●○○○

タイムアウト

Timeout ・ タイムアウト

応答を待つ時間に上限を設ける仕組み。無限に待たないことが連鎖障害を防ぐ第一歩。

✦ 30秒で試す — 航海返事をくれない相手を待つだけで、サーバは倒れる待つ上限を30秒から1秒にすると、待ちで埋まるスレッドは?予想してみる

概要

タイムアウトは、外部への呼び出しや処理の完了を「いつまで待つか」に上限を設ける仕組みです。データベースへの問い合わせ、他サービスへの HTTP リクエスト、ファイルの読み込み — 相手のある処理は、いつでも「返ってこない」可能性を抱えています。タイムアウトはその待ちを一定時間で打ち切り、「遅い」を「失敗」という扱える形に変換して、制御を自分の手に取り戻すための装置です。

一見すると地味な設定値ですが、分散システムの信頼性設計では最も基本的で、最も重要な防御線とされています。リトライもサーキットブレーカーも、まず呼び出しが有限時間で決着すること — つまりタイムアウトがあること — を前提に成り立つからです。「失敗するなら、せめて速く失敗する」というフェイルファスト(fail fast)の思想の、いちばん土台にある語彙と言えます。

なぜ生まれたか

ネットワーク越しの通信では、「相手が落ちた」ことを直接知る方法がありません。ソケットの向こうでサーバがクラッシュしても、途中の回線が切れても、こちらに届くのはただの「沈黙」です。応答が来ないのは、処理に時間がかかっているだけなのか、永遠に返らないのか — 待つ側には区別がつきません。しかも TCP は、データが流れていない接続を何時間でも「生きている」ものとして保ち続けます。上限を決めずに待てば、返らない応答を文字どおり無限に待つことになります。

そして無限に待つことの本当のコストは、「遅い」ことではなく資源の占有です。応答を待つ間、呼び出し側はスレッドやコネクション、メモリを握ったまま解放しません。依存先が1つ沈黙しただけで、待ち続けるリクエストが積み上がり、スレッドプールやコネクションプールが枯渇し、無関係な処理まで巻き添えで止まる — 障害が呼び出し元へと逆流していきます。厄介なのは、多くのライブラリの既定値が「無制限」だったことです。Java の HttpURLConnection、Python の requests、Go の http.Client は、いずれも明示しなければ読み取りの待ちに上限がありません。Michael Nygard は『Release It!』(2007年)で、本番障害の多くがこの「書き忘れたタイムアウト」から始まることを実例とともに示し、タイムアウトを安定性パターンの筆頭に置きました。「どんな外部呼び出しにも必ず期限を切る」ことが信頼性設計の第一歩として確立されたのは、こうした事故の積み重ねからです。

詳細

一口にタイムアウトと言っても段階がある

外部呼び出しのタイムアウトは、実際にはいくつかの段階に分かれています。まず接続タイムアウトは、TCP の接続確立(3ウェイハンドシェイク)までの上限です。相手ホストに到達できるかどうかの判定なので、通常は短く(数百ミリ秒〜数秒)設定します。OS に任せると、Linux では SYN の再送を繰り返して2分以上粘ることがあるため、アプリ側で明示するのが定石です。次に読み取りタイムアウトは、接続後にデータが送られてくるのを待つ上限で、相手の処理時間に依存するため接続タイムアウトより長めになります。そして全体タイムアウト(リクエストタイムアウト、デッドラインとも呼びます)は、再接続やリトライも含めた「この呼び出し全体をいつまでに諦めるか」の上限で、利用者への応答時間の約束に直結するのはこれです。

1回目の試行2回目の試行接続確立送信データが少しずつ届く接続確立送信・受信×接続タイムアウトハンドシェイクまで読み取りタイムアウトデータが途切れた間だけ測る届くたびに0に戻る全体タイムアウト(デッドライン): 再試行も含めて、この時刻で必ず諦める締め切り時間
3つの時計は、それぞれ別の区間を測る。読み取りタイムアウトは「データが途切れた時間」しか見ていない

落とし穴になりやすいのは、読み取りタイムアウトが「データの届かない時間」の上限であって、リクエスト全体の上限ではないことです。図のように相手が少しずつデータを送り続ける限り、読み取りの時計は届くたびに0に戻り、呼び出しは想定よりはるかに長引きます。全体を確実に打ち切りたいなら、全体タイムアウトを別に設ける必要があります。

待つ時間は、そのままスレッドの本数になる

タイムアウトの長さがなぜ資源の問題なのかは、簡単な掛け算で分かります。毎秒 λ 件のリクエストが来て、1件あたり平均 W 秒待つなら、同時に待っているリクエストの数は平均 λ × W 件です(待ち行列の理論でリトルの法則と呼ばれる関係)。依存先が沈黙すると W はタイムアウトの値そのものになるので、毎秒50件のサービスでタイムアウトが30秒なら、1500本のスレッドが「返らない応答」に握られます。1秒なら50本です。タイムアウトの値は、障害時に待ちで埋まる資源の量を決めるつまみでもあるのです。

下の部品で確かめてください。上段はスレッドの枠、下段は返ってきた応答の時間の分布です。依存先を押して止め、金の線(タイムアウト)を左右にドラッグすると、「待ちに握られるスレッドの数」と「切られてしまう正常な応答の割合」が逆向きに動きます。

⚡ 体験: タイムアウトの線をどこに引くか
毎秒5件スレッド 0/12依存先押すと遅く・止まる占有スレッドの推移(直近15秒)応答時間の分布 — 返ってきた応答は、その時間の位置に積もる返らない0秒1秒2秒3秒p99タイムアウト 無制限
正常なのに切られる0%待ちに握られたスレッド0/12満杯で断った(直近10秒)0件

いまのタイムアウトは無制限(多くの HTTP ライブラリの既定値)です。まず右の依存先を2回押して「停止」にし、スレッドの枠がどうなるか見てください。

値はどう決めるか — p99 から逆算する

タイムアウト値に万能の正解はありませんが、勘で決めるものでもありません。出発点は依存先の実際のレイテンシ分布、とくに p99(99パーセンタイル。100回中99回はこの時間内に収まる値)です。平均値では裾の長さが見えないので、レイテンシは分位点で測ります。メトリクスから p99 を測り、その少し上 — たとえば p99 の1.5〜2倍程度 — に設定すれば、「正常なリクエストのほとんどは打ち切らず、異常な遅延だけを切る」バランスになります。

短すぎる正常なのに遅いだけのリクエストまで失敗にするタイムアウトp99 の少し上に置く長すぎる沈黙した相手を待ち続け資源を占有し続ける大半の応答が返る範囲p99応答時間適正値はレイテンシ分布から逆算する — p99 のすぐ上が出発点
タイムアウト値の置きどころ — 大半の応答が返る範囲の少し先に置き、短すぎ・長すぎの両方を避ける

短すぎるタイムアウトは、正常なのに少し遅いだけのリクエストを失敗にし、リトライと組み合わさると負荷をむしろ増やします。依存先が「遅いだけで生きている」とき、分布ごと右へずれた応答を片端から切ってしまうのもこの型です。長すぎるタイムアウトは、上の掛け算どおり資源の占有時間を延ばして枯渇のリスクを高めます。どちらに倒すかはその呼び出しの性質次第ですが、一番危険なのは「ライブラリのデフォルト値(無限や数分のことも多い)をそのまま使う」ことです。タイムアウトは設定し忘れたときに静かに牙をむく類のもので、負荷試験やカオスエンジニアリングで「依存先が沈黙したとき」の挙動を実際に確かめておく価値があります。

もう一つ忘れてはいけないのは、タイムアウトは「こちらが待つのをやめた」だけで、相手の処理を止めたわけではないことです。打ち切った時点で、相手側では処理が完了しているかもしれません。タイムアウトした呼び出しの結果は「失敗」ではなく「不明」であり、書き込みを再送するなら受け側の冪等性が欠かせません。また、裾の長い分布では、p99 付近で待つ代わりに少し遅れて別のレプリカへ同じ要求を投げ、先に返った方を使う「ヘッジリクエスト」という手もあります(Dean と Barroso の論文「The Tail at Scale」、2013年)。

タイムアウトは呼び出し階層で連鎖する

現実のシステムでは、呼び出しは階層になっています。ユーザー →APIゲートウェイ→ サービスA → サービスB、というように。ここで各層のタイムアウトがばらばらだと奇妙なことが起こります。上流が3秒で諦めているのに、下流は30秒かけて処理を続け、誰も受け取らない結果のために資源を燃やし続ける、といった具合です。

原則は「上流ほど長く、下流ほど短く」。外側の約束(たとえば「ユーザーには5秒以内に何か返す」)から逆算して、内側の各呼び出しに残り時間を配分します。これを仕組みとして徹底したのがデッドライン伝搬で、gRPC では「この処理全体の締め切り時刻」をリクエストと一緒に下流へ伝え、各サービスが残り時間を見て自ら処理を打ち切れるようになっています。

サービスBサービスAゲートウェイユーザーサービスBサービスAゲートウェイユーザー応答が遅延しているリクエスト 期限5秒呼び出し 残り4秒呼び出し 残り3秒3秒で待ちを打ち切り代替の応答を返す期限内に結果を返す

内側で確実に打ち切られるからこそ、外側は残り時間で代替処理(キャッシュを返す、機能を縮退するなど)に切り替えられます。この「期限内に必ず何かを返す」設計はグレースフルデグラデーションにつながります。

打ち切った後の設計へ

タイムアウトは「諦める」仕組みであって、「解決する」仕組みではありません。打ち切った後にどうするか — 間隔を空けて再試行するのがリトライと指数バックオフ、失敗が続く依存先への呼び出し自体を一時的に止めるのがサーキットブレーカー、待ちに使える資源の総量を依存先ごとに区切っておくのがバルクヘッドです。この仲間は「まず有限時間で失敗させ、賢く再試行し、駄目なら遮断し、それでも被害を区画に閉じ込める」という一連の系譜として設計されます。また、タイムアウトの多発はそれ自体が依存先の異常を示すシグナルなので、監視とアラートの対象としても重要です。

試問

利用者から毎秒5件の要求が来て、1件ごとにスレッド(枠は12本)を1本握って依存先の応答を待ちます。返ってきた応答は、かかった時間の位置で下の分布に積もります。依存先を押すと「正常 → 遅い → 停止(沈黙)」と切り替わり、金の線(タイムアウト、いまは無制限)をドラッグすると待つ時間の上限を変えられます。

✦ 命題挑戦中

依存先が止まってもスレッドの枠を待ちで埋めず、しかも正常な応答はほとんど切らないタイムアウトの線を引け

  • 依存先を「停止」にして、8秒以上見守る
  • 停止中も、待ちに握られるスレッドが平均 9本以下(枠12本の3/4)
  • 正常なときの応答を切ってしまう割合が 5%以下
⚡ 体験: タイムアウトの線をどこに引くか
毎秒5件スレッド 0/12依存先押すと遅く・止まる占有スレッドの推移(直近15秒)応答時間の分布 — 返ってきた応答は、その時間の位置に積もる返らない0秒1秒2秒3秒タイムアウト 無制限
正常なのに切られる0%待ちに握られたスレッド0/12満杯で断った(直近10秒)0件

いまのタイムアウトは無制限(多くの HTTP ライブラリの既定値)です。まず右の依存先を2回押して「停止」にし、スレッドの枠がどうなるか見てください。