GoSuda

Cilium 故事:微小代码变更带来的卓越网络稳定性提升

By iwanhae
views ...

引言

前不久,我偶然看到了前同事在 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 地址的响应,并直接丢弃该数据包。因此,当 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 网络栈。🤣

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|            |          |         |          |

由于是 Hash Table,为了快速查询,存在一个键值,它使用 src ipsrc portdst ipdst port组合作为键值

问题识别

现象 - 1:查询

因此,出现了一个问题:经过 eBPF 的数据包在执行 NAT 过程时,需要验证是否需要执行 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)连接:

  1. 在一个 Hash Table 中,出站数据包和入站数据包的相同数据被记录了两次。
  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 时,它会尝试在 SNAT 表中通过 src ip、src port、dst ip、dst port 的组合来生成键并进行查询。如果键不存在,则根据 SNAT 规则生成新的 NAT 信息并将其记录在表中。如果是新的连接,这将导致正常的通信;如果键因 LRU 而意外删除,则会使用与现有通信端口不同的新端口执行 NAT,导致接收方拒绝接收数据包,并以 RST 数据包终止连接。

这里,上述 PR 采用的方法很简单:

无论哪个方向的数据包被观察到一次,其反方向的 entry 也应被更新。

无论哪个方向的通信被观察到,两个 entry 都会被更新,从而降低了 LRU 逻辑的淘汰优先级,并通过这种方式降低了仅删除其中一个 entry 导致整个通信崩溃的可能性。

这可能看起来是一个非常简单的方法和想法,但通过这种方法,我们有效地解决了响应数据包的 NAT 信息过早过期导致连接中断的问题,并大大提高了系统的稳定性。此外,在网络稳定性方面,它也是一个重要的改进,取得了以下成果:

benchmark

结论

我认为这个 PR 是一个很好的案例,它从 NAT 如何运作的基础 CS 知识开始,展示了一个简单的想法如何在复杂的系统内部带来巨大的改变。

哦,当然,我并没有在这篇文章中直接展示复杂系统的案例。但是,为了正确理解这个 PR,我向 DeepSeek V3 0324 甚至加上了 Please 这个词,苦苦哀求了近 3 个小时,结果是 Cilium 知识 +1,并得到了一张如下图所示的图。😇

diagram

在阅读这些 Issue 和 PR 时,我产生了一种不祥的预感,觉得这些 Issue 可能是因为我以前创建的某些东西造成的,作为一种补偿心理,我写下了这篇文章。

后记 - 1

顺便说一下,这个 Issue 有一个非常有效的规避方法。由于 Issue 的根本原因是 NAT 表空间不足,所以只要增大 NAT 表的大小就可以了。:-D

当有人遇到同样的问题时,他们可能没有留下 Issue,而是直接增大了 NAT 表的大小并离开了。然而,gyutaeb 님 即使与自己没有直接关系,也彻底分析、理解,并提供了客观的数据,为 Cilium 生态系统做出了贡献,我对此感到非常钦佩和尊敬。

这就是我决定写这篇文章的契机。

后记 - 2

这个故事实际上与 Gosuda 专业的 Go 语言主题不太相符。但是,Go 语言与云生态系统有着密切的关系,并且 Cilium 的贡献者对 Go 语言都有一定的了解,所以我把个人博客上可以发布的内容搬到了 Gosuda。

由于得到了其中一位管理员(我自己)的许可,我想这应该没问题。

如果您认为有问题,请尽快保存为 PDF,因为我不知道它何时会被删除。;)

后记 - 3

本文的撰写得到了 Cline 和 Llama 4 Maveric 的大力帮助。虽然我最初使用 Gemini 进行分析,并向 DeepSeek 苦苦哀求,但实际上帮助我的是 Llama 4。Llama 4 很好用。一定要试试看。