TT Lab
博客

한국어 English 日本語 中文

开始
博客

博客

发卡式 NAT 实测 2:当 Pod 访问自己的 Service 时

状态:实测 (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 篇中路由器的路径形式相同。

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。这里会遇到两个阻碍。

  1. 网桥不会将其发回。 如果 Pod 连接到 Linux 网桥(例如 cni0),进入 P 的 veth 的报文,其新的目的地址依然是 P 的 veth。默认情况下,网桥不会将报文 从接收它的端口再发送出去。 允许这一行为的就是网桥端口的 hairpin 设置。其说明如下:决定是否将报文从接收端口再次发送出去,也称为反射中继(reflective relay),默认处于关闭状态。在 sysfs 中显示为 bridge(8) 。 /sys/class/net/<브리지>/brif/<포트>/hairpin_mode 。
  2. 源地址为自身地址的报文会受到怀疑。 在 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 的地方有两个:

发卡属于 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 内部发送,必定会返回给自己。

  1. Hairpin: 在该 Pod 内部通过 Python socket 向 Service 地址 (S:80) 发送了一次 GET /healthz 。记录了 socket 看到的自身地址和对端地址(getsockname, getpeername)、响应以及耗时。
  2. 服务器端: 随后在该 Pod 的访问日志中找到了该请求行。
  3. 连接跟踪: 在同一节点的 Cilium 代理中使用 cilium-dbg bpf ct list global 读取了该连接的条目。
  4. 对照: 在同一节点上启动一个临时 Pod --rm 并向相同的 Service 发送三次相同的请求,查看服务器日志中记录的源地址。

5. 实测结果

파드가 자기 Service 로 요청할 때 (Cilium, 실측) 엔드포인트가 자기 하나뿐인 Service 에 GET 한 번 파드 P (192.0.2.133) 앱이 보냄 S 192.0.2.96:80 으로 같은 앱이 받음 포트 8000 ① 192.0.2.133:48766 → S:80 파드 veth 의 tc eBPF (Cilium) 백엔드를 고르니 보낸 파드 자신이다 도착을 바꾼다(DNAT): S:80 → 192.0.2.133:8000 출발도 바꾼다(SNAT): → 169.254.42.1 ② 169.254.42.1:48766 → P:8000 실측으로 본 것 응답 200, 1.17ms 클라이언트 소켓의 상대 S:80 (getpeername) 서버 로그의 출발지 169.254.42.1:48766 연결 추적 표시 LBLoopback 대조: 다른 파드에서 같은 Service 로 3번 서버 로그에 그 파드의 주소가 그대로 찍혔다
图:当 Pod 向自己的 Service 发送请求时,Cilium 的 tc eBPF 将目的地改为自身、将源地址改为 169.254.42.1 的路径及实测结果
项目 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 ]

这是读取方法。

顺便一提,在同一日志中,kubelet 的健康检查请求被记录为来自该节点的 Cilium 内部地址(cilium_host)。

与第 1 部分的路由器相比,有两点是相同的。必须更改源地址才能使响应通过相同的路径返回,作为代价, 服务器无法看到真正的客户端。 Pod 发送给自己 Service 的请求在访问日志中记录为 169.254.42.1 并不是故障,而是此设计的结果。如果存在通过源地址进行过滤的功能(允许列表、请求速率限制),则必须了解此地址。

6. 局限性

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 邮局”。如果在我的日记本里经常看到那个邮局的名字,那正是我写给自己信!

登录后即可点赞

评论

还没有评论。

登录后即可发表评论