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

パケットの旅: ELB から別ノードの Pod まで

ロードバランサーから Pod まで、通信を 1 段ずつ追います。宛先や送信元の IP が変わる場所を見つけます。

  • L1 Web ターミナル
  • 更新 2026年10月6日

ブラウザーから Web アプリを開けたのに、アクセスログには利用者ではなくノードの IP が出ている。 通信が届く途中で、送信元のアドレスが変わることがあります。

この章では、ロードバランサーからノードを経由して Pod に届く経路を、図のステップ再生で追います。 各地点で宛先と送信元を見比べ、どこで、何のために書き換わるのかを確かめます。

GOAL この章のゴール

  • ロードバランサーからノードを経由して Pod へ届く通信を追える
  • 送信元 IP が書き換わる理由と、保持する設定が分かる
  • NodePort を作り、ノードの IP とポートでアプリに接続できる

全体像

type: LoadBalancer の通信は、LoadBalancer → NodePort → ClusterIP の書き換え表 → Pod と、Service の入れ子を順に辿るELB が 3 台のノードの NodePort のどれかに送り、受けたノードが ClusterIP の書き換え表で Pod IP に書き換え、Pod ネットワーク経由で目的の Pod へ届く。ELBtype: LoadBalancer1. LB に到達し、任意のノードに振り分けNode 110.0.1.10NodePort :30080kube-proxy / eBPFPod の所在を確認api10.244.1.4Node 210.0.1.11NodePort :30080kube-proxy / eBPFPod の所在を確認web10.244.2.7Node 310.0.1.12NodePort :30080kube-proxy / eBPFPod の所在を確認db10.244.3.22. 所在を引いて DNAT3. Pod ネットワークを通って「赤い Pod」に到達。どのノードで受けても同じ結果になる
図 1ELB は任意のノードの NodePort に送る。受けたノードが書き換え表で Pod の所在を引き、Pod ネットワーク経由で目的の Pod へ転送する。

通信は次の 3 ステップで Pod に届きます。

  1. 通信が LB に到達し、LB が任意のノードに振り分ける。
  2. そのノードの kube-proxy (または eBPF) が、書き換え表で Pod の所在 (EndpointSlice) を引く。
  3. 宛先が Pod IP に書き換わり、Pod ネットワークを通って目的の Pod に到達する。

Service の type で言えば、手順 1 が LoadBalancer、手順 1 から 2 への受け渡しが NodePort、手順 2 が ClusterIP の書き換え表にあたります。 公式ドキュメントが「LoadBalancer はまず NodePort と同じ変更を行い、外部 LB をその NodePort に向ける」と書いていたとおり、第 5 章で見た入れ子がそのまま経路になります。

1 つの Service が持つ 3 つの宛先: LB の IP、ノードの NodePort、ClusterIP。どこから入っても最後は同じ EndpointSlicetype: LoadBalancer の Service は、外部 LB の IP、各ノードの NodePort、クラスタ内の ClusterIP の 3 つの入口を持ち、どの入口も同じ書き換え表と EndpointSlice に合流する。198.51.100.10:80status.loadBalancer.ingress10.0.1.x:30080spec.ports[].nodePort (全ノード)10.96.12.34:80spec.clusterIP10.244.2.7:8080EndpointSlice の 1 件LB → NodePortDNATクラスタ内の Pod は ClusterIP から直接入る入口が 3 つあるだけで、向き先の表は 1 つ
図 21 つの Service の 3 つの入口。LB の IP、ノードの NodePort、ClusterIP のどこから入っても、同じ書き換え表と EndpointSlice に合流する。

一歩ずつ追う

下のステップ再生で、パケットの現在地と、その時点の宛先と送信元を追ってください。 既定 (externalTrafficPolicy: Cluster) では、LB が選んだ Node A に目的の Pod が居ないケースを再生しています。

client203.0.113.5ELB / NLB198.51.100.10:80全ノードの NodePort 30080 がターゲットNode A10.0.1.10書き換え表kube-proxy / eBPFDNAT + SNAT(web の Pod は居ない)Node B10.0.1.11書き換え表DNATweb Pod10.244.2.7:8080Pod ネットワーク (VXLAN / BGP / VPC ルート)
dst198.51.100.10:80src203.0.113.5:51000行き

1/7クライアントが LB の IP (198.51.100.10:80) に接続する

Cluster (既定) では LB がどのノードに送っても届く代わりに、SNAT でクライアント IP が消え、余計なホップが増えます。Local では Pod の居るノードだけが受け、クライアント IP が残ります。

ステップ 3 と 4 で、Node A は宛先を Pod IP に書き換える (DNAT) だけでなく、送信元を自分の IP に書き換えています (SNAT)。 公式ドキュメントも、ClusterIP 宛てではクライアント IP を書き換えずに転送するが、NodePort や LB 経由ではクライアント IP が変更される、と書いています。 この SNAT が要る理由は、しない場合を考えると分かります。

なぜ NodePort で SNAT するのか: 戻りパケットを DNAT したノードに通すためNode A が DNAT だけして Pod に送ると、Pod は client へ直接返そうとし、client は知らない送信元からの応答を捨てる。SNAT で送信元を Node A にしておけば、戻りは Node A を通り conntrack が元に戻す。SNAT しない場合 (壊れる)client203.0.113.5Node A.10web10.244.2.71. dst LB:802. DNAT3. src 10.244.2.7 で直接返すclient は 198.51.100.10 に送ったのに10.244.2.7 から返事が来る→ 対応する接続が無いので捨てるSNAT する場合 (既定の Cluster)client203.0.113.5Node A.10web10.244.2.71. 行き / 5. 戻り2. DNAT + SNAT3. src 10.0.1.10 宛てに返す4. Node A の conntrack が逆変換して client へ代償: Pod から見た送信元が Node A の IP になる
図 3SNAT しないと、Pod は client へ直接返そうとし、client は見知らぬ送信元からの応答を捨てる。SNAT で戻りを Node A に通し、conntrack が元に戻す。

NAT は往復で対にして初めて成立します。 行きで宛先を書き換えたノードを、戻りも必ず通す必要があります。 だからノードを跨いで転送するときは送信元もそのノードにしておく。 これが NodePort の SNAT (masquerade) で、第 2 章で nft を使って自分で入れたものと同じ動作です。 代償として、Pod から見た送信元はノードの IP になり、クライアントの IP は失われます。

externalTrafficPolicy

クライアント IP を残したい、余計なホップを減らしたい、という要求に応えるのが externalTrafficPolicy: Local です。

externalTrafficPolicy: Cluster は全ノードが受けて SNAT、Local は Pod の居るノードだけが受けてクライアント IP を保つCluster では LB が 3 台全部に送り、Pod の無いノードは別ノードへ SNAT して転送する。Local では healthCheckNodePort で Pod の無いノードが unhealthy になり、LB は Pod の居るノードにだけ送る。Cluster (既定)LBN1N2N3webSNATSNATどこに届いても OK。余計なホップ + クライアント IP が消えるLocalLBN1N2N3webunhealthyunhealthyhealthCheckNodePort で Pod の居るノードだけ。SNAT 無し、クライアント IP が残る。偏りは出る
図 4Cluster は全ノードが受けて SNAT で転送。Local は Pod の居るノードだけが受け、SNAT しない。
  • Cluster (既定):どのノードに届いても、Pod の居るノードへ転送する。LB のヘルスチェックはサービスプロキシの readiness (kube-proxy なら :10256/healthz) を見るので、全ノードが healthy。均等に散らばるが、ホップが増え、クライアント IP が消える。
  • Local:API リファレンスの説明では「外部 LB がノード間の振り分けを担うと仮定し、各ノードは自分の上の endpoint にだけ、クライアントの送信元 IP を masquerade せずに届ける。endpoint の無いノードに届いた通信は捨てられる」。type: LoadBalancer のときは healthCheckNodePort が自動で割り当てられ、LB はそこで Pod の有無を確かめて振り分け対象を絞る。ノードごとの Pod 数が違うと負荷が偏る。

上のシミュレータのトグルで Local に切り替えると、LB が Node B にだけ送り、SNAT のステップが消えるのが分かります。

演習クラスタで NodePort を辿る

演習クラスタの cloud-controller-manager は LoadBalancer 機能を持たないので (第 9 章で扱います)、外部 LB は作られません。 その代わり、NodePort までは辿れます。 第 5 章の web Service を NodePort に変えて、ノードの IP から届くことを確認します。

Service を NodePort に変える
kubectl patch svc web -p '{"spec":{"type":"NodePort"}}'
kubectl get svc web
NODE_IP=$(kubectl get node -o jsonpath='{.items[0].status.addresses[?(@.type=="InternalIP")].address}')
NODE_PORT=$(kubectl get svc web -o jsonpath='{.spec.ports[0].nodePort}')
echo "$NODE_IP:$NODE_PORT"
curl -s -o /dev/null -w '%{http_code}\n' "http://$NODE_IP:$NODE_PORT/"

出力例

NAME   TYPE       CLUSTER-IP    EXTERNAL-IP   PORT(S)        AGE
web    NodePort   10.96.12.34   <none>        80:31234/TCP   10m
10.0.1.11:31234
200

ClusterIP (10.96.12.34:80) と NodePort (10.0.1.11:31234) のどちらに送っても同じ Pod に届きます。 Cilium の表にも NodePort の行が増えています。

Cilium の表に NodePort が増えたことを見る
kubectl -n kube-system exec ds/cilium -c cilium-agent -- cilium-dbg service list | grep -B1 -A2 NodePort | head -12

出力例

13   0.0.0.0:31234/TCP      NodePort       1 => 10.244.0.23:80/TCP (active)
                                           2 => 10.244.0.31:80/TCP (active)
14   10.0.1.11:31234/TCP    NodePort       1 => 10.244.0.23:80/TCP (active)
                                           2 => 10.244.0.31:80/TCP (active)

Pod 側のアクセスログで、送信元がどう見えるかも確認しておきます。 演習クラスタは単一ノードなので backend は必ず同じノードに居て、ClusterIP 経由でも NodePort 経由でも、nginx のログには ターミナルの実行環境 の IP がそのまま出ます。

Pod から見た送信元 IP を見る
MY_IP=$(ip -4 addr show eth0 | awk '/inet /{print $2}' | cut -d/ -f1)
echo "workbench: $MY_IP"
curl -s -o /dev/null "http://10.96.12.34/" ; curl -s -o /dev/null "http://$NODE_IP:$NODE_PORT/"
kubectl logs -l app=web --tail=4 | awk '{print $1}'

出力例

workbench: 10.244.0.9
10.244.0.9
10.244.0.9

複数ノードのクラスタで Pod の無いノードの NodePort に送ると、ここにそのノードの IP が出ます。 それが上の図で見た SNAT の跡です。

healthCheckNodePort は type: LoadBalancer で Local にしたときだけ割り当てられるので、LB が pending のままでも、フィールドが埋まる様子は見られます。

externalTrafficPolicy を Local にする
kubectl patch svc web -p '{"spec":{"type":"LoadBalancer","externalTrafficPolicy":"Local"}}'
kubectl get svc web -o jsonpath='{.spec.healthCheckNodePort}{"\n"}'
curl -s -o /dev/null -w '%{http_code}\n' "http://$NODE_IP:$NODE_PORT/"
kubectl patch svc web -p '{"spec":{"type":"ClusterIP","externalTrafficPolicy":null}}'

出力例

31567
200

healthCheckNodePort の 31567 番が、LB のヘルスチェック先になるポートです。 最後の行で設定を元に戻しています。

ふりかえり

Qtype: LoadBalancer の通信が辿る順番として正しいのは?

LB はノードの NodePort に送り、ノード上の書き換え表 (ClusterIP の宛先を書き換えるものと同じ) が Pod IP に書き換えます。LB が Pod に直接送る構成は第 8 章のコンテナネイティブ LB です。

QNodePort で別ノードの Pod へ転送するときに SNAT する理由は?

NAT は往復で対になる必要があります。行きで宛先を書き換えたノードを戻りも通さないと、client は見知らぬ送信元からの応答を捨てます。SNAT はそのための措置で、クライアント IP が消えるのは副作用です。

QexternalTrafficPolicy: Local の説明として正しいのは?

Local では、受けたノードは同じノードの Pod にだけ転送し、送信元 IP を masquerade しません。type: LoadBalancer なら healthCheckNodePort で Pod の無いノードを LB の対象から外せます。代わりにノード間で負荷が偏ることがあります。

参考