背景

最近正在抽空研究 eBPF,看到一些有意思的特性,就随便写点东西记录一下 🐱

eBPF 代码作为从用户空间向内核空间注入的代码,不可避免地需要访问内核数据结构(某些 eBPF 程序可能只需要 trace 一些系统调用,所以可以不关注内核数据结构,或者说我们认为系统调用是比内核数据结构更为稳定的约定)。因此,内核的数据结构本质上就是面向 eBPF 代码的协议(或者说约定),一旦协议发生改变,eBPF 代码在运行时将有可能导致未知的问题。因此目前常见的方式就是将内核头文件与 eBPF 代码一起编译,于是诞生了这么几种方式:

  • 原始模式:人为去找对应 target host 上的内核头文件,然后和 eBPF 代码一起编译,比如内核 module 的开发方式;
  • BCC 的 on the fly 编译:BCC 会默认你的 target host 上已经装上了准确的内核头文件,然后 BCC 对用户提供的 Python 的 binding,你可以用 C 来写一段 eBPF 代码,然后这段代码被 Python 当成纯文本调用 LLVM 和对应的内核头文件进行编译加载执行,可想而知,用这种方式写 tool 运行性能得非常低,而且需要加载的 share object 也非常大;

无论如何,你都得摆脱不了对内核头文件的依赖,一旦你用在对应内核环境下编译除了一个 binary,换了一个内核环境极有可能就运行不起来了。这种对内核头文件的依赖极大的阻碍了写 eBPF 代码的易用性,无法做到一次编译满世界跑的幻想。

让我们回过头来看看这个问题的本质,Brendan Gregg 说得很好,这其实就是一个 Relocation(重定位)的问题。当 eBPF 代码访问内核数据结构时,它必须准确知道当前这个数据结构在内核中的 memory layout,也就是某个 field 到底在哪个 offset。举个栗子,假如 eBPF 代码需要用 struct task_struct 这个知名结构:

  • 如果 eBPF 代码使用的 task_struct 和实际运行内核运行的 task_struct 不一致,比如实际内核运行的 task_struct 在需要 eBPF 代码要访问的 field 前面又新增了一些 fileld,这就导致 offset 发生了改变,eBPF 代码将读到错误的数据;
  • 如果需要访问的 field 发生了重命名或者被删除了,这样这么办 ?
  • 如果用了不同的 Kconfig,同一份内核编译出不同的 struct,这样该怎么办 ?

种种的这些问题呼唤着新的解决方案。

BTF 和 CO-RE

无论如何,我们要用一种相对抽象的方式去描述 eBPF 和内核的数据结构的元数据,然后在加载的时候让加载器基于这些元数据来完成重定位。这时候就引入了 BTF。BTF 是一种比 DWARF 给为紧凑高效的描述数据结构的格式。不同于 DWARF,也就是在编译内核的时候加上一堆的符号信息,我们将得到一个体积极其庞大的内核(多 100 多 M),但如果换成 BTF 来描述,我们则可以得到一个即等价又轻量(几 M)的内核。也就是说,只要开启了 BTF 功能(CONFIG_DEBUG_INFO_BTF=y ,Linux 5.2 里引入,如果内核不支持,可用其他工具间接生成),我们就可以将内核数据结构的描述内嵌于内核中(/sys/kernel/btf/vmlinux),妈妈再也不用担心我找不到对应的内核头文件了。而且,这部分 BTF 信息还可以等价转换为 C 描述,所以你可以得到一个巨大的 header 文件 vmlinux.h。只要引入一个头文件,我们在写代码的时候就无需引入其他乱七八糟的内核头文件,就可以使用全部的内核数据结构。

一个完整的 CO-RE (Compile Once,Run Everywhere)能力需要以下几个组件的互相配合:

  • Linux 内核支持暴露 BTF 格式的数据结构;
  • Clang 编译器可将 eBPF 对内核数据结构的访问记录成相应的重定位信息保存在 ELF 文件的 section 中;
  • BPF Loader(即 libbpf)可以在加载的时候通过读取内核 BTF(具体逻辑可以看 btf.c#L4583) 和 eBPF 的重定位信息来修正访问信息,完成最终的重定位(具体逻辑可以看 libbpf.c#L6561);
  • libbpf 支持对 eBPF 暴露 Kconfig 配置或者 struct flavor 机制来兼容不同的内核数据结构改名或者含义不同的情况;

如果觉得 libbpf 不太好好读,可以读一读 Go 版本的 libbpf,即 Cillium 的 ebpf,社区已经逐步支持 CO-RE 的能力。

如此以来,基于 libbpf + BTF + CO-RE,我们的 eBPF 代码可以编译成一个很瘦的二进制在多个不同版本的内核上运行(BCC 的工具可真正做到准生产化)。

展望未来

根据 Brendan Gregg 和 Alexei Starovoitov 在 IOVisor 社区讨论的那样,未来是 libbpf + BTF + CO-RE(Alexei Starovoitov 建议新人直接用这套组合来写代码,但是当然,Python 的灵活性和可挑战性要更为容易)。 BCC 正在将对应工具迁移到 libbpf-tools 中,当然,这当然是个相对长期的过程。

一旦这个基础设施稳定并大面积推广下来,eBPF 代码就可以向近乎完美的做到 CO-RE,前途光明。

参考文档

  1. BPF portability and CO-RE
  2. BPF binaries: BTF, CO-RE, and the future of BPF perf tools