待ち行列を捌く以外に、案内係がやっていること
안녕하신게라!パナソニック コネクト株式会社クラウドソリューション部の加賀です。
前回「コンテナとDockerの基本の"キ"」では、アプリと依存ライブラリを丸ごと箱に詰め、どこへ運んでも同じように動く仕組みを追いかけました。イメージさえあれば同じ箱は何個でも複製できて、起動は数百ミリ秒。
次にやりたいことは決まっています。同じ箱を何個も立てて、仕事を分担させたい。同じイメージから docker run を叩くだけで、1個が2個に、2個が10個になります。
ところが、箱を10個立てただけでは何も起きません。ユーザのブラウザが知っているURLは1つきりで、10個の箱の存在など知る由もないからです。1つの宛先で受けて、10個のどれかへ渡す案内係が要る。それが今回の主役、ロードバランサ(負荷分散装置)です。
「TCP/UDPとポートの基本の"キ"」でIPアドレスをオフィスビルの住所、ポート番号をビル内の窓口の宛名にたとえました。この記事では、そのビルの入口に立つ案内係の仕事を、次の7つに分けて見ていきます。
- そもそもサーバを並べる意味と、1台を強くする道との違い
- サーバを並べたとき、DNSだけでは何が足りないのか
- ロードバランサが、振り分け以外に手間をかけている仕事と、その設定の勘どころ
- L4とL7の違いと、どちらを選ぶかの判断基準
- 暗号化された通信の中身を、どこで誰が開けているのか
- 複数台に配った瞬間に壊れる「ログイン」と、無停止デプロイの作法
- ここまでの話が、Kubernetesのどこに対応しているのか
急いでいる方は、最初の3つまでで「案内係が何をしている人か」は掴めます。4つ目から先は、現場で「なぜか繋がらない」に出くわしてから戻ってくる章だと思ってください。
設定名や既定値は、断りのない限りAWS(ALBとNLB)のものを使います。製品が変われば名前も値も変わりますが、つまみの意味はだいたい共通なので、お手元の環境に読み替えてください。
なお、ここから先は「サーバ」と書きますが、その実体が物理マシンでも、EC2のような仮想マシンでも、前回のコンテナでも、この先の話は変わりません1。
1台を強くするか、1台に頼るのをやめるか
そもそも、なぜサーバを並べるのか。リクエストを処理しきれなくなったとき、打てる手は大きく2つに分かれます。
ひとつはスケールアップ(垂直スケール)。CPUとメモリをより強力なものに載せ替える、クラウドならインスタンスタイプを一段上げる、という力技です。アプリを1行も直さずに済むのが最大の魅力で、「とりあえず今夜を乗り切る」場面では今でも現役の選択肢です。ただ、載せ替えには基本的に停止が伴いますし、どこかで「これ以上大きい機種がない」という天井に必ず当たります。
もうひとつがスケールアウト(水平スケール)。同じ働きをするサーバを2台、3台と横に並べます。天井がなく、必要なときだけ増やして不要になれば減らせるので、クラウドではこちらが主流です。
ただ、性能の話以上に大きいのが可用性の違いです。どれだけ豪華な1台を用意しても、その1台が止まればサービスも止まります。いわゆる単一障害点(SPOF: Single Point of Failure)で、こればかりはCPUを増やしても解決しません。台数を増やすというのは、性能を足す話であると同時に「1台の故障がサービス停止に直結しない」状態を作る話でもあるわけです。
そして横に並べた瞬間、冒頭の問題が戻ってきます。並べた2台へ、誰がどうやってリクエストを配るのか。
「DNSで複数返せばいいのでは?」
「DNSとIPアドレスの基本の"キ"」を読んだ方はこう思ったでしょう。1つのFQDNには複数のAレコードを持たせられます。確かめてみましょう。
# 1つのFQDNに複数のAレコードを持たせるのがDNSラウンドロビン
$ dig +short www.example.com A
192.0.2.10
192.0.2.11
192.0.2.12
これがDNSラウンドロビンで、実際に振り分けとして機能します。追加の機材も要らず、レコードを1行足すだけ。分散の第一歩としては悪くありません。
問題は、DNSが宛先サーバの生死をまったく知らないことです。192.0.2.11 のプロセスが落ちても、権威DNSサーバは何食わぬ顔でそのIPを配り続けます。しかもキャッシュがあるので、レコードを消しても各地のリゾルバやブラウザはTTLが切れるまで死んだIPを覚えたまま。運が悪いユーザは、その間ずっとエラー画面と睨めっこです。「浸透待ち」などと呼ばれた反映遅延が、こんな場面でも効いてきます。
どのIPを選ぶかがクライアント任せ、という点も見逃せません。3つ返したからといって、きれいに3等分されるとは限らないのです。
厳密には、クライアント側にも多少の救いはあります。複数のアドレスが返ってきたとき、1つ目に繋がらなければ次を試す挙動は多くのブラウザやOSに実装されていて、IPv4とIPv6の使い分けを定めた RFC 8305(Happy Eyeballs v2)では、前の候補の結果を待たずに推奨値250ミリ秒で次へ着手する、という手順まで決められています。ただし、この仕組みを持たない素朴なクライアントは接続タイムアウトを丸ごと待たされますし、どちらにせよ救えるのは「TCPの接続そのものが失敗する」場合だけです。
TCPは繋がるのにアプリが応答を返さないという一番ありがちな壊れ方には、対応できません。
DNSの側にもフェイルオーバーや加重ルーティングといった仕組みは用意されていて、これらをまとめてGSLB(Global Server Load Balancing)と呼ぶ話は前掲のDNS記事で触れました。ただそれらも、キャッシュとTTLという壁の外側には出られません。DNSはあくまで「どのビルへ行くか」を教える案内図であって、ビルの中の窓口の混み具合まで面倒を見る役ではないのです。
【コラム】世界中で同じIPを名乗る、という力技
キャッシュとTTLの壁を、まったく別の角度から殴り倒す方法もあります。Anycast です。
同じIPアドレスを世界中の拠点から、経路制御プロトコルのBGP(Border Gateway Protocol)で同時に広報し、経路的に最も近い拠点へ勝手に吸い込ませる。名乗る宛先が1つしかないので、DNSに複数の答えを返させる必要も、TTLが切れるのを待つ必要もありません。倒れた拠点の広報を止めれば、経路の収束だけで切り替わります。
AWS Global Accelerator は、この考え方をサービスにしたものです。固定のAnycast IPを払い出し(IPv4なら2つ、デュアルスタックならIPv6と合わせて4つ)、ユーザに一番近いエッジロケーションでいったん受けてから、AWSのバックボーンを通って裏のロードバランサへ運びます。GCPはもっと踏み込んでいて、グローバルの外部アプリケーションロードバランサは 単一のAnycast IPを持つのが標準です。リージョン単位の外部ロードバランサも別に用意されていますが、グローバルを選ぶ限り、リージョンごとに立ててDNSで振り分けるという手順自体が要りません。Azureにも同じ思想のFront Doorがあります。
「どのビルへ行くか」をDNSに教えてもらう代わりに、ネットワークの経路制御に決めさせる。先ほどのGSLBとは土俵が違う解き方です。
弱点もあります。経路制御に任せるというのは、経路が揺れれば行き先も揺れるということでもあります。BGPの収束の途中で別の拠点へ吸い込まれると、確立済みのTCPコネクションはあっさり切れる。問い合わせと返事で1往復して終わるDNSとは相性が良い反面、長く繋ぎっぱなしにする通信では、張り直しが起きうる前提で設計する必要があります。
そして何より、これらのアプローチが引き受けるのは「どのビルへ行くか」の案内までで、ビルの中の窓口が空いているかどうかは、やはり別の誰かが見に行く必要があります。
欲しいのは、通信の経路上に立っていて、いまこの瞬間のサーバの生死を知っている装置です。それがロードバランサです。
案内係が、長い行列よりも長く見ているものとは
「負荷分散装置」という日本語訳のせいで、アクセスを均等に配る装置という印象が先に立ちます。配り方(アルゴリズム)自体は、それほど複雑ではありません。順番に配るラウンドロビン、いま抱えている接続数が最も少ない相手を選ぶ最小接続数、送信元IPなどをハッシュ化して常に同じ相手へ送るハッシュ方式。この3つを押さえておけば、たいていの製品の設定画面は読めます。
ただし、既定のラウンドロビンで動いているロードバランサは、振り分け先のCPU使用率もメモリ残量も一切見ていません。ただ順番に配っているだけです。そのため「重いリクエストばかり引き当てた1台だけが瀕死」という偏りは普通に起きます。これを緩和するのが最小接続数や、AWSのALBが持つ Least Outstanding Requests(未処理リクエストが最も少ない相手を選ぶ)といった方式で、「処理中の数」という間接的な指標で混み具合を推し量っています。
とはいえ、クラウド側はその先へ進み始めています。ALBには Automatic Target Weights(ATW)という仕組みがあり、各ターゲットのHTTP 5xxと接続失敗の割合を常時監視して、周囲から明らかに外れた1台を「異常」と判定します(判定には最低3台の正常なターゲットが必要です)。この検知はすべてのALBで常時動いており、止めることも出来ません。ただし、見つけた1台へ流す量を自動で絞る「異常緩和」のほうは既定でオフです。ルーティングのアルゴリズムに Weighted Random を選び、そのうえで緩和をオンにして初めて、重みが自動で下がり、回復すれば徐々に戻ります。見ているのはCPUではなくエラー率ですが、「結果から調子を推し量って重みを変える」という点では相当に踏み込んだ振る舞いです。
それでも、負荷そのものを見て配ってくれるわけではありません。「バランサという名前だから勝手に平等になるはず」という期待は、捨てておくのが安全です。
そして、案内係が行列よりも長く見ているのは、来客ではなく窓口のほうです。ロードバランサの仕事は振り分けだけではありません。むしろこちらが本業と言えるのが、ヘルスチェック(死活監視)です。
ロードバランサは、裏に控えるサーバへ定期的に「生きてる?」と声をかけます。TCPで繋がるかを確かめるだけの軽いものから、GET /healthz を投げてステータスコードを見るものまで様々ですが、やっていることは同じで、決められた回数だけ連続で失敗した相手を振り分け先から静かに外します。復活したら、また静かに戻す。
ユーザから見ると、裏で1台死んでいることに気づく手立てがありません。エラー画面も出ず、深夜に叩き起こされることもなく、朝になってグラフを見て「あ、2時頃に1台落ちてたんですね」と知る。この地味さこそがロードバランサの価値です。
設定のつまみは、製品が変わってもだいたい3つに集約されます。何秒おきに聞くか(間隔)、何秒待って諦めるか(タイムアウト)、何回連続で失敗したら切り離すか(閾値)。この3つで「障害が起きてから切り離されるまでの時間」がほぼ決まるので、短くすれば復旧は速くなり、その代わり一瞬の詰まりで健全なサーバまで蹴り出す事故が増えます。
ここは実数で見ると実感が湧きます。AWSのALBの既定は、間隔30秒・タイムアウト5秒・閾値2回。サーバが黙った瞬間から切り離されるまで、最悪で1分強の空白があります。その間に当たったユーザには、普通にエラーが返ります。「ロードバランサを入れたのに障害時にエラーが出た」の多くは、壊れているのではなくこの空白を見ています。
【コラム】ヘルスチェックは、浅すぎても深すぎても事故る
浅すぎる例から。TCPで繋がるかどうかだけを見ていると、「プロセスは生きているがアプリは何も返せない」状態を見抜けません。Javaのプロセスがフルガベージコレクションで固まっていても、OSがTCPの接続だけは受け付けてしまい、ロードバランサは元気だと判断してリクエストを送り続けます。ユーザにはタイムアウトが返り、監視は緑のまま。背筋が凍る類の障害です。
では深くすればいいのかというと、これが逆方向の罠です。/healthz の中でDBへ疎通確認のクエリを投げる設計にしていると、DBが不調になった瞬間、全サーバが一斉にUnhealthyになります。アプリ自体は静的なページくらい返せたかもしれないのに、ロードバランサが全台を切り離してサービスが完全停止する。1つの故障が全体の停止に化ける、典型的な連鎖障害です。
ちなみにAWSのALBには、対象が全滅した場合はあえて全ターゲットへリクエストを流す(fail open)という挙動があります。「どうせ全部ダメなら、可能性のある方に賭ける」という割り切りで、この手の全滅事故に対する最後の保険になっています。
落としどころは、ヘルスチェック用のパスを専用に用意し、自プロセスが応答可能かどうかだけを軽く返すこと。加えて、監視用のパスはアクセスログから除外しないと、ログが「生きてる?」の記録で埋め尽くされます。実際これをやらかして、ログの保管料金だけがじわじわ膨らんだことがあります。
宛名だけ見るL4、中身も見るL7
ロードバランサは、判断材料としてどこまで中身を覗くかによって2種類に分かれます。「TCP/UDPとポートの基本の"キ"」で扱ったトランスポート層までしか見ないのがL4、その上のアプリケーション層まで開封するのがL7です2。
| 観点 | L4ロードバランサ | L7ロードバランサ |
|---|---|---|
| 見ている情報 | 宛先IPとポート番号、TCP/UDPのヘッダ | URLのパス、Hostヘッダ、Cookie、HTTPメソッド |
| 判断の粒度 | コネクション単位(一度決めたら切れるまで同じ相手) | リクエスト単位(同じ接続でも1本ごとに宛先を選べる) |
| ヘルスチェック | TCPが繋がるか、ポートが開いているか | ステータスコードやレスポンス本文まで判定できる |
| 処理の重さ | 軽い。遅延も小さく、桁違いのトラフィックを捌ける | 中身を解析するぶん重い |
| 代表例 | LVS(Linux Virtual Server/IPVS)、AWS NLB、Azure Load Balancer | nginx、HAProxy、Envoy、AWS ALB、Azure Application Gateway |
| 向く用途 | データベース、ゲームやVoIPのUDP通信、極端な量のTCP接続 | HTTPのWebサービス全般、マイクロサービスの入口 |
L4は、荷物の宛名だけを見て仕分けする配送センターです。中身は開けません。「443番宛てだから、空いてるサーバの443番へそのまま流す」という単純作業に徹しているぶん高速で、UDPも扱えます。
ただし、表の「ヘルスチェック」欄はL4の素の姿です。AWSのNLBやAzure Load Balancerは、配るときの判断こそL4のままですが、死活監視だけはHTTPやHTTPSで投げてステータスコードを見ることも出来ます(NLBの既定はTCP)。「配るときに何を見るか」と「生きているかをどう確かめるか」は別々に選べる、と覚えておくと混乱しません。
【コラム】戻りの荷物は、案内係を通らなくていい
L4の話が出たので、物理機器の時代の余談をひとつ。ロードバランサを経路上に挟むと、行きも帰りも全部そこを通ります。動画配信のように「要求は1KB、応答は1GB」という極端に非対称な通信だと、戻りの帯域まで案内係に背負わせることになる。そんなん割に合わへんやろ、という発想から生まれたのが DSR(Direct Server Return、直接サーバ返送)です。LVSでいうDRモード、F5ならnPath routingと呼ばれてきました。
仕掛け自体は単純で、各サーバのループバックインタフェースに仮想IP(VIP)を持たせておき、行きだけロードバランサを通し、戻りはサーバからクライアントへ直接返します。ロードバランサは行きの帯域しか担がないので、同じ機材で桁違いの流量を捌けました。その代わり、全台を同じL2セグメントに並べる必要があり、ARPの応答を抑制する設定(Linuxなら arp_ignore と arp_announce)を1台でも入れ忘れると、VIPの持ち主が入り乱れて阿鼻叫喚になります。ポート変換もできず、当然L7の芸当も一切ありません。
過去の技術に聞こえますが、考え方はクラウドにも残っています。Azure Load Balancerはもともと戻りがロードバランサを経由しないパススルー型で、そこに「Floating IP」を有効にすると、宛先IPを書き換えずフロントエンドのIPのままバックエンドへ届くようになります。DSRのうち「サーバ側にVIPを持たせる」というIPマッピングの部分だけを取り出したもので、こちらもゲストOS側でループバックへVIPを追加する作業が要る点まで同じ。SQL Serverの可用性グループのリスナーなどで現役です。
対してL7は、封を開けて中身を読んでから配ります。読めるからこそ、こんな芸当ができます。
同じドメインなのに、/api/ で始まるリクエストはAPIの箱へ、/images/ は静的配信の箱へ。これがパスベースルーティングで、Hostヘッダを見て別サービスへ振り分けることも出来ます。1つのロードバランサの裏に複数のサービスを同居させられるので、マイクロサービスの入口はほぼ例外なくL7です。
ところで、先ほどの比較表で代表例に並んだnginxやHAProxyは、専用の機器でもクラウドのサービスでもありません。普通のサーバに入れる、ただのソフトウェアです。
Webサーバには、受け取ったリクエストを自分で処理せずに裏のサーバへ代理で投げ、戻ってきた結果をユーザへ渡すリバースプロキシという働きがあります。この転送先を、1台ではなく複数登録して振り分けられるようにしたものが、そのままL7ロードバランサです。Apacheにも mod_proxy_balancer があり、昔から同じことが出来ました。クラウドのALBやApplication Gatewayは、この働きを自前で組んで冗長化して……という苦労を肩代わりしてくれる貸し出しサービスだと捉えると、両者の関係が見えてきます。
nginxなら、ここまでの話がこの程度の設定で形になります。
upstream app_backend { # 振り分け先の一覧
least_conn; # 抱えている接続が最も少ないサーバへ回す
server 192.0.2.10:8080 max_fails=3 fail_timeout=10s;
server 192.0.2.11:8080 max_fails=3 fail_timeout=10s;
server 192.0.2.12:8080 backup; # 上の2台が全滅したときだけ使う予備
}
server {
listen 80;
server_name www.example.com;
location /api/ { # パス単位に切り出した入口
proxy_pass http://app_backend;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
max_fails と fail_timeout が、この設定でのヘルスチェックにあたります。オープンソース版のnginxが持っているのは、実際のリクエストが失敗した回数を数える受動的な方式で、能動的に監視リクエストを送り続ける機能は商用のNGINX Plusの担当です。同じ「ヘルスチェック」という言葉でも中身が違うので、製品を比較するときは要注意です。
【コラム】502の犯人は、だいたいタイムアウト
ロードバランサを挟むと、それまで見たことのないエラーが出るようになります。切り分けの第一歩は、504(裏のサーバが時間内に応答を返さなかった)と502(裏のサーバとの通信そのものが壊れた)を混ぜないことです。
やっかいなのは、アプリが何も悪くないのに502が出るパターンです。ロードバランサは効率のため、裏のサーバとのTCP接続を使い回します(キープアライブ)。このとき サーバ側の待ち時間がロードバランサ側より短い と、事故が起きます。サーバが「もう誰も使うてへんな」と接続を閉じた、まさにその瞬間にロードバランサが同じ接続へリクエストを流し込み、行き先を失って502。数百回に1回だけ失敗する、再現性の低い不具合の正体はだいたいこれです。
悪いことに、既定値の組み合わせが最初から負ける側に向いています。AWSのALBのアイドルタイムアウトは既定60秒。対してGunicornの --keep-alive は既定2秒、Node.jsの server.keepAliveTimeout は5秒です。鉄則は 裏のサーバ側のタイムアウトを、ロードバランサ側より長くすること。ALBが60秒なら、アプリ側は75秒あたりにしておきます。nginxの keepalive_timeout の既定は75秒で、こちらは最初から勝っています。
なお「L4だからTLSを一切扱えない」というのは誤解で、たとえばAWSのNLBにもTLSリスナーがあり、暗号を解く仕事自体は引き受けてくれます。違いは解いた中身を見るかどうかで、L4は解いてもURLで振り分けたりはしません。
封が閉じたままでは、中身は読めない
L7は封を開ければ中身を読める、と書きました。ですが「SSL/TLSと証明書の基本の"キ"」を思い出してください。HTTPSの通信は暗号化されています。封を切っても、中身は暗号のまま。URLもCookieも読めないので、L7ロードバランサは入口で暗号を解いてから振り分けているわけです3。これをSSL/TLSターミネーション(オフロード)と呼びます。
これは振り分けのための手段であると同時に、運用上の大きなご褒美でもあります。裏に100台サーバが並んでいるとき、その100台すべてに証明書を配って更新し続けるのは控えめに言って地獄です。ターミネーションを使えば、証明書は入口の1箇所だけで済みます。更新も1箇所。暗号処理という重たいCPU仕事から裏側のサーバが解放されるおまけまで付いてきます。先ほどのnginxの設定で言えば、listen 80; を listen 443 ssl; に変えて証明書のパスを書き足した瞬間から、ここが暗号を解く担当になります。
そして暗号を解いた場所は、通信の中身を見られる唯一の場所でもあります。だからWAF(Web Application Firewall)、アクセスログ、リクエストの圧縮、認証といった機能は、こぞってロードバランサの周辺に集まってきます。もっとも便利さの裏返しで、入口が破られたときの影響もここに集中します。証明書の秘密鍵も、復号された通信も、ログに残る個人情報も、すべてこの1箇所に集まっているからです。裏側は平文HTTPで構わないのか、という点だけは要件次第で、社内ネットワークだから良しとする設計もあれば、ロードバランサから先も再度暗号化する設計もあります。
アプリのログから、送信元IPが消える日
ロードバランサを挟んだ翌日、アプリのアクセスログを開いて固まったことはないでしょうか。送信元IPが、全部ロードバランサのIPになっているのです。どこの誰が叩いたんか判らへん、と。
当然です。裏側のサーバから見れば、通信してきた相手はユーザではなくロードバランサなのですから。そこで使われるのが X-Forwarded-For(XFF)というHTTPヘッダで、先ほどのnginx設定にも出てきた「本来の送信元はこの人でした」という申し送りです。もともとは各実装が勝手に付けていた事実上の標準で、後から RFC 7239 が Forwarded ヘッダとして整理しました。
注意点が2つあります。まず、これはL7(HTTP)の話です。L4ロードバランサはヘッダを足せないので、送信元IPをそのまま透過する機能を使うか、HAProxy由来のPROXY protocol(TCP接続の冒頭に送信元情報を差し込む方式。AWSのNLBはv2に対応)で別途伝えることになります。
そしてもうひとつ、こちらの方が大事です。XFFは自己申告なので、そのまま信じてはいけません。攻撃者は好きな値を入れて送れるので、これを鵜呑みにしてIP制限やレート制限をかけると、簡単に迂回されます。信頼できるのは自分のロードバランサが付けた分だけ。ヘッダの何番目を採用するかを明示的に設定する必要があります。この「どこまでを信用するか」の指定はフレームワークごとに名前が違うので、Expressなら trust proxy、Spring Bootなら server.forward-headers-strategy、Gunicornなら forwarded_allow_ips と、自分の環境で該当する設定を一度探してみてください。
振り分けた先で待っている、セッションという難物
さて、複数台に配れるようになりました。めでたしめでたし、とはなりません。配れるようになった瞬間に、壊れるものがあります。
ログイン機能です。ユーザがサーバ1でログインし、その情報がサーバ1のメモリ上に保持されたとします。次のリクエストがサーバ2に振り分けられた瞬間、サーバ2は「あなた誰ですか」と言います。ユーザから見れば、ページを移動しただけで勝手にログアウトさせられる怪奇現象です。
応急処置として用意されているのがスティッキーセッション(セッション永続化)で、「一度サーバ1に当たった人は、以降もサーバ1へ送る」という紐付けをロードバランサが覚えます。L7ならCookieで(AWSのALBなら AWSALB というCookieを発行します)、L4なら送信元IPのハッシュで実現します。
設定ひとつで直るので魅力的に見えますが、これは借金です。特定のサーバにユーザが偏っても直せませんし、そのサーバをデプロイで入れ替えれば、そこに紐付いていた人のセッションはまとめて消し飛びます。せっかく「どの窓口へ回されても困らない」状態を手に入れたのに、「落ちたら特定の人だけが確実に困る」状態を自分で作り直しているわけです。
正しい措置は、12-Factor Appでいうところの「プロセスはステートレスであるべき」。つまりセッション情報をサーバのメモリではなく、外部のRedisやDBといった共有の置き場所へ追い出すことです。そうすればどの箱に当たっても同じ答えが返り、箱は本当に使い捨てに出来ます。前回コンテナの回で「消えては困るものは外に出す」と書いた、あの作法がここでも顔を出します。ただしこちらは、スティッキーセッションのように設定ひとつとはいきません。アプリ側の書き換えが要ります。
外すときと戻すときにも、作法がある
ヘルスチェックが手に入ると、障害対応以外にもご利益があります。無停止デプロイです。
考え方は単純で、新しいバージョンに入れ替えたいサーバを1台ずつロードバランサから外し、更新して、ヘルスチェックが通ったら戻す。これを順番に繰り返せば、サービスを止めずに全台を入れ替えられます。ローリングデプロイと呼ばれるやり方で、いま当たり前に使われているデプロイ手法のかなりの部分が、ロードバランサの切り離し機能の上に成り立っています。
外すときにいきなりプロセスを落とすと、処理の途中だったリクエストがぶつ切りになります。正しくは、まず振り分け対象から外して新規の流入を止め、進行中の処理が終わるのを待ってから停止する。AWSのターゲットグループでいう**登録解除の遅延**(deregistration delay)がこれで、既定では300秒待ってから切り離しを完了します。正直に告白すると、私も昔は「デプロイのたびに一瞬エラーが出るのは仕方ない」と諦めていました。仕方なくなかった、というのが答えです。
戻すときも同じくらい繊細です。長く休んでいたサーバへ復帰直後から全開でリクエストを流すと、キャッシュも接続プールも冷えたまま殴られて、そのまま倒れます。倒れればヘルスチェックで再び外され、外されればまた冷え、と往復ビンタが始まる。これを避けるために、復帰直後は流量を徐々に上げるスロースタートが用意されています(ALBのターゲットグループなら30〜900秒の範囲で設定できます)。
ただし、この手のつまみは全部を同時にオンには出来ません。ALBのスロースタートは Least Outstanding Requests とも Weighted Random とも併用できず、その Weighted Random はスティッキーセッションとも共存しません。先ほどのATWで自動調整させるのか、スロースタートで暖機するのか、セッションを固定するのか。良さそうな機能を並べて全部有効にしようとすると、どこかで必ず弾かれます。
【コラム】ロードバランサ自身は、誰が守るのか
ここまで読んで、当然の疑問が浮かんだはずです。全部の通信が通る入口なんて、それこそ最強の単一障害点では?
その通りで、だから物理機器の時代からロードバランサは必ず2台1組で置かれてきました。2台で仮想的なIPアドレスを共有し、片方が黙ったらもう片方が即座にそのIPを引き継ぐ。この引き継ぎを標準化したのが VRRP(Virtual Router Redundancy Protocol)で、現行仕様は RFC 5798(VRRPv3)です。Linuxでは keepalived がおなじみの実装です。
クラウドのマネージドなロードバランサでは、この冗長化が最初から中に畳み込まれています。AWSのALBやNLBは、見た目こそ1つのDNS名ですが、実体は複数のアベイラビリティゾーンに分散配置されたノード群です。だから「ロードバランサが1台落ちる」という心配を、こちらでする必要がありません。
ただ、このノード群にも既定値の罠があります。各AZのノードが自分と同じAZのターゲットにしか配らない状態を「クロスゾーン負荷分散が無効」と呼び、NLBはこれが既定です(ALBは既定で有効)。DNSはノードをおおむね均等に返すので、AZごとの台数が揃っていないと、台数の少ないAZのサーバだけが集中砲火を浴びます。有効にすれば解消しますが、NLBの場合はAZをまたぐぶんのEC2データ転送料が発生します。ややこしいのは、EC2の料金表には、同じVPC内でプライベートIPを使うロードバランサとインスタンス間の転送は無料という趣旨の記載があるものの、そこで名前を挙げられているのがALBとCLBだけだということです(2026年9月時点。料金は改定されるので、判断の前に必ず最新の料金表を見てください)。同じ「AZをまたぐ通信」でも、どのロードバランサを選ぶかで請求書が変わります。
マネージドに任せた代償として押さえておきたいのが、ALBのIPアドレスは勝手に増減するということ。負荷に応じてノードが入れ替わるので、dig で引いたIPを取引先のファイアウォールに登録してもらう、といった運用は破綻します。固定IPが要件なら、AZごとに固定IP(Elastic IP)を持てるNLBを選ぶか、前掲のGlobal Acceleratorを前に立てる。この選択理由、機能の優劣ではなく相手の都合で決まることが本当に多いです。
Kubernetesは、案内係に新しい名札をつけただけ
前回のコンテナ回で扱ったのは、あくまで「箱の作り方」まででした。箱を10個立て、死んだら入れ替え、増減に合わせて振り分け先の一覧を書き換える。この運用を人の手で回すのは、台数が2桁を超えたあたりで破綻します。そこを引き受けるのがコンテナオーケストレーションで、いま事実上の標準がKubernetesです。
面白いのは、その中身が今回の話の焼き直しで出来ていることです。
| 今回の話 | Kubernetesでの呼び名 |
|---|---|
| L4の振り分け |
Service(担当は kube-proxy。IPVSモードの正体は、L4/L7の比較表に並んだLVSそのものです) |
| L7のパスベースルーティング |
Ingress / Gateway API(実体はnginxやEnvoyを積んだコントローラ) |
| クラウドのロードバランサの用意 |
Service の type: LoadBalancer(AWSはAWS Load Balancer Controllerを入れていればNLB。入れていないと既定は旧世代のCLBなので要注意です。ALBはひとつ上の Ingress 側の担当です) |
| ヘルスチェックによる切り離し | readinessProbe |
| 戻したサーバへ流し始めるタイミング | Pod readiness gates4(ロードバランサへの登録が正常になるまでPodをReadyにしない仕掛け。これがないと、登録が終わる前にローリングデプロイが先へ進み、ターゲットが全部InitialやDrainingのまま瞬間的に全滅します) |
| 登録解除の遅延、接続の抜き取り |
terminationGracePeriodSeconds と preStop
|
とりわけ、readinessProbe(振り分け対象に入れてよいか)と livenessProbe(コンテナを再起動すべきか)が別々に用意されているのは、先ほどの「浅すぎても深すぎても事故る」への回答そのものです。そして、DBが不調なときにreadinessまで失敗させる設計にしていて、全Podが一斉に外れてサービスが完全停止する、という事故の起こし方まで同じです。名前が変わっただけで、悩みどころは1ミリも変わっていません。
なので順番としては、ロードバランサの気持ちが分かってからKubernetesを触った方が、はるかに迷いません。逆にここを飛ばすと、Service と Ingress の違いが最後まで腑に落ちないまま、YAMLを写経し続けることになります。
おわりに
DNSでは反映の遅さと生死の無知が壁になり、経路上に立つ装置が必要になりました。その本業は均等配分だけでなく、数秒おきに「生きてる?」と聞き続けるヘルスチェックにもありました。宛名だけを見て高速に捌くL4と、封を開けてURLやCookieまで読んで賢く配るL7があり、L7が中身を読むために入口で暗号を解くので、証明書の管理まで入口へ集まってくる。裏側のサーバがどこに当たっても同じ答えを返せるよう、状態は外へ出しておく。システムを「止まらない」状態へ引き上げる、そのひと通りが、名前だけ変えてKubernetesの中に畳み込まれている。
派手さは一切ありません。ですが、裏で1台や2台倒れていてもユーザが気づかないあの静けさは、案内係が行列を捌く以外にこなしている、この繰り返しが生み出したものです。動いていて当たり前と思われているものほど、止まった瞬間に全員が気づく。NTPやDNSと同じ、縁の下のインフラの宿命です。
時計を合わせ、相手を確かめ、住所を引き、荷物を届け、プロセスで処理し、権限で守り、箱に詰め、そして案内係が行列を捌く。ここまでで、サーバが何台死のうともサービスは動き続ける形になりました。
ところが、箱を使い捨てに出来るようになった代わりに、宿題がひとつ残っています。ユーザがアップロードした画像も、登録したアカウント情報も、箱と一緒に捨てられては困る。セッションを外に出したのと同じ理屈で、消えては困るデータは、どこへ、どうやって置けばいいのでしょうか。
次回「永続化の基本の"キ"(ストレージとデータベース)」へ続きます。
お断り
記事内容は個人の見解であり、所属組織の立場や戦略・意見を代表するものではありません。
あくまでエンジニアとしての経験や考えを発信していますので、ご了承ください。
-
ロードバランサから見れば、振り分け先は「あるIPアドレスの、あるポートで待ち受けている何か」でしかありません。AWSのターゲットグループは、EC2インスタンス・IPアドレス・Lambda関数のいずれもターゲットの種類として選べますし、Kubernetesが振り分ける先はPodです。「物理か、仮想マシンか、コンテナか」と「その前に案内係を置くかどうか」は、独立した話だと考えてください。 ↩
-
このL4・L7という番号はOSI参照モデルの階層番号です。実務で使われるのはTCP/IPの4層モデルですが、「トランスポート層」「アプリケーション層」と毎回言うのが長いので、番号だけをOSIから借りて呼び続けている、という慣習です。 ↩
-
例外として、暗号を解かずにSNI(TLSの接続開始時に平文で流れる接続先のFQDN)だけを覗いて振り分ける「TLSパススルー」もあります。nginxなら
streamモジュールのssl_prereadがこれにあたり、証明書を裏のサーバに置いたまま複数のサービスを1つの入口に同居させられます。ただし読めるのはFQDNまでで、URLのパスやCookieでの振り分けは出来ません。 ↩ -
この仕掛けが効くのは、AWS Load Balancer Controllerを使い、かつターゲットの種類が
ip(Podへ直接振り分ける形)のときだけです。instanceだとロードバランサから見えているのはノードなので、Podの状態は判断材料になりません。加えて、名前空間へelbv2.k8s.aws/pod-readiness-gate-inject: enabledのラベルを貼る必要があります。設定はPodの作成時に注入されるので、ラベルを貼るのは必ずPodを立てる前に。 ↩