在本章中,我们会介绍编写 eBPF 代码所涉及的内容,包括内核中运行的 eBPF 程序,及与之交互的用户空间代码。
首先需要考虑的是,你可以使用哪些编程语言来编写 eBPF 程序?eBPF 本质是字节码形式加载到内核的,字节码本身没有任何平台依赖,手动使用类似汇编的方式编写字节码是可能的。但是考虑易用性和效率,我们通常使用高级语言编写,借助编译器将其编译成 eBPF 字节码。
现阶段 eBPF 程序不能用任意高级语言编写,当前可选择的是 C 和 Rust。这是因为将高级语言编译成 eBPF 字节码需要编译器的支持,另外一些语言自身也不适合编写内核中的程序,比如 Go/Java 等语言的内存管理机制(垃圾回收)。目前 eBPF 程序的编译还是以 C 语言为主。
用户空间的程序主要负责加载 eBPF 字节码至内核,同时还可能负责读取内核中 eBPF 程序发送的数据和写入用户空间的配置数据至 eBPF 内核中使用的 map 结构中。
eBPF 工具的用户空间部分至少在理论上可以用任何语言编写,目前具有对应库支持语言有:C、Go、Rust 和 Python。用户空间程序语言的选择更加复杂,因为并非所有语言都有支持 libbpf 的库,而 libbpf 库已成为使 eBPF 程序在不同内核版本之间可移植的主流选择。
当前 Go 编写的 eBPF 用户空间程序的库,主要有两种实现:基于 cgo 使用 libbpf 和纯 Go 语言实现。
Java 能不能用于编写用户空间的程序?答案当然是可以,但由于用户空间依赖一些底层的系统调用来完成数据交互,你可能需要使用 Java 基于底层调用库(通常是 C)进行封装。由于 Python 与 C 语言库集成的方便性,所以 BCC 选择了 Python 作为前端绑定。
eBPF 程序本身通常用 C 或 Rust 编写并被编译成目标文件。目标文件是一个标准的 ELF(可执行和可链接格式)文件,可使用 readelf 等工具进行检查,它包含程序字节码和 map 数据结构的定义,当然启用了 BTF 也包括相关的信息。如图 3-1 所示,如果 eBPF 程序被在前面章节遇到的验证器所允许,用户空间程序会读取此文件并将其加载到内核中。
图 3-1 用户空间的应用程序使用 bpf() 系统调用,从 ELF 文件加载 eBPF 程序到内核
eBPF 程序加载到内核中,就必须附着到一个事件上。每当事件发生时,相关的 eBPF 程序就会被触发执行。以下是我们常见的 eBPF 可附着的事件:
你可以附着一个 eBPF 程序,在进入或退出内核函数时触发。当前许多 eBPF 示例都使用 kprobes(附着到内核函数入口点)和 kretprobes(函数退出)的机制。在最近的内核版本中,有一个更有效的替代方案,称为 fentry/fexit
fexit 机制触发的 eBPF 程序中可同时获取到入口参数和返回值,而 kretprobes 则只能获取到程序的返回值,需要额外的 map 保存函数入参(比如通过 tid 进行关联)。
kprobes/kretprobes 这种跟踪机制一般被称作动态跟踪,基于跟踪函数的签名(包括函数名、参数以及参数对应的定义),其可能随着内核版本的演进被调整或者被移除。尽管动态机制可跟踪的函数数量巨大,但是可能会导致 eBPF 失去了多内核版本的移植性。但是基于 syscall 是相对稳定 ABI 接口。
另外也可以通过 uprobes 和 uretprobes 将 eBPF 程序附着到用户空间函数,这种机制需要具体的程序或库作为路径,同时由于 eBPF 程序是运行在内核中,使用 uprobes/uretprobes 机制,还面临通过 int3 中断机制从用户空间到内核空间的切换,但是性能开销基本可忽略。
图片来自于 Pixie[1]
也可以将 eBPF 附着到内核中 tracepoint。/sys/kernel/debug/tracing/events 文件是系统上支持的事件列表。
Perf 是一个用于收集性能数据的子系统。你可以将 eBPF 程序挂接到所有收集 perf 数据的地方,可运行 perf list 来查看。
LSM 接口允许在内核允许某些操作之前检查安全策略。使用 eBPF,你可以将自定义程序附着到相同的检查点,从而实现灵活、动态的安全策略和运行时安全工具的一些新方法。
XDP 允许将 eBPF 程序附着到网络接口,每当收到数据包就会触发 eBPF 执行。XDP 可以检查甚至修改数据包,eBPF 程序的退出代码用于通知内核如何处理该数据包:传递、丢弃或重定向。这可以构成一些非常有效的网络功能的基础。
你也可附着 eBPF 程序以在程序打开在网络套接字上执行操作、以及在发送或接收消息时运行 eBPF 程序。在内核的网络栈中还有称为 traffic control 或 tc 的 Hook,eBPF 程序可以在初始数据包处理后运行。
有些功能可以单独使用 eBPF 程序来实现,但在许多情况下,我们希望 eBPF 代码从用户空间应用程序接收信息或将数据传递给用户空间应用程序。允许数据在 eBPF 程序和用户空间之间或不同 eBPF 程序之间传递的机制称为 map。
Map 功能的添加是 eBPF 首字母缩写词中 e 代表扩展的显著标志之一,这是因为 map 机制的引入将 eBPF 从纯粹的内核运行扩展到了用户空间,而且 map 可以实现内核空间与 eBPF 程序的双向数据通信,这为 eBPF 功能的扩展提供了简单可行的机制。
Map 是 eBPF 程序定义的数据结构。Map 有多种不同类型的,但本质上都是键值存储。eBPF 程序及用户空间代码可以读取和写入数据。Map 的常见用途包括:
由于内核中的 eBPF 程序与用户空间代码通过 map 通信,那么如果 eBPF 程序与用户空间代码采用不同语言编写的时候,就需要注意 map 数据结构共识的问题,这本质是与我们常见的服务端和客户端代码协议约定一致。
我们已经讨论了 eBPF 工具的主要组成部分:在内核中运行的 eBPF 程序、加载程序并与之交互的用户空间代码,以及允许程序共享数据的 map。为了更好理解,我们通过一个样例进行演示。
Opensnoop 工具可用于显示进程打开的文件详情,原始版本是 Brendan Gregg 最初在 BCC 项目中使用 Python 编写的。当前,为了更加轻量分发和跨内核版本可移植性,该工具基于 libbpf 进行了重写,本例子中,作者展示的是 libbpf-tools 目录下的较新版本,需要特别注意。
当运行 opensnoop 效果大体如下:
PID COMM FD ERR PATH
93965 cat 3 0 /etc/ld.so.cache
93965 cat 3 0 /lib/x86_64-linux-gnu/libc.so.6
93965 cat 3 0 /usr/lib/locale/locale-archive
93965 cat 3 0 /usr/share/locale/locale.alias
...
每行输出表明一个进程打开(或试图打开)一个文件。这些列显示进程 ID、正在运行的命令、文件描述符、任何错误代码以及正在打开的文件的路径。
Opensnoop 通过将 eBPF 程序附着到 open() 和 openat() 系统调用上工作,任何应用程序都必须进行这些调用以请求内核打开文件。
eBPF 代码是用 C 语言编写的,位于文件 opensnoop.bpf.c[2] 中。在该文件头部,你可以看到两个 eBPF map 的定义 start 和 event(通过支持 BTF 方式定义):
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, 10240);
__type(key, u32);
__type(value, struct args_t);
} start SEC(".maps");
struct {
__uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY);
__uint(key_size, sizeof(u32));
__uint(value_size, sizeof(u32));
} events SEC(".maps");
当创建 ELF 目标文件时,它包含每个 map 和要加载到内核中的每个程序的区块(section),通过 SEC() 宏定义。
start map 用于在处理系统调用时临时存储系统调用的参数(包括打开文件的名称)。events map 用于将事件信息从内核中的 eBPF 代码传递到用户空间可执行文件,如图 3-2 所示。
图 3-2 调用 open() 会触发 eBPF 程序,然后将数据保存在 opensnoop 的 eBPF map 中
open 与 openat 处理的类似,这里只展示 open 相关的内容:
SEC("tracepoint/syscalls/sys_enter_open")
int tracepoint__syscalls__sys_enter_open(struct trace_event_raw_sys_enter* ctx)
{
u64 id = bpf_get_current_pid_tgid();
/* use kernel terminology here for tgid/pid: */
u32 tgid = id >> 32;
u32 pid = id;
/* store arg info for later lookup */
if (trace_allowed(tgid, pid)) {
struct args_t args = {};
args.fname = (const char *)ctx->args[0];
args.flags = (int)ctx->args[1];
bpf_map_update_elem(&start, &pid, &args, 0);
}
return 0;
}
如你所见,函数采用指向 trace_event_raw_sys_enter 的结构的指针参数,该结构定义可在内核生成的 vmlinux 头文件中找到。
函数使用 BPF 辅助函数来获取到请求系统调用的进程的 ID:
u64 id = bpf_get_current_pid_tgid();
后续代码得到了文件名和传递给系统调用的标志,并把它们放在一个叫做 args 的结构中:
args.fname = (const char *)ctx->args[0];
args.flags = (int)ctx->args[1];
该结构使用当前程序 ID 作为 key 保存到 start map 中。
bpf_map_update_elem(&start, &pid, &args, 0);
这就是 eBPF 程序在进入系统调用时所做的全部。但是在 opensnoop.bpf.c 中定义的另一对 eBPF 程序会在系统调用退出时触发:
SEC("tracepoint/syscalls/sys_exit_open")
int tracepoint__syscalls__sys_exit_open
你有没有注意到 eBPF 程序调用的所有函数都以静态 always_inline 为前缀?这指示编译器将函数的指令内联,这是因为在旧版本内核中,不允许 BPF 程序跳转到单独的函数。较新的内核和 LLVM 版本中已经可以支持非内联函数调用,但是设置 always_inline 可确保 BPF 验证器保能够更好工作。(现在还有 BPF 尾调用的概念,即执行从一个 BPF 程序跳转到另一个。)
在 trace_exit() 函数中创建一个空的事件结构:
struct event event = {};
这将填充有关即将结束的 open/openat 系统调用的信息,并通过 event map 发送到用户空间。
在开始的 start hash_map 中应该有一个对应于当前进程 ID 的条目:
ap = bpf_map_lookup_elem(&start, &pid);
这包含有关在 sys_enter_open(at) 调用期间先前写入的文件名和 flags 的信息。flags 字段是一个直接存储在结构体中的整数,所以直接从结构体中读取就可以了:
event.flags = ap->flags;
相反,文件名被写入用户空间内存中的一些字节数,验证器需要确保此 eBPF 程序从内存中的该位置读取该字节数是安全的。这是使用另一个辅助函数 bpf_probe_read_user_str() 完成的:
bpf_probe_read_user_str(&event.fname, sizeof(event.fname),
ap->fname);
当前的命令行名称(即进行 open(at) 系统调用的可执行文件的名称)也被复制到事件结构中,使用另一个 BPF 辅助函数:
bpf_get_current_comm(&event.comm, sizeof(event.comm));
event 结构被写入事件 perf 缓冲区 map :
bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU,
&event, sizeof(event));
用户空间代码从该 map 中读取事件信息。在我们讨论这个问题之前,让我们简要地看一下 Makefile。
当构建 eBPF 代码时,你会得到一个包含 eBPF 程序和 map 的二进制定义的目标文件。你还需要额外的一个用户空间可执行文件,它将这些程序和 map 加载到内核中,并充当用户接口。让我们看看构建 opensnoop 的 Makefile,是如何创建 eBPF 目标文件和可执行文件的。
我们正在查看的 opensnoop 示例是诸多样例工具之一,样例工具共同使用一个 Makefile 构建,你可以在 libbpf-tools 目录中找到该 Makefile。此文件中的所有内容并非都特别令人感兴趣,但我想强调一些规则。第一个是使用 bpf.c 文件并使用 clang 编译器创建 BPF 目标对象文件的规则:
$(OUTPUT)/%.bpf.o: %.bpf.c $(LIBBPF_OBJ) $(wildcard %.h) $(AR.. $(call msg,BPF,$@)
$(Q)$(CLANG) $(CFLAGS) -target bpf -D__TARGET_ARCH_$(ARCH) \
-I$(ARCH)/ $(INCLUDES) -c $(filter %.c,$^) -o $@ && \
$(LLVM_STRIP) -g $@
因此,opensnoop.bpf.c 被编译成 $(OUTPUT)/open snoop.bpf.o
。这个对象文件包含将被载入内核的 eBPF 程序和 map。
另一条规则使用 bpftool gen skeleton
从 bpf.o 对象文件中包含的映射和程序定义创建一个脚手架头文件。
因此,opensnoop.bpf.c 被编译成 $(OUTPUT)/open snoop.bpf.o
。该目标文件包含将加载到内核中的 eBPF 程序和 map。
另一个规则使用 bpftool gen 脚手架从该 bpf.o 目标文件中包含的 map 和程序定义创建脚手架头文件:
$(OUTPUT)/%.skel.h: $(OUTPUT)/%.bpf.o | $(OUTPUT) $(call msg,GEN-SKEL,$@)
$(Q)$(BPFTOOL) gen skeleton $< > $@
opensnoop.c 用户空间代码包含这个 opensnoop.skel.h 头文件,以获取它与内核中的 eBPF 程序共享的 map 的定义。这允许用户空间和内核代码了解存储在这些 map 中的数据结构的布局。
以下规则将用户空间代码从 opensnoop.c 编译成一个名为 $(OUTPUT)/opensnoop.o 的二进制对象:
$(OUTPUT)/%.o: %.c $(wildcard %.h) $(LIBBPF_OBJ) | $(OUTPUT) $(call msg,CC,$@)
$(Q)$(CC) $(CFLAGS) $(INCLUDES) -c $(filter %.c,$^) -o $@
最后,有一个规则使用 cc 将用户空间应用程序对象(在我们的例子中,opensnoop.o)链接到一组可执行文件中:
$(APPS): %: $(OUTPUT)/%.o $(LIBBPF_OBJ) $(COMMON_OBJ) | $(OUT... $(call msg,BINARY,$@)
$(Q)$ (CC) $(CFLAGS) $^ $(LDFLAGS) -lelf -lz -o $@
现在你已经了解了如何分别生成 eBPF 和用户空间程序,接着让我们看看对应的用户空间代码。
用户空间代码在 opensnoop.c 文件中。文件的前半部分有 #include
指令(其中一个是自动生成的 opensnoop.skel.h 文件)、各种定义以及处理不同命令行选项的代码,我们不会在这里详述。让我们也略过诸如 print_event() 之类的函数,它将有关事件的信息写入屏幕。从 eBPF 的角度来看,所有有趣的代码都在 main() 函数中。
你将看到诸如 opensnoop__bpf_open()
、open snoop__bpf_load()
和 opensnoop__bpf_attach()
之类的函数。这些都是在 bpftool gen 脚手架创建的自动生成代码中定义的。自动生成的代码处理在 eBPF 目标文件中定义的所有单独的 eBPF 程序、map 和附着点。
一旦 opensnoop 启动并运行,其工作就是监听事件 perf 缓冲区并将事件中包含的信息写入屏幕。首先,它打开与 perf 缓冲区关联的文件描述符,并将 handle_event() 设置为新事件到达时的调用的函数:
pb = perf_buffer__new(bpf_map__fd(obj->maps.events),
PERF_BUFFER_PAGES, handle_event, handle_lost_events,
NULL, NULL);
然后程序轮询缓冲区事件,直到达到时间限制,或者用户中断程序:
while (!exiting) {
err = perf_buffer__poll(pb, PERF_POLL_TIMEOUT_MS);
...
}
传递给 handle_event() 的数据参数指向 eBPF 程序为该事件写入 map 的事件结构。用户空间代码可以获取此信息,对其进行格式化并供用户查看。
正如你所见,opensnoop 注册了 eBPF 程序,每次任何应用程序调用 open() 或 openat() 系统调用时都会调用 eBPF 程序。这些在内核中运行的 eBPF 程序收集有关该系统调用的上下文的信息——可执行文件名称和进程 ID——以及有关正在打开的文件的信息。此信息被写入 map,用户空间可以从中读取它并将其显示给用户。
系统调用是一个稳定的内核接口,它们提供了一种非常强大的方式来观察(虚拟)机器上发生的事情。除系统调用之外,还有很多其他稳定的接口用于附着 eBPF 程序,包括 LSM 和网络栈中的各个挂载点。如果愿意冒险或解决内核版本之间的更改,那么你可以附着 eBPF 程序的地方范围将会非常广泛。
现在你已经看到了一个 eBPF 编程示例,并了解它是如何工作的。虽然基本示例可以使 eBPF 看起来相对简单,但也有一些复杂性使其具有挑战性。以往编写和分发 eBPF 程序相对困难的一个领域是内核兼容性。
eBPF 程序可以访问内核数据结构,这些数据结构可能会随着不同的内核版本而改变。结构本身是在构成 Linux 源代码一部分的头文件中定义的。过往,你必须针对与运行这些程序的内核兼容的正确头文件集来编译 eBPF 程序。
为了解决跨内核的可移植性,BCC)(BPF Compiler Collection)项目采用了在运行时在目标机器上编译 eBPF 代码的方法。这意味着编译工具链需要安装到代码运行的每台目标机器上,并且你必须等待编译完成才能启动工具。你还必须保证内核头文件存在于文件系统上(但并非总是如此)。接着我们进入 BPF CO-RE。
CO-RE(一次编译,到处运行)方法包含几个元素:
BTF (BPF Type Format)
这是一种表达数据结构和函数签名布局的格式。现代 Linux 内核支持 BTF,因此你可以从正在运行的系统生成一个名为 vmlinux.h 的头文件,其中包含 BPF 程序可能需要的有关内核的所有数据结构信息。
libbpf, BPF 库
一方面,libbpf 提供了加载 eBPF 程序和 map 到内核的函数。但它在可移植性方面也起着重要作用:依据 BTF 信息来调整 eBPF 代码,以补偿编译时存在的数据结构与目标机器上的数据结构之间的任何差异。
编译器支持
clang 编译器得到了增强,因此当它编译 eBPF 程序时,它包括所谓的 BTF 重定向(relocations),这是 libbpf 在加载 BPF 程序和 map 到内核时用来知道要调整哪些内容。
可选的 BPF 脚手架
可以使用 bpftool gen skeleton 从编译的 BPF 目标文件中自动生成脚手架,其中包含辅助函数,用户空间代码可以调用这些辅助函数来管理 BPF 程序的生命周期——将其加载到内核中,附着到事件等等。这些函数是更高级别的抽象,对开发人员来说比直接使用 libbpf 更方便。
有关 CO-RE 的更详细说明,请阅读 Andrii Nakryiko 详细的讲解[3]。
vmlinux 文件形式的 BTF 信息自 5.4 版起已包含在 Linux 内核中, 但也可以为较旧的内核生成 libbpf 可以使用的原始 BTF 数据。BTF Hub[4] 上有关于如何生成 BTF 文件的信息,以及用于各种 Linux 发行版的文件存档。
BPF CO-RE 方法使 eBPF 程序员比过去更容易让他们的代码在任何 Linux 发行版上运行——或者至少在任何新的 Linux 发行版上运行,以支持程序使用的任何 eBPF 功能集。但这并不能使 eBPF 编程轻松自如:其本质上仍然是内核编程。
很快你会认识到,你仍然需要关于 Linux 内核的领域知识才能编写更高级的工具。你需要了解可访问 eBPF 代码的上下文相关的数据结构。并非每个应用程序开发人员都有解析网络数据包、访问套接字缓冲区或处理系统调用参数的经验。
内核将如何响应 eBPF 代码的行为?正如你在第 2 章中所了解的,内核由数百万行代码组成。它的文档可能很少,因此你可能会发现自己必须阅读内核源代码才能弄清楚某些东西是如何工作的。
你还需要弄清楚 eBPF 代码应该附着到哪些事件上。如果可以选择将 kprobe 附着到整个内核中的任何函数入口点,这可能不是一个容易的决定。在某些情况下,这很简单,例如你想访问传入的网络数据包,那么在合适的网络接口进行 XDP 挂钩是一个明显的选择。如果你想提供对特定内核事件的可观察性,在内核代码中找到适当的附着点可能并不难。
但在其他情况下,选择可能并不明确。例如,仅使用 kprobes 挂钩构成内核系统调用接口的函数的工具可能会受到称为 time-of-check to time-of-use (TOCTTOU) 的安全漏洞的影响。攻击者有一个很小的机会窗口,他们可以在 eBPF 代码读取参数之后,但在被复制到内核内存之前更改系统调用的参数。Rex Guo 和 Junyuan Zeng 在 DEF CON 29 上对此进行了精彩的演示[5]。一些最广泛使用的 eBPF 工具是以非常初级的方式编写的,并且容易受到这种攻击。这并不是一个容易的利用,并且有一些方法可以减轻这些攻击,但是如果你要保护高度敏感的数据免受老练的对手的攻击,请深入了解你使用的工具是否会受到影响。
你已经看到了 BPF CO-RE 如何使 eBPF 程序在不同的内核版本上工作,但它只考虑了数据结构布局的变化,而不考虑内核行为的更广泛的变化。例如,如果想将 eBPF 程序附着到内核中的特定函数或跟踪点,你可能需要一个计划 B,如果该函数或跟踪点不存在于不同的内核版本中该怎么办。
现在可用的许多基于 eBPF 的工具都提供了一套可观察性功能,通过将 eBPF 程序挂钩到一组内核事件来实现。其中大部分是由 Brendan Gregg 和其他人在 BCC 和 bpftrace 工具中所研发。当前工具(通常是商业的)可能会提供更漂亮的图形和 UI,但他们利用的 eBPF 程序高度基于这些原来的工具。
当你想要编写协调不同类型事件之间交互的代码时,事情会变得相当复杂。例如,Cilium 通过内核的网络栈在多个点查看网络数据包,并根据来自 Kubernetes CNI(容器网络接口)的有关 Kubernetes Pod 的信息来处理流量。构建这个系统需要 Cilium 开发人员深入了解内核如何处理网络流量,以及 “Pod” 和 “Container” 的用户空间概念如何映射到 cgroup 和 namespace 等内核概念。在实践中,几个 Cilium 维护者也是内核开发人员,致力于增强 eBPF 和网络支持。
底线是,尽管 eBPF 提供了一个非常高效和强大的平台来连接内核,但对于没有丰富内核经验的普通开发人员来说,这不是一件容易的事。如果你有兴趣亲身体验 eBPF 编程,我强烈建议你将其作为练习学习;在这一领域积累经验可能非常有价值,因为它必将在未来几年继续成为广受欢迎的专业技能。但实际上,大多数组织不太可能在内部构建大量定制的 eBPF 工具,而是会利用来自专业 eBPF 社区的项目和产品。
让我们继续思考为什么这些基于 eBPF 的项目和产品在云原生环境中能够展示强大的能力。
Pixie: https://blog.px.dev/ebpf-http-tracing/
[2]opensnoop.bpf.c: https://github.com/iovisor/bcc/blob/master/libbpf-tools/opensnoop.bpf.c
[3]讲解: https://nakryiko.com/posts/bpf-portability-and-co-re/
[4]BTF Hub: https://github.com/aquasecurity/btfhub
[5]演示: https://www.youtube.com/watch?v=yaAdM8pWKG8