障害が起きたサービスに、なぜリトライを重ねるほどとどめを刺してしまうのか?
「失敗したらリトライすればいい」——そう思って retry: 3 を書いたことがあるはずだ。だが依存先のサービスが重くなり始めたとき、そのリトライが状況を良くするどころか、瀕死のサービスへの追い打ちになることがある。なぜ良かれと思って足した保険が、障害を増幅させる側に回るのだろうか。
- 依存先が重くなった瞬間、大量のクライアントが同時にリトライすると、リクエスト数が減るどころか跳ね上がり、回復しかけた依存先を再び倒す「リトライ嵐」が起きる
- 単純な指数バックオフは待ち時間のクラスタを作るだけで衝突を減らせず、AWS が提唱する jitter(ランダムなゆらぎ)を加えて初めて衝突が実効的に減る
- Circuit Breaker は「失敗しそうな呼び出しをそもそも試みない」層で、Retry と組み合わせて初めて、回復中の依存先へリトライが再び殺到するのを防げる
リトライが増幅させる障害
依存先のサービスが過負荷でレスポンスが遅くなると、呼び出し元のクライアントはタイムアウトしてリトライする。ここまでは正しい対処に見える。問題は、過負荷になっている依存先を呼んでいるクライアントは通常 1 台ではなく、数百・数千台の同時実行インスタンスだということだ。ほぼ同時にタイムアウトした全クライアントが、ほぼ同時にリトライを発行する。結果として依存先が受け取るリクエスト数は、障害が起きる前より増える。
これはリトライストーム(retry storm、リトライ嵐)と呼ばれる現象で、負荷が原因の障害が別の負荷の波を生み、それがまた次の波を生むというフィードバックループを形成する。依存先が一時的に持ち直しかけても、次のリトライの波が来ればまた倒れる。individual なリトライは合理的でも、大量のクライアントが同時に同じ判断をすると全体としては最悪の挙動になる——これがリトライ設計の核心的な難しさだ。
混雑した窓口が一時的に処理落ちして列が進まなくなったとき、並んでいた全員が一斉に「もう一度整理券を取り直そう」と番号札発行機に殺到する状況に近い。発行機の前に新たな行列ができ、本来の窓口業務よりもその行列処理に手を取られて、かえって復旧が遅れる。一人ひとりの「取り直せば早いはず」という判断は間違っていないのに、全員が同時にそれをやることで状況を悪化させてしまう。
指数バックオフだけでは衝突が減らない
この波をならす定番の対処が指数バックオフ(exponential backoff)で、リトライのたびに待ち時間を 2 倍・4 倍と広げ、上限(cap)で頭打ちにする。だが AWS Architecture Blog の分析によれば、素のバックオフには見落とされがちな弱点がある。同時に失敗した大量のクライアントは同じ待ち時間の計算式に従うため、待ち時間が伸びても同じタイミングに集中したままで、衝突する瞬間が減っているだけで衝突の激しさは変わらない。
そこで待ち時間にランダムなjitter(ゆらぎ)を混ぜて、クライアントごとの再試行タイミングをばらけさせる。同記事は 3 通りの計算式を挙げている。
# Full Jitter(推奨): 0 〜 上限の間で完全にランダム sleep = random(0, min(cap, base * 2 ** attempt)) # Equal Jitter: 半分は固定、残り半分だけランダム sleep = cap/2 + random(0, cap/2) # Decorrelated Jitter: 直前の待ち時間を種にする sleep = min(cap, random(base, last_sleep * 3))
この 3 つのうち Full Jitter と Decorrelated Jitter は、Equal Jitter よりも依存先が受け取るリクエスト数を大きく減らせたと報告されている。待ち時間を完全にランダム化するほど、クライアント間の再試行タイミングが分散し、衝突による無駄なリトライが減るという直感通りの結果だ。base を固定して jitter を入れないまま「バックオフしている」と思い込むのが、実務で最もよくある誤りになる。
jitter なし:波が揃って何度もぶつかる ██ ██ ██ ██ ██ ██ jitter あり:波が分散し衝突が減る █ █ ██ █ ███ █ █ ██
Circuit Breaker — そもそも呼びに行かない
バックオフと jitter は「リトライのタイミングをずらす」対処であり、「リトライすること自体」は止めない。依存先が完全にダウンしている間は、どれだけタイミングをずらしてもリトライは失敗し続け、無駄な待ち時間とコネクションを消費するだけになる。ここで効くのが Circuit Breaker(サーキットブレーカー)だ。Microsoft の Azure Architecture Center は、これを電気のブレーカーになぞらえた 3 状態の状態機械として説明している。
失敗が閾値超過
Closed ────────────▶ Open
▲ │
│ 成功が連続 │ タイムアウト
│ ▼
└──────────── Half-Open
(少数だけ試す)
Closed(通常状態)では呼び出しをそのまま通しつつ直近の失敗数を数え、一定期間内に閾値を超えると Open に遷移する。Open では呼び出しを依存先に送らず、即座に失敗を返す——これによりクライアントは依存先の応答を待たずに済み、回復中の依存先に新たな負荷もかけない。タイムアウトタイマーが切れると Half-Open に移り、少数のリクエストだけを試験的に通す。それらが成功すれば Closed に戻り、1 つでも失敗すれば即座に Open へ戻ってタイマーが再スタートする。
2 つは対立ではなく役割分担
「リトライすべきか、遮断すべきか」は対立する選択ではない。Azure Architecture Center は、Retry パターンは「操作はいずれ成功するはずだ」という前提で再試行するのに対し、Circuit Breaker パターンは「この操作は失敗しそうだ」と判断して試行自体を止めるものだと役割を分けて説明し、両者を重ねて使うことを推奨している。具体的には、Circuit Breaker を経由して呼び出しを行い、Circuit Breaker が返す例外を Retry ロジックが見て、Open 状態を示す例外であればそれ以上リトライしない、という組み合わせだ。
この分担が無いと、Retry だけの実装は Open になるべき状況でも延々とバックオフ付きリトライを繰り返し、Circuit Breaker だけの実装は一時的な単発の失敗にまで過敏に反応しかねない。バックオフ+jitter は「同時に叩きに行くクライアントの波を分散させる」層、Circuit Breaker は「すでに落ちていると分かっている依存先には、そもそも波を送らない」層——両者は同じ問題を別の角度から抑えている。
HTTP クライアントライブラリのデフォルト設定にはリトライ回数だけがあり jitter が無いことが多く、依存先の障害時にトラフィックを増幅させる原因になりやすい。マイクロサービス構成で複数レイヤーがそれぞれ独立にリトライしていると、末端の障害がリトライの掛け算で指数的に増幅される「retry amplification」も起きうる。Resilience4j や Polly、Istio のようなサービスメッシュは Circuit Breaker を標準機能として持っているので、自前実装より先にまず確認するとよい。
jitter の有無で待ち時間の分布がどう変わるかを Python の対話環境で比較してみる。
python3 - <<'EOF'
import random
def backoff_no_jitter(attempt, base=1, cap=30):
return min(cap, base * 2 ** attempt)
def backoff_full_jitter(attempt, base=1, cap=30):
return random.uniform(0, min(cap, base * 2 ** attempt))
for attempt in range(4):
same = [backoff_no_jitter(attempt) for _ in range(5)]
spread = [round(backoff_full_jitter(attempt), 1) for _ in range(5)]
print(f"attempt={attempt} no-jitter={same} full-jitter={spread}")
EOF
jitter なしは同じ attempt 番号なら 5 回とも全く同じ待ち時間になり、全クライアントが同じ瞬間に再試行することが分かる。full jitter は同じ attempt でも毎回バラバラの値になり、再試行のタイミングが分散することが確認できる。
- 指数バックオフさえ入れておけばリトライ嵐は防げる — jitter が無いバックオフは、待ち時間が伸びても同時に失敗したクライアント同士が同じタイミングに集中したままで、衝突そのものは減らない。
- Circuit Breaker を入れればリトライは不要になる — Circuit Breaker は「試行するかどうか」を判断する層で、Closed 状態で通った呼び出しが一時的なネットワーク揺らぎで失敗した場合の再試行は依然として Retry の役目であり、両者は代替関係ではない。
- リトライ回数を増やすほど可用性は上がる — 依存先が過負荷で失敗している場面ではリトライ回数を増やすほど送信リクエスト数が増え、依存先の回復を妨げて可用性をむしろ下げうる。
- リトライストーム(retry storm)
- 過負荷で失敗した大量のクライアントが同時に再試行し、依存先へのリクエスト数がかえって増える現象。
- 指数バックオフ(exponential backoff)
- リトライのたびに待ち時間を指数的に延ばし、上限(cap)で頭打ちにする手法。
- jitter(ジッター)
- バックオフの待ち時間にランダムなゆらぎを加え、クライアント間の再試行タイミングを分散させる仕組み。
- Circuit Breaker(サーキットブレーカー)
- 失敗が閾値を超えた依存先への呼び出しを一時的に遮断し、即座に失敗を返す状態機械。Closed / Open / Half-Open の3状態を持つ。
- Half-Open
- Circuit Breaker が遮断を解除できるか試すため、少数のリクエストだけを試験的に通す中間状態。
- Exponential Backoff And Jitter - AWS Architecture Blog — Full/Equal/Decorrelated Jitter の計算式と、各方式が依存先の負荷をどれだけ減らせるかを比較した一次情報。
- Circuit Breaker Pattern - Azure Architecture Center — Closed / Open / Half-Open の状態遷移と、Retry パターンとの役割分担を解説する公式ドキュメント。
- REL05-BP03 Control and limit retry calls - AWS Well-Architected Framework — リトライ回数の制御と Circuit Breaker の併用を推奨する AWS の設計指針。