使用 Go 语言开发 ebpf 程序
在 Introduction to eBPF 这篇文章中介绍了基于内核源码开发并加载 eBPF 代码的过程。本文将介绍基于 Go 和对应的库开发 eBPF 程序,文中所有涉及的代码可以在我的 Github 中找到。
选择 eBPF 库
当涉及到选择库和工具来与 eBPF 进行交互时,会让人有所困惑。在选择时,你必须在基于 Python 的 BCC 框架、基于 C 的 libbpf 和一系列基于 Go 的 Dropbox、Cilium、Aqua 和 Calico 等库中选择。
在大多数情况下,eBPF 库主要协助实现两个功能:
- 将 eBPF 程序和 Map 载入内核并执行重定位,通过其文件描述符将 eBPF 程序与正确的 Map 进行关联。
- 与 eBPF Map 交互,允许对存储在 Map 中的键/值对进行标准的 CRUD 操作。
部分库也可以帮助你将 eBPF 程序附加到一个特定的钩子,尽管对于网络场景下,这可能很容易采用现有的 netlink API 库完成。
当涉及到 eBPF 库的选择时,仍然让人感到困惑(见[1], [2])。事实是每个库都有各自的范围和限制。
- Calico 在用 bpftool 和 iproute2 实现的 CLI 命令基础上实现了一个 Go 包装器。
- Aqua 实现了对 libbpf C 库的 Go 包装器。
- Dropbox 支持一小部分程序,但有一个非常干净和方便的用户API。
- IO Visor 的 gobpf 是 BCC 框架的 Go 语言绑定,它更注重于跟踪和性能分析。
- Cilium 和 Cloudflare 维护一个 纯 Go 语言编写的库 (以下简称 “libbpf-go”),它将所有 eBPF 系统调用抽象在一个本地 Go 接口后面。
参考 使用 Go 语言管理和分发 ebpf 程序 可以看到 cilium/ebpf 更加活跃,本文也选择基于 cilium/ebpf 库来开发。cilium/ebpf 纯 Go 程序编写,从而实现了程序最小依赖;与此同时其还提供了 bpf2go 工具,可用来将 eBPF 程序编译成 Go 语言中的一部分,使得交付更加方便,后续如果配合 CO-RE 功能则威力大增。
环境准备
eBPF 程序一般有两部分组成:
- 基于 C 语言的 eBPF 程序,最终使用
clang/llvm编译成elf格式的文件,为内核中需要加载的程序; - Go 语言程序用于加载、调试 eBPF 程序,为用户空间的程序,用于配置或者读取 eBPF 程序生成的数据。
前置条件需要安装 clang/llvm 编译器:
|
|
可以从我的 Github 下载代码,目录结构如下:
|
|
编程规范
BPF 代码
以 kprobe 为例
|
|
头文件
libbpf
|
|
vmlinux.h
vmlinux.h 是使用工具生成的代码文件。它包含了系统运行 Linux 内核源代码中使用的所有类型定义。当我们编译 Linux 内核时,会输出一个称作 vmlinux 的文件组件,其是一个 ELF 的二进制文件,包含了编译好的可启动内核。vmlinux 文件通常也会被打包在主要的 Linux 发行版中。
内核中的 bpftool 工具其中功能之一就是读取 vmlinux 文件并生成对应的 vmlinux.h 头文件。vmlinux.h 会包含运行内核中所使用的每一个类型定义,因此该文件的比较大。
生成 vmlinux.h 文件的命令如下:
|
|
包含该 vmlinux.h,就意味着我们的程序可以使用内核中使用的所有数据类型定义,因此 BPF 程序在读取相关的内存时,就可以映射成对应的类型结构按照字段进行读取。
例如,Linux 中的 task_struct 结构用于表示进程,如果 BPF 程序需要检查 task_struct 结构的值,那么首先就需要知道该结构的具体类型定义。
由于 vmlinux.h 文件是由当前运行内核生成的,如果你试图将编译好的 eBPF 程序在另一台运行不同内核版本的机器上运行,可能会面临崩溃的窘境。这主要是因为在不同的版本中,对应数据类型的定义可能会在 Linux 源代码中发生变化。
但是,通过使用 libbpf 库提供的功能可以实现 “CO:RE”(一次编译,到处运行)。libbpf 库定义了部分宏(比如 BPF_CORE_READ),其可分析 eBPF 程序试图访问 vmlinux.h 中定义的类型中的哪些字段。如果访问的字段在当前内核定义的结构中发生了移动,宏 / 辅助函数会协助自动找到对应字段。对于可能消失的字段,也提供了对应的辅助函数 bpf_core_field_exists。因此,我们可以使用当前内核中生成的 vmlinux.h 头文件来编译 eBPF 程序,然后在不同的内核上运行它【需要运行的内核也支持 BTF 内核编译选项】。
代码编译
bpf2go
该注解使用 bpf2go 程序将 kprobe.c 文件编译成 bpfdemo_bpfeb.go 和 bpfdemo_bpfel.go 两个文件,分别为 bigendian 和 littleendian 两种平台的程序。
其中参数中的 BPFDemo 参数为 main.go 文件中函数调用的名称,例如 objs := BPFDemoObjects{} 和 LoadBPFDemoObjects(&objs, nil);
|
|
Makefile
|
|
执行编译,可以看到生成了对应的 BPF 字节码 bpfdemo_bpfeb.o 和 bpfdemo_bpfel.o,还有对应的 go 文件:
|
|
加载代码
在我们编写的 Go 代码中,首先需要将编译好的 eBPF 代码加载进内核,调用的是 LoadBPFDemoObjects
|
|
这里的 LoadBPFDemoObjects 和 BPFDemoObjects 都来自 bpf2go 自动生成的代码。
以 bpfdemo_bpfeb.go 为例,可以看到生成了很多辅助函数和结构体,其中:
- BPFDemoObjects 包括 BPF 程序和 BPF Map
- LoadBPFDemoObjects 会调用
LoadBPFDemo将编译好的 ELF 格式的 BPF 代码加载进内存,然后调用LoadAndAssign实际调用 BPF 系统调用 load BPF 程序到内核。
|
|
实际查看 LoadAndAssign 可以看到它会加载 BPF Program 和 BPF Map 到内核
|
|
这里的 loadProgram 会调用 newProgramWithOptions,处理很多与 BTF 等其他内容后,最终调用 sys.ProgLoad(attr)
|
|
此即调用了 BPF 的系统调用:
|
|
加载 map 也是类似,最终调用了 sys.MapCreate
|
|
Kprobe 处理
kprobe可以对任何内核函数进行插桩,可以实时在生产环境中启用,不需要重启系统,也不需要以特殊方式重启内核。 现在有以下三种接口可以访问kprobes.
- kprobe API: 如
register_kprobe()等,在 这篇文章中 介绍了其用法 - 基于Frace的,通过
/sys/kernel/debug/tracing/kprobe_events: 通过向这个文件写入字符串,可以配置开启和停止kprobes,在 这篇文章中 介绍了其用法 perf_event_open(): 与 perf 工具所使用的一样,现在BPF跟踪工具也开始使用这些函数
对应到 main.go 中,在 LoadBPFDemoObjects之后,我们还调用了 link.Kprobe 来
|
|
创建 kprobe类型的 perf event
- symbol 是追踪的内核函数
- prog 是编译的 eBPF 程序
|
|
这里创建了一个 kprobe 类型的 Perf Event,传入的追踪地址是 symbol
|
|
最终调用了 PerfEventOpen 来开启一个 perf event,这个系统调用可以参考 这里
|
|
挂载 eBPF 程序到 perf event
通过 perf_event 的 ioctl 调用把 BPF 程序 attach 到 kprobe event
PERF_EVENT_IOC_SET_BPF,表示允许 attach BPF 程序到 kprobe event 上,其中 ioctl 设置的第三个参数代表 bpf 系统调用的 fd。PERF_EVENT_IOC_ENABLE,表示使能 event。
|
|
attachPerfEvent 通过 perf_event 的 ioctl 调用把 BPF 程序 attach 到 kprobe event
|
|
通过 ioctl 挂载 BPF 程序:
|
|
查看 Map 信息
定期查看 eBPF map 的更新:
|
|
容器镜像
|
|
参考资料
-
No backlinks found.