jimyag's Blog

Kubernetes Service 的流量路径:kube-proxy、NAT 与源 IP

访问一个 Service 时,数据包并不会进入某个名为 kube-proxy 的用户态进程,再由它转发给 Pod。kube-proxy 的主要工作是监听 Service 和 EndpointSlice,把期望状态翻译成节点内核规则;真正处理每个数据包的是 Linux netfilter、conntrack 和路由系统,或者替代 kube-proxy 的 eBPF 数据面。

这一区别能解释几件经常混在一起的事:ClusterIP 为什么不需要绑定到网卡,Service 为什么能把目标地址改成 Pod IP,NodePort 为什么可能改写客户端 IP,以及 externalTrafficPolicy: Local 为什么既能保留源 IP,又会让没有本地后端的节点拒绝流量。

本文以 Kubernetes v1.35.0 的 kube-proxy iptables 模式为主线,先建立 NAT 和连接跟踪的基础模型,再用三节点 kind 集群中的请求结果、iptables 规则和服务端日志还原真实流量。IPVS、nftables 与 Cilium eBPF 的实现差异留到后续文章;它们实现 Service 的方式不同,但本文使用的地址、连接和返回路径模型仍然适用。

kube-proxy 通常不转发数据包

在实验集群里,kube-proxy 的配置和启动日志分别给出了两份独立证据:

1
2
3
4
5
6
$ kubectl -n kube-system get configmap kube-proxy \
    -o jsonpath='{.data.config\.conf}' | grep '^mode:'
mode: iptables

$ kubectl -n kube-system logs daemonset/kube-proxy --tail=80 | grep Proxier
I0920 16:52:10.201690 1 server_linux.go:136] "Using iptables Proxier"

Kubernetes v1.35.0 源码也沿着同一条分支执行:createProxier 根据 config.Mode 创建 iptables、IPVS 或 nftables proxier。选中 iptables 后,kube-proxy 持续把 Service 和 EndpointSlice 同步成规则;连接建立后,不需要为每个包再次调用 kube-proxy。

可以把控制链路和数据链路分开看:

  flowchart LR
  API["API Server<br/>Service 与 EndpointSlice"] -->|watch 变更| KP["每个节点的 kube-proxy"]
  KP -->|同步| NF["netfilter / iptables 规则"]
  C["客户端数据包"] --> NF
  NF -->|选择 endpoint 并改写地址| P["后端 Pod"]

图中省略了 CNI 路由、网桥和物理网络。关键点是:上半部分负责生成状态,下半部分才是逐包路径。

NAT 到底改了什么

一条 TCP 连接可以用五元组标识:

1
源 IP、源端口、目标 IP、目标端口、协议

假设客户端 10.244.2.2:44834 访问 Service 10.96.123.219:80,最终后端是 10.244.1.3:8080。数据面至少要做一次目标地址转换:

1
2
转换前:10.244.2.2:44834 -> 10.96.123.219:80
DNAT 后:10.244.2.2:44834 -> 10.244.1.3:8080

DNAT 修改目标地址。只做 DNAT 时,后端仍能看到客户端 Pod IP。

SNAT 修改源地址。例如外部客户端 172.30.0.5 通过另一个节点的 NodePort 访问后端,实验中进入后端的连接变成:

1
2
原始连接:172.30.0.5:<port> -> 172.30.0.4:30080
转换之后:172.30.0.4:44424 -> 10.244.1.3:8080

这里的 172.30.0.4 是入口节点地址。后端只看到入口节点,原客户端地址已经被 SNAT 隐藏。

Linux conntrack 会记录同一条连接转换前后的状态。返回包从 10.244.1.3:8080 发出时,conntrack 按已有状态做反向转换,让客户端仍然认为自己在与 Service IP 或 Node IP 通信。Service 不是在请求方向改完地址就结束;返回方向能否沿着持有 conntrack 状态的节点回来,同样重要。

实验拓扑与证据边界

实验运行在 m6 的三节点 kind 集群。清单、采集脚本和原始输出已提交到 k8sdev 6da31f8,命令与该提交匹配:

对象地址作用
svc-iptables-worker172.30.0.3运行唯一后端 Pod
svc-iptables-worker2172.30.0.4没有本地后端,用于观察跨节点转发
service-path-client172.30.0.5kind 网络中的集群外客户端
后端 Pod10.244.1.3:8080返回 TCP 对端地址
Service10.96.123.219:80ClusterIP 入口
NodePort30080两个 worker 上的外部入口

节点使用 kindnet,Pod CIDR 为 10.244.0.0/16,kube-proxy 明确运行在 iptables 模式。文中的具体地址属于这个容器化实验环境,生产集群的接口名和地址会不同;DNAT、SNAT、Local 策略和 conntrack 的判断不依赖这些具体数值。

后端使用 Python 标准库直接读取 TCP 对端地址,不读取 X-Forwarded-For:

1
2
if self.path == "/clientip":
    body = f"{self.client_address[0]}:{self.client_address[1]}\n"

因此下面的输出表示后端 socket 真正看到的源地址,不是七层代理补写的 HTTP Header。

ClusterIP:通常只做 DNAT,保留 Pod 源 IP

先从两个 Pod 访问同一个 ClusterIP。一个客户端与后端同节点,另一个客户端在另一个 worker:

1
2
3
ClusterIP requests from Pods
pod=client-local-node  pod_ip=10.244.1.4 backend_observed=10.244.1.4:60658
pod=client-remote-node pod_ip=10.244.2.2 backend_observed=10.244.2.2:44834

无论是否跨节点,后端看到的源 IP 都与客户端 Pod IP 相同。这个实验没有开启 masqueradeAll,客户端地址属于 Pod CIDR,普通 ClusterIP 流量不需要 SNAT。

跨节点路径可以简化为:

  flowchart LR
  C["客户端 Pod<br/>10.244.2.2"] -->|"目标 10.96.123.219:80"| N2["worker2 netfilter"]
  N2 -->|"DNAT 目标<br/>10.244.1.3:8080"| R["Pod 跨节点路由"]
  R --> P["后端 Pod<br/>10.244.1.3"]
  P -.->|"返回包由 conntrack 反向转换"| C

图中虚线把返回方向压缩成一条关系,实际返回包仍经过节点路由和 conntrack。

从 iptables 规则走一遍

实验 Service 生成了以下关键规则,省略哈希无关的其他 Service:

1
2
3
4
5
6
7
8
9
-A KUBE-SERVICES -d 10.96.123.219/32 -p tcp --dport 80 \
  -j KUBE-SVC-HMXYGTJUZB5GQ45C

-A KUBE-SVC-HMXYGTJUZB5GQ45C \
  -m comment --comment "service-path/source-ip:http -> 10.244.1.3:8080" \
  -j KUBE-SEP-MGYZ72N52QLOHZGY

-A KUBE-SEP-MGYZ72N52QLOHZGY -p tcp \
  -j DNAT --to-destination 10.244.1.3:8080

链名可以按职责读:

  • KUBE-SERVICES 捕获发往 ClusterIP 和 Service port 的流量;
  • KUBE-SVC-* 表示某个 Service port 的 Cluster 策略,负责选择 endpoint;
  • KUBE-SEP-* 表示一个具体 endpoint,最后执行 DNAT。

当 Service 有多个后端时,iptables proxier 会在 KUBE-SVC-* 中为前面的 endpoint 写入 statistic --mode random --probability ...,最后一个 endpoint 作为必然匹配。源码入口是 writeServiceToEndpointRules。这是按连接首次经过 NAT 表时选择后端,不是每个应用请求轮询一次;HTTP keep-alive 上的多个请求通常仍属于同一条 TCP 连接。

Hairpin:后端通过 Service 访问自己

“ClusterIP 保留源 IP”不是无条件规则。让唯一后端 10.244.1.3 通过 Service 再访问自己,结果是:

1
2
3
pod=source-ip-server-9f9b77f75-svnvx
pod_ip=10.244.1.3
self_via_service_observed=10.244.1.1:64745

后端没有看到自己的 Pod IP,而是看到了该节点 Pod 网段一侧的地址 10.244.1.1。原因是 hairpin:请求 DNAT 后,源和目标会落到同一个 Pod。如果不改写源地址,返回流量可能绕过原来的 NAT 路径,直接从 Pod 回到自己,连接两端看到的地址关系就不一致。

iptables proxier 在每个 KUBE-SEP-* 链里显式处理这一情况:当源 IP 等于 endpoint IP 时,先跳到 KUBE-MARK-MASQ,再执行 DNAT。对应代码位于 proxier.go 的 endpoint 链生成逻辑。

普通 Pod 到 ClusterIP 的流量通常保留 Pod 源 IP;hairpin、集群外来源、masqueradeAll 和具体网络实现都可能触发 SNAT。

NodePort + Cluster:为什么客户端 IP 会变成节点 IP

Service 初始配置为:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
apiVersion: v1 # Service 使用 core/v1 API。
kind: Service # 同时提供 ClusterIP 和 NodePort 入口。
metadata:
  name: source-ip
  namespace: service-path
spec:
  type: NodePort # 在节点地址上开放端口。
  externalTrafficPolicy: Cluster # 任意节点可以选择任意 Ready endpoint。
  selector:
    app.kubernetes.io/name: source-ip-server # 选择回显源地址的后端。
  ports:
    - name: http
      port: 80 # ClusterIP 端口。
      targetPort: 8080 # 后端 Pod 端口。
      nodePort: 30080 # 外部客户端访问的节点端口。

固定客户端 IP 为 172.30.0.5。分别访问有后端的 worker1 和没有后端的 worker2:

1
2
3
4
5
6
7
8
client_container=service-path-client client_ip=172.30.0.5
externalTrafficPolicy=Cluster

# 访问 worker1 172.30.0.3:30080
10.244.1.1:44473

# 访问 worker2 172.30.0.4:30080
172.30.0.4:44424

两次都没有保留 172.30.0.5。进入 worker2 的流量还需要跨节点到 worker1 上的 Pod,后端看到的源地址就是入口节点 172.30.0.4。进入 worker1 的流量虽然不跨节点,但 kube-proxy 的 Cluster 外部流量规则仍统一标记为需要 masquerade;在 kind 的网络结构中,最终选出的源地址是 Pod 网桥侧的 10.244.1.1。

规则直接写明了这个意图:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
-A KUBE-NODEPORTS -p tcp --dport 30080 \
  -j KUBE-EXT-HMXYGTJUZB5GQ45C

-A KUBE-EXT-HMXYGTJUZB5GQ45C \
  -m comment --comment "masquerade traffic for service-path/source-ip:http external destinations" \
  -j KUBE-MARK-MASQ

-A KUBE-POSTROUTING -m comment \
  --comment "kubernetes service traffic requiring SNAT" \
  -j MASQUERADE --random-fully

这次 SNAT 不是多余动作。若入口节点把外部客户端地址原样交给另一个节点上的 Pod,后端的默认路由可能直接把响应发往外部,而不再经过保存该连接 DNAT 状态的入口节点。SNAT 强制返回包先回入口节点,使 conntrack 可以完成反向 NAT。

  sequenceDiagram
  participant C as 外部客户端 172.30.0.5
  participant N2 as 入口 worker2 172.30.0.4
  participant P as 后端 Pod 10.244.1.3
  C->>N2: dst=172.30.0.4:30080
  Note over N2: DNAT 目标到 10.244.1.3:8080<br/>SNAT 源到 172.30.0.4
  N2->>P: src=172.30.0.4 dst=10.244.1.3:8080
  P-->>N2: 返回入口节点
  Note over N2: conntrack 执行反向 NAT
  N2-->>C: 看起来仍由 NodePort 返回

这张图只表达四层地址转换,省略 Docker bridge、kindnet veth 和二层转发。

NodePort + Local:保留源 IP 的代价

把策略改为 Local:

1
2
3
kubectl -n service-path patch service source-ip \
  --type merge \
  -p '{"spec":{"externalTrafficPolicy":"Local"}}'

同一个客户端再次访问两台 worker:

1
2
3
4
5
6
7
externalTrafficPolicy=Local

# 访问有本地 endpoint 的 worker1
172.30.0.5:38230

# 访问没有本地 endpoint 的 worker2
curl: (28) Connection timed out after 2001 milliseconds

worker1 上的后端准确看到客户端 172.30.0.5。worker2 虽然仍有 NodePort 入口,却不再把连接转到 worker1;请求被丢弃。

  flowchart TB
  C["外部客户端<br/>172.30.0.5"] --> N{"收到 NodePort 的节点<br/>有本地 Ready endpoint 吗"}
  N -->|"有"| D["只做 DNAT<br/>保留客户端源 IP"]
  D --> P["本地后端 Pod"]
  N -->|"没有"| X["DROP / 超时"]

运行时规则与这个分支完全一致。有本地 endpoint 的 worker1 生成了 Local 策略链:

1
2
3
4
-A KUBE-EXT-HMXYGTJUZB5GQ45C -j KUBE-SVL-HMXYGTJUZB5GQ45C
-A KUBE-SVL-HMXYGTJUZB5GQ45C \
  -m comment --comment "service-path/source-ip:http -> 10.244.1.3:8080" \
  -j KUBE-SEP-MGYZ72N52QLOHZGY

worker2 没有本地 endpoint,因此没有 KUBE-SVL-HMXYGTJUZB5GQ45C endpoint 跳转。源码在对 endpoint 分类后,把 externalTrafficPolicy: Local 的外部策略指向 local chain;如果本地 endpoint 数量为零,就把外部流量的 filter target 设为 DROP。实现位于 proxier.go 的 endpoint 分类和无本地后端处理。

这带来三个生产约束:

  1. 外部负载均衡器不能把连接继续发给没有本地 Ready endpoint 的节点;它需要理解节点健康检查,或只注册真正有后端的节点。
  2. 流量只在本地 Pod 间分配。各节点 Pod 数量或外部流量不均时,负载可能倾斜。
  3. Pod 滚动、Ready 状态变化和 LB 健康检查存在传播时间。连接排空需要同时考虑应用终止、EndpointSlice 状态、kube-proxy 规则同步和外部 LB 行为。

Local 的含义不是“优先本地,找不到再跨节点”,而是外部流量只允许使用本地 endpoint。后续生产实践文章会单独处理健康检查、终止态 endpoint 和连接排空。

从 Service 字段回到源码决策

iptables 链不是固定模板,而是 Service 字段和当前 endpoint 集合共同计算的结果。Kubernetes v1.35.0 中,BaseServicePortInfo 保存 externalPolicyLocal 和 internalPolicyLocal,并判断当前 Service 是否需要 Cluster endpoint、Local endpoint 或两者都需要。

iptables proxier 随后建立四类主要链:

前缀职责
KUBE-SVC-*Cluster 策略,从全部合格 endpoint 中选择
KUBE-SVL-*Local 策略,只从本节点 endpoint 中选择
KUBE-EXT-*NodePort、LoadBalancer IP、ExternalIP 等外部入口的特殊处理
KUBE-SEP-*单个 endpoint,处理 hairpin 标记和最终 DNAT

这些名称和职责直接定义在 pkg/proxy/iptables/proxier.go。对于 Cluster 外部策略,源码先写 KUBE-MARK-MASQ;对于 Local 外部策略,则将外部来源导向本地 endpoint 链。对应规则生成位于 proxier.go 1207—1262 行。

运行时规则与源码的对应关系如下:

1
2
3
4
5
6
Service.externalTrafficPolicy
  -> BaseServicePortInfo.externalPolicyLocal
  -> CategorizeEndpoints 区分 clusterEndpoints / localEndpoints
  -> 生成 KUBE-EXT / KUBE-SVC / KUBE-SVL / KUBE-SEP
  -> netfilter 对新连接执行 DNAT,必要时标记 SNAT
  -> conntrack 保存映射并处理返回方向

排查时怎样确认一条 Service 流量

不要一上来就翻完整的 iptables-save。先从 API 期望状态开始,再进入具体节点:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# 1. 确认入口、端口和 traffic policy。
kubectl -n service-path get service source-ip -o yaml

# 2. 确认 Ready endpoint、Pod IP 和所在节点。
kubectl -n service-path get endpointslice \
  -l kubernetes.io/service-name=source-ip \
  -o wide
kubectl -n service-path get pod -o wide

# 3. 确认集群实际使用的数据面,不要默认一定是 iptables。
kubectl -n kube-system get configmap kube-proxy \
  -o jsonpath='{.data.config\.conf}'
kubectl -n kube-system logs daemonset/kube-proxy --tail=100

# 4. 只有确认是 iptables 模式后,才在出现问题的节点抓取 NAT 规则。
iptables-save -t nat | grep -E 'KUBE-(SERVICES|NODEPORTS|EXT|SVC|SVL|SEP)'

# 5. 用后端日志或抓包核对后端真正看到的源地址。
kubectl -n service-path logs deployment/source-ip-server --tail=50

若 Service 和 EndpointSlice 正确,但流量不通,再根据“客户端在哪、入口节点是谁、endpoint 在哪、策略是 Cluster 还是 Local”选择抓包位置。只在客户端抓包可能看不到 DNAT 后的目标,只在 Pod 抓包又可能看不到转换前的 Service IP;复杂问题通常至少要对照入口节点和后端 Pod 两侧。

现在可以准确回答哪些问题

  • ClusterIP 是虚拟入口。iptables 模式通过内核规则捕获目标地址,不要求某块网卡持有该 IP。
  • kube-proxy 负责把 API 状态同步成数据面规则,通常不在每个数据包的转发路径上。
  • Service 至少需要 DNAT,把 Service IP 和 port 改成一个 endpoint 地址。
  • Pod 到 ClusterIP 通常保留 Pod 源 IP;hairpin 和集群外来源等路径可能触发 SNAT。
  • externalTrafficPolicy: Cluster 允许任意入口节点转到任意 Ready endpoint,为了保证跨节点返回路径,外部流量通常会被 SNAT。
  • externalTrafficPolicy: Local 用“不跨节点”换取源 IP 保留;没有本地 Ready endpoint 的节点会丢弃外部请求。
  • NAT 是按连接跟踪的。一次后端选择不等于每个 HTTP 请求都重新负载均衡。

以上结论针对本文实测的 kube-proxy iptables 模式。Kubernetes 官方的 Virtual IPs and Service Proxies 和 Using Source IP 给出了 API 语义;下一篇会把同一个 Service 分别放到 iptables、IPVS、nftables 与 Cilium eBPF 数据面中,比较规则结构、后端选择、连接状态和排障工具,而不是把不同实现都套进 iptables 链模型。

#Kubernetes #Service #Kube-Proxy #NAT #网络