作者:於桐,鄭昱笙
eBPF(extended Berkeley Packet Filter)是一種高性能的內核虛擬機,可以運行在內核空間中,用來收集系統和網絡信息。隨着計算機技術的不斷髮展,eBPF 的功能日益強大,進而被用來構建各種效率高效的在線診斷和跟蹤系統,以及安全的網絡和服務網格。
WebAssembly(Wasm)最初是以瀏覽器安全沙盒為目的開發的,發展到目前為止,WebAssembly 已經成為一個用於雲原生軟件組件的高性能、跨平台和多語言軟件沙箱環境,Wasm 輕量級容器也非常適合作為下一代無服務器平台運行時,或在邊緣計算等資源受限的場景高效執行。
現在,藉助 Wasm-bpf 編譯工具鏈和運行時,我們可以使用 Wasm 將 eBPF 程序編寫為跨平台的模塊,同時使用 C/C++ 或 Rust 來編寫 Wasm 程序。通過在 WebAssembly 中使用 eBPF 程序,我們不僅能讓 Wasm 應用享受到 eBPF 的高性能和對系統接口的訪問能力,還可以讓 eBPF 程序使用到 Wasm 的沙箱、靈活性、跨平台性、和動態加載,並且使用 Wasm 的 OCI 鏡像來方便、快捷地分發和管理 eBPF 程序。結合這兩種技術,我們將會給 eBPF 和 Wasm 生態來一個全新的開發體驗!
使用 Wasm-bpf 工具鏈在 Wasm 中編寫、動態加載、分發運行 eBPF 程序
Wasm-bpf 是一個全新的開源項目:https://github.com/eunomia-bp...。它定義了一套 eBPF 相關係統接口的抽象,並提供了一套對應的開發工具鏈、庫以及通用的 Wasm + eBPF 運行時實例。它可以提供和 libbpf-bootstrap 相似的開發體驗,自動生成對應的 skeleton 頭文件,以及用於在 Wasm 和 eBPF 之間無序列化通信的數據結構定義。你可以非常容易地使用任何語言,在任何平台上建立你自己的 Wasm-eBPF 運行時,使用相同的工具鏈來構建應用。更詳細的介紹,請參考我們的上一篇博客:Wasm-bpf: 架起 Webassembly 和 eBPF 內核可編程的橋樑。
基於 Wasm,我們可以使用多種語言構建 eBPF 應用,並以統一、輕量級的方式管理和發佈。以我們構建的示例應用 bootstrap.wasm 為例,大小僅為 ~90K,很容易通過網絡分發,並可以在不到 100ms 的時間內在另一台機器上動態部署、加載和運行,並且保留輕量級容器的隔離特性。運行時不需要內核頭文件、LLVM、clang 等依賴,也不需要做任何消耗資源的重量級的編譯工作。
本文將以 C/C++ 語言為例,討論 C/C++ 編寫 eBPF 程序並編譯為 Wasm 模塊。使用 Rust 語言編寫 eBPF 程序並編譯為 Wasm 模塊的具體示例,將在下一篇文章中描述。
我們在倉庫中提供了幾個示例程序,分別對應於可觀測、網絡、安全等多種場景。
使用 C/C++ 編寫 eBPF 程序並編譯為 Wasm
libbpf 是一個 C/C++ 的 eBPF 用户態加載和控制庫,隨着內核一起分發,幾乎已經成為 eBPF 用户態事實上的 API 標準,libbpf 也支持 CO-RE(Compile Once – Run Everywhere) 的解決方案,即預編譯的 bpf 代碼可以在不同內核版本上正常工作,而無需為每個特定內核重新編譯。我們希望儘可能的保持與 libbpf 的用户態 API 以及行為一致,儘可能減少應用遷移到 Wasm (如果需要的話)的成本。
libbpf-bootstrap 為生成基於 libbpf 的 bpf 程序提供了模板,開發者可以很方便的使用該模板生成自定義的 bpf 程序。一般説來,在非 Wasm 沙箱的用户態空間,使用 libbpf-bootstrap 腳手架,可以快速、輕鬆地使用 C/C++構建 BPF 應用程序。
編譯、構建和運行 eBPF 程序(無論是採用什麼語言),通常包含以下幾個步驟:
- 編寫內核態 eBPF 程序的代碼,一般使用 C/C++ 或 Rust 語言
- 使用 clang 編譯器或者相關工具鏈編譯 eBPF 程序(要實現跨內核版本移植的話,需要包含 BTF 信息)。
- 在用户態的開發程序中,編寫對應的加載、控制、掛載、數據處理邏輯;
- 在實際運行的階段,從用户態將 eBPF 程序加載進入內核,並實際執行。
bootstrap
bootstrap是一個簡單(但實用)的BPF應用程序的例子。它跟蹤進程的啓動(準確地説,是 exec() 系列的系統調用)和退出,併發送關於文件名、PID 和 父 PID 的數據,以及退出狀態和進程的持續時間。用-d <min-duration-ms> 你可以指定要記錄的進程的最小持續時間。
bootstrap 是在 libbpf-bootstrap 中,根據 BCC 軟件包中的libbpf-tools的類似思想創建的,但它被設計成更獨立的,並且有更簡單的 Makefile 以簡化用户的特殊需求。它演示了典型的BPF特性,包含使用多個 BPF 程序段進行合作,使用 BPF map 來維護狀態,使用 BPF ring buffer 來發送數據到用户空間,以及使用全局變量來參數化應用程序行為。
以下是我們使用 Wasm 編譯運行 bootstrap 的一個輸出示例:
$ sudo sudo ./wasm-bpf bootstrap.wasm -h
BPF bootstrap demo application.
It traces process start and exits and shows associated
information (filename, process duration, PID and PPID, etc).
USAGE: ./bootstrap [-d <min-duration-ms>] -v
$ sudo ./wasm-bpf bootstrap.wasm
TIME EVENT COMM PID PPID FILENAME/EXIT CODE
18:57:58 EXEC sed 74911 74910 /usr/bin/sed
18:57:58 EXIT sed 74911 74910 [0] (2ms)
18:57:58 EXIT cat 74912 74910 [0] (0ms)
18:57:58 EXEC cat 74913 74910 /usr/bin/cat
18:57:59 EXIT cat 74913 74910 [0] (0ms)
18:57:59 EXEC cat 74914 74910 /usr/bin/cat
18:57:59 EXIT cat 74914 74910 [0] (0ms)
18:57:59 EXEC cat 74915 74910 /usr/bin/cat
18:57:59 EXIT cat 74915 74910 [0] (1ms)
18:57:59 EXEC sleep 74916 74910 /usr/bin/sleep
我們可以提供與 libbpf-bootstrap 開發相似的開發體驗。只需運行 make 即可構建 wasm 二進制文件:
git clone https://github.com/eunomia-bpf/wasm-bpf --recursive
cd examples/bootstrap
make
編寫內核態的 eBPF 程序
要構建一個完整的 eBPF 程序,首先要編寫內核態的 bpf 代碼。通常使用 C 語言編寫,並使用 clang 完成編譯:
char LICENSE[] SEC("license") = "Dual BSD/GPL";
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, 8192);
__type(key, pid_t);
__type(value, u64);
} exec_start SEC(".maps");
struct {
__uint(type, BPF_MAP_TYPE_RINGBUF);
__uint(max_entries, 256 * 1024);
} rb SEC(".maps");
const volatile unsigned long long min_duration_ns = 0;
const volatile int *name_ptr;
SEC("tp/sched/sched_process_exec")
int handle_exec(struct trace_event_raw_sched_process_exec *ctx)
{
struct task_struct *task;
unsigned fname_off;
struct event *e;
pid_t pid;
u64 ts;
....
受篇幅所限,這裏沒有貼出完整的代碼。內核態代碼的編寫方式和其他基於 libbpf 的程序完全相同,一般來説會包含一些全局變量,通過 SEC 聲明掛載點的 eBPF 函數,以及用於保存狀態,或者在用户態和內核態之間相互通信的 map 對象(我們還在進行另外一項工作:bcc to libbpf converter,等它完成後就可以以這種方式編譯 BCC 風格的 eBPF 內核態程序)。在編寫完 eBPF 程序之後,運行 make 會在 Makefile 調用 clang 和 llvm-strip 構建BPF程序,以剝離調試信息:
clang -g -O2 -target bpf -D__TARGET_ARCH_x86 -I../../third_party/vmlinux/x86/ -idirafter /usr/local/include -idirafter /usr/include -c bootstrap.bpf.c -o bootstrap.bpf.o
llvm-strip -g bootstrap.bpf.o # strip useless DWARF info
之後,我們會提供一個為了 Wasm 專門實現的 bpftool,用於從 BPF 程序生成C頭文件:
../../third_party/bpftool/src/bpftool gen skeleton -j bootstrap.bpf.o > bootstrap.skel.h
由於 eBPF 本身的所有 C 內存佈局是和當前所在機器的指令集一樣的,但是 wasm 是有一套確定的內存佈局(比如當前所在機器是 64 位的,Wasm 虛擬機裏面是 32 位的,C struct layout 、指針寬度、大小端等等都可能不一樣),為了確保 eBPF 程序能正確和 Wasm 之間進行相互通信,我們需要定製一個專門的 bpftool 等工具,實現正確生成可以在 Wasm 中工作的用户態開發框架。
skel 包含一個 BPF 程序的skeleton,用於操作 BPF 對象,並控制 BPF 程序的生命週期,例如:
struct bootstrap_bpf {
struct bpf_object_skeleton *skeleton;
struct bpf_object *obj;
struct {
struct bpf_map *exec_start;
struct bpf_map *rb;
struct bpf_map *rodata;
} maps;
struct {
struct bpf_program *handle_exec;
struct bpf_program *handle_exit;
} progs;
struct bootstrap_bpf__rodata {
unsigned long long min_duration_ns;
} *rodata;
struct bootstrap_bpf__bss {
uint64_t /* pointer */ name_ptr;
} *bss;
};
我們會將所有指針都將根據 eBPF 程序目標所在的指令集的指針大小轉換為整數,例如,name_ptr。此外,填充字節將明確添加到結構體中以確保結構體佈局與目標端相同,例如使用 char __pad0[4];。我們還會使用 static_assert 來確保結構體的內存長度和原先 BTF 信息中的類型長度相同。
構建用户態的 Wasm 代碼,並獲取內核態數據
我們默認使用 wasi-sdk 從 C/C++ 代碼構建 wasm 二進制文件。您也可以使用 emcc 工具鏈來構建 wasm 二進制文件,命令應該是相似的。您可以運行以下命令來安裝 wasi-sdk:
wget https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-17/wasi-sdk-17.0-linux.tar.gz
tar -zxf wasi-sdk-17.0-linux.tar.gz
sudo mkdir -p /opt/wasi-sdk/ && sudo mv wasi-sdk-17.0/* /opt/wasi-sdk/
然後運行 make 會在 Makefile 中使用 wasi-clang 編譯 C 代碼,生成 Wasm 字節碼:
/opt/wasi-sdk/bin/clang -O2 --sysroot=/opt/wasi-sdk/share/wasi-sysroot -Wl,--allow-undefined -o bootstrap.wasm bootstrap.c
由於宿主機(或 eBPF 端)的 C 結構佈局可能與目標(Wasm 端)的結構佈局不同,因此您可以使用 ecc 和我們的 wasm-bpftool 生成用户空間代碼的 C 頭文件:
ecc bootstrap.h --header-only
../../third_party/bpftool/src/bpftool btf dump file bootstrap.bpf.o format c -j > bootstrap.wasm.h
例如,原先內核態的頭文件中結構體定義如下:
struct event {
int pid;
int ppid;
unsigned exit_code;
unsigned long long duration_ns;
char comm[TASK_COMM_LEN];
char filename[MAX_FILENAME_LEN];
char exit_event;
};
我們的工具會將其轉換為:
struct event {
int pid;
int ppid;
unsigned int exit_code;
char __pad0[4];
unsigned long long duration_ns;
char comm[16];
char filename[127];
char exit_event;
} __attribute__((packed));
static_assert(sizeof(struct event) == 168, "Size of event is not 168");
注意:此過程和工具並不總是必需的,對於簡單的應用,你可以手動完成。對於內核態和 Wasm 應用都使用 C/C++ 語言的情況下,你可以手動編寫所有事件結構體定義,使用 __attribute__((packed)) 避免填充字節,並在主機和 wasm 端之間轉換所有指針為正確的整數。所有類型必須在 wasm 中定義與主機端相同的大小和佈局。
對於複雜的程序,手動確認內存佈局的正確是分困難,因此我們創建了 wasm 特定的 bpftool,用於從 BTF 信息中生成包含所有類型定義和正確結構體佈局的 C 頭文件,以便用户空間代碼使用。可以通過類似的方案,一次性將 eBPF 程序中所有的結構體定義轉換為 Wasm 端的內存佈局,並確保大小端一致,即可正確訪問。
對於 Wasm 中不是由 C 語言進行開發的情況下,藉助 Wasm 的組件模型,我們還可以將這些 BTF 信息結構體定義作為 wit 類型聲明輸出,然後在用户空間代碼中使用 wit-bindgen 工具一次性生成多種語言(如 C/C++/Rust/Go)的類型定義。這部分會在關於如何使用 Rust 在 Wasm 中編寫 eBPF 程序的部分詳細描述,我們也會將這些步驟和工具鏈繼續完善,以改進 Wasm-bpf 程序的編程體驗。
我們為 wasm 程序提供了一個僅包含頭文件的 libbpf API 庫,您可以在 libbpf-wasm.h(wasm-include/libbpf-wasm.h)中找到它,它包含了一部分 libbpf 常用的用户態 API 和類型定義。Wasm 程序可以使用 libbpf API 操作 BPF 對象,例如:
/* Load and verify BPF application */
skel = bootstrap_bpf__open();
/* Parameterize BPF code with minimum duration parameter */
skel->rodata->min_duration_ns = env.min_duration_ms * 1000000ULL;
/* Load & verify BPF programs */
err = bootstrap_bpf__load(skel);
/* Attach tracepoints */
err = bootstrap_bpf__attach(skel);
rodata 部分用於存儲 BPF 程序中的常量,這些值將在 bpftool gen skeleton 的時候由代碼生成映射到 object 中正確的偏移量,然後在 open 之後通過內存映射修改對應的值,因此不需要在 Wasm 中編譯 libelf 庫,運行時仍可動態加載和操作 BPF 對象。
Wasm 端的 C 代碼與本地 libbpf 代碼略有不同,但它可以從 eBPF 端提供大部分功能,例如,從環形緩衝區或 perf 緩衝區輪詢,從 Wasm 端和 eBPF 端訪問映射,加載、附加和分離 BPF 程序等。它可以支持大量的 eBPF 程序類型和映射,涵蓋從跟蹤、網絡、安全等方面的大多數 eBPF 程序的使用場景。
由於 Wasm 端缺少一些功能,例如 signal handler 還不支持(2023年2月),原始的C代碼有可能無法直接編譯為 wasm,您需要稍微修改代碼以使其工作。我們將盡最大努力使 wasm 端的 libbpf API 與通常在用户空間運行的 libbpf API儘可能相似,以便用户空間代碼可以在未來直接編譯為 wasm。我們還將盡快提供更多語言綁定(Go等)的 wasm 側 eBPF 程序開發庫。
可以在用户態程序中使用 polling API 獲取內核態上傳的數據。它將是 ring buffer 和 perf buffer 的一個封裝,用户空間代碼可以使用相同的 API 從環形緩衝區或性能緩衝區中輪詢事件,具體取決於BPF程序中指定的類型。例如,環形緩衝區輪詢定義為BPF_MAP_TYPE_RINGBUF:
struct {
__uint(type, BPF_MAP_TYPE_RINGBUF);
__uint(max_entries, 256 * 1024);
} rb SEC(".maps");
你可以在用户態使用以下代碼從 ring buffer 中輪詢事件:
rb = bpf_buffer__open(skel->maps.rb, handle_event, NULL);
/* Process events */
printf("%-8s %-5s %-16s %-7s %-7s %s\n", "TIME", "EVENT", "COMM", "PID",
"PPID", "FILENAME/EXIT CODE");
while (!exiting) {
// poll buffer
err = bpf_buffer__poll(rb, 100 /* timeout, ms */);
ring buffer polling 不需要序列化開銷。bpf_buffer__poll API 將調用 handle_event 回調函數來處理環形緩衝區中的事件數據:
static int
handle_event(void *ctx, void *data, size_t data_sz)
{
const struct event *e = data;
...
if (e->exit_event) {
printf("%-8s %-5s %-16s %-7d %-7d [%u]", ts, "EXIT", e->comm, e->pid,
e->ppid, e->exit_code);
if (e->duration_ns)
printf(" (%llums)", e->duration_ns / 1000000);
printf("\n");
}
...
return 0;
}
運行時基於 libbpf CO-RE(Compile Once, Run Everywhere)API,用於將 bpf 對象加載到內核中,因此 wasm-bpf 程序不受它編譯的內核版本的影響,可以在任何支持 BPF CO-RE 的內核版本上運行。
從用户態程序中訪問和更新 eBPF 程序的 map 數據
runqlat 是一個更復雜的示例,這個程序通過直方圖展示調度器運行隊列延遲,給我們展現了任務等了多久才能運行。
$ sudo ./wasm-bpf runqlat.wasm -h
Summarize run queue (scheduler) latency as a histogram.
USAGE: runqlat [--help] [interval] [count]
EXAMPLES:
runqlat # summarize run queue latency as a histogram
runqlat 1 10 # print 1 second summaries, 10 times
$ sudo ./wasm-bpf runqlat.wasm 1
Tracing run queue latency... Hit Ctrl-C to end.
usecs : count distribution
0 -> 1 : 72 |***************************** |
2 -> 3 : 93 |************************************* |
4 -> 7 : 98 |****************************************|
8 -> 15 : 96 |*************************************** |
16 -> 31 : 38 |*************** |
32 -> 63 : 4 |* |
64 -> 127 : 5 |** |
128 -> 255 : 6 |** |
256 -> 511 : 0 | |
512 -> 1023 : 0 | |
1024 -> 2047 : 0 | |
2048 -> 4095 : 1 | |
runqlat 中使用 map API 來從用户態訪問內核裏的 map 並直接讀取數據,例如:
while (!bpf_map_get_next_key(fd, &lookup_key, &next_key)) {
err = bpf_map_lookup_elem(fd, &next_key, &hist);
...
lookup_key = next_key;
}
lookup_key = -2;
while (!bpf_map_get_next_key(fd, &lookup_key, &next_key)) {
err = bpf_map_delete_elem(fd, &next_key);
...
lookup_key = next_key;
}
運行時 wasm 代碼將會使用共享內存來訪問內核 map,內核態可以直接把數據拷貝到用户態 Wasm 虛擬機的堆棧中,而不需要面對用户態主機側程序和 Wasm 運行時之間的額外拷貝開銷。同樣,對於 Wasm 虛擬機和內核態之間共享的類型定義,需要經過仔細檢查以確保它們在 Wasm 和內核態中的類型是一致的。
可以使用 bpf_map_update_elem 在用户態程序內更新內核的 eBPF map,比如:
cg_map_fd = bpf_map__fd(obj->maps.cgroup_map);
cgfd = open(env.cgroupspath, O_RDONLY);
if (cgfd < 0) {
...
}
if (bpf_map_update_elem(cg_map_fd, &idx, &cgfd, BPF_ANY)) {
...
}
因此內核的 eBPF 程序可以從 Wasm 側的程序獲取配置,或者在運行的時候接收消息。
更多的例子:socket filter 和 lsm
在倉庫中,我們還提供了更多的示例,例如使用 socket filter 監控和過濾數據包:
SEC("socket")
int socket_handler(struct __sk_buff *skb)
{
struct so_event *e;
__u8 verlen;
__u16 proto;
__u32 nhoff = ETH_HLEN;
bpf_skb_load_bytes(skb, 12, &proto, 2);
...
bpf_skb_load_bytes(skb, nhoff + 0, &verlen, 1);
bpf_skb_load_bytes(skb, nhoff + ((verlen & 0xF) << 2), &(e->ports), 4);
e->pkt_type = skb->pkt_type;
e->ifindex = skb->ifindex;
bpf_ringbuf_submit(e, 0);
return skb->len;
}
Linux Security Modules(LSM)是一個基於鈎子的框架,用於在Linux內核中實現安全策略和強制訪問控制。直到現在,能夠實現實施安全策略目標的方式只有兩種選擇,配置現有的LSM模塊(如AppArmor、SELinux),或編寫自定義內核模塊。
Linux Kernel 5.7 引入了第三種方式:LSM eBPF。LSM BPF 允許開發人員編寫自定義策略,而無需配置或加載內核模塊。LSM BPF 程序在加載時被驗證,然後在調用路徑中,到達LSM鈎子時被執行。例如,我們可以在 Wasm 輕量級容器中,使用 lsm 限制文件系統操作:
// all lsm the hook point refer https://www.kernel.org/doc/html/v5.2/security/LSM.html
SEC("lsm/path_rmdir")
int path_rmdir(const struct path *dir, struct dentry *dentry) {
char comm[16];
bpf_get_current_comm(comm, sizeof(comm));
unsigned char dir_name[] = "can_not_rm";
unsigned char d_iname[32];
bpf_probe_read_kernel(&d_iname[0], sizeof(d_iname),
&(dir->dentry->d_iname[0]));
bpf_printk("comm %s try to rmdir %s", comm, d_iname);
for (int i = 0;i<sizeof(dir_name);i++){
if (d_iname[i]!=dir_name[i]){
return 0;
}
}
return -1;
}
總結
本以 C/C++ 語言為例,討論瞭如何使用 C/C++ 編寫 eBPF 程序並編譯為 Wasm 模塊。更完整的代碼,請參考我們的 Github 倉庫:https://github.com/eunomia-bp....
在下一篇文章中,我們會討論使用 Rust 編寫 eBPF 程序並編譯為 Wasm 模塊,並使用 OCI 鏡像發佈、部署、管理 eBPF 程序,獲得類似 Docker 的體驗。
接下來,我們也會繼續完善在 Wasm 中使用多種語言開發和運行 eBPF 程序的體驗,提供更完善的示例和用户態開發庫/工具鏈,以及更具體的應用場景。
參考資料
- wasm-bpf Github 開源地址:https://github.com/eunomia-bp...
- 什麼是 eBPF:https://ebpf.io/what-is-ebpf
- WASI-eBPF: https://github.com/WebAssembl...
- 龍蜥社區 eBPF 技術探索 SIG https://openanolis.cn/sig/ebp...
- eunomia-bpf 項目:https://github.com/eunomia-bp...
- eunomia-bpf 項目龍蜥 Gitee 鏡像:https://gitee.com/anolis/eunomia
- Wasm-bpf: 架起 Webassembly 和 eBPF 內核可編程的橋樑:https://mp.weixin.qq.com/s/2I...
- 當 WASM 遇見 eBPF :使用 WebAssembly 編寫、分發、加載運行 eBPF 程序:https://zhuanlan.zhihu.com/p/...
- 教你使用eBPF LSM熱修復Linux內核漏洞:https://www.bilibili.com/read...