Towards truly portable eBPF

March 10, 2022

在上一篇 eBPF Loader 中介绍了 eBPF 加载器的工作原理。Compile-Once Run-Everywhere (CO-RE) 是目前社区的发力方向,但它要求内核版本支持 CONFIG_DEBUG_INFO_BTF=y 特性。除了 REHL 等商业公司会回合高版本特性到当前支持的商业版本之外,大部分社区免费版本都要求 >= 5.5 版本的内核。为了能在当前主流的 4.14, 4.15, 4.18, 4.19 内核版本上支持 eBPF CO-RE 特性, Aqua Security 的工程师在 2021 Linux Plumbers Conference - Towards truly portable eBPF 议题上展示了它们的想法: BTF-Hub + Embedded BTF。

1. BPF Portable

作为用户提供的程序片段,eBPF bytecode 可直接注入到 linux 内核里直接读取和操作内核运行态的内存数据,它的可观测性和对内核模块的可拓展性受到系统开发者的青睐。这是它强大的优势,但同时也是一大痛点:只有获得了目标内核版本的头文件才能保证 eBPF 程序能正确地访问内存数据。

早期在使用 bcc 诊断工具时,我们需要 llvm/clang 做 eBPF 实时编译;如果开发 - 测试 - 线上环境没有做到版本的强一致,那么即使逃过了 BPF Verifier 的审判,程序也会因为没有正确地读取内存 (偏移地址不正确) 而出现非预期的行为。

shall-not-pass

虽然 eBPF 程序的非预期行为不会导致内核崩溃,但研发体验和内核模块开发差别不大:eBPF 开发者还是需要针对不同的内核版本编译出不同的版本,线上内核版本越多,测试上线的成本就越高。下图为 Falco 为不同版本提供的内核模块;如果 eBPF 没有移植能力的话,它的发布模式其实和普通的内核模块差别不大。一次构建,到处运行 的能力直到 BPF Type Format (BTF) 的出现才有了质的飞跃。

falco-driver-ko

1.1 BTF

BTF 描述了程序所需要的数据结构信息,包括结构体大小名字,字段类型和偏移量等等。它一般通过 pahole 将 DWARF 调试信息转化得到,其压缩比非常高,一个常用内核的 BTF 仅需要 1-5 MB。正是因为它压缩比高、易携带,社区通过 CONFIG_DEBUG_INFO_BTF=y 编译选项来决定将其注入到 vmlinux 上,携带该信息的内核将通过 /sys/kernel/btf/vmlinux 路径作为外部获取的接口。

dwarf-vs-btf

BTF 同 DWARF 一样,我们可以根据 BTF 信息来生成了头文件。对于开发者而言,BTF 改善体验的第一件事就是仅需要一个头文件就可以拿到内核所使用的所有结构体描述。

$ bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h

但仅靠 BTF 还是无法保证 eBPF 程序能在目标内核上正确地获取内存地址,只能通过修正、校对现有指令才行。

1.2 preserve_access_index

以 Tracing 为例,eBPF 程序会经常使用进程元数据 task_struct,内核提供了 bpf_get_current_task 接口来获取当前上下文的进程信息,这个接口的返回值将直接指向内核运行态地址。从安全角度考虑,eBPF 访问内核空间的地址必须要走 bpf_probe_read_kernel 调用来完成。比如,我们想获取当前上下文的父进程 pid, 那么我们是无法直接通过 task->real_parent->pid 来读取;当前 libbpf 库为我们提供了 BPF_PROBE_READ 宏来读取父进程的 pid

probe-read-bytecode

BPF_PROBE_READ 宏展开后的编译结果显示两次 task->real_parent, real_parent->pid 读取,每次都是基于 vmlinux_508.h 头文件 (内核 v5.8) 的偏移量来读取。这和我本地的目标内核 (v5.13) 偏移量存在差异;即使程序不报错,它也不是按照预期来进行。为了解决这个问题,社区在编译器上做了优化。

libbpf 提供了新的宏 BPF_CORE_READ,它使用 __builtin_preserve_access_index 包住被读取的内核空间地址,比如 task->real_parent, real_parent->pid,那么在编译阶段会将访问路径 0:570:54 作为 relocation 的符号保存在 .BTF.ext section 里,其中 5754 分别是 real_parentpid 在 v5.8 内核 task_struct 结构体里的第 57 和 54 个字段。加载器会根据访问路径来比较源和当前内核对应数据结构的差异。当找到匹配对象后,那么将会以当前内核的偏移地址来修改原先的指令,保证程序可以正确运行;否则将会加载失败。

relo-access-bytecode

早期 preserve_access_index 是配合 BPF_CORE_READ 来使用,现在直接将这个拓展放到每一个内核结构上,保证编译期间能注入 relocation 信息。当然这里仅仅列列出了 field offset relocation,还有 ENUM, BITFIELD 等模式,这些模式仅在结构匹配算法上存在差异,整体还都是先匹配再修改指令,在此就不再赘述。

$ cat vmlinux.h
#ifndef __VMLINUX_H__
#define __VMLINUX_H__

#ifndef BPF_NO_PRESERVE_ACCESS_INDEX
#pragma clang attribute push (__attribute__((preserve_access_index)), apply_to = record)
#endif
...

eBPF 加载器在做指令修改时,它需要当前内核的 BTF 信息。一般来说,这个特性仅在 >= v5.5 内核才支持,还有很多用户在使用 4.x 的内核。因可观测性的需求强行升级内核并不现实,我们需要寻求其他方式来获得 BTF 信息。

2. BTF-Hub and embedded BTF

内核在编译时,它是通过 pahole 将 DWARD 信息转化成 BTF,并在 link-vmlinux 阶段将数据放到 BTF section 中。所以,即使是低内核版本,只要能拿到对应内核的编译调试信息,我们就可以独立转化并得到 BTF 信息,它和 /sys/kernel/btf/vmlinux 读出来的结果一致。eBPF 加载器是在用户态做指令改写,只要 eBPF 加载器能识别到指定的 BTF 信息即可。

Towards truly portable eBPF 议题提出了 BTF-Hub:Aqua Security 工程师将 ubuntu/centos/federo/debian 对外开放的 DWARF 信息转化成对应的 BTF 信息,并配合加载器使用就可以完成 CO-RE 能力了。

customize-btf-path

这个方案解决了低内核版本不支持导出 BTF 的问题。虽然每一个内核版本 BTF 信息不大,但也抗不住小版本的迭代,就目前 BTF-Hub 提供 ubuntu 发行版的可用 BTF 信息就有 200 MB。在演讲者看来它和 falco 驱动的多内核版本构建差别不大,会场主持人也开玩笑说:“如果 github 挂了,我们就获取不到 BTF 数据了”。演讲者提出了比较有意思的想法: Embedded BTF。

Embeded BTF 核心设计就是去除无用的 BTF 信息。举个例子, bcc 提供了 libbpf-tools 38 个 eBPF 程序,但这些程序使用的内核数据结构只是内核 BTF 信息的子集,只要能获取出这个子集就能降低携带 BTF 信息成本,这个提取的方案叫做 BTFGen,已经被 Linux bpf-next 所接受了。下面可以看下我本地使用的效果:

min-core-gen

4.9MB 里只有 3.3KB 数据有用,对于一类 eBPF 程序而言,即使把尽可能多内核版本的 BTF 随身携带也不会带来存储空间压力;当线上内核版本较为统一时,开发者甚至还可以将 BTF 信息放入到容器镜像或者应用程序的二进制里。

对我而言,这个方案的确是让低版本的内核享受到 CO-RE 的好处,甚至会改变 bcc 工具包的发布方式

3. Summary

除了 BTF-Hub 和 Embedded BTF 之外,演讲者还介绍了很多他们遇到的 CO-RE 挑战,实战编程中还是有一定的参考意义,值得一看。