如何给eBPF程序写单元测试
前言
eBPF程序还很年轻,周边质量建设体系还刚起步,常用于Linux内核上的监控跟踪,本身比较底层,测试成本很高。对于常写eBPF的同学,特别头疼的是快速迭代的项目,如何保证功能正常。如何给eBPF程序写单元测试呢?译者看到一篇文章,分享给大家。本文使用openAI翻译,如有错误,请看原文:Unit Testing eBPF Programs
当然,原文在 Hacker News上也被热烈讨论,大佬Daniel给出自己的看法,文章质量也很高,推荐看看。
BPF_PROG_RUN很棒,但不幸的是它依赖于正在运行的内核版本。为此,我编写了
vmtest
(https://dxuuu.xyz/vmtest.html),它专门用于BPF_PROG_RUN的使用场景。
eBPF的单元测试
无论你喜欢与否,编写单元测试几乎已经成为你的代码的必需品。在进行更改时,它们为你提供了一个安全网,并在更改后全部通过时给你一种愉悦、温暖的感觉。
在处理内核补丁时,我不得不研究为 eBPF 程序编写单元测试。事实证明,内核开发人员已经考虑到这一点,并已存在基础设施来实现它。
我将提供一个实际的例子,演示如何对一个 TC eBPF
程序进行单元测试。在这个测试中,我们希望确认查找一个发送到外部 IP 地址的数据包的路由是否会选择默认网关。我们将完全控制测试所运行的网络命名空间。如果你对这其中的任何概念一无所知,别担心,我将介绍的概念同样适用于测试其他类型的 eBPF 程序。
测试环境
在本文中,我假设你知道如何使用clang
、bpftool
编译你的eBPF程序,并且知道如何生成一个vmlinux.h
文件。
话虽如此,我们确实需要在你的编程环境中进行一些基础设置,并安装我们需要的工具。
你必须拥有:
bpftool – 除了生成vmlinux.h文件之外,还将用于为你编译的eBPF程序生成一个"骨架"加载器。 clang – 我们需要它来编译eBPF程序。 make – 用于运行我的修改过的Makefile。此外,你的机器上必须具有CAP_SYS_ADMIN
特权,如果你不知道是什么意思,99%的情况下以root身份运行将满足此要求。
我还假设你使用的是Linux系统,你可能会认为这是一个多此一举的说法,但Windows eBPF在快速迭代。
好了,最后一个假设,你已经正确安装了libbpf
,并且clang/gcc
能够找到它并编译你的eBPF程序。
介绍BPF_PROG_RUN命令
我们想要重点关注用于单元测试eBPF程序的核心功能,这个功能就是一个名为BPF_PROG_RUN
的eBPF命令。
这个命令从BPF_PROG_TEST_RUN
改名而来,这个标识符可能是可以互换使用的。命令
是一个枚举值,可以传递给Linux暴露的bpf系统调用。然而,libbpf通常会为了方便和健壮性而封装bpf系统调用的使用。
因此,我们将重点关注使用libbpf封装的BPF_PROG_RUN
命令,即bpf_test_run_opts
。
让我们来看一下它的前向声明:
struct bpf_test_run_opts {
size_t sz; /* size of this struct for forward/backward compatibility */
const void *data_in; /* optional */
void *data_out; /* optional */
__u32 data_size_in;
__u32 data_size_out; /* in: max length of data_out
* out: length of data_out
*/
const void *ctx_in; /* optional */
void *ctx_out; /* optional */
__u32 ctx_size_in;
__u32 ctx_size_out; /* in: max length of ctx_out
* out: length of cxt_out
*/
__u32 retval; /* out: return code of the BPF program */
int repeat;
__u32 duration; /* out: average per repetition in ns */
__u32 flags;
__u32 cpu;
__u32 batch_size;
};
#define bpf_test_run_opts__last_field batch_size
LIBBPF_API int bpf_prog_test_run_opts(int prog_fd,
struct bpf_test_run_opts *opts);
如果我们来看实现,我们会发现bpf_prog_test_run_opts
简单地将提供的opts复制到一个内核将拥有的结构体中,对opts结构体进行一些合理性检查,然后直接调用bpf系统调用。
libbpf函数的参数接受一个eBPF程序文件描述符和一个opts结构体。
eBPF程序文件描述符表示加载到内核中的eBPF程序,我们将在本文后面演示一种方便获取此文件描述符的方法。
opts结构体既提供模拟数据,也提供函数的选项。虽然某些字段标注为可选,但我们将了解到这实际上取决于你正在测试的eBPF程序类型,这些字段到底是可选还是必需的。
在本文中,我们将使用以下重要字段:
sz
始终是必需的,它只需设置为sizeof(bpf_test_run_opts)
。
data_in
、data_size_in
允许您向传递给eBPF程序的ctx提供模拟数据,对于TC程序而言,就是模拟的IPv4数据包。
ctx_in
、ctx_size_in
允许您传入一个模拟的ctx,对于TC程序而言,就是模拟的__sk_buff
结构,它是eBPF对内核套接字缓冲区的表示。
测试用例和Skeleton加载器
现在介绍了bpf_test_run_opts
,让我们开始编写我们的eBPF测试用例。
我们还将使用bpftool生成一个Skeleton加载器,它是一个带有函数的头文件,用于将我们编译的eBPF程序加载到内核,并为我们提供一个对已加载程序的句柄。
该句柄可用于获取加载的eBPF程序的文件描述符,并在内核运行时与其交互。我们的测试用例的目标是确保源自主机、目标为外部节点的数据包选择默认路由,并转发到正确的接口。
为了测试这一点,我们将利用eBPF辅助函数bpf_fib_lookup
。我们不需要详细了解这个辅助函数的工作原理,简单来说,我们提供一个传入数据包的源地址和目的地址,它将返回一个接口(如果有的话),该数据包将被转发到该接口。
在我们的测试用例中,我们希望上述接口是网络命名空间的默认网关。我们的测试数据包的源地址将为127.0.0.1
,目的地址将为8.8.8.8
。
由于我们运行的是单元测试,实际上不会发送任何数据,并且主机之外不会产生任何副作用。请记住,这个测试有点人为,主要是为了展示测试基础设施的一些特点,我们更倾向于演示而不是实用。
好的,让我们来看看我们的测试eBPF程序:
// fib_lookup.bpf.c
#include "../vmlinux.h"
#include <bpf/bpf_helpers.h>
#define TC_ACT_OK 0
#define TC_ACT_SHOT 2
#define TC_ACT_REDIRECT 7
#define AF_INET 2 /* Internet IP Protocol */
struct bpf_fib_lookup fib_params = {0};
int fib_lookup_ret = 0;
SEC("tc")
int fib_lookup(struct __sk_buff *skb)
{
struct iphdr *ip = 0;
bpf_printk("performing FIB lookup\n");
bpf_printk("fib lookup original ret: %d\n", fib_lookup_ret);
fib_lookup_ret = bpf_fib_lookup(skb, &fib_params, sizeof(fib_params),
0);
bpf_printk("fib lookup ret: %d\n", fib_lookup_ret);
return TC_ACT_OK;
}
char _license[] SEC("license") = "GPL";
如您所见,测试非常简单。我们导入必要的头文件,然后定义两个全局变量,并将它们都设置为零。
通过将这些变量定义为全局变量并将其设置为零,实际上使其通过我们的骨架在用户空间中可用。让我们使用以下Makefile来编译并生成此eBPF程序的骨架。
# makefile
CFLAGS += -g3 \
-Wall
LIBS = bpf
all: fib_lookup.bpf.o fib_lookup.skel.h
fib_lookup.bpf.o: fib_lookup.bpf.c
clang -target bpf -Wall -O2 -g -c $<
fib_lookup.skel.h: fib_lookup.bpf.o
bpftool gen skeleton $< > $@
test: test.c
gcc $(CFLAGS) -l$(LIBS) -o $@ $<
.PHONY:
clean:
rm -rf fib_lookup.bpf.o
rm -rf fib_lookup.skel.h
rm -rf test
现在先忽略测试二进制文件,我们将在下一部分编写测试运行器。
如果我们检查文件fib_lookup.skel.h
,我们会遇到一个有趣的结构。
// fib_lookup.skel.h
struct fib_lookup_bpf {
struct bpf_object_skeleton *skeleton;
struct bpf_object *obj;
struct {
struct bpf_map *bss;
struct bpf_map *rodata;
} maps;
struct {
struct bpf_program *fib_lookup;
} progs;
struct {
struct bpf_link *fib_lookup;
} links;
struct fib_lookup_bpf__bss {
struct bpf_fib_lookup fib_params;
int fib_lookup_ret;
} *bss;
struct fib_lookup_bpf__rodata {
} *rodata;
#ifdef __cplusplus
static inline struct fib_lookup_bpf *open(const struct bpf_object_open_opts *opts = nullptr);
static inline struct fib_lookup_bpf *open_and_load();
static inline int load(struct fib_lookup_bpf *skel);
static inline int attach(struct fib_lookup_bpf *skel);
static inline void detach(struct fib_lookup_bpf *skel);
static inline void destroy(struct fib_lookup_bpf *skel);
static inline const void *elf_bytes(size_t *sz);
#endif /* __cplusplus */
};
这是我们加载的eBPF程序的句柄,当我们调用Skeleton加载器时,它会返回给我们。
static inline struct fib_lookup_bpf *
fib_lookup_bpf__open_and_load(void)
在这个文件里,我感兴趣的在这
struct fib_lookup_bpf__bss {
struct bpf_fib_lookup fib_params;
int fib_lookup_ret;
} *bss;
注意,我们可以在bss字段中访问到全局的零初始化变量。
这使得用户空间程序能够加载eBPF程序,并检索其句柄,然后在调用bpf_test_run_opts
之前和之后注入和读取全局变量的值。
这正是我们的测试运行器要做的事情。
编写测试运行器
如上所述,我们希望我们的测试运行器执行以下操作:
将eBPF测试程序加载到内核中,并获得在bpf_lookup.skel.h
中定义的fib_lookup_bpf
结构的句柄。
在运行测试之前,向测试中注入一个模拟的bpf_fib_lookup
参数结构。
利用libpf的bpf_test_run_opts
函数在用户空间中运行我们的测试。
读取生成的fib_lookup_bpf
和fib_lookup_ret
以确定是否使用了默认网关。
由于我们控制测试运行的网络命名空间,因此我们可以硬编码表示默认网关的接口ID(ifindex),使得我们的测试运行器更简单。
让我们来看一下测试运行器:
// test.c
#include <bpf/bpf.h>
#include <bpf/libbpf.h>
#include <stdio.h>
#include <bpf/bpf_endian.h>
#include "fib_lookup.skel.h"
#include "net/ethernet.h"
#include "linux/ip.h"
#include "netinet/tcp.h"
#define TARGET_IFINDEX 2
// in our test, we only care that the packet is the correct size,
// since our test does not touch any packet data.
char v4_pkt[(sizeof(struct ethhdr) + sizeof(struct iphdr) + sizeof(struct tcphdr))];
// create an empty skb as mock data, our tests do not touch any skb fields.
struct __sk_buff skb = {0};
int main (int argc, char *argv[]) {
struct fib_lookup_bpf *skel;
int prog_fd, err = 0;
// define our BPF_PROG_RUN options with our mock data.
struct bpf_test_run_opts opts = {
// required, or else bpf_prog_test_run_opts will fail
.sz = sizeof(struct bpf_test_run_opts),
// data_in will wind up being ctx.data
.data_in = &v4_pkt,
.data_size_in = sizeof(v4_pkt),
// ctx is an skb in this case
.ctx_in = &skb,
.ctx_size_in = sizeof(skb)
};
// load our fib lookup test program into the Kernel and return our
// skeleton handle to it.
skel = fib_lookup_bpf__open_and_load();
if (!skel) {
printf("[error]: failed to open and load skeleton: %d\n", err);
return -1;
}
// inject our test parameters into the fib lookup parameter, this primes
// our test.
skel->bss->fib_lookup_ret = -1;
skel->bss->fib_params.family = AF_INET;
skel->bss->fib_params.ipv4_src = 0x100007f;
skel->bss->fib_params.ipv4_dst = 0x8080808;
skel->bss->fib_params.ifindex = 1;
// get the prog_fd from the skeleton, and run our test.
prog_fd = bpf_program__fd(skel->progs.fib_lookup);
err = bpf_prog_test_run_opts(prog_fd, &opts);
if (err != 0) {
printf("[error]: bpf test run failed: %d\n", err);
return -2;
}
// check global variables for response
if (skel->bss->fib_lookup_ret != 0) {
printf("[FAIL]: fib lookup returned: %d", skel->bss->fib_lookup_ret);
return -1;
}
if (skel->bss->fib_params.ifindex != TARGET_IFINDEX) {
printf("[FAIL]: fib lookup did not choose default gw interface: %d", skel->bss->fib_params.ifindex);
return -1;
}
printf(" %d\n", skel->bss->fib_params.ifindex);
return 0;
}
让我们更新Makefile来构建我们的测试运行器。
CFLAGS += -g3 \
-Wall
LIBS = bpf
all: fib_lookup.bpf.o fib_lookup.skel.h test
fib_lookup.bpf.o: fib_lookup.bpf.c
clang -target bpf -Wall -O2 -g -c $<
fib_lookup.skel.h: fib_lookup.bpf.o
bpftool gen skeleton $< > $@
test: test.c
gcc $(CFLAGS) -l$(LIBS) -o $@ $<
.PHONY:
clean:
rm -rf fib_lookup.bpf.o
rm -rf fib_lookup.skel.h
rm -rf test
最后,我们提供一个脚本,为这个测试运行程序创建一个网络命名空间,并运行测试。
#!/bin/bash
NETNS_NAME="netns-1"
n='sudo ip netns'
nexec="sudo ip netns exec $NETNS_NAME"
function setup_netns() {
# add 'netns-1' network namespace where we'll
# run our test.
$n add $NETNS_NAME
# setup loopback
$nexec ip addr add 127.0.0.1 dev lo
# setup a dummy interface which can route to the default gw, and
# setup a route to the default gw.
$nexec ip link add name eth0 type dummy
$nexec ip link set up eth0
$nexec ip addr add 192.168.1.10/24 dev eth0
$nexec ip route add default via 192.168.1.11
# since 192.168.1.11 doesn't actually exist, create a perm arp-table entry
# for it, allowing fib lookup to succeed.
$nexec ip neigh add 192.168.1.11 dev eth0 lladdr "0F:0F:0F:0F:0F:0F" nud permanent
}
function teardown_netns() {
$n del $NETNS_NAME
}
setup_netns
$nexec ./test
teardown_netns
当我们运行这个脚本时,我们会得到以下输出:
[PASS]: ifindex 2
总结
让我们总结一下这篇文章的要点。
一个 eBPF 程序可以定义全局变量,在用户空间测试运行程序之前和之后都可以对其进行修改。
可以使用BPF_PROG_RUN
命令在用户空间中运行你的 eBPF 程序,该命令由 libbpf 中的 bpf_prog_test_run_opts()
函数包装。
一旦将 eBPF 程序编译为对象文件,你可以使用 bpftool 生成一个skeleton加载器,该加载器将你的 eBPF 程序加载到内核,并为用户空间程序提供访问上述全局变量的权限。
最后,你可以编写一个用户空间测试运行器,在测试之前设置加载的 eBPF 程序的全局变量,并在测试之后读取它们,从而确定 eBPF 程序是否执行了你预期的操作。
CFC4N的博客 由 CFC4N 创作,采用 知识共享 署名-非商业性使用-相同方式共享(3.0未本地化版本)许可协议进行许可。基于https://www.cnxct.com上的作品创作。转载请注明转自:如何给eBPF程序写单元测试
文章来源:
Author:CFC4N
link:https://www.cnxct.com/unit-testing-ebpf/