--- tags: Linux Kernel Internals, 作業系統, eBPF --- # BPF 的可移植性: Good Bye BCC! Hi CO-RE! ## 序 BPF 是 Linux 中的重要技術之一,是允許使用者動態載入程式碼到 kernel 中運行的利器。從導入 eBPF 以來,社群一直致力於提升其使用性(usability),例如簡易化載入 BPF code 的方式、提供更易於撰寫 BPF code 的函式介面等等。然而除此以外,增加使用性的一項重點是可移植性(portability)的提升。 由於 BPF 是被編譯為 bytecode 執行,在不同 CPU 架構間的可移植性並不成嚴重的問題。然而 BPF 需考量的移植不止是跨平台的,更重要的是核心版本間的跨動。BPF bytecode 既然可以載入到核心中,也就意味著其可以存取到 kernel 中的數據。但是 kernel 的 memory layout、型別定義、struct 定義可能隨版本更動,這些皆非 BPF 可以控制,也就因此一段 BPF bytecode 若受限於這些問題,將被侷限只能在開發和編譯之的環境上運行。 為了降低這些可移植性上的缺失,CO-RE(Compile Once – Run Everywhere) 機制在 [bpfconf 2019](http://vger.kernel.org/bpfconf2019.html#session-2) 被提出來討論,並隨後引入至 Linux 的後續版本。本文將以 [BPF CO-RE (Compile Once – Run Everywhere)](https://nakryiko.com/posts/bpf-portability-and-co-re/) 這篇文章為基礎,並延伸之來探討 CO-RE 機制的目的、原理以及使用方式。 ## BPF 可移植性的問題 kernel 隨著版本一直在變化是無可避免的,那麼 BPF 的開發者是否可能不受這些變化影響呢?在某些需求上,有幾個因素讓這件事是可能的: 1. 並非所有的 BPF code 都需要深入到核心中的結構: 比如 `opensnoop` 主要是透過跟蹤幾個 system call 參數運作的,而 system call 的介面通常相對穩定,不容易隨核心版本變動 2. 第一點的狀況並不常見,但從中我們看到提升可移植性的關鍵在於「一致的介面」。因此 BPF 基礎建設嘗試提供 BPF 和 kernel 間的穩定介面: 底層的結構和機制仍可能隨核心的遷移而調整,但在上層(使用者的角度)則保持一致。因此在可允許的狀況下,BPF 端只要透過該穩定的介面存取想要的資料,則可避免核心版本移植的問題 但是,如果 BPF 的開發者想查看的就是原始的 kernel 資料呢? 這在 BPF 的移植性上就顯得棘手。舉例來說,以下的幾種狀況 1. 對於在 struct 裡的一個成員(例如 `field`,參考下圖),相異版本的 kernel 中存在 offset 差異: ```cpp /* 1.0 */ struct s { u8 dummy; u64 field; }'' /* 2.0 */ struct s { u64 dummy; u64 field; }; ``` 2. struct 中的成員名稱被修改 3. 即使是同一版本的 kernel,但使用不同的 CONFIG 編譯,struct 的定義也可能因此產生差異 在這些狀況下,都意味著如果你的 BPF code 是依賴當下環境的 kernel header 編譯,那麼直接放到其他機器上運行的話,是可能得到不正確的結果的。 ## 使用 BCC 來取得可移植性 最早要取得可移植性的方式是透過 [BCC](https://github.com/iovisor/bcc)(BPF Compiler Collection)。簡而來說,BCC 的運作方式是: 1. 將 C 語言寫成的 BPF code 以 plain string 的方式嵌入為 userspace 程式(可以稱之為 control application)的一部分 2. 當 userspace 程式被執行在目標機器上時,BCC 引入本地的 kernel header,並啟動其內置的 Clang/LLVM 編譯它並載入核心中 通過這種方式可以確保產生 bytecode 期望的 memory layout 和實際運行的核心版本保持一致。 ### BCC 的缺點 然而使用 BCC 有幾個主要的缺點: 1. BCC 仰賴內置的 Clang/LLVM 來重寫、編譯、和載入 BPF program,意味著部屬整個應用時也要包含 Clang/LLVM,但 Clang/LLVM 佔用空間龐大 2. Clang/LLVM 的運行很消耗硬體資源,但 BCC 方式每次運行都需要重新為目標機器編譯 BPF code,造成硬體負載的上升 * 反過來說,如果目標機器已經處於忙碌狀態,重新編譯是耗時的 3. 從前述的編譯方式我們知道: BCC 能夠運作的前提是機器本地需要具有 kernel header,有時這帶來開發上的不便 4. BCC 方式的測試和開發的迭代速度緩慢,這是因為 BPF code 的編譯發生於整個 userspace 程式(即 control application)被執行時,因此這些編譯錯誤必須到 control application 的 runtime 期間才得以發現 正是這些問題促成了 BPF-CORE,後者被設計以更完整地解決 BPF 移植性問題,以適用於在更複雜的環境中的開發與使用 BPF。 ## 使用 CO-RE 來取得可移植性 BPF CO-RE 將它所依賴的 kernel, userspace 的 BPF loader(libbpf), 編譯器(clang) 三者整合在了一起。其旨在讓使用者們能以輕鬆的方式編寫可移植的 BPF 應用。BPF CO-RE 需要協同並整合下列元件: * BTF type information: 這是 CO-RE 的基礎,使得我們能獲取 kernel、BPF program 的關鍵信息 * Clang compiler: 使得 C 語言編寫成的程式得以轉換成 bytecode,並具備 relocation 的能力 * BPF loader(libbpf): 將 BPF code 和 kernel 聯繫起來,並將編譯出的 BPF code 適配到目標機器的 kernel 版本 * kernel: 雖然從 kernel 的角度並不會知道載入的 bytecode 最初是否來自 CO-RE,但可以提供一些進階的 BPF 功能以運用於進階的場景 通過這些元件的互相搭配,BPF 的開發可以具備與 BCC 相似的簡單性和可移植性,同時沒有 BCC 所需的大量開銷。下面我們將深入這些元件在 CO-RE 底下的細節。 ### BTF [BTF](https://www.kernel.org/doc/html/latest/bpf/btf.html) 是一種類似 DWARF 的 metadata 格式,用以描述與 BPF program/BPF map 相關的信息。但相對 DWARF,BTF 更為通用,並且有更高的空間效率。 我們可以在編譯時設定 `CONFIG_DEBUG_INFO_BTF=y` 來讓 Linux 在運行的同時攜帶 BTF 信息。這些 BTF 除了被 kernel 自身使用,還可以用於增強 BPF verifier 的能力。(例如允許了直接讀取 kernel memory 的能力,不再需要通過 `bpf_probe_read()` 間接讀取)。最為關鍵的是,kernel 可以利用 BTF 信息來將當前 kernel 中的型別、struct 定義提取出來,這些都透過 sysfs (`/sys/kernel/btf/vmlinux`) 揭露。 下面的命令可以讓我們取得與當前核心 「所有的」(真的是「所有」! 包含在 `kernel-devel` 底下沒有涵蓋的) kernel 型別相容的 header file。 ``` $ bpftool btf dump file /sys/kernel/btf/vmlinux format c ``` :::info 你可以通過以下方式從 GitHub 上取得 bpftool: ``` $ git clone https://github.com/libbpf/bpftool.git $ cd bpftool $ git submodule update --init $ cd src $ make $ sudo make install ``` ::: ### Compiler 為了支援 BPF CO-RE 並讓 BPF loader(libbpf) 能將 BPF 程式在目標機器的 kernel 環境上運行,Clang 增加了幾個 builtin。這些 builtin 的功能是導出 BTF relocations,後者是 BPF 程序想讀取的資料的高級描述。例如我們打算存取 `task_struct->pid` 時,Clang 就記錄要存取的是在結構 `task_struct` 中、類型為 `pid_t`,命名為 `pid` 的字段(field)。 ```cpp struct task_struct { ... pid_t pid; ... } ``` 透過這種記錄方式,即便日後 `pid` 在 `task_struct` 底下的位置發生改變,甚至是移動到巢狀的 struct 結構或 union 中(這種改變對 C 語言的開發是透明的,見如下範例),CO-RE 仍然可以僅通過名稱和類型信息找到正確的數據。這種方式被稱為 **field offset relocation** ```cpp struct task_struct { ... struct { pid_t pid; } ... } struct task_struct { ... union { pid_t pid; } ... } ``` 透過這種資訊,不僅可以取得 field offset,還可以用來得到如 field 的大小或者是否存在的信息。甚至是 bitfields 也可以透過 BTF 來 relocation。 ### BPF loader(libbpf) 回顧前述,BTF 攜帶 kernel 的資訊,而 Clang 負責導入 relocation 信息,這些都聚集在一起並由 BPF loader - libbpf 處理。 它讀取編譯得到 BPF ELF,根據需要對其進行後處理(post-processes),設置各種 kernel objects(map、program 等),然後觸發 BPF 的載入和驗證。 libbpf 知道如何將 BPF 程式適配到目標機器上。它查看 BPF 程式中記錄的 BTF 類型和重定位信息,並將它們與正在運行的 kernel 之 BTF 信息匹配。如果匹配上沒有問題,BPF 應用開發者將得到一個針對目標機器「量身訂做」的 BPF 程式。但這種方式不需如同 BCC 那樣將 Clang 與 BPF 一同打包來部署,也沒有在目標機器上運行時編譯的開銷。 ### Kernel 在 kernel 部分,不需要太多改動就能支持 BPF CO-RE。換句話說,對 kernel 來說,經 libbpf 處理後的 BPF 程式與其他在目標機器上依賴最新的 kernel header 編寫出的 BPF 程式並無區別。 ## CO-RE 可提供的可移植性 在前面的章節中,我們已經探究了在 CO-RE 以前 eBPF programming 具有的問題,並敘述了 CO-RE 能帶來的改變 。下面就讓我們透過實際的應用場景,來看看 CO-RE 如何可以解決這些問題。 ### 避免依賴 kernel header 前面有提到,我們可以透過 BTF 和 [`bpftool`](https://github.com/libbpf/bpftool) 來產生與當前核心 「所有的」 kernel 型別相容的 header file,後者會被命名為 `vmlinux.h`。有了 `vmlinux.h`,就無需再像過安裝 `kernel-devel`,然後為 BPF 程式 include Linux 內部 header file。例如 `#include <linux/sched.h>`、`#include <linux/fs.h>`。只需要 `#include "vmlinux.h"` 就足夠。 換句話說 `vmlinux.h` 包含了以下: * 作為 UAPI 的一部分揭露出來的介面 * 透過 `kernel-devel` 揭露的內部型別 * 甚至是一些通過過去的任何方式都無法獲取的內部型別 不幸的是 `vmlinux.h` 的不足之處是 BPF 並不記錄 `#define` macro,也因此這在`vmlinux.h` 中是缺失的。但最常見的一些已經有在 [`bpf_helpers.h`](https://github.com/libbpf/libbpf/blob/master/src/bpf_helpers.h) (libbpf 提供給 eBPF programming 使用的函式庫)提供。 ### 讀取 kernel 內部結構的字段(field) 在 eBPF 最常見的情況是要 kernel 結構中讀取一個字段。舉例來說,我們想讀取 `task_struct` 的 `pid` 一欄。作為對照,BCC 的方式可以透過以下寫法: ```cpp pid_t pid = task->pid; ``` BCC 會藉由強大的代碼重寫能力,將以上程式碼轉換成一次 `bpf_probe_read()`(注意到重寫之後的程式碼並不一定能正確,具體取決於表達式的複雜程度)。 libbpf 沒有 BCC 這樣的重寫機制,但提供了幾種其他方式來實現同樣的目的: 如果你使用的是 [`BTF_PROG_TYPE_TRACING`](https://elixir.bootlin.com/linux/latest/source/include/uapi/linux/bpf.h#L983) 類型的 BPF 程式,那麼 BPF verifier 將有能力可以直接理解 BTF 信息,並將下列的程式碼轉換成對 kernel 記憶體的直接讀取,不需要依賴於編譯器的重寫。 ```cpp pid_t pid = task->pid; ``` * 這種寫法不具備可移植性(i.e. 不可跨 kernel 版本) :::info 根據 eBPF 所追蹤的事件類型會被分類為某種 [`bpf_prog_type `](https://elixir.bootlin.com/linux/latest/source/include/uapi/linux/bpf.h#L956),`BTF_PROG_TYPE_TRACING` 是其中的一種,可以參考 [這段敘述](https://github.com/falcosecurity/libs/blob/master/proposals/20220329-modern-bpf-probe.md#new-bpf-tracing-programs-kernel-version-55) 中如何寫出一段為 `BTF_PROG_TYPE_TRACING` 類的程式 ::: 但上述的寫法是不可移植的,要改進成可移植的版本,可以將 `task->pid` 放到編譯器內置的 `__builtin_preserve_access_index()` 中: ```cpp pid_t pid = __builtin_preserve_access_index(({ task->pid; })); ``` 如果需透過非 `BPF_PROG_TYPE_TRACING` 的方式,在非 CO-RE 的情況下必須顯示的使用 `bpf_probe_read()` 来讀取。 ```cpp /* Non CO-RE(not portable) */ pid_t pid; bpf_probe_read(&pid, sizeof(pid), &task->pid); ``` 而在 CO-RE 我們可以轉用 `bpf_core_read()`。 ```cpp /* CO-RE */ bpf_core_read(&pid, sizeof(pid), &task->pid); ``` 後者實際上效果如同被展開為 `bpf_probe_read()`,但加上剛剛介紹的 `__builtin_preserve_access_index()` 來實現可移植。 ```cpp bpf_probe_read(&pid, sizeof(pid), __builtin_preserve_access_index(&task->pid)); ``` 然而,這些 `bpf_probe_read()`/`bpf_core_read()` 實際上並不太理想,很快就會變成過時的 API。這主要是因為當你處理一堆通過 pointer 鏈接在一起的結構時,這個 API 將顯得有些笨重。例如,假設要獲取當前 process 對應 executable 的 inode 編號,在 BCC 下我們可以如下操作: ```cpp u64 inode = task->mm->exe_file->f_inode->i_ino; ``` BCC 會將這進行重寫,但實際上這被展開而成的是 4 次的 `bpf_probe_read()`/`bpf_core_read()`。換句話說,在使用沒有重寫魔法的 libbpf 的,我們就要用如下的方式操作: ```cpp struct task_struct *t = ...; struct mm_struct *mm; struct file *exe_file; struct inode *f_inode; u64 inode; bpf_core_read(&mm, sizeof(struct mm_struct *), &t->mm); bpf_core_read(&exe_file, sizeof(struct file *), &mm->exe_file); bpf_core_read(&f_inode, sizeof(struct inode *), &exe_file->path.dentry); bpf_core_read(&inode, sizeof(u64), &f_inode>i_ino); ``` 因此實際上 BPF CO-RE 提供如下更為簡潔的寫法,且不需依賴編譯器的代碼重寫: ```cpp u64 inode = BPF_CORE_READ(task, mm, exe_file, f_inode, i_ino); ``` 如果已經有一個你想讀入的變數,而不是作為返回值回傳,也可以改為以下的寫法。 ```cpp u64 inode; BPF_CORE_READ_INTO(&inode, task, mm, exe_file, f_inode, i_ino); ``` 其他還有用來讀取 C string 的 `bpf_core_read_str()`,作為 `bpf_probe_read_str()` 的直接替代品。類似的 `BPF_CORE_READ_STR_INTO()` 則與 `BPF_CORE_READ_INTO()` 相近,但會對最後一個 field 改用 `bpf_probe_read_str()` 去讀。 除了讀取 struct 中的 field,我們前面也提到 CO-RE 由 BTF 得以提供檢查一個 field 是否存在的功能。具體的介面是 `bpf_core_field_exists()`。 ```cpp pid_t pid = bpf_core_field_exists(task->pid) ? BPF_CORE_READ(task, pid) : -1; ``` 對於可能隨版本變動的 field,`bpf_core_field_size()` 可以提供可移植的方式。 ```cpp u32 comm_sz = bpf_core_field_size(task->comm); /* will set comm_sz to 16 */ ``` 如果有需要在 kernel 結構中存取 bitfield 類型的 field,`BPF_CORE_READ_BITFIELD()` (直接的記憶體存取) 和 `BPF_CORE_READ_BITFIELD_PROBED()`(底層透過 `bpf_probe_read()` 存取) 可以幫得上忙。 ```cpp struct tcp_sock *s = ...; /* with direct reads */ bool is_cwnd_limited = BPF_CORE_READ_BITFIELD(s, is_cwnd_limited); /* with bpf_probe_read()-based reads */ u64 is_cwnd_limited; BPF_CORE_READ_BITFIELD_PROBED(s, is_cwnd_limited, &is_cwnd_limited); ``` ### 處理核心版本和配置(configuration)差異 在上述範例中,我們看到了 libbpf 提供了一些函式和 macro,來滿足可以移植於各版本 kernel 的寫法。但某些情況下,libbpf 對提供各版本一致的方式是無能為力的。比如有時某個字段會被重命名,或者某個字段雖然名稱無異,卻變成了一個完全不同涵義的字段。 或者,你想要存取的資料只存在於某些配置中。 對此,libbpf 提供兩種可互補的辦法。 第一種是透過 **extern Kconfig variables** 的方式: BPF 程式可以透過 extern variable 來取得一些知名的 kconfig 變量,例如以下的 `LINUX_KERNEL_VERSION` 和 `CONFIG_HZ`。這些變量將具有與 BPF 程式運作的 kernel 相匹配的值。而 BPF verifier 可以將這些變數作為已知常數進行分析與 dead code elimination。舉例來說,下面的程式碼示範了 BPF CO-RE 中如何拿到 thread 的 CPU usertime。因為在 Linux 4.11 的版本後,`utime` 從以 jiffies 為單位的記錄方式切換為以 nano second 為單位([patch](https://lkml.org/lkml/2016/11/17/583)),這樣的寫法使我們引入可移植性。 :::warning 具體是 4.xx 版還需要釐清,因為示例雖然是寫 4.11,但原文章敘述說是 4.6 ::: ```cpp extern u32 LINUX_KERNEL_VERSION __kconfig; extern u32 CONFIG_HZ __kconfig; u64 utime_ns; if (LINUX_KERNEL_VERSION >= KERNEL_VERSION(4, 11, 0)) utime_ns = BPF_CORE_READ(task, utime); else /* convert jiffies to nanoseconds */ utime_ns = BPF_CORE_READ(task, utime) * (1000000000UL / CONFIG_HZ); ``` **struct flavors** 是另一種方式。它用來處理在兩個版本有不相容的型別的情況。如以下例子,需知道的事實是: `thread_struct` 這個結構型別中,`fs` 這個名稱在 4.6 版本後變成了 `fsbase`。BPF 應用將 <= 4.6 kernel 的舊 `thread_struct` 定義為 `thread_struct___v46`。名稱中的三個底線及其後的所有內容都被稱為是該結構的 "flavor"。在使用時,libbpf 會忽略 flavor 部分,這意味著在執行必要的重定位時,`thread_struct___v46` 仍將與實際運行的 kernel 的 `thread_struct` 匹配。 這樣的作法讓 BPF 開發可以在單個 C 程式中為相同的型別提供多種定義,並能夠在運行時根據版本選擇最合適的。 ```cpp /* up-to-date thread_struct definition matching newer kernels */ struct thread_struct { ... u64 fsbase; ... }; /* legacy thread_struct definition for <= 4.6 kernels */ struct thread_struct___v46 { /* ___v46 is a "flavor" part */ ... u64 fs; ... }; extern int LINUX_KERNEL_VERSION __kconfig; ... struct thread_struct *thr = ...; u64 fsbase; if (LINUX_KERNEL_VERSION > KERNEL_VERSION(4, 6, 0)) fsbase = BPF_CORE_READ((struct thread_struct___v46 *)thr, fs); else fsbase = BPF_CORE_READ(thr, fsbase); ``` 如果沒有 struct flavors,我們就無法做到 compile once, run everywhere 的效果了。因為透過 `#ifdef` 的方式,我們就需要編譯成兩個獨立的 BPF 程式,這樣的作法是相對繁瑣且複雜的。 ### 根據用戶配置調整 BPF 程式行為 有些場景中,根據 kernel 版本和配置來決定 BPF 程式的行為是不足的。我們可能得從 userspace 的 control application 才能知道實際的需求。一種可以不依賴於 CO-RE 的方式是透過 BPF map。將 BPF map 作為一個儲存配置的空間。然後 BPF 程式從 map 中提取資料來決定控制流如何執行。 但這種方式有幾個主要的缺點: * 每次 BPF 程式查詢 map 時都會產生額外開銷,在某些追求高性能的 BPF 應用場景中這並非好方法 * 在 BPF 程序啟動後,性質上,config 值是不可變且只可讀的,但 map 方式使得對 BPF verifier 來說,在驗證階段都是視為未知的值。這意味著 verifier 無法做 dead code elimination 等 control flow 的分析 * 也就是說,我們無法依賴 map 方式的用戶配置制定能處理不同 kernel 版本的 BPF 程式碼,因為 verifier 必須假設所有的程式路徑都是可行的,並進行檢查,即便 user 端設置理論上可以使某個 control flow 是不會被執行的 這種場景下,BPF CO-RE 提供另一種作法: 使用 read-only 的 global 變數。此變數只會在 BPF 程序被載入前設置一次。我們可以在 userapce 通過 BPF skeleton 來設置這個 global 變數。 則對於此前提到的兩個缺點的改進: * 從 BPF 程式的角度,這只是普通的存取 global 變量訪問,因此沒有 BPF map 查找的開銷 * 由於配置值是在加載 BPF 程序前設置,因此 BPF verifier 可以將配置值作為已知 constant 來分析 ## BPF skeleton TODO ## Reference * [BPF CO-RE (Compile Once – Run Everywhere)](https://nakryiko.com/posts/bpf-portability-and-co-re/) * [BPF CO-RE reference guide](https://nakryiko.com/posts/bpf-core-reference-guide/) * [深入浅出 eBPF](https://www.ebpf.top/categories/BPF-CORE/) * [Why We Switched from BCC to libbpf for Linux BPF Performance Analysis](https://www.pingcap.com/blog/why-we-switched-from-bcc-to-libbpf-for-linux-bpf-performance-analysis/) * [Libbpf Vs. BCC for BPF Development](https://devops.com/libbpf-vs-bcc-for-bpf-development/) * [Towards truly portable eBPF](https://fuweid.com/post/2022-ebpf-portable-with-btfhub/)