[译] 如何用 EBPF 改变网络编程的游戏规则

这篇文章介绍了 eBPF 在网络领域的一些应用和实践,包括 XDP、TC 和 sysprobes 三种不同的 eBPF 程序挂载方式的优缺点,以及如何使用 eBPF 实现网络数据的捕获、分析和修改。作者还分享了一些有用的 eBPF 工具和资源,以及自己开发的一个 eBPF 网络代理的项目。

声明
此文为个人翻译,仅供参考,不代表我个人立场。翻译过程中可能有删改或遗漏,如需了解原文,请自行查阅。如有疏漏,欢迎指正。
查看本文大纲

我一直想写一些关于 eBPF 的帖子,希望它们能有所帮助,尽管通常在我想到可能有用的东西时,其他人已经先我一步了。鉴于我已经在网络方面集中精力一段时间,这基本上是我专注的领域,尽管我确实设法为最近的 eBPF 峰会 2023 准备了一些我认为很有趣的东西。正如我之前提到的,有很多人开始撰写关于 eBPF 的内容,因此我可能会参考他们的帖子,而不是重复内容。

我将从一些在 Linux 内核中可能或可能不会遇到的首字母缩写或技术开始。但基本上从我的角度来看,这些是你修改正在运行的系统以与网络数据交互的主要选项。

XDP

关于 eXpress Data Plane 已经存在大量信息,因此我不会深入探讨太多细节。tl;dr是 XDP eBPF 程序挂钩到 XDP 将使其能够访问由内核自身处理之前的传入网络帧。在某些情况下,eBPF 程序将加载到 NIC 驱动程序本身中,这将有效地将程序卸载到 NIC 本身。

优点

  • 最佳性能
  • 非常适用于防火墙、DDoS 防护或负载均衡等用例
  • 在任何其他内容进行修改之前看到传入的流量

缺点

  • 仅支持入站流量,使用 XDP 程序看到的任何流量都只是传入流量,目前无法看到出站流量
  • 使用XDP数据结构,与大多数套接字编程的默认数据结构SKB有一些不同。

TC(Traffic Control)或流量控制

Traffic Control 是内核网络结构的一个重要组成部分,主要包括添加诸如 qdisc 和过滤器之类的功能到接口的能力。qdisc 主要集中在为 TBD(待定)提供服务,而过滤器通常在底层实际上是一个 eBPF 程序。

常见的工作流程是:

  1. 创建一个关注入口或出口的 qdisc,或者替换一个现有的 qdisc。qdisc 将附加到接口上。
  2. 加载你的 eBPF 程序。
  3. 创建一个过滤器,将其附加到通过接口上的 qdisc 上的入口或出口之一。该过滤器将与 eBPF 程序相关联,这意味着所有传入或传出的流量现在都会通过一个程序运行(如果连接)。
  4. 获利 💰

优点

  • 提供入口和出口的挂钩点
  • 使用传统的SKB数据结构

缺点

  • 将 TC 程序附加到入口或出口队列稍微复杂一些。用户需要使用 qdisc 来做到这一点,某些 eBPF SDK 不会原生支持 TC 程序的使用。
  • TC eBPF 程序看到的流量可能已经被之前的 XDP 程序或内核本身修改。

系统调用

与其他两种专门设计用于处理网络的方法相比,这可能会显得有些奇怪,因为它是将一些 eBPF 代码附加到内核中的系统调用的一种替代方法,具体来说是tcp4_connect() / tcp6_connect()等调用。这在协议栈中略微靠后,因为在此时,传入数据包已经经过了很多内核逻辑,而 eBPF 内省点是当流量即将与应用程序本身交互时。

编写网络程序!

所以在这一点上,我们(希望)意识到我们有许多不同的入口点,允许我们在“传送带”上注入我们的代码,这个传送带从 NIC 开始,一直到应用程序(以及在出站的情况下)。

回顾

在我们所谓的“传送带”的开头,我们可以附加我们的 XDP 程序并获得未经触碰的原始网络数据。在“传送带”的中间,我们的 TC 程序将成为通过内核的路径的一部分,并接收可能被修改的网络数据。在传送带的末端,我们可以将代码附加到应用程序将在它被运行之前获取网络数据的函数,这些函数可以在传送带的末端进行附加。

数据表示

你的程序附加到的位置决定了两个主要事物,一个是潜在的流量修改的相对级别,另一个是流量的表示方式。

XDP 结构

我会写关于它的内容,但是 DataDog 已经做了,你可以在这里阅读。

SKB(套接字缓冲区)

SKB 是在 eBPF 添加到内核之前就存在于内核中的数据类型,它已经具备了一些使与 SKB 对象交互变得稍微容易一些的辅助功能。有关更深入的 SKB 介绍,你可以阅读此文 -> http://vger.kernel.org/~davem/skb_data.html

解析数据

无论与哪个结构进行交互,它们都共享一些共同之处,这主要是两个变量,对于这两种数据类型来说是相同的。

这些变量是:

  • data,它是 eBPF 程序接收到的数据的指针

  • data_len,它是一个整数,指定了有多少数据可用(以帮助确保你永远不会访问data超过data_len(显而易见的真理 🤓))

所以这一切似乎很简单,但等等… *data中实际上有什么?(这取决于你的发现)

通过不断“转换”*data并沿着它移动以剥离各种标头,我们可以了解和查找底层数据!

转换?

如果你愿意,你可以跳过此部分,但这是一个快速(且糟糕)的示例,说明了我们通常如何将一些原始数据转换为有意义的东西。目前,*data将只是一串随机数据,毫无意义,我们需要有效地为其添加“格式”以便我们可以理解其外观。

考虑一下随机数据行:“Bobby0004500100.503 Harvard Drive90210”,其中一些对裸眼来说是有意义的,但有些是不清楚的。

想象一下名为“person”的数据结构:

Name: string
Age: number
Balance: float
Street: string
ZipCode: number

如果我们要将我们的随机数据“转换”为上面的“person”结构,它将突然变成:

Name: Bobby
Age: 45
Balance: 100.50
Street: 3 Harvard Drive
ZipCode: 90210

现在突然间,我能够理解并访问结构中的底层变量,因为它们现在是有意义的,即 person->Name,并且发现此特定的 person 类型对象具有名称变量“Bobby”!

这正是我们将对我们的*data所做的!

数据中包含什么?

因此,第一步是确定数据是否以以太网帧开头!几乎所有传输的数据都以以太网帧开头,这相当简单,但其作用是具有源和目标硬件地址(无论虚拟化/容器化/有线网络还是 WiFi 如何)。因此,我们的第一步是将我们的*data转换为类型ETHHDR,如果成功,我们现在将能够了解组成以太网头数据类型的变量。这些包括源和目标 MAC 地址,但更重要的是剩余数据的内容是什么。再次,在大多数情况下,以太网头之后的*data内容通常是 IP 头,但我们将通过检查以太网帧的 TBD 变量来验证。

一旦我们验证下一组数据是 IP 头,我们将需要将以太网头之后的数据转换为 IPHDR 类型。一旦我们这样做,我们将能够访问 IP 特定的数据,例如源 IP(saddr)或目标地址(daddr),再次重要的是 IP 头包含一个变量,详细说明了 IP 头之后的数据是什么。这通常是 TCP 头或 UDP 头,但还有其他选择,例如 sctp 等。

一旦我们查看了 IP 头的内部并确定数据类型是 TCP(也可能是 UDP 或其他内容),我们将把以太网头和 IP 头之后的数据都转换为 TCP 头类型!(几乎完成了)。通过访问 TCP 头的内容,我们可以获得 TCP 特定的数据,例如源端口或目标端口,用于验证数据的有效性的校验和以及其他有用的变量。

现在我们几乎拥有一切,但是 TCP 头的长度可能是可变的,因此我们需要通过查看 tcl_len 变量来确定这一点,我们需要将其乘以 4。现在我们拥有了访问最终数据所需的一切!

因此,*data指向数据的开头!我们已经确定了以太网头之后是 IP 头,最后是 TCP 头,这意味着`*data + 以太网头 + IP 头 + TCP 头 = 实际应用程序数据!

我们可以用这些信息做什么?

当我们解析各种标头时,实际上在 OSI 模型的不同层次上解锁了越来越多的信息!

[第 2 层] 以太网头为我们提供了源和目标硬件地址,我们可以使用此信息来潜在地阻止从我们知道危险的源 MAC 地址处理的帧。

[第 3 层] IP 头包含源和目标 IP 地址,再次,我们可以像防火墙一样运作,通过使用 eBPF 程序丢弃特定 IP 地址的所有流量。或者,我们可以根据 IP 地址重定向流量,或者甚至在这一层实施负载均衡逻辑,以将流量重定向到其他底层 IP 地址集合。

[第 4 层] TCP 或 UDP 标头定义了目标端口号,我们可以使用这些信息来确定应用程序协议是什么(即端口 80 通常意味着剩余的*data 可能是 HTTP 数据)。在这一层,我们通常会执行负载均衡等操作,基于目标(即在多个其他负载均衡器地址之间平衡)。

[第 7 层] 正如前面提到的,各种标头集合的末尾的数据是实际的应用程序数据,只要我们知道格式,我们也可以解析它。例如,如果外部 Web 浏览器尝试访问我的计算机上的/index.html并附加了 eBPF 程序,我会解析到 TCP,然后确定它是端口 80,然后应用程序数据应该是 HTTP 格式。我可以通过查看应用程序数据的前三个字符(在所有标头之后)来验证这一点,使用以下伪代码:

ApplicationData = EthernetHDR + IPHDR + TCPHDR // 将所有标头长度相加以找到数据
If ( data[ApplicationData] = "G" && data[ApplicationData+1] = "E" && data[ApplicationData+2] = "T" ) {
	// 这是一个HTTP GET请求
	// 做一些令人兴奋的事情
}

总结

现在我们“有点”理解了这个逻辑,也许我们应该考虑实施一些代码来完成所有这些…但这将是另一天的事情。

最后更新于 2024/12/12