状态:实测 (2026-11-01)。 这是在 TT Lab 集群(Kubernetes 1.34.10, Cilium 1.20.1)中测得的数据。集群配置未作更改,也没有创建新的 Service。我们从现有 Service 中挑选了一个只有一个端点的 Service,并在其 Pod 内部发送了一次 GET 请求,对照组则使用临时 Pod 进行。地址已替换为文档专用示例(192.0.2.0/24),而 Cilium 的默认值 169.254.42.1 以及时间和端口均为实际测量值。编写规则:以实测数据为准,未能测量的数据标记为“未测”,原因未经确认的解释标记为“推测”。
目录
1. 从结论开始
Web 应用通过 Service 名称调用自己的 API 这种情况非常普遍。如果副本只有一个,或者有多个但负载均衡恰好选中了自己,请求就会返回给发送请求的 Pod。 返回给发送请求的 Pod。 第 1 篇中路由器的路径形式相同。
- 问题的根源是一样的。 如果仅仅是将 Service 地址替换为 Pod 地址(DNAT),就会变成源地址和目的地址都是自身的报文。Linux 从外部设备收到这种报文时会直接丢弃,而网桥也不会将其重新发送到接收它的端口上。
- 解决方法也一样:修改源地址。 kube-proxy iptables 模式会加入“如果源地址是该端点自身,则进行伪装 (Masquerade)”的规则,而基于网桥的 CNI 会在网桥端口上启用
hairpin_mode。Cilium 则在 eBPF 中将源地址修改为 169.254.42.1。 - kubelet 的
hairpinMode目前不起作用。 虽然默认值保留了下来,文档也未作修改,但自从使用该值的 kubenet 在 1.24 版本与 dockershim 一同被移除后,我们在源码中除了配置验证之外,再也找不到读取该值的地方。发卡(Hairpin)是 CNI 的工作。 - 实测: 在 Cilium 1.20.1 中,Pod 向自己的 Service 发送的 GET 请求返回了 200(1.17ms),服务器看到的源地址是 169.254.42.1,连接跟踪(Conntrack)中附带了
LBLoopback标记。其他 Pod 发送的 3 次对照请求则正常显示了原 Pod 的地址。
2. 为什么直接访问不行
Pod P (192.0.2.133) 向 Service S (192.0.2.96:80) 发起请求。S 的端点仅有 P 自身(端口 8000)一个。负责处理 Service 的组件(无论是 kube-proxy 的 iptables 规则还是 CNI 的 eBPF)如果仅修改目的地址,报文就会变成这样。
바꾸기 전 출발 192.0.2.133:48766 → 도착 192.0.2.96:80 (Service)
DNAT 뒤 출발 192.0.2.133:48766 → 도착 192.0.2.133:8000 (자기 자신)
源地址和目的地址相同的报文必须从 Pod 外部(节点侧)再次进入 Pod。这里会遇到两个阻碍。
- 网桥不会将其发回。 如果 Pod 连接到 Linux 网桥(例如
cni0),进入 P 的 veth 的报文,其新的目的地址依然是 P 的 veth。默认情况下,网桥不会将报文 从接收它的端口再发送出去。 允许这一行为的就是网桥端口的 hairpin 设置。其说明如下:决定是否将报文从接收端口再次发送出去,也称为反射中继(reflective relay),默认处于关闭状态。在 sysfs 中显示为bridge(8)。/sys/class/net/<브리지>/brif/<포트>/hairpin_mode。 - 源地址为自身地址的报文会受到怀疑。 在 Pod 内核看来,从外部设备(eth0)进入的报文,其源地址竟然是自己。Cilium 源码的注释也以此为例进行解释:除非是从回环设备(Loopback)收到的,否则 Linux 会将此类报文视为火星源(martian source)并将其丢弃(bpf_lxc.c 第 259~264 行).即使接收了,应答也会变成发往“自己”的报文并在 Pod 内部终结,从而无法经过将地址还原为 Service 地址的反向 NAT(reverse NAT)。发送方的套接字(Socket)一直在等待来自 S:80 的响应,结果却收到了来自 P:8000 的响应,这与第 1 篇中的路由器一样,导致连接无法建立。
因此解决方法也与第 1 篇相同: 将源地址更改为其他地址,以便响应能够返回给负责处理 Service 的组件并经过反向转换。
3. 谁做了什么
kube-proxy:“若是自身则进行 SNAT”规则
kube-proxy iptables 模式会为 Service 的每个端点创建一个链,并在该链的最前面加入一条 如果源地址与该端点地址相同则打上伪装标记 的规则。源码中的注释是“返回给发送者的流量通过 SNAT 处理”(proxier.go 第 1342~1346 行,v1.34.0)。即便有这条规则,如果 Pod 连接在网桥上,第 1 个问题(网桥不予发回)依然存在,因此需要同时启用网桥端口的 hairpin。
kubelet hairpinMode:虽然保留但目前不起作用
kubelet 配置中包含 hairpinMode(标志 --hairpin-mode),并且有三个可选值(KubeletConfiguration v1beta1 types.go 第 463~476 行,v1.34.0).
| 值 | 说明(源码注释) |
|---|---|
promiscuous-bridge(默认) |
将容器网桥设置为混杂模式(promiscuous)。假设存在名为 cbr0 的网桥 |
hairpin-veth |
在容器 veth 接口上启用 hairpin 标记 |
none |
什么都不做 |
过去真正使用该值的组件是包含在 kubelet 中的网络插件 kubenet 。查看 1.23 源码可知,当 kubenet 为 hairpin-veth 时,会在 bridge CNI 配置中加入 hairpinMode: true ,否则将网桥设为混杂模式(kubenet_linux.go, v1.23.0)。kubenet 已在 1.24 中与 dockershim 一同被移除。在当前的源码中,除了标志定义(cmd/kubelet/app/options/options.go)、默认值和配置验证之外,我们再也找不到读取该值的地方(通过 GitHub 代码搜索默认分支,并确认 1.34.0 的 kubelet.go·cmd/kubelet/app/server.go 中不包含 hairpin 一词,2026-11-01)。目前发卡(Hairpin)由 CNI 负责。
我们在测量集群中也进行了确认。节点的 kubelet 配置(/configz) 保持默认值 promiscuous-bridge 不变,但节点上并没有用于 Pod 的网桥(只有一个未使用的
docker0 ),14 个 Pod 的 veth 接口也没有连接到任何网桥上。自 7 月份以来保留的 kubelet 日志中,甚至连一行 hairpin 字样都没有。
Kubernetes 官方文档的 Debug Services 章节中的“A Pod fails to reach itself via the Service IP”
小节仍然引导用户检查 kubelet 的 hairpin-mode 标志、“Hairpin mode set to”日志行以及 cbr0 网桥。结合上述源码来看,这一指导已经不适用于 1.24 之后的集群。如果发卡功能失效,我们应该检查 CNI 配置,而不是 kubelet。
不同 CNI 的实现
| CNI | Pod 挂载方式 | 发卡处理(确认依据) |
|---|---|---|
| bridge 插件 | Linux 网桥 | 配置项 hairpinMode(布尔值,默认 false)启用连接到网桥的接口的 hairpin(CNI bridge 文档). Service 的 SNAT 由 kube-proxy 负责 |
| Flannel | 委托给上述 bridge 插件 | 默认清单中的 CNI 配置在 delegate 中加入了 "hairpinMode": true (kube-flannel.yml) |
| Cilium | 无网桥,veth + eBPF | 在进行 Service 转换时,如果后端是发送请求的 Pod 自身,eBPF 会将源地址变更为 169.254.42.1 (见下文)。如果替代了 kube-proxy,SNAT 规则也由 Cilium 负责 |
其他 CNI(如 Calico 等)由于本次未查阅其源码,故不在此列出。
Cilium:将源地址变更为 169.254.42.1
Cilium 处理 Service 的地方有两个:
- socket LB: 当应用调用
connect()的瞬间,eBPF 会将 Service 地址替换为后端地址。由于套接字从一开始就连接到后端,因此不需要按报文进行 NAT(kubeproxy-free 文档“Socket LoadBalancer Bypass in Pod Namespace”). - tc LB: Pod veth 的 tc eBPF 会对每个报文进行 Service 查找并替换。根据同一文档,如果是
socketLB.hostNamespaceOnly=true(代理标志bpf-lb-sock-hostns-only),则在 Pod 内部会跳过 socket LB 并使用 tc LB。这是为了适应类似 Istio Sidecar 这样需要在 Pod 内部查看原始 ClusterIP 的场景,或者是针对 KubeVirt 这种 socket LB 无法监听的 Pod 的设置。
发卡属于 tc LB 侧的内容。 bpf_lxc.c 会在选定后端后,检查 如果后端地址与报文的源地址相同 ,则为该连接打上 loopback 标记(bpf_lxc.c 第 249~266 行)。随后, lb4_dnat_request 在将目的地址修改为后端的同时,将源地址修改为配置值 service_loopback_ipv4 (lb.h 第 2355~2376 行).其默认值为代理标志 --ipv4-service-loopback-address 的 169.254.42.1 ,其解释为“当 Pod 通过 Service 与自身通信时用于 SNAT 的 IPv4 源地址”(cilium-agent 命令参考)。IPv6 则是 fe80::1 。
还有一个细节。根据同一文件的注释,端点经过 Service 访问自身的流量会 跳过网络策略(Network Policy)检查。 这是因为如果不这样做,用户就必须另外编写“允许 Pod 与自身通信”的策略(bpf_lxc.c 第 1501~1508 行).
如果是在 Pod 内部也使用 socket LB 的设置, connect() 将直接连接到其自己的地址,因此上述 hairpin 路径本身将不会生成(推测。由于测量的集群在 Pod 内部没有使用 socket LB,因此未能测量)。
4. 测量环境与方法
| 项目 | 值 |
|---|---|
| Kubernetes | 1.34.10, 无 kube-proxy |
| Cilium | 1.20.1. kube-proxy-replacement=true, 隧道(VXLAN), bpf-lb-sock-hostns-only=true → 代理状态 “Socket LB Coverage: Hostns-only” |
| 回环地址 | 在端点的 eBPF 配置中 ServiceLoopbackIPv4 = 169.254.42.1 (默认值) |
| 目标 | 现有的一个 Web 服务。Service 端口 80 → 容器端口 8000,一个端点,网络策略强制关闭。在访问日志中记录客户端地址 |
我们说过,创建新的 Service 不算集群写入。我们选择了一个只有单个端点的现有 Service。发送到该 Service 的请求由于其后端只有该 Pod 本身,因此如果在该 Pod 内部发送,必定会返回给自己。
- Hairpin: 在该 Pod 内部通过 Python socket 向 Service 地址 (S:80) 发送了一次
GET /healthz。记录了 socket 看到的自身地址和对端地址(getsockname,getpeername)、响应以及耗时。 - 服务器端: 随后在该 Pod 的访问日志中找到了该请求行。
- 连接跟踪: 在同一节点的 Cilium 代理中使用
cilium-dbg bpf ct list global读取了该连接的条目。 - 对照: 在同一节点上启动一个临时 Pod
--rm并向相同的 Service 发送三次相同的请求,查看服务器日志中记录的源地址。
5. 实测结果
| 项目 | Hairpin (自身 → 自身的 Service) | 对照 (其他 Pod → 相同的 Service) |
|---|---|---|
| 响应 | HTTP 200, 1.17ms (1次) | HTTP 200, 2.64·2.65·3.21ms (3次) |
| 客户端 socket 看到的对端 | S:80 | S:80 |
| 服务器日志中的源地址 | 169.254.42.1:48766 | 该 Pod 的地址:51502 等 (保持不变) |
| 连接跟踪显示 | LBLoopback |
不适用 |
服务器日志的一行和连接跟踪条目如下(仅更改了地址作为示例)。
INFO: 169.254.42.1:48766 - "GET /healthz HTTP/1.1" 200 OK
TCP SVC 192.0.2.133:48766 -> 192.0.2.96:80 ... RevNAT=177 BackendID=216
TCP OUT 169.254.42.1:48766 -> 192.0.2.133:8000 ... Flags=0x001b [ RxClosing TxClosing LBLoopback SeenNonSyn ] RevNAT=177
TCP IN 169.254.42.1:48766 -> 192.0.2.133:8000 ... Flags=0x001b [ RxClosing TxClosing LBLoopback SeenNonSyn ]
这是读取方法。
- Socket 知道自己连接到了 Service。 客户端 socket 的对端是 S:80。由于在 Pod 内部不使用 socket LB (Hostns-only),这意味着地址转换不是在 socket 处发生的,而是在 veth 的数据包阶段发生的。如果是 socket LB,对端看起来应该是后端地址。
- 服务器将其视为来自 169.254.42.1 的请求。 正如第 3 节的源码所示,源地址已被更改为回环地址。端口 (48766) 保持不变。
- 连接跟踪中生成了三行。 这是通往 Service 的原始连接 (SVC),以及从回环地址到 Pod 的双向连接 (OUT·IN),后两者附带了
LBLoopback。响应将根据此条目被导回 Service 地址。 - 对照请求的源地址保持不变。 来自其他 Pod 的请求在没有 SNAT 的情况下被记录为该 Pod 的地址。只有在 hairpin 情况下才会更改源地址。
顺便一提,在同一日志中,kubelet 的健康检查请求被记录为来自该节点的 Cilium 内部地址(cilium_host)。
与第 1 部分的路由器相比,有两点是相同的。必须更改源地址才能使响应通过相同的路径返回,作为代价, 服务器无法看到真正的客户端。 Pod 发送给自己 Service 的请求在访问日志中记录为 169.254.42.1 并不是故障,而是此设计的结果。如果存在通过源地址进行过滤的功能(允许列表、请求速率限制),则必须了解此地址。
6. 局限性
- 由于 hairpin 请求是在运行中的 Pod 内部发送的,因此只发送了 一次。时间 1.17ms 仅为单次测量值,切勿将与对照组(3次)的差异解读为性能差异。
- 由于未更改配置,因此未测量在没有回环 SNAT 时实际如何失败(未测量)。第 2 节中的故障说明来自源码注释和 Linux 行为。
- kube-proxy iptables 模式与网桥 CNI 的组合,以及关闭和开启网桥端口的
hairpin_mode的对比由于测量的集群中没有网桥而未能测量(未测量)。 - 未测量在 Pod 内部使用 socket LB 的情况(第 3 节的推测)。
- 目标 Pod 关闭了网络策略强制,因此源码中关于 hairpin 跳过策略检查的说明未能通过实测证实(未测量)。
7. 总结
| 确认 | 原因 |
|---|---|
| 如果说“无法访问自己的 Service”,那么看的就不是 kubelet 而是 CNI | 1.24 之后的 kubelet 的 hairpinMode 没有读取的地方 |
如果是基于网桥的 CNI, hairpinMode: true |
网桥默认不会将数据包发回其接收的端口 |
如果有 kube-proxy,端点链的 -s <자기 주소> -j KUBE-MARK-MASQ |
更改源地址的就是这条规则 |
| 如果是 Cilium,访问日志中的 169.254.42.1 是正常的 | 这是发往自己 Service 的请求的源地址 |
| 通过源地址过滤的功能 | Hairpin 请求会丢失真正的源地址(路由器也是如此) |
TT 的一句话。 我的房门上贴着“TT 的收件箱”,如果我把信放进去,它又会回到我的房间里。但是如果发件人栏里依然写着我的名字,就会被当成“奇怪的信”而丢弃。所以邮递员 Cilium 大叔把发件人改成了“169.254.42.1 邮局”。如果在我的日记本里经常看到那个邮局的名字,那正是我写给自己信!