Try   HackMD

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 被提出來討論,並隨後引入至 Linux 的後續版本。本文將以 BPF CO-RE (Compile Once – Run Everywhere) 這篇文章為基礎,並延伸之來探討 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 差異:
/* 1.0 */
struct s {
    u8 dummy;
    u64 field;
}''

/* 2.0 */
struct s {
    u64 dummy;
    u64 field;
};
  1. struct 中的成員名稱被修改
  2. 即使是同一版本的 kernel,但使用不同的 CONFIG 編譯,struct 的定義也可能因此產生差異

在這些狀況下,都意味著如果你的 BPF code 是依賴當下環境的 kernel header 編譯,那麼直接放到其他機器上運行的話,是可能得到不正確的結果的。

使用 BCC 來取得可移植性

最早要取得可移植性的方式是透過 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 是一種類似 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

你可以通過以下方式從 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)。

struct task_struct {
    ...
    pid_t pid;
    ...
}

透過這種記錄方式,即便日後 pidtask_struct 底下的位置發生改變,甚至是移動到巢狀的 struct 結構或 union 中(這種改變對 C 語言的開發是透明的,見如下範例),CO-RE 仍然可以僅通過名稱和類型信息找到正確的數據。這種方式被稱為 field offset relocation

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 來產生與當前核心 「所有的」 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 (libbpf 提供給 eBPF programming 使用的函式庫)提供。

讀取 kernel 內部結構的字段(field)

在 eBPF 最常見的情況是要 kernel 結構中讀取一個字段。舉例來說,我們想讀取 task_structpid 一欄。作為對照,BCC 的方式可以透過以下寫法:

pid_t pid = task->pid;

BCC 會藉由強大的代碼重寫能力,將以上程式碼轉換成一次 bpf_probe_read()(注意到重寫之後的程式碼並不一定能正確,具體取決於表達式的複雜程度)。

libbpf 沒有 BCC 這樣的重寫機制,但提供了幾種其他方式來實現同樣的目的:

如果你使用的是 BTF_PROG_TYPE_TRACING 類型的 BPF 程式,那麼 BPF verifier 將有能力可以直接理解 BTF 信息,並將下列的程式碼轉換成對 kernel 記憶體的直接讀取,不需要依賴於編譯器的重寫。

pid_t pid = task->pid;
  • 這種寫法不具備可移植性(i.e. 不可跨 kernel 版本)

根據 eBPF 所追蹤的事件類型會被分類為某種 bpf_prog_type BTF_PROG_TYPE_TRACING 是其中的一種,可以參考 這段敘述 中如何寫出一段為 BTF_PROG_TYPE_TRACING 類的程式

但上述的寫法是不可移植的,要改進成可移植的版本,可以將 task->pid 放到編譯器內置的 __builtin_preserve_access_index() 中:

pid_t pid = __builtin_preserve_access_index(({ task->pid; }));

如果需透過非 BPF_PROG_TYPE_TRACING 的方式,在非 CO-RE 的情況下必須顯示的使用 bpf_probe_read() 来讀取。

/* Non CO-RE(not portable) */
pid_t pid;
bpf_probe_read(&pid, sizeof(pid), &task->pid);

而在 CO-RE 我們可以轉用 bpf_core_read()

/* CO-RE */
bpf_core_read(&pid, sizeof(pid), &task->pid);

後者實際上效果如同被展開為 bpf_probe_read(),但加上剛剛介紹的 __builtin_preserve_access_index() 來實現可移植。

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 下我們可以如下操作:

u64 inode = task->mm->exe_file->f_inode->i_ino;

BCC 會將這進行重寫,但實際上這被展開而成的是 4 次的 bpf_probe_read()/bpf_core_read()。換句話說,在使用沒有重寫魔法的 libbpf 的,我們就要用如下的方式操作:

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 提供如下更為簡潔的寫法,且不需依賴編譯器的代碼重寫:

u64 inode = BPF_CORE_READ(task, mm, exe_file, f_inode, i_ino);

如果已經有一個你想讀入的變數,而不是作為返回值回傳,也可以改為以下的寫法。

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()

pid_t pid = bpf_core_field_exists(task->pid) ? BPF_CORE_READ(task, pid) : -1;

對於可能隨版本變動的 field,bpf_core_field_size() 可以提供可移植的方式。

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() 存取) 可以幫得上忙。

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_VERSIONCONFIG_HZ。這些變量將具有與 BPF 程式運作的 kernel 相匹配的值。而 BPF verifier 可以將這些變數作為已知常數進行分析與 dead code elimination。舉例來說,下面的程式碼示範了 BPF CO-RE 中如何拿到 thread 的 CPU usertime。因為在 Linux 4.11 的版本後,utime 從以 jiffies 為單位的記錄方式切換為以 nano second 為單位(patch),這樣的寫法使我們引入可移植性。

具體是 4.xx 版還需要釐清,因為示例雖然是寫 4.11,但原文章敘述說是 4.6

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 程式中為相同的型別提供多種定義,並能夠在運行時根據版本選擇最合適的。

/* 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