分散システム
Google SRE Book の第21章(過負荷への対処)と第22章(カスケード障害への対処)を取得して読み、 理解した内容を自分の言葉で書いたもの。本文の引用はしていない。
このリポジトリで一番薄かった領域。設計は「何を作るか」の方針集で、 構成要素が壊れたときにどう振る舞うかが書かれていなかった。
カスケード障害
1箇所の失敗が、残った側の負荷を押し上げて連鎖する。 これが最も一般的な引き金。
レプリカが1つ落ちると他のレプリカの負荷が上がり、それがまた落ちる。
資源の枯渇は種類ごとに違う壊れ方をする
| 資源 | 起きること |
|---|---|
| CPU | 処理が遅くなる → キューが伸びる → スレッド枯渇 → RPC の期限切れ |
| メモリ | タスクの退避、GC のデススパイラル、キャッシュヒット率の低下 |
| スレッド / ファイルディスクリプタ | 直接エラーになるか、ヘルスチェックが落ちる |
そして相互に伝播する。GC が CPU を食い、キャッシュが効かなくなり、バックエンドが過負荷になる。
デススパイラル
過負荷でサーバーが落ちると容量が減る。 再起動したサーバーが、高い負荷を浴びて即座にまた落ちる。 このループに入ると自力での回復がほぼ不可能になる。
リトライ
素朴なリトライは事態を悪化させる。 毎秒100件の失敗が、リトライの積み重なりでバックエンドを溶かす。
多層で掛け算になるのが怖い。フロントエンド・バックエンド・JavaScript がそれぞれ3回リトライすると、 データベースには 4³ = 64回届く。
守るべきルール
リトライには必ずランダム化した指数バックオフを使う
1リクエストあたりのリトライ回数に上限を置く(3回)
サーバー全体のリトライ予算を持つ(例: 毎分60回まで)
リトライ可能なエラーと恒久的なエラーを、ステータスコードで区別して返す
不正な形式のリクエストはリトライしない
3段構えで増幅を止める
| 制御 | 内容 |
|---|---|
| 1リクエストあたりの予算 | 最大3回まで |
| クライアント全体の予算 | 全リクエストに対するリトライの比率を 10% に抑える |
| 多層での重複を防ぐ | 拒否したサービスの直上の層だけがリトライする。 それより深い層は「過負荷。リトライするな」を返す |
効果が数字で出ている。 1リクエスト単位の予算だけだとデータセンターのトラフィックが3倍まで増えうるが、 クライアント側の10%制限を足すと1.1倍に収まる。
過負荷を検知して「リトライするな」と返す
バックエンドがリクエストの試行回数のヒストグラムを持ち、 入ってくるリクエストのリトライ率が高いかを見る。 高ければシステム全体が過負荷なので、リトライ禁止のエラーを返して爆発を止める。
どこでリトライするか
- 多くのバックエンドが過負荷 → リトライせずに呼び出し元へエラーを返す
- 一部だけが過負荷 → 別のバックエンドへ即座にリトライする
タイムアウトと期限
期限の決め方
期限を設けない、あるいは極端に長い期限を置くのが一番まずい。
平均レイテンシより数桁長い期限は、たいてい良くない。 ごく一部のリクエストが期限に達しただけでスレッドが枯渇する。
期限の伝播
残り時間を下流の RPC へ渡す。
フロントエンドが30秒の期限を設定して7秒使ったなら、 バックエンドへの RPC の期限は23秒になる。
伝播させないと、クライアントがもう待っていないリクエストをバックエンドが処理し続ける。
バイモーダルなレイテンシが効く仕組み
数字で見ると怖さが分かる。
フロントエンドのスレッド数 1,000
期限 100秒
完了しないリクエストの割合 5%
→ 遅いリクエストが約5,000スレッドを占有する
→ 容量を超える
→ 実際のエラー率は 5% ではなく 80% になる
対処は3つ。
平均ではなくレイテンシの分布を監視する
fail-fast の選択肢を用意する
クライアントごとの同時実行数に上限を置く(例: 全スレッドの25%まで)
キャンセルの伝播
ヘッジリクエスト(複数サーバーへ同じ RPC を投げる)を使う場合、 1つが応答したら他へキャンセルを送って無駄な処理を止める。
過負荷の受け止め方
QPS で容量をモデル化しない
クエリごとに必要な資源が全く違う。 コストはクライアントの実装、時間帯、ソフトウェアのバージョンで変わる。比率が予測不能に動く。
代わりに実際の資源を直接測る。 主要なシグナルは CPU 消費。 GC のあるシステムでは、メモリ圧もたいてい CPU 増加として現れる。
クライアント側の適応スロットリング
バックエンドが拒否し始めたら、クライアント自身が送るのをやめる。 拒否されるだけのリクエストに資源を使わないため。
2分間の窓で2つを数える。
requests アプリケーション層が試行した回数
accepts バックエンドが受け入れた回数
requests ≥ K × accepts になったら、クライアント側で確率的に拒否する。
P(reject) = (requests - K × accepts) / requests
K のデフォルトは 2。 小さくする(1.1 など)ほど積極的に絞り、大きくするほど緩くなる。
重要度(criticality)
4段階を RPC システムに通す。
| レベル | 意味 |
|---|---|
CRITICAL_PLUS | 失敗すると利用者に深刻な影響 |
CRITICAL | 本番ジョブの既定値。利用者に影響あり |
SHEDDABLE_PLUS | バッチの既定値。部分的な利用不可を織り込み済み |
SHEDDABLE | 頻繁に落ちてよい |
重要度は自動的に下流へ伝播する。 CRITICAL を受け取ったバックエンドが出す RPC も、既定で CRITICAL になる。 各層で明示的に指定しなくても、スタック全体で優先度が揃う。
負荷のシグナル
最も有用なのは executor load average — 実行中または実行可能なスレッド数。 利用可能なプロセッサ数を超え始めたら、重要度の低いリクエストから拒否する。
壊れ方を設計する
キュー管理
定常的なトラフィックなら、キューはスレッドプールの50%以下に保つ。 予測可能なパターンなら、キューを持たずにスレッドが埋まったら他タスクへフェイルオーバーする方がよい。
負荷の切り捨てと優雅な劣化
- 上限を超えたら HTTP 503 を返す
- LIFO や CoDel を使って、どうせ間に合わないリクエストを落とす
- 優雅な劣化は、拒否するのではなく質を落として返す。 新鮮なデータの代わりにキャッシュを返すなど
劣化モードを持つときの注意。
何を引き金にするか決める(CPU、レイテンシ、キュー長)
定期的にテストして、そのコードパスが動くことを確かめる
劣化モードに入ったことを監視する
意図せず発動しないよう、ロジックを単純に保つ
冷えたキャッシュ
キャッシュが空の状態は脆い。 再起動や新クラスタ投入のときは徐々に負荷を上げて温める。
2種類を区別する。
- レイテンシのためのキャッシュ — 空でもサービスは生きる。遅くなるだけ
- 容量のためのキャッシュ — 空だとサービスが成立しない
後者なら、過剰にプロビジョニングするか、memcache のような別サービスへ切り出す。
層をまたがない
リクエストパスで層を横断しない。 バックエンドが別のバックエンドへプロキシせず、 「そのバックエンドへリトライしてくれ」というエラーをフロントエンドに返す。
分散デッドロックと、スレッドプール枯渇の伝播を防げる。
壊れることをテストする
- 壊れるまで負荷をかける。 コンポーネントごとに限界点を見つける。 キャッシュを温めてから段階的に上げ、その後インパルス負荷を試す
- 通常負荷に戻したときの挙動を確かめる。 介入なしで劣化モードから抜けられるか。安定するにはどこまで負荷を下げる必要があるか
- 本番の小さなスライスで試す。 合成負荷では現実の効果を取り逃す
- 大口クライアントの挙動を理解する。 リトライのパターン、オフラインでのキューイング
- 重要でないバックエンドが落ちても、重要な経路を枯渇させないことを確かめる
容量計画の目安として、1クラスタの限界が5,000 QPS、ピークが19,000 QPS なら、 N+2 の余裕を見て6クラスタ。
起きてしまったときの手順
上から順に試す。
1. 資源を増やす 容量があり、デススパイラルに入っていないなら
2. ヘルスチェックの失敗を止める
プロセスのヘルスチェック(スケジューラ向け)と
サービスのヘルスチェック(ロードバランサ向け)を区別する
一時的に無効化すると安定することがある
3. サーバーを再起動する GCのデススパイラル、デッドロック、処理中リクエストの資源占有のとき
カナリアで段階的に
4. トラフィックを落とす 最終手段。クラッシュループなら1%まで落とす
キャッシュを温め、接続を確立させてから段階的に戻す
5. 劣化モードに入る 設計に組み込んであれば
6. バッチ負荷を止める インデックス作成や統計収集など重要でない裏の処理
7. 悪いトラフィックを止める 死のクエリなど