编辑
2023-07-10
eBPF
00
请注意,本文编写于 299 天前,最后修改于 265 天前,其中某些信息可能已经过时。

目录

为什么需要 BPF CO-RE
BPF CO-RE
远离头文件
读取结构体成员
处理结构体问题
判断内核版本
自定义结构体
BTFhub
ebpf-go 的使用心得
如何判读 BTF 文件是否有效
使用自定的 BTF
变量替换
eBPF 程序的debug
如何正确的操作 Map
从 C 结构体到 Go 结构体
参考资料
总结

在最近这一段时间,我们使用 eBPF 实现了一些监控组件,在这过程中因为 eBPF 的一些兼容性遇到不少问题。包括头文件的引入、低版本内核使用 BTF 等,同时我们没用使用 bcc 来作为 eBPF 前端,而是使用了cilium/ebpf(下文简称ebpf-go),它提供了一层 Go 的接口来操作 eBPF 程序,不过因为该项目和 repo 内示例程序较为简单,在实际开发中还是遇到了一些编程上的问题。本篇的主要是围绕着 BPF CO-RE的相关介绍,并且将我们在实际开发中使用 BPF CO-RE 和 ebpf-go相关的问题以及解决办法分享出来。

限于篇幅,本篇文章并不是 eBPF 的入门文章,为了能够彻底理解文章的内容,读者最好有 eBPF、bcc、ebpf-go 的基本使用经验。

为什么需要 BPF CO-RE

虽然本篇的内容是介绍 BPF CO-RE,但是不对ebpf-go的使用方式做简要介绍,就不容易理解为什么需要使用 CO-RE来解决一些痛点问题。ebpf-go的作用就是提供一个可编程的Go 语言接口,提供操作 eBPF 程序的基本函数: 挂载、释放,对 eBPF map进行增删改查等。

它的基本使用方法是让程序员编写好一段纯的 eBPF 程序,ebpf-go会负责对这段程序的翻译,能够让我们使用 Go 进行 eBPF 程序的挂载和 map 数据的处理。下面是一个简单的示例,参考自ebpf-go-exmaple:

go
#include "common.h" char __license[] SEC("license") = "Dual MIT/GPL"; struct bpf_map_def SEC("maps") kprobe_map = { .type = BPF_MAP_TYPE_ARRAY, .key_size = sizeof(u32), .value_size = sizeof(u64), .max_entries = 1, }; SEC("kprobe/sys_execve") int kprobe_execve() { u32 key = 0; u64 initval = 1, *valp; valp = bpf_map_lookup_elem(&kprobe_map, &key); if (!valp) { bpf_map_update_elem(&kprobe_map, &key, &initval, BPF_ANY); return 0; } __sync_fetch_and_add(valp, 1); return 0; }

上面这段程序的插桩点在sys_execve,作用是记录sys_execve被调用的次数。不过SEC("kprobe/sys_execve")只是一个提示性的宏,实际的函数挂载位于下面这段程序。实际使用中需要将上面这段ebpf程序编译为.o文件,然后被下面程序中的loadBpfObjects函数加载到内核,脏活累活 ebpf-go都做完了。//go:generate这行就就是预处理指令,将 C 语言编写的 ebpf 程序编译为.o文件。

go
//go:generate go run github.com/cilium/ebpf/cmd/bpf2go -cc $BPF_CLANG -cflags $BPF_CFLAGS bpf kprobe.c -- -I../headers const mapKey uint32 = 0 func main() { objs := bpfObjects{} if err := loadBpfObjects(&objs, nil); err != nil { log.Fatalf("loading objects: %v", err) } defer objs.Close() // 使用kprobe挂载 kp, err := link.Kprobe(fn, objs.KprobeExecve, nil) if err != nil { log.Fatalf("opening kprobe: %s", err) } defer kp.Close() ticker := time.NewTicker(1 * time.Second) defer ticker.Stop() log.Println("Waiting for events..") for range ticker.C { var value uint64 if err := objs.KprobeMap.Lookup(mapKey, &value); err != nil { log.Fatalf("reading map: %v", err) } log.Printf("%s called %d times\n", fn, value) } }

从这个例子来看,ebpf-go无法动态的在程序运行时进行判断某些条件是否成立。比如说struct task_struct结构体内部的state在这个commit被重命名为了__state,我们很难根据内核版本去判断是否有某个结构体(不过确实libbpf后来也提供了判断内核版本的功能,还有一些别的奇技淫巧也可以做,不过使用BPF CO-RE有更好的实现方式)。甚至如果一个结构体的成员被重命名、被移除那么就会导致在本地开发的 eBPF 程序在线上环境无法使用。ebpf-go的实现是静态的,而不像bcc那样可以在运行时期间进行字符替换。

BPF CO-RE

现如今 eBPF 在很多方便都有它的身影,cilium、skywalking、bpftrace、pixie等等在各个方面上都发挥着作用,不过 eBPF 存在的缺陷是功能随着内核版本的更新而更新,老版本内核无法使用相当多基于 eBPF 的组件,另外一个问题是 eBPF 程序的移植性较不好,这一点已经从上一小节得到了验证,结构体成员的更新、重命名等操作都会让一个 eBPF 到一个新的内核版无法在使用。

BPF CO-RE(Compile Once – Run Everywhere)的目的是能够让 eBPF 程序有更好的可移植性,在多个不同版本的内核之间可以做兼容。除了前面的示例以外,还可能存在结构体成员的偏移量改变、结构体成员被移除、类型被改变等等兼容问题。那么,尽可能的让bpf 程序有更好的兼容性,可以用tracepoint来替代kprobe,不过tracepoint缺陷是可以插桩的函数点相当少,另外一个可行的方法根据目标内核版本在运行时做一些工作,这就是BCC所做的事情,不过BCC依赖于内核的头文件,它需要机器上内核版本所对应的内核头文件是安装的,而且bcc内置了clang/llvm,这无疑也会增加一个程序所需的内存(这一点是十分显而易见的,使用bcc做好的镜像比ebpf-core要大很多),bcc在运行时进行程序的编译,加载bpf程序到内核,所以程序的一点小问题都只能到运行时才能发觉。

CO-RE的目的就是解决上述的问题,CO-RE的实现依赖于BTF(BPF type formation) 它可以认为是面向 eBPF 程序的 debuginfo,文档开宗明义地介绍了它的作用:

BTF (BPF Type Format) is the metadata format which encodes the debug info related to BPF program/map. The name BTF was used initially to describe data types. The BTF was later extended to include function info for defined subroutines, and line info for source/line information.

BTF 在较新的内核版本是默认自带的,否则的话需要手动的指定CONFIG_DEBUG_INFO_BTF=y,在某些线上的低版本内核当中没有BTF支持那么使用CO-RE就相当棘手,在后文我们将会介绍如何在低版本内核使用BTF。除此以外,CO-RE的实现来依赖于Clang提供的一些结构体重定位等辅助信息,libbpf会将btf与clang所提供的信息相结合完成整个重定位的过程。限于知识水平,对于这些更加底层的内容了解的很少,不过多展开

本小节的不少例子都来自于这篇博客,作者是CO-RE项目的核心开发者,这里只是挑选了部分内容作为示例。

远离头文件

如果有过编写bcc脚本的经验,就会有我要使用的结构体到底在哪个头文件这种问题。如果内核有 BTF 的支持1,那么可以根据当前内核版本生成一个囊括了所有结构体的头文件,不过它没有所需要的宏,大部分宏的取值直接参考内核头文件手动编写。生成头文件的命令如下2:

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

1: BTF 应该是从linux 5.4开始默认支持的,不过可以要验证当前内核是否具有BTF支持只需要查看/sys/kernel/btf路径是否存在。

2: 该命令对 bpftool 的版本有要求,通过 apt 下载的 bpftool 可能版本较低,会提示 failed to load BTF from /sys/kernel/btf/vmlinux: Unknown error -4001这样类似的错误,最好的方法是手动编译,参考bpftool repo

3: 对于没有 BTF 支持的内核如果使用vmlinux.h程序将无法通过编译。

读取结构体成员

在bcc当中访问结构体的方法就如同普通的C语言程序那样,如下示例程序是bcc程序访问结构体的方法:

c
pid_t pid = task->pid;

实际上,bcc对这段程序进行改写,设置BPF(debug=4)可以看到被bcc重写过后的代码,这会给人一种误导,误以为实际的这种形式就是正确的结构访问。bcc使用了bpf_probe_read函数对上面这行代码进行了改写,示例程序改写过后的代码如下:

c
pid_t pid = ({ typeof(pid_t) _val; __builtin_memset(&_val, 0, sizeof(_val)); bpf_probe_read(&_val, sizeof(_val), (u64) &task->pid); _val; });

Note: 对于bcc其他相关 debug 参数参考bcc reference-guide

BPF CO-RE引入了一个新的函数bpf_core_read,用法与bpf_probe_read,只不过它会针对内核的版本做兼容性的调整(比如说pid在不同的内核版本所处的偏移量不同)。该函数只是一个宏,它的内部还是调用了bpf_probe_read,下面是示例:

c
pid_t pid; bpf_core_read(&pid, sizeof(pid), &task->pid);

该函数的源码如下,位于bpf_core_read.h如果本机上没有该头文件,需要手动安装libbpf

c
#define bpf_core_read(dst, sz, src) \ bpf_probe_read_kernel(dst, sz, (const void *)__builtin_preserve_access_index(src))

不过无论是使用原生的bpf helper系列函数,还是使用bpf_core_read,面对链式读取的场景就显得十分麻烦。如将 eBPF 程序挂载到vfs_open函数,读取被打开的文件名,使用原生的 API 程序如下:

c
SEC("kprobe/vfs_open") int kprobe_func(struct pt_regs *ctx) { u64 pid = bpf_get_current_pid_tgid(); struct path *p = (struct path*)PT_REGS_PARM1(ctx); struct dentry *de; bpf_probe_read_kernel(&de,sizeof(void*),&p->dentry); struct qstr d_name; bpf_probe_read_kernel(&d_name,sizeof(d_name),&de->d_name); char filename[32]; bpf_probe_read_kernel(&filename,sizeof(filename),d_name.name); if (d_name.len == 0) return 0; char fmt_str[] = "path:%s"; bpf_trace_printk(fmt_str,sizeof(fmt_str),filename); return 0; };

可以看到大部分的程序篇幅都在调用bpf_probe_read_kernel,CO-RE 提供了一个宏,对这个过程进一步地封装,那么这个程序就可以简化为:

c
SEC("kprobe/vfs_open") int BPF_KPROBE(vfs_open, const struct path *path, struct file *file) { pid_t pid; pid = bpf_get_current_pid_tgid() >> 32; const unsigned char *filename; // 一行语句就实现了链式的读取 filename = BPF_CORE_READ(path,dentry,d_name.name); bpf_printk("KPROBE ENTRY pid = %d, filename = %s\n", pid, filename); return 0; }

相类似的还有一些宏BPF_CORE_READ_STR_INTO,BPF_CORE_READ_INTO大差不差,看博客里边的讲解就行。值得一提的是,最近的内核版本中新增的

一种 eBPF程序类型:BPF_PROG_TYPE_TRACING,它支持如同c语言语法那样直接访问结构体成员。

处理结构体问题

回到最开始的例子,我们提到过struct task_struct内的state成员新一点的内核版本已经被重命名为__state(在21年被合并进了内核),那么如何编写一个使用到该成员的程序并且能够在不同的内核版本兼容就是一个值得关注的问题。BPF CO-RE提供了ignored suffix rule的功能,它的作用是对于任何的符号只要包含着三下划线,那么下划线以及它所有的字符都会被忽略。我们定义一个struct task_struct___my_own对于 BPF CO-RE而言,这完全地等价于struct task_struct。这是一个十分重要的特性意味着我们可以通过定义不同类型的结构体来处理不同版本的内核兼容问题,示例如下:

c
struct task_struct { pid_t pid; int __state; } __attribute__((preserve_access_index)); // 兼容老版本内核 struct task_struct___old { pid_t pid; long state; } __attribute__((preserve_access_index));

Note: 在后文会对__attribute__((preserve_access_index))介绍。

我们定义了两个结构体分别作用于新版本与老版本的内核,在结合BPF CO-RE提供的另外一个函数bpf_core_field_exists用于判断某个结构体内是否具有某个成员,如下语句就就很好的处理了兼容问题:

c
struct task_struct *prev = (struct task_struct*)PT_REGS_PARM1(ctx); unsigned int state; if (bpf_core_field_exists(prev->__state)) { state = BPF_CORE_READ(prev, __state); } else { // ___old 这几个字符会被 libbpf 忽略,它实际等同于struct task_struct // 这就解决了不同版本的内核兼容性问题。 struct task_struct___old *t_old = (void *)prev; state = BPF_CORE_READ(t_old, state); }

我们使用ebpf-go重写了bcc-runqlat就用到了该特性,很好地解决了我们线上集群当中内核版本较低不兼容问题。

判断内核版本

另外一种解决办法是手动判断内核版本,针对不同的内核版本做处理。在BPF CO-RE 之前据我所知没有这方便的支持,不过该功能没有实际的在项目中使用,在此只是抛砖引玉地做介绍。BPF CO-RE 提供了extern Kconfig variables, 能够读取一些位于/proc/config.gz内的kconfig 配置并且在ebpf程序当中使用,如下的示例读取了内核版本信息,那么也可以根据不同的内核版本做对应的处理,遗憾的是目前 ebpf-go 不支持这一功能。

c
extern int LINUX_KERNEL_VERSION __kconfig; if (LINUX_KERNEL_VERSION > KERNEL_VERSION(5, 15, 0)) { /* we are on v5.15+ */ }

Note:

该功能与ebpf-go的结合并不是很好,会出现如下错误:

can't load BPF from ELF: load BTF: reference to .kconfig: not supported

issue有人反馈了该问题,不过社区一直没有支持,有一个最近的PR提交对LINUX_KERNEL_VERSION支持,cilium社区对应支持kconfig的意见是它的移植性不好,因为在debian系列的内核中没有/proc/config.gz文件,而是位于/boot/config-$(uname-r),可以参考这issue

自定义结构体

在前文-远离头文件-一小节介绍过有BTF支持下的内核可以产生一个大型头文件囊括了所有内核数据结构,问题是在低版本机器上都没有 BTF 支持,那么如何使用各种头文件呢? 最笨的方法是从所需的内核源码中逐个复制结构体,然而内核结构体往往层级嵌套很深,这种方法不切实际。另外一种方法是直接下载当前内核版本所对应的头文件然后手动的链接(bcc就是这种做法),不过在实践中我们发现手动的链接无法通过编译。

这一切有了 BPF CO-RE都迎刃而解,无论我们需要什么结构体,只要秉持着我要什么,就定义什么,BPF CO-RE 会处理好各个结构体的重定位。以获取vfs_read的文件名为例,vfs_read源码第一个参数struct file包含着文件名,路径为struct file -> struct path -> struct dentry -> struct qstr ,只需要使用一个编译器的 attribute 就可以实现要啥就定义啥,结构体的源码定义如下:

c
struct qstr { union { struct { u32 hash; u32 len; }; u64 hash_len; }; const unsigned char *name; } struct dentry { struct qstr d_name; }__attribute__((preserve_access_index)); struct path { struct dentry *dentry; }__attribute__((preserve_access_index)); struct file { struct path f_path; }__attribute__((preserve_access_index));

这一切所有的魔法都在于__attribute__((preserve_access_index)),该attribute解释可以参考文档,简单来说它让编译器期间保留了被修饰的结构体的debuginfo,让 BPF CO-RE 可以完成对所需结构体的重定位,这也依赖于 BTF 的支持。实际上对于BPF_CORE_READ这些宏调用来说,自己定义的结构体也不需要使用这个attribute,这个宏的源码实际调用的是bpf_core_read函数,而它的源码中使用这个特性,如下:

c
#define bpf_core_read(dst, sz, src) \ bpf_probe_read_kernel(dst, sz, (const void *)__builtin_preserve_access_index(src))

一个十分hack的技巧:

我们知道CPU访问结构体成员的方式就是简单的首地址+offset,而BPF CO-RE 的实现上也是记录所需结构体成员偏移量是多少,也因此只需定义我们关注的结构体成员加上__attribute__((preserve_access_index));就可以工作。那么很自然的,如果没有这个attribuet,我们认为地在结构体内进行字节的填充,也可以达到类似的效果,上一小节的struct qstr 我们其实只关注的它的*name,前面8个字节并不重要,摇身一变可以使用手工地填充8个字节达到相同的效果,如下:

c
struct qstr { char padding[8]; // 人为填充 8 个字节 const unsigned char *name; };

BTFhub

归根到底,BPF CO-RE 十分依赖于 BTF,而老版本的内核甚至一些5.x版本内核在build的时候如果没有指定CONFIG_DEBUG_BTF=y内核都没有 BTF 的支持。BTFhub的出现解决了这个问题,除此以外它还能够对 BTF 文件进行剪裁,让它只包含我们所需要的结构体所相关的debuginfo。btfhub的使用也相当简单,参考文档。它还依赖于BTF archive,该 repo 就是将很多低版本内核的 BTF 文件制作好了,使用btfhub/tools/btfgen.sh 脚本将这些 BTF 文件按需裁剪,命令如下:

shell
./tools/btfgen.sh -a x86_64 -o foo.o

foo.o就是C语言编写的 eBPF 程序经过//go genrate预处理指令编译过后的.o文件,裁剪过后的btf文件会位于btfhub/custom-archive目录内。值得一提的是,repo中的btfgen.sh会将btfarchive仓库内所有的 BTF 都进行裁剪,这个过程十分耗时,我们对 btfgen.sh 做了一点修改,可以指定发行版来裁剪 BTF 文件加快这个过程,实现思路就是在脚本内判断此时被裁剪的BTF路径是否包含我们所期望的发行版,关键代码如下:

shell
# 使用方法: /tools/btfgen.sh -a x86_64 -r debian -o foo.o while getopts ":a:o:r:" opt; do case "${opt}" in a) a=${OPTARG} [[ "${a}" != "x86_64" && "${a}" != "arm64" ]] && usage ;; o) [[ ! -f ${OPTARG} ]] && { echo "error: could not find bpf object: ${OPTARG}"; usage; } o+=("${OPTARG}") ;; r) # 给 btfgen.sh 新增加了一个 echo "target release: ${OPTARG}" r=${OPTARG} ;; *) usage ;; esac done # 省略一些代码 # $file 是 btf 全文件名,只要判断它是否包含所期望的发行版名称即可 if [[ ! $file =~ "${r}" ]];then continue fi # 省略部分代码

值得一提的是,实际开发中制作 BTF 文件可以一次性的传入多个.o文件,也就是:btfgen.sh -a x86_64 -r debian -o foo.o -o bar.o。对于btfgen的原理参考这篇文档

ebpf-go 的使用心得

虽然本篇文章的主要目的是介绍如何使用 BPF CO-RE,不过我仍然认为在开发 eBPF 监控组件过程中遇到的一些问题是有借鉴意义,另外由于 ebpf-go 的文档缺失也不可避免的带来一些学习上的成本。

如何判读 BTF 文件是否有效

使用 BPF hub 生成的 btf 文件验证,可以使用bpftool btf dump 命令来验证,示例如下:

shell
$ bpftool btf dump file 4.19.0-21-amd64.btf # 省略一些内容 [7] STRUCT 'task_struct' size=7104 vlen=3 'state' type_id=4 bits_offset=128 'pid' type_id=6 bits_offset=9792 'nsproxy' type_id=8 bits_offset=13824 [8] PTR '(anon)' type_id=9 [9] STRUCT 'nsproxy' size=56 vlen=1 'mnt_ns' type_id=10 bits_offset=192 [10] PTR '(anon)' type_id=12 [11] STRUCT 'ns_common' size=24 vlen=1 'inum' type_id=1 bits_offset=128 [12] STRUCT 'mnt_namespace' size=120 vlen=1 'ns' type_id=11 bits_offset=64

我实际的 eBPF 程序当中所定义的 struct task_struct只包含了state,pid,nsproxy三个结构体这也在上面的数据得到了反映。

使用自定的 BTF

前文介绍的 BPF hub 能为低版本的内核生成对应的 BTF 文件,不过对于如何在 ebpf-go 当中使用自定的 BTF 文件文档中并没有提及。在ebpfgo源码的prog.go内有相关的信息,ProgramOptions结构体内的KernelTypes就是用于传递自定 BTF 相关的内容。测试代码prog_test.go展示了用法。总结一下在实际开发中,如下这段代码就是标准的加载自定的 BTF 过程:

go
spec, err := loadBpf() if err != nil { log.Fatalf("loading BPF error %v", err) } var options *ebpf.CollectionOptions // 自定的 BTF 文件路径吗,由 BTFHub 内的工具制作而成 btfSpec, err := btf.LoadSpec("/root/4.19.0-18-amd64.btf") if err != nil { log.Errorln(err) return } options = &ebpf.CollectionOptions{Programs: ebpf.ProgramOptions{KernelTypes: btfSpec}} if err = spec.LoadAndAssign(&objs, options); err != nil { log.Fatalf("loading objects error %v", err) } defer objs.Close()

Note: 虽然未找到官方的文档描述,不过实际开发中发现可以使用vmlinux.h+自定义 BTF 文件的实现所有的结构结构体引用,不在需要自己手动的定义结构体。

变量替换

对于所监控的指标,我们往往期望它是更加动态的,可以在运行时指定的或者是以配置文件的形式传入。在bcc当中有相当多的例子都是在进行时进行字符替换,如runqslower.py,它用于发现那些处于调度队列中太久的进程,变量min_us是等待时间的阈值,其中关键代码如下:

python
# ebpf 程序, 省略了一些代码 delta_us = (bpf_ktime_get_ns() - *tsp) / 1000; if (FILTER_US) return 0; # 省略了一些代码 # FILTER_US 将会被替换 if min_us == 0: bpf_text = bpf_text.replace('FILTER_US', '0') else: bpf_text = bpf_text.replace('FILTER_US', 'delta_us <= %s' % str(min_us))

因为 ebpf-go 本身的实现方式(eBPF 程序需要经过静态编译再被加载),进行变量替换不能像bcc那样简便,这里介绍两种方式的变量替换:

一、使用RewriteConst函数

它的主要用法是在 eBPF C 程序中以 volatile const定义变量,在包含着 eBPF 程序被加载以后进行运行时的重写。下面是监控vfd_read函数的调用时长是否超过了预设的阈值示例代码:

c
// 这个值会被重写 const volatile u64 latency_thresh; SEC("kprobe/vfs_read") static int kprobe_vfs_read(struct pt_regs *ctx) { u64 ts = bpf_ktime_get_ns(); u64 pid = bpf_get_current_pid_tgid(); bpf_map_update_elem(&start_map,&pid,&ts,BPF_ANY); return 0; } SEC("kretprobe/vfs_read") int kretprobe_vfs_read(struct pt_regs *ctx) { // 从 map 当中取得进入到 vfs_read 的时间戳 u64 pid = bpf_get_current_pid_tgid(); u64 *tsp = bpf_map_lookup_elem(&start_map,&pid); if (tsp == 0) { return 0; } // tsp 是进入到 vfs_read 的时间戳,使用 kprobe/vfs_read 记录到 map u64 latency = bpf_ktime_get_ns() - *tsp; if (latency > latency_thresh) { // 进行数据的采集 } }

在 ebpf-go 的程序中对latency_thresh进行重写,下面的示例程序将阈值设置为了 1ms。

go
// 内核时间以 ns 为单位,将 ms 转为 ns thresh := time.Millisecond.Nanoseconds() spec, err := loadBpf() if err != nil { log.Fatalf("load bpf error %v", err) } consts := map[string]interface{}{ "latency_thresh": thresh.String(), } if err = spec.RewriteConstants(consts); err != nil { log.Fatalf("RewriteConstants error:%v", err) } var objs = bpfObjects{} if err = spec.LoadAndAssign(&objs, nil); err != nil { log.Fatalf("loading objects error %v", err) }

对于该函数的使用可以参考文档issue注意,该方法只能在5.2+的内核可以使用,对于低版本内核使用该操作会提示如下类似的错误信息:

map .rodata: map create: read- and write-only maps not supported (requires >= v5.2)

确实,大部分的 eBPF 程序的错误信息都很不直观。该错误的原因是对于全局变量(global constant)clang会在编译期间将这些变量放到.rodata这个节(ELF section),libbpf 会将这个节的数据写入到一个.rodata的map当中并且在正式被加载到内核之前被重写,不过这个功能在 linux 5.2 内核被引入。对于该问题的讨论参考以下三个链接:

https://github.com/cilium/ebpf/discussions/592

https://arthurchiao.art/blog/bpf-advanced-notes-4-zh/

https://nakryiko.com/posts/bpf-tips-printk/

二、通过内联汇编

这种方式比较 hack,通过内联汇编在 ELF 文件的符号表内写入了一个符号,然后在 ebpf-go 程序内进行变量重写。示例代码如下:

c
u64 latency_thresh; asm("%0 = thresh ll" : "=r"(latency_thresh));

该代码会在 ELF 文件的符号表内插入一个名为thresh的符号,这行代码的意思是,将thresh的赋值给latency_thresh,即相当于latency_tresh=thresh,那么我们要做的就是重写thresh的值即可。

查看 ELF 文件的符号表,确实内联汇编插入了一个新的符号。

shell
# 省略了一些输出 13: 0000000000000000 504 FUNC GLOBAL DEFAULT 7 kprobe_finish_ta[...] 14: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND thresh # 内联汇编插入的符号 15: 0000000000000014 20 OBJECT GLOBAL DEFAULT 10 latency_map 16: 0000000000000000 13 OBJECT GLOBAL DEFAULT 9 LICENSE 17: 0000000000000000 8 OBJECT GLOBAL DEFAULT 11 unused_data_t

下面要做的事情就是变量替换,ebpf-go 会将所要挂载的 eBPF 程序抽象为 ProgramSpec结构体,它内部有一个Name成员对应于 eBPF 程序的函数名,所以我们只需要遍历所有要挂载的 eBPF 程序,按需重写变量。还是以前面的 vfs_read函数为例,需要对kretprobe_vfs_read函数内的latency_thresh进行重写,ebpf-go中关键部分程序如下。

go
for _, prog := range spec.Programs { if prog.Name == "kretprobe_vfs_read" { // 选择在哪个程序内进行变量重写 for i, ins := range spec.Programs[prog.Name].Instructions { if ins.Reference() == "thresh" { // 内核时间是 ns,阈值为 1ms,要将 ms 转为 ns spec.Programs[prog.Name].Instructions[i].Constant = time.Millisecond.Nanoseconds() spec.Programs[prog.Name].Instructions[i].Offset = 0 } } } }

eBPF 程序的debug

对于 eBPF 程序的 debug 是相当麻烦的,在这里我们演示一个访存错误的例子,在 eBPF 内使用未初始化来作为 map 的 value 是不允许的,还是以 vfs_read 为例,我们记录每一个执行vfs_read的时间戳并且记录到map,如下:

c
static int trace_enter(struct pt_regs *ctx) { u64 pid = bpf_get_current_pid_tgid(); u64 ts = bpf_ktime_get_ns(); struct data_t data; // 没有初始化,只是声明,这种写法是不被允许的 // struct data_t data = {}; 正确的写法 data.pid = pid >> 32; data.ts = ts; bpf_get_current_comm(&data.comm,sizeof(data.comm)); bpf_map_update_elem(&start_map, &pid, &data, BPF_ANY); return 0; }

运行后程序提示如下的错误信息:

shell
invalid indirect read from stack R3 off -40+29 size 32

至于为什么内核不允许这样做,参考这里。显然这样的提示信息对于如何发觉程序问题出在哪毫无用处,好在 ebpf-go 提供了方法能够输出 eBPF 的校验日志,代码如下:

go
objs := bpfObjects{} if err := loadBpfObjects(&objs, nil); err != nil { var ve *ebpf.VerifierError // 输出校验日志, if errors.As(err, &ve) { fmt.Printf("Verifier error: %+v\n", ve) } log.Fatalf("loading objects: %v", err) } defer objs.Close()

运行带有bug的程序,terminal 会输出校验日志:

shell
; bpf_map_update_elem(&start_map, &pid, &data, BPF_ANY); 17: (18) r1 = 0xffff8948118b5800 19: (b7) r4 = 0 20: (85) call bpf_map_update_elem#2 invalid indirect read from stack R3 off -40+29 size 32

结果表明该bug的问题出现在bpf_map_update_elem这行代码中,结合前面的描述 eBPF 不允许 map 的 value 是未初始化的结构体,这一切恰好对应。相类似的问题还会出现在结构体字节对齐的问题当中,这个可以参考cilium文档描述

对于 Debug 的讨论: https://github.com/cilium/ebpf/discussions/838

官方文档: https://pkg.go.dev/github.com/cilium/ebpf#example-Program-VerifierError

如何正确的操作 Map

参考了一些开源项目的写法,他们使用 ebpf-go 读取 eBPF map 的伪代码如下,使用定时器来以某个时间间隔的读取。

go
for { select { case <- timer expier: return case <- ticker: readMapData() } }

因此每一轮间隔结束后都会对整个 map 重新遍历,那么就会读取到重复的数据。当然会想到在遍历的过程中,然而直接在遍历过程中删除数据是不安全的,这一点不同于go原生的map。所以可行的方法是,遍历过程中记录本轮所迭代的key,遍历完了再删除。示例代码如下:

go
var iter = objs.TsMap.Iterate() var keys []uint64 // 记录已经遍历过的entry的key var key uint64 var val Data // 结构体,用于 for iter.Next(&key, &val) { // 注意: 不能再Iterator当中删除entry,这是不安全的 keys = append(keys, key) fmt.Println(val.Pid, val.Latency, string(val.Comm[:])) } // 在本轮迭代中,将遍历过的数据从map中删除。 for _, k := range keys { err = objs.TsMap.Delete(&k) if err != nil { log.Errorln(err) } }

对于遍历 map 的过程中删除元素是不安全的描述查看文档: https://pkg.go.dev/github.com/cilium/ebpf#MapIterator.Next

从 C 结构体到 Go 结构体

在 ebpf-go 的底层使用的是反射来将 eBPF map内的数据转为 go 结构体(所以如果手动定义结构体务必要保证Go结构体的成员是大写字母开头)。因此我们在定义的时候也必须保证 Go 结构体定义顺序、成员字节数与 eBPF 程序内结构体是完全一致的。但是手动定义结构体存在一些问题,会导致结构体成员所反射出来的结果是错误的,为此我向 ebpf-go的作者提了相关issue,具体的整个过程可以查看 issue,限于篇幅只阐述这个问题的基本表现:

c
// c 结构体 struct data_t { u32 pid; u64 latency; char comm[16]; }; // go 结构体,会导致有bug type Data struct { Pid uint32 Latency uint64 Comm [16]uint8 }

这两个结构体定义会让 Go 程序所得到的 Latency成员是不正确的值。该问题归根到底的原因是编译器对c结构体的字节填充以及ebpf-go将c结构体反射为Go结构体并不是简单的memcpy,它使用的是go-binary库。为了彻底地避免这个问题,可以使用 ebpf-go 的-type参数,它的作用就是根据 c 结构体自动地生成一个 Go 结构体,它会负责处理好必要的字节填充问题,这一用法参考这个例子注意,该例子内的这行语句是必须的:

c
struct rtt_event *unused_event __attribute__((unused));

否则不能生成所需的 Go 结构体。此外,相类似的用例还可以参考这里

参考资料

eBPF 的系统性资料相对零散、复杂,下面是我个人在学习当中认为相当不错的参考文档。

https://github.com/iovisor/bcc/blob/master/docs/kernel-versions.md eBPF 的某些特性都是随着内核的变化而变化的,这个文档列出了各种特性在哪个版本被加入到内核。

https://github.com/iovisor/bcc/blob/master/docs/reference_guide.md bcc 仓库的 API 文档,相较于 man page来说好懂一点。

https://github.com/iovisor/bcc/blob/master/docs/tutorial_bcc_python_developer.md 入门 eBPF 很好的一个教程

https://arthurchiao.art/index.html 个人博客,有相当多的 eBPF 文章,写的很不错

https://nakryiko.com/ BPF CO-RE 核心开发者的个人博客

https://docs.cilium.io/en/latest/bpf/ cilium文档,详尽的对bpf做了描述

https://elixir.bootlin.com/linux/latest/source/samples/bpf Linux 内核中 ebpf 程序的示例代码

https://www.brendangregg.com/ bcc 的核心开发者之一 Brendan Gregg的博客

https://www.ebpf.top/ 一个关于 ebpf 的中文网站

https://github.com/cilium/ebpf ebpf-go,相较于其他的go实现,这个做的最好,社区活跃度也高

https://man7.org/linux/man-pages/man2/bpf.2.html man page,内容大而全就是有些晦涩,择需参考

https://libbpf.readthedocs.io/en/latest/api.html libbpf 的 API 文档

总结

本文先对 BPF CO-RE 做了基本介绍,描述了什么是 BPF CO-RE 和 它的一些使用样例以及简单地描述了背后的原理。还介绍了如何使用 BTFHub 解决低版本内核不支持 BTF 的方案。虽然的出发点是关注于 BPF CO-RE,但是我们使用 ebpf-go 开发监控组件的过程中还是遇到了一些开发上的问题,我们将所遇到的问题、解决办法都一并地分享了出来。

最后,eBPF 是一个相对较新并且仍在快速变化的技术,限于我的个人知识面未能完全的将所有的内容都一一解释清楚,对于文中相关内容所存在的任何问题、技术上的误解,欢迎大家反馈、讨论。

本文作者:strickland

本文链接:

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!