インフラ・DevOps
「健全なアップストリームがない」は、あなたが思うアップストリームに関するものではない場合
When "no healthy upstream" isn't about the upstream you think (sahansera.dev)
要約
この記事は、システム障害時に発生する「no healthy upstream」エラーの原因を、CPUスロットリングという一般的な誤解から、依存関係にあるデータストアへの接続タイムアウトとそれに伴うリトライの連鎖という、より根本的な問題へと深掘りします。筆者は、表面的な症状に惑わされず、ログやシステムの状態を注意深く分析することで、見過ごされがちな根本原因を特定し、根本的な解決策を見出すプロセスを解説しています。この問題は、システムが自己回復するように見えても、実際には不安定な状態に陥っている警告であると警鐘を鳴らしています。
全文翻訳
健全なアップストリームがない、というエラーは、破壊を予期させる種類のエラーです。
そして、ダッシュボードを開くと、ほとんど何も見つかりません。
CPUは低いです。
ポッドはクラッシュしていません。
最後のデプロイは数時間前でした。
ページをリフレッシュする頃には、サービスは自己回復していました。
それが数週間前、私が検索バックエンドの断続的な障害を追跡し始めたときの状況でした。
最終的な修正はわずか数行でした。
興味深かったのは、そこに至るまでの過程でした。
私たちはすでに、整然とした説明と1行の修正を備えた、確実な根本原因分析(RCA)を持っていました。
それはまた、間違ったサブシステムを指していました。
この記事は、もっともらしいストーリーと証拠の間のギャップ、そしてほとんど健全なフリートがゆっくりとサービスから自身を削除していく一般的な障害モードについてです。
障害の形状
セットアップは普通でした:ロードバランサーの後ろにある検索バックエンド、各インスタンスに固定されたワーカープロセスのプール。
時折、明らかなスケジュールなしに、リクエストのバーストが失敗しました。
ブラウザには、むき出しの「no healthy upstream」が表示され、1〜2分後にすべてが正常に動作しました。
手がかりが毎回1つ現れました。
バックエンドのp99レイテンシは、ほぼロードバランサーのタイムアウトまで上昇し、平坦に保たれ、その後通常に戻りました。
その形状が重要です。
有機的なスローダウンは不均一になりがちです。
これは崖、平坦な頂上、そして回復でした。
リクエストは徐々に遅くなっていたのではなく、期限にぶつかり、カットオフされていました。
私が引き継いだ理論はCPUスロットリングでした。
重い周期的なジョブがポッドのCPUを占有し、スケジューラがそれをスロットリングし、リクエスト処理が枯渇し、ロードバランサーが最終的にインスタンスを追放した、というものでした。
それは一貫していました。
さらに良いことに、それは1行の修正を伴いました:CPU制限を引き上げる。
これはインシデント中の魅力的な組み合わせです。
しかし、根本原因理論は予測を立てますが、これらの予測は証拠との接触に耐えられませんでした。
RCAを結論ではなく仮説として扱う
既存のRCAを結論として扱うのではなく、仮説として扱いました:CPUスロットリングがインシデントを引き起こしたなら、他に何を観察できるはずか?
3つのチェックが間違っていました。
サービングパスにはCPUバウンドの作業はありませんでした。
計算負荷の高いバッチインデクサーは別のマシンで実行され、ネットワーク経由でデータストアにアクセスしていました。
それらはリクエストを処理するポッド内で実行されることはありませんでした。
ポッドのcgroupの外にあるプロセスは、そのポッドがCPUスロットリングされる原因にはなり得ません。
グラフも同意しました:コンテナのスロットリングカウンターは、すべてのイベント中に平坦なままでした。
タイミングはスケジュールされたトリガーに合いませんでした。
バッチジョブは粗いスケジュールで実行され、1つの持続的な利用率の増加を生み出しました。
インシデントは任意の分に到着し、ジョブが実行されるよりもはるかに頻繁に発生しました。
タイマーが原因であれば、障害はそのタイマーに従うはずでした。
そうではありませんでした。
障害はインスタンスとバージョンの境界を無視しました。
同じ症状が異なるポッドとデプロイメントSHAで現れました。
リグレッションは通常、バージョンに従います。
共有された外部イベントは通常、フリート全体にヒットします。
これらの障害はどちらもそうではありませんでした。
パターンは、各インスタンスが自身のトラフィックによってトリガーされ、自身に対して何かをしているように見えました。
CPU理論を維持するために、トポロジー、タイミング、および障害の分布を説明する必要があったでしょう。
その時点で、理論は質問よりも多くの質問を生み出していたので、それを捨ててログに戻りました。
ログの1行、そしてそれがモデルを変える理由
イベントウィンドウには、1つの繰り返しエラーが含まれていました:ConnectionTimeout: Connection timed out
スタックはデータストアクライアントで終了し、決して返らないソケット読み取りでブロックされていました。
その1行がモデルを反転させました。
CPUスロットリングされたワーカーは実行準備ができていますが、十分なスケジューラ時間を取得できません。
I/OでブロックされているワーカーはCPUオフであり、ワーカーのスロットを占有したままネットワークを待っています。
外部からは、どちらも「レイテンシが上昇し、リクエストがタイムアウトした」ように見えます。
しかし、それらは反対です。
CPUを追加しても、I/Oストールはソケットをブロック解除しません。
せいぜい、同じ遅い依存関係の後ろに駐車するためのより多くのワーカーが得られるだけです。
これが、エッジ症状が調整するのに危険な理由です:リソース飽和と依存関係のブロックは、同じ熱を発生させながら、まったく異なる治療を必要とします。
なぜ1つの遅い依存関係がインスタンス全体を飽和させるのか
メカニズムは、2つの通常のクライアント設定が組み合わさって危険になったことに帰着しました:
個々のデータストア呼び出しにタイムアウトがない。
1回の呼び出しは、ユーザーが諦めた後もワーカーを占有し続ける可能性があります。
接続タイムアウト時のリトライ。
一度長時間待った後、ワーカーはバックオフを挟んで再び待機します。
エキゾチックなものはありません。
それが、この障害モードが見逃されやすい理由の一部です。
リトルの法則は、それが固定ワーカープールにとって致命的である理由を示しています。
同時実行性(L)はλWです:到着率(λ)×各リクエストがシステムに費やす平均時間(W)。
サービスが毎秒200リクエストを受信し、通常40ミリ秒で応答すると仮定します:L = 200 req/s × 0.04 s = 8 同時リクエスト
8つの同時リクエストは、プールが吸収するのは簡単です。
しかし、データストアが遅くなり、リクエストが数十秒待機すると、Wは3桁増加します。
リトライはそれをさらに伸ばします。
必要な同時実行性は、利用可能なワーカーの数をすぐに超えます。
プールはその後、次のようなものになります。
これはヘッド・オブ・ライン・ブロッキングを作成します。
データストア呼び出しで駐車されているワーカーは、その背後にある高速なリクエストを処理できないため、遅い依存関係に到達したリクエストだけでなく、すべてに対してレイテンシが上昇します。
ヘルスチェックも同じキューに捕捉されます。
それらはタイムアウトし、ロードバランサーはインスタンスをローテーションから削除し、残りのインスタンスはより多くのトラフィックを受け取ります。
十分なインスタンスがヘルスチェックに失敗すると、ロードバランサーは次のリクエストを送信する場所がなくなります。
その時にユーザーは「no healthy upstream」を目にします。
さらにひねりがありました。
クライアントの合計リトライ時間は、ロードバランサーのデッドラインを超える可能性がありました。
ロードバランサーがエラーを返している間、ワーカーは誰も受信できない結果のリトライを続けていました。
外側のデッドライン以降のミリ秒は無駄な作業であり、無駄な作業は希少なワーカーのスロットを占有していました。
これはデッドライン伝播障害です。
内部操作は、外部リクエストのデッドライン内に完了する必要があります。
さらに良いことに、外側のデッドラインを呼び出しチェーン全体に渡して、各レイヤーがその結果が役に立たなくなった時期を知れるようにします。
なぜ自己回復したのか
回復は当初安心できるものでした。
hindsightでは、それは心配な部分でした。
データストアの瞬間的な遅延がリトライを引き起こしました。
それらのリトライは、すでに苦労しているデータストアに負荷を追加し、さらにタイムアウトを引き起こし、それによってさらにリトライを引き起こしました。
それがメタステーブル障害の始まりです:短いトリガーがシステムを健全な状態からノックアウトし、その後フィードバックループが元のトリガーが過ぎ去った後もそれを不健全に保ちます。
私たちは幸運でした。
データストアの遅延は、リトライループが自己維持的になる前に、トラフィックがティッピングポイントを下回るほど短かったです。
もう少し長い遅延があれば、フリートを再起動するか、それを脱出するのに十分なトラフィックを削減するまで、ループを維持できた可能性があります。
自己回復はレジリエンスの証明ではありませんでした。
それは警告ショットでした。
修正、そしてなぜそれを小さく保ったのか
可用性インシデント中の本能は、ヘッドルームを追加することです:CPU制限を引き上げる、ワーカープールを増やす、レプリカを追加する。
それは本物の容量問題に役立つかもしれません。
ここでは、リトライループに占有するワーカーを増やすだけです。
有用な修正は、1つのリクエストのコストにハードバウンドを設けることでした。
まず、すべてのダウンストリーム呼び出しに明示的なタイムアウトを追加します。
心地よい丸い数字を選ぶのではなく、依存関係の健全なレイテンシ分布から選択します。
それは健全なp99.9を快適に上回り、ロードバランサーのデッドラインよりはるかに下にあるべきです。
永遠に待つことができる呼び出しは、永遠に失うことができるワーカーです。
第二に、リトライを通常のトラフィックの割合としてバウンドし、各リクエストに対する無条件のカウントとしてではありません。
「3回リトライする」のようなルールは、依存関係が遅いときに各クライアントがトラフィックを正確に乗算できるようにします。