kube-proxyを外したらServiceのルールが消えた
目標
kube-proxyなしで動いている本物のCilium 1.20.1で、Serviceの負荷分散がどのBPFマップとどのeBPFプログラムに移ったのかを、自分で読み取ります。iptablesが空であることを確認し、Serviceマップ・conntrackマップ・Podデバイスのプログラム・NodePort・バックエンドのないServiceの応答まで追いかけます。
なぜ重要なのか
kube-proxyのiptablesモードは、Serviceとエンドポイントごとにルールを作り、ルールは上から順に評価されます。Serviceが増えるとルールが増え、変更のたびにルールを書き直します。Ciliumのkube-proxy置き換えは、Serviceをハッシュマップのエントリとして持ち、Podとノードのデバイスに付いたeBPFプログラムが、パケットが通過するときにマップを検索して宛先を書き換えます。
この違いを知っていてこそ、障害のときにどこを見るべきかがわかります。「Serviceが動かない」という報告に対してiptables-saveを調べても、何も出てきません。代わりに、Serviceマップのバックエンドスロット、conntrackのエントリ、Podデバイスにプログラムが付いているかどうかを見る必要があります。
VM内の個人用k3s(Cilium 1.20.1、kubeProxyReplacement=true)だけを使ってください。環境の準備に約5分かかります。セッションが終わると、/root/cca-datapathのファイルは消えます。
ステップ
kubectl apply -f /opt/fixtures/cca-datapath/web.yamlで、cca-dpネームスペースにweb(レプリカ2)とClusterIP Service webを起動してください。PodがReadyになったら、VMのシェルでiptables-saveを読み取り、/root/cca-datapath/iptables.jsonに記録します。記録する項目は、cluster_ip(Service webのClusterIP)、kube_svc_lines(KUBE-SVCを含む行数)、cluster_ip_lines(そのClusterIPを含む行数)、cilium_lines(CILIUMを含む行数)、kube_proxy_replacement(agentのcilium-dbg statusのKubeProxyReplacementの値)です。数は数値で書きます。- agentの
cilium-dbg service list -o jsonでcca-dp/webのClusterIPのエントリを探し、cilium-dbg bpf lb listで同じフロントエンドのスロットを確認してください。/root/cca-datapath/svc.jsonに、service_id(数値)、frontend(ClusterIP:80)、backends(バックエンドのip:port 2つのリスト)、backend_pods(そのIPを持つweb Pod名2つ)を記録します。 - webをレプリカ4個に増やしてください。4つのPodがReadyになり、
bpf lb listのwebフロントエンドのバックエンドスロットが4個になるまで短くポーリングしたあと、/root/cca-datapath/scale.jsonに、backends(現在マップにあるip:port 4つ)、new_backends(svc.jsonになかった2つ)を記録します。EndpointSliceのreadyアドレスとも一致している必要があります。 kubectl apply -f /opt/fixtures/cca-datapath/holder.yamlで、holder Podを起動してください。holderは、送信元ポート40404でwebのService(ClusterIP:80)へのTCP接続を1つ開いたまま保持し、ログにアプリがgetpeernameで見た相手のアドレスを出力します。3か所で同じ接続を確認してください。holderのログ、holder内のnetstat -tn、agentのcilium-dbg bpf ct list globalの:40404のエントリです。/root/cca-datapath/ct.jsonに、client(holderIP:40404)、app_peer(ログの相手のアドレスip:port)、socket_peer(holderのnetstatのForeign Address)、backend(conntrackのOUTエントリの宛先ip:port)、backend_pod(そのIPのweb Pod名)、svc_entries(:40404を持つTCP SVCエントリの数、数値)、socket_lb_coverage(cilium-dbg status --verboseのSocket LB Coverage)を記録します。バックエンドPod内のnetstatにも、holderのIP:40404が表示される必要があります。- holderのエンドポイント番号(
kubectl -n cca-dp get cep holderのstatus.id)で、agentのcilium-dbg endpoint get <번호> -o json(プレースホルダーはエンドポイント番号です)を読み取り、ホスト側のデバイス名とifindexを探してください。そして、VMのシェルでbpftool net show dev <장치>(プレースホルダーはデバイス名です)とtc filter show dev <장치> ingress(プレースホルダーはデバイス名です)を比較してください。/root/cca-datapath/prog.jsonに、endpoint_id、interface、ifindex、attach(bpftoolが示したアタッチ先)、program(プログラム名)、policy_map(cilium-dbg map listで見つけた、このエンドポイントのポリシーマップ名)、tc_filter_lines(tc filterの出力の行数、数値)を記録します。 - cca-dpにNodePort Service
web-npを作成してください。selectorはapp=web、portは80、targetPortは8080、nodePortは30780です。ノードのInternalIPの30780にVMのシェルからリクエストを送って200を確認し、/root/cca-datapath/nodeport.jsonに、node_ip、node_port、http_code(数値)、iptables_lines(iptables-saveで30780を含む行数)、listen_sockets(ss -Htln 'sport = :30780'の行数)、service_id(agentの一覧にある0.0.0.0:30780のNodePortエントリの番号)を記録します。 - cca-dpに、selectorが
app=ghost、port 80 → targetPort 8080のClusterIP Serviceghostを作成してください(そのラベルのPodは作成しません)。curl Podprobe(イメージはcurlimages/curl:8.10.1@sha256:d9b4541e214bcd85196d6e92e2753ac6d0ea699f0af5741f8c6cccbfcf00ef4b、コマンドはsleep 86400)を起動し、その中からghostのClusterIPに5秒の時間制限を付けてリクエストを送ります。/root/cca-datapath/ghost.jsonに、cluster_ip、backends(agentの一覧にあるバックエンドの数、数値)、curl_exit(curlの終了コード)、seconds(curlのtime_total、数値)、no_backend_response(cilium-dbg config -aのServiceNoBackendResponseの値)を記録します。 /root/cca-datapath/report.txtに、키=값形式の行(プレースホルダーはキーと値です)を7行書きます。項目は、kube_proxy_replacement、socket_lb_coverage(status --verboseのSocket LB Coverage)、web_backends(現在のbpf lb listのwebのバックエンドスロット数)、holder_backend(現在のconntrackでholderの接続が向かったip:port)、holder_program(prog.jsonのprogram)、nodeport_iptables_lines(現在の30780を含むiptablesの行数)、ghost_no_backend_responseです。値はすべて、現在の状態および前の記録と一致している必要があります。
参考
- Kubernetes Without kube-proxy(ソケットLB、hostNamespaceOnly、NodePort): https://docs.cilium.io/en/v1.20/network/kubernetes/kubeproxy-free/
- eBPFマップの一覧とサイズ: https://docs.cilium.io/en/v1.20/network/ebpf/maps/
- BPFとXDPのリファレンスガイド: https://docs.cilium.io/en/v1.20/reference-guides/bpf/index.html
- agentのコマンド:
kubectl -n kube-system exec ds/cilium -c cilium-agent -- cilium-dbg service list | bpf lb list | bpf ct list global | map list | endpoint get <번호>(プレースホルダーはエンドポイント番号です) - VMのシェルには、bpftool・tc・ss・iptables-save・jqがあります。pythonイメージのPod内には、busyboxのnetstatがあります。
kube-proxyがないのに、Serviceは誰が動かしているのか
kubectl apply -f /opt/fixtures/cca-datapath/web.yamlで、cca-dpネームスペースにweb(レプリカ2)とClusterIP Service webを起動してください。PodがReadyになったら、VMのシェルでiptables-saveを読み取り、/root/cca-datapath/iptables.jsonに記録します。記録する項目は、cluster_ip(Service webのClusterIP)、kube_svc_lines(KUBE-SVCを含む行数)、cluster_ip_lines(そのClusterIPを含む行数)、cilium_lines(CILIUMを含む行数)、kube_proxy_replacement(agentのcilium-dbg statusのKubeProxyReplacementの値)です。数は数値で書きます。
kube-proxy(iptablesモード)は、Serviceごとに、KUBE-SVCチェーンとKUBE-SEPチェーンを作って宛先を書き換えます。その痕跡があるかどうかを、行数で数えてみてください。grep -cは1つも見つからないと終了コード1を返すので、set -eの下では注意してください。agentのコマンドは、kubectl -n kube-system exec ds/cilium -c cilium-agent -- cilium-dbg ...で実行します。
Serviceの番号札をBPFマップで見つける
agentのcilium-dbg service list -o jsonでcca-dp/webのClusterIPのエントリを探し、cilium-dbg bpf lb listで同じフロントエンドのスロットを確認してください。/root/cca-datapath/svc.jsonに、service_id(数値)、frontend(ClusterIP:80)、backends(バックエンドのip:port 2つのリスト)、backend_pods(そのIPを持つweb Pod名2つ)を記録します。
Serviceリストのjsonでは、spec.flagsに名前・ネームスペース・タイプが、spec.frontend-addressとspec.backend-addressesにアドレスがあります。bpf lb listの1つのフロントエンドには、かっこ内の番号が0の行(Service自体)と、1から始まるバックエンドの行があります。PodのIPは、kubectl get pod -o wideと突き合わせてください。
レプリカを増やすと、マップが先に知る
webをレプリカ4個に増やしてください。4つのPodがReadyになり、bpf lb listのwebフロントエンドのバックエンドスロットが4個になるまで短くポーリングしたあと、/root/cca-datapath/scale.jsonに、backends(現在マップにあるip:port 4つ)、new_backends(svc.jsonになかった2つ)を記録します。EndpointSliceのreadyアドレスとも一致している必要があります。
KubernetesがEndpointSliceを更新し、agentはそれを見てServiceマップのバックエンドスロットを書き直します。iptablesのルール数千行を書き直す代わりに、マップのエントリ数個だけが変わります。固定のsleepの代わりに、スロット数を数えるループを使ってください。
アプリはServiceにつながったと思っているが、ソケットはすでにPodにつながっている
kubectl apply -f /opt/fixtures/cca-datapath/holder.yamlで、holder Podを起動してください。holderは、送信元ポート40404でwebのService(ClusterIP:80)へのTCP接続を1つ開いたまま保持し、ログにアプリがgetpeernameで見た相手のアドレスを出力します。3か所で同じ接続を確認してください。holderのログ、holder内のnetstat -tn、agentのcilium-dbg bpf ct list globalの:40404のエントリです。/root/cca-datapath/ct.jsonに、client(holderIP:40404)、app_peer(ログの相手のアドレスip:port)、socket_peer(holderのnetstatのForeign Address)、backend(conntrackのOUTエントリの宛先ip:port)、backend_pod(そのIPのweb Pod名)、svc_entries(:40404を持つTCP SVCエントリの数、数値)、socket_lb_coverage(cilium-dbg status --verboseのSocket LB Coverage)を記録します。バックエンドPod内のnetstatにも、holderのIP:40404が表示される必要があります。
kube-proxy置き換えのソケットLBは、Podがconnect()を呼び出した瞬間に、cgroupに付いたeBPFが宛先をバックエンドに書き換えます。そのため、パケットには最初からServiceのアドレスがなく、アプリにはgetpeernameでServiceのアドレスを返して見せます。3つの観測がそれぞれ異なるアドレスを示す理由を、この順序で説明してみてください。バックエンドから見た送信元がノードのIPに変わっていないかも確認します。
Pod脇のデバイスに付いたプログラムと、そのPodだけのポリシーマップ
holderのエンドポイント番号(kubectl -n cca-dp get cep holderのstatus.id)で、agentのcilium-dbg endpoint get <번호> -o json(プレースホルダーはエンドポイント番号です)を読み取り、ホスト側のデバイス名とifindexを探してください。そして、VMのシェルでbpftool net show dev <장치>(プレースホルダーはデバイス名です)とtc filter show dev <장치> ingress(プレースホルダーはデバイス名です)を比較してください。/root/cca-datapath/prog.jsonに、endpoint_id、interface、ifindex、attach(bpftoolが示したアタッチ先)、program(プログラム名)、policy_map(cilium-dbg map listで見つけた、このエンドポイントのポリシーマップ名)、tc_filter_lines(tc filterの出力の行数、数値)を記録します。
Ciliumは、Podごとにホスト側のveth(lxc…)にプログラムを付け、Podごとに別々のポリシーマップを持たせます。マップ名の末尾の数字とエンドポイント番号を比べてみてください。最新のカーネルではプログラムをtcxでアタッチしますが、古いtcコマンドはtcxのアタッチを表示しません。
リッスンするプロセスがないのに、NodePortが応答する
cca-dpにNodePort Service web-npを作成してください。selectorはapp=web、portは80、targetPortは8080、nodePortは30780です。ノードのInternalIPの30780にVMのシェルからリクエストを送って200を確認し、/root/cca-datapath/nodeport.jsonに、node_ip、node_port、http_code(数値)、iptables_lines(iptables-saveで30780を含む行数)、listen_sockets(ss -Htln 'sport = :30780'の行数)、service_id(agentの一覧にある0.0.0.0:30780のNodePortエントリの番号)を記録します。
NodePortは、ポートを開くプロセスではなく、ノードのデバイスに付いたeBPFプログラムが受け取ってServiceマップに送ります。そのため、ssでもiptablesでも見えないポートが応答します。Serviceリストのjsonで、frontend-addressのipが0.0.0.0で、flags.typeがNodePortであるエントリを選んでください。
Podが1つもないServiceは待たない
cca-dpに、selectorがapp=ghost、port 80 → targetPort 8080のClusterIP Service ghostを作成してください(そのラベルのPodは作成しません)。curl Pod probe(イメージはcurlimages/curl:8.10.1@sha256:d9b4541e214bcd85196d6e92e2753ac6d0ea699f0af5741f8c6cccbfcf00ef4b、コマンドはsleep 86400)を起動し、その中からghostのClusterIPに5秒の時間制限を付けてリクエストを送ります。/root/cca-datapath/ghost.jsonに、cluster_ip、backends(agentの一覧にあるバックエンドの数、数値)、curl_exit(curlの終了コード)、seconds(curlのtime_total、数値)、no_backend_response(cilium-dbg config -aのServiceNoBackendResponseの値)を記録します。
バックエンドがないときにデータパスがパケットを黙って破棄すると、クライアントは制限時間まで待ちます。拒否の応答を返せば、すぐに失敗します。curlの終了コード7と28が何を意味するのかを比べてください。curlの-w '%{time_total}'は、失敗しても出力されます。
kube-proxyがしていた仕事の新しい住所録
/root/cca-datapath/report.txtに、키=값形式の行(プレースホルダーはキーと値です)を7行書きます。項目は、kube_proxy_replacement、socket_lb_coverage(status --verboseのSocket LB Coverage)、web_backends(現在のbpf lb listのwebのバックエンドスロット数)、holder_backend(現在のconntrackでholderの接続が向かったip:port)、holder_program(prog.jsonのprogram)、nodeport_iptables_lines(現在の30780を含むiptablesの行数)、ghost_no_backend_responseです。値はすべて、現在の状態および前の記録と一致している必要があります。
前のステップのファイルを書き直すのではなく、現在の状態を読み直して書いてください。holderが再起動して再接続した場合、バックエンドが変わっている可能性があるので、ct.jsonも作り直す必要があります。