はじめに -- なぜ障害は伝播するのか
自分のサービスのコードにはバグがないはずなのに、なぜかレスポンスが返せなくなる。調べてみると、呼び出し先のサービスが遅くなっていただけだった。あるいは、突然リクエストが殺到して処理が追いつかず、すべてのリクエストが中途半端にタイムアウトしていく――そんな話を耳にしたことはないでしょうか。
分散システムでは、コンポーネント同士が互いに呼び出し合って動いています。この「依存関係」が、そのまま障害の伝播経路になります。呼び出し先(下流)が遅くなると、自分のサービスのスレッドや接続が解放されずに溜まっていき、やがて自分自身も応答できなくなる。すると、自分を呼び出している側(上流)にも影響が広がり、最終的にはシステム全体が連鎖的に壊れていきます。
逆方向の問題もあります。上流からリクエストが殺到すると、処理しきれなくなって全リクエストの応答が遅延し、最終的には何も返せなくなる。
障害は必ず起きます。大事なのは「障害そのものを防ぐこと」ではなく、障害が隣のコンポーネントに伝わるのを止めることです。
この記事では、そのための戦術的なレジリエンスパターン(コンポーネント間に設置する防御壁)を7つ紹介します。大きく分けて2つの方向があります。
上流(自分を呼び出す側)
|
[上流防御パターン]
- ロードシェディング
- ロードレベリング
- レートリミット
|
v
+-------------------+
| 自分のサービス | ← [横断的] 一定の仕事量
+-------------------+
|
[下流防御パターン]
- タイムアウト
- リトライ
- サーキットブレーカー
|
v
下流(自分が呼び出す先)
前半では「自分が呼び出す先の障害から身を守る」下流防御パターン(タイムアウト、リトライ、サーキットブレーカー)を、後半では「自分を呼び出す側の過負荷から身を守る」上流防御パターン(ロードシェディング、ロードレベリング、レートリミット)を扱います。
この記事のパターンは、既存のコードに比較的少ない変更で適用できる「戦術的」なものです。冗長化やパーティショニングのような「アーキテクチャレベル」の対策とは別の話になります。
TL;DR
- 障害対策には 下流防御(自分が呼び出す先の障害に耐える)と 上流防御(自分を呼び出す側の過負荷から守る)の2方向がある
- 下流防御は「検出 → 回避 → 遮断」と段階的に強化していく
- 上流防御は「ローカル判断での切り捨て」「キューによる平滑化」「グローバル判断でのクライアント単位制限」を使い分ける
- 加えて、通常時と異常時で挙動を変えないことで信頼性を高める横断的な考え方がある
- どのパターンも「一つのコンポーネントの問題が他に広がるのを止める」という共通の目的を持っている
タイムアウト -- 障害を「検出」する
ここからは下流防御の話です。自分のサービスが別のサービスを呼び出す場面を考えます。
タイムアウトがないと何が起きるか
ネットワーク呼び出しを行ったとき、相手から応答が返ってこないことがあります。このとき、タイムアウトを設定していなければどうなるでしょうか。
呼び出しは永遠に「待ち」の状態になります。その間、スレッドや接続といったリソースは握られたままで解放されません。こうしたリソースリーク(使い終わったはずのリソースが戻ってこない状態)が積み重なると、やがてサービス全体が応答不能に陥ります。
タイムアウトの本質は 「障害の検出器」 です。「一定時間待っても返事がなければ、何か問題が起きたと判断して待つのをやめる」。これが、接続障害が自分のサービスに伝播するのを防ぐ最初の防衛線になります。
「設定し忘れ」の罠
「タイムアウトが大事なのはわかった。でも、普通はライブラリが良い感じにやってくれるのでは?」と思うかもしれません。実は、多くのHTTPクライアントライブラリはデフォルトでタイムアウトが設定されていない、あるいは非常に緩い設定になっています。
| 言語 / ライブラリ | デフォルトのタイムアウト |
|---|---|
JavaScript / XMLHttpRequest
|
0(無限) |
JavaScript / fetch API |
当初はタイムアウト設定手段なし(現在はAbortSignal.timeout()で設定可能) |
Python / requests
|
無限 |
Go / httpパッケージ |
タイムアウトなし |
Java / java.net.http.HttpClient(JDK 11〜) |
タイムアウトなし(明示的にconnectTimeoutの設定が必要) |
.NET / HttpClient
|
100秒(他よりマシだが、本番環境では見直しが必要) |
正直なところ、想像以上に多くのライブラリがデフォルトで「タイムアウトなし(無限)」になっていて驚きました。
鉄則は明確です。ネットワーク呼び出しには常に明示的にタイムアウトを設定する。サードパーティライブラリを使う場合も、タイムアウト設定を公開しているか確認しておきたいところです。
適切なタイムアウト値の決め方
では、タイムアウトは何秒に設定すればいいのでしょうか。短すぎると正常なリクエストまで打ち切ってしまうし、長すぎると障害の検出が遅れます。
一つの方法は、許容できる「誤検知によるタイムアウト率」から逆算することです。例えば、「本来は正常に返ってくるリクエストのうち、0.1%がタイムアウトになるのは許容する」と決めたとします。この場合、下流サービスのレスポンスタイムのp99.9(全リクエストのうち99.9%がこの時間以内に返ってくるという値)を基準にタイムアウト値を設定できます。
そのためには、呼び出しの所要時間やステータスコード、タイムアウトの発生率をモニタリングしておく必要があります。
実装の工夫として、タイムアウト設定とモニタリングをまとめてラップしたライブラリ関数を用意しておくと、設定し忘れを防げます。サービスメッシュ(サービス間通信の制御をアプリケーションの外側で透過的に行うインフラ層。Istioなどが代表的)を使って、プロセスの外側で自動的にタイムアウトを管理する方法もあります。
リトライ -- 一時的な障害を「乗り越える」
タイムアウトで「何か問題が起きた」と検出できました。次にどうするか。もし一時的なネットワークの揺らぎが原因なら、もう一度やり直せば成功するかもしれません。
ただし、リトライ(失敗したリクエストをもう一度送ること)は諸刃の剣です。安易にリトライすると、すでに苦しんでいる下流サービスにさらなる負荷をかけてしまう場合があります。
指数関数的バックオフとジッター
リクエストが失敗したとき、選択肢は2つあります。即座にエラーとして返す(Fail Fast)か、リトライするかです。
リトライする場合に重要なのが 指数関数的バックオフ という考え方です。これは「リトライの待ち時間を試行ごとに倍増させていき、上限値で固定する」という戦略です。
delay = min(上限値, 初期待ち時間 * 2^試行回数)
例えば、初期待ち時間が2秒、上限が8秒なら、こうなります。
1回目: 2秒待つ
2回目: 4秒待つ
3回目: 8秒待つ(上限に到達)
4回目: 8秒待つ(上限のまま)
しかし、バックオフだけでは不十分な場面があります。下流サービスが一時的に遅くなったとき、複数のクライアントがほぼ同時に失敗を検知します。すると、全員が同じタイミングでリトライを開始し、下流サービスに負荷のスパイクが集中します(リトライストームと呼ばれます)。
[リトライストームのイメージ]
時間 →
★ 障害発生
|
| 全クライアントが一斉にリトライ
v
負荷 ■■■■■■■ ■■■■■■■ ■■■■■■■
| | | | | |
+--2秒--+ +--4秒--+ +--8秒--+
1回目 2回目 3回目
これを防ぐのが ジッター(待ち時間にランダムなばらつきを加える仕組み)です。
delay = random(0, min(上限値, 初期待ち時間 * 2^試行回数))
ジッターを加えることで、リトライのタイミングが時間軸上に分散され、下流への負荷が平滑化されます。
リアルタイム性が求められない場面(バッチ処理など)では、失敗したリクエストをキューに入れて後から再処理する「リトライキュー」という方法もあります。
リトライすべきでないケースもあります。権限不足などの恒久的なエラーは何度やり直しても成功しないため、即座にエラーにすべきです。また、冪等(べきとう)でない操作(同じリクエストを何回実行しても結果が変わらない性質を「冪等性」と呼び、それを持たない操作は2回送ると結果が変わってしまう)のリトライにも注意が必要です。
リトライ増幅 -- リトライがリトライを呼ぶ
リトライには、もう一つ見落としやすい落とし穴があります。複数のサービスが連鎖している場合の リトライ増幅 です。
サービスA → サービスB → サービスC という呼び出しチェーンを考えます。
BがCへのリトライを繰り返すと、Aから見た処理時間が長くなり、Aのタイムアウトに引っかかります。するとAもリトライを始め、Bの処理がさらに増え、Cへの負荷が雪だるま式に膨らんでいきます。
依存チェーンの各レベルでリトライを設定すると、深い階層ほど指数的に負荷が増幅されるのです。
対策として、長い依存チェーンでは 「リトライは特定の1レベルでのみ行い、他のレベルでは即座に失敗させる」 という設計が検討されます。
サーキットブレーカー -- 長期障害を「遮断」する
タイムアウトで障害を検出し、リトライで一時的な障害を乗り越える。ここまでのパターンで短期的な問題には対処できます。
しかし、下流のサービスが長時間にわたって応答しない状態が続いたらどうなるでしょうか。失敗し続けるリクエストをリトライし続ければ、自分のサービスは常に遅延し、その遅延がシステム全体に広がっていきます。
ここで必要になるのが「そもそもリクエストを送らない」という判断です。最も速いネットワーク呼び出しは「行わなかった呼び出し」だ、という発想ですね。
3つの状態を持つステートマシン
サーキットブレーカー は、電気のブレーカーと同じ発想です。異常な電流を検知したら回路を切って、機器を守る。サーキットブレーカーは、下流サービスの異常を検知したら、リクエストの送信自体を止めて自分のサービスを守ります。
リトライが「次は成功するだろう」と期待するのに対し、サーキットブレーカーは「次も失敗するだろう」と予見してリクエスト自体を阻止する。この違いがポイントです。
サーキットブレーカーは3つの状態を持つステートマシンとして実装されます。
- Closed(通常状態): リクエストをそのまま通過させつつ、エラーの数をカウントします。一定時間内に失敗が閾値を超えると、Open状態に遷移します
- Open(遮断状態): リクエストを下流に送らず、即座にエラーを返します。一定時間が経過するとHalf-open状態に遷移します
- Half-open(試行状態): 少数のリクエストだけを流して、下流サービスが回復したかを確認します。成功すればClosedに戻り、失敗すれば再びOpenに戻ります
Open状態のとき何をするか -- 機能縮退という選択肢
サーキットブレーカーがOpen状態になると、下流サービスの機能は使えません。ここで重要なのは、「サービス全体を止めるのか」「一部の機能だけを犠牲にして動き続けるのか」を、あらかじめ設計で決めておくことです。
後者のアプローチを 機能縮退(Graceful Degradation) と呼びます。飛行機が飛行中に一部のシステムを失っても、墜落するのではなく飛行と着陸を継続できるように設計されているのと同じ発想です。
例えば、ECサイトのトップページでレコメンデーションサービスがダウンしたとします。ページ全体をエラーにするのではなく、レコメンデーション部分だけを非表示にしてページはレンダリングする。これが機能縮退です。
閾値の設計は難しい
「何回の失敗でOpen状態にするか」「Open状態からHalf-open状態まで何秒待つか」は、サービスの特性や過去の障害データに基づいて判断する必要があります。サーキットブレーカーの仕組みを理解すること自体はシンプルですが、実際の閾値設計は非常にコンテキスト依存で複雑です。
下流防御のまとめ -- 3つのパターンの連携
ここまでで、下流防御の3つのパターンを見てきました。これらは個別に使うものではなく、組み合わせて段階的に障害対応の深度を増していくものです。
[下流防御の3段階]
タイムアウト リトライ サーキットブレーカー
「障害を検出する」 「一時的障害を 「長期障害を遮断する」
乗り越える」
+-----------+ +-----------+ +-----------+
| 応答が来な | | 失敗したら | | 失敗が続く |
| いことを | --> | もう一度 | --> | ならそもそ |
| 検知する | | 試す | | も送らない |
+-----------+ +-----------+ +-----------+
検出 回避 遮断
- タイムアウトがなければ、リトライするかどうかの判断ができない
- リトライだけでは、長期的な障害に対応できない
- サーキットブレーカーは、タイムアウトとリトライの結果を利用して状態を遷移させる
共通の目的は 「下流の障害が自分のサービスに伝播するのを止めること」 です。
これらのパターンは、アプリケーションコードで実装するだけでなく、サービスメッシュ(Istioなど)を使ってインフラ層に委譲する選択肢もあります。本記事ではパターンそのものの理解に集中します。
ロードシェディング -- 処理しきれないリクエストを「切り捨てる」
ここからは視点を反転させます。自分が呼び出す先(下流)ではなく、自分を呼び出す側(上流) からの過負荷に対する防御パターンの話です。
なぜ「全部受ける」と全部壊れるのか
サーバーの容量を超えるリクエストが殺到すると、メモリ、スレッド、ソケット、ファイルディスクリプタ(OSがファイルやネットワーク接続を管理するための識別子)といったリソースが枯渇します。
すると、すべてのリクエストが中途半端に遅くなります。例えば、10件のリクエストを5秒で返せるサーバーに20件が殺到すると、10件を正常に返して10件を拒否するよりも、20件すべてが10秒以上かかって大半がタイムアウトする――そんな結果になりがちです。
つまり、超過分を拒否して残りを正常に処理するほうが、全体のスループット(単位時間あたりの処理数)は高くなります。
ロードシェディングの仕組み
ロードシェディング は、「処理しきれないリクエストを意図的に切り捨てる」パターンです。
処理中の同時リクエスト数をカウントし、閾値を超えたら503(Service Unavailable)を返して即座に拒否します。
拒否するリクエストの選び方にはいくつかの方針があります。
- 無差別に拒否する: シンプルだが公平
- 優先度の低いリクエストを優先的に拒否する: 重要な処理を守れる
- 最も古いリクエストを拒否する: 古いリクエストはクライアント側で既にタイムアウトしている可能性が高く、今さら処理しても無駄になりがち
拒否にもコストがある
リクエストを拒否するにも、TLS接続の確立やリクエストの読み取りなど、ゼロではないコストがかかります。負荷が増え続ければ、「拒否するためのコスト」だけでサーバーがパンクする可能性もあります。ロードシェディングは万能ではなく、スケールアウト(サーバーを増やすこと)と組み合わせる前提のパターンです。
ロードレベリング -- キューで負荷の波を平らにする
ロードシェディングは超過リクエストを「捨てる」パターンでした。しかし、クライアントが即時の応答を必要としない場合には、捨てずに「後で処理する」という選択肢もあります。
キューによる負荷の分離
ロードレベリング は、クライアントとサービスの間にメッセージングチャネル(キュー)を挟むパターンです。
[ロードレベリングのイメージ]
クライアント側 キュー サービス側
(負荷がバラバラ) (一定ペースで処理)
■■■■■■ +------+
■■■ | | ■■■
■■■■■■■■ ------> |キュー | ------> ■■■
■ | | ■■■
■■■■■ +------+ ■■■
サービスに送られる負荷と処理容量が切り離されるため、サービスは自分のペースでリクエストを処理できます。短期間のスパイク的な負荷を平滑化するのに適しています。
限界と組み合わせ
ただし、サービスの処理能力が長期的に不足していれば、キューに未処理分(バックログ)が膨大に溜まり、別の問題を引き起こします。
ロードシェディングもロードレベリングも、負荷の増加そのものを解決するものではありません。サービスが壊れるのを防ぐための「時間稼ぎ」です。実運用では、負荷を検知して自動的にインスタンスを増やすオートスケーリングと組み合わせて使うのが一般的です。
レートリミット -- クライアント単位で流量を制限する
ロードシェディングは「サーバー自身の容量」を基準にリクエストを拒否するパターンでした。しかし、それだけでは「特定のクライアントがリソースを独占する」問題を防げません。ここで必要になるのが、クライアントごとに利用量の上限を設けるパターンです。
レートリミットの基本
レートリミット は、特定のユーザー、APIキー、IPアドレスごとに「時間あたりのリクエスト数」などの上限(クォータ)を設定するパターンです。
クォータを超えたリクエストには429(Too Many Requests)を返します。レスポンスにRetry-Afterヘッダーを含めることで、「いつ再試行すべきか」をクライアントに伝えることもできます。行儀のよいクライアントは429を受け取ると自主的にリクエストを停止します。
レートリミットの主な活用場面は以下の通りです。
- 善意のユーザーが誤ってリソースを独占するのを防ぐ
- クライアント側のバグによる過負荷を防ぐ
- 料金プランに応じた利用量の制限を強制する
ロードシェディングとレートリミットの違い
ここで、似ているようで異なるロードシェディングとレートリミットの違いを整理しておきます。
| 観点 | ロードシェディング | レートリミット |
|---|---|---|
| 判断の基準 | サーバーのローカルな状態(現在の同時処理数) | システム全体のグローバルな状態(特定APIキーの全インスタンス合計リクエスト数) |
| レスポンス | 503 (Service Unavailable) | 429 (Too Many Requests) |
| 実装の複雑さ | 比較的シンプル(単一プロセスで完結) | インスタンス間の調整(コーディネーション)が必要 |
レートリミットは「グローバルな状態」に基づいて判断するため、複数のインスタンス間で情報を共有する必要があり、実装が複雑になります。
単一プロセスでの実装 -- スライディングウィンドウ
ここからは少し実装寄りの話になりますが、レートリミットの仕組みを理解するうえで重要なポイントです。
「APIキーごとに毎分2リクエスト」という制限を課すとしましょう。
素朴な方法として、APIキーごとにリクエストのタイムスタンプをすべて保存する方法が考えられます。しかし、これではリクエスト数が増えるとメモリ消費が膨れ上がります。
改善策として、時間を固定期間のバケット(例えば1分区切りの箱)に分割し、バケットごとのリクエスト数だけをカウントする方法があります。
[バケットによる時間の分割]
12:00 12:01 12:02 12:03
+-----+ +-----+ +-----+ +-----+
| 5 | | 3 | | 8 | | 2 | ← 各バケットにリクエスト数だけ記録
+-----+ +-----+ +-----+ +-----+
この上に スライディングウィンドウ を実装します。スライディングウィンドウとは、時間軸上をリアルタイムに移動する計測窓のことです。前の完了済みバケットはウィンドウとの重なり割合で按分し、現在の未完了バケットは到着済みのカウントをそのまま合算することで、ウィンドウ内のリクエスト数を近似します。
[スライディングウィンドウのイメージ]
12:00 12:01 12:02
+-----+ +-----+ +-----+
| 5 | | 3 | | 8 |
+-----+ +-----+ +-----+
<---[ ウィンドウ(1分間) ]--->
↑
現在 12:01:30 の場合
12:00バケットの50% + 12:01バケットの100%
= 5*0.5 + 3*1.0 = 5.5(近似値)
これは近似値ですが、実用的には十分な精度です。バケットの粒度を細かく(例えば30秒単位に)すれば精度が上がります。APIキーごとに数個のカウンターを持つだけで済むため、メモリ効率に優れています。
分散環境での実装
複数のプロセス(複数のサーバーインスタンス)でリクエストを受け付ける場合、ローカルな状態だけではグローバルなリクエスト数を把握できません。全インスタンスの合計値を管理するための共有データストアが必要です。
ただし、単純な読み書きでは、2つのプロセスが同時にカウンターを更新したときに「更新の紛失(Lost Update)」が起こりえます。これを防ぐ方法にはいくつかの段階があります。
- トランザクション: 正確だが遅い。全リクエストで実行するとデータストアの負荷が大きい
- アトミック操作(get-and-increment、Compare-and-Swap(現在の値を確認してから更新する操作)など): トランザクションより高速で、多くのデータストアがサポートしている
- メモリ内バッチ処理: メモリ上でカウントをまとめ、非同期にデータストアへ書き出す。精度はわずかに下がるがデータストアへの負荷を大幅に削減できる
共有データストアがダウンした場合にどうするか、という問題もあります。レートリミットのデータストアに繋がらないからといってリクエストを全拒否するのは、ビジネス上の損失が大きすぎるケースが多いです。一般的には、最後に読み取った状態に基づいてリクエストを処理し続ける(可用性を優先する)アプローチが取られます。
DDoS攻撃とレートリミットの限界
レートリミットはDDoS攻撃(大量のリクエストを送りつけてサービスを妨害する攻撃)に対してある程度の保護を提供しますが、完全ではありません。
制限されたクライアントが429のレスポンスを無視して攻撃を続けることは止められません。さらに、APIキーの確認だけでもTLS接続(通信を暗号化するための接続確立処理)のコストがかかります。
DDoS対策の本質は 「規模の経済」 です。巨大なゲートウェイサービスの背後でトラフィックを遮断し、そのコストを複数のサービスで分散する。個々のサービスのレートリミットだけで対処するものではない、という点は覚えておきたいところです。
一定の仕事量 -- 負荷に関わらず挙動を一定に保つ
ここまでで、下流防御(タイムアウト・リトライ・サーキットブレーカー)と上流防御(ロードシェディング・ロードレベリング・レートリミット)の6つのパターンを見てきました。
最後に、どちらの方向にも当てはまる横断的な考え方を1つ紹介します。
ここまでのパターンは全て「異常時に特別な処理を行う」ものでした。通常はリクエストをそのまま流すけれど、障害を検知したらサーキットブレーカーを開く。通常は全てのリクエストを受け付けるけれど、過負荷になったらロードシェディングで拒否する。
しかし、「通常時と異常時で挙動が変わること」自体がリスクになる場合がある、というのがこのセクションの話です。
なぜ「モードの切り替え」は危険なのか
過負荷や障害によってアプリケーションの挙動が変わることを「多モード(multimodal)な振る舞い」と呼びます。通常と異なるモードでの動作は、隠れたバグを引き起こしたり、運用者のメンタルモデル(「このシステムはこう動くはず」という期待)を狂わせたりします。
異常時にしか動かないコードは、いざというときに本当に動くか確証が持てないのです。
原則として、動作モードの数は最小限に抑えるのが望ましいとされています。
例えば、データプレーン(実際のデータ処理を担う部分)でリレーショナルデータベースよりもキーバリューストアが好まれるケースがあります。リレーショナルデータベースは高度な最適化により実行のたびにパフォーマンスが変わりますが、キーバリューストアは特定のクエリに対して常に予測可能なパフォーマンスを返すためです。
「一定の仕事量」パターンの考え方
一定の仕事量(Constant Work) とは、「忙しくても暇でも、単位時間あたりに行う仕事の量を一定に保つ」というパターンです。
高負荷時でも平均負荷時でも同じ量の仕事をする。これにより、負荷の変動がシステムの挙動に影響を与えなくなります。
さらに理想的なのは、ストレス下で変化があるとしても「より良く動く方向」に変化することです。
このような性質は 反脆弱性(Antifragility) と呼ばれます。これはナシーム・タレブが提唱した概念で、「ストレスや障害を受けるとむしろ強くなる」という性質を指します。壊れないだけの「頑健性」とは異なり、負荷がかかることで改善される点が特徴です。
具体例: 設定変更の伝播
抽象的な話だけだとイメージしにくいので、具体例を見てみます。
コントロールプレーン(システムの管理・制御を担う部分)からデータプレーンへ、ユーザーごとの設定を同期する場面を考えます。
差分送信アプローチ(よくある方法):
変更があった分だけを個別にブロードキャスト(一斉送信)します。通常は効率的ですが、数万件の設定が一斉に変更されると、膨大なメッセージがデータプレーンに殺到してパンクする可能性があります。
一定の仕事量アプローチ:
コントロールプレーンが定期的に「全ユーザーの設定」をファイルとしてダンプし、高可用なストレージ(S3など)に保存します。データプレーンは定期的にこのファイルを一括で読み取ります。
[差分送信 vs 一定の仕事量]
差分送信:
変更1件 → メッセージ1件送信 (軽い)
変更1万件 → メッセージ1万件送信 (パンクの危険)
一定の仕事量:
変更1件 → 全設定ファイルを丸ごと書き出し・読み取り(一定)
変更1万件 → 全設定ファイルを丸ごと書き出し・読み取り(一定)
変更0件 → 全設定ファイルを丸ごと書き出し・読み取り(一定)
変更が何万件でも0件でも、書き出しと読み取りの仕事量は常に一定です。
このパターンには他にも利点があります。
- 差分管理の複雑なロジックが不要で、実装がシンプル
- 自己修復性: 設定ファイルが破損しても、次の更新で自動的に修正される。誤った設定を流してしまっても、正しい設定でダンプし直すだけで全インスタンスが復旧する
トレードオフ
一定の仕事量は、必要最小限の仕事だけをこなすより常にコストがかかります。変更がなくても毎回全設定ファイルを丸ごと書き出すわけですから、リソースの使い方としては効率的とは言いにくい面があります。
しかし、それによって得られる信頼性の向上と複雑さの低減は、多くの場合コストに見合うとされています。個人的にも、「通常時と異常時で同じコードが動く」という安心感は想像以上に大きいと感じます。
まとめ -- 障害の連鎖を止める2方向の防御
この記事では、障害の伝播を止めるための7つのレジリエンスパターンを、下流防御と上流防御の2方向に分けて見てきました。
上流(自分を呼び出す側)
|
+-------------+-------------+
| | |
ロードシェディング ロードレベリング レートリミット
(超過の切り捨て) (キューで平滑化) (クライアント単位の制限)
[ローカル判断] [即時性不要の場合] [グローバル判断]
| | |
+-------------+-------------+
|
+--------+--------+
| 自分のサービス | ← 一定の仕事量
+---------+-------+ (モードの切り替えを減らす)
|
+--------------+--------------+
| | |
タイムアウト リトライ サーキットブレーカー
(障害の検出) (一時障害の回避) (長期障害の遮断)
| | |
+--------------+--------------+
|
下流(自分が呼び出す先)
下流防御は、タイムアウト → リトライ → サーキットブレーカーの順で「積極的に止める」方向に強化されていきます。
上流防御は、ロードシェディング(ローカルな判断で超過を拒否)、ロードレベリング(キューで後処理に回す)、レートリミット(グローバルな判断でクライアント単位に制限)の使い分けです。
そして一定の仕事量は、そもそも「異常時の挙動」自体を減らすことで信頼性を高める横断的なパターンです。
どのパターンも、共通して 「一つのコンポーネントの問題が他に広がるのを止める」 ことを目的としています。
故障は避けられません。完璧なシステムを作ることは不可能です。だからこそ、爆発半径を小さくし、亀裂の伝播を食い止める。この記事で紹介したパターンが、その設計判断の一助になれば幸いです。