使用 cilium/ebpf 构建 eBPF 程序
Cilium/ebpf 是纯 go 语言实现的 eBPF 程序开发框架/库,本文记录了使用它完成开发的实践过程,并简要分析了它和其它框架的相同及不同之处。完整代码:https://github.com/cheneytianx/ebpf_demo/tree/main/cilium_ebpf
1. 关于 cilium/ebpf 库
如果想开发 eBPF 程序,并且在多台机器上进行部署。那么在开发工具的选择上,libbpf 无疑是最佳选择。libbpf 其实是 linux 内核源码的一部分,位于 tools/lib/bpf/ 路径下,它提供了bpf()
系统调用的简单封装,是一个轻量级的开发库。
正因为 libbpf 足够底层,一些现成的工具函数比较少,开发效率会相对低一点。例如,在uprobe
类型的 eBPF 程序开发中,目标函数相对 ELF 文件的偏移量需要开发者自己进行计算。
libbpfgo 在 libbpf 基础上提供了一层简单的封装,用户空间的执行逻辑可以使用 Go 语言来开发,相对方便很多。不过,一些核心的 bpf 调用是通过 CGo 来实现的。根据 该PPT 提供的信息,在调用map_lookup_elem()
时,CGo 会增加 10% 的性能开销。
Cilium/ebpf 是一个用纯 Go 语言实现的库,在提升开发效率的同时,也兼顾了运行性能。下面介绍使用cilium/ebpf
开发 eBPF 程序的一般过程。
2. eBPF 程序开发
不论采用什么框架,开发流程一般都是内核部分程序开发 + 用户空间程序开发,使用 MAP 这类数据结构完成数据的存储和传递。这里,我们 eBPF 程序的功能是:统计一段时间内不同程序触发系统调用的次数,将统计的逻辑放在内核部分程序中,采用 HASH 类型的 MAP 来实现。
2.1 内核部分代码
|
|
因为最后是使用 Go 来编译整个模块,第一行加入了特殊注释,告知 Go 忽略这个.c
源文件。这是 go 的编译特性,具体可以参考:Build_constraints。
2.2 bpf2go 工具
一般来说,内核程序开发完成后,会利用 clang & llvm 将源程序编译成eBPF
目标程序。它是一个内核可加载,ELF 格式的二进制文件。随后在用户空间代码中,将这个.o
文件加载进内核。
Cilium/ebpf 采用了和 libbpf + skeleton 相同的思想,将目标文件.o
转换成源码的一部分,提供一个结构体和一些方法来抽象内核部分的程序,随后在用户空间代码中就可以直接访问和加载内核程序。关于 libbpf + skeleton 可以参考:bpftool-gen 和 https://nakryiko.com/posts/libbpf-bootstrap/#the-user-space-side 。下面来看看 bpf2go 是怎样实现同样功能的。
bpf2go 可以看成 cilium/ebpf 中独立的小工具,定义在:ebpf/cmd/bpf2go中,我们直接使用 go install 进行安装。
|
|
结合 官方的文档 可知,bpf2go 工具会将*.bpf.c
文件转换为.go
文件。工具默认会生成*_bpfel.go
和*_bpfeb.go
两个文件,分别用在采用小端字节序和大端字节序的机器上,一般来说,我们只需要生成第一个,通过 -target bpfel 来控制即可。
由于我们已经安装了 libbpf 库,添加了很多头文件到系统的默认搜索路径中(/usr/include/bpf/)。如果没有安装 libbpf 库,也可以从其它开源项目中(https://github.com/cilium/ebpf/tree/master/examples/headers)拷贝需要的头文件放入本地的指定路径下,然后在生成 *_bpfel.go
代码的指令中使用-I
参数进行路径指定。
|
|
这样,项目目录下就多出了.o
和.go
文件。
官方代码使用的是 go generate 来执行上述命令,关于 go generate 可以参考:https://go.dev/blog/generate
生成的.go
文件中主要包含如下内容,以结构体的方式对编写的 bpf 程序和定义的 map 进行了抽象。
|
|
将 bpf 的.o
文件内容以字节序列的形式加载到*_bpfel.go
文件的_CountBytes
变量中。使用了go 1.16 中提供的 embed 特性,它可以在编译阶段将静态资源文件打包进程序变量中。详情参考:embed package - embed - Go Packages
和 libbpf + skeleton 类似,这样有两个好处。
- 内核态代码直接集成到了用户空间程序中,编译完成后只生成一个可执行文件;若要进行分发只需要拷贝这一个可执行文件即可(libbpfgo 库的案例中,.o文件和可执行文件要配合使用)。
- 程序启动更快。可执行文件从磁盘一次性加载到内存即可,而分离的
.o
文件需要在运行时动态加载。
2.3 用户空间代码
|
|
用户空间的代码中,可以使用很多 Go 的高级特性,例如 defer 。
2.4 实现效果
直接使用go build
编译出可执行文件。随后运行程序,Ctrl + C 停止程序后,会打印出这段时间内的统计结果。
3. 总结
3.1 cilium/ebpf 库的优势
cilium/ebpf 是纯 Go 语言实现的库,在用户空间的代码编写中,可以使用很多 Go 语言的高级特性,像 Goroutine,Channel和定时器等。
同时它也封装了很多和bpf
程序相关的接口,例如,遍历 MAP 中的 key 和 value。在 libbpf 中并没有直接遍历 MAP 拿到 key 和 value 的方法,需要自己来实现,例如:
|
|
3.2 cilium/ebpf 的注意事项
在使用 libbpf + skeleton 的时候,可以使用一个小 trick,在内核部分代码中可以定义一个全局变量,例如gval
。这时在生成*.skel.h
头文件时,会额外生成一个名为bss
的 section 抽象。在用户空间通过skel->bss.gval
可直接读写该变量,进行一些简单的数据交换。目前在 cilium/ebpf 中未见到类似的实现,只抽象出了 maps 和 programs。
另外,在使用 MAP 数据结构时,内核部分和用户空间所定义变量的内存结构应该保持一致。本文案例中,key 是长度为 16 的字节序列,在用户空间不能定义为 string 类型。
eBPF 程序有很多种类型,cilium/ebpf 应该是在开发阶段逐步支持了这些类型的程序。这导致不同类型的程序(例如,Rawtracepoint 和 Kprobe)在 attach 时的接口命名风格及使用上并不一致。在使用时,可以去官方的 examples 找到对应类型程序的 attach 方法。