GoSuda

Cilium 的故事:小小代码变更带来的惊人网络稳定性提升

By iwanhae views ...

引言

不久前,我浏览了一位前同事为 Cilium 项目提交的 PR。

bpf:nat: Restore ORG NAT entry if it's not found

(不包括测试代码)实际的修改量很小,只是增加了一个 if 语句块。然而,这一修改所带来的影响十分巨大,一个简单的想法竟能对系统稳定性做出如此巨大的贡献,这一事实让我个人觉得十分有趣。因此,我想尝试以通俗的方式讲述这个案例,使不具备网络领域专业知识的人也能轻松理解。

背景知识

如果说智能手机之外,现代人还有哪些必不可少的必需品,那么大概就是无线路由器了。无线路由器通过 Wi-Fi 这一通信标准与设备进行通信,并承担着将其所拥有的公网 IP 地址共享给多个设备使用的职责。由此产生的一个技术性问题是:它是如何进行“共享”的?

这里所使用的技术正是网络地址转换(Network Address Translation,NAT)。NAT 是一种技术,它利用 TCP 或 UDP 通信由 IP 地址与端口信息的组合构成这一特点,将以 私有IP:Port 形式进行的内部通信映射到当前未被使用的 公网IP:Port 上,从而使内部设备能够与外部进行通信。

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 数据包。

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 地址为私有 IP 地址,Web 服务器的响应将无法返回至智能手机(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 地址,并适当修改源端口,然后将其转发至外部。这一过程与前面在无线路由器中所述的 NAT 过程相似。

例如,假设集群中的一个 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(目标 NAT,Destination NAT) 与 SNAT(源 NAT,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 地址:一个是可从外部访问的节点 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 地址的响应,因而会直接丢弃该数据包。因此,Kubernetes 节点在 Pod 向外部发送响应数据包时,会额外执行 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 网络协议栈。🤣

https://cilium.io/blog/2021/05/11/cni-benchmark/

因此,Cilium 在上图所示的、原本由 Linux 网络协议栈承担的职能中,选择了仅直接实现 Kubernetes 环境所需功能的路线。基于此,对于前面提到的 SNAT 过程,Cilium 以 LRU Hash Map(BPF_MAP_TYPE_LRU_HASH)的形式直接管理其 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|            |          |         |          |

此外,由于其为哈希表,为了实现快速查找,系统使用 src ip、src port、dst ip、dst port 的组合作为键值。

问题识别

现象 1:查找

由此产生了一个问题:经过 eBPF 的数据包在进行 NAT 处理时,需要查询上述哈希表,以判断该数据包是否需要执行 SNAT 或 DNAT。然而,正如前面所见,SNAT 过程中存在两类数据包:1. 从内部流向外部的数据包;2. 作为其响应,从外部流向内部的数据包。这两类数据包都需要进行 NAT 转换,但其源 IP、端口与目标 IP、端口的值恰好相反。

因此,为了快速查找,要么在哈希表中额外添加一个以源与目标互换后的值为键的条目,要么对所有数据包(包括与 SNAT 无关的数据包)都对同一哈希表进行两次查找。当然,为了获得更好的性能,Cilium 采用了名为 RevSNAT 的方案,即将相同的数据写入两次。

现象 2:LRU

此外,与上述问题无关的是,任何硬件都不可能拥有无限的资源;特别是在对性能要求极高的硬件级逻辑中,又无法使用动态数据结构,因此在资源不足时,有必要驱逐已有数据。Cilium 通过使用 Linux 默认提供的基础数据结构 LRU Hash Map 解决了这一问题。

现象 1 + 现象 2 = 连接丢失

https://github.com/cilium/cilium/issues/31643

也就是说,对于一个经过 SNAT 的 TCP(或 UDP)连接:

  1. 在同一个哈希表中,外发数据包和入站数据包各自对应的相同数据被记录了两次;并且
  2. 根据 LRU 逻辑,两者中的任何一条数据都可能随时丢失。

在这种情况下,只要外发或入站数据包对应的 NAT 信息(以下简称 entry)中有任何一条因 LRU 而被移除,便无法正常执行 NAT,进而可能导致整个连接丢失。

解决方案

此时,前面提到的以下 PR 登场了。

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 时,会以 src ip、src port、dst ip、dst port 的组合构造键,并在 SNAT 表中进行查找。如果键不存在,则根据 SNAT 规则生成新的 NAT 信息并将其写入表中。若为新连接,这属于正常的通信过程;但如果键是因 LRU 而被意外移除的,则数据包将使用与原有通信不同的端口重新执行 NAT,接收端会拒绝接收该数据包,并随 RST 数据包一同终止连接。

在这里,上述 PR 所采用的方法十分简单:

只要任一方向的数据包被观测到,就顺便刷新其反方向的 entry。

只要观测到任一方向的通信,双向 entry 都会被重新刷新,从而脱离 LRU 逻辑的驱逐优先级范围。由此,可以降低仅有一侧 entry 被删除、进而导致整个通信崩溃的可能性。

这种方法非常简单,看似只是一个朴素的想法,但正是通过这一方式,有效解决了响应数据包的 NAT 信息先过期而导致连接中断的问题,并显著提升了系统的稳定性。此外,就网络稳定性而言,这也是一项取得了如下成果的重要改进。

benchmark

结论

我认为,这个 PR 是一个非常好的案例:它从 NAT 如何运作这一基础计算机科学知识出发,展示了即使在复杂系统内部,一个简单的想法也能带来多么巨大的变化。

啊,当然,本文并未直接展示复杂系统的案例。不过,为了真正理解这个 PR,我曾向 DeepSeek V3 0324 恳求了将近三个小时,并特意加上了 Please 一词,最终获得了 Cilium 知识 +1 以及下面这张图。😇

diagram

另外,在阅读这些 issue 与 PR 的过程中,我对以前自己编写的某些代码可能引发了这些问题这一不祥预感感到不安,于是出于一种补偿心理,写下了这篇文章。

后记 - 1

顺便一提,这个问题有一个非常有效的规避方法:问题的根本原因是 NAT 表空间不足,因此只需增大 NAT 表的大小即可。:-D

有人在遇到同样的问题时,可能会不留 issue,只是把 NAT 表调大就走了;而 gyutaeb 先生即便与该问题并无直接关联,仍然彻底地进行了分析与理解,并以客观的数据依据为 Cilium 生态做出了贡献,我对此深感钦佩并由衷尊敬。

这正是我下定决心撰写本文的契机。

后记 - 2

这个故事其实与专注于 Go 语言的 Gosuda 并不直接相关。不过,Go 语言与云生态密切相关,而且 Cilium 的贡献者们也具备一定的 Go 语言素养,因此我将可以发布在个人博客上的内容拿到了 Gosuda 这里。

由于管理员之一(即我本人)已经同意,我认为应该没有问题。

如果您认为不妥,请尽快将其以 PDF 形式保存,因为本文可能随时被删除。;)

后记 - 3

撰写本文时,我很大程度上得益于 Cline 与 Llama 4 Maveric 的帮助。虽然我最初是用 Gemini 开始分析的,也向 DeepSeek 恳求过,但真正的帮助却来自 Llama 4。Llama 4 很不错,强烈推荐您使用。