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 的配置和启动日志分别给出了两份独立证据:
| |
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 连接可以用五元组标识:
| |
假设客户端 10.244.2.2:44834 访问 Service 10.96.123.219:80,最终后端是 10.244.1.3:8080。数据面至少要做一次目标地址转换:
| |
DNAT 修改目标地址。只做 DNAT 时,后端仍能看到客户端 Pod IP。
SNAT 修改源地址。例如外部客户端 172.30.0.5 通过另一个节点的 NodePort 访问后端,实验中进入后端的连接变成:
| |
这里的 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-worker | 172.30.0.3 | 运行唯一后端 Pod |
svc-iptables-worker2 | 172.30.0.4 | 没有本地后端,用于观察跨节点转发 |
service-path-client | 172.30.0.5 | kind 网络中的集群外客户端 |
| 后端 Pod | 10.244.1.3:8080 | 返回 TCP 对端地址 |
| Service | 10.96.123.219:80 | ClusterIP 入口 |
| NodePort | 30080 | 两个 worker 上的外部入口 |
节点使用 kindnet,Pod CIDR 为 10.244.0.0/16,kube-proxy 明确运行在 iptables 模式。文中的具体地址属于这个容器化实验环境,生产集群的接口名和地址会不同;DNAT、SNAT、Local 策略和 conntrack 的判断不依赖这些具体数值。
后端使用 Python 标准库直接读取 TCP 对端地址,不读取 X-Forwarded-For:
| |
因此下面的输出表示后端 socket 真正看到的源地址,不是七层代理补写的 HTTP Header。
ClusterIP:通常只做 DNAT,保留 Pod 源 IP
先从两个 Pod 访问同一个 ClusterIP。一个客户端与后端同节点,另一个客户端在另一个 worker:
| |
无论是否跨节点,后端看到的源 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:
| |
链名可以按职责读:
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 再访问自己,结果是:
| |
后端没有看到自己的 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 初始配置为:
| |
固定客户端 IP 为 172.30.0.5。分别访问有后端的 worker1 和没有后端的 worker2:
| |
两次都没有保留 172.30.0.5。进入 worker2 的流量还需要跨节点到 worker1 上的 Pod,后端看到的源地址就是入口节点 172.30.0.4。进入 worker1 的流量虽然不跨节点,但 kube-proxy 的 Cluster 外部流量规则仍统一标记为需要 masquerade;在 kind 的网络结构中,最终选出的源地址是 Pod 网桥侧的 10.244.1.1。
规则直接写明了这个意图:
| |
这次 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:
| |
同一个客户端再次访问两台 worker:
| |
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 策略链:
| |
worker2 没有本地 endpoint,因此没有 KUBE-SVL-HMXYGTJUZB5GQ45C endpoint 跳转。源码在对 endpoint 分类后,把 externalTrafficPolicy: Local 的外部策略指向 local chain;如果本地 endpoint 数量为零,就把外部流量的 filter target 设为 DROP。实现位于 proxier.go 的 endpoint 分类和无本地后端处理。
这带来三个生产约束:
- 外部负载均衡器不能把连接继续发给没有本地 Ready endpoint 的节点;它需要理解节点健康检查,或只注册真正有后端的节点。
- 流量只在本地 Pod 间分配。各节点 Pod 数量或外部流量不均时,负载可能倾斜。
- 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 行。
运行时规则与源码的对应关系如下:
| |
排查时怎样确认一条 Service 流量
不要一上来就翻完整的 iptables-save。先从 API 期望状态开始,再进入具体节点:
| |
若 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 链模型。