
访问一个 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 的方式不同，但本文使用的地址、连接和返回路径模型仍然适用。

<!--more-->

## kube-proxy 通常不转发数据包

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

```text
$ 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`](https://github.com/kubernetes/kubernetes/blob/v1.35.0/cmd/kube-proxy/app/server_linux.go#L127-L294) 根据 `config.Mode` 创建 iptables、IPVS 或 nftables proxier。选中 iptables 后，kube-proxy 持续把 Service 和 EndpointSlice 同步成规则；连接建立后，不需要为每个包再次调用 kube-proxy。

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

```mermaid
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 连接可以用五元组标识：

```text
源 IP、源端口、目标 IP、目标端口、协议
```

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

```text
转换前：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 访问后端，实验中进入后端的连接变成：

```text
原始连接：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`](https://github.com/jimyag/k8sdev/tree/6da31f8221e048b204208c2d37b060fa3e8c0630/service-traffic-path)，命令与该提交匹配：

| 对象 | 地址 | 作用 |
| --- | --- | --- |
| `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`：

```python
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：

```text
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。

跨节点路径可以简化为：

```mermaid
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：

```text
-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`](https://github.com/kubernetes/kubernetes/blob/v1.35.0/pkg/proxy/iptables/proxier.go#L1541-L1584)。这是按连接首次经过 NAT 表时选择后端，不是每个应用请求轮询一次；HTTP keep-alive 上的多个请求通常仍属于同一条 TCP 连接。

## Hairpin：后端通过 Service 访问自己

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

```text
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 链生成逻辑](https://github.com/kubernetes/kubernetes/blob/v1.35.0/pkg/proxy/iptables/proxier.go#L1326-L1353)。

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

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

Service 初始配置为：

```yaml
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：

```text
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`。

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

```text
-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。

```mermaid
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：

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

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

```text
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；请求被丢弃。

```mermaid
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 策略链：

```text
-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 分类和无本地后端处理](https://github.com/kubernetes/kubernetes/blob/v1.35.0/pkg/proxy/iptables/proxier.go#L933-L1026)。

这带来三个生产约束：

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`](https://github.com/kubernetes/kubernetes/blob/v1.35.0/pkg/proxy/serviceport.go#L147-L194) 保存 `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`](https://github.com/kubernetes/kubernetes/blob/v1.35.0/pkg/proxy/iptables/proxier.go#L640-L690)。对于 Cluster 外部策略，源码先写 `KUBE-MARK-MASQ`；对于 Local 外部策略，则将外部来源导向本地 endpoint 链。对应规则生成位于 [`proxier.go` 1207—1262 行](https://github.com/kubernetes/kubernetes/blob/v1.35.0/pkg/proxy/iptables/proxier.go#L1207-L1262)。

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

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

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

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

```sh
# 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](https://v1-35.docs.kubernetes.io/docs/reference/networking/virtual-ips/) 和 [Using Source IP](https://kubernetes.io/docs/tutorials/services/source-ip/) 给出了 API 语义；下一篇会把同一个 Service 分别放到 iptables、IPVS、nftables 与 Cilium eBPF 数据面中，比较规则结构、后端选择、连接状态和排障工具，而不是把不同实现都套进 iptables 链模型。

