kube-proxy の externalTrafficPolicy=Local の改善

2023/03/29に公開

tl;dr;

  • Service type LoadBalancer の externalTrafficPolicy: Local は、Kubernetes 1.26 まで Pod のローリング更新時にトラフィックが喪失する問題があるので注意
  • kubernetes-sigs/cloud-provider-kind は、ローカル環境でクラウドリソース (現在は LB のみ) が絡む処理をシミュレートできて便利
  • GKE Dataplane v2 を利用している場合、GKE 1.26.1 時点で Cilium に externalTrafficPolicy: Local の改善が入っていないので、Pod のローリング更新時にトラフィックが喪失する問題は解決していないので注意

背景

Kubernetes の機能の一つに DNS ベースのサービス検出があります。Service リソースを使用することで、クラスタ内では仮想 IP を経由して、クラスタ外ではクラウドプロバイダが提供する LB を経由してアプリケーションに接続可能です。このサービス検出を実現するデータプレーンとして、kube-proxy が in-tree で開発されてきました。

Kubernetes のマネージドサービスの多くで iptables モード として動作し、kube-proxy がノード上の iptables のルールを書き換えます。kube-proxy という名前から勘違いされがちですが、パケットは kube-proxy を通っていません。あくまで Linux カーネルのパケット処理の仕組みを通しているだけです。Kubernetes におけるコンテナ間のネットワークの仕組みは複雑です。以下の発表やブログ記事が理解に役立つかもしれません。

Kubernetes は、結果整合性 (Eventual Consistency) の上に成り立つ分散システムです。Kubernetes のコントロールプレーンが Pod を削除すると、各ノード上で動作する kube-proxy が iptables のルールを書き換えますが、全てのノードの書き換えを待ったりはしません。削除予定の Pod にもトラフィックが流れる可能性があります。Kubernetes の Service type LoadBalancer のようにクラウドプロバイダが提供する LB と連携する必要がある場合、考慮すべき点が増えます。本記事では、Service type LoadBalancer が抱える課題と最近の取り組みについてまとめています。

k/k#85643 - Create ability to do zero downtime deployments when using externalTrafficPolicy: Local

概要

Service type LoadBalancer を externalTrafficPolicy=Local (eTP=Local) で作成した場合に、Pod のローリングアップデート中に LB から Pod へのトラフィックが一部喪失する問題です。

eTP=Local の場合、LB のヘルスチェックで Pod の存在しないノードにトラフィックが流れないようにしています。eTP=Cluster (デフォルト) とは違い、LB から Pod への経路で LB -> NodeA -> NodeB -> Pod (NodeB) のような不要なホップが発生することはありません。LB は Pod の存在するノードにトラフィックを流し、そのノード上の Pod に直接トラフィックが向かいます。


引用: https://kubernetes.io/blog/2022/12/30/advancements-in-kubernetes-traffic-engineering/

Pod のローリングアップデートを考えましょう。kube-proxy は、Terminating 状態になった Pod の iptables のルールをすぐに消そうとします。この状態で Pod にリクエストを流すと、ノード上から Pod にルーティングするためのルールが存在せず、パケットが喪失します。ノード上の全ての Pod が Terminating 状態になり、そのことに LB が気付かずトラフィックを流す場合に問題になります。LB のヘルスチェックの設定によって、LB が気付けない状態が起こりやすくなります。

検証

ローカル環境に Kubernetes 1.25 を起動します。今回は Linux マシン上に kind でクラスタを起動します。kubernetes-sigs/cloud-provider-kind を使ってクラウドプロバイダの LB の挙動をシミュレートします。今回の検証では、kube-proxy の動作を確認するだけなので、実際にクラウドプロバイダの LB を作成する必要はありません。

ローカル環境に cloud-provider-kind のソースコードをクローンして、バイナリをビルドしておきます。

git clone git@github.com:kubernetes-sigs/cloud-provider-kind.git
cd cloud-provider-kind
make build

kind で 3 ノード構成のクラスタを起動します。kind 0.17.0 の場合、デフォルトで v1.25.3 の Kubernetes クラスタが起動します。

# kind 0.17.0 ではデフォルトで 1.25.3 の Kubernetes クラスタが起動
# kubernetes-sigs/cloud-provider-kind のルートディレクトリにある kind の設定ファイルを使用
kind create cluster --config kind.yaml

kind の Cloud Provider を起動します。

CLUSTER_NAME=kind

# Cloud Provider をローカル環境で起動するので、KubeConfig を生成
kind get kubeconfig --name ${CLUSTER_NAME} > bin/kubeconfig

bin/cloud-provider-kind --cloud-provider kind --kubeconfig $PWD/bin/kubeconfig --cluster-name ${CLUSTER_NAME} --controllers "*" --v 5 --leader-elect=false
# 以下のログがそれぞれのノードで出力されたら準備完了
# Successfully initialized node kind-control-plane with cloud provider
# Successfully initialized node kind-worker with cloud provider
# Successfully initialized node kind-worker2 with cloud provider

eTP=Local な Service type LoadBalancer を作成します。起動するコンテナは、SIGTERM を受け取ると 30 秒間待ってから Graceful シャットダウンを開始するようになっています。

kubectl apply -f examples/loadbalancer_deployment.yaml

# replicas を 1 台に変更
# kind のノードが 2 台構成なので PodAntiAffinity により
# 必ず別のノードで起動しなければならない状態にする
kubectl scale deploy/zeroapp --replicas 1

しばらく待つと Docker 上に HAProxy のコンテナが起動し、External-IP に接続先の IP が表示されます。

❯ kubectl get svc
NAME         TYPE           CLUSTER-IP     EXTERNAL-IP   PORT(S)        AGE
kubernetes   ClusterIP      10.96.0.1      <none>        443/TCP        12m
zeroapp      LoadBalancer   10.96.228.27   172.18.0.5    80:30808/TCP   5m54s

ローカル環境から External-IP にリクエストを投げます。HAProxy を経由して、バックエンドがレスポンスを返してくれます。

❯ curl -sS http://172.18.0.5/hostname
zeroapp-5b448c556b-2wwlk

1 秒間隔で External-IP にリクエストを投げながら、バックエンドの Pod をローリングアップデートします。

kubectl rollout restart deploy/zeroapp

以下のような結果が得られると思います。

❯ while true; do curl --max-time 0.5 --no-keepalive -sS http://172.18.0.5/hostname; echo; sleep 1; done
zeroapp-5b448c556b-2wwlk
zeroapp-5b448c556b-2wwlk
zeroapp-5b448c556b-2wwlk
zeroapp-5b448c556b-2wwlk
zeroapp-5b448c556b-2wwlk
zeroapp-5b448c556b-2wwlk
zeroapp-5b448c556b-2wwlk
curl: (28) Operation timed out after 500 milliseconds with 0 bytes received

curl: (28) Operation timed out after 501 milliseconds with 0 bytes received

zeroapp-c44756fc4-hj487
curl: (28) Operation timed out after 501 milliseconds with 0 bytes received

zeroapp-c44756fc4-hj487
curl: (28) Operation timed out after 500 milliseconds with 0 bytes received

zeroapp-c44756fc4-hj487
curl: (28) Operation timed out after 502 milliseconds with 0 bytes received

zeroapp-c44756fc4-hj487
curl: (28) Operation timed out after 500 milliseconds with 0 bytes received

zeroapp-c44756fc4-hj487
zeroapp-c44756fc4-hj487
zeroapp-c44756fc4-hj487
zeroapp-c44756fc4-hj487
zeroapp-c44756fc4-hj487
zeroapp-c44756fc4-hj487
zeroapp-c44756fc4-hj487

HAProxy のヘルスチェックの設定は pkg/loadbalancer/proxy.go#L60 に書かれています。HTTP GET リクエストをノード上のヘルスチェック用のポートに 5 秒間隔で実行しています。

kind の Cloud Provider のログで生成された HAProxy の設定ファイルが確認でき、例えば以下のようになっています。

global
  log /dev/log local0
  log /dev/log local1 notice
  daemon

resolvers docker
  nameserver dns 127.0.0.11:53

defaults
  log global
  mode tcp
  option dontlognull
  # TODO: tune these
  timeout connect 5000
  timeout client 50000
  timeout server 50000
  # allow to boot despite dns don't resolve backends
  default-server init-addr none

frontend service
  bind *:80
  default_backend nodes

backend nodes
  option httpchk GET /healthz
  server kind-worker 172.18.0.3:32193 check port 30608 inter 5s fall 3 rise 1
  server kind-worker2 172.18.0.2:32193 check port 30608 inter 5s fall 3 rise 1

古い Pod が動作しているノードに対する HAProxy のヘルスチェックが失敗し、古いノードが正常なバックエンド一覧から外れるまで curl のタイムアウトが発生していたことが分かります。

zeroapp-5b448c556b-2wwlk
curl: (28) Operation timed out after 500 milliseconds with 0 bytes received

curl: (28) Operation timed out after 501 milliseconds with 0 bytes received

zeroapp-c44756fc4-hj487
curl: (28) Operation timed out after 501 milliseconds with 0 bytes received

zeroapp-c44756fc4-hj487
curl: (28) Operation timed out after 500 milliseconds with 0 bytes received

zeroapp-c44756fc4-hj487
curl: (28) Operation timed out after 502 milliseconds with 0 bytes received

zeroapp-c44756fc4-hj487
curl: (28) Operation timed out after 500 milliseconds with 0 bytes received

Kubernetes 1.26 より前では、Service type LoadBalancer を eTP=Local で作成し、Pod をローリングアップデートするとトラフィックの一部がドロップされてしまう現象が確認できました。

対応

Kubernetes v1.26: Advancements in Kubernetes Traffic Engineering で紹介されているように、ノードに Terminating 状態の Pod しか存在しない場合に、Terminating 状態の Pod にトラフィックを流すようになりました。トラフィックを可能な限り喪失しない挙動に変わりました。これにより、Terminating 状態でも新規コネクションを一定期間受けられるようになっている (e.g. Pod の preStop hook で一定時間 sleep) とリクエストを処理できます。

この問題は Kubernetes 1.26 以降では発生しません。KEP-1669: Proxy Terminating Endpoints がベータに昇格し、デフォルトで有効化されたからです。KEP-1672: Tracking Terminating Endpoints in the EndpointSlice API で EndpointSlice API に servingterminating という status が追加されました。これらを利用して、eTP=Local にフォールバック機能を追加した形です。ノード上に Terminating 状態な Pod しかいない場合は、readinessProbe に失敗してサービスアウトしていない (serving: true, terminating: true) な Pod にトラフィックを流すようになりました。(source)

これにより、preStop hook やプロセスの停止処理の前に sleep を挟んでいると、リクエストを取りこぼすことがなくなります。先ほどと同じ方法で動作確認してみましょう。

ローカル環境に Kubernetes 1.26 を起動します。

kind create cluster --config kind.yaml --image=kindest/node:v1.26.0@sha256:691e24bd2417609db7e589e1a479b902d2e209892a10ce375fab60a8407c7352

先ほどと同じ手順で、kind の Cloud Provider を起動し、eTP=Local な Service type LoadBalancer を作成します。

kubectl apply -f examples/loadbalancer_deployment.yaml

# replicas を 1 台に変更
# kind のノードが 2 台構成なので PodAntiAffinity により
# 必ず別のノードで起動しなければならない状態にする
kubectl scale deploy/zeroapp --replicas 1

しばらく待つと Docker 上に HAProxy のコンテナが起動し、External-IP に接続先の IP が表示されます。1 秒間隔で External-IP にリクエストを投げながら、バックエンドの Pod をローリングアップデートします。

kubectl rollout restart deploy/zeroapp

以下のような結果が得られると思います。今度はリクエストがタイムアウトすることなく、全てのリクエストがバックエンドで処理されました!途中で新旧の Pod 名が返ってきていることから、ノード上の serving: true な Pod にリクエストが流すようフォールバックしたことが分かります。

 while true; do curl --max-time 0.5 --no-keepalive -sS http://172.18.0.5/hostname; echo; sleep 1; done
zeroapp-6566795f45-pcnxz
zeroapp-6566795f45-pcnxz
zeroapp-6566795f45-pcnxz
zeroapp-6566795f45-pcnxz
zeroapp-6566795f45-pcnxz
zeroapp-6566795f45-pcnxz
zeroapp-7f4658bfb-w86j9
zeroapp-6566795f45-pcnxz
zeroapp-7f4658bfb-w86j9
zeroapp-6566795f45-pcnxz
zeroapp-7f4658bfb-w86j9
zeroapp-6566795f45-pcnxz
zeroapp-7f4658bfb-w86j9
zeroapp-6566795f45-pcnxz
zeroapp-7f4658bfb-w86j9
zeroapp-6566795f45-pcnxz
zeroapp-7f4658bfb-w86j9
zeroapp-7f4658bfb-w86j9
zeroapp-7f4658bfb-w86j9
zeroapp-7f4658bfb-w86j9
zeroapp-7f4658bfb-w86j9
zeroapp-7f4658bfb-w86j9

Cilium socket LB (Kubernetes without kube-proxy)

GKE Dataplane v2 の場合、kube-proxy は存在せず、Cilium のソケット LB 機能でルーティングされます。Cilium にも既にこの改善は実装されているのでしょうか。

1.26.1-gke.1500 の GKE クラスタを作成して、同様の手順で確認しました。

❯ kubectl version --short
Flag --short has been deprecated, and will be removed in the future. The --short output will become the default.
Client Version: v1.26.3
Kustomize Version: v4.5.7
Server Version: v1.26.1-gke.1500

Cilium のバージョンは以下の通りです。

❯ kubectl exec -it -n kube-system ds/anetd -- bash

# cilium version
Client: 1.12.4 a5c6528c5d 2023-01-17T18:16:36+00:00 go version go1.18.8 linux/amd64
Daemon: 1.12.4 a5c6528c5d 2023-01-17T18:16:36+00:00 go version go1.18.8 linux/amd64

Cilium 1.12.4 では、まだトラフィックを取りこぼす実装のままでした。

❯ while true; do curl --max-time 0.5 --no-keepalive -sS http://34.85.47.153/hostname; echo; sleep 1; done
zeroapp-798b5644d8-6fg8g
zeroapp-798b5644d8-6fg8g
zeroapp-798b5644d8-6fg8g
zeroapp-798b5644d8-6fg8g
zeroapp-798b5644d8-6fg8g
curl: (7) Failed to connect to 34.85.47.153 port 80 after 15 ms: Couldn't connect to server

curl: (7) Failed to connect to 34.85.47.153 port 80 after 18 ms: Couldn't connect to server

curl: (7) Failed to connect to 34.85.47.153 port 80 after 22 ms: Couldn't connect to server

curl: (7) Failed to connect to 34.85.47.153 port 80 after 27 ms: Couldn't connect to server

curl: (7) Failed to connect to 34.85.47.153 port 80 after 15 ms: Couldn't connect to server

curl: (7) Failed to connect to 34.85.47.153 port 80 after 26 ms: Couldn't connect to server

curl: (7) Failed to connect to 34.85.47.153 port 80 after 26 ms: Couldn't connect to server

curl: (7) Failed to connect to 34.85.47.153 port 80 after 25 ms: Couldn't connect to server

curl: (7) Failed to connect to 34.85.47.153 port 80 after 20 ms: Couldn't connect to server

zeroapp-cb84466c8-mgmhk
curl: (7) Failed to connect to 34.85.47.153 port 80 after 22 ms: Couldn't connect to server

curl: (7) Failed to connect to 34.85.47.153 port 80 after 25 ms: Couldn't connect to server

curl: (7) Failed to connect to 34.85.47.153 port 80 after 21 ms: Couldn't connect to server

curl: (7) Failed to connect to 34.85.47.153 port 80 after 17 ms: Couldn't connect to server

zeroapp-cb84466c8-mgmhk
zeroapp-cb84466c8-mgmhk
zeroapp-cb84466c8-mgmhk
curl: (7) Failed to connect to 34.85.47.153 port 80 after 15 ms: Couldn't connect to server

zeroapp-cb84466c8-mgmhk
zeroapp-cb84466c8-mgmhk
zeroapp-cb84466c8-mgmhk

Cilium の Issue/PR を探してみると、先日 sig-network / GKE チームのアントニオさんが投げた修正の PR (cilium/cilium#24174) がマージされていました。パッチに以下のコードが入っていたので、きっとこれで問題は解決しそうです。

	// To avoid connections drops during rolling updates, Kubernetes defines a Terminating state on the EndpointSlices
	// that can be used to identify Pods that, despite being terminated, still can serve traffic.
	// In case that there are no Active backends, use the Backends in TerminatingState to answer new requests
	// and avoid traffic disruption until new active backends are created.
	// https://github.com/kubernetes/enhancements/tree/master/keps/sig-network/1669-proxy-terminating-endpoints
	if option.Config.EnableK8sTerminatingEndpoint && len(activeBackends) == 0 {
		nonActiveBackends = []lb.BackendID{}
		for _, b := range backends {
			if b.State == lb.BackendStateTerminating {
				activeBackends[b.String()] = b
			} else {
				nonActiveBackends = append(nonActiveBackends, b.ID)
			}
		}
	}

Cilium 1.13 にバックポートされる予定なので、GKE で利用する Cilium のバージョンが 1.13 に更新されたらこの問題は解決されそうです。in-tree の kube-proxy に入った改善がサードパーティ製のツールに入るのにラグがあるので注意が必要です。

まとめ

Kubernetes の L4 LB (Service type LoadBalancer) はいくつかの問題を抱えており、改善が進められています。externalTrafficPolicy / internalTrafficPolicy の安定化とトポロジを考慮したルーティングの紆余曲折など、便利になる反面で内部の仕組みは複雑化しています。L4 LB はまだまだ発展途上で、今後も目が離せません。

Discussion