Web開発
毎秒100万リクエストを処理するクライアントサイドロードバランシング
Client-side load balancing at a million requests per second (engineering.zalando.com)
要約
ZalandoのProduct Read API (PRAPI)は、毎秒数百万のリクエストを処理し、単一桁ミリ秒のレイテンシを実現しています。当初、共有のIngressロードバランサーであるSkipperを経由して内部トラフィックをルーティングしていましたが、レイテンシスパイクの原因特定が困難でした。この問題を解決するため、バッチ処理におけるファンアウトトラフィックに特化したクライアントサイドロードバランシングを導入。これにより、Skipperと同じハッシュリングアルゴリズムをプロセス内で実装し、KubernetesのEndpointSlice APIをウォッチャーベースで利用してポッドのディスカバリーを行い、高パフォーマンスと信頼性を実現しました。
全文翻訳
最もビジーなAPIは、大量の内部トラフィックをクラスター共有のエッジイングレスロードバランサー経由で実行していました。何年もの間、レイテンシスパイクが自社コードによるものなのか、それとも共有エッジルーターを内部で再利用しているためなのか、確信が持てませんでした。
以前の投稿で、ZalandoのProduct Read API (PRAPI) を構築した方法について説明しました。これは、25のヨーロッパ市場で毎秒数百万のリクエストを処理し、単一桁ミリ秒のレイテンシを実現しています。すべての商品ページ、検索結果、チェックアウトがこれに依存しており、わずかな性能低下が売り上げに測定可能な影響を与えるため、高いパフォーマンスと可用性が要求されます。低レイテンシは、一貫したハッシュルーティングによって実現されます。クラスターのエッジロードバランサーであるSkipperは、同じ商品IDを同じポッドにルーティングし、基盤となるアプリケーションのポッドローカルキャッシュを活用するのに役立っています。このAPIのルーティングインフラストラクチャは重要です。
ローンチ時、Skipperはエッジルーティングと、バッチ処理とシングルゲットコンポーネント間の内部トラフィックの両方を処理していました。クライアントサイドロードバランシング (CSLB) が後者を置き換えることは常に私の意図であり、迅速に追随することを望んでいました。しかし、Skipperは高速で、各リクエストに数百マイクロ秒しか追加せず、チームは稼働中のシステムに大きな変更を加えることに、当然ながら抵抗がありました。何年にもわたり、根本原因が不明確なインシデント(SkipperかPRAPIか?)が蓄積するにつれて、構造的な問題を無視することは困難になりました。1つの100バッチリクエストの場合、PRAPIはSkipperに対して100倍の露出がありました。Skipperがくしゃみをすると、PRAPIはインフルエンザにかかりました。
それらのインシデントのいくつかは、後になって判明しますが、SkipperでもPRAPIでもありませんでした。しかし、ルーティングの決定権とそれに伴う詳細なログを手に入れるまでは、それを見る術がありませんでした。
Skipperとファンアウト問題
SkipperはZalandoのオープンソースKubernetesイングレスコントローラーであり、HTTPルーターです。エッジロードバランシングを鮮やかに処理します。一貫したハッシュルーティング、バウンドされた負荷保護、新しいポッドのフェードインなどです。私たちはSkipperに主要な機能を貢献しました。スケーリング中のキャッシュ損失の最小化や、話題になった製品による過負荷の防止などです。今日でも、すべての単一製品GETリクエストにSkipperを使用しています。
問題はバッチエンドポイントでした。PRAPIのプロダクトセットコンポーネントは、単一のバッチリクエストを最大100の並列ダウンストリーム呼び出しに分解し、個々の製品ポッドに送ります。これらの100の呼び出しはそれぞれSkipperを経由します。Skipperはホップごとに数百マイクロ秒しか追加しませんが、バッチは一度に最大100のホップを待つため、そのレイテンシは100の中で最も遅いものに追従し、典型的なものには追従しません。そして、Skipperは共有インフラストラクチャです。私たちはクラスターの他の部分と同じフリートで動作し、設定は私たちが設定するのではなく、継承されたグローバル設定です。
イングレスロードバランサーを介したプロダクトセットのファンアウト
インシデント発生時、レイテンシスパイクがSkipperで発生したのか、それとも自社コードで発生したのか、確信が持てませんでした。それはすべてのリクエストのホットパスにあり、私たちが運用しているわけではなく、その動作と私たちの動作をきれいに分離することはできませんでした。Skipperが高速な場合でも、その共有された運命が問題でした。
私たちは、大量のファンアウトを伴う内部トラフィックの場合、ルーティングの決定を呼び出しプロセス自体の中に置くべきだと判断しました。Skipperが得意とするエッジトラフィックは、現状のままにしておくべきです。私たちはSkipperを置き換えるわけではありませんでした。内部のファンアウトパスをプロセス内で実行されるクライアントサイドロードバランサーにアップグレードしたのです。
プロダクトセットは製品ポッドに直接ルーティング
同じハッシュリングの構築
私たちは抽象的なクライアントサイドバランシングを必要としていたわけではなく、Skipperと全く同じリングを自社のプロセス内に必要としていました。したがって、最も重要な制約はハッシュパリティでした。移行中、Skipperとクライアントサイドロードバランサーの両方が、同じ製品ポッドのプールにリクエストをルーティングします。ハッシュリングが一致しない場合、SkipperがポッドAにルーティングする製品が、私たちのライブラリによってポッドBにルーティングされる可能性があります。これはキャッシュを分割し、DynamoDBの負荷を倍増させることになり、私たちが望んでいることとは真逆です。
私たちはSkipperが使用するのと同じアルゴリズム、つまり設定可能な仮想ノードリング上のxxHash64を実装しました。各エンドポイントURLは、Skipperのデフォルトに合わせて、64ビットハッシュリング上の100箇所に配置されます。リクエストが来ると、製品IDがハッシュされ、リング上の二分探索によって最も近い時計回りのエンドポイントが見つかります。
これは、エンドポイントを追加または削除すると、キーの約1/Nしか再マッピングされず、キャッシュのチャーンを最小限に抑えることを意味します。そして、Skipperと私たちのライブラリの両方が同じハッシュ関数と仮想ノードの数を使用するため、同じポッドセットに対して同一のリングを生成します。一連のユニットテストがこれを厳密に検証します。それらは、Skipperのアルゴリズムと同じキーを任意のポッドセットに対して同じエンドポイントに配置することをアサートし、すべてのビルドで実行されるため、後の変更がSkipperから静かにずれることはありません。カナリア導入中も本番環境でこれが保持されていることを確認しました。キャッシュヒット率は両方のパスで同じままでした。
私たちはこれをスタンドアロンのフレームワークフリーなJVMモジュールとして作成し、長期的にこのサービスから切り出す意図がありました。その唯一の本当の依存関係は、Skipperと一致するxxHash64のための小さなゼロアロケーションハッシュライブラリであり、その他すべて、リング、占有率の計算、バウンドロードの計算はJDK標準ライブラリです。KubernetesクライアントとMicrometerは、検出とメトリクスのためにエッジに位置します。
Kubernetesディスカバリー
私たちのロードバランサーは、どのポッドが存在するかを知る必要があります。最初の方法は、数秒ごとにKubernetes EndpointSlice APIをポーリングすることでしたが、ポーリングは私たちが注意して扱うべきパターンであると学んでいました。Zalandoでは以前、Akka ClusterのデプロイメントがKubernetes APIを高頻度でポーリングしたために、コントロールプレーン全体をダウンさせたインシデントを経験していました。PRAPIは何百ものプロダクトセットポッドで動作します。何百ものポッドがそれぞれ独自のスケジュールで独立してポーリングすることは、まさにそのようなインシデントを引き起こす集計です。
私たちはウォッチベースのKubernetesインフォーマーに切り替えました。起動時に、現在のEndpointSliceをリストしてリングをシードし、その後、変更をリアルタイムでストリーミングする永続的なウォッチを保持します。2秒のデバウンスは、スケールアップ中の迅速な変更を単一のリング更新に統合し、ポッドごとにリングが再構築されるのを防ぎます。
informer (list + watch)
|-- 起動時: 現在のEndpointSliceをリストし、リングをシード
+-- その後、各イベントで追加/更新/削除イベントをストリーミング
|-- ローカルのPer-Sliceキャッシュを更新
+-- 2秒のデバウンスでapplyLocalState()をスケジュール
+-- デバウンスは、ローリングデプロイのチャーンを単一のリング更新にまとめる
Kubernetes APIが利用できなくなった場合、最後の良好なエンドポイントセットがそのまま残ります。ロードバランサーは、一時的なAPIの不具合のために空のリングを提示することはありません。陳腐化は、HTTPレイヤーでの接続エラーと呼び出し元のリトライロジックによって処理されます。
最初にパイプラインを修正する
あることが最初に必要でした。なぜなら、次に続くことは、それを修正するまで不可能だったからです。すべてのフェードインカーブ、負荷信号実験、ゾーン試験には、多くの小さく高速なデプロイが必要でした。私たちが始めたパイプラインでは、せいぜい1日に1つしか管理できず、それさえも多くの場合、デプロイ中にアラームが鳴りました。
パイプラインは、何年にもわたる累積的な注意によって劣化していました。すべての手動承認ゲートと180秒のスリープは、過去のインシデントに対応して追加され、最終的な結果は、保護するシステムよりも遅く、脆弱なものになっていました。ビルドには21分かかりました。単一の機能デプロイは、一日中トラフィックステップを監視することを意味しました。中央値の実行時間は4時間49分で、記録された最悪のケースは4日21時間でした。
修正は3つのPRでした。ビルドキャッシュにより、壁時計時間は21分から12分に短縮されました。40以上の手動トラフィックステップを単一のCI/CDステップにまとめました。承認ゲートは廃止され、代わりにシーケンス化された市場グループロールアウト(テスト -> eu-0 -> eu-1 -> eu-2)が導入され、小さなリージョンが重要なeu-2リージョンに到達する前のアラームバッファとして使用されます。中央値のデプロイ時間は289分から128分に短縮されました。
CI/CDパイプラインの実行時間、最悪のほぼ5日から1〜2時間に短縮
チームがパイプラインを信頼すると、そのペースは