0%

从0开始的ebpf程序开发

从0开始的ebpf程序开发

上一篇博客中把learning ebpf这本书读完了,获得了足够的基础理论知识,今天开始进行ebpf程序的开发。目前打算是用c写kernel program,然后用ebpf-go做客户端程序,bpf2go套一层进行编译和骨架生成(到时候程序复杂了之后可能得手写Makefile编译.o,然后用bpf2go对.o生成骨架吧)

开发环境

Linux ubuntu 5.15.0-88-generic #98-Ubuntu SMP Mon Oct 2 15:18:56 UTC 2023 x86_64 x86_64 x86_64 GNU/Linux
go version go1.21.3 linux/amd64

goland+clion+jetbrain gateway远程开发。主要原因是用不惯vscode

但是gateway一万个bug,比如run as privileged功能勾了没用,有时候不知道按了什么操作之后IDE的部分按键失效,以及git窗口鼠标实际位置和点击位置有偏移之类的。。。

所以是直接上root号+编译完了不运行手动去终端运行解决的

后来升级了机器上了32g内存就可以直接开一个16g内存的虚拟机了….

debug命令

感觉就bpftool一路按?
bpftool prog list展示所有加载的ebpf程序
cat /sys/kernel/tracing/trace_pipe看 printk结果,需要注意的是应该使用cat而非less,我感觉这个文件本身就是一个流,cat之后会阻塞住等待输出,但是less会直接卡住Ctrl C都退不掉,虽然我知道应该是用法不对但是我懒得搜了。

要是能调试内核直接打断点之类的就好了,可惜我不会。。所以,最后全靠print调试法。。。

header准备

内核态程序的编写还是要多多仰仗libbpf的封装的,无论你的客户端程序用的是什么,所以libbpf该学还得学,该装还得装。

vmlinux.h

必须include的header
linux全套内核数据结构,使用bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h命令手动导出。该文件会随着linux的版本变化,所以我感觉是自己编译的时候生成一份会比较好,ebpf-go的example中include的common.h就是对该文件的精简版(不知道是不是这部分数据结构是稳定的?)
虽说数据结构是版本固定的,但是可以使用libbpf的CORE方法实现重定向,以达到一次编译四处运行的效果

贴一篇不错的介绍博文
What is vmlinux.h?

以及我之前不知道从哪抄了一个<linux/bpf.h>,和vmlinux.h里面冲突了一万个定义,猛报redefinition of enumerator xxx,但是这个bpf.h又缺一万个定义,所以只include vmlinux.h即可

也有很多项目就自己把几个关键头文件拉下来自己整一个header目录,编译的时候用-I参数指定头文件

bpf_xxx.h

bpf_helpers.h,bpf_tracing.h等,可以从libbpf处直接下一个最新版下来,也可以装了libbpf之后直接<bpf/bpf_helpers.h>来include。暂时不清楚tracing.h里面都有啥,反正先include,如果用不上的话编译器会帮我优化掉的吧。

主程序

先把需要查的东西都翻出来(我怎么没找着最简单的bpf_printk?)
bpf helper man page

少用BCC

少用BCC!少用BCC!少用BCC!
写伪C一时爽,写多了到时候要写C的时候就等死8
网上的教程还一堆用BCC的,不认真看有时候抄一下发现完全用不了

SEC宏

这个不多说了8,根据给的值定义编译出来的代码section,map,kprobe,tracepoint等都是靠这个宏定义的

参数

如果写的是kprobe,可以使用BPF_KPROBEBPF_KPROBE_SYSCALL两个宏对参数直接进行一个解包,但是如果是uprobe,就只能接收一个struct pt_regs *ctx,然后用PT_REGS_PARM*这个宏去获取第x个参数,使用bpf2go的话默认会生成el和eb大小端两个文件,但是这个宏是只适用于amd和arm的,因为pt_regs是一个放寄存器的结构体,显然其他的奇怪架构寄存器结构不一样。所以需要加一个-target amd64指定架构

返回值

使用类似的宏,一开始乱按PT_REGS_RET,还以为是返回值,结果完全不对,看了下定义这个是sp寄存器,栈顶寄存器,应该是函数返回时的返回地址,返回值是PT_REGS_RC

uprobe

kprobe好像可以直接考SEC宏直接指定内核符号,而uprobe因为是hook用户态的程序,自然不可能直接指定符号。直接网上寻找教程的话是说直接去寻找二进制然后算symbol的偏移去hook,略微的有些手动。而learning-ebpf中提到了对于动态链接库,可以使用SEC(/uprobe/path/to/so/xxx.so)去hook,但是无论是哪种方法,都是需要对特定环境进行特定配置

又,需要二进制没有被trip过保留符号信息才能找到函数的偏移。对于.so可以使用nm -D xxx.so来获取导出函数

uprobe实现原理

类似于调试,将目标位置的指令替换为中断,具体看下面这篇博文
ebpf user-space probes 原理探究

ebpf-go特化?

因为想hook经典的SSL_read/write,是一个uprobe,理论上来说需要用上述提到的办法,但是ebpf-go里面有一个奇妙的函数,OpenExecutable,从文档上看就直接是指定一个二进制打开,然后反手指定一个symbol名字就可以attach uprobe上去了。
但是函数注释里面是提到了如果executable是shared library的话必须提供offset,但是又提到提供的offset会覆盖原有的symbol,不是很懂注释到底想表达什么,反正最后我没加offset也跑起来了。。。。

core read

显然,如果是读用户态数据的话是不存在CORE问题的,只有读内核态数据的时候考虑使用,用bpf_core_read来代替bpf_probe_read_kernel么?暂时没用上,但是看到一篇看起来很不错的博文,贴一下
bpf core reference guide

map

不知道之前是在哪看的map定义,反正跑了半天跑不起来,最后找了一个其他的定义方法,如下

struct bpf_map_def SEC("maps") map_name = {
    .attr = value;
    ......
};

用bpf_map_def这个结构体可以直接定义出一个map来,虽然内部原理暂时未知,type属性用于指定map类型,先搓个perf array出来传递信息试试

perf event array

新版本的ring buff,他的好处是输出时用户态直接接受,就是一个生产者消费者模型,而其他的map就需要用户态轮询去一个个查

perf array的定义大致需要这几个值

    .type = BPF_MAP_TYPE_PERF_EVENT_ARRAY,
    .key_size = sizeof(int),
    .value_size = sizeof(__u32),
    .max_entries = 16,

使用bpf_perf_event_output写入,具体参数看bpf helper的man page

perf array里面一般会塞一个自定义的结构体,这里有一个大坑,bpf2go默认情况下是不会导出这些自定义结构体的,我排查了一个多小时。。。最后是找了个官方的例子一行行看过去才发现的。。。

在其README下面有这么一段话

bpf2go generates Go types for all map keys and values by default. You can disable this behaviour using -no-global-types. You can add to the set of types by specifying -type foo for each type you’d like to generate.

我在排查的时候看到了这个,但是没有意识到这就是问题所在,bpf2go会自动对map的kv对进行导出,但是perf array的kv对并不能直接塞自定义结构体,就需要用户手动指定-type来导出。。。

小坑

需要注意的是,虽然这里面塞的是自定义结构体,但是在定义的时候,value_size一定得是4字节的,也就是sizeof(u32),不是很懂设计原理,可能是header和tail是两个指针?

而在对数据进行输出时的bpf_perf_event_output,第三个参数是void*,即为用户自定义的结构体,而第四个参数length需要是sizeof(struct ),而非指针的大小。
(这大概也是为什么这个map的大小是由客户端这边指定的),以go-ebpf为例,在定义perf event array时需要填一个buffer大小

verifier log

之前学的时候懒得看verifier,现在自己写起来了verifier报错看不懂一点。。。
ebpf-go提供了一个接口去获取完整的verifier log,如下

    err := loadSslObjects(&sslObj, nil)
    if err != nil {
        var ve *ebpf.VerifierError
        if errors.As(err, &ve) {
            log.Fatalf("verify error:\n %+v", ve)
        }
        log.Fatal(err)
    }

%+v可以输出完整的日志,否则就只有关键报错一行。至于怎么读log,可以回顾一下learning ebpf的pdf。其实看多了之后一眼就能看出来是哪里出问题了,倒也还算友好

safety access

我是想hook SSL_read/write,里面有三个参数,后面两个是buffer和num,分别对应缓冲区和写入的字节数,本来是想bpf_probe_read_user读num字节数,然后做了num的边界检查,但是无论如何verifier都过不了。。。不知道为什么,一直显示R2可能是负数,虽然我已经套了num>0判断和定义num为unsigned int。。。

最后找到了一篇博文,给出了解决方案
The art of writing eBPF programs: a primer

如下写法不行

    res = bpf_probe_read_str(map_value, PATH_MAX, pathname);
    if (res > 0 && res <= path_max)< span>
        map_value[res - 1] = 0;

需要尽可能的将使用和判断靠近,并且上界的限制用大小于号不行,得用与运算。。。?
如下为可以通过verifier的版本

    res = bpf_probe_read_str(map_value, PATH_MAX, pathname);
    if (res > 0)
        map_value[(res - 1) & (PATH_MAX - 1)] = 0;

最后的最后发现bpf_probe_read_user的size只要是第一个参数的size就行了,进来的数据小了不会爆,多了也不会爆。。。

为了一个本身不存在的问题找了半天。。。

大内存变量

由于ssl的buffer最大是4096(好像),所以我需要声明一个长为4096的数组,然后直接爆了,给出超级报错Looks like the BPF stack limit of 512 bytes is exceeded,并且初始化的为0的时候报第二个错A call to built-in function 'memset' is not supported,ebpf程序的栈为512,该限制无法逾越,且不能Alloc堆上内存。但是bpf map可以当做ebpf程序的堆来使用(输入还是不能动态分配。。)

声明一个简单的per cpu array存储数据

struct bpf_map_def tmp_storage_map = {
    .type = BPF_MAP_TYPE_PERCPU_ARRAY,
    .key_size = sizeof(u32),
    .value_size = PATH_MAX,
    .max_entries = 1,
};

感觉可以不加SEC("map")对外导出,内部存个数据即可
才怪,不加的话verifier会不认识你传入的map指针,报一个type=map_value expected=map_ptr的错
BPF verifier throws error “expected=map_ptr”
如果要写复杂结构体,也可以value size放一个结构体
关于在eBPF程序中构建长字符串并进行修改

看到了别的例子,除了用一个array来存储数据外,还可以直接定义一个全局变量,全局变量位于bss段,也就不在栈上,自然就不会爆栈了。但是这种情况的话全局变量只有一个,如果多个CPU同时访问的话会不会有条件竞争之类的可能性呢?

所以解决方案是全局变量是一个empty对象,实际的对象使用hashmap之类的方式存储。空对象只被用于初始化map中的项。

除此之外,如果内核版本高可以直接迁移到ringbuf,如果不需要在多个probe之间传递数据的话,可以直接从ringbuf里面申请空间,总算有点动态的感觉了。不过ringbuf申请的空间需要当场释放掉,所以不能在多个probe直接传递ringbuf的内存(大概,我也没试过,估计是不行的8)

helper函数返回值

helper函数成功返回0,失败时对应错误返回负数,然而我找了半天也没找着返回值对应的错误在哪。最后在一个奇怪的角落找到了,这个返回值似乎是直接内核的系统错误返回值,使用errno <NUM>命令可以拿到对应的错误类型
bpf_probe_read_str return error(-14) sometimes

可能遇到的奇怪bug以及解决方案

bpf_probe_read_user return -14

-14刚才已经讲过了,可以用errno命令看报错,该报错的含义为Bad Address,但是问题在于,我输入的指针是从ctx里面用PT_REGS_PARM*拿到的,这个值肯定不会有问题,不然函数本身也跑不起来

我先在这里放一个函数签名

long bpf_probe_read_user(void *dst, u32 size, const void *unsafe_ptr)

排查了一个下午,对比了各家代码,最后发现问题出在size上。我一开始的设计将size设置为了sizeof(buff),用的是dst buff的大小,这样子保证内存不会越界,但是来自用户输入的unsafe_ptr的数据长度不一定有size那么大,然后我size又设置成了4096,是不是大小足够跨页之类的导致稳定报错?

总之,最后打了个uretprobe的hook,从返回值中拿到unsafe_ptr中数据的长度在read就表现稳定了

bpf_perf_event_output return -7

我也放一个函数签名在这

long bpf_perf_event_output(void *ctx, struct bpf_map *map, u64 flags, void *data, u64 size)

这个问题是和上面那个返回-14同时出现的,7的意思是args too big,虽然我的data和size肯定是对应的,但是我估摸着吧肯定是之前读出来的数据有问题导致这里写入会出错,因为我把第一个问题修好之后这个问题也就消失了

verifier的迷之上界判断

如果你写过类似如下的代码,你会发现verifier对你使用if else进行的范围判断视而不见,然后和你说这个范围不行

int len = xxxx;
if(len > MAX_SIZE){
    len = MAX_SIZE;
}

理论上来说,此时的len已经只可能是小于MAX_SIZE的值,但verifier会认为len没有上界(但是如果你用大小于号去限制下界的话,好像verifier又是能认出来的。。。。)。不要问我为什么,反正就是这么判断的,目测verifier支持的上界判断必须是和上界做与运算,即改为如下版本即可通过。

int len = xxxx;
if(len > MAX_SIZE){
    len = MAX_SIZE;
} else {
    len = len & (MAX_SIZE - 1);
}

除此之外,verifier也无法识别过于复杂的条件,比如我想有一个超长buff,然后用index存当前数据的offset,将多次的数据读到一个buff里,verifier就是做不到的。就是程序分析中的may analysis,我的data_len和offset均属于[0, 4096),且data_len+offset也属于[0, 4096),这两个条件合起来之后,因为只存变量的取值范围,所以得到的还是[0, 4096)捏。

当时感觉有点无解然后创了个号去Stack Overflow提问,然后发现是真的无解,哈哈。
ebpf: “value is outside of the allowed memory range” when reading data into array with offset

刚才点上去一看我这个问题怎么还被点了个踩?我的吧,但是我确实没找着什么相关问题啊

map创建报错 map create: invalid argument (without BTF k/v)

这个网上的说法都是说你内核版本太低用了太先进的feature,但是我内核6.6+顶级先进arch和我还说这个就不合理了。我这里遇到的问题是ringbuf创建不成功,我在一通乱按之后发现把max_entries上调到4096以上就可以通过。暂且不知道怎么回事,是页大小刚好是4k然后map至少得有一个页大么?

找一个靠谱的doc

一开始一直看的是直接谷歌搜SSL_read后出来的第一条,SSL_read,只能说讲的有点抽象,几个函数堆一起讲,导致我也一直没看懂在说什么,最后是翻到了这个手册才完全理解
ssl_read(3) - Linux man page
也可能是这个的排版看着舒服点。。。

LOG

要写一个大规模的程序的话,日志系统肯定是不能少的,至少得有一个往用户态输出的方法,天天printk不是一件事。然后log的话最好也能支持fmt字符串,这样子写起来也舒服一点。但要是需要支持fmt,那就务必需要支持可变参数,然后我搜了一圈得出的结论是ebpf不支持可变参数。在外面把msg渲染好再传进去也太憨了一点,加上栈大小限制,即使我将log的长度设置成128也最好不要放在栈上,还是另开一个map处理为妙。那这样子每次log要写一大堆,就更憨了,最后找了半天在github上找到了一个用宏展开来代替可变参数的log例子

#define LOG(ctx, level, fmt, __args...)                                                 \
({                                                                                       \
    int zero = 0;                                                                       \
    struct LogEvent* logEvent = bpf_map_lookup_elem(&logStorageMap, &zero);             \
    if(logEvent != 0 && logEvent->buf != 0){                                            \
        logEvent->logLevel = level;                                                     \
        int len = BPF_SNPRINTF((char*)logEvent->buf, LOG_SIZE, fmt, __args);            \
        if(len > 0){                                                                    \
            bpf_perf_event_output(ctx, &logEventArray, BPF_F_CURRENT_CPU, logEvent, sizeof(struct LogEvent));    \
        }                                                                               \
    }                                                                                   \
})

大括号外面再套一层小括号可以让这个宏展开之后仍然是一个statement,就需要在后面添加分号,如果直接用大括号的话展开之后直接是一个block,这时后面就可以不加分号。加了也就是个空语句没有影响,但是ide的warning会让我看着很不舒服

然后用户态程序监听这个log perf array并且根据结构体里的level按照level输出即可。

memset清空buff

这里踩了一个新坑,因为我拿PERCPU_ARRAY做数据的buff,而所有的写操作都是往这个buff里写入目标长度的数据,这会产生一个问题,如果之前写入了一次长数据,那么之后的短数据只能覆盖掉长数据的前面部分,多出来的部分还是会存在,所以需要memset一下。然后我看大家都能用一个叫__builtin_memset的函数,就跟着用,然后爆了,报错a call to built-in function 'memset' is not supported.就是那个经典爆栈的报错。最后发现这个函数不是不能用,但是也只能初始化长度不大于512的数据,我buff长度4096是一点办法没有了。
后来想着要不自己写个for循环一位位赋值得了,最后的最后我意识到了,我直接在数据里面加一个len字段,客户端直接根据这个len的长度去读取就可以了

使用cgroup监控目标范围内的进程

ebpf中有一个helper function叫bpf_current_task_under_cgroup,用于判断当前的运行上下文是否在对应的cgroup下。cgroup是control group的缩写,用于对进程的内存,CPU,io等资源进行控制,docker也是通过cgroup对容器的资源占用进行的限制。

入门可以尝试着看点文章Linux资源管理之cgroups简介
美团的这篇有点老了,cgroup也分v1和v2版本,v1的话感觉复杂一些,v2就变得简洁了不少。

可以从vfs中访问到cgroup信息,位于/sys/fs/cgroup下,然而,不同版本的cgroup的文件结构也并不一致。

对于v2,/sys/fs/cgroup就是根controller,一个cgroup目录下面配好对所有资源的限制

而对于v1,/sys/fs/cgroup下面就塞了一堆controller,其下的每一个文件夹对应一种资源的controller,然后再在各controller下配置不同cgroup对不同资源的访问,这种模式下每个controller下的文件结构与v2的文件结构类似

但除此之外,还有一个hybrid的混搭模式,既有v1也有v2,方法就是在v1的基础上,在/sys/fs/cgroup下添加一个unified目录,该目录的内容就是v2的/sys/fs/cgroup内容

再贴两个不错的链接
Mount the ‘unified’ cgroup
详解Cgroup V2

在讲了一堆cgroup的入门知识后,讲一下ebpf怎么用
首先需要创建一个BPF_MAP_TYPE_CGROUP_ARRAY类型的map,然后往里面塞cgroup文件句柄,对于systemd拉起来的服务,可以直接用systemctl status xxx查看,将里面的cgroup项拼上/sys/fs/cgroup/(unified),对应的目录就是需要的句柄(仅适用于hybrid和v2模式,v1不清楚)

然后在ebpf程序里面调用bpf_current_task_under_cgroup即可

map复用

比如说我有多个ebpf程序,他们共享一个log map,用于对外输出一些日志信息。最好的情况自然是我客户端只用听一个map,所有的ebpf程序都只往这一个map中输出。需求很简单,实现也不复杂。
最方便的做法是使用map pinning,将map pin在文件系统上,然后所有程序按照名字约定去加载。在cilium的example里面也写的有用法
kprobepin

具体写法可以看例子,需要使用这种BTF格式的map define,添加一个pinning属性,然后在ebpf程序加载的时添加一个Map选项指定PinPath,在加载程序的时候对于属性为pinning的map就会优先查看PinPath下是否存在,有直接复用,没有则创建。

这种情况下,可以考虑使用ebpf go去创建这个map,便于对map进行管理,否则会出现程序退出后pinning map没有卸载的问题。但是bpf2go生成的结构体又默认是不导出的,还得自己封装半天,不甚方便

(最后map也没复用,两个不同的程序用了不同的log map进行输出,如果有多个ebpf程序想共用一个map还有一种方法就是把他们变成一堆.h文件直接编译成一个ebpf程序。。)

perf array read length

这个是一开始写log的时候发现的问题,比如我先写入长100的log,再写入长50的log,那么读长50的log的时候会把之前长100的log的数据也读进来。一开始为了解决这个问题,我是给log结构体里面加了个length,然后在用户态手动按长度截断。后来我发现是在bpf_perf_event_output中有一个len选项,我之前都默认用的sizeof(struct),但是我实际的数据长度却不够struct的长度,导致出现这么个覆写问题。实际上perf array在写的时候也会先写入一个长度,然后客户端再根据长度读。所以也可以在写之前计算数据的长度,然后写入精确长度的数据。不过这样子客户端又会爆炸,因为客户端将数据应用到结构体时需要完整的大小,写入的数据长度不足size会读出eof,然后就要客户端再适配一下,把读出来的数据padding到和结构体长度一致,一个可能的例子如下。

    var logEvent sslLogEvent
    buff := make([]byte, p.eventSize)
    copy(buff, record.RawSample)
    err := binary.Read(bytes.NewBuffer(buff), binary.LittleEndian, &logEvent)

想了一下感觉好麻烦。。。不知道copy整个结构体和计算size哪个的开销小。应该要尽可能的降低ebpf在内核中执行的指令数的。

move from perf array to ringbuf

ringbuf是5.8内核中才加入到ebpf中的技术,相较于perf array有着更好的性能,以及吹的一些乱七八糟优点。总之,有先进技术就像先进技术迁移一下,下面有一篇吹ringbuf优点的文章。
BPF ring buffer

ringbuf提供了bpf_ringbuf_reserve,bpf_ringbuf_submit,bpf_ringbuf_output三个方法进行输出,其中bpf_ringbuf_output是为了兼容perf array使用的,可以继续就着per cpu array开的空间进行输出。同时,也会产生之前提到的per cpu array导致的数据覆盖问题。

bpf_ringbuf_reserve,bpf_ringbuf_submit是另一组函数,同时完成了空间分配和输出,比较先进。bpf_ringbuf_reserve会从ringbuf里面先预留一块空间,可以直接在这个空间上操作,操作完了submit一键提交。因为每次分配的内存也都不一样,所以也不会有之前的覆盖问题。

但是,网络上的ringbuf例子都非常简单,全都是bpf_ringbuf_reserve之后直接submit,而我在部署ringbuf的时候却遇到了一个奇怪的verifier报错,Unreleased reference,网上搜了一圈没搜到有用的信息。。。但不难猜出应该是ringbuf申请的空间需要手动释放,释放有两种情况,一种是bpf_ringbuf_submit进行提交,另一种是bpf_ringbuf_discard,手动丢弃这个内存。大部分的例子不会考虑中间过程的错误,翻来覆去就是那两句reserve完了submit。而实际处理过程中,应当在出现错误不会再输出的情况下手动discard去释放内存,即可解决Unreleased reference这个报错。
且不能重复释放,否则会产生double free之类的问题。verifier也不会通过

misc

记一些和ebpf关系感觉没那么大的东西

golang同步

以前一直拿ctx同步,但是就感觉好像并没有那么同步,因为context是用于同步运行状态的,一个ctx结束只意味着所有对应的context都应该结束。但context并不能保证同步等待。
我起了一堆goroutine,然后用context对他们进行同步,所有goroutine在收到Ctrl C的时候结束运行,然后关闭对应的资源。然而,结束运行能够做到,而关闭资源却不一定。主进程(routine?)退出的飞快那其余的routine还没来得及关就结束了,为此,还需要引入一个额外的waitgroup进行同步等待,在ctx取消了执行后,还需要用waitgroup进行同步等待。

但是这样子每个函数除了ctx还要再传入一个wg,感觉略显臃肿。怎么回事呢

用户态跟踪

除了uprobe以外,也允许开发者主动在二进制中留下标记,被称为USDT(User-level Statically Defined Tracing),在不同的发行版中只要USDT的定义不变,就能准确的hook到对应的位置。不过这个很依赖开发者,所以感觉对于我目前的开发来说似乎意义不大。

可以使用readelf来读取一个已知二进制中定义的USDT,readelf -n <binary>即可,目测其中的.note.stapsdt节就是USDT。但是USDT是需要对应的编译选项才会保留的,有的release本身默认不带,就需要进行源码编译,实用性进一步降低。。。

简单试了下php,python,java,node这四个解释器性质的语言,我机器上默认带有USDT的只有PHP。。。

贴一个参考链接
eBPF Tutorial by Example 15: Capturing User-Space Java GC Event Duration Using USDT

参考文献

很详细的一篇讲linux user space tracing的文章
Exploring USDT Probes on Linux

涵盖了各种边角点的博客
mozillazg’s blog