0%

ebpf-go 库学习(1):ebpf-go 使用入门

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
2
3
4
# 需要增加  privileged 权限,见下文分析
# docker run -d -it --privileged --name ebpf_go --network="host" -v /root/docker_share:/root/data ubuntu:22.04 /bin/bash

# docker exec -it ebpf_go /bin/bash

为了能够运行该示例,需要满足如下依赖:

  • Linux 内核是 5.7 及以上版本,以提供 bpf_link 支持
  • LLVM 11 及以上版本(clang 和 llvm-string)
  • libbpf 头文件
  • Linux kernel 开发头文件
  • 支持 ebpf-go 模块的 go 版本
1
2
3
4
5
6
7
8
9
10
# apt-get update

# 目前看安装 libbpf-dev 会自动安装所需的 Linux kernel 头文件,例如 `linux/bpf.h` 等
# apt-get install libbpf-dev llvm clang wget

# 安装 go
# wget https://go.dev/dl/go1.22.3.linux-amd64.tar.gz
# rm -rf /usr/local/go && tar -C /usr/local -xzf go1.22.3.linux-amd64.tar.gz
# go version
go version go1.22.3 linux/amd64

由于 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
//go:build ignore

#include <linux/bpf.h>
#include <bpf/bpf_helpers.h>

struct {
__uint(type, BPF_MAP_TYPE_ARRAY);
__type(key, __u32);
__type(value, __u64);
__uint(max_entries, 1);
} pkt_count SEC(".maps");

// count_packets atomically increases a packet counter on every invocation.
SEC("xdp")
int count_packets() {
__u32 key = 0;
__u64 *count = bpf_map_lookup_elem(&pkt_count, &key);
if (count) {
__sync_fetch_and_add(count, 1);
}

return XDP_PASS;
}

char __license[] SEC("license") = "Dual MIT/GPL";

关于该代码,解释如下:

  • 当把 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 永远只能为 0
  • bpf_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
2
3
package main

//go:generate go run github.com/cilium/ebpf/cmd/bpf2go counter counter.c

在使用 Go 工具链之前,需要先声明一个 Go 模块:

1
2
3
4
5
6
# go mod init counter
go: creating new go.mod: module counter
go: to add module requirements and sums:
go mod tidy

# go mod tidy

接下来手动增加 bpf2go 作为依赖,因为它没有被任何 .go 文件显式导入:

1
# go get github.com/cilium/ebpf/cmd/bpf2go

接下来运行 go generate,遇到如下错误:

1
2
3
4
5
6
7
8
9
10
# go generate
In file included from /root/data/learn/ebpf/ebpf_go/counter/counter.c:3:
In file included from /usr/include/linux/bpf.h:11:
/usr/include/linux/types.h:5:10: fatal error: 'asm/types.h' file not found
#include <asm/types.h>
^~~~~~~~~~~~~
1 error generated.
Error: can't execute clang: exit status 1
exit status 1
gen.go:3: running "go": exit status 1

我看了下,是有 types.h 这个文件

1
2
# ls /usr/include/x86_64-linux-gnu/asm/types.h
/usr/include/x86_64-linux-gnu/asm/types.h

查阅一些资料,可以安装 gcc-multilib,它会将 /usr/include/asm 符号链接到 usr/include/x86_64-linux-gnu

1
2
3
4
# apt-get install gcc-multilib

# # ls -l /usr/include/asm
lrwxrwxrwx 1 root root 20 Aug 5 2021 /usr/include/asm -> x86_64-linux-gnu/asm

再次执行 go generate,构建成功:

1
2
3
4
5
6
7
# go generate
Compiled /root/data/learn/ebpf/ebpf_go/counter/counter_bpfel.o
Stripped /root/data/learn/ebpf/ebpf_go/counter/counter_bpfel.o
Wrote /root/data/learn/ebpf/ebpf_go/counter/counter_bpfel.go
Compiled /root/data/learn/ebpf/ebpf_go/counter/counter_bpfeb.o
Stripped /root/data/learn/ebpf/ebpf_go/counter/counter_bpfeb.o
Wrote /root/data/learn/ebpf/ebpf_go/counter/counter_bpfeb.go

bpf2go 会使用 clang 将 counter.c 构建为 counter_bpf*.obpf2go 一共生成了两个 object 文件以及两个对应的 Go 文件。查看其中的 counter_bpfel.go 文件

1
2
3
4
5
6
// counterPrograms contains all programs after they have been loaded into the kernel.
//
// It can be passed to loadCounterObjects or ebpf.CollectionSpec.LoadAndAssign.
type counterPrograms struct {
CountPackets *ebpf.Program `ebpf:"count_packets"`
}

可以看到,bpf2go 会自动生成用于和 count_packets 程序交互的 go 脚手架代码。接下来将把该 bpf 程序加载到内核的 XDP hook 点。

go 应用程序

在上一步,我们已经编译好了 BPF C 代码,同时生成了 Go 脚手架代码,接下来需要编写 Go 应用代码用于将 bpf 程序加载到内核的 hook 点上,main.go 代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
package main

import (
"log"
"net"
"os"
"os/signal"
"time"

"github.com/cilium/ebpf/link"
"github.com/cilium/ebpf/rlimit"
)

func main() {
// Remove resource limits for kernels <5.11.
if err := rlimit.RemoveMemlock(); err != nil {
log.Fatal("Removing memlock:", err)
}

// Load the compiled eBPF ELF and load it into the kernel.
var objs counterObjects
if err := loadCounterObjects(&objs, nil); err != nil {
log.Fatal("Loading eBPF objects:", err)
}
defer objs.Close()

ifname := "eth0" // Change this to an interface on your machine.
iface, err := net.InterfaceByName(ifname)
if err != nil {
log.Fatalf("Getting interface %s: %s", ifname, err)
}

// Attach count_packets to the network interface.
link, err := link.AttachXDP(link.XDPOptions{
Program: objs.CountPackets,
Interface: iface.Index,
Flags: link.XDPGenericMode,
})
if err != nil {
log.Fatal("Attaching XDP:", err)
}
defer link.Close()

log.Printf("Counting incoming packets on %s..", ifname)

// Periodically fetch the packet counter from PktCount,
// exit the program when interrupted.
tick := time.Tick(time.Second)
stop := make(chan os.Signal, 5)
signal.Notify(stop, os.Interrupt)
for {
select {
case <-tick:
var count uint64
err := objs.PktCount.Lookup(uint32(0), &count)
if err != nil {
log.Fatal("Map lookup:", err)
}
log.Printf("Received %d packets", count)
case <-stop:
log.Print("Received signal, exiting..")
return
}
}
}

上述代码的一些核心要点:

  • 在 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
2
3
# go build
# ./counter
2024/05/15 04:17:29 Removing memlock:failed to set memlock rlimit: operation not permitted

因为是在容器内运行,所以遇到了权限问题,需要给容器增加 privileged 权限,只能重新启动容器了。

1
2
3
# docker commit ebpf_go ebpf_go
# docker rm ebpf_go -f
# docker run -d -it --privileged --name ebpf_go --network="host" -v /root/docker_share:/root/data ebpf_go /bin/bash
1
2
3
4
5
6
7
8
# ./counter
2024/05/15 04:58:42 Counting incoming packets on eth0..
2024/05/15 04:58:43 Received 1 packets
2024/05/15 04:58:44 Received 5 packets
2024/05/15 04:58:45 Received 6 packets
2024/05/15 04:58:46 Received 9 packets
2024/05/15 04:58:47 Received 11 packets
......

迭代工作流

当修改了 C ebpf 代码后,需要保证自动生成的文件总是最新的。如果不重新运行 bpf2go,则不会重新编译 C 代码,生成的 go 脚手架代码也不会被更新。所以修改 C ebpf 代码后,总是需要重新运行 go generate 来重新生成:

1
go generate && go build && ./counter

Reference