2

如何给eBPF程序写单元测试

 1 year ago
source link: https://www.cnxct.com/unit-testing-ebpf/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

eBPF程序还很年轻,周边质量建设体系还刚起步,常用于Linux内核上的监控跟踪,本身比较底层,测试成本很高。对于常写eBPF的同学,特别头疼的是快速迭代的项目,如何保证功能正常。如何给eBPF程序写单元测试呢?译者看到一篇文章,分享给大家。本文使用openAI翻译,如有错误,请看原文:Unit Testing eBPF Programs

当然,原文在 Hacker News上也被热烈讨论,大佬Daniel给出自己的看法,文章质量也很高,推荐看看。

BPF_PROG_RUN很棒,但不幸的是它依赖于正在运行的内核版本。为此,我编写了vmtesthttps://dxuuu.xyz/vmtest.html),它专门用于BPF_PROG_RUN的使用场景

eBPF的单元测试

无论你喜欢与否,编写单元测试几乎已经成为你的代码的必需品。在进行更改时,它们为你提供了一个安全网,并在更改后全部通过时给你一种愉悦、温暖的感觉。

在处理内核补丁时,我不得不研究为 eBPF 程序编写单元测试。事实证明,内核开发人员已经考虑到这一点,并已存在基础设施来实现它。

我将提供一个实际的例子,演示如何对一个 TC eBPF 程序进行单元测试。在这个测试中,我们希望确认查找一个发送到外部 IP 地址的数据包的路由是否会选择默认网关。我们将完全控制测试所运行的网络命名空间。如果你对这其中的任何概念一无所知,别担心,我将介绍的概念同样适用于测试其他类型的 eBPF 程序。

在本文中,我假设你知道如何使用clangbpftool编译你的eBPF程序,并且知道如何生成一个vmlinux.h文件。

话虽如此,我们确实需要在你的编程环境中进行一些基础设置,并安装我们需要的工具。

你必须拥有:

  1. bpftool – 除了生成vmlinux.h文件之外,还将用于为你编译的eBPF程序生成一个"骨架"加载器。
  2. clang – 我们需要它来编译eBPF程序。
  3. 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_indata_size_in允许您向传递给eBPF程序的ctx提供模拟数据,对于TC程序而言,就是模拟的IPv4数据包。

ctx_inctx_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之前和之后注入读取全局变量的值。

这正是我们的测试运行器要做的事情。

编写测试运行器

如上所述,我们希望我们的测试运行器执行以下操作:

  1. 将eBPF测试程序加载到内核中,并获得在bpf_lookup.skel.h中定义的fib_lookup_bpf结构的句柄。
  2. 在运行测试之前,向测试中注入一个模拟的bpf_fib_lookup参数结构。
  3. 利用libpf的bpf_test_run_opts函数在用户空间中运行我们的测试。
  4. 读取生成的fib_lookup_bpffib_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 程序是否执行了你预期的操作。

unit-testing-600x394.jpeg

CFC4N的博客 由 CFC4N 创作,采用 知识共享 署名-非商业性使用-相同方式共享(3.0未本地化版本)许可协议进行许可。基于https://www.cnxct.com上的作品创作。转载请注明转自:如何给eBPF程序写单元测试


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK