CTS-KB
インフラ

リトライの掛け算 — Job・Workflow・Pub/Sub を素直に足すと、直したはずの罠が半年後に一段外側で再発した

⏱ 約 7 分で読めます
#GCP#Cloud Run#Pub/Sub#Workflows#リトライ設計#冪等性#SAGA#技術的負債

📖 はじめに — リトライは足し算ではなく掛け算で効く

分散システムでリトライを設計するとき、「念のためこの層にもリトライを足しておこう」は一見無害に見える。だが層を跨いだリトライは足し算ではなく掛け算で効く。CTS-EC の AI マッピング SAGA では、この掛け算がまず 2026年4月に障害として顕在化し、その修正から半年も経たない6月に、同じ形の罠が一段外側で再発した。本稿はこの 2 つの事件を通して、「リトライを追加する」という一見安全な変更が、既にある層と衝突して危険になり得るという教訓を記録する。

🧮 事件1(4月8日)— 3層のリトライがバラバラに動き、掛け算になった

カテゴリマッピング SAGA のバッチ処理で、複数チャンクが失敗した。原因を追うと、1 つのジョブ実行を包む3つのリトライ層が、互いを意識せずバラバラに設定されていた。

🧮 事件1(4月8日)— 3層のリトライがバラバラに動き、掛け算になった

Layer3: GCP Workflow標準リトライ述語

Layer2: Cloud Run JobmaxRetries

Layer1: アプリ内リトライ処理

Gemini API 呼び出し

設定問題
Layer1(アプリ内)429 のみリトライ503 はリトライ対象外で即死
Layer2(Cloud Run maxRetries7 ジョブ中 5 つが maxRetries=1ジョブ間で設定がバラバラ
Layer3(Workflow)GCP Workflows の標準リトライ述語(HTTP エラーコードのみ判定)Job の exit(1) による失敗を検知できず実質無効

設計文書に残っていた試算がそのまま核心を突いている。

maxRetries=3 + Workflow retry → (3+1)×(2+1) = 12回実行の可能性、コスト爆発

Layer2 と Layer3 は「相手が制御している」つもりで両方が制御しておらず、外側からもう一段リトライが重なることで、1回の論理的な処理が最大12回まで実行され得る状態になっていた。

実害はコストだけでは済まなかった

同日、この掛け算がもたらした実害の一例が見つかっている。

  1. Gemini 503 エラー → アプリが SAGA に failure を通知 → exit 1
  2. Cloud Run が maxRetries=1 で裏側から自動リトライ → 今度は成功
  3. リトライされた実行が SAGA に success を通知
  4. だが .NET 側の SAGA は手順①の failure で既に Failed 確定済み
  5. 冪等ガードが「もう Failed になっている」を理由に③の success を重複として無視
  6. 結果: 処理は実際には成功しているのに、SAGA は Failed のまま記録される

裏で自動リトライが黙って成功しても、その結果を伝える手段がなければ「実は成功」は闇に葬られる。

対処 — 3層を1つに統合する

是正は「3層リトライ戦略」への統一だった。

  • Layer1(アプリ内、秒オーダー): 429/500/503 を明示的にリトライ、線形バックオフ
  • Layer2(Cloud Run): 全7ジョブで maxRetries=0 に統一。ジョブ自身は二度と自分でリトライしない
  • Layer3(Workflow): 標準のリトライ述語を、Job の失敗を明示的に判定する自作の述語に置き換え、最大リトライ2回・バックオフ 30秒→120秒×2 で一元管理

リトライ権限を Workflow 1 層に集約し、**「新規 Job で maxRetries >= 1 を設定してはならない」**という禁止ルールを恒久文書(設計仕様の障害対応セクション)に明文化した。70件のテストで検証し、本番へ反映。

🔁 事件2(6月22日)— 同じ形の罠が、一段外側で再発した

4月の是正で「リトライは Workflow の1層だけ」というルールは徹底されたはずだった。ところが半年も経たないうちに、構造的に同じ罠が発覚する。今度の舞台は Workflow のさらに外側、Pub/Sub(Eventarc)だった。

🔁 事件2(6月22日)— 同じ形の罠が、一段外側で再発した

Job 失敗

Workflow の失敗処理分岐

SAGA へ failure 通知

Workflow 自体を失敗として終了させる

Eventarc push サブスクリプションへの応答が失敗扱い

Pub/Sub がメッセージを再配信

Workflow + Job が丸ごと再実行(Layer3 のリトライ述語とは別枠)

2回目が成功 → SAGA へ success 通知

SAGA は Failed 確定済み

冪等ガードが後発の success を『重複』として破棄

原因は、Workflow の失敗処理が 2 つのことを同時にやっていたことだ。

  • .NET 側の SAGA へ「このジョブは失敗した」と伝える
  • Workflow の実行自体を失敗として終わらせる

後者が、Eventarc push サブスクリプションから見ると「配信失敗」に見えてしまう。Pub/Sub は律儀にメッセージを再配信し、Workflow と Job がまるごと再実行される。これは Layer3 のリトライ述語によるリトライとは別枠のリトライであり、4月に「Workflow に一元化した」はずのリトライ制御のさらに外側に、意図しないリトライ経路が存在していたことになる。

STG 環境で実際に発生した例(あるチャンクの処理)はこうだ。

  1. 1回目の実行が失敗 → SAGA が Failed に確定
  2. Eventarc がメッセージを再配信
  3. 2回目の実行は成功
  4. 冪等ガードは「真の重複配信(同じ結果の再送)」と「リトライが生んだ新しい結果(失敗→成功)」を区別できず、後者も握り潰す
  5. 本当は成功しているのに、SAGA は Failed のまま永続化される

事件1と骨格は同じだ。「裏でリトライが起きて実は成功した」という事実を、確定済みの失敗ステータスと冪等ガードが握り潰す。違うのは、今回のリトライが Layer3 のさらに外側、Pub/Sub の再配信という設計上ノーマークだった経路から来ていたことだ。

対処 — 「notify-then-ack」で失敗の伝え先を1つに絞る

採用された解決策は、Workflow が Eventarc に対して失敗を伝えることを禁止するという方針転換だった(社内では “notify-then-ack” と呼ばれている)。

  • SAGA への失敗通知はそのまま残す(.NET 側への通知がこれが唯一の正)
  • Workflow を失敗として終了させる処理は使わない。代わりに明示的にジョブ失敗の事実を返しつつ、Workflow の実行自体は正常終了として扱う
  • 結果として Eventarc には常に成功応答(ACK)が返り、Pub/Sub の再配信は起きなくなる

原則を1行にすると、「失敗の Single Source of Truth は .NET 側 SAGA。Workflow は Eventarc に対して絶対に失敗を伝えない」。十数個の Workflow に適用し、STG→PROD へ展開された。

興味深いのは、検討段階で「Workflow を失敗させる処理を単純に削除するだけ」の案が却下されている点だ。削除しただけだと成功時の戻り値処理に迷い込み、そこで未定義の変数を参照して新しいエラーを起こし、結局同じ再配信ループが再発することが判明した。「危険なコードを消す」だけでは安全にならず、消した後に何が起きるかまで確認する必要があったという記録が残っている。

🪞 2つの事件を並べて見えること

事件1(4月)事件2(6月)
掛け算の相手Cloud Run maxRetries × Workflow retryWorkflow の失敗シグナル × Pub/Sub 再配信
気づかれにくい理由各層は「自分は正しく設定されている」つもりだった4月の是正で「リトライは Workflow に一元化済み」と思われていた
実害の形裏で成功していたのに失敗のまま記録裏で成功していたのに失敗のまま記録(同じ)
対処の方向性リトライ実行を1層に集約するリトライを誘発する失敗シグナルを1本に絞る

2つの事件は独立した偶然ではない。「ある層のリトライを制御した」つもりでも、その層の失敗の伝え方次第で、さらに外側の層が勝手にリトライを始めるという同じ形の罠が、層を1つ変えて再演されている。4月の教訓が「リトライの実行主体を1つに絞れ」だったのに対し、6月の教訓は「リトライを引き起こす失敗シグナルの送り先も1つに絞れ」だった。前者を直しても後者が残っていれば、罠は場所を変えて再発する。

🧾 まとめ

  1. リトライは足し算ではなく掛け算で効く。 独立に設定された層は (内側の試行回数) × (外側の試行回数) で実行数が膨れ上がる。層ごとに「自分は妥当な設定」でも、掛け合わせた瞬間に破綻し得る
  2. 「裏で成功した」という事実は、伝える手段がなければ握り潰される。 冪等ガードは「同一結果の再配信」を捌くようにはできていても、「失敗確定後にリトライが生んだ新しい成功」までは区別できないことが多い
  3. リトライ層を1つに統合しても、失敗シグナルの伝わり先まで統合しないと、罠は外側で再発する。 「このエラーは誰に伝わり、その相手はそれをどう解釈してリトライを始め得るか」を、変更のたびに一段外まで確認する価値がある

📚 関連記事