⚖️

Connection draining for Service type LoadBalancer

2023/05/11に公開

はじめに

Service リソースは Kubernetes のサービス検出を支えるコアリソースです。Service のデータプレーンとして kube-proxy を使用している場合は、各ノード上の iptables や ipvs を設定することで L4 負荷分散を実現しています。

Kubernetes は、結果整合性 (Eventual Consistency) の上に成り立つ分散システムです。Kubernetes のコントロールプレーンが Pod を削除する時に、全てのノード上のルーティングルールを更新してから Pod を削除したりはしません。削除中の Pod にもトラフィックが流れることがあります。

そのため、ダウンタイムなしで Pod をローリング更新できるように、Pod の preStop hook で固定時間 sleep してから Graceful Shutdown (新規のコネクションの受け付けを止めて、既存のリクエストの処理が終わってから停止) する方法が推奨されています。

これは、LB から直接 Pod にルーティングする機能 (e.g. GKE の Container-native load balancing through Ingress や AWS LoadBalancer controller の ip mode) を使っていても同じです。Kubernetes クラスタ上で動作しているコントローラーが、クラウドプロバイダの LB のバックエンドから接続情報 (Pod IP とポート番号) を削除します。バックエンドから削除する処理も、ヘルスチェックによるサービスアウトも当然時間差が生じるので、停止中の Pod にリクエストが流れることはあります。Kubernetes の世界に閉じた話ではないので、この問題を根本的に解決するのはかなり難しいです。

Kubernetes の Service type LoadBalancer は、Network Programming Latency とクラウドサービスの世界の両方の難しさが混在した領域です。本記事では、Kubernetes の upstrem で対応が進められている Service type LoadBalancer の改善について見ていきます。KubeCon Europe 2023 の Improving the Reliability of Kubernetes Load Balancers の発表では余り深くまで触れられていなかったので記事にまとめてみました。

改善のはじまり

Service type LoadBalancer の改善は、k/k#111539 の Issue から始まりました。Kubernetes Cloud Controller Manager (KCCM) は、Service type LoadBalancer 経由で作成されたクラウドプロバイダの LB を reconcile するために、クラウドプロバイダの API を大量に呼び出します。大規模な環境ではクラウドプロバイダの API のレート制限に引っかかったり、LB の状態の最新化が追いつかないことがあります。

ノードが 500 台規模の Kubernetes クラスタで、クラウドプロバイダの LB を 200 個作成したとします。1 台のノードの状態が Ready から NotReady に遷移しただけで、KCCM が全ての LB の状態を reconcile しようとします。この規模のクラスタだと reconcile 完了に数時間掛かることが確認されているそうです。

数あるノードの中で 25 番目のノード (Node25) の状態が Ready から NotReady に遷移した例をもとに、もう少し考えてみましょう。

  1. Node25 の状態が NotReady に遷移したことを検知し、KCCM が全ての LB の状態を同期開始
    • 全ての LB を同期するのに時間が掛かるのですぐには終わらない
  2. Service1 には Node25 の Endpoint (Pod) しか含まれていないので、KCCM が LB のバックエンドから Node25 を削除
  3. Node25 の状態が NotReady から Ready に戻り、Service1 に属する Endpoint (Pod) を他のノードに移す Eviction 処理を取りやめた
  4. Node25 は Ready 状態だが、KCCM はまだ 1. で発生した他の Service の同期処理を継続中。Service1 に接続しているクライアントはエラーが発生したまま
  5. KCCM が 3. による 2 回目の LB の reconciliation 処理をやっと始める
  6. Node25 が Service1 の LB のバックエンドに戻ってクライアントの接続エラーが終息

externalTrafficPolicy=Cluster (eTP=Cluster) の場合、ノードの状態の変更 (Ready <-> NotReady) を検知して、LB を再構成するのは理解できます。ただ、eTP=Local だとノード上に Endpoint が存在しない場合にトラフィックを流すことはないのに、LB を再構成する必要はあるのでしょうか。eTP=Local かつどのノードにもエンドポイント (= Pod) が存在しない上記の例では、LB を再構成するのは無意味な挙動のように思えます。

最初の一歩

k/k#109706 と follow-up PR である k/k#111663k/k#111691 は、Kubernetes 1.26 から正式に入った修正で、eTP=Local の場合にクラウドプロバイダの API 呼び出しを劇的に減らす対応です。eTP=Local の場合、ノードの状態の遷移 (Ready <-> NotReady) によって、KCCM がノードを LB のバックエンドから削除しないようになりました。LB に設定されたヘルスチェックで NotReady なノードにトラフィックを流さないようにします。

この修正では、eTP=Local とそれ以外の Service で LB を再構成する条件を変えています。eTP=Local の Service は、以下の条件に一致したノードを LB のバックエンドに同期する対象とします。

  • ノードに node.kubernetes.io/exclude-from-external-load-balancers のラベル (明示的に LB のバックエンドに含めないノードを指定できる) が付与されていない
  • ノードに ClusterAutoscaler がノードをスケールダウンする際に指定する ToBeDeletedByClusterAutoscaler の taints が付与されていない

eTP=Local 以外の Service は、上記の条件にノードの状態が NotReady でない条件も追加されます。つまり、eTP=Local の Service は、ノードの状態が遷移しても LB のバックエンドからノードが削除されなくなります。

思わぬ副作用 (1)

最初の一歩で話した k/k#109706k/k#111691 は、実は Kubernetes 1.25 に取り込まれていました。k/k#111663 は元々 k/k#109706 に含まれていましたが、当時は処理を綺麗にするだけだと思われていたので、別の PR に切り出されました。そして、コードフリーズの発動により k/k#111663 だけ 1.25 に取り込まれないことになりました。この歪みが問題を引き起こすことになります。

1.25.0 のリリース後しばらくしてから、GKE の開発チームの方が k/k#112793 の Issue を上げました。1.24 から 1.25 へのバージョン更新の検証の中で問題を発見したようです。k/k#109706 の変更により、eTP=Local な Service type LoadBalancer の一部のノードが LB のバックエンドに追加されなくなるという報告でした。

KCCM は、新しいノードの作成や削除のイベントを検知して、LB を再構成しようとします。最後に再構成した時のノードの一覧と現在のノードの一覧を比較して、再構成が必要かを判断します。現在のノードの一覧を取得する際に、Ready 状態のノードでフィルターを掛けてしまっていた (source) ため、新しく作成されたノードは含まれず、削除中のノードは逆に含まれたままでした。両者を比較しても差分が出ず、LB の再構成のフラグが意図せず立たないようになっていました。

さらに悪いことに、その後でノードの更新のイベントを検知して、LB の再構成のフラグが立っても、eTP=Local の場合は LB の再構成が発動しませんでした。最初の一歩の修正で、eTP=Local な Service はノードの状態を気にしなくなっていたからです。最後に再構成した時のノードの一覧と Ready 状態かに関係なく取得したノードの一覧が同じになっていました。

最終的に、k/k#112807 の PR で k/k#109706k/k#111691 の変更は revert され、1.25.3 としてリリースされました。

1.26.0 には k/k#109706k/k#111691k/k#111663 が全て含まれています。では、k/k#111663 がなぜこの問題を解消することになったのでしょうか。それはノードの追加・削除のイベントで再構成が必要かを判断する比較処理が完全になくなったからです。最後に再構成した時のノードの一覧と現在のノードの一覧を比較しなくりました。KCCM は、ノードの追加のイベントを検知して、キャッシュ内の Service を無条件で同期します。

思わぬ副作用 (2)

思わぬ副作用は 1 つだけではありませんでした。最初の一歩の修正から半年以上経過してから、今度は EKS の開発チームの方が k/k#117375 の Issue を上げました。eTP=Local な Service で ELB / NLB を作成した場合に、新しくノードを追加しても LB のバックエンドに追加されないことがあるという問題でした。

これは、cloud-provider-awsproviderID に依存した処理を含んでいた (source) からです。クラスタに正常に追加されたノードには providerID が指定されているという暗黙のお約束に依存していた訳です。KCCM の一連の修正により、以下の 2 つの問題が浮き彫りになりました。

  1. providerID の設定されていないノードが cloud-provider-aws に渡されることがあり、cloud-provider-aws の処理の中で providerID の指定されていないノードをスキップして同期しないようになっていた
    • cloud-provider-aws 側で providerID の指定されていないノードが渡されたら、 providerID が指定されるまでリトライすれば問題にはなっていなかった
    • KCCM とクラウドプロバイダの間で providerID の取り決めがされていなかったのが原因ではある
  2. providerID の変更 (追加や削除) で LB の再構成が発動しなくなったので、その後も LB のバックエンドにノードが追加されることはなかった

以前の実装でこれが問題になっていなかったのは、KCCM が Ready 状態になったノードの変更を検知して LB を再構成しようとしていたからです。ノードが作成されて諸々準備ができると、providerID を設定します。その後でノードが Ready 状態になるので、以前の実装は暗黙的にこのケースに対応していたのです。

k/k#117388 の PR で、providerID に変更があった場合でも LB を再構成するようになりました。ただ、providerID を使っていないクラウドプロバイダが存在する可能性があるので、providerID の使用を強制しない k/k#117602 の PR が上がっています。クラウドプロバイダ間で providerID の利用がどこまで厳格に規定されているのか明らかにしてからマージされる予定のようです。

思わぬ副作用 (3)

更に半年後に 3 つ目の思わぬ副作用が comment - k/k#112793kubernetes-sigs/cloud-provider-azure#4230 で報告されます。三大クラウド全てで思わぬ副作用が発生したことになります。新たに eTP=Local な Service type LoadBalancer を作成した時に、新しくクラスタに参加したノードが LB のバックエンドに追加されないという問題です。その後で、以下の条件を満たした時にやっと LB のバックエンドにノードが追加されるというものでした。

  • KCCM を再起動する
  • 新しくノードを追加する
    • 新しいノードはバックエンドに追加されないが、一つ前のノードがバックエンドに追加される
  • 定期的に発火する resync を待つ (10 時間間隔?)

原因は cloud-provider-azure で UpdateLoadBalancerHost 関数の呼び出し前にノード一覧から NotReady なノードを除外していたからでした。k/k#109706 で resync の発火条件が厳しくなっており、ノードが追加されても LB のバックエンドに追加されないことがあります。例えば、クラスタに追加された直後の NotReady なノードが、cloud-provider-azure に渡されることで除外されてしまいます。そして、ノードの状態の変更で resync は発生しないので、次の resync である新しいノードの追加や削除のイベントもしくは強制的な resync までは追加されないことになります。

この問題は kubernetes-sigs/cloud-provider-azure#4234 で修正されていて、NotReady なノードのフィルターが削除されています。

KEP-3458: Remove transient node predicates from KCCM's service controller

eTP=Local の改善の続きで、以下の条件でも LB のバックエンドからノードを追加・削除しないことを目指した KEP です。

  • ClusterAutoscaler がスケールインする / スケールインを取りやめたノードに対して ToBeDeletedByClusterAutoscaler の taints を追加・削除するとき
  • eTP=Local の Service 以外で、ノードの状態が Ready <-> NotReady に遷移するとき

LB のバックエンドからノードを削除すると、既存のコネクションは一定時間待つことなく、突然切断されます。ノードは LB のバックエンドからできるだけ削除しない方が安全です。クライアントからの接続が可能な限り安定し、コネクション ドレインの恩恵を受けられるようにします。


引用: https://github.com/kubernetes/kubernetes/pull/111661#issuecomment-1253033598

KEP-3458 の対応

KEP-3458 の対応は k/k#115204 と follow-up PR の k/k#116536 で、Kubernetes 1.27 に含まれています。実装は KEP の内容から大きくは変わっておらず、ノードに node.kubernetes.io/exclude-from-external-load-balancers のラベルを追加・削除しない限り、LB のバックエンドが同期されることはなくなりました。

また、追加で以下の状態のノードを LB のバックエンドから可能な限り削除しないようにしています。これにより、可能な限りコネクション・ドレインの恩恵を受けられるようになっています。

  • ToBeDeletedByClusterAutoscaler の taints があるノード
  • deletionTimestamp が設定されたノード (= 削除処理に入っているノード)

ノードの作成・削除のイベントでは当然 LB を再構成します。ノードの変更のイベントで LB のバックエンドが同期されるのは、node.kubernetes.io/exclude-from-external-load-balancers のラベルが追加/更新/削除された場合のみなので注意して下さい。

次の改善に向けて

k/k#111661 は、これまでの修正とは少し毛色が異なる kube-proxy のヘルスチェックの改善の PR です。eTP=Local の場合の LB のヘルスチェックに kube-proxy のヘルスチェックを使っているクラウドプロバイダ (e.g. Google Cloud) が恩恵を受けられます。LB のバックエンドの更新頻度が下がったことで、LB のヘルスチェックの重要性が増している訳です。

ヘルスチェックの検査項目に以下の kube-proxy の正常性が追加されました。

  • kube-proxy の iptables / ipvs のルールの変更処理のキューが空っぽなら正常
  • kube-proxy の iptables / ipvs のルールの変更処理のキューが空っぽではないが、最後に更新された時刻が同期間隔の 2 倍の時間未満なら正常

KEP-3836: Kube-proxy improved ingress connectivity reliability

eTP=Cluster の場合の kube-proxy のヘルスチェックを改善することで、ノードがスケールインする際に、LB を経由したコネクションをより長く安全に維持できるようにするための KEP です。

  1. kube-proxy が動作しているノードに ToBeDeletedByClusterAutoscaler の taints が付与されたら、kube-proxy のヘルスチェックを失敗させて、ノードを LB からサービスアウトさせる
    • ノードの unschedulable フィールドを使う方法も考えたが、cordon だけで設定されてしまうのでノードの削除・停止を示す指標としては使えない
  2. kube-proxy に新しく /livez のエンドポイントを生やし、これまでのヘルスチェックの処理を移行する
    • /healthz にのみ 1. の改善を含めるようにし、クラウドプロバイダがどちらを使うか選択できるようにする
  3. eTP=Cluster な Service のヘルスチェックはこうするのが良いよというドキュメントを作成する

重要なのはやはり 1. の変更です。ノードが停止中でもうすぐ削除される状態であることを示す指標は残念ながらありません。KEP の中では、ToBeDeletedByClusterAutoscaler の taints が良くはないけど一番最もらしいと結論付けていますが、一時迷っていたようです。そもそもノードのライフサイクルに関するドキュメントがないよという k/k#115139 の Issue を上げて、ノードが削除されてもうすぐいなくなる状態をどう知るのが良いのかも議論していました。

KEP-3836 の対応

k/k#116470 の PR で実装されていて、Kubernetes 1.28 でアルファ機能として入る予定です。迷いはあったようですが、ToBeDeletedByClusterAutoscaler の taints がノードに付与されると LB のヘルスチェックで失敗するようになりました。これまでの挙動のヘルスチェックは /livez のパスで提供され、新しい挙動のヘルスチェックは /readyz のパスで提供されます。

NetworkNotReady なノードが LB のバックエンドから除外されてしまうという別の問題も slack で議論されていました。CNI 周りで問題が起きた場合 (e.g. Cilium の設定ファイルを DaemonSet で配置) にノードの状態が NetworkNotReady となりますが、既存の動作中の Pod は問題なくトラフィックを捌けているケースもあるので LB のバックエンドから除外する必要はないというものです。CNI v1.1 で STATUS と呼ばれる新しい動作が追加される予定です。KEP-3836 のスコープを広げることはなさそうですが、今後 NetworkNotReady なノードを LB のバックエンドから除外しない KEP が出てくるかもしれませんね。

まとめ

  • Service type LoadBalancer の改善が upstream で進められているよ
  • クラウドプロバイダの API 制限はいろいろなところで問題になっているよ
  • ノードがスケールインする際のコネクションの維持は重要
  • Service type LoadBalancer は思ったよりも複雑で難しい領域
  • 複数の SIG が関わる領域 (今回だと sig-network, sig-node, sig-cloud-provider) に変更を加えると思わぬ副作用を生じることがあるよ

Discussion