Contents

使用 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 内核部分代码

 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
//go:build ignore

// 导入各种头文件,省略
char LICENSE[] SEC("license") = "Dual BSD/GPL";

struct {
    __uint(type, BPF_MAP_TYPE_HASH);
    __type(key, char[16]);  // name
    __type(value, u32);
    __uint(max_entries, 128);
} counter SEC(".maps");

SEC("raw_tracepoint/sys_enter")
int syscall_count(void *ctx)
{
    char name[16] = {0};
    u32 cnt = 1;
    bpf_get_current_comm(name, sizeof(name));

    u32 *exist = NULL;
    exist = bpf_map_lookup_elem(&counter, name);
    if (exist) { // 程序已经统计过,将 cnt+1
        cnt = *exist + 1;
    }
    
    bpf_map_update_elem(&counter, name, &cnt, BPF_ANY);
    return 0;
}

因为最后是使用 Go 来编译整个模块,第一行加入了特殊注释,告知 Go 忽略这个.c源文件。这是 go 的编译特性,具体可以参考:Build_constraints

2.2 bpf2go 工具

一般来说,内核程序开发完成后,会利用 clang & llvm 将源程序编译成eBPF目标程序。它是一个内核可加载,ELF 格式的二进制文件。随后在用户空间代码中,将这个.o文件加载进内核。

Cilium/ebpf 采用了和 libbpf + skeleton 相同的思想,将目标文件.o转换成源码的一部分,提供一个结构体和一些方法来抽象内核部分的程序,随后在用户空间代码中就可以直接访问和加载内核程序。关于 libbpf + skeleton 可以参考:bpftool-genhttps://nakryiko.com/posts/libbpf-bootstrap/#the-user-space-side 。下面来看看 bpf2go 是怎样实现同样功能的。

bpf2go 可以看成 cilium/ebpf 中独立的小工具,定义在:ebpf/cmd/bpf2go中,我们直接使用 go install 进行安装。

1
go install github.com/cilium/ebpf/cmd/bpf2go@latest

./Snipaste_2022-08-26_21-39-51.jpg

结合 官方的文档 可知,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参数进行路径指定。

1
GOPACKAGE=main bpf2go -cc clang -cflags -O2 -target bpfel count ./count.bpf.c -I<path_to_bpf_headers>

这样,项目目录下就多出了.o.go文件。 ./Snipaste_2022-08-26_21-37-27.jpg

官方代码使用的是 go generate 来执行上述命令,关于 go generate 可以参考:https://go.dev/blog/generate

生成的.go文件中主要包含如下内容,以结构体的方式对编写的 bpf 程序和定义的 map 进行了抽象。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
type countObjects struct {  // 整个内核部分程序抽象成 countObjects
    countPrograms
    countMaps
}

type countPrograms struct {
    MyBpftrace *ebpf.Program `ebpf:"syscall_count"`
}

type countMaps struct {
    Counter *ebpf.Map `ebpf:"counter"`
}

//go:embed count_bpfel.o
var _CountBytes []byte

将 bpf 的.o文件内容以字节序列的形式加载到*_bpfel.go文件的_CountBytes变量中。使用了go 1.16 中提供的 embed 特性,它可以在编译阶段将静态资源文件打包进程序变量中。详情参考:embed package - embed - Go Packages

和 libbpf + skeleton 类似,这样有两个好处。

  1. 内核态代码直接集成到了用户空间程序中,编译完成后只生成一个可执行文件;若要进行分发只需要拷贝这一个可执行文件即可(libbpfgo 库的案例中,.o文件和可执行文件要配合使用)。
  2. 程序启动更快。可执行文件从磁盘一次性加载到内存即可,而分离的.o文件需要在运行时动态加载。

2.3 用户空间代码

 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
package main

import ... // 导入模块,省略

// 采用 go generate 完成 .go 文件生成
//go:generate bpf2go -cc clang -cflags -O2 -target bpfel count ./count.bpf.c
func main() {
    stopper := make(chan os.Signal, 1)
    signal.Notify(stopper, os.Interrupt, syscall.SIGTERM)

    // Unlock resources for eBPF progs
    if err := rlimit.RemoveMemlock(); err != nil {
        log.Fatal(err)
    }

    // Load pre-compiled programs and maps into the kernel. Defined in *_bpfel.go
    objs := countObjects{}  // objs 抽象了整个内核程序
    if err := loadCountObjects(&objs, nil); err != nil {
        log.Fatalf("loading objects: %s", err)
    }
    defer objs.Close()

    ops := link.RawTracepointOptions{  
        Name: "sys_enter",
        Program: objs.SyscallCount,
    }
    rtp, err := link.AttachRawTracepoint(ops)  // attach to rawTracepoint
    if err != nil {
        log.Fatalf("opening raw_tracepoint: %s", err)
    }
    log.Printf("Successfully started!\n")

    <-stopper  // Wait for a signal and close the eBPF prog
    rtp.Close()
    fmt.Println()
 
    var key [16]byte  // 开始输出 map 中的内容
    var val uint32
    mapIter := objs.Counter.Iterate()

    for mapIter.Next(&key, &val) {  // 库提供了非常方便的遍历接口
        fmt.Println(string(key[:]), val)
    }
}

用户空间的代码中,可以使用很多 Go 的高级特性,例如 defer 。

2.4 实现效果

直接使用go build编译出可执行文件。随后运行程序,Ctrl + C 停止程序后,会打印出这段时间内的统计结果。 ./Snipaste_2022-08-26_21-49-14.jpg

3. 总结

3.1 cilium/ebpf 库的优势

cilium/ebpf 是纯 Go 语言实现的库,在用户空间的代码编写中,可以使用很多 Go 语言的高级特性,像 Goroutine,Channel和定时器等。

同时它也封装了很多和bpf程序相关的接口,例如,遍历 MAP 中的 key 和 value。在 libbpf 中并没有直接遍历 MAP 拿到 key 和 value 的方法,需要自己来实现,例如:

1
2
3
4
5
6
7
8
9
int key = -1, next;
int value;
while (bpf_map_get_next_key(test_map_fd, &key, &next) == 0) {
    if (bpf_map_lookup_elem(test_map_fd, &next, &value) == -1) {
        continue;
    }
    printf("key: %d, value: %d\n", next, value);
    key = next;
}

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 方法。