kube-proxy の externalTrafficPolicy=Local の改善
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 におけるコンテナ間のネットワークの仕組みは複雑です。以下の発表やブログ記事が理解に役立つかもしれません。
- The ins and outs of networking in Google Container Engine and Kubernetes (Google Cloud Next '17)
- Life of a Packet [I] - Michael Rubin, Google
- Tracing the path of network traffic in Kubernetes
- Liberating Kubernetes From Kube-proxy and Iptables - Martynas Pumputis, Cilium
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 に serving
と terminating
という 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