如何給 eBPF 程序寫單元測試

前言

eBPF 程序還很年輕,周邊質量建設體系還剛起步,常用於 Linux 內核上的監控跟蹤,本身比較底層,測試成本很高。對於常寫 eBPF 的同學,特別頭疼的是快速迭代的項目,如何保證功能正常。如何給 eBPF 程序寫單元測試呢?譯者看到一篇文章,分享給大家。本文使用 openAI 翻譯,如有錯誤,請看原文:Unit Testing eBPF Programs[1]

當然,原文在 ** Hacker News[2]** 上也被熱烈討論,大佬 Daniel[3] 給出自己的看法,文章質量也很高,推薦看看。

BPF_PROG_RUN 很棒,但不幸的是它依賴於正在運行的內核版本。爲此,我編寫了vmtest(https://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[4] 在快速迭代。

好了,最後一個假設,你已經正確安裝了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

讓我們來看一下它的前向聲明 [5]:

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_
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 程序是否執行了你預期的操作。

參考資料

[1]

Unit Testing eBPF Programs: https://who.ldelossa.is/posts/unit-testing-ebpf/

[2]

Hacker News: https://news.ycombinator.com/item?id=35989911

[3]

大佬 Daniel: https://github.com/danobi/

[4]

Windows eBPF: https://github.com/microsoft/ebpf-for-windows

[5]

前向聲明: https://elixir.bootlin.com/linux/v6.2.11/source/tools/lib/bpf/bpf.h#L454

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/56ms5xpQn4iWaKab90DxNA