eBPF 概述:第 1 部分:介紹

語言: CN / TW / HK

1. 前言

有興趣瞭解更多關於 eBPF 技術的底層細節?那麼請繼續移步,我們將深入研究 eBPF 的底層細節,從其虛擬機器機制和工具,到在遠端資源受限的嵌入式裝置上執行跟蹤。

注意:本系列部落格文章將集中在 eBPF 技術,因此對於我們來講,文中 BPF 和 eBPF 等同,可相互使用。BPF 名字/縮寫已經沒有太大的意義,因為這個專案的發展遠遠超出了它最初的範圍。BPF 和 eBPF 在該系列中會交替使用。

  • 第 1 部分和第 2 部分 為新人或那些希望通過深入瞭解 eBPF 技術棧的底層技術來進一步瞭解 eBPF 技術的人提供了深入介紹。

  • 第 3 部分是對使用者空間工具的概述,旨在提高生產力,建立在第 1 部分和第 2 部分中介紹的底層虛擬機器機制之上。

  • 第 4 部分側重於在資源有限的嵌入式系統上執行 eBPF 程式,在嵌入式系統中完整的工具鏈技術棧(BCC/LLVM/python 等)是不可行的。我們將使用佔用資源較小的嵌入式工具在 32 位 ARM 上交叉編譯和執行 eBPF 程式。只對該部分感興趣的讀者可選擇跳過其他部分。

  • 第 5 部分是關於使用者空間追蹤。到目前為止,我們的努力都集中在核心追蹤上,所以是時候我們關注一下使用者程序了。

如有疑問時,可使用該流程圖:

2. eBPF 是什麼?

eBPF 是一個基於暫存器的虛擬機器,使用自定義的 64 位 RISC 指令集,能夠在 Linux 核心內執行即時本地編譯的 “BPF 程式”,並能訪問核心功能和記憶體的一個子集。這是一個完整的虛擬機器實現,不要與基於核心的虛擬機器(KVM)相混淆,後者是一個模組,目的是使 Linux 能夠作為其他虛擬機器的管理程式。eBPF 也是主線核心的一部分,所以它不像其他框架那樣需要任何第三方模組(LTTng 或 SystemTap),而且幾乎所有的 Linux 發行版都預設啟用。熟悉 DTrace 的讀者可能會發現 DTrace/BPFtrace 對比非常有用。

在核心內執行一個完整的虛擬機器主要是考慮便利和安全。雖然 eBPF 程式所做的操作都可以通過正常的核心模組來處理,但直接的核心程式設計是一件非常危險的事情 - 這可能會導致系統鎖定、記憶體損壞和程序崩潰,從而導致安全漏洞和其他意外的效果,特別是在生產裝置上(eBPF 經常被用來檢查生產中的系統),所以通過一個安全的虛擬機器執行本地 JIT 編譯的快速核心程式碼對於安全監控和沙盒、網路過濾、程式跟蹤、效能分析和除錯都是非常有價值的。部分簡單的樣例可以在這篇優秀的 eBPF 參考中找到。

基於設計,eBPF 虛擬機器和其程式有意地設計為 不是 圖靈完備的:即不允許有迴圈(正在進行的工作是支援有界迴圈【譯者注:已經支援有界迴圈,#pragma unroll 指令】),所以每個 eBPF 程式都需要保證完成而不會被掛起、所有的記憶體訪問都是有界和型別檢查的(包括暫存器,一個 MOV 指令可以改變一個暫存器的型別)、不能包含空解引用、一個程式必須最多擁有 BPF_MAXINSNS 指令(預設 4096)、“主"函式需要一個引數(context)等等。當 eBPF 程式被載入到核心中,其指令被驗證模組解析為有向環狀圖,上述的限制使得正確性可以得到簡單而快速的驗證。

譯者注:BPF_MAXINSNS 這個限制已經被放寬至 100 萬條指令(BPF_COMPLEXITY_LIMIT_INSNS),但是非特權執行的 BPF 程式這個限制仍然會保留。

歷史上,eBPF (cBPF) 虛擬機器只在核心中可用,用於過濾網路資料包,與使用者空間程式沒有互動,因此被稱為 “伯克利資料包過濾器”(譯者注:早期的 BPF 實現被稱為經典 cBPF)。從核心 v3.18(2014 年)開始,該虛擬機器也通過 bpf() syscall 和uapi/linux/bpf.h 暴露在使用者空間,這導致其指令集在當時被凍結,成為公共 ABI,儘管後來仍然可以(並且已經)新增新指令。

因為核心內的 eBPF 實現是根據 GPLv2 授權的,它不能輕易地被非 GPL 使用者重新分發,所以也有一個替代的 Apache 授權的使用者空間 eBPF 虛擬機器實現,稱為 “uBPF”。撇開法律條文不談,基於使用者空間的實現對於追蹤那些需要避免核心-使用者空間上下文切換成本的效能關鍵型應用很有用。

3. eBPF 是怎麼工作的?

eBPF 程式在事件觸發時由核心執行,所以可以被看作是一種函式掛鉤或事件驅動的程式設計形式。從使用者空間執行按需 eBPF 程式的價值較小,因為所有的按需使用者呼叫已經通過正常的非 VM 核心 API 呼叫(“syscalls”)來處理,這裡 VM 位元組碼帶來的價值很小。事件可由 kprobes/uprobes、tracepoints、dtrace probes、socket 等產生。這允許在核心和使用者程序的指令中鉤住(hook)和檢查任何函式的記憶體、攔截檔案操作、檢查特定的網路資料包等等。一個比較好的參考是 Linux 核心版本對應的 BPF 功能。

如前所述,事件觸發了附加的 eBPF 程式的執行,後續可以將資訊儲存至 map 和環形緩衝區(ringbuffer)或呼叫一些特定 API 定義的核心函式的子集。一個 eBPF 程式可以連結到多個事件,不同的 eBPF 程式也可以訪問相同的 map 以共享資料。一個被稱為 “program array” 的特殊讀/寫 map 儲存了對通過 bpf() 系統呼叫載入的其他 eBPF 程式的引用,在該 map 中成功的查詢則會觸發一個跳轉,而且並不返回到原來的 eBPF 程式。這種 eBPF 巢狀也有限制,以避免無限的遞迴迴圈。

執行 eBPF 程式的步驟:

  1. 使用者空間將位元組碼和程式型別一起傳送到核心,程式型別決定了可以訪問的核心區域(譯者注:主要是 BPF 輔助函式的各種子集)。

  2. 核心在位元組碼上執行驗證器,以確保程式可以安全執行(kernel/bpf/verifier.c)。

  3. 核心將位元組碼編譯為原生代碼,並將其插入(或附加到)指定的程式碼位置。(譯者注:如果啟用了 JIT 功能,位元組碼編譯為原生代碼)。

  4. 插入的程式碼將資料寫入環形緩衝區或通用鍵值 map。

  5. 使用者空間從共享 map 或環形緩衝區中讀取結果值。

map 和環形緩衝區結構是由核心管理的(就像管道和 FIFO 一樣),獨立於掛載的 eBPF 或訪問它們的使用者程式。對 map 和環形緩衝區結構的訪問是非同步的,通過檔案描述符和引用計數實現,可確保只要有至少一個程式還在訪問,結構就能夠存在。載入的 JIT 後代碼通常在載入其的使用者程序終止時被刪除,儘管在某些情況下,它仍然可以在載入程序的生命期之後繼續存在。

為了方便編寫 eBPF 程式和避免進行原始的 bpf()系統呼叫,核心提供了方便的 libbpf 庫,包含系統呼叫函式包裝器,如bpf_load_program 和結構定義(如 bpf_map),在 LGPL 2.1 和 BSD 2-Clause 下雙重許可,可以靜態連結或作為 DSO。核心程式碼也提供了一些使用 libbpf 簡潔的例子,位於目錄 samples/bpf/ 中。

4. 樣例學習

核心開發者非常可憐,因為核心是一個獨立的專案,因而沒有使用者空間諸如 Glibc、LLVM、JavaScript 和 WebAssembly 諸如此類的好東西! - 這就是為什麼核心中 eBPF 例子中會包含原始位元組碼或通過 libbpf 載入預組裝的位元組碼檔案。我們可以在 sock_example.c 中看到這一點,這是一個簡單的使用者空間程式,使用 eBPF 來計算環回介面上統計接收到 TCP、UDP 和 ICMP 協議包的數量。

我們跳過微不足道的的 main 和 open_raw_sock 函式,而專注於神奇的程式碼 test_sock。

static int test_sock(void)
{
int sock = -1, map_fd, prog_fd, i, key;
long long value = 0, tcp_cnt, udp_cnt, icmp_cnt;

map_fd = bpf_create_map(BPF_MAP_TYPE_ARRAY, sizeof(key), sizeof(value), 256, 0);
if (map_fd < 0) {
printf("failed to create map'%s'\n", strerror(errno));
goto cleanup;
}

struct bpf_insn prog[] = {
BPF_MOV64_REG(BPF_REG_6, BPF_REG_1),
BPF_LD_ABS(BPF_B, ETH_HLEN + offsetof(struct iphdr, protocol) /* R0 = ip->proto */),
BPF_STX_MEM(BPF_W, BPF_REG_10, BPF_REG_0, -4), /* *(u32 *)(fp - 4) = r0 */
BPF_MOV64_REG(BPF_REG_2, BPF_REG_10),
BPF_ALU64_IMM(BPF_ADD, BPF_REG_2, -4), /* r2 = fp - 4 */
BPF_LD_MAP_FD(BPF_REG_1, map_fd),
BPF_RAW_INSN(BPF_JMP | BPF_CALL, 0, 0, 0, BPF_FUNC_map_lookup_elem),
BPF_JMP_IMM(BPF_JEQ, BPF_REG_0, 0, 2),
BPF_MOV64_IMM(BPF_REG_1, 1), /* r1 = 1 */
BPF_RAW_INSN(BPF_STX | BPF_XADD | BPF_DW, BPF_REG_0, BPF_REG_1, 0, 0), /* xadd r0 += r1 */
BPF_MOV64_IMM(BPF_REG_0, 0), /* r0 = 0 */
BPF_EXIT_INSN(),
};

size_t insns_cnt = sizeof(prog) / sizeof(struct bpf_insn);

prog_fd = bpf_load_program(BPF_PROG_TYPE_SOCKET_FILTER, prog, insns_cnt, "GPL", 0, bpf_log_buf, BPF_LOG_BUF_SIZE);
if (prog_fd < 0) {
printf("failed to load prog'%s'\n", strerror(errno));
goto cleanup;
}

sock = open_raw_sock("lo");

if (setsockopt(sock, SOL_SOCKET, SO_ATTACH_BPF, &prog_fd, sizeof(prog_fd)) < 0) {
printf("setsockopt %s\n", strerror(errno));
goto cleanup;
}

首先,通過 libbpf API 建立一個 BPF map,該行為就像一個最大 256 個元素的固定大小的陣列。按 IPROTO_* 定義的鍵索引網路協議(2 位元組的 word),值代表各自的資料包計數(4 位元組大小)。除了陣列,eBPF 對映還實現了其他資料結構型別,如棧或佇列。

接下來,eBPF 的位元組碼指令陣列使用方便的核心巨集進行定義。在這裡,我們不會討論位元組碼的細節(這將在第 2 部分描述機器後進行)。更高的層次上,位元組碼從資料包緩衝區中讀取協議字,在 map 中查詢,並增加特定的資料包計數。

然後 BPF 位元組碼被載入到核心中,並通過 libbpf 的 bpf_load_program 返回 fd 引用來驗證正確/安全。呼叫指定了 eBPF 是什麼程式型別,這決定了它可以訪問哪些核心子集。因為樣例是一個 SOCKET_FILTER 型別,因此提供了一個指向當前網路包的引數。最後,eBPF 的位元組碼通過套接字層被附加到一個特定的原始套接字上,之後在原始套接字上接受到的每一個數據包執行 eBPF 位元組碼,無論協議如何。

剩餘的工作就是讓使用者程序開始輪詢共享 map 的資料。

    for (i = 0; i < 10; i++) {
key = IPPROTO_TCP;
assert(bpf_map_lookup_elem(map_fd, &key, &tcp_cnt) == 0);

key = IPPROTO_UDP;
assert(bpf_map_lookup_elem(map_fd, &key, &udp_cnt) == 0);

key = IPPROTO_ICMP;
assert(bpf_map_lookup_elem(map_fd, &key, &icmp_cnt) == 0);

printf("TCP %lld UDP %lld ICMP %lld packets\n", tcp_cnt, udp_cnt, icmp_cnt);
sleep(1);
}
}

5. 總結

第 1 部分介紹了 eBPF 的基礎知識,我們通過如何載入位元組碼和與 eBPF 虛擬機器通訊的例子進行了講述。由於篇幅限制,編譯和執行例子作為留給讀者的練習。我們也有意不去分析具體的 eBPF 位元組碼指令,因為這將是第 2 部分的重點。在我們研究的例子中,使用者空間通過 libbpf 直接用 C 語言從核心虛擬機器中讀取 eBPF map 值(使用 10 次 1 秒的睡眠!),這很笨重,而且容易出錯,而且很快就會變得很複雜,所以在第 3 部分,我們將研究更高級別的工具,通過指令碼或特定領域的語言自動與虛擬機器互動。

原文:

https://www.collabora.com/news-and-blog/blog/2019/04/05/an-ebpf-overview-part-1-introduction/