Kubernetes 解体新書Part 2 ネットワーク

Service と ClusterIP: 宛先はどこで書き換わるのか

Pod が入れ替わっても、同じ宛先へ接続できる。Service の入口から実際の Pod へ、通信が届く仕組みを確かめます。

  • L2 ノードの中
  • 更新 2026年10月6日

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 つで、公式ドキュメントは「各段階が前の段階に機能を足す入れ子として設計されている」と書いています。

Service の type は入れ子: LoadBalancer は NodePort を含み、NodePort は ClusterIP を含む外部 LB からノードの NodePort へ、NodePort から ClusterIP の書き換え表へ、そこから Pod へ。ExternalName と headless は書き換えを行わず DNS だけで答える。外部 LBtype: LoadBalancerNode10.0.1.11NodePort :30080type: NodePort (全ノードで同じ番号)ClusterIP 10.96.12.34:80type: ClusterIP (既定)web10.244.0.23web10.244.0.31DNATEndpointSlice に載った Pod へ振り分けheadless (clusterIP: None)DNS が Pod IP をそのまま並べて返す。書き換え無しExternalNameDNS の CNAME だけ。IP も書き換えも持たない
図 1LoadBalancer は NodePort を含み、NodePort は ClusterIP を含む。headless と ExternalName は宛先の書き換えを行わず、DNS だけで答える。
  • 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: selector に一致する Pod の IP と状態を controller が保ち、各ノードの kube-proxy や cilium-agent が watch するService の selector app=web に一致する Pod を endpointslice controller が集め、ready / serving / terminating の状態を付けて EndpointSlice に書く。各ノードの kube-proxy や cilium-agent はそれを watch して書き換え表を更新する。Service webclusterIP: 10.96.12.34selector: app=webendpointslice controller(kube-controller-manager)selector に一致する Pod を集めて状態を付けるwatchweb10.244.0.23web10.244.0.31web (削除中)10.244.0.40EndpointSlice web-abc12kubernetes.io/service-name: web- 10.244.0.23 ready: true- 10.244.0.31 ready: true- 10.244.0.40 ready: false terminating: true書く (最大 100 件 / slice)各ノードのkube-proxy / cilium-agentwatch書き換え表 (iptables / nftables / eBPF map) を更新
図 2selector に一致する Pod を controller が集め、状態を付けて EndpointSlice に書く。各ノードの kube-proxy や cilium-agent はそれを watch して、宛先書き換えの表を更新する。

公式ドキュメントは「サービスプロキシの実装は Service と EndpointSlice を監視し、OS やクラウドの API でパケットを横取りまたは書き換えるようデータプレーンを設定する」と説明しています。 各ノードの kube-proxy か cilium-agent がその実装で、EndpointSlice の変更通知を apiserver から受け取り (watch)、ノード上の書き換え表 (次の節で見ます) を更新します。 Pod が 1 つ消えると、EndpointSlice の該当 endpoint が terminating になり、やがて外れ、各ノードの表からも外れます。

Service と EndpointSlice を作って見る
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.31

EndpointSlice の ENDPOINTS は Pod の IP と一致しています。 -l kubernetes.io/service-name=web で引けるのは、controller がこのラベルを付けて Service との対応を示しているからです。 次に ClusterIP へ curl と ping を打ちます。

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 です。

ClusterIP 宛てのパケットは、ノードの書き換え表で宛先が Pod IP に変わるclient Pod が 10.96.12.34:80 に送ったパケットは、ノードを出る前に書き換え表で宛先 10.244.0.31:80 に書き換わる。10.96.12.34 を持つ NIC はどこにも無い。Node (どのノードも同じ書き換え表を持つ)10.0.1.11client10.244.0.9パケット (送信時)dst 10.96.12.34:80src 10.244.0.9書き換え表kube-proxy の iptables / nftablesまたは Cilium の eBPF map10.96.12.34:80 → .23 または .31DNATパケット (書き換え後)dst 10.244.0.31:80src 10.244.0.9web10.244.0.3110.96.12.34 を持つ NIC は無い
図 3client が 10.96.12.34:80 へ送ったパケットは、ノードを出る前に宛先が 10.244.0.31:80 に書き換わる。

それでも届くのは、パケットが 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 世代

ClusterIP の書き換え表の 3 世代: iptables チェーン、nftables の map、eBPF の mapiptables は KUBE-SERVICES から KUBE-SVC、KUBE-SEP へとチェーンを順に辿る。nftables は service-ips という verdict map を 1 回引く。eBPF は cilium_lb4_services の map を 1 回引く。どれも最後は DNAT。iptables (2026 年も既定)KUBE-SERVICESKUBE-SVC-XXXXKUBE-SEP-AKUBE-SEP-Bp=0.5ルールを上から順に照合Service 数に比例して遅くなる-j DNAT --to 10.244.0.23:80nftables (1.33 で GA)table ip kube-proxyvmap @service-ipsservice-XXXX-ns/svc/tcp/801 回の表引きmap で宛先を引く (ハッシュ)変更分だけ差分更新dnat to numgen mod N map {…}eBPF (Cilium、演習クラスタ)tc / socket hookcilium_lb4_services_v2cilium_lb4_backendsmap lookupnetfilter を通らず NIC 直後で処理socket LB なら connect() 時に解決cilium bpf lb listどれも最後は「宛先を Pod IP に書き換える」
図 4iptables はチェーンを順に辿る。nftables は verdict map を 1 回引く。eBPF は netfilter を通らず eBPF map を引く。最後は全部 DNAT。

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 30080
    10.244.0.23:808010.244.0.31:8080
  • default/api10.96.40.7:443
    10.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 です。

kube-proxy が居ないことを確認する
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 の表にどう載っているかを見ます。

cilium-dbg service list と cilium-dbg bpf lb list
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 のテーブル一覧に kube-proxy が無いノードの中で実行
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 には現れません。

eBPF プログラムが取り付いている場所を見るノードの中で実行
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 を消しておきます。

デバッグ 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 からは見えません。

参考