coolbpf 如何硬核提升 BPF 開發效率?

語言: CN / TW / HK

一、 引言

要完成一個 BPF 二進位制程式的開發,需要搭建開發編譯環境,要關注目標系統的核心版本情況,需要掌握從 BPF 核心態到使用者態程式的編寫,以及如何載入、繫結至對應的 HOOK 點等待事件觸發,最後再對輸出的日誌及資料進行處理。

這幾個過程對於一個剛接觸 BPF 的同學會感覺相當繁瑣。本文先通過對 BPF 知識的介紹,帶領大家入門 BPF,然後介紹 coolbpf 的遠端編譯(原名 LCC,LibbpfCompilerCollection, 意為酷玩 BPF,目標把複雜的 BPF 開發和編譯過程簡化),以一個示例來體驗 coolbpf 的享受式開發。

二、 BPF 入門

BPF 最早在 1992 年被提出,當時叫伯克利包過濾器(Berkely Packet Filter,一般稱為 cBPF),號稱比當時最先進的資料包過濾技術快 20 倍,主要應用場景在 tcpdump、seccomp。

2014 年,Alexei Starovoitov 對 BPF 進行徹底地改造,提出 Extended Berkeley Packet Filter (eBPF)。eBPF 指令更接近硬體的 ISA,便於提升效能,提供了可基於系統或程式事件高效安全執行特定程式碼的通用能力,通用能力的使用者不再侷限於核心開發者。其使用場景不再僅僅是網路分析,可以基於 eBPF 開發效能分析、系統追蹤、網路優化等多種型別的工具和平臺。我們通常不加區分,把 cBPF 和 eBPF 都稱之為 BPF。

2.1 BPF 知識點總結

大家都在談 BPF,介紹 BPF 的文章也很多,這裡先總結 BPF 的知識點,最後我們從最基礎的 BPF 指令架構及 map 和 prog type 去介紹如何快速入門。

  • risc 指令集:包含 11 個 64 位暫存器 (R0 ~ R10)。

  • maps:BPF 程式之間或核心及使用者態間資料互動。

  • prog type:BPF 程式型別用來確定程式功能以及程式 attach 到什麼位置。

  • helper functions:通過輔助函式訪問核心資料,如訪問 task、pid 等。

  • jit:將 BPF 程式的位元組碼轉換成目標機的機器碼。

  • object pinning:提供 BPF 檔案系統,延長 map 和 prog 的生命週期。

  • tail call:一個 BPF 程式可以呼叫另一個 BPF 程式,並且呼叫完成後不用返回到原來的程式。

  • hardening:保護 BPF 程式和其二進位制程式不被破壞(設定成只讀)。

2.2 BPF 發展概況

BPF 經過幾十年的發展,已經從原來單一的功能用途,到如今遍佈各個領域的應用,包括雲原生、企業伺服器、安全系統等都在運用這一技術,服務生產和生活。下圖是 BPF 技術的發展史(圖源: https://zhuanlan.zhihu.com/p/444454862 )。

隨著越來越多的特性被合入到 Linux 核心社群,BPF 支援的功能因為這些特性加持,已經越來越豐富。

2.3 BPF 和核心模組對比

BPF 的優勢是靈活、安全。在沒有引入 BPF 之前,如果我們想要實現一些跟蹤診斷的功能,可以使用 tracefs/debugfs 中匯出的檔案系統介面,或者編寫核心模組插入到核心中執行。前者的功能較為固定,無法靈活地實現對資料的過濾;後者雖然靈活,但容易導致核心 crash,不夠安全。ftrace 和 kernel module 給我們很大的自由去抓取核心的一些資訊,但是卻存在不少弊端。

下圖是核心模組和 BPF 的對比(圖源: https://zhuanlan.zhihu.com/p/444454862 )。

2.4 BPF 指令集

BPF(預設指 eBPF 非 cBPF) 程式指令都是 64 位,使用了 11 個 64 位暫存器,32 位稱為半暫存器(subregister)和一個程式計數器(program counter),一個大小為 512 位元組的 BPF 棧。所有的 BPF 指令都有著相同的編碼方式。eBPF 虛擬指令系統屬於 RISC,擁有 11 個虛擬暫存器、r0-r10,在實際執行時,虛擬機器會把這 11 個暫存器一 一對應於硬體 CPU 的物理暫存器。下圖是新老指令的對比:

BPF 指令的核心結構體如下,每一條 eBPF 指令都以一個 bpf_insn 來表示,在 cBPF 中是其他的一個結構體(struct sock_filter ),不過最終都會轉換成統一的格式,這裡我們只研究 eBPF:

由結構體中的__u8 code 可以知道,一條 BPF 指令是 8 個位元組長。這 8 位的 code,第 0、1、2 位表示的是該操作指令的類別,共 8 種:

從最低位到最高位分別是:

  • 8 位的 opcode;有 BPF_X 型別的基於暫存器的指令,也有 BPF_K 型別的基於立即數的指令。

  • 4 位的目標暫存器 (dst)。

  • 4 位的原始暫存器 (src)。

  • 16 位的偏移(有符號),是相對於棧、對映值(map values)、資料包(packet data)等的相對偏移量。

  • 32 位的立即數 (imm)(有符號)

8 bit 的 opcode 進一步拆開,下圖表示的是儲存和載入類指令:

下圖表示的是運算和跳轉指令:

總之,BPF 指令很簡潔,我們未必會在開發過程中使用它來進行程式碼編寫(類似於純彙編,會讓人崩潰),瞭解這些是有助於我們更深刻的理解 BPF 的執行原理。

2.5 BPF 的 prog type 和 map

PROG TYPE

BPF 相關的程式,首先需要設定為相對應的的程式型別,截止 Linux 核心 5.8 程式型別定義有 29 個,而且還在持續增加中,BPF 程式型別(prog_type)決定了程式可以呼叫的核心輔助函式的子集,也決定了程式輸入上下文 -- bpf_context 結構的格式。

我們經常使用 BPF 程式型別主要涉及以下兩類:

  • 跟蹤

大部 BPF 程式都是這一類,主要通過 kprobe、tracepoint(rawtracepoint)等追蹤系統行為及獲取系統硬體資訊。也可以訪問特定程式的記憶體區域,從執行程序中提取執行跟蹤資訊。

  • 網路

這類 BPF 程式用於檢測和控制系統的網路流量。可以對網路介面資料包進行過濾,甚至可以完全拒絕資料包。

使用者態是通過系統呼叫來載入 BPF 程式到核心的,在載入程式時,需要傳遞的引數中有一個欄位叫 prog_type,這個就是 BPF 的程式型別,跟蹤相關的是:BPF_PROG_TYPE_KPROBE 和 BPF_PROG_TYPE_TRACEPOINT,網路相關是:BPF_PROG_TYPE_SK_SKB、BPF_PROG_TYPE_SOCK_OPS 等。下面是描述 BPF 程式型別的列舉結構:

<code data-type="codeline">enum bpf_prog_type {</code><code data-type="codeline">  BPF_PROG_TYPE_UNSPEC,        /* Reserve 0 as invalid program type */</code><code data-type="codeline">  BPF_PROG_TYPE_SOCKET_FILTER,</code><code data-type="codeline">  BPF_PROG_TYPE_KPROBE,</code><code data-type="codeline">  BPF_PROG_TYPE_SCHED_CLS,</code><code data-type="codeline">  BPF_PROG_TYPE_SCHED_ACT,</code><code data-type="codeline">  BPF_PROG_TYPE_TRACEPOINT,</code><code data-type="codeline">  BPF_PROG_TYPE_XDP,</code><code data-type="codeline">  BPF_PROG_TYPE_PERF_EVENT,</code><code data-type="codeline">  BPF_PROG_TYPE_CGROUP_SKB,</code><code data-type="codeline">  BPF_PROG_TYPE_CGROUP_SOCK,</code><code data-type="codeline">  BPF_PROG_TYPE_LWT_IN,</code><code data-type="codeline">  BPF_PROG_TYPE_LWT_OUT,</code><code data-type="codeline">  BPF_PROG_TYPE_LWT_XMIT,</code><code data-type="codeline">  BPF_PROG_TYPE_SOCK_OPS,</code><code data-type="codeline">  BPF_PROG_TYPE_SK_SKB,</code><code data-type="codeline">  BPF_PROG_TYPE_CGROUP_DEVICE,</code><code data-type="codeline">  BPF_PROG_TYPE_SK_MSG,</code><code data-type="codeline">  BPF_PROG_TYPE_RAW_TRACEPOINT,</code><code data-type="codeline">  BPF_PROG_TYPE_CGROUP_SOCK_ADDR,</code><code data-type="codeline">  BPF_PROG_TYPE_LWT_SEG6LOCAL,</code><code data-type="codeline">  BPF_PROG_TYPE_LIRC_MODE2,</code><code data-type="codeline">  BPF_PROG_TYPE_SK_REUSEPORT,</code><code data-type="codeline">  BPF_PROG_TYPE_FLOW_DISSECTOR,</code><code data-type="codeline">  /* See /usr/include/linux/bpf.h for the full list. */</code><code data-type="codeline">};</code>

複製程式碼

BPF MAP

BPF 的 map 可用於核心 BPF 程式和使用者應用程式之間實現雙向的資料交換, 是重要基礎資料結構,它可以通過宣告 struct bpf_map_def 結構完成建立。

關於 BPF 最吸引人的一個方面,就是執行在核心上的程式可以在執行時使用訊息傳遞相互通訊,而 BPF Map 就是使用者空間和核心空間之間的資料交換、資訊傳遞的橋樑。

BPF Map 本質上是以鍵/值方式儲存在核心中的資料結構。在核心空間的程式建立 BPF Map 並返回對應的檔案描述符,在使用者空間執行的程式就可以通過這個檔案描述符來訪問並操作 BPF Map。

根據申請記憶體方式的不同,BPF Map 有很多種型別,常用的型別是 BPF_MAP_TYPE_HASH 和 BPF_MAP_TYPE_ARRAY,它們背後的記憶體管理方式跟我們熟悉的雜湊表和陣列基本一致。隨著多 CPU 架構的成熟發展,BPF Map 也引入了 per-cpu 型別,如 BPF_MAP_TYPE_PERCPU_HASH、BPF_MAP_TYPE_PERCPU_ARRAY 等,每個 CPU 都會儲存並看到它自己的 Map 資料,從屬於不同 CPU 之間的資料是互相隔離的。

下面是描述 BPF map 的列舉結構:

<code data-type="codeline">enum bpf_map_type {</code><code data-type="codeline">  BPF_MAP_TYPE_UNSPEC,</code><code data-type="codeline">  BPF_MAP_TYPE_HASH,</code><code data-type="codeline">  BPF_MAP_TYPE_ARRAY,</code><code data-type="codeline">  BPF_MAP_TYPE_PROG_ARRAY,</code><code data-type="codeline">  BPF_MAP_TYPE_PERF_EVENT_ARRAY,</code><code data-type="codeline">  BPF_MAP_TYPE_PERCPU_HASH,</code><code data-type="codeline">  BPF_MAP_TYPE_PERCPU_ARRAY,</code><code data-type="codeline">  BPF_MAP_TYPE_STACK_TRACE,</code><code data-type="codeline">  BPF_MAP_TYPE_CGROUP_ARRAY,</code><code data-type="codeline">  BPF_MAP_TYPE_LRU_HASH,</code><code data-type="codeline">  BPF_MAP_TYPE_LRU_PERCPU_HASH,</code><code data-type="codeline">  BPF_MAP_TYPE_LPM_TRIE,</code><code data-type="codeline">  BPF_MAP_TYPE_ARRAY_OF_MAPS,</code><code data-type="codeline">  BPF_MAP_TYPE_HASH_OF_MAPS,</code><code data-type="codeline">  BPF_MAP_TYPE_DEVMAP,</code><code data-type="codeline">  BPF_MAP_TYPE_SOCKMAP,</code><code data-type="codeline">  BPF_MAP_TYPE_CPUMAP,</code><code data-type="codeline">  BPF_MAP_TYPE_XSKMAP,</code><code data-type="codeline">  BPF_MAP_TYPE_SOCKHASH,</code><code data-type="codeline">  BPF_MAP_TYPE_CGROUP_STORAGE,</code><code data-type="codeline">  BPF_MAP_TYPE_REUSEPORT_SOCKARRAY,</code><code data-type="codeline">  BPF_MAP_TYPE_PERCPU_CGROUP_STORAGE,</code><code data-type="codeline">  BPF_MAP_TYPE_QUEUE,</code><code data-type="codeline">  BPF_MAP_TYPE_STACK,</code><code data-type="codeline">  BPF_MAP_TYPE_SK_STORAGE,</code><code data-type="codeline">  BPF_MAP_TYPE_DEVMAP_HASH,</code><code data-type="codeline">  BPF_MAP_TYPE_STRUCT_OPS,</code><code data-type="codeline">  BPF_MAP_TYPE_RINGBUF,</code><code data-type="codeline">  BPF_MAP_TYPE_INODE_STORAGE,</code><code data-type="codeline">  BPF_MAP_TYPE_TASK_STORAGE,</code><code data-type="codeline">  BPF_MAP_TYPE_BLOOM_FILTER,</code><code data-type="codeline">};</code>

複製程式碼

三、BPF 的開發姿勢

3.1 BPF 開發框架圖片

上圖就是非常經典的 BPF 開發框架圖了,一般開發流程都是先將使用者編寫的特定程式通過 LLVM 編譯為 BPF 位元組碼,在注入到核心中時會經過 verifier 嚴格的檢查,確保程式碼不會出現死迴圈、宕機之類的問題,然後再通過 jit 將其翻譯為 native code 進行執行,使用者可以通過檢視核心透出的資料,瞭解系統的執行情況。

雖然 BPF 有非常多的應用,但使用的時候也有許多的限制。比如:

  • 不能有不確定的迴圈操作;核心要確保執行流一定可以從 BPF 程式中出來。

  • 不允許睡眠;睡眠可能導致核心執行流一直出不來。

  • 不允許修改核心資料結構;一般不能修改資料報文。

  • 棧空間有限;當前只有 512 位元組。

  • 不允許被直接呼叫核心函式;必須通過輔助函式。

3.2 開源工具和平臺

BPF 的程式設計分為兩部分,一部分是執行在核心態,這是 BPF 程式設計的核心;另一部分是執行在使用者態,這部分程式碼主要用來載入 BPF,採集並處理資料等。

libbpf

libbpf 是官方的使用者態庫。和編譯普通的 c 程式會得到一個.o 檔案一樣,LLVM 編譯完 BPF 程式也會得到一個的.o 檔案(一般我們命名為 xx.bpf.o)。這個 bpf.o 檔案按照 elf 的格式組織 BPF 程式的位元組碼、map 定義以及符號等資訊,libbpf 庫負責解析這些資訊,建立 map,注入 BPF 程式到核心工作。因此,對於使用者來說,需要做很多繁瑣的工作:

  • 用 C 語言來編寫 BPF 程式;

  • 編寫 makefile,呼叫 LLVM 編譯生成 .o 檔案;

  • 呼叫 libbpf 的介面載入 .o 檔案;

  • 使用 libbpf 的介面獲取 map 中的資料。

BCC

BCC 是如今最熱門也是對新手最友好的開源平臺。它用 python 封裝了編譯、載入和讀取資料的過程,提供了很多非常好用的 API。

和 libbpf 需要提前把 bpf 程式編譯成 bpf.o 不同,BCC 是在執行時才呼叫 LLVM 進行編譯的,所以要求使用者環境上有 llvm 和 kernel-devel。這個就會像後面我們提到的,它會出現執行時偏移導致的瞬時資源衝高問題。

bpftrace

bpftrace 是單命令工具,讓使用者像使用指令碼一樣使用 BPF。它自定義了自己的 DSL 作為前端,底層也是呼叫 LLVM 的。事實上,它依賴於 BCC 提供的 libbcc.so 檔案。

3.3 libbpf CORE

前面提到 libbpf 開發,成為當前最主流的開發方式,但是需要為每個核心版本都開發特定的二進位制程式,不能達到同一個 BPF 程式在不同核心版本上安全執行。由於不同核心版本資料的記憶體佈局不同,就需要支援 CORE(CompileOnce、Runeverywhere),提到 CORE 我們要先來了解一下 BTF。

BTF(BPF Type Format)是一種類似於 DWARF 的格式,專用於描述程式中資料型別。其主要存在於兩個地方:

一是 vmlinux 映象內。二是使用者編寫的 BPF 程式內。

二者存在著一定的差異。第一個差異是:BPF 程式內除了存在 .btf 段外,還存在.btf.ext 段,專用於記錄 BPF 程式內使用的資料型別的情況。第二個差異是:vmlinux 映象內的 BTF 程式由原存在於該映象內的 dwarf 資訊簡化而來,而 BPF 程式內的 BTF 段由 CLANG 編譯器生成,需要在編譯時指定 --target bpf。利用 BPF 程式裡的 BTF 段和存放在每個系統上的 BTF 檔案,我們將這些資訊進行重定位,就能確定每個資料結構的偏移,達到 CORE 的目的。

關鍵元件:

  • BTF: 描述核心映象,獲取核心及 BPF 程式型別和程式碼的關鍵資訊(http://pylcc.openanolis.cn/)。

  • Clang 釋放 bpf 程式重定位資訊到 .btf 段。

  • Libbpf CO-RE 根據 .btf 段重定位 bpf 程式。

目前在 libbpf CO-RE 中需要進行重定位的資訊主要有三類:

1)結構體相關重定位,這部分和 BTF 息息相關;

Clang 通過 __builtin_preserve_access_index() 記錄成員偏移量

<code data-type="codeline">u64 inode = task->mm->exe_file->f_inode->i_ino;</code><code data-type="codeline">u64 inode = BPF_CORE_READ(task, mm, exe_file, f_inode, i_ino);</code>

複製程式碼

2)map fd、全域性變數、extern 等重定位,這部分主要依賴於 ELF 重定位機制。通過查詢 ELF 重定位段收集重定位資訊,更新相應指令的 imm 欄位。

<code data-type="codeline">skel->rodata->my_cfg.feature_enabled = true;</code><code data-type="codeline">skel->rodata->my_cfg.pid_to_filter = 123;</code><code data-type="codeline">extern u32 LINUX_KERNEL_VERSION   __kconfig;</code><code data-type="codeline">extern u32 CONFIG_HZ              __kconfig;</code>

複製程式碼

3)子函式重定位,也是依賴於 ELF 重定位機制。但是目的不一樣,子函式重定位是為了將 eBPF 程式呼叫的子函式同主函式放在同一塊連續的記憶體中,便於一起載入到核心。例如將所有子程式拷貝到主程式所在區域, always_inline 函式。

libbpf CORE 開發步驟:

1)生成帶所有核心型別的標頭檔案 vmlinux.h

bpftoolbtf dump file vmlinux format c > vmlinux.h

複製程式碼

2)使用 Clang (版本 10 或更新版本)將 BPF 程式的原始碼編譯為 .o 物件檔案;

3)從編譯好的 BPF 物件檔案中生成 BPF skeleton 標頭檔案 bpftool gen 命令生成;

4)在使用者空間程式碼中包含生成的 BPF skeleton 標頭檔案;

5)編譯使用者空間程式碼,這樣會嵌入 BPF 物件程式碼,後續就不用釋出單獨的檔案。

6)生成的 BPF skeleton 使用如下步驟載入、繫結、銷燬:

<name>__open() – 建立並開啟 BPF 應用,之後可以設定 skel->rodata 變數。

<name>__load() – 初始化,載入和校驗 BPF 應用部分。

<name>__attach() – 附加所有可以自動附加的 BPF 程式 (可選,可以直接使用 libbpf API 作更多控制)。

<name>__destroy() – 分離所有的 BPF 程式並使用其使用的所有資源。

四、 coolbpf 享受式開發

前面我們已經對 BPF 有了一個認識,到此已經入門了,同時也學習了 BPF 的高階知識:libbpf 的 CORE,它也是未來的一個方向,但是我們也看到這裡面,還需要寫一堆程式碼:open、load、attach 等等。我們能否把這一切進行簡化呢?能不能享受式的進行開發。別急,先來看看常用的開發方式:

4.1 BPF 開發常用方案對比

1)原生 libbpf,無 CO-RE (核心 samples/bpf 示例)

優勢:資源佔用量低

缺點:

  • 需要搭建程式碼工程、開發效率低;

  • 不同核心版本相容性差;

2)BCC(BPF Compile Collection、python 程式碼)

優勢:開發效率高,可移植性好,支援動態修改核心部分程式碼

缺點:

  • 部署依賴的 Clang/LLVM;

  • 每次執行都要執行 Clang/LLVM 編譯,爭搶記憶體 CPU 記憶體等資源;

  • 依賴目標環境標頭檔案;

3)BPF CO-RE(libbpf-tools 下面的程式碼)

優勢:不依賴在環境中部署 Clang/LLVM,資源佔用少

缺點:

  • 仍需要搭建編譯編譯工程;

  • 部分程式碼相對固定,無法動態配置;

  • 使用者態開發支援資訊較少,缺乏高階語言對接;

綜上所述,上述方案不能很好適配生產環境中,多核心並存、快速批量部署等需求

4.2 Coolbpf(可以酷玩的 BPF)解決的問題

通過將 BPF 的三種開發方式對比,我們發現都不能完美的在生產環境中解決如下幾個問題:

  • 安裝依賴庫和核心標頭檔案;

  • CPU 和記憶體等資源瞬時衝高;

  • BTF 需要按版本隨 BPF 二進位制程式釋出。

為解決這幾個問題,我們提出一個 coolbpf 的開發編譯平臺,目前包含 pylcc、rlcc、golcc 等目錄,分別是高階語言 python、rust 和 go 語言支援遠端和本地編譯的能力。

程式碼連結地址:

https://gitee.com/anolis/coolbpf

https://github.com/aliyun/coolbpf

這裡不對 coolbpf 過多介紹,具體內容請參考《 龍蜥社群開源 coolbpf,BPF 程式開發效率提升百倍 》。

4.3 pyLCC 的享受式

為了介紹什麼叫享受式開發,在這裡我們拿 coolbpf 的 pyLCC 進行演示:

import sysfrom pylcc.lbcBase import ClbcBase, CexecCmd  //import pylcc base庫
bpfProg = r"""struct data_t { int cpu; int type; // 0: irq, 1:sirq u32 stack_id; u64 delayed;};LBC_PERF_OUTPUT(e_out, struct data_t, 128); //定義perf event output array mapLBC_STACK(call_stack, 256); //定義stack 的map
SEC("kprobe/check_timer_delay")int j_check_timer_delay(struct pt_regs *ctx){ struct data_t data = {}; data.cpu = PT_REGS_PARM2(ctx); data.type = PT_REGS_PARM1(ctx); data.delayed = PT_REGS_PARM3(ctx); data.stack_id = bpf_get_stackid(ctx, &call_stack, KERN_STACKID_FLAGS); bpf_perf_event_output(ctx, &e_out, BPF_F_CURRENT_CPU, &data, sizeof(data)); return 0;}"""class Crunlatency(ClbcBase): def __init__(self, lat=10): self._exec = CexecCmd self.setupKo(lat >> 1) //只需要簡單的init,就可以把open load attach 等動作做好,然後專注於資料處理 super(Crunlatency, self).__init__("runlatency", bpf_str=bpfProg)
def _cb(self, cpu, data, size): stacks = self.maps['call_stack'].getStacks(e.stack_id) print("call trace:") //call back函式裡專心處理資料 for s in stacks: print(s)

複製程式碼

大家看到上面的示例,只需要以下三步,就可以完成一個程式的開發:

1)pip install coolbpf。

2)編寫 bpf.c 程式碼。

3)編寫 python,通過 init() 載入之後,就專注功能開發。

你可以不用關心 BPF 的彙編、位元組碼,也不用安裝 Clang,不用安裝 kernel-dev 標頭檔案,不用自己生成 BTF 檔案(它會自動到遠端伺服器下載),只需專注你的功能開發,比如分析網路流量、監控檔案開啟和關閉、跟蹤系統呼叫。

總結來看,BPF 技術還在如火如荼的發展著,但只要掌握了這些基礎的知識點,就能夠觸類旁通,利用好已有的工具或平臺,更加能如虎添翼。通過前面的介紹,我們不僅對 BPF 有了比較深刻的理解,還能借助 coolbpf,非常酷的玩了一把享受式的開發,簡潔如此,誰能不愛呢?