「あなたのリトライが私たちを殺しています。」
あるサービスチームが、依存先チームからこのメッセージを受け取りました。障害の真っ最中に、依存先のAPIがタイムアウトしている。当然、リトライする。3回、5回、10回。クライアント側は正しい動作をしているつもりです。
しかし依存先から見れば、障害で処理能力が半分に落ちているところに、通常の数倍のリクエストが殺到している状態でした。リトライが障害を悪化させ、回復を妨げていた。
これはどこかの寓話ではありません。2012年8月、Knight Capitalの話です。デプロイ時に旧コード(Power Peg)が誤って活性化され、45分間に数百万件の注文を自動生成。4.4億ドルの損失を出して実質破綻しました。注文が「完了」とマークされず、システムが注文を再生成し続けた。構造としては「フィードバックループが閉じていない無限再実行」で、リトライ暴走と同じ力学です。
リトライは障害を乗り越えるための仕組みです。しかし設計を誤ると、リトライそのものが障害の原因に変わります。
自分自身と戦うシステム -- 3つの暴走パターン
Michael Nygardは著書 Release It! で、本番システムが自分自身を攻撃するパターンを複数挙げています。リトライ関連の暴走パターンは3つです。
ドッグパイル
キャッシュの有効期限が切れた瞬間、全クライアントが同時にオリジンサーバーへリクエストを送る。通常時は1秒あたり100リクエストのサービスが、ドッグパイル時には数千リクエストを同時に受ける。正常に戻ったはずのサービスが、一斉リトライの波で再び倒れる。
障害から復旧した瞬間が最も危険です。キューに溜まったリクエストが一斉に流れ込むので、復旧直後に再度ダウンする。これを繰り返すと、オンコール担当の精神が先にダウンします。
カスケーディング障害
サービスAがBに依存し、BがCに依存する。Cが遅延すると、Bのスレッドがブロックされる。Bのスレッドが枯渇すると、Aのリクエストもブロックされる。1つのサービスの遅延が、依存チェーンを通じて全体に波及します。
ここでの厄介なポイントは、遅延がエラーより悪質だということです。エラーは速く返るのでリソースを長時間占有しません。しかし遅延はスレッドやコネクションを占有し続ける。Nygardの言葉を借りれば、「遅いレスポンスは、エラーよりも悪い」。
遅いレスポンスの罠
タイムアウト30秒のHTTPクライアントが、遅延するサービスを呼び出す。30秒間スレッドが占有される。その間にリクエストが積み上がり、スレッドプールが枯渇する。
タイムアウトが長すぎればリソースを長時間食い潰し、短すぎれば正常な処理まで中断する。「適切なタイムアウト値」は見た目以上に難しい問題です。私はこれまで「デフォルト値のままでした」という告白を何度も聞いてきました。自分もやっていた時期があるので、人のことは言えませんが。
リトライ設計の3原則
リトライそのものが悪なのではありません。問題は「考えなしのリトライ」です。
ただし大前提として、リトライ先のAPIが冪等(同じリクエストを複数回送っても結果が変わらない)でなければ、どんなリトライ戦略も安全にはなりません。POST /orders を3回リトライしたら注文が3件できた、という話は笑えません。
| 原則 | やること | やらないと何が起きるか |
|---|---|---|
| 指数バックオフ | リトライ間隔を1→2→4→8秒と倍増 | 全クライアントが同時にリトライし続ける |
| ジッター | バックオフにランダムな揺らぎを加える | バックオフの波が同期してスパイクが周期的に発生 |
| リトライバジェット | システム全体のリトライ割合に上限を設ける | 個々は合理的でも、集団として破壊的になる |
指数バックオフだけでは足りない
即座にリトライするのではなく、リトライ間隔を徐々に広げる。これは基本中の基本です。しかし指数バックオフだけでは不十分です。全クライアントが同じタイミングでリトライを開始すると、1秒後、2秒後、4秒後にそれぞれスパイクが発生する。バックオフの波が同期してしまうのです。
ジッターで波を崩す
バックオフにランダムな揺らぎを加えます。
import random
def retry_with_jitter(attempt, base=1, max_delay=60):
delay = min(base * (2 ** attempt), max_delay)
jitter = random.uniform(0, delay)
return jitter
AWSのブログ「Exponential Backoff And Jitter」(2015)で、Full Jitter戦略が推奨されています。リトライタイミングが分散され、ドッグパイル効果が軽減されます。
リトライバジェット -- 集団行動の制御
「直近1分間のリクエストのうち、リトライが占める割合が20%を超えたら、新規のリトライを停止する。」
Google SREハンドブックで紹介されているアプローチです。個々のクライアントは自分のリトライが「合理的」だと判断しています。しかし全員が同時にリトライすれば、集団として破壊的になる。リトライバジェットは、この「合成の誤謬」に対処します。
交通渋滞と同じです。1台が車線変更するのは合理的。全員が同時に車線変更すると、渋滞が悪化する。
本番デバッグ時の5つのチェックポイント
障害の原因がリトライや制御ロジックにある場合、以下の5点を確認してください。
| # | チェック項目 | 確認すること | 危険サイン |
|---|---|---|---|
| 1 | リトライ間隔 | 指数バックオフ+ジッターが実装されているか |
sleep(1) のベタ書き |
| 2 | リトライ上限 | 最大リトライ回数が設定されているか |
while True + retry |
| 3 | タイムアウト値 | デフォルト値のまま放置されていないか | タイムアウト未設定、または30秒超 |
| 4 | サーキットブレーカー | 依存先障害時にリクエストを止める仕組みがあるか | 障害中も全リクエストを投げ続ける |
| 5 | フィードバックループ | 処理完了が正しく記録されるか | 完了マークなしの処理が再実行される |
Knight Capitalの事故では、項目2と項目5の両方が欠落していました。注文に上限がなく、完了マークがつかないので再生成し続けた。2つの欠落が重なった結果が4.4億ドルです。
制御ロジックの本質的な怖さ
ここまで読んで「うちのリトライ設定は大丈夫だろう」と思った方。私も以前はそう思っていました。本番障害で初めて「あれ、リトライ何回まで?」と調べて、答えが見つからなかったときの焦りは忘れられません。
正常系のコードは毎日何百万回も実行され、バグがあればすぐに気づきます。しかし制御ロジック -- リトライ、タイムアウト、バックオフ、サーキットブレーカー -- は障害時にしか実行されません。障害は頻繁には起きないので、制御ロジックのバグは長期間潜伏します。そして本当に必要な瞬間に、期待通りに動かない。
「障害を乗り越えるための仕組み」が「障害を悪化させる仕組み」に変わる。このパラドックスが、制御ロジックの本質的な難しさです。
だからこそカオスエンジニアリングが必要になります。本番環境で意図的に障害を起こし、制御ロジックが想定通りに動くか検証する。平常時に制御ロジックをテストする唯一の方法は、平常時に障害を作ることです。矛盾しているようですが、それが現実の運用です。
まとめ
リトライは味方にも敵にもなります。
- ドッグパイル、カスケーディング障害、遅いレスポンス -- 暴走パターンを知らなければ、自分のリトライが何を引き起こしているか気づけません
- 指数バックオフ + ジッター + リトライバジェット -- この3つをセットで実装して初めて、リトライは安全になります
- 制御ロジックのバグは障害時まで眠っている -- 平常時のテストでは見つからない
あなたのサービスのリトライ設定、最後に確認したのはいつですか? grep -r "retry" src/ を叩いてみると、思わぬ発見があるかもしれません。
付録: リトライデバッグスキル(コピペ用)
以下をそのまま CLAUDE.md やAIエージェントのスキル定義に貼ると、障害時のリトライ関連チェックをAIに実行させられます。
# リトライ・制御ロジックデバッグ
障害が発生し、リトライや制御ロジックが原因として疑われる場合に実行する。
## 初動: 5つのチェックポイント
以下を順番に確認し、各項目の結果を報告すること。
1. **リトライ間隔**: 指数バックオフ+ジッターが実装されているか。`sleep(1)` のベタ書きは危険サイン
2. **リトライ上限**: 最大リトライ回数が設定されているか。`while True` + retry は無限ループと同義
3. **タイムアウト値**: デフォルト値のまま放置されていないか。未設定または30秒超は要確認
4. **サーキットブレーカー**: 依存先障害時にリクエストを止める仕組みがあるか
5. **フィードバックループ**: 処理完了が正しく記録されるか。完了マークなしの処理が再実行される構造は致命的
## 検出コマンド
# リトライ関連コードの洗い出し
grep -rn "retry\|retries\|max_attempts\|backoff\|jitter" src/
# タイムアウト設定の確認
grep -rn "timeout\|TIMEOUT\|time_out" src/
# サーキットブレーカーの有無
grep -rn "circuit\|breaker\|CircuitBreaker" src/
## 判定基準
- 5項目すべてに明示的な設定がある → 🟢 問題なし
- 1-2項目が欠落 → 🟡 改善推奨(欠落箇所と修正案を提示)
- 3項目以上が欠落、またはリトライ上限なし → 🔴 即時対応(Knight Capital型の暴走リスクあり)
## 前提条件の確認
リトライ設計の前に、以下の前提が満たされているか確認すること。
- リトライ先APIは冪等か(同じリクエストを複数回送っても結果が変わらないか)
- 冪等でない場合、冪等キー(Idempotency-Key ヘッダ等)の仕組みはあるか
参考文献
- Michael Nygard, Release It! (2007, 2018 2nd Edition) -- 安定性アンチパターンの原典
- Google SRE Handbook, Chapter 22: "Addressing Cascading Failures" -- リトライバジェットの解説
- AWS Architecture Blog, "Exponential Backoff And Jitter" (2015) -- Full Jitter戦略の提案
- SEC Filing: Knight Capital Group, Form 10-Q (2012) -- 4.4億ドル損失の詳細