第 1 章 什么是 eBPF,为何它如此重要?

eBPF 是一种革命性的内核技术,允许开发者编写自定义代码并动态加载到内核中,从而改变内核的行为。(如果您对内核的概念不太自信,不用担心——我们将在本章稍后进行介绍。)

这种技术使得新一代高性能网络、可观察性和安全工具成为可能。正如您将看到的,如果您想用这些基于 eBPF 的工具来检测应用程序,您不需要以任何方式修改或重新配置应用程序,这要归功于 eBPF 在内核中的优势地位。

使用 eBPF,您可以做的事情包括但不限于:

  • 对系统几乎任何方面的性能追踪
  • 高性能网络,具有内置的可见性
  • 检测并(可选地)阻止恶意活动

让我们从伯克利数据包过滤器(Berkeley Packet Filter)开始,简要回顾一下 eBPF 的历史。

eBPF 的起源:伯克利数据包过滤器

我们今天所说的 "eBPF" 起源于 BSD 数据包过滤器,最早由劳伦斯伯克利国家实验室的 Steven McCanne 和 Van Jacobson 在 1993 年的一篇论文1中描述。该论文讨论了一种能够运行过滤器的伪机器,这些过滤器是用来决定是否接受或拒绝网络数据包的程序。这些程序是用 BPF 指令集编写的,这是一组通用的 32 位指令,与汇编语言非常相似。以下是直接取自该论文的一个示例:

ldh [12]
jeq #ETHERTYPE IP, L1, L2
L1: ret #TRUE
L2: ret #0

这段简短的代码过滤掉不是互联网协议(IP 协议)的数据包。该过滤器的输入是一个以太网数据包,第一条指令(ldh)从该数据包的第 12 字节开始加载一个 2 字节的值。接下来的指令(jeq)将该值与代表 IP 数据包的值进行比较。如果匹配,则执行跳转到标记为 L1 的指令,并通过返回一个非零值(这里标识为 #TRUE)来接受数据包。如果不匹配,则数据包不是 IP 数据包,通过返回 0 来拒绝该数据包。

您可以想象(或参考该论文找到示例)更复杂的过滤程序,这些程序基于数据包的其他方面做出决定。重要的是,过滤器的作者可以编写自己的自定义程序在内核中执行,而这正是 eBPF 的核心功能。

BPF 代表“Berkeley Packet Filter”,它首次引入 Linux 是在 1997 年的内核版本 2.1.75 中2,用于 tcpdump 工具中,作为一种高效的捕获数据包的方法。

时间快进到 2012 年,seccomp-bpf 在内核版本 3.5 中引入。这使得可以使用 BPF 程序来决定是否允许用户空间应用程序进行系统调用。我们将在第 10 章中详细探讨这一点。这是将 BPF 从数据包过滤的狭窄范围演变为今天的通用平台的第一步。从此之后,名称中的“数据包过滤器”一词开始变得不那么有意义!

从 BPF 到 eBPF

从 2014 年的内核 3.18 版本开始,BPF 演变为我们所说的 "extended BPF" 或 "eBPF"。这涉及到几项重大变更:

  • 为了在 64 位机器上更高效的运行,BPF 指令集被彻底改造,解释器也被完全重写。
  • 引入了 eBPF 映射(maps),这是可以由 BPF 程序和用户空间应用程序访问的数据结构,允许在它们之间共享信息。您将在第 2 章中了解映射。
  • 添加了 bpf() 系统调用,以便用户空间程序可以与内核中的 eBPF 程序进行交互。您将在第 4 章中阅读到关于这个系统调用的内容。
  • 增加了几个 BPF 辅助函数。您将在第 2 章中看到一些示例,并在第 6 章中了解更多细节。
  • 增加了 eBPF 验证器,以便确保 eBPF 程序可以安全运行。这将在第 6 章中讨论。

这些变化奠定了 eBPF 的基础,但发展并没有放缓!自那以后,eBPF 取得了显著的发展。

eBPF 向生产系统的演变

自 2005 年以来,Linux 内核中一直存在一种名为 kprobes(内核探针)的特性,允许在内核代码的几乎任何指令上设置陷阱(traps)。开发者可以编写内核模块,将函数附加到 kprobes 上,用于调试或性能测量3

2015 年,添加了将 eBPF 程序附加到 kprobes 的功能,这开启了 Linux 系统中追踪方式的革命性变革。同时,内核的网络协议栈中开始添加钩子(hooks),允许 eBPF 程序处理更多的网络功能。在第 8 章中,我们将详细探讨这一点。

到 2016 年,基于 eBPF 的工具已在生产系统中使用。Brendan Gregg在 Netflix 的追踪工作在基础设施和运维领域中广为人知,他曾表示eBPF“为 Linux 带来了超能力”。同年,Cilium 项目宣布成立,这是第一个使用 eBPF 替换容器环境中整个数据路径的网络项目。

接下来的一年,Facebook(现在是 Meta)将 Katran 项目开源。Katran 是一个四层负载均衡器,满足了 Facebook 对高度可扩展和快速的解决方案的需求。自 2017 年以来,所有发送到 Facebook.com 的数据包都通过 eBPF/XDP 进行处理。(这一精彩事实来自 Daniel Borkmann 在 KubeCon 2020 上发表的题为 “eBPF 和 Kubernetes:用于扩展微服务的小助手”。)对我个人而言,这一年点燃了我对这项技术所带来可能性的兴奋,因为我在德克萨斯州奥斯汀的 DockerCon 上看到了 Thomas Graf 关于 eBPF 和 Cilium 项目的演讲。

次年,Facebook(现在是 Meta)将Katran开源。Katran 是一个四层负载均衡器,满足了 Facebook 对高度可扩展和快速解决方案的需求。自 2017 年以来,每一个访问Facebook.com的数据包都通过了 eBPF/XDP4。对我个人而言,这一年点燃了我对这种技术可能性的热情,在得克萨斯州奥斯汀的 DockerCon 上听到Thomas Graf 关于 eBPF 和 Cilium 项目的演讲后尤为如此。

2018 年,eBPF 成为 Linux 内核中的一个独立子系统,由来自 Isovalent 的Daniel Borkmann和来自 Meta 的Alexei Starovoitov担任维护者(后来同样来自 Meta 的 Andrii Nakryiko 加入了他们)。同年,引入了 BPF 类型格式(BTF),使得 eBPF 程序更具可移植性。我们将在第 5 章中探讨这一点。

2020 年引入了 LSM BPF,允许 eBPF 程序附加到 Linux 安全模块 (Linux Security Module,LSM) 内核接口。这表明 eBPF 的第三个主要用途已经确定:除了网络和可观察性之外,eBPF 还成为了安全工具的优秀平台。

多年来,得益于 300 多名内核开发者以及众多相关用户空间工具(如第 3 章中会介绍的 bpftool)、编译器和编程语言库的贡献,eBPF 的功能得到了显著提升。程序曾经被限制为 4,096 条指令,但如今这一限制已增加到 100 万条经过验证的指令5,并且通过对尾调用(tail calls)和函数调用的支持(将在第 2 章和第 3 章中看到),这一限制实际上变得无关紧要。

note

要深入了解 eBPF 的历史,最好参考那些从一开始就致力于此的维护者。

Alexei Starovoitov 曾发表过一场引人入胜的演讲,讲述了 BPF 从软件定义网络(SDN)起源的发展历程。在这次演讲中,他讨论了早期 eBPF 补丁被接受到内核中的策略,并透露 eBPF 的正式生日是 2014 年 9 月 26 日,这标志着包括验证器、BPF 系统调用和映射的第一组补丁的接受。

Daniel Borkmann 也讨论了 BPF 的历史及其支持网络和追踪功能的演变。我强烈推荐他题为“eBPF 和 Kubernetes: 用于扩展微服务的小助手”的演讲,其中充满了有趣的信息。

艰难的命名

eBPF 的应用范围已经远远超出了数据包过滤的范畴,因此这个缩写现在本质上已经失去了意义,它已经成为一个独立的术语。由于当前广泛使用的 Linux 内核都对 "extended" 部分提供支持,因此 eBPF 和 BPF 这两个术语通常可以互换使用。在内核源代码和 eBPF 编程中,常用的术语是 BPF。例如,在第 4 章中我们会看到,与 eBPF 进行交互的系统调用是bpf(),辅助函数以bpf_开头,不同类型的(e)BPF 程序以BPF_PROG_TYPE开头的名称进行标识。在内核社区之外,"eBPF"这个名称似乎已经被广泛使用,例如在社区网站 ebpf.io 上和 eBPF 基金会的名称中都使用了这个术语。

eBPF 的应用范围远远超出了数据包过滤的范畴,以至于这个缩写如今基本上没有实际意义,已经成为一个独立的术语。而且,由于目前广泛使用的 Linux 内核都支持“扩展(extended)”部分,因此 eBPFBPF 的术语基本上可以互换使用。在内核源代码和 eBPF 编程中,常用的术语是 BPF。例如,正如我们将在第 4 章中看到的,与 eBPF 交互的系统调用是 bpf(),辅助函数以 bpf_ 开头,不同类型的 (e)BPF 程序以 BPF_PROG_TYPE 开头。在内核社区之外,“eBPF”这个名称似乎已经被固定下来,例如,在社区网站 ebpf.ioeBPF 基金会的名称中都使用了这个术语。

Linux 内核

要理解 eBPF,您需要对 Linux 中内核空间和用户空间之间的区别有深入的了解。我在我的报告“什么是 eBPF?(What Is eBPF?)”6中谈到了这一点,并将其中的一些内容进行调整,形成接下来的几个段落。

Linux 内核是应用程序与其运行的硬件之间的软件层。应用程序运行在一个称为*用户空间(user space)*的非特权层,无法直接访问硬件。相反,应用程序使用系统调用(syscall)接口请求内核代表其执行操作。硬件访问可能涉及读取和写入文件、发送或接收网络流量,甚至只是访问内存。内核还负责协调并发进程,使许多应用程序能够同时运行。如图 1-1 所示。

作为应用程序开发者,我们通常不直接使用系统调用接口,因为编程语言为我们提供了更高级的抽象和更易编程的标准库。因此,很多人对内核在我们的程序运行时所做的大量工作并不知情。如果您想了解内核被调用的频率,可以使用 strace 工具显示应用程序进行的所有系统调用。

图 1-1. 用户空间中的应用程序通过系统调用接口向内核发出请求

图 1-1. 用户空间中的应用程序通过系统调用接口向内核发出请求

以下是一个示例,使用 cat 命令将“hello”这个词回显到屏幕上,涉及超过 100 次系统调用:

Alt text

由于应用程序严重依赖内核,这意味着如果我们能够观察应用程序与内核的交互,我们可以了解很多关于应用程序行为的信息。通过 eBPF,我们可以在内核中添加检测点来获得观察能力。

例如,如果您能拦截打开文件的系统调用,您就可以准确地查看任何应用程序访问了哪些文件。但是,如何进行拦截呢?让我们考虑一下,如果我们想修改内核,添加新代码,在每次调用该系统调用时创建某种输出,会涉及到什么问题。

向内核添加新功能

Linux 内核非常复杂,截至撰写本文时,代码量大约为 3000 万行7。对任何代码库进行更改都需要对现有代码有一定的熟悉程度,因此除非您已经是内核开发者,否则这可能会是一个挑战。

此外,如果您想将您的更改贡献到上游,您将面临的不仅仅是技术挑战。Linux 是一个通用操作系统,用于各种环境和情况下。这意味着,如果您希望您的更改成为官方 Linux 版本的一部分,那就不是简单的编写代码就可以了。该代码必须被社区(更具体地说,是 Linux 的创建者 Linus Torvalds 和主要开发者)接受,认为这一更改对所有人都有益。这并不是理所当然的——提交的内核补丁中只有三分之一被接受8

假设您已经找到了一个良好的技术方法来拦截打开文件的系统调用。经过数月的讨论和您的辛勤开发工作,假设您的更改被接受到内核中。太棒了!但这项更改何时才能到达每个人的机器上呢?

Linux 内核每两到三个月发布一个新版本,但即使某个更改被纳入这些版本中的一个,它离大多数人的生产环境中可用还有一段时间。这是因为大多数人并不直接使用 Linux 内核——我们使用的是像 Debian、Red Hat、Alpine 和 Ubuntu 这样的 Linux 发行版,这些发行版将 Linux 内核与各种其他组件一起打包。您很可能会发现您最喜欢的发行版使用的是几年前的内核版本。

例如,很多企业用户使用 Red Hat Enterprise Linux(RHEL)。截至撰写本文时,当前版本是 2021 年 11 月发布的 RHEL 8.5,它使用的是 2018 年 8 月发布的 Linux 内核版本 4.18。

正如图 1-2 中的漫画所示,从想法阶段到将新功能纳入生产环境的 Linux 内核中,需要数年的时间9

图 1-2. 向内核添加功能(插图由 Isovalent 的 Vadim Shchekoldin 绘制)

图 1-2. 向内核添加功能(插图由 Isovalent 的 Vadim Shchekoldin 绘制)

内核模块

如果您不想等待数年才能将更改纳入内核,还有另一种选择。Linux 内核被设计允许接受内核模块(Kernel Modules),这些模块可以按需加载和卸载。如果您想更改或扩展内核行为,编写一个模块无疑是一种方法。内核模块可以独立于官方的 Linux 内核发布而进行分发,因此无需被接受到主要的上游代码库中。

这里最大的挑战在于,这仍然是完全的内核编程。用户历来对使用内核模块非常谨慎,原因很简单:如果内核代码崩溃,整台机器及其上运行的所有程序都会崩溃。用户如何确信内核模块是安全的?

“安全运行”不仅意味着不会崩溃——用户还希望知道内核模块在安全性方面是可靠的。它是否包含攻击者可以利用的漏洞?我们是否信任模块的作者不会在其中加入恶意代码?由于内核是特权代码,它可以访问机器上的所有内容,包括所有数据,因此内核中的恶意代码将是一个严重的问题。这同样适用于内核模块。

内核安全性是 Linux 发行版需要很长时间才能引入新版本的一个重要原因。如果其他人在各种情况下运行某个内核版本数月或数年,这应该已经排除了问题。发行版维护者可以相对自信地认为,他们提供给用户/客户的内核是经过*加固(hardened)*的——即安全运行的。

eBPF 提供了一种非常不同的安全方法:eBPF 验证器(eBPF verifier),确保只有在安全运行的情况下才能加载 eBPF 程序——它不会导致机器崩溃或陷入死循环,也不会允许数据被泄露。我们将在第 6 章中更详细地讨论验证过程。

eBPF 程序的动态加载

eBPF 程序可以动态地加载到内核中和从内核中移除。一旦它们被附加到某个事件上,无论是什么原因触发该事件,它们都会被触发。例如,如果您将程序附加到打开文件的系统调用上,只要任何进程尝试打开文件,该程序就会被触发。无论该进程是在程序加载时就已经运行,还是之后才运行,都无关紧要。与升级内核后需要重启机器才能使用新功能相比,这是一个巨大的优势。

这引出了使用 eBPF 的可观察性或安全工具的一个巨大优势——它可以立即获得对机器上所有活动的可见性。在运行容器的环境中,这包括对所有在这些容器内运行的进程以及主机上的进程的可见性。在本章稍后部分,我将深入探讨这对云原生部署的影响。

此外,如图 1-3 所示,人们可以通过 eBPF 非常快速地创建新的内核功能,而不需要每个 Linux 用户都接受相同的更改。

图 1-3. 使用 eBPF 添加内核功能(插图由 Isovalent 的 Vadim Shchekoldin 绘制)

图 1-3. 使用 eBPF 添加内核功能(插图由 Isovalent 的 Vadim Shchekoldin 绘制)

eBPF 程序的高性能

eBPF 程序是一种非常高效的添加检测点的方法。在加载并进行 JIT 编译(JIT-compiled)后(第 3 章中将看到),程序将以原生机器指令在 CPU 上运行。此外,在处理事件时,无需在内核和用户空间之间进行转换(这是一项代价高昂的操作)。

2018 年描述 eXpress Data Path(XDP)的论文10中包含了一些关于 eBPF 在网络中实现性能改进的示例。例如,与常规的 Linux 内核实现相比,在 XDP 中实现路由可以“将性能提高 2.5 倍”,在负载均衡方面,“XDP 提供了比 IPVS 高 4.3 倍的性能提升”。

对于性能追踪和安全可观察性,eBPF 的另一个优势是相关事件可以在内核中被过滤掉,然后再将其发送到用户空间,从而减少了开销。毕竟,过滤特定的网络数据包正是最初 BPF 实现的目的。如今,eBPF 程序可以收集关于系统中各种事件的信息,并使用复杂的、定制的可编程过滤器,只将相关信息的子集发送到用户空间。

云原生环境中的 eBPF

如今,许多组织选择不直接在服务器上执行程序来运行应用程序。相反,许多组织采用云原生方法:容器、如 Kubernetes 或 ECS 的编排器,或无服务器方法,如 Lambda、云函数、Fargate 等。这些方法都使用自动化技术来选择每个工作负载运行的服务器;在无服务器环境中,我们甚至不知道每个工作负载在哪个服务器上运行。

尽管如此,仍然涉及服务器,每台服务器(无论是虚拟机还是裸金属机器)都运行一个内核。如果应用程序在容器中运行,那么它们在同一(虚拟)机器上运行时,共享同一个内核。在 Kubernetes 环境中,这意味着在给定节点上所有 pod 中的所有容器都使用相同的内核。当我们用 eBPF 程序对该内核进行检测时,该节点上所有容器化的工作负载对于这些 eBPF 程序都是可见的,如图 1-4 所示。

图 1-4. 内核中的 eBPF 程序可以看到在 Kubernetes 节点上运行的所有应用程序

图 1-4. 内核中的 eBPF 程序可以看到在 Kubernetes 节点上运行的所有应用程序

对节点上所有进程的可见性,加上动态加载 eBPF 程序的能力,为云原生计算中的 eBPF 工具带来了真正的超能力:

  • 我们无需更改应用程序,甚至不需要更改它们的配置,就可以用 eBPF 工具对应用程序进行检测。
  • 一旦 eBPF 程序加载到内核并附加到一个事件上,它就可以开始观察预先存在的应用程序进程。

与之相比,Sidecar 模型已被用于向 Kubernetes 应用程序添加日志记录、追踪、安全和服务网格功能等功能。在 Sidecar 方法中,检测工具作为一个容器“注入”到每个应用程序 pod 中。该过程涉及修改定义应用程序 pod 的 YAML,添加 sidecar 容器的定义。这种方法比将检测工具添加到应用程序的源代码中(在 sidecar 方法出现之前,我们不得不这样做;例如,在我们的应用程序中包含一个日志库,并在代码的适当位置调用该库)更方便。然而,Sidecar 方法有一些缺点:

  • 为了添加 Sidecar,必须重新启动应用程序 pod。
  • 必须修改应用程序的 YAML。这通常是一个自动化过程,但如果出问题,Sidecar 就不会被添加,这意味着 pod 无法被检测。例如,一次部署可能会标注,来指示准入控制器将 Sidecar 的 YAML 添加到本次部署的 pod spec 中。但如果部署没有正确标注,Sidecar 就不会被添加,因此检测工具不具备可见性。
  • 当一个 pod 中有多个容器时,它们可能会在不同的时间达到就绪状态,其顺序可能不可预测。注入 Sidecar 可能会显著延长 Pod 的启动时间,甚至更糟糕的是,可能会引发竞争条件或其他不稳定性问题。例如,Open Service Mesh 文档描述了应用程序容器必须能够应对所有流量在 Envoy 代理容器准备就绪之前被丢弃的情况。
  • 当网络功能(如服务网格(service mesh))作为 Sidecar 实现时,这意味着所有进出应用程序容器的流量都必须通过内核中的网络协议栈到达网络代理容器,从而增加了流量的延迟;如图 1-5 所示。我们将在第 9 章讨论用 eBPF 改善网络效率的方法。

Alt text

图 1-5. 使用服务网格代理 sidecar 容器的网络数据包路径

所有这些问题都是 Sidecar 模型固有的。幸运的是,现在有了 eBPF 作为平台,我们有了一种新的模型可以避免这些问题。此外,由于基于 eBPF 的工具可以观察到(虚拟)机器上发生的一切,恶意行为者很难绕过这些工具。例如,如果攻击者设法在您的主机上部署了一款加密货币挖矿应用程序,他们可能不会将您在应用程序工作负载中使用的 Sidecar 工具部署在他们的挖矿应用程序上。假设您依赖基于 Sidecar 的安全工具来防止应用程序进行意外的网络连接,那么如果 Sidecar 没有被注入,该工具将无法发现挖矿应用程序连接到其挖矿池的行为。相比之下,基于 eBPF 实现的网络安全可以监控主机上的所有流量,因此可以轻松阻止这种加密货币挖矿行为。我们将在第 8 章中重新讨论出于安全原因丢弃网络数据包的能力。

总结

希望本章能让您了解到为何 eBPF 作为一个平台如此强大。它允许我们改变内核的行为,提供构建定制工具或自定义策略的灵活性。基于 eBPF 的工具可以观察到内核中的任何事件,从而观察到(虚拟)机器上运行的所有应用程序,无论它们是否容器化。eBPF 程序还可以动态部署,允许行为随时更改。

到目前为止,我们主要从概念层面讨论了 eBPF。在下一章中,我们将更具体地探索基于 eBPF 的应用程序的组成部分。

2

这些及其他细节来自 Alexei Starovoitov 在 2015 年 NetDev 演讲中的“BPF – 内核中的虚拟机(BPF – in-kernel virtual machine)”

3

内核文档中对 kprobes 工作原理有详细的描述。

5

有关指令限制和“复杂性限制”的更多细节,请参见 https://oreil.ly/0iVer

6

摘自 Liz Rice 的 “什么是 eBPF?(What Is eBPF?)”。版权 © 2022 O’Reilly Media。经许可使用。

7

“Linux 5.12 的代码量约 2880 万行”。Phoronix,2021 年 3 月。

8

Jiang Y, Adams B, German DM. 2013. “我的补丁会被接受吗?会有多快?”(2013)。根据这篇研究论文,33%的补丁被接受,大多数补丁需要三到六个月的时间。

9

庆幸的是,现有功能的安全补丁会更快地发布。

10

Høiland-Jørgensen T, Brouer JD, Borkmann D 等人 “eXpress 数据路径:操作系统内核中的快速可编程数据包处理”。第 14 届国际网络实验与技术会议(CoNEXT ’18)论文集。计算机协会;2018:54–66。