ebpf-go 是一个适用于 eBPF 的 go 库,是一种用于编译、加载、调试 ebpf 程序的工具。它具有最小的外部依赖,可以应用于长时间运行的进程。
它不依赖于 C 库、libbpf 以及其他 go 库(标准库除外),这使得它非常适合用于编写自包含的、可在多种体系结构上运行的可移植工具。
这里我们将学习如何使用 ebpf-go
来构建使用 ebpf
的 go 程序。可以在这里找到使用了 ebpf-go
库的部分开源项目,同时 ebpf-go
代码仓库的 examples 目录中也包含大量的 demo 示例。
示例简介
我们将通过一个示例来从头演示如何使用 ebpf-go
库来构建使用 ebpf
go 程序。我们会介绍相应的工具链、编写一个 ebpf C 示例程序、通过 bpf2go 来编译这个 C 程序,之后通过一个 go 应用程序来加载该 ebpf 程序到内核中,并周期性地显示它的输出。
这个示例会将 eBPF 程序 attach 到 XDP 这个 hook 点,该 BPF 程序会统计物理接口上收到的网络报文数。过滤、修改报文是 eBPF 的主要应用场景,因此会看到很多 ebpf 在网络流量处理方面的应用。但是 eBPF 的能力正在不断增长,它已被用于跟踪、系统和应用程序的可观察性、安全等场景。
依赖环境准备
首先,我们使用一个干净的 ubuntu22.04 系统做为测试环境。
1 | # 需要增加 privileged 权限,见下文分析 |
为了能够运行该示例,需要满足如下依赖:
- Linux 内核是 5.7 及以上版本,以提供 bpf_link 支持
- LLVM 11 及以上版本(clang 和 llvm-string)
- libbpf 头文件
- Linux kernel 开发头文件
- 支持
ebpf-go
模块的 go 版本
1 | # apt-get update |
由于 ubuntu 官方仓库移除了我机器上对应的 kernel 开发头文件
包,所以暂时没有在容器中安装 Linux kernel 开发头文件
。另外需要说明一下,这里的 Linux kernel 开发头文件
是需要通过 apt-get install linux-headers-$(uname -r)
的方式来安装,安装成功之后,会在 /usr/include/linux-headers-$(uname -r)/
目录下包含各种 Linux 内核开发所用的头文件。而 /usr/include/linux
则是 Linux 内核提供的用户态编程接口,用于库的开发者构建 Linux 系统上的 C 标准库
等。关于这两者的区别,可以参考这里 以及这里。
编写 ebpf C 程序
编写如下 XDP 程序,对接收到的报文进行计数。
1 | //go:build ignore |
关于该代码,解释如下:
- 当把 C 代码和 go 代码放在同一个项目中时,
go:build ignore
这个go build
tag 可以告诉 Go 编译器忽略这个文件,否则 go 编译器可能会告警C source files not allowed when not using cgo or SWIG
。因为这个 ebpf C 程序文件本身不需要经过 go 编译器构建,所以可以直接忽略它 linux/bpf.h
是 Linux 的内核头文件,__u64、BPF_MAP_TYPE_ARRAY 等符号是 Linux 内核头文件提供bpf/bpf_helpers.h
是 libbpf 提供的头文件,__uint
、__type
,SEC
以及 BPF helper 等都是由 libbpf 头文件定义- pkt_count 是一个 ebpf map,需要放到
.maps
这个 section 中,ebpf-go
会在这个 section 中查找 map - BPF 程序可以分为多种类型,这里我们编写的是一个 XDP 程序,所以使用
SEC("xdp")
作为 section pkt_count
是一个数组类型的 ebpf map,且只最多只有一个元素,所以它的 key 永远只能为 0bpf_map_lookup_elem
是一个 bpf helper,用来查找 bpf map 中的元素- 在 SMP 环境中,BPF 程序可以并发执行,因此
pkt_count
是会并发修改的,因此使用原子操作来修改pkt_count
中的元素 - 返回 XDP_PASS,报文继续交由内核网络栈处理
- 由于一些 bpf helper 需要调用使用 GPLv2 的内核代码,因此 bpf 程序需要声明它们使用
GPL
协议。可以支持双协议,由于 ebpf-go 使用 MIT 协议,因此最终使用Dual MIT/GPL
使用 bpf2go
编译 ebpf C 文件
接下来继续创建 gen.go
文件,该文件中包含了 go:generate
指令,在该指示符中,通过 bpf2go
命令来编译 ebpf C 代码。这样当我们在项目目录中运行 go generate
命令时,就会自动编译 ebpf C 程序。除此之外,bpf2go
还会生成一些 脚手架代码
,用于加载 ebpf 程序到内核、提供组件交互接口等,这显著减少了我们需要自行编写的代码量。为软件包里的 go:generate
指示使用专门的文件可以很好地将它们与应用程序逻辑分离。
1 | package main |
在使用 Go 工具链之前,需要先声明一个 Go 模块:
1 | # go mod init counter |
接下来手动增加 bpf2go
作为依赖,因为它没有被任何 .go
文件显式导入:
1 | # go get github.com/cilium/ebpf/cmd/bpf2go |
接下来运行 go generate
,遇到如下错误:
1 | # go generate |
我看了下,是有 types.h
这个文件
1 | # ls /usr/include/x86_64-linux-gnu/asm/types.h |
查阅一些资料,可以安装 gcc-multilib
,它会将 /usr/include/asm
符号链接到 usr/include/x86_64-linux-gnu
:
1 | # apt-get install gcc-multilib |
再次执行 go generate
,构建成功:
1 | # go generate |
bpf2go 会使用 clang 将 counter.c
构建为 counter_bpf*.o
。bpf2go
一共生成了两个 object 文件以及两个对应的 Go
文件。查看其中的 counter_bpfel.go
文件
1 | // counterPrograms contains all programs after they have been loaded into the kernel. |
可以看到,bpf2go
会自动生成用于和 count_packets 程序交互的 go 脚手架代码
。接下来将把该 bpf 程序加载到内核的 XDP hook 点。
go 应用程序
在上一步,我们已经编译好了 BPF C 代码,同时生成了 Go 脚手架代码,接下来需要编写 Go 应用代码用于将 bpf 程序加载到内核的 hook 点上,main.go
代码如下所示:
1 | package main |
上述代码的一些核心要点:
- 在 Linux 5.11 之前,使用 RLIMIT_MEMLOCK 来控制一个进程 ebpf 资源的最大内存
counterObjects
是一个结构体类型,包含了指向 Map 和 Program 对象的指针。loadCounterObjects
会基于struct tags
来填充这些字段。- 通过
objs.Close()
来释放objs
所打开的文件描述符资源 - 调用
link.AttachXDP()
将objs.CountPackets
将 XDP 程序(即 C 代码中的count_packets
)加载到指定的接口上 - 通过
objs.PktCount.Lookup
从 bpf map(即 C 代码中的pkt_count
)中读取key=0
所对应的值,从而得到报文计数
最后构建并运行我们的 go 程序:
1 | # go build |
因为是在容器内运行,所以遇到了权限问题,需要给容器增加 privileged
权限,只能重新启动容器了。
1 | # docker commit ebpf_go ebpf_go |
1 | # ./counter |
迭代工作流
当修改了 C ebpf 代码后,需要保证自动生成的文件总是最新的。如果不重新运行 bpf2go
,则不会重新编译 C 代码,生成的 go 脚手架代码也不会被更新。所以修改 C ebpf 代码后,总是需要重新运行 go generate
来重新生成:
1 | go generate && go build && ./counter |