Cilium 案例分析:微小代码变更如何显著提升网络稳定性
引言
前不久,我偶然看到了前同事对 Cilium 项目的一个 PR。
bpf:nat: Restore ORG NAT entry if it's not found
(不包括测试代码)修改量本身很少,仅增加了单个 if 语句块。然而,这项修改所带来的影响巨大,一个简单的想法能够为系统稳定性做出如此巨大的贡献,这让我个人感到非常有趣,因此我将尝试讲述这个案例,以便没有网络领域专业知识的人也能轻松理解。
背景知识
如果说有什么现代人的必需品可以与智能手机相提并论,那可能就是 Wi-Fi 路由器了。Wi-Fi 路由器通过 Wi-Fi 通信协议与设备进行通信,并将其拥有的公共 IP 地址共享给多个设备使用。这里产生的一个技术特点是它如何进行“共享”?
这里使用的技术就是 Network Address Translation (NAT)。NAT 是一种技术,它利用 TCP 或 UDP 通信由 IP 地址和端口信息的组合构成这一特点,将由 私有 IP:端口 组成的内部通信映射到当前未使用的 公共 IP:端口,从而实现与外部的通信。
NAT
当内部设备尝试访问外部互联网时,NAT 设备会将其私有 IP 地址和端口号组合转换为自己的公共 IP 地址和未使用的任意端口号。此转换信息记录在 NAT 设备内部的 NAT 表中。
例如,假设家中的智能手机(私有 IP: a.a.a.a,端口: 50000)尝试连接到 Web 服务器(公共 IP: c.c.c.c,端口: 80)。
1智能手机 (a.a.a.a:50000) ==> 路由器 (b.b.b.b) ==> Web 服务器 (c.c.c.c:80)
路由器收到智能手机的请求后,会看到如下 TCP Packet:
1# 路由器收到的 TCP 数据包,智能手机 => 路由器
2| src ip | src port | dst ip | dst port |
3-------------------------------------------
4| a.a.a.a | 50000 | c.c.c.c | 80 |
如果直接将此数据包发送到 Web 服务器 (c.c.c.c),则拥有私有 IP 地址的智能手机 (a.a.a.a) 将无法收到响应,因此路由器首先会找到一个当前未参与通信的任意端口(例如:60000),并将其记录在内部 NAT 表中。
1# 路由器内部 NAT 表
2| local ip | local port | global ip | global port |
3-----------------------------------------------------
4| a.a.a.a | 50000 | b.b.b.b | 60000 |
路由器在 NAT 表中记录新条目后,将智能手机发送的 TCP 数据包的源 IP 地址和端口号更改为自己的公共 IP 地址 (b.b.b.b) 和新分配的端口号 (60000),然后将其发送到 Web 服务器。
1# 路由器发送的 TCP 数据包,路由器 => Web 服务器
2# 执行 SNAT
3| src ip | src port | dst ip | dst port |
4-------------------------------------------
5| b.b.b.b | 60000 | c.c.c.c | 80 |
现在,Web 服务器 (c.c.c.c) 将其识别为来自路由器 (b.b.b.b) 端口 60000 的请求,并将响应数据包发送到路由器,如下所示:
1# 路由器收到的 TCP 数据包,Web 服务器 => 路由器
2| src ip | src port | dst ip | dst port |
3-------------------------------------------
4| c.c.c.c | 80 | b.b.b.b | 60000 |
路由器收到此响应数据包后,会在 NAT 表中查找与目标 IP 地址 (b.b.b.b) 和端口号 (60000) 对应的原始私有 IP 地址 (a.a.a.a) 和端口号 (50000),然后将数据包的目标更改为智能手机。
1# 路由器发送的 TCP 数据包,路由器 => 智能手机
2# 执行 DNAT
3| src ip | src port | dst ip | dst port |
4-------------------------------------------
5| c.c.c.c | 80 | a.a.a.a | 50000 |
通过这个过程,智能手机感觉就像它自己拥有公共 IP 地址并直接与 Web 服务器通信一样。多亏了 NAT,一个公共 IP 地址可以允许多个内部设备同时使用互联网。
Kubernetes
Kubernetes 是近年来出现的技术中网络结构最精巧也最复杂的之一。当然,前面提到的 NAT 也在各种地方得到了应用。典型的案例有以下两种:
Pod 内部与集群外部通信时
Kubernetes 集群内部的 Pod 通常被分配私有 IP 地址,这些地址只能在集群网络内部进行通信。因此,如果 Pod 要与外部互联网通信,则需要对出站流量进行 NAT。此时,NAT 主要在运行该 Pod 的 Kubernetes 节点(集群中的每个服务器)上执行。当 Pod 发送出站数据包时,该数据包首先被发送到 Pod 所属的节点。节点会将该数据包的源 IP 地址(Pod 的私有 IP)更改为其公共 IP 地址,并适当地更改源端口,然后将其转发到外部。这个过程与前面 Wi-Fi 路由器中描述的 NAT 过程类似。
例如,假设 Kubernetes 集群中的一个 Pod (10.0.1.10,端口: 40000) 尝试连接到外部 API 服务器 (203.0.113.45,端口: 443),则 Kubernetes 节点将从 Pod 收到如下数据包:
1# 节点收到的 TCP 数据包,Pod => 节点
2| src ip | src port | dst ip | dst port |
3---------------------------------------------------
4| 10.0.1.10 | 40000 | 203.0.113.45 | 443 |
节点将记录以下内容:
1# 节点内部 NAT 表(示例)
2| local ip | local port | global ip | global port |
3---------------------------------------------------------
4| 10.0.1.10 | 40000 | 192.168.1.5 | 50000 |
然后执行 SNAT,并将数据包发送到外部,如下所示:
1# 节点发送的 TCP 数据包,节点 => API 服务器
2# 执行 SNAT
3| src ip | src port | dst ip | dst port |
4-----------------------------------------------------
5| 192.168.1.5 | 50000 | 203.0.113.45 | 443 |
此后的过程与之前的智能手机路由器案例相同。
从集群外部通过 NodePort 与 Pod 通信时
在 Kubernetes 中,将服务暴露给外部的一种方法是使用 NodePort 服务。NodePort 服务会打开集群中所有节点的特定端口(NodePort),并将进入此端口的流量转发到属于该服务的 Pod。外部用户可以通过集群节点的 IP 地址和 NodePort 访问服务。
此时,NAT 起着重要作用,特别是 DNAT (Destination NAT) 和 SNAT (Source NAT) 同时发生。当外部流量通过特定节点的 NodePort 进入时,Kubernetes 网络必须将此流量最终转发到提供该服务的 Pod。在此过程中,首先发生 DNAT,数据包的目标 IP 地址和端口号被更改为 Pod 的 IP 地址和端口号。
例如,假设集群外部的用户 (203.0.113.10,端口: 30000) 通过 Kubernetes 集群中一个节点 (192.168.1.5) 的 NodePort (30001) 访问服务。假设此服务内部指向 IP 地址为 10.0.2.15、端口为 8080 的 Pod。
1外部用户 (203.0.113.10:30000) ==> Kubernetes 节点 (外部:192.168.1.5:30001 / 内部: 10.0.1.1:42132) ==> Kubernetes Pod (10.0.2.15:8080)
这里,Kubernetes 节点同时拥有外部可访问的节点 IP 地址 192.168.1.5 和 Kubernetes 内部网络中有效的 IP 地址 10.0.1.1。(根据所使用的 CNI 类型,相关策略会有所不同,但本文以 Cilium 为例进行说明。)
当外部用户的请求到达节点时,节点必须将此请求转发到处理它的 Pod。此时,节点会应用如下 DNAT 规则来更改数据包的目标 IP 地址和端口号:
1# 节点准备发送给 Pod 的 TCP 数据包
2# 应用 DNAT 后
3| src ip | src port | dst ip | dst port |
4---------------------------------------------------
5| 203.0.113.10 | 30000 | 10.0.2.15 | 8080 |
这里重要的是,当 Pod 发送此请求的响应时,源 IP 地址是它自己的 IP 地址 (10.0.2.15),目标 IP 地址是发送请求的外部用户的 IP 地址 (203.0.113.10)。在这种情况下,外部用户将收到一个从未请求过的、不存在的 IP 地址的响应,并会直接丢弃该数据包。因此,当 Pod 向外部发送响应数据包时,Kubernetes 节点会额外执行 SNAT,将数据包的源 IP 地址更改为节点的 IP 地址 (192.168.1.5 或内部网络 IP 10.0.1.1,此处使用 10.0.1.1)。
1# 节点准备发送给 Pod 的 TCP 数据包
2# 应用 DNAT, SNAT 后
3| src ip | src port | dst ip | dst port |
4---------------------------------------------------
5| 10.0.1.1 | 40021 | 10.0.2.15 | 8080 |
现在,收到该数据包的 Pod 将响应发送给最初通过 NodePort 接收请求的节点,节点会反向应用相同的 DNAT 和 SNAT 过程,并将信息返回给外部用户。在此过程中,每个节点将存储以下信息:
1# 节点内部 DNAT 表
2| original ip | original port | destination ip | destination port |
3------------------------------------------------------------------------
4| 192.168.1.5 | 30001 | 10.0.2.15 | 8080 |
5
6# 节点内部 SNAT 表
7| original ip | original port | destination ip | destination port |
8------------------------------------------------------------------------
9| 203.0.113.10 | 30000 | 10.0.1.1 | 42132 |
正文
通常,在 Linux 中,这些 NAT 过程通过 iptables 在 conntrack 子系统管理下运行。实际上,flannel 或 calico 等其他 CNI 项目就是利用这一点来处理上述问题。然而,问题在于 Cilium 使用 eBPF 技术,完全绕过了传统的 Linux 网络堆栈。🤣

因此,Cilium 选择了直接实现上图中 Linux 网络堆栈在 Kubernetes 场景下所需的功能。这就是为什么 Cilium 直接以 LRU Hash Map (BPF_MAP_TYPE_LRU_HASH) 的形式管理 SNAT 过程中的 SNAT 表。
1# Cilium SNAT 表
2# !为便于解释的示例。实际定义在:https://github.com/cilium/cilium/blob/v1.18.0-pre.1/bpf/lib/nat.h#L149-L166
3| src ip | src port | dst ip | dst port | protocol, conntrack 等其他元数据
4----------------------------------------------
5| | | | |
由于它是 Hash Table,为了快速查询,存在键值,它使用 src ip、src port、dst ip、dst port 组合作为键值。
问题识别
现象 - 1:查询
因此产生了一个问题:eBPF 经过的数据包需要检查是否需要进行 SNAT 或 DNAT 过程,为此需要查询上述 Hash Table,但如前所述,SNAT 过程存在两种类型的数据包。1. 从内部发往外部的数据包,以及 2. 作为响应从外部发往内部的数据包。这两种数据包都需要进行 NAT 转换,并且其 src ip、port 和 dst ip、port 值会互换。
因此,为了快速查询,要么将 src 和 dst 互换的值作为键再向 Hash Table 中添加一个值,要么即使与 SNAT 无关的数据包,也需要对所有数据包进行两次相同的 Hash Table 查询。显然,为了更好的性能,Cilium 采用了以 RevSNAT 为名 的方式,将相同的数据插入两次。
现象 - 2:LRU
除了上述问题之外,所有硬件都不可能拥有无限的资源,尤其是在需要快速性能的硬件逻辑中,由于无法使用动态数据结构,因此在资源不足时需要驱逐现有数据。Cilium 通过使用 Linux 提供的基本数据结构 LRU Hash Map 来解决这个问题。
现象 1 + 现象 2 = 连接丢失
https://github.com/cilium/cilium/issues/31643
也就是说,对于一个 SNAT 的 TCP(或 UDP)连接:
- 在一个 Hash Table 中,出站数据包和入站数据包的相同数据被记录了两次。
- 根据 LRU 逻辑,任何时候其中一个数据都可能被驱逐。
如果出站或入站数据包的 NAT 信息(以下简称 entry)中任何一个被 LRU 删除,则无法正常执行 NAT,从而导致整个连接丢失。
解决方案
这里就引出了前面提到的 PRs:
bpf:nat: restore a NAT entry if its REV NAT is not found
bpf:nat: Restore ORG NAT entry if it's not found
以前,当数据包经过 eBPF 时,它会尝试在 SNAT 表中通过 src ip、src port、dst ip、dst port 组合的键进行查询。如果键不存在,则根据 SNAT 规则创建新的 NAT 信息并将其记录在表中。如果是新的连接,这将导致正常的通信,但如果键由于 LRU 而被意外删除,则会使用与现有通信不同的端口执行新的 NAT,接收方将拒绝接收数据包,连接将以 RST 数据包终止。
这里,上述 PR 所采用的方法很简单:
无论哪个方向的数据包被观察到一次,都会更新其反向的 entry。
无论哪个方向的通信被观察到,两个 entry 都会被更新,从而使它们远离 LRU 逻辑的驱逐优先级目标,这可以降低只有一个 entry 被删除导致整个通信中断的可能性。
这看似是一种非常简单的方法和想法,但通过这种方法,有效解决了响应数据包的 NAT 信息过早过期导致连接中断的问题,并大大提高了系统的稳定性。此外,它在网络稳定性方面取得了以下重要改进:

结论
我认为这个 PR 是一个非常好的例子,它展示了从 NAT 如何运作的基础 CS 知识开始,一个简单的想法如何在复杂的系统内部带来巨大的改变。
当然,在这篇文章中我并没有直接展示复杂系统的案例。但是,为了正确理解这个 PR,我向 DeepSeek V3 0324 苦苦哀求了将近 3 个小时,甚至加上了 Please 这个词,结果是我获得了 Cilium 知识 +1,以及下面这张图。😇
在阅读这些问题和 PR 的过程中,我产生了一种不祥的预感,觉得某些问题可能是我以前做的一些事情造成的,因此我写了这篇文章,作为一种补偿心理。
后记 - 1
顺便说一句,这个问题有一个非常有效的规避方法。由于问题发生的根本原因是 NAT 表空间不足,因此只需增加 NAT 表的大小即可。:-D
当有人遇到同样的问题时,他们可能没有留下任何问题记录,而是扩大了 NAT 表并逃之夭夭,而 gyutaeb 先生却对一个与自己没有直接关系的问题进行了彻底的分析和理解,并提供了客观的证据数据,为 Cilium 生态系统做出了贡献,我对此感到非常钦佩和尊敬。
这是我决定写这篇文章的契机。
后记 - 2
这个故事与 Go 语言专业人士 Gosuda 确实没有直接关系。但是,Go 语言和云生态系统密切相关,而且 Cilium 的贡献者在 Go 语言方面都有一定的造诣,因此我将原本可以发布在个人博客上的内容带到了 Gosuda。
由于得到了其中一位管理员(我自己)的许可,我想这大概没问题。
如果您认为有问题,请尽快将其保存为 PDF,因为它随时可能被删除。;)
后记 - 3
这篇文章的撰写得到了 Cline 和 Llama 4 Maveric 的大力帮助。尽管我最初使用 Gemini 进行分析,并向 DeepSeek 苦苦哀求,但实际的帮助却来自 Llama 4。Llama 4 很好用。一定要试试看。