「前にロードバランサーを置けばいい」は、システム設計で最もよく聞く一言です。しかしその裏には、本番で効いてくるいくつもの判断が隠れています。どのレイヤーで動くのか、どうバックエンドを選ぶのか、バックエンドが健全だとどう判断するのか、そしてバックエンドを外すときに処理中のリクエストはどうなるのか、です。
この記事では、これらの判断を順に見ていき、多くの大規模なデプロイが最終的に行き着く 2 層構成を図にします。L7 プロキシのフリートの前に L4 バランサーを置く構成です。
L4 と L7
| L4 | L7 | |
|---|---|---|
| 見えるもの | IP アドレス、ポート、TCP または UDP の接続 | HTTP リクエスト:ホスト、パス、ヘッダー、Cookie |
| 分散する単位 | 接続 | リクエスト |
| TLS | そのまま通すか、終端する | 通常は終端する |
| ルーティングの基準 | ポート、送信元アドレス | パス、ホスト、ヘッダー、メソッド |
| ほかにできること | 非常に高いスループット、低いレイテンシー | 再試行、タイムアウト、書き換え、認証、レート制限 |
| 例 | クラウドのネットワークロードバランサー、IPVS、Maglev 型のバランサー | Envoy、NGINX、HTTP モードの HAProxy、クラウドのアプリケーションロードバランサー |
痛い目を見る違いは、L4 バランサーが分散するのはリクエストではなく接続だという点です。HTTP/2 や gRPC では、クライアントは長寿命の接続を 1 本開いて何千ものリクエストを送り、そのすべてが同じバックエンドに届きます。gRPC をうまく分散するには、L7 プロキシかクライアント側のロードバランシングが必要です。
なぜ 2 層にするのか
大規模な構成では、たいてい両方を重ねます。L4 の層が公開 IP アドレスを持ち、接続の殺到を吸収し、フローごとのハッシュで L7 プロキシのフリートに接続を分散します。プロキシは TLS を終端し、ルーティングルールを適用して、個々のリクエストをバックエンドのプールに分散します。
それぞれの層は独立してスケールします。L4 の層はパケットと接続に応じて、プロキシのフリートはリクエストと TLS ハンドシェイクに応じてです。分けておくと影響範囲も限定できます。プロキシの設定を誤って変更しても、影響を受けるのはある層の後ろにあるプロキシで、ユーザーが接続するアドレスではありません。さらに、プロキシはステートレスで 1 つの設定を共有しているので、1 台追加することはそのまま容量を追加することになります。
分散アルゴリズム
| アルゴリズム | 選び方 | 向いているケース | 注意点 |
|---|---|---|---|
| ラウンドロビン | 順番に次のバックエンド | 同一のバックエンド、似たリクエスト | 遅いリクエストが遅いバックエンドにたまる |
| 重み付きラウンドロビン | 重みに比例したラウンドロビン | インスタンスサイズの混在、段階的な移行 | 重みが静的 |
| Least connections | 開いている接続が最も少ないもの | 長寿命の接続 | バランサーにグローバルな状態が必要 |
| Least request(power of two choices) | ランダムに 2 つ選び、空いているほうに送る | ほとんどの HTTP サービス | バックエンドごとのリクエスト数が必要 |
| コンシステントハッシュ | ユーザー ID などのキーのハッシュ | キャッシュ、アフィニティ | ホットキーで負荷が偏る |
2 つをランダムに選ぶ least request は、HTTP サービスにとって良いデフォルトです。Envoy のロードバランサーのドキュメント はこう説明しています。ランダムに 2 つのホストを選び(デフォルトは 2)、アクティブなリクエストが少ないほうにリクエストを送る。Mitzenmacher らの研究によれば、これはすべてのバックエンドを調べるのとほぼ同じくらい良い結果を、そのコストなしで得られます。ring hash や Maglev アルゴリズムのようなコンシステントハッシュは、同じキーを同じバックエンドに保ち、バックエンドが変わってもごく一部のキーしか移動させません。キャッシュが求めるのはまさにこれです。
ヘルスチェック
バランサーは、処理できないバックエンドにトラフィックを送るのをやめなければなりません。2 つの仕組みが連携します。
- アクティブチェック: バランサーがヘルスエンドポイントを呼び出します。たとえば 5 秒ごとに
GET /healthzを呼び、一定回数続けて失敗したバックエンドを外します。 - パッシブチェック(外れ値検出): バランサーが実際のレスポンスを監視し、エラーを返したりタイムアウトしたりするバックエンドを除外して、クールダウン期間の後に戻します。
ヘルスエンドポイントは、正直でありながら軽くしておきます。すべての依存先を確認すると、データベースが 1 つ遅くなっただけで全バックエンドが一斉に不健全となり、バランサーはトラフィックの送り先を失います。このインスタンスがリクエストを処理するのに必要なものだけを確認し、共有の依存先はアプリケーション自身のエラー処理に任せましょう。
ドレインとスティッキーセッション
デプロイやスケールインでバックエンドを外すときは、新しいリクエストの受け付けを止めつつ、処理中のリクエストは完了させるべきです。通常のリクエストで最も遅いものより長いドレイン期間(クラウドのバランサーでは登録解除の遅延と呼ばれます)を設定し、アプリケーションが終了時に新しい仕事を受け付けないようにします。
スティッキーセッションは、通常は Cookie を使って、ユーザーを毎回同じバックエンドに送ります。状態がサーバーのメモリーにある場合には役立ちますが、それ以外のすべてを悪くします。負荷の偏り、バックエンドが落ちたときのセッション消失、デプロイの難しさです。セッションの状態は共有ストアに移し、できるところではスティッキーをオフにしましょう。
1 つのリージョンを越えて
ここまでの話はすべて 1 つのサイトの中で起きます。ユーザーをリージョン間に分散するのは別のレイヤーです。最も近い健全なリージョンを返す DNS ベースのルーティング(レイテンシーや地理位置によるルーティング)や、多くのサイトが同じ IP アドレスを広告し、インターネットのルーティングで各ユーザーを近くのサイトに届けるエニーキャストがあります。グローバルなバランシングがリージョンを選び、この記事の 2 つの層がサーバーを選びます。ヘルスのシグナルは上位に伝わるようにしておきましょう。そうすれば、プロキシがプールに到達できないリージョンは、ユーザーにエラーを返す代わりに、ユーザーを受け付けなくなります。
アーキテクチャ図
ダイアグラムの内容
- Clients
- DNS: one name
- Layer 4 load balancer
- Layer 7 proxy 1
- Layer 7 proxy 2
- Orders pool: 6 instances
- Search pool: 4 instances
- Static files bucket
- Proxy fleet, same config
- resolve
- TCP 443
- flow hash
- /api/orders
- /api/search
- /static
図の読み方は次のとおりです。
- 左: クライアントは 1 つの DNS 名を解決し、L4 ロードバランサーに対してポート 443 で TCP 接続を開きます。
- 中央: L4 の層がフローハッシュでプロキシのフリートに接続を分散します。フレームは、すべてのプロキシが同じ設定で動いていることを示しています。
- 右: プロキシが TLS を終端し、各リクエストをパスでルーティングします。
/api/ordersと/api/searchはそれぞれのプールへ、/staticはバケットへ送られます。 - Markdown ブロック には、ルーティングテーブル、プールごとのアルゴリズム、ヘルスチェックの設定を書いています。ふだんは設定ファイルの中にしかない詳細です。
ロードバランサーの図に書くべきラベル
- 各ホップのレイヤーとプロトコル: TCP 443、HTTP/2、gRPC。
- TLS を終端する場所。 各層から何が見えるか、証明書がどこに置かれるかが決まります。
- プールごとのアルゴリズム: ハッシュを使うならハッシュのキーも書きます。
- ヘルスチェックのパス、間隔、しきい値。
- ドレインとスティッキーの設定: あれば書きます。
ルーティングの上に載る L7 の機能は API ゲートウェイのアーキテクチャ図 で、同じ考え方をクラスター内で使う例は Kubernetes Ingress の図 で、ネットワーク全体の中でのロードバランサーの位置は ネットワークトポロジーの図 で扱っています。
Archboard で作る
- Google または GitHub でサインインし、図を作成します。
- C4 モデルパックからクライアント用に 人物 を、ネットワークパックから DNS と ロードバランサー を追加します。
- 基本パックからプロキシのフリート用に フレーム を追加し、その中に コンテナ ノードを 2 つ置きます。
- クラウドパックから、プール用に コンピューティング ノードを 2 つ、バケット用に オブジェクトストレージ ノードを 1 つ追加します。
- エッジにプロトコルとルートのラベルを付け、ルーティングテーブルとヘルスチェックを書いた基本パックの Markdown ブロックを追加します。
この例で使うダイアグラムノードは 10 個で、Free プランの範囲に収まります。
よくある質問
L4 ロードバランサーと L7 ロードバランサーの違いは何ですか? L4 バランサーは TCP や UDP の接続を、中身を見ずに分散します。L7 バランサーは HTTP を理解するので、パスやホストでルーティングし、個々のリクエストを分散できます。
どのロードバランシングアルゴリズムを使うべきですか? 2 つをランダムに選ぶ least request は、HTTP にとって強力なデフォルトです。キャッシュのように同じキーを同じバックエンドに届けたい場合は、コンシステントハッシュを使います。
gRPC サービスがすべてのトラフィックを 1 つの Pod に送るのはなぜですか? gRPC は長寿命の HTTP/2 接続を使い、L4 バランサーはリクエストではなく接続を分散するからです。間に L7 プロキシを入れるか、クライアント側のロードバランシングを使います。
ヘルスチェックのエンドポイントは何を確認すべきですか? このインスタンスがリクエストを処理できることです。プロセスが動いていて、自分の重要なリソースが使える状態かどうかを確認します。全バックエンドを一斉に失敗させるような共有の依存先は確認しないようにします。
スティッキーセッションは良い考えですか? 状態をサーバーの外に出せない場合に限ります。負荷の偏りや障害時のセッション消失を招くので、共有のセッションストアのほうがたいてい良い選択です。