如何給 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 程序。
測試環境
在本文中,我假設你知道如何使用clang
、bpftool
編譯你的 eBPF 程序,並且知道如何生成一個vmlinux.h
文件。
話雖如此,我們確實需要在你的編程環境中進行一些基礎設置,並安裝我們需要的工具。
你必須擁有:
-
bpftool - 除了生成 vmlinux.h 文件之外,還將用於爲你編譯的 eBPF 程序生成一個 "骨架" 加載器。
-
clang - 我們需要它來編譯 eBPF 程序。
-
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_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_
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