eBPF 概述:第 1 部分:介紹

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 部分是關於用戶空間追蹤。到目前爲止,我們的努力都集中在內核追蹤上,所以是時候我們關注一下用戶進程了。

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

image

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 部分,我們將研究更高級別的工具,通過腳本或特定領域的語言自動與虛擬機交互。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章