飛ばない豚
記事一覧へ戻る
この記事を読むのにかかる時間:約9分

マイクロサービスアーキテクチャにおけるリトライ設計 - Uber の事例

IT#アーキテクチャ / マイクロサービス

Uberの技術ブログの記事How Uber Protects Against Retry Stormsでは、マイクロサービスの呼び出しチェーンでリトライが増幅する問題と、それを抑えるためのエラー所有権(Error Ownership)という仕組みが紹介されている。

リトライは、一時的な通信障害や低頻度のエラーから回復するための一般的な手段である。一方で、複数のサービスが同じエラーに対してそれぞれリトライすると、障害中のサービスへ大量のリクエストが送られる。この現象はリトライストーム(Retry Storm) と呼ばれる。

リトライが増幅する仕組み

次のように、4つのサービスが直列に呼び出される構成を考える。

A → B → C → D

Dがエラーを返し、すべてのサービスが下流への呼び出しを1回リトライするとする。ここで「1回リトライする」とは、初回の呼び出しとリトライを合わせて、各エッジで最大2回試行するという意味である。

CはDを最大2回呼び出す。Cへの呼び出しが失敗した場合、BもCを最大2回呼び出す。それぞれのCへの呼び出しでDが2回呼ばれるため、1回のBの処理からDへは最大4回のリクエストが届く。さらに、AもBを最大2回呼び出すため、最終的にDへのリクエストは最大8回になる。

AがBを呼ぶ(初回)
├─ BがCを呼ぶ(初回)
│ ├─ CがDを呼ぶ(初回) → 失敗
│ └─ CがDを呼ぶ(リトライ) → 失敗
└─ BがCを呼ぶ(リトライ)
├─ CがDを呼ぶ(初回) → 失敗
└─ CがDを呼ぶ(リトライ) → 失敗
AがBを呼ぶ(リトライ)
├─ BがCを呼ぶ(初回)
│ ├─ CがDを呼ぶ(初回) → 失敗
│ └─ CがDを呼ぶ(リトライ) → 失敗
└─ BがCを呼ぶ(リトライ)
├─ CがDを呼ぶ(初回) → 失敗
└─ CがDを呼ぶ(リトライ) → 失敗

各サービスが処理するリクエスト数は次のようになる。

サービス 処理するリクエスト数
A 1
B 2
C 4
D 8

Dまでに3つの呼び出しエッジがあり、それぞれで最大2回試行するため、Dへの呼び出し回数は次の式で表せる。

2 × 2 × 2 = 2³ = 8

一般化すると、各エッジのリトライ回数をr、障害箇所までの深さをdとすると、最大試行回数は(1 + r)ᵈになる。呼び出しチェーンが深くなるほど、下流サービスへの負荷は急速に増加する。

リトライ予算だけでは解決できない問題

リトライストームを抑える方法の一つが リトライ予算(Retry Budget) である。リトライ予算は、追加のリトライを一定割合までに制限する仕組みである。

たとえば各サービスのリトライ予算を10%とすると、基準となる通常時リクエスト数をNとして、Bは最大1.1N、Cは最大1.21N、Dは最大約1.33Nのリクエストを処理する。リトライ予算による制限がなければ、Dに8Nのリクエストが届くため、リトライ予算だけでも増幅を大きく抑えられる。

ただし、リトライ予算が制御するのは「どれだけリトライできるか」であり、「どのサービスがリトライすべきか」ではない。Dで発生したエラーをC、B、Aが順番に受け取ったとき、それぞれのサービスから見ると、直下のサービス(Aから見たB、Bから見たC)がエラーを返したように見える。そのため、各サービスが自分のリトライ予算を使って同じ障害に対するリトライを実行する可能性が残る。

原文では、すべての区間に10%のリトライ予算を設定する場合と、エラー発生元の直前にあるCからDへの区間だけでリトライする場合を比較している。両者の違いは次のようになる。

方式 A B C D
制限なしで各区間が1回リトライ N 2N 4N 8N
各区間に10%のリトライ予算 N 1.1N 1.21N 1.33N
CからDへの区間だけ10%リトライ N N N 1.1N

すべての区間にリトライ予算を設定した場合、Dには基準の約1.33倍の負荷がかかる。CからDへの区間だけに限定すれば、Dの負荷は1.1倍に収まる。

つまり、リトライ予算は無制限な増幅を抑えるが、エラーがそのサービス自身から生じたのか、下流から伝搬してきたのかは判断できない。必要なのは、各区間で少しずつリトライすることではなく、回復を期待できる区間にリトライを集約することである。

エラー所有権によるリトライ箇所の限定

ここで使えるのが、エラーの発生元のサービスを判別する エラー所有権(Error Ownership) の発想である。

あるサービスがリクエストを処理するとき、下流への呼び出しがすべて成功しているにもかかわらず自身がエラーを返すなら、そのサービスがエラーの原因であり、エラーの所有を主張する(claim)。一方、下流のエラーが原因で自身もエラーを返すなら、自身のエラーは原因ではなく症状である。その場合はエラーの所有を否定して(unclaim)、上流へ返す。

A → B → C → Dの例で、Dの内部処理に失敗した場合の流れは次のようになる。

  1. Dは、自身が発生元であることを示してエラーの所有を主張する。
  2. Cは所有が主張されたエラーを受け取り、リトライ予算の範囲でDをリトライする。
  3. リトライ後も失敗した場合、Cは自分が発生元ではないため、エラーの所有を否定してBへ返す。
  4. Bは所有が否定されたエラーを受け取るため、Cをリトライしない。

これにより、回復の可能性があるDの直前ではリトライを許可しながら、BやAによる重複したリトライを止められる。

エラー所有権を伝えるレスポンス

記事では、エラー所有権をx-uber-error-claimというレスポンスヘッダーで伝える方式が説明されている。

Dが自身のエラーについて所有を主張するレスポンスは、概念的には次のようになる。

HTTP/1.1 500 Internal Server Error
Content-Type: application/json
x-uber-error-claim: claimed
{
"error": "internal_server_error",
"message": "Failed to process the request"
}

Cが同じエラーをBへ伝搬するときは、次のように所有を否定する。

HTTP/1.1 500 Internal Server Error
Content-Type: application/json
x-uber-error-claim: unclaimed
{
"error": "downstream_service_error",
"message": "A downstream request failed"
}

ここで使用している「所有を主張済み」を表すclaimedと、「所有を否定済み」を表すunclaimedという値やJSONボディは説明用の例であり、Uberの実際のレスポンス形式を示すものではない。原文から確認できるのは、x-uber-error-claimヘッダーの有無と、その値に基づいてリトライを判断する点である。

リトライによる可用性の改善

リトライを制限しすぎると、今度は可用性の観点で問題になる。

1回の呼び出しの成功率が90%なら、失敗率は10%である。初回とリトライの結果が互いに独立していると仮定すると、両方とも失敗する確率は次のようになる。

0.1 × 0.1 = 0.01

したがって、少なくとも一方が成功する確率は99%である。

1 - (0.1 × 0.1) = 0.99

同じ結果は、初回で成功する確率と、初回に失敗してリトライで成功する確率を足しても求められる。

0.9 + (0.1 × 0.9) = 0.99

ただし、この計算は初回とリトライの失敗が独立している場合に限られる。一時的なネットワークエラーであれば、次の試行が成功する可能性はある。一方、サービスやデータベースの過負荷、特定ホストの障害、リクエスト内容そのものの不備では、何度リトライしたところで、同じ理由で失敗しやすい。このような状況でリトライを増やしても、可用性は計算どおりには改善せず、障害中のサービスに追加の負荷を与えることになる。

実運用上の例外への対応

すべてのサービスがエラー所有権に対応しているとは限らない。所有権ヘッダーがない場合、最初にそのエラーを受け取ったサービスはリトライを許可するが、上流へ返す際には所有を否定する。これにより、未対応のサービスが含まれていても、リトライの増幅範囲を限定できる。

また、呼び出しチェーンのどこにもリトライが設定されていなければ、上流から見た可用性が低下する可能性がある。Uberの仕組みでは、既存のリトライ設定のうち少なくとも一か所で試行機会が確保されたかを伝搬し、最低1回のリトライ機会を維持する。新しいリトライを追加するのではなく、設定済みのリトライをどこで使うかを調整する方式である。

サービス内部でコンテキストの伝搬が切れ、受信リクエストと下流呼び出しを関連付けられない場合、そのサービスが誤ってエラーの所有を主張することもある。この場合はリトライ可能な境界が一段上流へ移るが、さらに上流へ返すときには所有が否定されるため、チェーン全体への増幅は避けられる。

Uberでの導入結果

この仕組みは、Uberのユーザー向けAPIを処理するサービスメッシュの共通基盤に導入されている。

2025年11月18日に発生した大規模障害では、呼び出しチェーンの5段以上深い位置にある重要サービスが高いエラー率を返した。もしリトライ予算だけを導入していた場合、障害中のサービスに対するトラフィックが46〜135%増加していた可能性がある。エラー所有権によって、直接の呼び出し元では最大20万件、サービスメッシュ全体では約950万件の不要なリトライを抑制できたという。

まとめ

マイクロサービスにおけるリトライ設計では、回数や間隔を制御するだけでなく、どのサービスがリトライするかを決める必要がある。呼び出しチェーンの各サービスが同じエラーを個別にリトライすると、下流へのリクエスト数は乗算され、局所的な障害がシステム全体へ広がりやすくなる。

Uberのエラー所有権は、エラー発生元に最も近い場所にのみリトライを限定する仕組みである。リトライ予算と組み合わせることで、回復のための試行機会を残しつつ、同じ障害に対する重複した(無駄な?)リトライを抑制できる。

参考資料