第四章:eBPF 的复杂性

查看本文大纲

现在你已经看到了 eBPF 编程的例子,了解到它是如何工作的。虽然基础示例使得 eBPF 看起来相对简单,但也有一些复杂的地方使得 eBPF 编程充满挑战。

长期以来,有个问题使得编写和发布 eBPF 程序相对困难,那就是内核兼容性。

跨内核的可移植性

eBPF 程序可以访问内核数据结构,而这些结构可能在不同的内核版本中发生变化。这些结构本身被定义在头文件中,构成了 Linux 源代码的一部分。在过去编译 eBPF 程序时,必须基于你想运行这些程序的内核兼容的头文件集。

BCC 对可移植性的处理方法

为了解决跨内核的可移植性问题,BCC 1(BPF 编译器集合,BPF Compiler Collection)项目采取了在运行时编译 eBPF 代码的方法,在目标机器上就地进行。这意味着编译工具链需要安装到每个你想让代码运行 2 的目标机器上,而且你必须在工具启动之前等待编译完成,而且文件系统上必须有内核头文件(实际上并不总是这样)。这就引出了 BPF CO-RE。

CO-RE

CO-RE(Compile Once, Run Everyone,编译一次,到处运行)方法由以下元素组成。

BTF (BPF Type Format)

这是一种用于表达数据结构和函数签名布局的格式。现代 Linux 内核支持 BTF,因此你可以从运行中的系统中生成一个名为 vmlinux.h 的头文件,其中包含一个 BPF 程序可能需要的关于内核的所有数据结构信息。

libbpf,BPF 库

libbpf 一方面提供了加载 eBPF 程序和映射到内核的功能,另一方面也在可移植性方面也起着重要的作用:它依靠 BTF 信息来调整 eBPF 代码,以弥补其编译时的数据结构与目标机器上的数据结构之间的差异。

编译器支持

clang 编译器得到了增强,因此当它编译 eBPF 程序时,它包括所谓的 BTF 重定位(relocation),这使得 libbpf 在加载 BPF 程序和映射到内核时知道要调整什么。

可选的 BPF 骨架

使用 bpftool gen skeleton 可以从编译的 BPF 对象文件中自动生成一个骨架,其中包含用户空间代码可以方便调用的函数,以管理 BPF 程序的生命周期 —— 将它们加载到内核,附加到事件等等。这些函数是更高层次的抽象,对开发者来说比直接使用 libbpf 更方便。

关于 CO-RE 的更详细的解释,请阅读 Andrii Nakryiko 的出色 描述

自 5.4 版本 3 以来,vmlinux 文件形式的 BTF 信息已经包含在 Linux 内核中,但 libbpf 可以利用的原始 BTF 数据也可以为旧内核生成。在 BTF Hub 上有关于如何生成 BTF 文件的信息,以及用于各种 Linux 发行版的文件档案。

BPF CO-RE 方法使得 eBPF 程序更易于在任意 Linux 发行版上运行 —— 或者至少在新 Linux 发行版上支持任意 eBPF 能力。但这并不能使 eBPF 更优雅:它本质上仍然是内核编程。

Linux 内核知识

很快就会发现,为了编写更高级的工具,你需要一些关于 Linux 内核的领域知识。你需要了解你可以访问的数据结构,取决于你的 eBPF 代码被调用的环境。不是所有应用程序的开发者都有解析网络数据包、访问套接字缓冲区或处理系统调用参数的经验。

内核将如何对你 eBPF 代码的行为做出反应?正如你在 第二章 中了解到的,内核由数百万行代码组成。它的文档可能是稀少的,所以你可能会发现自己不得不阅读内核的源代码来弄清楚某些东西是如何工作的。

你还需要弄清楚你的 eBPF 代码应该附加到哪些事件。由于可以将 kprobe 附加到整个内核的任何函数入口点,这可能不是一个简单的决定。在某些情况下,这可能很明确 —— 例如,如果你想访问一个传入的网络数据包,那么适当的网络接口上的 XDP 钩子是一个明显的选择。如果你想提供对特定内核事件的可观测性,在内核代码中找到合适的点可能并不难。

但在其他情况下,选择可能不那么明显。例如,简单地使用 kprobes 来钩住构成内核系统调用接口的函数的工具,可能会被名为 time-of-checktime-of-use(TOCTTOU)的安全漏洞所影响。攻击者有一个小的机会窗口,他们可以在 eBPF 代码读取参数后,但在参数被复制到内核内存之前,改变系统调用的参数。在 DEF CON 29 4 上,Rex Guo 和 Junyuan Zeng 做了一个关于这个问题的 出色演讲。一些被最广泛使用的 eBPF 工具是以相当天真的方式编写的,极易受到这种攻击。这不是一个简单的漏洞,而且有办法减轻这些攻击,但如果你正在保护高度敏感的数据,对抗复杂的、有动机的对手,请深入了解你使用的工具是否可能受到影响。

你已经看到了 BPF CO-RE 是如何使 eBPF 程序在不同的内核版本上工作的,但它只考虑到了数据结构布局的变化,而没有考虑到内核行为的更大变化。例如,如果你想把一个 eBPF 程序附加到内核中的一个特定的函数或 tracepoint 上,你可能需要一个 B 计划,如果该函数或 tracepoint 在不同的内核版本中不存在,该怎么做。

编排多个 eBPF 程序

当前有很多基于 eBPF 的工具提供了一套可观测能力,通过将 eBPF 程序与一组内核事件挂钩来实现。其中大部分是由 Brendan Gregg 和其他人在 BCC 和 bpftrace 工具中所做的工作而开创的。很多工具(通常是商业的)可能会提供更漂亮的图形和用户界面,但他们还是在这些 eBPF 程序的基础上实现的。

当你想写代码来编排不同类型的事件之间的交互时,事情就变得相当复杂了。举个例子,Cilium 通过内核的网络堆栈 5 在不同的点上观察到网络数据包,基于来自 Kubernetes CNI(容器网络接口)关于 Kubernetes pod 的信息,对流量进行操作。构建这个系统需要 Cilium 开发人员深入了解内核如何处理网络流量,以及用户空间的 pod容器 概念如何映射到内核概念,如 cgroups 和命 namespace。在实践中,一些 Cilium 的维护者也是内核的开发者,他们致力于增强 eBPF 和网络支持;因此,他们拥有这些知识。

底线是,尽管 eBPF 提供了一个极其有效和强大的平台来连接到内核,但对于没有大量内核经验的普通开发者来说,这并不容易。如果你对 eBPF 编程感兴趣,我非常鼓励你把它作为练习来学习;在这个领域积累经验可能是非常有价值的,因为它在未来几年内一定会成为受欢迎的专业技能。但实际上,大多数组织不太可能在内部建立许多定制的 eBPF 工具,而是利用专业 eBPF 社区的项目和产品。

让我们继续思考为什么这些基于 eBPF 的项目和产品在云原生环境中如此强大。

参考


  1. 你可以在 GitHub 页面 上找到 BCC。 ↩︎

  2. 一些项目采取了将 eBPF 源和所需工具链打包成一个容器镜像的方法。这避免了安装工具链的复杂性和任何随之而来的依赖管理,但这仍意味着编译步骤在目标机器上运行。 ↩︎

  3. 更多信息见 Andrii Nakryiko 的 IO Visor 帖子。 ↩︎

  4. Rex Guo and Junyuan Zeng, “Phantom Attack: 逃离系统调用监控,"(DEF CON,2021 年 8 月 5-8 日)。 ↩︎

  5. Cilium 文档描述了 eBPF 程序如何附加到不同的网络能力钩子,组合起来以实现复杂的网络能力。 ↩︎