レジリエンス設計パターン 入門 - タイムアウト・リトライ・指数バックオフ・サーキットブレーカー

レジリエンス設計パターン 入門 - タイムアウト・リトライ・指数バックオフ・サーキットブレーカー

作成日:
読了:17
更新日:
この記事を読む人におすすめPR / Amazonアソシエイト

当サイトは Amazon.co.jp を宣伝しリンクすることで紹介料を得る手段を提供する、Amazonアソシエイト・プログラムの参加者です。価格・在庫はリンク先の最新情報をご確認ください。

マイクロサービスや外部API連携では、処理の多くがネットワーク越しの呼び出しになります。ネットワークは遅延し、パケットは落ち、相手のサーバーは過負荷や再起動で応答しなくなる——つまりリモート呼び出しは必ず失敗しうる、というのが分散システムの前提です。しかも全体ではなく一部だけが壊れる「部分障害(partial failure)」が常態で、1つの下流の遅延が呼び出し側のスレッドを食い潰し、連鎖的に全体を巻き込むことがあります。

この記事のテーマは、その連鎖からクライアント(呼び出し側)が自分を守るための設計パターンです。サーバー側が過剰なアクセスから自分を守るレート制限と対になる考え方で、レート制限が「入ってくる負荷を絞る」防御なら、レジリエンスパターンは「呼び出した先が壊れているときに自分が道連れにならない」防御と言えます。タイムアウト・リトライ・指数バックオフ・サーキットブレーカー・バルクヘッド・フォールバックという古典的な道具を順に見ていきます。

タイムアウト - 無限に待たないこと

最悪の障害は「エラーが返ること」ではなく、いつまでも応答が返らないことです。タイムアウトのない呼び出しは、下流が固まった瞬間に呼び出し側のスレッドやコネクションを無期限に占有します。それが積み重なるとプールが枯渇し、健全なリクエストまで処理できなくなります。

タイムアウトには大きく2種類あります。

  • 接続タイムアウト(connect timeout): TCP接続を確立できるまでの上限。相手が落ちている・到達不能なケースを早く切る。
  • 読み取りタイムアウト(read / socket timeout): 接続後、レスポンス(の次のバイト)を待つ上限。相手が受け付けたのに処理が遅いケースを切る。

さらに重要なのがデッドラインの伝播(deadline propagation)です。ユーザーからのリクエスト全体に「あと何ミリ秒で応答すべきか」という締め切りを持たせ、下流を呼ぶときは残り時間から逆算した値をタイムアウトとして渡します。gRPC の deadline のように、締め切りを呼び出しの連鎖でリレーしていく考え方です。

これに関連するのがタイムアウト予算(timeout budget)という発想です。たとえば全体の締め切りが 1 秒なら、内部でA→B→Cと直列に呼ぶ各段のタイムアウトの合計が予算を超えてはいけません。子の合計が親を上回ると、親がとっくに諦めた後も子が延々と作業を続ける(無駄仕事)ことになります。

NOTE

タイムアウトは「速すぎ」も問題です。正常な処理時間より短く設定すると、成功しかけた呼び出しを打ち切って不要なリトライを誘発します。実測のレイテンシ分布(p99 など)を見て決めるのが基本です。

リトライと冪等性 - 何を再送してよいか

一時的な失敗(瞬断、パケットロス、相手の一過性の過負荷)はリトライで回復できます。しかしすべての失敗をリトライしてよいわけではありません。判断の軸は2つです。

第一に失敗の種類。ネットワークエラーや 503 Service Unavailable429 Too Many Requests は一過性の可能性が高くリトライ向きです。一方 400 Bad Request401404 のような恒久的なエラーは、何度送っても結果は同じなのでリトライすべきではありません。429503 にサーバーが Retry-After ヘッダを付けてきたら、その値を尊重して待つのが作法です(HTTPステータスコードの記事も参照)。

第二に操作の冪等性(idempotency)です。冪等とは「同じ操作を複数回実行しても結果が1回のときと変わらない」性質を指します。HTTPメソッドでは GET PUT DELETE は仕様上冪等とされ、安全にリトライできます。問題は POST で、これは「注文を作成する」のように非冪等なことが多く、素朴にリトライすると二重登録・二重課金を起こします。

対策は、クライアントが生成した冪等キー(idempotency key)をリクエストに付け、サーバー側で「同じキーの処理は1回だけ実行し、2回目以降は最初の結果を返す」ようにすることです。設計の詳細はREST APIの冪等性の記事にまとめています。配信側がリトライするWebhook設計でも、受信側の冪等な処理は必須です。

WARNING

「タイムアウトした呼び出し」は成功か失敗か分かりません。リクエストは相手に届いて処理された可能性があります。だからこそ、非冪等な操作の安易なリトライは危険で、冪等キーなしにやってはいけません。

指数バックオフとジッター - リトライストームを避ける

リトライで一番やってはいけないのが固定間隔での即時リトライです。下流が一時的に落ちると、全クライアントが同じ間隔で一斉に再送します。これがリトライストーム / サンダリングハード(thundering herd)で、回復しかけたサーバーに同期した波が繰り返し押し寄せ、いつまでも立ち上がれなくなります。

第一歩は指数バックオフ。待ち時間を試行ごとに倍々にし、上限(cap)で頭打ちにします。

backoff = min(cap, base * 2 ** attempt)

しかしこれだけでは、失敗した瞬間が揃っていると各クライアントの待ち時間も揃い、波が残ります。そこでジッター(jitter, ゆらぎ)を加えて発火時刻をばらけさせます。AWS の Architecture Blog「Exponential Backoff And Jitter」が定番の出典で、そこで比較されている代表的な2方式は次のとおりです。

Full Jitter(記事で示される式):

sleep = random_between(0, min(cap, base * 2 ** attempt))

上限をバックオフ値とし、0からその間の一様乱数で待ちます。待ち時間の分散が最も大きく、発火が最も散らばります。

Equal Jitter: バックオフ値の半分を固定の待ちとして残し、残り半分をランダムにする方式です(おおよそ 半分 + random(0, 半分) の形)。「まったく待たない」ケースを避けたいときに使われます。

同記事は、full jitter と equal jitter のいずれもジッターなしの指数バックオフに比べてクライアントの仕事量とサーバー負荷を大きく減らすとし、full jitter は仕事量がより少ない一方で所要時間はわずかに増える、と述べています。実装イメージは次のような形です。

指数バックオフ + full jitter
import random, time
 
def call_with_retry(fn, max_attempts=5, base=0.1, cap=10.0):
    for attempt in range(max_attempts):
        try:
            return fn()
        except RetriableError:
            if attempt == max_attempts - 1:
                raise
            # full jitter: 0 〜 min(cap, base*2^attempt) の一様乱数
            delay = random.uniform(0, min(cap, base * (2 ** attempt)))
            time.sleep(delay)

リトライ回数の上限も必ず設けます。無制限リトライは、部分障害を全体障害に育てるアンチパターンです。

サーキットブレーカー - 壊れた相手を呼び続けない

バックオフを入れても、下流が長時間ダウンしているのに呼び続ければ、毎回タイムアウトぶんの時間とリソースを浪費します。電気回路のブレーカーのように、異常を検知したら回路を開いて(呼び出しを遮断して)即座に失敗させ、相手にも自分にも回復の余地を与えるのがサーキットブレーカーです。Michael T. Nygard の『Release It!』で広く知られ、Martin Fowler の "CircuitBreaker" 記事が準一次の解説として参照されます。

状態は3つです。

  • Closed(閉): 正常。呼び出しはそのまま下流に通す。失敗をカウントし、閾値を超えたら Open へ。
  • Open(開): 遮断中。下流を呼ばず即座に失敗(またはフォールバック)を返す。リセットタイムアウト経過後に Half-Open へ。
  • Half-Open(半開): 試験中。試しに1回(または少数)だけ通す。成功なら Closed に戻し、失敗なら再び Open に落として時計をリセット。

Fowler の解説では、失敗数が閾値以上かつ最後の失敗からリセットタイムアウトを過ぎると Half-Open に移り、そこでの試行が成功すれば失敗カウンタをリセットして Closed に戻る、という時間ベースのゲート制御として説明されています。状態遷移を図にすると次のようになります。

Loading diagram...

擬似コードの骨子は次のとおりです。

サーキットブレーカー(概念)
def call(self, fn, now):
    if self.state == "OPEN":
        if now - self.opened_at >= self.reset_timeout:
            self.state = "HALF_OPEN"      # 試験を許可
        else:
            raise CircuitOpenError()       # 即座に遮断
    try:
        result = fn()
    except Exception:
        self.failures += 1
        if self.state == "HALF_OPEN" or self.failures >= self.threshold:
            self.state = "OPEN"            # 開いて時計をリセット
            self.opened_at = now
        raise
    # 成功
    if self.state == "HALF_OPEN":
        self.state = "CLOSED"
        self.failures = 0
    return result

閾値は「連続失敗数」より「一定ウィンドウ内の失敗率」で判断する実装が多く、リセットタイムアウトは下流の回復に必要な時間を見て決めます。Half-Open で一気に全トラフィックを流すと再び潰すので、少数だけ試すのが肝心です。

バルクヘッド - 障害の連鎖を隔壁で止める

バルクヘッド(bulkhead, 隔壁)は船の防水区画に由来します。船体をいくつもの区画に仕切っておけば、一区画に浸水しても船全体は沈みません。ソフトウェアでも、リソースを用途ごとに分離して1つの障害が全体に波及しないようにします。

典型はスレッドプールやコネクションプールの分離です。下流サービスAとBを1つの共有プールで呼んでいると、Aが遅延してプールを食い尽くした瞬間、Bへの健全な呼び出しまで待たされます。AとBに別々のプールを割り当てれば、Aが詰まってもBは生き残ります。同時実行数の上限(セマフォ)で区画を仕切るのも同じ発想です(排他制御・デッドロックの記事も参照)。サーキットブレーカーが「壊れた相手を呼ばない」時間軸の防御なら、バルクヘッドは「壊れた相手にリソースを取られない」空間軸の防御です。

フォールバックとグレースフルデグラデーション

遮断や失敗が起きたとき、ユーザーに素のエラーを返すのではなく縮退運転(graceful degradation)で応じられると、体感の可用性は大きく上がります。その手段がフォールバックです。

  • キャッシュ済みの古い値を返す(多少古くても無いよりまし)。関連してCDNキャッシュの記事も。
  • 安全なデフォルト値を返す(例: レコメンドが落ちたら人気商品一覧で代替)。
  • 機能の一部を無効化して主要機能だけ提供する(例: 決済は生かし、レビュー表示は一時停止)。

重要なのは、フォールバックが意図的な設計判断であることです。「何を落としてよく、何は絶対に落とせないか」を事前に決めておくと、障害時に慌てずに済みます。

パターンの組み合わせと順序

これらは単体ではなく重ねて使います。呼び出し1回の外側から内側へ、おおむね次の順で包むのが定石です。

  1. バルクヘッドで使えるリソースを区画に限定する。
  2. サーキットブレーカーで相手が壊れていれば即座に遮断する。
  3. リトライ(指数バックオフ+ジッター)で一過性の失敗だけ再送する。
  4. タイムアウトで1回ごとの呼び出しを打ち切る。
  5. すべて駄目ならフォールバックで縮退応答を返す。

ここで注意すべきがリトライ増幅(retry amplification)です。多段のサービス(A→B→C)でそれぞれが独立に3回リトライすると、最下流Cには最大 3×3=9 倍の負荷が届きえます。対策として、リトライはできるだけ入口側(1箇所)に寄せる、各段でリトライ回数の合計を予算として管理する、サーキットブレーカーと組み合わせて壊れている間はそもそもリトライしない、といった設計が有効です。過負荷時にサーバー側が一部を早期に捨てるロードシェディングや、ロードバランシングによる分散もあわせて効いてきます。

実装の選択肢と可観測性

主要なライブラリ・基盤には次のようなものがあります(機能の概要のみ。細かい仕様やバージョンは各公式を参照してください)。

  • Resilience4j(Java): サーキットブレーカー・リトライ・バルクヘッド・レートリミッタ等をモジュールとして提供。Netflix Hystrix の後継的な位置づけとされ、Hystrix 公式は「もはや活発な開発はしておらずメンテナンスモード」であり、新規プロジェクトには resilience4j のような活発なプロジェクトの利用を勧める、と表明しています。
  • Polly(.NET): リトライ・サーキットブレーカー・タイムアウト・バルクヘッド・フォールバックをポリシーとして組み合わせられるライブラリ。
  • Envoy / サービスメッシュ: プロキシ層でリトライやアウトライア検知(outlier detection)によるサーキットブレーキングをアプリ非依存に設定できるとされます。
  • gRPC: デッドライン伝播や(設定に応じた)リトライポリシーを標準的に扱えます。
  • 各クラウドのSDK: AWS SDK などは指数バックオフ+ジッターを含むリトライを既定で備えるとされ、再試行モードや最大試行回数を設定できます。

そして忘れてはいけないのが可観測性です。サーキットブレーカーの状態、リトライ回数、タイムアウト発生率、フォールバック発動率、各プールの使用率をメトリクスとして出し、分散トレースで呼び出しの連鎖を追えるようにします。これらが見えていないと、「なぜか遅い」の原因がリトライ増幅なのかブレーカー開放なのか切り分けられません。なお、諦めた処理を後で人手や別系統で拾うデッドレターキューは、ここまでの「呼び出し側の即時防御」とは層が異なり、非同期メッセージングで処理しきれなかったものを退避する仕組みである点は区別しておくとよいでしょう。

まとめ

レジリエンスパターンは、どれも「リモート呼び出しは失敗しうる」という前提から素直に導かれます。

  • タイムアウトで無限待ちを断ち、デッドラインを伝播して予算内に収める。
  • リトライは冪等な操作に限り、Retry-After を尊重する。
  • 指数バックオフ+ジッターでリトライストームを避ける。
  • サーキットブレーカーで壊れた相手を呼び続けない(Closed/Open/Half-Open)。
  • バルクヘッドでリソースを区画化し連鎖を止める。
  • フォールバックで縮退運転し、体感可用性を保つ。

サーバー側のレート制限一貫性の議論(CAP定理)と組み合わせて、システム全体の信頼性を設計していきましょう。

参考リンク

CAP定理とは - 分散システムで一貫性と可用性のどちらを守るか(PACELCまで整理)

CAP定理とは - 分散システムで一貫性と可用性のどちらを守るか(PACELCまで整理)

18

CAP定理を一次ソースから整理します。CのC=線形化可能性という定義、ACIDのCとの違い、「3つから2つを選ぶ」という有名な誤解の正体、分断時にCP/APのどちらを選ぶかという実像、Brewerの2012年再考、通常時のレイテンシと一貫性を扱うPACELC定理、結果整合性とBASE、そしてetcd・Cassandra・MongoDB・DynamoDBの分類を留保付きで解説します。

コンシステントハッシュ法とは - 分散システムでノードを増減してもキャッシュが崩れない仕組み

コンシステントハッシュ法とは - 分散システムでノードを増減してもキャッシュが崩れない仕組み

14

コンシステントハッシュ法(Consistent Hashing)を基礎から解説します。単純な mod N ハッシュがノード数変更でほぼ全キー再配置になる問題、ハッシュリングと時計回り割り当て、仮想ノードによる負荷平準化、再配置がなぜ平均 K/N で済むのか、そして Jump Consistent Hash・Rendezvous(HRW)・Maglev といった発展、DynamoDB/Cassandra/memcached(ketama) での実運用まで、原論文を一次ソースに整理します。

UUID と ULID 入門 - 分散システムで衝突しない ID を、時系列に強く設計する

UUID と ULID 入門 - 分散システムで衝突しない ID を、時系列に強く設計する

14

分散システムの一意な ID 設計を、UUID と ULID を軸に整理します。RFC 9562(2024 年発行)が標準化した UUIDv7、UUIDv4 のランダム性が DB インデックスに与える影響、ULID の 128 ビット構成と時系列ソート、生成コード例、そしてタイムスタンプ露出などのセキュリティ注意点まで、一次ソースを裏取りしてまとめます。