Web アプリの Pod を作り直すと、IP アドレスが変わることがあります。 そのたびに接続先を書き換えずに済むよう、Service が共通の入口を用意します。
では、Service 宛てに送った通信は、どの Pod に届くのでしょうか。 この章では、送り先の一覧と転送のルールを見て、Service の IP から実際の Pod へ届くまでを追います。
GOAL この章のゴール
- Service の種類を、どこから接続するかで選び分けられる
- Service の送り先となる Pod の一覧を確認できる
- Service 宛ての通信が Pod へ転送される仕組みを説明できる
Service の種類
Serviceサービスラベルで選んだ Pod 群に、Pod が入れ替わっても変わらない IP と DNS 名を与えるリソース。クラスタ内部のロードバランサー。用語集で見る は、Pod の IP を直接教える代わりに固定の IP を 1 つ渡します。
DNS の名前に Pod の IP を複数並べる方式にしない理由を、公式ドキュメントは 3 つ挙げています。
DNS の TTL を守らず結果を保持し続ける実装が昔から多いこと、1 回だけ名前を引いて結果を持ち続けるアプリがあること、TTL を短くすれば DNS の負荷が高くなることです。
Service の type は ClusterIP、NodePort、LoadBalancer、ExternalName の 4 つで、公式ドキュメントは「各段階が前の段階に機能を足す入れ子として設計されている」と書いています。
- ClusterIP (既定):クラスタ内だけで届く IP。apiserver の
--service-cluster-ip-range(10.96.0.0/12など) から払い出される。 - NodePort:ClusterIP に加えて、全ノードの同じポート (
--service-node-port-range、既定 30000〜32767) で受ける。どのノードに届いても目的の Pod へ転送される。 - LoadBalancer:公式ドキュメントによれば「まず NodePort を要求したのと同じ変更を行い、その後 cloud-controller-manager が外部のロードバランサーをその NodePort に向けて設定する」。作る側は第 9 章で扱う。
- ExternalName:IP を持たず、クラスタの DNS が CNAME で外部のホスト名を返すだけ。
- headless (
clusterIP: None):ClusterIP を払い出さず、kube-proxy も扱わない。DNS が Pod の IP をそのまま並べて返す。StatefulSet の各 Pod に名前で届きたいときに使う。
EndpointSlice: 向き先の一覧
Service の selector に一致する Pod の IP 一覧は、EndpointSliceエンドポイントスライスService の向き先 (Pod の IP とポート、ready などの状態) を最大 100 件ずつ分割して持つリソース。Endpoints の後継。用語集で見る という別のリソースに入っています。
公式ドキュメントによれば、selector を持つ Service ごとに、コントロールプレーン (endpoint slice controller) が EndpointSlice を自動で作り、1 つの EndpointSlice に入れる endpoint は既定で最大 100 件 (--max-endpoints-per-slice で 1000 まで) です。
各 endpoint には 3 つの状態が付きます。
serving は Pod の Ready 条件をそのまま写したもの、terminating は Pod に削除のタイムスタンプが付いたときに立ち、ready は「serving かつ terminating でない」の短縮形です。
公式ドキュメントは「サービスプロキシの実装は Service と EndpointSlice を監視し、OS やクラウドの API でパケットを横取りまたは書き換えるようデータプレーンを設定する」と説明しています。
各ノードの kube-proxy か cilium-agent がその実装で、EndpointSlice の変更通知を apiserver から受け取り (watch)、ノード上の書き換え表 (次の節で見ます) を更新します。
Pod が 1 つ消えると、EndpointSlice の該当 endpoint が terminating になり、やがて外れ、各ノードの表からも外れます。
kubectl apply -f - <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
spec:
replicas: 2
selector:
matchLabels: { app: web }
template:
metadata:
labels: { app: web }
spec:
containers:
- name: nginx
image: nginx:1.29
ports: [{ containerPort: 80 }]
---
apiVersion: v1
kind: Service
metadata:
name: web
spec:
selector: { app: web }
ports:
- name: http
port: 80
targetPort: 80
EOF
kubectl rollout status deploy/web
kubectl get svc web
kubectl get endpointslices -l kubernetes.io/service-name=web -o wide
kubectl get pods -l app=web -o wide | awk '{print $1, $6}'出力例
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
web ClusterIP 10.96.12.34 <none> 80/TCP 5s
NAME ADDRESSTYPE PORTS ENDPOINTS AGE
web-abc12 IPv4 80 10.244.0.23,10.244.0.31 5s
web-7c9d8f6b5-abcde 10.244.0.23
web-7c9d8f6b5-fghij 10.244.0.31EndpointSlice の ENDPOINTS は Pod の IP と一致しています。
-l kubernetes.io/service-name=web で引けるのは、controller がこのラベルを付けて Service との対応を示しているからです。
次に ClusterIP へ curl と ping を打ちます。
IP=$(kubectl get svc web -o jsonpath='{.spec.clusterIP}')
curl -s -o /dev/null -w '%{http_code}\n' "http://$IP/"
ping -c 1 -W 1 "$IP" || echo "ping は返らない"ClusterIP 宛てのパケットはどこで書き換わるのか
さっきの curl を、パケットの側から追います。
ターミナルの実行環境 が 10.96.12.34:80 に接続すると、宛先 10.96.12.34、送信元 10.244.0.9 のパケットが Pod の eth0 から出ていきます。
ところが、10.96.12.34 を持つ NIC はクラスタのどこにもありません。
公式ドキュメントの言い方では「Pod の IP が固定の宛先に届くのと違い、Service の IP はどのホストも応答しない」IP です。
それでも届くのは、パケットが Pod の veth を抜けてノードの Linux カーネルに入った直後に、宛先が 10.244.0.31:80 (EndpointSlice に載っていた Pod の 1 つ) に書き換えられるからです。
公式ドキュメントは kube-proxy のこの動作を「仮想 IP 宛てのパケットを、endpoint ごとのルールで宛先 NAT (DNAT) によって backend に転送する」と説明しています。
書き換え後のパケットは、普通の Pod 宛てとして第 4 章の経路で届きます。
戻りのパケットは、第 2 章で見た conntrack が対応表を引いて送信元を 10.96.12.34 に戻してから返すので、ターミナルの実行環境 からはずっと 10.96.12.34 と話しているように見えます。
書き換えの元になる「ClusterIP:port → Pod IP:port の候補一覧」を各ノードに置き、EndpointSlice の変更に合わせて全ノードで同じ内容に保つのが、kube-proxy か cilium-agent です。
kube-proxy は第 2 章で使った netfilter の NAT ルールとして、Cilium は eBPF map (カーネル内の表) として置きます。
以後、この表を 書き換え表 と呼びます。
ping が返らないのは、書き換えの対象が Service に書いたプロトコルとポート (ここでは TCP 80) だけで、ICMP echo に応答する NIC も無いからです。
表の持ち方には 3 つの世代があります。
書き換え表の 3 世代
iptables (kube-proxy、2026 年時点の既定)
KUBE-SERVICES というルールの列に Service ごとの照合ルールを並べ、宛先が一致したら KUBE-SVC-xxxx に飛び、乱数 (-m statistic --probability) で endpoint ごとの KUBE-SEP-xxxx を選び、-j DNAT で宛先を書き換えます。
公式ドキュメントは、Service と endpoint ごとに数個ずつルールができるので、Pod と Service が数万あるクラスタではルールも数万になり、変更の反映に時間がかかる、と書いています。
nftables (kube-proxy、1.33 で GA)
table ip kube-proxy の中に service-ips という verdict map (キーを渡すと飛び先が返る表) を置き、「宛先 IP . プロトコル . ポート → Service ごとのチェーン」を 1 回の表引きで決めます。
飛んだ先のチェーンで dnat to numgen random mod N map { … } により endpoint を選んで書き換えます。
変更は map の要素とチェーンの単位で済みます。
eBPF (Cilium kube-proxy replacement。演習クラスタで動いているもの)
eBPFイービーピーエフカーネルの中で安全に実行される小さなプログラムと、それが使うデータ構造 (map) の仕組み。ネットワークの hook (tc、XDP、socket) に取り付けて、パケットや接続を書き換えられる。用語集で見る は、netfilter を通らずにパケットを書き換えます。
Cilium は書き換え表を eBPF map に持ち、veth のホスト側や NIC の tc hook に取り付けたプログラムがそれを引きます。
さらに socket LB では、Cilium のドキュメントによれば「connect() などのシステムコールの時点で宛先が Service IP かを調べ、backend の 1 つを選ぶ」ので、アプリは Service に接続したつもりでも、カーネルのソケットは最初から backend に接続しています。
演習クラスタの socketLB.hostNamespaceOnly=true では、この差し替えはホストの netns のプロセスだけに適用され、Pod からの通信は tc の eBPF プログラムが書き換えます。
同じ構成を 3 通りで描く
下のシミュレータは、同じ構成を 3 つの実装で描き分けます。
Service を足したり Pod を増減したりして、ルールが何行になるかを見ます。
iptables の --probability は、kube-proxy が実際に書く値と同じ計算です (残り N 個から 1 つを選ぶので 1/N、1/(N-1)、…、最後は無条件)。
- default/web10.96.12.34:80 · NodePort 3008010.244.0.23:808010.244.0.31:8080
- default/api10.96.40.7:44310.244.0.52:8443
eBPF (Cilium) (15 行)
# cilium-dbg service list (cilium-agent の Pod の中で)
ID Frontend Service Type Backend
1 10.96.12.34:80/TCP ClusterIP 1 => 10.244.0.23:8080/TCP (active)
2 => 10.244.0.31:8080/TCP (active)
2 0.0.0.0:30080/TCP NodePort 1 => 10.244.0.23:8080/TCP (active)
2 => 10.244.0.31:8080/TCP (active)
3 10.96.40.7:443/TCP ClusterIP 1 => 10.244.0.52:8443/TCP (active)
# cilium-dbg bpf lb list (eBPF map cilium_lb4_services_v2 / cilium_lb4_backends_v3 の中身)
SERVICE ADDRESS BACKEND ADDRESS (REVNAT_ID) (SLOT)
10.96.12.34:80/TCP 0.0.0.0:0/TCP (1) (0) [ClusterIP, non-routable]
10.244.0.23:8080/TCP (1) (1)
10.244.0.31:8080/TCP (1) (2)
0.0.0.0:30080/TCP 0.0.0.0:0/TCP (1) (0) [ClusterIP, NodePort, non-routable]
10.244.0.23:8080/TCP (1) (1)
10.244.0.31:8080/TCP (1) (2)
10.96.40.7:443/TCP 0.0.0.0:0/TCP (2) (0) [ClusterIP, non-routable]
10.244.0.52:8443/TCP (2) (1)
# スロット 0 がサービス自身 (backend 数などのメタ)、1.. がバックエンド。
# tc / socket の eBPF プログラムが接続ごとにスロットを 1 つ選び、宛先を書き換える。
# nft list ruleset を見ても、kube-proxy 相当のルールはどこにも無い。Service や Pod を増減すると、3 つの表現が同時に変わります。iptables は Service と endpoint の数だけルールとチェーンが伸び、nftables は map の要素と Service ごとのチェーンが増え、eBPF は map のスロットが増えます。
演習クラスタで eBPF 側を読む
演習クラスタでは Cilium が Service の書き換えを担当していて、kube-proxy は入れていません。
まずそれを確認します。
cilium-agent のコンテナに入っている診断コマンドは cilium-dbg です。
kubectl -n kube-system get ds,pods -l k8s-app=kube-proxy
kubectl -n kube-system exec ds/cilium -c cilium-agent -- cilium-dbg status | grep -E "KubeProxyReplacement|Cilium:"出力例
No resources found in kube-system namespace.
KubeProxyReplacement: True [eth0 10.0.1.11 (Direct Routing)]
Cilium: Ok 1.20.2 (v1.20.2-...)web Service が Cilium の表にどう載っているかを見ます。
IP=$(kubectl get svc web -o jsonpath='{.spec.clusterIP}')
kubectl -n kube-system exec ds/cilium -c cilium-agent -- cilium-dbg service list | grep -A2 "$IP"
kubectl -n kube-system exec ds/cilium -c cilium-agent -- cilium-dbg bpf lb list | grep -A2 "$IP"出力例
12 10.96.12.34:80/TCP ClusterIP 1 => 10.244.0.23:80/TCP (active)
2 => 10.244.0.31:80/TCP (active)
10.96.12.34:80/TCP 0.0.0.0:0/TCP (12) (0) [ClusterIP, non-routable]
10.244.0.23:80/TCP (12) (1)
10.244.0.31:80/TCP (12) (2)service list は cilium-agent が持つ Service の一覧、bpf lb list はカーネルの eBPF map の中身そのものです。
後者の (0) は Service 自身のエントリ、(1) (2) が backend です。
Pod を 1 つ消して作り直されるのを待ってから打ち直すと、backend の IP が差し替わります。
次はノードの中で、netfilter に kube-proxy の痕跡が無いことを nft で確認します。
kubectl debug node/$(kubectl get node -o jsonpath='{.items[0].metadata.name}') -it \
--profile=sysadmin --image=busybox:1.37 -- chroot /host nsenter -t 1 -m -u -i -n -p -- bash抜けるときは exit。デバッグ用 Pod は自動では消えないので、終わったら kubectl delete pod -l app.kubernetes.io/managed-by=kubectl-debug かkubectl get pods で確認して消してください。
nft list tables
nft list ruleset | grep -c -i "kube-\|KUBE-" || true
nft list ruleset | head -40出力例
table ip filter
table ip nat
table ip mangle
table ip raw
table ip6 filter
...
0
table ip filter {
chain INPUT {
type filter hook input priority filter; policy accept;
}
...
chain CILIUM_INPUT {
...table ip kube-proxy も KUBE-SERVICES も無く、あるのは Cilium 自身が入れる少数のチェーン (CILIUM_*) だけです。
Service の書き換えは eBPF map を引いて行われるので、nft list ruleset には現れません。
tc filter show dev cilium_host ingress 2>/dev/null | head -3
ls /sys/fs/bpf/tc/globals/ | grep lb4 | head出力例
filter protocol all pref 1 bpf chain 0 handle 0x1 cil_to_host-cilium_host direct-action not_in_hw id 1234 ...
cilium_lb4_backends_v3
cilium_lb4_reverse_nat
cilium_lb4_services_v2/sys/fs/bpf/tc/globals/ の cilium_lb4_services_v2 と cilium_lb4_backends_v3 が、さっき bpf lb list で読んだ表そのものです (名前は Cilium のソース bpf/lib/lb.h で定義されています)。
ノードから抜けたら、デバッグ用の Pod を消しておきます。
kubectl delete $(kubectl get pods -o name | grep node-debugger) --wait=falseふりかえり
Qtype: LoadBalancer の Service が持つものとして正しいのは?
type は入れ子です。LoadBalancer は NodePort を含み、NodePort は ClusterIP を含みます。外部 LB はノードの NodePort に向き、そこから ClusterIP の書き換え表を経て Pod に届きます。
QEndpointSlice を書くのは誰?
Service の selector に一致する Pod を endpoint slice controller が集め、serving / terminating / ready の状態を付けて書きます。kube-proxy や cilium-agent はそれを読む側です。
Q演習クラスタのノードで nft list ruleset に table ip kube-proxy が無い理由は?
kube-proxy は動いておらず、Cilium の eBPF プログラムが tc / socket の hook で宛先を書き換えます。書き換え表は /sys/fs/bpf の map に入っていて、nft からは見えません。
参考
- Kubernetes ドキュメント「Service」(type の入れ子、NodePort の範囲、LoadBalancer は NodePort の上に作られる、headless、ExternalName) https://kubernetes.io/docs/concepts/services-networking/service/
- Kubernetes ドキュメント「EndpointSlices」(controller、100 件の上限、serving / terminating / ready、service-name ラベル) https://kubernetes.io/docs/concepts/services-networking/endpoint-slices/
- Kubernetes ドキュメント「Virtual IPs and Service Proxies」(DNS ラウンドロビンにしない理由、モード、DNAT、既定の予告、ipvs の非推奨) https://kubernetes.io/docs/reference/networking/virtual-ips/
- Kubernetes ドキュメント「The Kubernetes network model」(サービスプロキシの実装の説明) https://kubernetes.io/docs/concepts/services-networking/
- Kubernetes ブログ「NFTables mode for kube-proxy」(table ip kube-proxy、service-ips map) https://kubernetes.io/blog/2025/02/28/nftables-kube-proxy/
- kube-proxy nftables proxier のソース (チェーンと map の名前、numgen random mod による振り分け) https://github.com/kubernetes/kubernetes/blob/release-1.37/pkg/proxy/nftables/proxier.go
- Cilium ドキュメント「Kubernetes Without kube-proxy」(socket LB、hostNamespaceOnly、cilium-dbg service list) https://docs.cilium.io/en/stable/network/kubernetes/kubeproxy-free/
- Cilium のソース bpf/lib/lb.h (cilium_lb4_services_v2 などの map 名) https://github.com/cilium/cilium/blob/v1.20.2/bpf/lib/lb.h