B システム設計/インフラ — 原因は 15 秒で消えたのに戻らない: 3 層のリトライが負荷を 16 倍にするメタステーブル障害を、予算・締切・受け側の背圧で切る

2026-10-06 火曜 火曜 B: システム設計/インフラ ★★★★★ Cloud Run / GKE / 信頼性設計 retry budget / deadline propagation / load shedding

概要

数値はすべて自作の離散イベントシミュレータ(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 分で戻った 架空の前提

この構成には 8 つの問題 が隠れています。見つけてから先へ進んでください。
  • 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 — 方向性
利用者 1 人のリクエストが、最悪のとき member-api に何件届くかを数える。BFF 4 回 × coupon-api 4 回。しかも member-api は「もう誰も待っていない要求」も処理する。容量 1,000 req/s のところに何件来るか。
ヒント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
7Retry-After を無視「0.2 秒後に」と言われても即座に戻る待ちの下限にする
8増幅を監視していない「直ったのに 16 倍来ている」が見えない。振り返りで説明できない増幅率・無駄な処理の割合

図(SVG)— ループの 2 つの部品と、切る場所

続けさせているもの(sustaining effect): 2 つの部品でできたループ member-api の待ち時間が伸びる(きっかけ: 15 秒の容量低下) 試行が時間切れ → 再試行(a) 増幅: 2 層で 16 倍 到着が容量の 11 倍に容量が戻っても超えたまま 誰も待っていない仕事を処理(b) 無駄: 処理の 73.6% ✂ 予算 10%リトライは 1 層だけ ✂ 締切を渡して捨てる✂ キュー上限+503 緑の ✂ のどちらか一方でループは切れる(2×2 の実測)。両方入れるのは、片方が外れても戻るようにするため。

Bronson et al.(HotOS 2021)の枠組み: きっかけ(trigger)が消えても、sustaining effect が残れば系は弱い状態に留まる。バナーを下げて戻ったのは、ループの入口(到着)を外から細めたから。

3 案の評価(実測、seed 1)

構成成功率(80〜128 秒)99% に戻った時刻増幅無駄な処理
Bad0.0%戻らない15.9873.6%
案 A: Bad + Full Jitter0.0%戻らない16.0273.6%
案 B: リトライを BFF だけに0.0%戻らない4.0073.6%
案 C: Circuit Breaker未実装・未測定(開くと全員が落ちる。社内呼び出しでは締切と背圧の方が捨てる仕事を正しく選ぶ)
Good100.0%55 秒(きっかけ終了と同時)1.000.8%
障害後の増幅(member-api 到着 / 利用者)。点線 = 容量で耐えられる上限 1,000/700 ≒ 1.43 Bad15.98 案 A ジッタ16.02 案 B 1 層4.00 Good1.00 ジッタは総数を減らさない。1 層に寄せても 4 倍は容量を超える。

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(コード差分)

Bad
# 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)}
Good
# 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 の日なので書籍の章対応は無し。

模範解答(呼び出し側の全文)

下は設計の写像で、本番の httpx では実行していない。同じ方針を 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 本の中で下流に何回届いたかが数えられる。

ポイント解説

  1. メタステーブル障害は「原因」を探しても見つからない: きっかけは 15 秒で消えている。振り返りは trigger と sustaining effect を分けて書く
  2. リトライの増幅は層の数の指数: 2 層で 16 倍、3 層で 64 倍(SRE 本 22 章の 4^3)
  3. ジッタは総数を減らさない: 過負荷を減らすのは予算
  4. 受け側は誰も待っていない仕事をしない: 締切を絶対時刻で渡して捨てる/満杯なら断る
  5. 多層防御の一層が効いていると、もう一層の故障はテストに出ない: 層ごとに単独で検査する

扱わなかった範囲(止め方)

  • 本番での実測: 離散イベントのモデルのみ。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 で同じ)。

自己評価(あとで記入)