事件の発端 — 凍りついた注文
木曜の夕方17時。自分のオフィスの小会議室を予約していた。
ホワイトボードには、前回と前々回のセッションで描いたCircuit BreakerとRetryの構成図がうっすら残っている。消し忘れたのではなく、あえて消さなかった。今日の相談で使うかもしれないと思ったからだ。
ノートPCを開き、Grafanaのダッシュボードを映しておく。水曜日の在庫照会APIのレスポンスタイムのグラフ。80ms前後で安定していたラインが、14:30を境に55秒に張り付いている。同じ時刻に、注文処理のスループットがゼロに落ちている。
3回目の相談だ。最初は深夜にアポなしで来た。2回目は土曜にLCIの事務所を訪ねた。3回目は——普通に会議室を予約して呼んだ。「レガシー・コード・インベスティゲーション」に会議室を予約する日が来るとは。
時間通りにドアが開いた。ロックさんだ。いつものツイードのジャケット。片手にペットボトル——前回はエナジードリンクだったが、今回はただの水だ。
ロックさんは椅子に座らず、まず窓に近づいた。夕暮れの空を見ている。
「いい色だ。東京の夕焼けは、工場の煤のせいでロンドンに似ている」
「……ロックさん、ロンドンに住んだことあるんですか」
「ない」
一瞬、呆れかけた。でも笑ってしまった。以前なら「何を言っているんだこの人は」と思っただろう。今は「ああ、この人だ」と思える。
ロックさんが椅子に座り、ノートPCの画面を見た。
「で、今度は何が燃えた」
「燃えたというか——凍った、が正確です」
Grafanaのダッシュボードを示した。水曜14:30からの在庫照会APIのレスポンスタイムグラフ。
「在庫照会APIが水曜の午後に遅延しました。応答に55秒かかる状態が30分間続いて、その間、注文処理が完全に止まりました」
「決済APIは?」
「正常です。CBもRetryも期待通りに動いています」
ロックさんが静かにグラフを見ている。決済APIのレスポンスタイムのグラフも確認する。そちらは安定している。
「CBもRetryも正常で、注文処理が止まった」
「はい。在庫照会APIは——CBの対象にしていませんでした。社内の別チームが運用しているAPIなので、まさか遅延するとは……」
ロックさんが私を見た。前回のような鋭い視線ではなく、どこか穏やかな目だった。
「ワトソン君。決済API以外の外部呼び出しを全て挙げてくれ」
「在庫照会API、配送料計算API、ポイント残高API。いずれも社内の別チームが運用しています」
「それぞれのタイムアウト設定は?」
ノートPCでコードを確認する。少し間が空いた。自分でも顔が曇るのがわかった。
「……在庫照会は、HTTP::Tinyのデフォルト60秒です。配送料計算も60秒。ポイント残高は30秒——先月、ポイントチームから『30秒以内に返らなければ障害と見なして』と指示があって設定しました」
「在庫照会と配送料計算は、デフォルトのまま。意図的に設定したのではなく、設定しなかった。——それが60秒になっている」
「……はい」
「60秒のタイムアウトで、在庫照会APIが55秒かかっている。タイムアウトしない。ギリギリで返ってくる。——CBから見れば"成功"だ。遅いだけで、エラーではない」
「……だからCBがOpen遷移しなかった。55秒で返ってきているから、失敗として検知されない」
「そして10スレッドのワーカープールが、全て55秒の在庫照会を待っている。新しい注文が来ても、スレッドが空いていない」
CBを入れた。Retryを直した。2回直したのに、3回目に止まった。しかも今回は、CBもRetryも正常に動いている。問題はその外にあった。
「……在庫照会にもCBを追加すればいいんじゃないかと思ったんですが」
「CBを追加する。いい。——だが、在庫照会APIが55秒間応答を返さない状態で、CBはいつOpenに遷移する?」
「失敗閾値5回で……60秒のタイムアウトが5回……300秒」
「300秒。5分。その間、10スレッドは全て埋まっている。CBがOpenになる前に、システムが止まる」
間が空いた。
「ワトソン君。**CBは"呼び出しが返ってくること"を前提にしている。**返ってこなければ——あるいは返ってくるのが遅すぎれば——失敗のカウントすら始まらない。CBの速度は、タイムアウトの速度で決まる」
窓の外を見た。夕焼けが少し暗くなっている。
「……最初にCBを入れた。次にRetryを直した。でも一番内側の——タイムアウトが設定されていなかった」
「基礎を最後に学ぶのは珍しいことではない。**問題は外側から見えるからだ。**カスケード障害はCBで防げる。リトライストームはRetryで防げる。しかし"待ちすぎ"は外からは見えない。スレッドが静かに待っているだけだから」
ロックさんが言葉を切った。
「沈黙の障害は、最も発見が遅い」
現場検証 — タイムアウトの指紋
ノートPCで在庫照会の呼び出しコードを開いた。二人で画面を見る。
「これが在庫照会の呼び出し部分です」
| |
ロックさんがコードを読む。指が HTTP::Tiny の呼び出し行で止まった。
「http_client にタイムアウトを渡していない。——デフォルトは何秒だ」
「HTTP::Tinyのデフォルトは60秒です」
「60秒。1つのリクエストが在庫照会に60秒間接続を保持する。ワーカープールは10スレッド。全スレッドが同時に在庫照会を呼んでいたら?」
「10スレッド × 60秒……全員が60秒間占有されて、その間は新しいリクエストを処理できない」
「そしてHTTP::Tinyの timeout は1つの値だ。接続の確立にも、レスポンスの読み取りにも、同じ秒数が適用される。——ワトソン君、TCP接続の確立と、サーバーの処理は同じものか?」
「……違います。接続はネットワーク経路の問題で、処理はサーバーの負荷の問題です」
「**接続に1秒以上かかるなら、サーバーに到達できていない。**ネットワーク障害か、サーバーが停止している。この場合、60秒待っても意味がない。0.5秒で切るべきだ」
「接続タイムアウトを短くする——」
「一方、接続は0.01秒で成功したが、レスポンスが5秒かかる。これはサーバーが処理中だ。このとき0.5秒で切ったら?」
「正常なリクエストまで殺してしまう」
「接続と読み取りでは、“待っている対象"が違う。対象が違えば、適切な待機時間も違う。——接続タイムアウトは短く設定して到達不能をすぐに検知する。読み取りタイムアウトは下流サービスのパーセンタイルに基づいて設定する——正常な処理を殺さない範囲で」
「読み取りタイムアウトはどうやって値を決めるんですか? 在庫照会APIのレスポンスタイムは日によってバラバラで……」
ロックさんがホワイトボードの前に立った。CB/Retryの消えかけた図の横に、横軸がレスポンスタイム、縦軸がリクエスト数のヒストグラム概念図を描く。左端に大きな山、右裾が長く伸びる分布。
「ワトソン君のチームは、在庫照会APIの偽タイムアウト——正常なリクエストをタイムアウトで殺すこと——をどの程度許容できる?」
「……0.1%未満なら許容範囲です」
「ではp99.9を見たまえ。**タイムアウト値は、下流サービスのレイテンシの許容パーセンタイルで決める。**p99.9が1.5秒なら、タイムアウトは1.5秒に設定する。99.9%のリクエストは正常に返り、0.1%だけがタイムアウトになる」
「水曜のように55秒かかっている状態は、p99.9の外——つまりタイムアウトで切られる」
「そうだ。55秒待つ必要がなくなる。1.5秒でタイムアウト。CBの失敗カウントが即座に進む。5回連続タイムアウト——7.5秒でOpen遷移」
計算して目を丸くした。
「……300秒から7.5秒。40倍速い」
「タイムアウト値が適切であれば、CBは設計通りの速度で動く。CBが遅かったのではない。タイムアウトが遅かったのだ」
推理披露 — 待たない勇気の設計
TimeoutWrapper — 全体を包む制限時間
ロックさんがホワイトボードに構造を描く。
「HTTPクライアントのタイムアウトだけでは足りない場合がある。DNS解決やTLSハンドシェイクがタイムアウトに含まれない実装もある。——全体を包む TimeoutWrapper を作る」
| |
「3つのタイムアウトを設定する。接続タイムアウト、読み取りタイムアウト、そして全体タイムアウト——DNS解決、TLS、接続、読み取りの合計がこの値を超えたら強制終了する」
「全体のデッドラインがある……Retryの回数制限に似ていますね。個々のリトライ間隔とは別に、全体の試行時間に上限を設ける」
ロックさんが少し目を細めた。前回の知識を自発的に接続したことに気づいたようだ。
「その通りだ」
Perl での実装 — alarm + eval
「Perlでのタイムアウトの基本形は alarm と eval の組み合わせだ」
| |
「alarm は SIGALRM をプロセスに送る。eval の中で die をキャッチする。——シンプルだが、注意点がある」
「alarm 0 が2箇所あるのは?」
「eval の中の alarm 0 は正常終了時にタイマーを止める。外の alarm 0 は例外が起きた場合にタイマーを止める。——タイマーが残ったまま別の処理に入ると、無関係の処理がタイムアウトで死ぬ。必ず後始末をする」
「$SIG{ALRM} を local にしているのは、元のハンドラを壊さないため?」
「そうだ。local は eval を抜けた時点で元に戻る。alarm はプロセスグローバルだ——1つしかタイマーを持てない。もし別のライブラリが alarm を使っていたら、上書きしてしまう。これが alarm の最大の弱点だ」
FallbackStrategy — タイムアウト後の選択
「タイムアウト後にどうするか——フォールバック戦略を、タイムアウト制御とは分離する」
| |
「CachedValue は正常時にレスポンスを保存し、タイムアウト時に古い値を返す。DefaultValue は常に同じデフォルト値を返す。——どちらを使うかはサービスごとに決める」
「フォールバックは安全なんですか? 古いキャッシュで在庫ありと返したのに、実際は欠品だったら……」
「正しい懸念だ。AWSの設計者が言っている——フォールバックは避けるべきだ、と。フォールバック経路はめったにトリガーされないから、テストが不十分になりやすい。本番で初めて動いたときにバグがある」
「じゃあ、タイムアウト時はエラーを返すべき?」
「**理想はそうだ。**しかし現実には——在庫照会がタイムアウトしたときにユーザーに"注文できません"を返すのか、“在庫情報を確認中です、注文を仮受付します"と返すのか。——**ビジネス判断だ。**技術者が一人で決めることではない」
「……チームで決める必要がある」
「そうだ。フォールバック戦略はコードの問題ではない。サービスレベルの設計判断だ。——ただし、もしフォールバックを使うなら、3つのルールを守れ。(1) フォールバック経路を定期的にテストする。(2) 主経路より単純にする。(3) 主経路と同じリソースに依存しない」
「……在庫照会のフォールバックとして、在庫DBに直接クエリを投げるのは?」
「在庫照会APIが遅延している原因がDB負荷だったら? フォールバックが主経路と同じ障害源を叩くことになる。——フォールバックが障害を増幅する」
ペンを取り、メモを書き始めた。フォールバックの3つのルール。主経路と同じリソースに依存しない。
After コード — TimeoutWrapper の統合
「TimeoutWrapperとフォールバックを在庫照会に適用すると——」
| |
「正常時はレスポンスをキャッシュに保存する。タイムアウト時はキャッシュを確認する。キャッシュもなければ——潔くエラーを返す」
「{ timeout => $read_to } ——HTTP::Tinyのタイムアウトと、alarm の全体タイムアウト、二重に設定しているわけですね」
「そうだ。HTTP::Tinyは接続と読み取りを分離できない。**全体タイムアウト(alarm)が外側のガードになる。**接続の確立に異常に時間がかかる場合は、全体タイムアウトが先に発火する。——完璧ではないが、“タイムアウトなし"からは天と地ほど違う」
CB + Retry + Timeout — 3層の防衛線
「CBとRetryとTimeout、3つを全部組み合わせると、呼び出しの順序はどうなるんですか?」
ロックさんがホワイトボードの前に立ち、大きく図を描いた。
graph LR
A[リクエスト] --> B[Retry]
B -->|should_retry? → yes| B
B --> C[Circuit Breaker]
C -->|Open? → 即エラー| X[エラー返却]
C --> D[Timeout]
D -->|接続TO + 読み取りTO + 全体TO| E[外部API]
「外側から内側に向かって——Retryが最も外側。CBがその内側。Timeoutが最も内側。Timeoutが"呼び出しが返ってくるまでの時間"を制限する。CBがTimeoutの結果を見て状態遷移する。Retryが"もう一度やるか"を判断する」
「Timeoutが最も内側——基盤ということですか」
「Timeoutがなければ、CBは"呼び出しが返ってくるまで"待つ。Retryは"CBが判断するまで"待つ。——全てが遅くなる。Timeoutは"待つ限界"を定義する。限界がなければ、全てのパターンは無限を相手にする」
ホワイトボードの図を見つめた。CB、Retry、Timeout。3層が重なっている。
「……最初にCBを入れた。次にRetryを直した。でも一番内側のTimeoutが未設定だった。——基礎が抜けていた」
「基礎を最後に学ぶのは珍しいことではない。**問題は外側から見えるからだ。**カスケード障害はCBで防げる。リトライストームはRetryで防げる。しかし"待ちすぎ"は外からは見えない。スレッドが静かに待っているだけだから。——沈黙の障害は、最も発見が遅い」
解決 — 平和なビルド
テストを回した。
タイムアウトなし(Before)——在庫照会APIの遅延時にスレッドが60秒間ブロック。新規リクエストは全てキュー待ち。CBは成功と判定。システム停止。
タイムアウトあり(After)——在庫照会APIの遅延時に1.5秒でタイムアウト。CBの失敗カウントが即座に進む。7.5秒でOpen遷移。フォールバックがキャッシュされた在庫情報を返す。新規リクエストは正常処理。
「……300秒が7.5秒。注文処理は止まらない」
「在庫照会の結果は古いキャッシュだが——注文は受け付けている。全面停止か、在庫情報の多少の遅れか。どちらがましだ」
「全面停止よりは——はるかにまし、です」
ロックさんが立ち上がった。
「**これで3枚目の防衛線だ。**CB、Retry、Timeout。——もうこれで終わりですか、と聞きたい顔をしているな」
苦笑が漏れた。
「……聞きたかったです、正直」
ロックさんが窓の外を見た。夕焼けが暗くなっている。何か言いかけて、一瞬口を閉じた。それから——
「終わらない。防衛線は一度に完成しない。——だが、ワトソン君。今日の1枚は、昨日より確実に厚い」
「……全部にタイムアウトを入れれば完璧ですか」
「**完璧にはならない。**新しいサービスが追加されれば、新しいタイムアウトが必要になる。タイムアウト値は下流サービスのレイテンシが変われば更新しなければならない。フォールバック戦略はビジネス要件の変化で見直しが要る。——防衛線は生き物だ。建てたら終わりではない。メンテナンスが要る」
黙って頷いた。疲弊は消えていない。だが、諦めの色ではなくなっている。
事件の余韻 — 共有する防衛線
ロックさんが帰った後、会議室に一人残った。
ホワイトボードには、今日描いた図が残っている。CB、Retry、Timeoutの3層構成図。前回と前々回に残っていた薄い図の上に、今日の図が重なっていた。
スマートフォンでホワイトボードの写真を撮った。Slackの自チームチャンネルを開く。
「明日のスプリントレビューで共有したい図があります。在庫照会の件の対策案です」
送信ボタンを押した。
CBを入れたときは、「これで守れる」と思った。Retryを直したときも、「これで大丈夫だ」と思った。3回目でようやくわかった。防衛線は完成しない。完成しないものを、1枚ずつ積んでいくしかない。
でも、1枚ずつ積むやり方は——もう知っている。
私が学んだことは、私だけのものじゃない。
会議室の電気を消した。窓の外はもう暗い。
探偵の調査報告書
| 容疑(アンチパターン) | 真実(パターン) | 証拠(効果) |
|---|---|---|
| タイムアウト未設定(HTTP::Tinyデフォルト60秒の放置) | 接続/読み取りタイムアウトの分離設定 | 障害検知が300秒→7.5秒に短縮 |
| 全外部呼び出し共通の単一タイムアウト | パーセンタイルベースの個別タイムアウト値設定 | 偽タイムアウト率0.1%以下を維持しつつ障害を高速検知 |
| タイムアウト時の即死(エラーのみ) | FallbackStrategy による段階的対応 | 全面停止を部分縮退に変換 |
推理のステップ
- タイムアウト設定の棚卸し: 全ての外部呼び出しのタイムアウト設定を確認し、デフォルト値のまま放置されていないかを洗い出す
- 接続タイムアウトの短縮: TCP接続確立の制限を短く設定する(0.5秒程度)。到達不能な場合を高速に検知するため
- 読み取りタイムアウトのパーセンタイル設定: 下流サービスのレイテンシのp99.9に基づいて読み取りタイムアウトを設定する。正常なリクエストを殺さない範囲を見極める
- 全体タイムアウトの設定:
alarm+evalで全体のデッドラインを制御する。個々のタイムアウトの合計とは別に、1リクエストに費やせる絶対的な上限を設ける - フォールバック戦略の決定: タイムアウト発生時の対応をサービスごとに決定する。フォールバック経路が主経路と同じリソースに依存していないことを確認する
- Timeout → CB → Retry の統合: Timeoutを最も内側に配置し、CBがTimeoutの結果を基に状態遷移し、Retryが最も外側でリトライ判断を行う3層構成を組む
ロックより
待つことは美徳ではない、ワトソン君。待つべきときに待ち、待つべきでないときに断つ——それが防衛線の思想だ。
君のシステムには今日、3枚目の防衛線が加わった。CBが障害を遮断し、Retryが再挑戦の作法を整え、Timeoutがその全てに"待つ限界"を与える。基礎は地味だ。しかし、基礎が抜けたビルは、どれほど美しい外壁を持っていても倒れる。
今日のことを、チームに伝えたまえ。防衛線は1人で守るものではない。
