概要
retry_sim.py)での実測。本番の Cloud Run/GKE では測っていない。読むのは「戻るか・戻らないか」と、その差を作る部品。リトライは層の数だけ掛け算
4 回 × 2 層 = 16 倍。リトライは一番上の 1 か所に置く。
予算で増幅を切る
失敗が増えたら再試行をやめる(gRPC A6 の retry throttling)。
締切は絶対時刻で渡す
受け側は締切切れの仕事を捨てる。誰も待っていない仕事をしない。
満杯なら即座に断る
キュー上限+503+Retry-After。積むより断る方が全員に優しい。
問題
チェックアウトの「使えるクーポン」は checkout-bff → coupon-api → member-api の 3 層。タイムセール中に member-api の DB がフェイルオーバーし、接続プールが 15 秒間 50 → 15 本に細った。DB は 21:00:55 に直ったのに、クーポン適用の成功率は 47 分間ほぼ 0%、member-api の受信は平常の約 16 倍。バナーを下げて流入を止めたら 3 分で戻った 架空の前提
- BFF と coupon-api が同じ共通ライブラリ: tenacity で
stop_after_attempt(4)、待ちなし、1 回 1 秒、503/429 も即リトライ - member-api は受けた要求を全部キューに積み、呼び出し元がもう待っていなくても DB を引く
- 利用者の我慢 2 秒。BFF が諦めても coupon-api は自分の 4 回を続ける。監視はエラー率と p99 だけ
- 700 req/s、member-api の容量 約 1,000 req/s(平常の負荷率 70%)、平常時の一時的失敗 1% 仮定
チームの案: A 指数バックオフ+ジッタを足す / B リトライを BFF だけに寄せる / C Circuit Breaker を入れる
期待する回答形式: 問題点 8 点/trigger と sustaining effect の説明/3 案の評価(数値つき)/改善後コード/テストと変異テスト/監視・止め方
ヒント(段階的開示)
ヒント1 — 方向性
ヒント2 — アプローチ
- メタステーブル障害 = きっかけが消えても、系の中に自分を過負荷に保つループが残る状態
- ループの部品は 2 つ: (a) 失敗するほど試行が増える(増幅) (b) 受け側が諦められた仕事を処理し続ける
- (a) を切る: リトライを 1 層に/予算。 (b) を切る: 締切の伝播+締切切れの破棄/キュー上限+503+Retry-After
- どれが本当に効いているかは、1 つずつ外して測らないと分からない
ヒント3 — 誘導
class RetryBudget: # gRPC A6 retryThrottling と同じ規則
def on_failure(self): tokens = max(0, tokens - 1)
def on_success(self): tokens = min(max_tokens, tokens + ratio)
def allow_retry(self): return tokens > max_tokens / 2
deadline = now + USER_PATIENCE # BFF で 1 回だけ決め、絶対時刻で下流へ
if queue_full: return 503, {"Retry-After": "0.2"}
if job.deadline <= now: drop(job) # 取り出した時点で締切切れなら DB を引かない問題点分析(8 点)
| # | 問題点 | 何が起きるか(出所つき) | 改善 |
|---|---|---|---|
| 1 | リトライが 2 層で掛け算 | 全部失敗で 1 件 → 16 件 導出・テスト 16.0±0.05。障害後 15.98 倍 実測 | リトライは BFF だけ |
| 2 | 即時リトライ | 同時刻に戻る。ただしこれだけ直しても戻らない(案 A) | Full Jitter |
| 3 | 予算なし | 失敗率が上がるほど試行が増える。平常は増幅 1.01 で気づけない 実測 | retry budget 10% |
| 4 | 締切を渡さない | 処理の 73.6% は誰も待っていなかった 実測 | 絶対時刻の締切ヘッダ |
| 5 | タイムアウトの階層が逆 | 利用者 2 秒・BFF 合計 4 秒・coupon 合計 4 秒。下ほど長く粘る | 締切 1 つを全層で共有 |
| 6 | 受け側のキュー無制限 | 待ちが全員のタイムアウトを超え、処理結果が全部捨てられる | 上限 200・満杯なら即 503 |
| 7 | Retry-After を無視 | 「0.2 秒後に」と言われても即座に戻る | 待ちの下限にする |
| 8 | 増幅を監視していない | 「直ったのに 16 倍来ている」が見えない。振り返りで説明できない | 増幅率・無駄な処理の割合 |
図(SVG)— ループの 2 つの部品と、切る場所
Bronson et al.(HotOS 2021)の枠組み: きっかけ(trigger)が消えても、sustaining effect が残れば系は弱い状態に留まる。バナーを下げて戻ったのは、ループの入口(到着)を外から細めたから。
3 案の評価(実測、seed 1)
| 構成 | 成功率(80〜128 秒) | 99% に戻った時刻 | 増幅 | 無駄な処理 |
|---|---|---|---|---|
| Bad | 0.0% | 戻らない | 15.98 | 73.6% |
| 案 A: Bad + Full Jitter | 0.0% | 戻らない | 16.02 | 73.6% |
| 案 B: リトライを BFF だけに | 0.0% | 戻らない | 4.00 | 73.6% |
| 案 C: Circuit Breaker | 未実装・未測定(開くと全員が落ちる。社内呼び出しでは締切と背圧の方が捨てる仕事を正しく選ぶ) | |||
| Good | 100.0% | 55 秒(きっかけ終了と同時) | 1.00 | 0.8% |
1 つずつ外す・2×2 で外す(何が本当に効いているか)
Good から 1 部品だけ外すと、どれを外しても戻った(予算なしは p99 が 752 → 1,046 ms、キュー上限なしは無駄な処理 0.8% → 4.7%)。仕組みが重なっているので、2 つ同時に外した。
| 予算 10% | 受け側の保護(キュー上限+締切切れ破棄) | 成功率(80〜128 秒) | 戻った時刻 | 無駄な処理 | p99 |
|---|---|---|---|---|---|
| なし | なし | 0.0% | 戻らない | 73.7% | 345 ms |
| なし | あり | 100.0% | 55 秒 | 0.6% | 1,046 ms |
| あり | なし | 100.0% | 74 秒 | 25.4% | 552 ms |
| あり | あり | 100.0% | 55 秒 | 0.8% | 752 ms |
- 1 行目: リトライを 1 層・Full Jitter・締切ヘッダ付きにしても、予算も受け側の保護も無ければ戻らない。締切を渡しても、受け側が読んで捨てなければ無駄は 73.7% のまま
- 予算だけ: 戻るが、きっかけ中に積んだ「誰も待っていない仕事」を掃くまで 19 秒間 0%
- 受け側の保護だけ: 戻るが、障害中の p99 が 1,046 ms に伸びる
- キュー上限と「締切を渡して締切切れを捨てる」は同じ役の 2 つの実装。両方外すと(予算あり・増幅 1.00 でも)65〜80 秒の成功率は 35.2%
Bad → Good(コード差分)
# common/http_client.py — BFF と coupon-api の両方が使う
@retry(stop=stop_after_attempt(4), # 2 層で 4×4=16 倍
retry=retry_if_exception_type(
(httpx.TimeoutException,
httpx.HTTPStatusError))) # 503 も 429 も即
def get_json(url: str) -> dict:
r = httpx.get(url, timeout=1.0) # 締切を渡さない
r.raise_for_status()
return r.json()
# member-api
@app.get("/members/{member_id}/rank")
def rank(member_id: str):
# 全部積む。誰も待っていなくても DB を引く
return {"rank": db.fetch_rank(member_id)}# checkout-bff だけがリトライする
deadline = started_at + USER_PATIENCE_S - MARGIN
for attempt in range(1, MAX_ATTEMPTS + 1): # 3
r = client.get(url, headers={DEADLINE_HEADER: deadline},
timeout=min(PER_TRY, deadline - now()))
if ok(r): _budget.on_success(); return r.json()
_budget.on_failure()
if not _budget.allow_retry(): break # 予算 10%
wait = uniform(0, min(CAP, BASE * 2**attempt)) # Full Jitter
wait = max(wait, retry_after(r)) # 相手の指示が下限
if now() + wait >= deadline: break
# coupon-api: 締切をそのまま渡し、1 回だけ
# member-api: 満杯なら 503、締切切れは捨てる
if waiting >= QUEUE_LIMIT: return 503, {"Retry-After": "0.2"}
async with inflight:
if now() >= deadline: return 504 # DB を引かない適用パターン: Retry Budget(retry throttling)/Exponential Backoff with Full Jitter/Deadline Propagation/Load Shedding(bounded queue + fast fail)/Backpressure(Retry-After)/リトライは 1 層だけ(Google SRE 本 22 章)。B の日なので書籍の章対応は無し。
模範解答(呼び出し側の全文)
retry_sim.py の RetryPolicy・TokenBucket・_call で実装し、そちらで測った。USER_PATIENCE_S = 2.0 # 利用者が待つ上限(購入画面の仕様)
DEADLINE_MARGIN_S = 0.1 # 返事をブラウザへ返す時間を残す
PER_TRY_TIMEOUT_S = 0.8 # 1 回の試行。締切の残りとの小さい方
MAX_ATTEMPTS = 3 # 初回を含む。gRPC A6 は既定上限 5
BACKOFF_BASE_S, BACKOFF_CAP_S = 0.05, 0.5
BUDGET_MAX_TOKENS = 10.0 # gRPC A6 retryThrottling の maxTokens
BUDGET_TOKEN_RATIO = 0.1 # 成功 10 回で再試行 1 回分が戻る
DEADLINE_HEADER = "X-Request-Deadline" # 絶対時刻。gRPC なら grpc-timeout
RETRYABLE_STATUS = frozenset({429, 502, 503, 504})
@dataclass
class RetryBudget:
"""クライアント(プロセス)単位の再試行予算。
失敗で 1 減り、成功で ratio 増える。残りが上限の半分を超えている間だけ再試行を許す。
"""
max_tokens: float = BUDGET_MAX_TOKENS
ratio: float = BUDGET_TOKEN_RATIO
tokens: float = BUDGET_MAX_TOKENS
def on_success(self) -> None: self.tokens = min(self.max_tokens, self.tokens + self.ratio)
def on_failure(self) -> None: self.tokens = max(0.0, self.tokens - 1.0)
def allow_retry(self) -> bool: return self.tokens > self.max_tokens / 2
_budget = RetryBudget() # プロセスに 1 つ。リクエストごとに作ると予算にならない
def get_coupons(client: httpx.Client, member_id: str, started_at: float) -> dict:
"""会員に使えるクーポンを取る。締切を超える試行・予算を超える再試行はしない。
Raises:
DeadlineExceeded: 締切までに成功しなかった(呼び出し元はクーポン無しで進める)。
"""
deadline = started_at + USER_PATIENCE_S - DEADLINE_MARGIN_S
for attempt in range(1, MAX_ATTEMPTS + 1):
remaining = deadline - time.time()
if remaining <= 0:
break
retry_after = None
try:
r = client.get(f"/members/{member_id}/coupons",
headers={DEADLINE_HEADER: f"{deadline:.3f}"}, # 下流は同じ締切
timeout=min(PER_TRY_TIMEOUT_S, remaining))
if r.status_code < 400:
_budget.on_success()
return r.json()
if r.status_code not in RETRYABLE_STATUS:
_budget.on_success() # 4xx は相手が健康な証拠
r.raise_for_status()
retry_after = _parse_retry_after(r.headers.get("Retry-After"))
except httpx.TimeoutException:
pass
_budget.on_failure()
if attempt == MAX_ATTEMPTS or not _budget.allow_retry():
break # 予算切れ: 弱っている下流に足さない
wait = random.uniform(0, min(BACKOFF_CAP_S, BACKOFF_BASE_S * 2 ** attempt))
if retry_after is not None:
wait = max(wait, retry_after) # 相手の指示を待ちの下限に
if time.time() + wait >= deadline:
break # 待っても締切に間に合わない
time.sleep(wait)
raise DeadlineExceeded(member_id)
受け側(member-api のミドルウェア)
QUEUE_LIMIT = 200 # ワーカー 50 本 × 平均 50 ms で約 0.2 秒分の仕事
RETRY_AFTER_S = "0.2" # キューが掃ける目安
@app.middleware("http")
async def shed_and_drop(request, call_next):
global _waiting
deadline = float(request.headers.get(DEADLINE_HEADER, "inf"))
if _waiting >= QUEUE_LIMIT:
return Response(status_code=503, headers={"Retry-After": RETRY_AFTER_S}) # 積まずに断る
_waiting += 1
try:
async with _inflight: # ワーカーの空きを待つ
if time.time() >= deadline:
return Response(status_code=504) # 待つ間に締切切れ。DB を引かない
return await call_next(request)
finally:
_waiting -= 1テストと変異テスト
$ python3 -m unittest -v test_retry_policy # 標準ライブラリだけ。Bad の通し実行を含む
test_bad_multiplies_attempts_across_layers ... ok # 全部失敗で 1 件 → 16.0 件
test_good_is_bounded_by_budget ... ok # 同条件で 1.1 件未満
test_full_jitter_spreads_retries ... ok # 待ちが U(0, 0.1) に散る
test_expired_work_is_not_executed ... ok
test_retry_after_is_a_floor_on_the_wait ... ok
test_bad_stays_down_after_trigger_ends ... ok # Bad: 65〜80 秒の成功率 5% 未満
test_deadline_drop_alone_stops_abandoned_work ... ok
test_good_recovers_after_trigger_ends ... ok
test_normal_transient_errors_are_still_absorbed ... ok # 平常 1% 失敗: Good 99.9% 超
test_retries_stop_after_half_the_tokens_are_spent ... ok
test_ten_successes_buy_back_one_retry ... ok
Ran 11 tests in 20.335s
OK
| 壊したもの | 1 回目 | 2 回目(テストを足した後) |
|---|---|---|
| M1 予算を無視 | 赤 | 赤 |
| M2 締切切れを捨てない | 緑(見逃し) | 赤 |
| M3 Retry-After を無視 | 赤 | 赤 |
| M4 締切を下流へ渡さない | 緑(見逃し) | 赤 |
| M5 キュー上限を無視 | 赤 | 赤 |
| M6 coupon-api にもリトライを戻す | 赤 | 赤 |
| M7 Full Jitter を即時再試行に | 緑(見逃し) | 赤 |
1 回目は 7 種中 3 種が生き残った。キュー上限があると、キューの待ちは最大 200 / 300 req/s ≒ 0.67 秒で試行のタイムアウト 0.8 秒より短く、締切の層は働く場面が無い。多層防御の一層が効いていると、もう一層の故障がテストに出ない。「キュー上限を外した Good でも戻る」テストと、待ち時間の分布を直接見るテストを足して全部赤にした。
迷った案と採らなかった理由
| 案 | 採らなかった理由 | 前提が変わったら |
|---|---|---|
| ジッタだけ(案 A) | 実測で戻らない(増幅 16.02)。総数が減らない | 多数のクライアントが同時刻に再送する場面(cron の一斉起動)ではジッタが主役 |
| 1 層に寄せるだけ(案 B) | 実測で戻らない(増幅 4.00、無駄 73.6%)。必要だが十分でない | 下流に 4 倍以上の余裕があれば戻る |
| Circuit Breaker(案 C) | 開くと全員落ちる。測っていない | 相手が外部で背圧を入れられないとき(9/1) |
| リトライ全廃 | 障害からは戻るが、平常 1% の失敗が利用者に出る(98.99% 対 Good 100.000%) | 利用者側で再読み込みしてもらえる画面なら |
| 予算 20%(Envoy 既定) | 障害中の追加負荷が 10% の倍。gRPC A6 の例と SRE 本の 10% に合わせた | 平常の失敗が 5% を超える下流なら上げる |
| ヘッジ | p99 は縮むが常に負荷を足す。過負荷からの回復とは逆向き | p99 が売上に直結し容量に余裕があるとき(予算と併用) |
| adaptive concurrency | 固定 200 で足りた。自動調整は振る舞いの説明が難しい | ワーカー数が頻繁に変わる(オートスケール)なら |
| サービスメッシュで一括 | Autopilot と Cloud Run をまたぎ範囲が揃わない | 全サービスが同じメッシュに乗ったら移す |
直した後に見るもの・止め方
| 指標 | 正常 | こうなったら |
|---|---|---|
| 増幅率(member-api 受信 / BFF 受信) | 1.00〜1.02(実測 1.010) | 1.2 超で設定外れの疑い、2 超で掛け算が戻った |
| 予算の残り | 上限付近 | 半分割れが 1 分続いたらアラート |
| 503(満杯)・504(締切切れ破棄) | 平常 0 | 平常時に出るなら容量不足 |
| 無駄な処理の割合 | 1% 未満(実測 0.8%) | 5% 超で締切の伝播が切れている |
| 回復時間(きっかけ終了 → 99%) | 秒単位 | 1 分超ならループが残っている |
OpenTelemetry のスパンに http.request.resend_count を付けると、トレース 1 本の中で下流に何回届いたかが数えられる。
ポイント解説
- メタステーブル障害は「原因」を探しても見つからない: きっかけは 15 秒で消えている。振り返りは trigger と sustaining effect を分けて書く
- リトライの増幅は層の数の指数: 2 層で 16 倍、3 層で 64 倍(SRE 本 22 章の 4^3)
- ジッタは総数を減らさない: 過負荷を減らすのは予算
- 受け側は誰も待っていない仕事をしない: 締切を絶対時刻で渡して捨てる/満杯なら断る
- 多層防御の一層が効いていると、もう一層の故障はテストに出ない: 層ごとに単独で検査する
扱わなかった範囲(止め方)
- 本番での実測: 離散イベントのモデルのみ。Cloud Run のオートスケール・コネクションプール・TCP 再送・GC は入っていない。秒数は変わりうる
- Circuit Breaker: 文章で評価しただけ。シミュレータに入れていない
- 書き込み(POST)のリトライ: 冪等キーが要る。10/3 で扱った
- 別のきっかけ: 「速いエラー」(50% が即 500)や流入の急増は測っていない。速いエラーでは受け側の保護が働かず、予算だけが増幅を止める → 発展問題
- クライアントが多数のとき: 予算はインスタンスごと。合計が増える。Envoy はクラスタ単位
一次資料(参照日 2026-10-06): gRPC Proposal A6(retryThrottling の規則・maxAttempts 既定上限 5、本日確認)/Google SRE 本 22 章 Addressing Cascading Failures(4^3=64・締切伝播、本日確認)/Envoy RetryBudget(budget_percent 既定 20%・min_retry_concurrency 既定 3、本日確認)/Huang et al. OSDI 2022(22 事例・AWS 主要障害 15 件中少なくとも 4 件、本日確認)/Bronson et al. HotOS 2021・Brooker の Builders' Library 記事と 2015 年の Jitter 記事(本日は本文を取得できず、記憶に基づく要約)
発展: きっかけを「15 秒間 50% の確率で即 500」に変え、予算なし・あり・全廃で到着量と成功率を測り、予算の比率(5/10/20%)を決めよ。
今日のまとめ
DB が直っても戻らなかったのは、2 層のリトライが負荷を 16 倍にし、受け側が誰も待っていない仕事を処理し続けるループが残っていたから。ジッタだけ(16.02 倍)でも 1 層に寄せるだけ(4.00 倍)でも戻らない。呼び出し側の予算と受け側の「締切切れを捨てる・満杯なら断る」でループを切ると、成功率は 0% → 100%、きっかけが消えた瞬間に戻った(シミュレータでの実測、seed 1〜3 で同じ)。