
2026/07/01 20:35
1 秒間で 100 万件のリクエストに対応するクライアントサイドの負荷分散
RSS: https://news.ycombinator.com/rss
要約▶
Japanese 訳:
Zalando は、共有 Skipper ingress ロードバランサー上で発生した内部の高容量ファンアウトトラフィックによる重要なパフォーマンスのボトルネックを解消するために、Product Read API(PRAPI)の大規模なリファクタを実行しました。これらのリクエストを新規のクライアントサイドロードバランシング(CSLB)スタンドアロン JVM モジュールへ移行することで、キャッシュのパリティは同一のxxHash64アルゴリズムを使用して維持しつつ、速度とコストの大幅な改善を達成しました。アーキテクチャの変更では、ポーリングを廃止し 2 秒のdebounce を適用したウォッチベースの Kubernetes informerを採用することで、コントロールプレーンの枯渇を防ぎました。安全なリリースを確保するため、チームはパーセンテージベースの段階的導入戦略、アラームバッファ、ビルドキャッシングによるデプロイパイプラインの最適化、「N-ring fade-in」機構を導入しました。「N-ring fade-in」機構はスケーリングアップに伴う遅延に対応すると同時に読み込みバーストを引き起こさないように設計されています。追加の最適化として、HPA ロードシグナルをリクエストスループットから**占有度(occupancy)**へ切り替え、ポッド総数を 25% 以上削減し、日常運用コストを 450 ドルから約 110 ドル(および追加の 1,000 ドル以上)へと削減しました。さらなる強化策には、リトライポリシーの厳格化、過負荷保護のための FIFO バッファの追加、デスチネイションポッド IP のログ記録が含まれます。本プロジェクトは、アルゴリズム的な調整単独よりも、詳細なルーティングテlemetry を所有する方が価値が高く、共有リソースによって以前には隠蔽されていた潜在的インフラストラクチャの不具合を明らかにすることを示しました。
本文
高負荷 API の「共有エッジロードバランサー」からの脱却:クライアントサイドロードバランシング(CSLB)への移行と最適化
Zalando の主要サービスである Product Read API (PRAPI) では、毎秒数百万件のリクエストを処理し、ミリ秒単位のレイテンシーを実現しています。しかし、共有インフラとして利用していた Skipper を介した内部トラフィックにより、重大なパフォーマンス問題が発生していました。この記事では、共有ロードバランサーからの移行と、自社工場で開発した最適化手法について解説します。
1. 課題:共有エッジロードバランサー「Skipper」の罠
サービス開始当初、PRAPI は Skipper という Kubernetes エンゲレスコントローラー(HTTP ルーター)を Edge ロードバランサーとして使用していました。
- 目的: キャッシュ locality を高めるため、同じ製品 ID を同じポッド群へルーティングする「一貫性ハッシュルーティング」を実装。
- 問題点: Skipper はバッチ処理エンドポイント(1 リクエストで最大 100 ポッドへの呼び出し)において以下の弱点を露呈しました。
- 「くしゃみと風邪」: Skipper が数マイクロ秒の遅延を起こすと、直下流の PRAPI がその影響を大きく受けてレイテンシーが跳ね上がりました。
- 原因不明: レイテンピークの原因が「Skipper 自身」なのか「自社のコード」なのか判断できず、デバッグに時間を要しました。
- 共有インフラのリスク: Skipper を直接管理できないため、振る舞いを分離することが困難でした。
解決策:ファンアウトパスの移行
共有 Skipper を経由する内部トラフィックは、プロセス内で実行する**クライアントサイドロードバランシング(CSLB)**へ移行することを決断しました。
- 移行方針:
- Edge ロードバランサーとしての Skipper は維持する。
- 高ファンアウトの内部トラフィックのみを CSLB にシフトさせる(Skipper の置換ではなく、パスの分岐)。
2. アプローチ:「同じハッシュリング」の実装
移行において最も重要な制約は**ハッシュの平行性(Hash Parity)**でした。
- 要件: Skipper と自社 CSLB が、常に同じ製品ポッドプールのいずれかをリクエストへ割り当てる必要があります。
- リスク: ハッシュリングが合致しなければ、キャッシュ分断が発生し、DynamoDB への読み込み負荷が 2 倍になります。
実装の鍵:Skipper と同一のアルゴリズム
Skipper が使用する仕組みを完全に複製しました。
- ハッシュ関数:
を使用。xxHash64 - リング構造:
- 64 ビットのハッシュリング上に、各エンドポイントを 100 の位置に配置。
- 時計回り方向の最も近いエンドポイントを探す。
- 安定性の保証:
- ユニットテストで Banc(銀行)により、両者のリングが同一であることを断言。
- 毎回のビルドで実行し、「静かなずけ(Silent Drift)」を防ぐ。
Kubernetes Discovery:ポーリングから Watch へ移行
Kubernetes API を頻繁にポーリングするアプローチは、コントロールプレーンを停止させるインシデントを引き起こすリスクがありました。これに対処するため、以下の**Informer(リスト + ウォッチ)**方式へ変更しました。
- 起動時: 現在の
リストを参照しリングを初期化。EndpointSlices - 以後: リアルタイムの変更点をストリーミングする Watch を保持。
- デボーン処理: スケールアップ時の急激な変化を単一のリング更新に統合(約 2 秒)。
informer (リスト + ウォッチ)
- 起動時: 現在の EndpointSlices をリストアップし、リングを初期化
- その後ストリーミングで追加/更新/削除イベントを受け取る
- 各イベントあたり:
- ローカルパー・スライスキャッシュを更新
- 2 秒デボーン付きの applyLocalState() をスケジュール
- デボーンにより、ロールアウト中のチャンジョールを単一のリング更新に統合
- 各イベントあたり:
3. パイプラインの修復:高速かつ安全なデプロイ
最適化の実施には、信頼できるデプロイメントパイプラインが必要です。過去の課題は以下の通りでした。
- ビルド時間: 21 分 → 手動ステップが多い。
- ロールアウト: 1 つの機能変更でも数日間(中央値で 4 時間 49 分)かかり、大規模なバビーシティングが必要だった。
改善策
以下の 3 つの PR でパイプラインを劇的に改善しました。
- ビルドキャッシングの導入: ビルド時間を 12 分に短縮。
- CI/CD の統合: 40 に及ぶ手動ステップを自動化。
- 承認ゲートの廃止: 小規模なリージョン(eu-0, eu-1)でロールアウトし、安定後に全展開(eu-2)へ移行するアプローチへ変更。
結果:
- 中央値デプロイメント時間:289 分 → 128 分へ減少。
- CI/CD パイプライン実行時間の最悪ケース:ほぼ 5 日 → 1〜2 時間へ短縮。
- 影響: チームがパイプラインを信頼するようになり、実験コストが低下しました。
安全なロールアウト戦略
段階的な切り替えを行うためのトグル(切り替えスイッチ)を実装しました。
| トグル名 | 機能 |
|---|---|
| CSLB のオン/オフ制御 |
| 0〜100 のトラフィックランプ(CSLB と Skipper バックアップ経路の比率) |
| 暗黙的な Skipper バックアップ | クライアントサイド経由で失敗時や未ルーティング時のフォールバックパス |
- ロールアウト手順:
- Canary マーケットグループで 1% から開始。
- 段階的に 10%, 50% と増加。
- 全市場グループで 100% に到達。
- 監視指標: レイテンシー、エラー率、キャッシュヒット率の即時比較。
成果:
- Skipper を経由するトラフィックはピーク時にほぼゼロに収束。
- 日次 Skipper ノードグループのコスト:約 $450 → 約 $110 へ削減。
- Skipper フリート(50 ポッド以上)を8 ポッドまで削減し、コストプロジェクトから運用性向上プロジェクトへ転換。
4. スケールアップスプライクの排除:N-Ring フェードイン
Skipper から除去したことで残った問題として、「新ポッド追加時の冷キャッシュ」と「スケールアップスプライク」がありました。
N-Ring フェードイン手法
従来の確率的プリフィルタでは、フェード完了後に製品がサービスできない状態が残るリスクがありました。これに対し、N-Ring手法を導入しました。
- 仕組み: 各スケールイベントで、既存リングのスーパーセット(新リング)を作成。新リングのみを独立してフェードインさせます。
- カーブ: 緩やかなスタート、急激な完了を制御(例:30 秒で完了)。
N-Ring フェードインの効果:
- 正しい製品 ID を持つポッドのみをウォームアップ可能。
- キャッシュエントリーの無駄やエビジョンチャンジョールを排除。
- ポッド追加時、各リングでの位置は同一のため、定常状態に近いトラフィック分担を実現。
| 経過時間 | 進行度 | トラフィックシェア |
|---|---|---|
| 3 秒 | 10% | 0.3% |
| 9 秒 | 30% | 4.9% |
| 15 秒 | 50% | 17.7% |
| 21 秒 | 70% | 41.0% |
| 27 秒 | 90% | 76.8% |
| 30 秒 | 100% | 100% |
注:最初のフェードイン完了中に第 2 のスケールイベントが発生した場合、各イベントは独立したウィンドウを持ち、重ならないように制御されます。
5. ポッド占有度の制御とバウンデッドロード
定常状態において一部のポッドが過熱し、他のポッドがアイドル状態という不均一性を解消するため、「ポッド占有度(Occupancy)」を指標として採用しました。
なぜ「In-flight」ではダメか?
- In-flight(進行中)のリクエスト数は、高速なキャッシュヒットと低速な DynamoDB ミスを見分けることができないため、不正確な指標でした。
- シンプルにリクエスト数で判定すると、苦しんでいるポッドを早期にスケールアウトする必要が出てき、余剰容量が浪費されます。
正しい信号:Occupancy(占有度)
- 定義: 「1 秒あたりの作業時間」。
- 計算式:
occupancy = total_occupied_time / window_duration- パルス間のカウントがゼロになっても、作業時間に基づいて負荷を正確に評価。
スループットミスの修正とリトルの法則(Little's Law)
初期実装ではスループット(1 秒間のリクエスト数)を使用しましたが、これではキャッシュヒット率の高い短時間リクエストが過大評価されました。
- 解決策: キューイング理論に基づくリトルの法則適用。 [ L = \lambda W ] (平均同時並行数 = 到着率 × 平均サービス時間)
- 実装: スライディング時間ウィンドウ内で「継続時間を蓄積」し、時間で割ることで負荷を正しく測定。
信号の比較:
| 信号 | 報告負荷 | 理由付け |
|---|---|---|
| In-flight | 0 | パルス間サンプリングで何もしない場合 |
| Throughput | 1,000 req/s | 継続時間に関係なくカウント |
| Occupancy | ~1.0 | 1,000 x 1ms = 1.0 秒分の作業/秒 |
バウンデッドロードの最適化
- 基本動作: ポッド負荷が平均を超えると、リングを時計回りに探索(ウォーク)して負荷が少ない候補を探す。
- 有効負荷の計算: レイテンシーも考慮した重み付けを実装。
[ effectiveLoad = max(inflight, occupancy) \times min(podLatency / globalLatency, 5) ]
- スローなポッド(高いレイテンシ)を適切に重み付けし、トラフィックから迂回させる。
- ウォークの上限: 最悪の場合、リング全体を探索するのを防ぎ、キャッシュヒット率低下を防ぐため、10 ホップに制限。
インパクト
- 占有度バンド: 0.4〜1.3 の広がり → 0.6〜0.9の緊密な帯へ収束。
- バウンデッドロード係数: 慎重な 1.10 から、余剰容量を活かせるように緩和された 1.25へ。
- Horizontal Pod Autoscaler 閾値: CPU 65%(以前は 50%)へ引き上げ。
- 結果: ポッド数 25% 以上減少し、コスト削減が日額 $1,000 を超えるように。
6. AZ-Aware(リージョン意識型)ルーティングの挑戦
最後に残ったフロンティアは、利用可能なゾーン(AZ)間でのルーティングでした。
- 課題: Skipper を経由すると約 2/3 のホップが AZ 境界を跨ぎ、データ転送コストとレイテンシが発生します。
- リスク: 局所ゾーンに十分なポッドがない場合、キャッシュフラグメンテーション(分断)を引き起こす可能性があります。
慎重なフェードイン
- ランダムにゾーンアフィニティを ON にせず、初期には全ゾーン待機期間中に局所キャッシュをウォームアップ。
- レイテンシーヘルス係数を監視し、局所レイテンシが設定マージン(35%)以上ドリフトすると、自動的に 1% のプローブフロアへ抑制。
バウンデッドロードの再計算
フェードイン中に、局所ポッドは両リング(局所分・全域分)からのトラフィックを受け持つため、単純なクラスター平均では過負荷に見えました。
- 解決策: 各ポッドが実際に受け持つ負荷に基づいた動的閾値を計算。 [ threshold = (loadPerLocalPod + loadPerGlobalPod) \times balanceFactor ]
- これにより、フェードイン中の非対称性を無視せず、真の外れ値のみでトリガーされるよう調整。
2 つのフェードインの衝突
- 問題: ポッド追加時の N-Ring フェードイン と、ゾーン切り替えのフェードアウトが同時に発生する際のエッジケース。
- 現状: アルゴリズムと Safeguards(安全装置)は整備済みですが、長時間稼働させるには On-Call チームによる監視が必要となり、現在は一時停止状態です。
7. ファンアウトパスのハードニング
CSLB を導入したことで、再試行やタイムアウトなどの制御権が自社に戻り、より強力なレイテンシ保護が可能になりました。
フェイルセーフとファウルオーバー
- 再試行ポリシー: 緊密なバックオフを持つ単一の高速再試行へ。トランスポート障害や 5xx に対してのみ発火。
- FIFO バッファ: 過負荷フィルターにより、キャップを超えた入力却下を防止し、キュー自体が自己排水(完了待機)。
- ログの強化: 失敗時に「呼び出し先のポッド IP」および「ノード」を記録。これにより、ネットワークレイヤーやノードレベルでのフリーズを検知可能に。
結果:PRAPI の高可用性
- フリーズ中のノードは、他の健康なポッドへの自動迂回により影響が限定されます(1 ノード上の数ポッドのみ)。
- システム全体としては「3 時のページコール」から「グラフの微細な揺らぎ」へと変化。
- インパクト: PRAPI は共有インフラからの依存性を脱却し、独自の失敗面を掌握しました。
8. レッスンと結論
得られた学び
- キャッシュローカリティ vs コスト削減のトレードオフ: AZ アフィニティはコスト削減に寄与しますが、キャッシュ分断リスクがあります。各パーティションがホット製品をカバーできるか確認が必要です。
- パイプラインの重要性: スローなデプロイメントはリスクを高めます。小規模なインクリメンタルな変更と自動アラームバッファを持つパイプラインが安全です。
- 所有即責任: ルーティング決定を所有すると、テレメトリも同時におさえます。共有インフラに隠れていた障害(例:特定のノードのフリーズ)が見えるようになります。
- コスト削減の総額: Skipper フリート削減 + 占有度ベースの最適化により、日額 $1,000 超のコスト削減を実現しました。
次に何をすべきか?
現在は AZ-Aware ルーティングが唯一の未完了スレッドです。
- エコノミーとトレードオフを慎重に検証中。
- 高トラフィック時にはホットセットをカバーするだけのポッドを持ち、大規模な節約につながる可能性があります。
自作すべきか?
一般的には「やらない」のが賢明です。成熟したプロキシ(Skipper, Envoy など)が標準機能を提供します。しかし、共有インフラの暴露面積が過大になりやすい場合や、極めて高いパフォーマンス要件がある場合には、自社でリングを実装し、詳細な可視性を確保することが有効であることが証明されました。
参考: Zalando の SPP Product Data Serving チームによる実装事例に基づく.