天天看點

BPF 可移植性和 CO-RE(一次編譯,到處運作)「譯」

作者:SRE實戰

eBPF支援的可程式設計性是非常大的一個創新,而且能夠在驗證器的保證下,對核心沒有破壞性,就像編寫普通的程式一樣,能保證無害,但卻無法保證能寫出邏輯正确的程式。隻有程式員能夠更全面的了解各種機制和限制後,才能完全駕馭eBPF并實作自己期望的邏輯。

從前面兩篇文章中,可以對eBPF的整體能力和生态有個初步的了解:

  • eBPF初見(入門篇)
  • eBPF初見(生态篇)

但如果讓你立即寫一個eBPF程式實作一些功能,相信依然有很多細節比較疑惑,直到讀到一篇BPF作者的文章豁然開朗,很多問題都明白了,網上有一些舊版本的翻譯,作者更新了新版,是以翻譯一下。

新版原文位址: https://nakryiko.com/posts/bpf-portability-and-co-re/

标題 BPF Portability and CO-RE, 作者 Andrii Nakryiko。

BPF CO-RE 使我們回到了熟悉、自然的工作流程:将 BPF C 源碼編譯成二進制,然後将 二進制檔案分發到目标機器進行部署和運作 —— 無需再随着應用一起分發重量級的編譯器庫、無需消耗寶貴的運作時資源做運作時編譯,也無需等到運作之前才能捕捉一些細微的編譯時錯誤了。

由于譯者水準有限,本文不免存在遺漏或錯誤之處。如有疑問,請查閱原文。

BPF應用程式的可移植性意味着什麼?為什麼BPF實際上很難做到?在這篇文章中,我們将看到編寫能夠跨多個核心版本工作的BPF程式的挑戰,以及BPF CO-RE(編譯一次-到處運作)如何幫助解決這個問題。

1 BPF:最新狀态

自(e)BPF成立以來,BPF社群一直優先考慮盡可能簡化BPF應用程式開發,使其與使用者空間應用程式一樣簡單和熟悉。随着BPF可程式設計性的穩步發展,編寫BPF程式從未如此簡單。

盡管有這些可用性改進,但BPF應用程式開發的一個方面被忽略了(主要是技術原因):可移植性。然而,“BPF可移植性”意味着什麼?BPF可移植性是一種編寫BPF程式的能力,該程式将成功編譯、通過核心驗證,并在不同的核心版本之間正确工作,而無需為每個特定核心重新編譯。

本說明描述了BPF可移植性問題及其解決方案:BPF CO-RE(編譯一次–到處運作)。首先,我們将研究BPF可移植性問題本身,描述為什麼它是一個問題,以及為什麼解決它很重要。然後,我們将概述解決方案的進階組成部分,BPF CO-RE,并将簡要介紹需要組合起來才能實作的難題。最後,我們将以各種教程結束,描述BPF CO-RE方法的使用者可見API,并用示例示範其應用。

2 BPF 可移植性面臨的問題

2.1 BPF程式不控制周圍核心環境的記憶體布局

BPF程式是一段使用者提供的代碼,經加載并驗證後,在核心上下文中執行。這些程式在核心記憶體空間内運作,可以通路其可用的所有内部核心狀态,這種能力非常強大,也是BPF技術成功應用于衆多不同應用的原因之一。然而,這種強大的能力也造成了我們今天所面臨的BPF可移植性難題:BPF程式不控制周圍核心環境的記憶體布局。是以,BPF 程式隻能運作在開發和編譯這些程式時所在的核心。

2.2 不同版本不同配置的核心記憶體布局是不一緻的

此外,核心類型和資料結構也在不斷變化。不同的核心版本将使結構字段在結構内部來回移動,甚至移動到新的内部結構中,字段可能重命名或删除,它們的類型也可以更改,可以是一些普通的相容字段,也可以是完全不同的字段。結構和其他類型可以重命名,也可以有條件地編譯(取決于核心配置),或者在核心版本之間直接删除。

換言之,在核心釋出之間,情況總是會發生變化,但BPF應用程式開發人員應該以某種方式解決這個問題。考慮到這個不斷變化的核心環境,如何做才能使BPF能實作可移植性目标呢?實際上,這是可能的:

首先,并非所有BPF程式都需要依賴内部核心資料結構。一個例子是opensnoop工具,它依賴于kprobe/tracepoints來跟蹤哪些程序打開了哪些檔案,隻需要捕獲幾個syscall參數即可工作。由于系統調用參數提供了一個穩定的ABI,這些參數不會在核心版本之間發生變化,是以這種可移植性從一開始就不受關注。不幸的是,像這樣的應用程式非常罕見,這些類型的應用程式通常也非常有限。

另外,核心為BPF機制提供了一組有限的“穩定接口”,BPF程式可以依靠這些接口在核心之間保持穩定。實際上,底層結構和機制确實發生了變化,但這些BPF提供的穩定接口從使用者程式中抽象出了這些細節。

作為一個例子,對于網絡應用程式,通常隻需檢視一組有限的sk_buff屬性(當然還有資料包資料)就可以非常有用和通用。為此,BPF驗證器提供了一個穩定的__sk_buff“視圖”(注意前面的下劃線),它保護BPF程式不受結構sk_buf布局的影響。所有__sk_buff字段通路都被透明地重寫為實際的sk_buf通路(有時非常複雜——在最終擷取請求的字段之前進行一系列内部指針跟蹤)。類似的機制可用于一系列不同的BPF程式類型。它們作為BPF驗證者了解的特定于程式類型的BPF上下文來完成。是以,如果你在這樣的環境下開發一個BPF程式,請認為自己是幸運的,你可以幸福地生活在一個穩定的美好幻想中。

但是,一旦需要檢視原始的核心内部資料, 例如 常見的表示程序或線程的 struct task_struct,這個結構體中有非常詳細的程序資訊,那你就隻能靠自己了。對于 tracing、monitoring 和 profiling 應用來說這個需求非常常見,而這類 BPF 程式也是極其有用的。

在這種情況下,當某些核心在您認為的字段之前添加了一個額外的字段時,例如,在距structtask_struct開始位置偏移16處,如何確定您沒有讀取垃圾資料?突然,對于該核心,您需要從例如偏移量24讀取資料。問題還不止于此:如果一個字段被重命名了呢,thread_struct的fs字段(用于通路線程本地存儲)就是這樣,它在4.6和4.7核心之間被重命名為fsbase。或者,如果您必須在核心的兩種不同配置上運作,其中一種配置禁用了某些特定功能,并完全編譯出了結構的一部分(這是其他會計字段的常見情況,這些字段是可選的,但如果存在則非常有用),該怎麼辦?所有這些都意味着,您不能再使用開發伺服器的核心頭在本地編譯BPF程式,并将其以編譯後的形式分發給其他系統,同時期望它能夠工作并産生正确的結果。這是因為不同核心版本的核心頭将指定程式所依賴的資料的不同記憶體布局。

2.3 BCC方案

到目前為止,人們一直依靠BCC(BPF編譯器集合)來解決這個問題。使用BCC,可以将BPF程式C源代碼作為純字元串嵌入到使用者空間程式(控制應用程式)中。當控制應用程式最終在目标主機上部署和執行時,BCC調用其嵌入式Clang/LLVM,拉入本地核心頭(您必須確定從正确的核心開發包将其安裝在系統上),并實時執行編譯。這将確定BPF程式期望的記憶體布局與目标主機運作核心中的記憶體布局完全相同。如果您必須處理核心中的一些可選和可能編譯出來的東西,您隻需在源代碼中執行#ifdef/#else保護,以适應重命名字段、不同值語義或目前配置中不可用的任何可選東西等危害。嵌入的Clang将很高興地删除代碼中不相關的部分,并将根據特定核心定制BPF程式代碼。

這聽起來很棒,不是嗎?不幸的是,情況并非如此。雖然這個工作流程有效,但它也有很大的缺點。

Clang/LLVM組合是一個大庫,導緻需要随應用程式一起分發的大二進制檔案。

Clang/LLVM組合是資源密集型的,是以當您在啟動時編譯BPF代碼時,您将使用大量的資源,可能會導緻仔細平衡的生産工作負載。反之亦然,在繁忙的主機上,編譯一個小BPF程式在某些情況下可能需要幾分鐘。

您可以打賭,目标系統将存在核心頭檔案,這在大多數情況下不是問題,但有時會引起很多麻煩。對于核心開發人員來說,這也是一個特别令人讨厭的要求,因為他們通常必須在開發過程中建構和部署定制的一次性核心。如果沒有一個定制的核心頭包,任何基于BCC的應用程式都無法在這樣的核心上運作,進而使開發人員無法使用一組有用的調試和監控工具。

BPF程式測試和開發疊代也是相當痛苦的,因為一旦重新編譯并重新啟動使用者空間控制應用程式,即使在運作時也會出現最輕微的編譯錯誤。這當然會增加摩擦,也無助于快速疊代。

總的來說,雖然BCC是一個很好的工具,特别是用于快速原型、實驗和小型工具,但當用于廣泛部署的生産BPF應用程式時,它有很多缺點。

BPF CO-RE正在加強BPF可移植性的能力,可以說是BPF程式開發的未來,尤其是對于複雜的現實世界BPF應用程式。

3 BPF CO-RE:高層機制

BPF CO-RE在軟體堆棧的各個級别(核心、使用者空間BPF加載程式庫(libbpf)和編譯器(Clang))彙集了必要的功能和資料,以便于以可移植的方式編寫BPF程式,處理同一預編譯BPF程式中不同核心之間的差異。BPF CO-RE仔細整合以下組成部分:

  1. BTF類型資訊,它允許捕獲有關核心、BPF程式類型和代碼的關鍵資訊,這也是下面其他部分的基礎;
  2. 編譯器(Clang)為BPF程式C代碼提供了表達意圖和記錄重新定位資訊的手段;
  3. BPF加載器(libbpf)将來自核心的BTF和BPF程式綁定在一起,将編譯的BPF代碼适配到目标主機上的特定核心;
  4. 核心,在保持完全不依賴BPF CO-RE的同時,提供了一些進階BPF特性,以支援一些更進階的場景。

這些元件以內建方式工作,使開發可移植BPF程式的能力前所未有地輕松、适應性和表現力,實作了BCC在運作時編譯BPF程式C代碼才能實作的可移植性,并且且克服了BCC的高昂代價。

3.1 BTF(BPF Type Format)

BTF 是 BPF CO-RE 的核心之一, 它是是一種與 DWARF 類似的調試資訊,但

  • 更通用、表達更豐富,用于描述 C 程式的所有類型資訊。
  • 更簡單,空間效率更高(使用 BTF 去重算法), 占用空間比 DWARF 低 100x。

如今,讓 Linux 核心在運作時(runtime)一直攜帶 BTF 資訊是可行的, 隻需在編譯時指定 CONFIG_DEBUG_INFO_BTF=y。核心的 BTF 除了被核心自身使用, 現在還用于增強 BPF 校驗器自身的能力 —— 某些能力甚至超越了一年之前我們的想象力所及(例 如,已經有了直接讀取核心記憶體的能力,不再需要通過 bpf_probe_read() 間接讀取了)。

更重要的是,核心已經将這個自描述的權威 BTF 資訊(定義結構體的精确記憶體布局等資訊) 通過 sysfs 暴露出來,在 /sys/kernel/btf/vmlinux。 下面的指令将生成一個與所有核心類型相容的 C 頭檔案(通常稱為 “vmlinux.h"):

$ bpftool btf dump file /sys/kernel/btf/vmlinux format c           

這裡說的 ”所有“ 真的是 ”所有“:包括那些并未通過 kernel-devel package 導出的類型!

3.2 編譯器支援

為了啟用BPF CO-RE并讓BPF加載器(即libbpf)将BPF程式調整到目标主機上運作的特定核心,Clang使用了一些内置程式進行了擴充。它們發出BTF重定位,捕獲BPF程式代碼要讀取的資訊的進階描述。如果要通路task_struct->pid字段,Clang将記錄它正是一個名為“pid”、類型為“pid_t”的字段,位于結構task_struct中。這樣做的目的是,即使目标核心有一個task_struct布局,其中“pid”字段被移動到task_struc結構中的不同偏移量(例如,由于在“pid”域之前添加了額外的字段),或者即使它被移動到某個嵌套的匿名結構或聯合體中(這在C代碼中是完全透明的,是以沒有人關注過這樣的細節),您仍然可以通過其名稱和類型資訊找到它。這稱為字段偏移重新定位。

不僅可以捕獲(并随後重新定位)場偏移,還可以捕獲其他場方面,如場的存在或大小。即使對于位字段(在C語言中,位字段是出了名的“不合作”類型的資料,抵制了使其可重定位的努力),仍然有可能捕獲足夠的資訊,使其可重新定位,而且對BPF程式開發人員透明。

3.3 BPF 加載器(libbpf)

之前的所有資料(核心BTF和Clang重定位)都彙集在一起,由libbpf處理,libbpf充當BPF程式加載器。它擷取已編譯的BPF ELF對象檔案,根據需要對其進行後處理,設定各種核心對象(映射、程式等),并觸發BPF程式加載和驗證。

Libbpf知道如何為主機上特定的運作核心定制BPF程式代碼。它檢視BPF程式記錄的BTF類型和重新定位資訊,并将它們與運作核心提供的BTF資訊相比對。Libbpf解析并比對所有類型和字段,根據需要更新必要的偏移量和其他可重定位資料,以確定BPF程式的邏輯對于主機上的特定核心正确運作。如果一切順利,您(BPF應用程式開發人員)将得到一個BPF程式,它是針對目标主機上的核心“定制的”,就像您的程式是專門為它編譯的一樣。但是,所有這些都是在不支付随應用程式分發Clang和在目标主機上運作時執行編譯的開銷的情況下實作的。

3.4 核心

令人驚訝的是,核心不需要太多更改就可以支援BPF CO-RE。由于良好的關注點分離,在libbpf處理BPF程式代碼之後,對于核心來說,它看起來像任何其他有效的BPF程式碼。它與在主機上用最新核心頭編譯的BPF程式沒有差別。這意味着BPF CO-RE的許多功能不需要前沿核心功能,是以可以更廣泛、更快地進行調整。

一些進階場景可能需要更新的核心,但這種情況應該很少。在下一部分中,我們将在解釋BPF CO-RE面向使用者的機制時讨論這些場景,其中将詳細介紹BPF CO-RE面向使用者API。

4 BPF CO-RE:使用者側經驗

接下來看幾個真實世界中 BPF CO-RE 的典型場景,以及它是如何解決面臨的一些問題的。 我們将看到,

  • 一些可移植性問題(例如,相容 struct 記憶體布局差異)能夠處理地非常透明和自然,
  • 而另一些則需要通過顯式處理的,具體包括,
    • 通過 if/else 條件判斷(而不是 BCC 中的那種條件編譯 #ifdef/#else)。
    • BPF CO-RE 提供的其他一些額外機制。

4.1 擺脫核心頭檔案依賴

核心 BTF 資訊除了用來做字段重定位之外,還可以用來生成一個大的頭檔案("vmlinux.h"), 這個頭檔案中包含了所有的核心内部類型,進而避免了依賴系統層面的核心頭檔案。

通過 bpftool 獲得 vmlinux.h:

$ bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h           

有了 vmlinux.h,就無需再像通常的 BPF 程式那樣 #include <linux/sched.h>、#include <linux/fs.h> 等等頭檔案, 現在隻需要 #include "vmlinux.h",也不用再安裝 kernel-devel 了。

vmlinux.h 包含了所有的核心類型:

  • 作為 UAPI 的一部分暴露的 API
  • 通過 kernel-devel 暴露的内部類型
  • 其他一些通過任何其他方式都無法擷取的内部核心類型

不幸的是,BPF(以及 DWARF)并不記錄 #define 宏,是以某些常用 的宏可能在 vmlinux.h 中是缺失的。但這些沒有記錄的宏中 ,最常見的一些已經在 bpf_helpers.h (libbpf 提供的核心側”庫“)提供了。

4.2 讀取核心結構體字段

最常見和最典型的場景就是從某些核心結構體中讀取一個字段。

4.2.1 例子:讀取task_struct->pid字段

假設我們想讀取 task_struct 中的 pid 字段。

方式一:BCC(可移植)

用 BCC 實作,代碼很簡單:

pid_t pid = task->pid;           

BCC 有強大的代碼重寫(rewrite)能力,能自動将以上代碼轉換成一次 bpf_probe_read() 調用 (但有時重寫之後的代碼并不能正确,具體取決于表達式的複雜程度)。

libbpf 沒有 BCC 的代碼重寫魔法(code-rewriting magic),但提供了幾種其他方式來實作同樣的目的。

方式二:libbpf+BPF_PROG_TYPE_TRACING(不可移植)

如果使用的是最近新加的 BTF_PROG_TYPE_TRACING 類型 BPF 程式,那校驗器已經足夠智能了,能原生地了解和記錄 BTF 類型、跟蹤指針,直接(安全地)讀取核心記憶體 ,

pid_t pid = task->pid;           

進而避免了調用 bpf_probe_read(),格式和文法更為自然,而且無需編譯器重寫(rewrite)。 但此時,這段代碼還不是可移植的。

方式三:BPF_PROG_TYPE_TRACING+ CO-RE(可移植)

要将以上 BPF_PROG_TYPE_TRACING 代碼其變成可移植的,隻需将待通路字段 task->pid 放到編譯器内置的一個名為 __builtin_preserve_access_index() 的宏中:

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

這就是全部工作了:這樣的程式在不同核心版本之間是可移植的。

方式四:libbpf + CO-RE bpf_core_read()(可移植)

如果使用的核心版本還沒支援 BPF_PROG_TYPE_TRACING,就必須顯式地使用 bpf_probe_read() 來讀取字段。

Non-CO-RE libbpf 方式:

pid_t pid; bpf_probe_read(&pid, sizeof(pid), &task->pid);           

有了 CO-RE+libbpf,我們有兩種方式實作這個目的。

第一種,直接将 bpf_probe_read() 替換成 bpf_core_read():

pid_t pid; bpf_core_read(&pid, sizeof(pid), &task->pid);           

bpf_core_read() 是一個很簡單的宏,直接展開成以下形式:

bpf_probe_read(&pid, sizeof(pid), __builtin_preserve_access_index(&task->pid));           

可以看到,第三個參數(&task->pid)放到了前面已經介紹過的編譯器 built-int 中, 這樣 clang 就能記錄該字段的重定位資訊,實作可移植。

第二種方式是使用 BPF_CORE_READ() 宏,我們通過下面的例子來看。

4.2.2 例子:讀取task->mm->exe_file->f_inode->i_ino字段

這個字段表示的是目前程序的可執行檔案的 inode。 來看一下通路嵌套層次如此深的結構體字段時,面臨哪些問題。

方式一:BCC(可移植)

用 BCC 實作的話可能是下面這樣:

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

BCC 會對這個表達式進行重寫(rewrite),轉換成 4 次 bpf_probe_read()/bpf_core_read() 調用, 并且每個中間指針都需要一個額外的臨時變量來存儲。

方式二:BPF CO-RE(可移植)

下面是 BPF CO-RE 的方式,仍然很簡潔,但無需 BCC 的代碼重寫(code-rewriting magic):

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

4.2.3 其他與字段讀取相關的 CO-RE 宏

  • bpf_core_read_str():可以直接替換 Non-CO-RE 的 bpf_probe_read_str()。
  • BPF_CORE_READ_STR_INTO():與 BPF_CORE_READ_INTO() 類似,但會對最後一個字段執行 bpf_probe_read_str()。
  • bpf_core_field_exists():判斷字段是否存在,
  • 1 pid_t pid = bpf_core_field_exists(task->pid) ? BPF_CORE_READ(task, pid) : -1;
  • bpf_core_field_size():判斷字段大小,同一字段在不同版本的核心中大小可能會發生變化,
  • 1 u32 comm_sz = bpf_core_field_size(task->comm); /* will set comm_sz to 16 */
  • BPF_CORE_READ_BITFIELD():通過直接記憶體讀取(direct memory read)方式,讀取比特位字段
  • BPF_CORE_READ_BITFIELD_PROBED():底層會調用 bpf_probe_read()
  • 1 2 3 4 5 6 7 8 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);

4.3 處理核心版本和配置差異

某些情況下,BPF 程式必須處理不同核心版本之間常用核心結構體的細微差異。例如,

  • 字段被重命名了:對依賴這個字段的調用方來說,這其實變成了一個新字段(但語義沒變)。
  • 字段名字沒變,但表示的意思變了:例如,從 4.6 之後的某個核心版本開始, task_struct 的 utime 和 stime 字段,原來機關是 jiffies,現在變成了 nanoseconds,是以 調用方必須自己轉換機關。
  • 需要從核心提取的某些資料是與核心配置有直接關系,某些核心在編譯時并沒有将相關代碼編譯進來。
  • 其他一些無法用單個、通用的類型定義來适用于所有核心版本的場景。

對于這些場景,BPF CO-RE 提供了兩種互補的解決方式;

  • libbpf 提供的 extern Kconfig 變量
  • struct flavors

libbpf 提供的externsKconfig 全局變量

  • 系統中已經有一些”知名的“變量,例如 LINUX_KERNEL_VERSION,表示目前核心的版本。 BPF 程式能用 extern 關鍵字聲明這些變量。
  • 另外,BPF 還能用 extern 的方式聲明 Kconfig 的某些 key 的名字(例如 CONFIG_HZ,表示核心的 HZ 數)。

接下來的事情交給 libbpf,它會将這些變量分别比對到系統中相應的值(都是常量), 并保證這些 extern 變量與全局變量的效果是一樣的。

此外,由于這些 extern ”變量“都是常量,是以 BPF 校驗器能用它們來做一些 進階控制流分析和死代碼消除。

下面是個例子,如何用 BPF CO-RE 來提取線程的 CPU user time:

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

有些場景中,不同版本的核心中有不相容的類型,無法用單個通用結構體來為所有核心 編譯同一個 BPF 程式。struct flavor 在這種情況下可以派上用場。

下面是一個例子,提取 fs/fsbase(前面提到過,字段名字在核心版本更新時改了)來 做一些 thread-local 的資料處理:

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

在這個例子中,對于 <=4.6 的核心,我們将原來的 thread_struct 定義為了 struct thread_struct___v46。 雙下劃線及其之後的部分,即 ___v46,稱為這個 struct 的 “flavor”。

flavor 部分會被 libbpf 忽略,這意味着在目标機器上執行字段重定位時, struct thread_struct__v46 比對的仍然是真正的 struct thread_struct。

這種方式使得我們能在單個 C 程式内,為同一個核心類型定義不同的(而且是不相容的) 類型,然後在運作時(runtime)取出最合适的一個,這就是用 type cast to a struct flavor 來提取字段的方式。

沒有 struct flavor 的話,就無法真正實作像上面那樣“編譯一次”,然後就能在不同核心 上都能運作的 BPF 程式 —— 而隻能用#ifdef 來控制源代碼,編譯成兩個獨立的 BPF 程式變種,在運作時(runtime)由控制應用根據所在機器的核心版本選擇其中某個變種。 所有這些都添加了不必要的複雜性和痛苦。 相比之下,以上 BPF CO-RE 方式雖然不是透明的(上面的代碼中也包含了核心 版本相關的邏輯),但允許用熟悉的 C 代碼結構解決即便是這樣的進階場景的問題。

4.4 根據使用者提供的配置修改程式行為

BPF 程式知道核心版本和配置資訊,有時還不足以判斷如何 —— 以及以何種方式 —— 從該版本的核心擷取資料。 在這些場景中,使用者空間控制應用(control application)可能是唯一知道究竟需要做哪些事情,以及需要啟用或禁用哪些特性的主體。 這通常是在使用者空間和 BPF 程式之間通過某種形式的配置資料來通信的。

BPF map 方式

要實作這種目的,一種不依賴 BPF CO-RE 的方式是:将 BPF map 作為一個存儲配置 資料的地方。BPF 程式從 map 中提取配置資訊,然後基于這些資訊改變它的控制流。

但這種方式有幾個主要的缺點:

  1. BPF 程式每次執行 map 查詢操作,都需要運作時開銷(runtime overhead)。
  2. 多次查詢累積起來,開銷就會比較比較明顯,尤其在一些高性能 BPF 應用的場景。
  3. 配置内容(config value),雖然在 BPF 程式啟動之後就是不可變和隻讀 (immutable and read-only)的了,但 BPF 校驗器在校驗時扔把它們當作未知的黑盒值。
  4. 這意味着校驗器無法消除死代碼,也無法執行其他進階代碼分析。進一步, 這意味着我們無法将代碼邏輯放到 map 中,例如,能處理不同核心版本差異的 BPF 代 碼,因為 map 中的内容對校驗器都是黑盒,是以校驗器對它們是不信任的 —— 即使使用者配置資訊是安全的。

隻讀的全局資料方式

這種(确實複雜的)場景的解決方案:使用隻讀的全局資料(read-only global data)。 這些資料是在 BPF 程式加載到核心之前,由控制應用設定的。

  • 從 BPF 程式的角度看,這就是正常的全局變量通路,沒有任何 BPF map lookup 開銷 —— 全局變量實作為一次直接記憶體通路。
  • 控制應用方面,在 BPF 程式加載到核心之前設定初始的配置值,此後配置值就是全局可 通路且隻讀(well known and read-only)的了。
  • 這使得 BPF 校驗器能将它們作為常量對待,然後就能執行進階控制流分析 (advanced control flow analysis)來消除死代碼。

是以,針對上面那個例子,

  • 某些老核心的 BPF 校驗器就能推斷出,例如,代碼中某個未知的 BPF helper 不可能會用到,接下來就可以将相關代碼直接移除。
  • 而對于新核心來說,應用提供的配置(application-provided configuration)會所有不 同,是以 BPF 程式就能用到功能更強大的 BPF helper,而且這個邏輯能成功通過 BPF 校驗器的驗證。

下面的 BPF 代碼例子展示了這種用法:

/* global read-only variables, set up by control app */
const bool use_fancy_helper;
const u32 fallback_value;

...

u32 value;
if (use_fancy_helper)
    value = bpf_fancy_helper(ctx);
else
    value = bpf_default_helper(ctx) * fallback_value;           

從使用者空間方面,通過 BPF skeleton 可以很友善地做這種配置。BPF skeleton 的讨論不在 本文讨論範圍内,使用它來簡化 BPF 應用的例子,可參考核心源碼中的 runqslower tool。

5 總結

BPF CO-RE 的目标是:

作為一種簡單的方式幫助 BPF 開發者解決簡單的移植性問題(例如讀取結構體的字段),并且作為一種仍然可行(不是最優,但可容忍)的方式 解決複雜的移植性問題(例如不相容的資料結構改動、複雜的使用者空間控制條件等)。使得開發者能遵循”一次編譯、到處運作“(Compile Once – Run Everywhere)範式。

這是通過幾個 BPF CO-RE 子產品的組合實作的:

  1. vmlinux.h 消除了對核心頭檔案的依賴;
  2. 字段重定位資訊(字段偏置、字段是否存在、字段大小等等)使得從核心提取資料這個過程變得可移植;
  3. libbpf 提供的 Kconfig extern 變量允許 BPF 程式适應不同的核心版本 —— 以及配置相關的差異;
  4. 當其他方式都失效時,應用提供的隻讀配置和 struct flavor 最終救場,能解決任何需要複雜處理的場景。

要成功地編寫、部署和維護可移植 BPF 程式,并不是必須用到所有這些 CO-RE 特性。 隻需選擇若幹,用最簡單的方式解決你的問題。

BPF CO-RE 使我們回到了熟悉、自然的工作流程:将 BPF C 源碼編譯成二進制,然後将 二進制檔案分發到目标機器進行部署和運作 —— 無需再随着應用一起分發重量級的編譯器庫、無需消耗寶貴的運作時資源做運作時編譯,也無需等到運作之前才能捕捉一些細微的編譯時錯誤了。

BPF CO-RE 2021

到2021年,BPF CO-RE已成為一項成熟的技術,廣泛應用于各種項目。

在Facebook,BPF CO-RE成功地為多個基于BPF的生産應用程式提供支援,既可以處理更改字段偏移的簡單情況,也可以處理核心資料結構被删除、重命名或完全更改的更進階情況。所有這些都在一個編譯一次的BPF應用程式中。

自從引入BPF CO-RE以來,超過50個BCC工具被轉換為libbpf和BPF CO-RE。随着越來越多的Linux發行版預設啟用核心BTF(見後面系統清單),基于BPF CO RE的工具變得更廣泛,更有效地取代了基于Python的BCC工具。正如Brendan Gregg在其“BPF二進制檔案:BTF、CO-RE和BPF性能工具的未來”部落格文章中所強調的那樣,這就是前進的方向。

BPF CO-RE在各個領域得到迅速采用,為高效的BPF應用提供了動力。它用于跟蹤和性能監控、安全和審計,甚至用于BPF應用程式的聯網。從小型嵌入式系統到大型生産伺服器。建立libbpf boostrap項目是為了簡化使用libbpf和BPF CO-RE啟動BPF開發。是以,如果您感興趣,請務必檢視“使用libbpf boostrap建構BPF應用程式”部落格文章。

在更技術層面上,除了已經描述的字段重定位,BPF CO-RE還獲得了以下方面的支援:

類型大小和位置。當添加、删除或重命名類型時,能夠檢測到這一點并相應地調整BPF應用程式邏輯非常重要。請參見libbpf提供的bpf_core_type_exists()和bpf_core_type_size()宏。

枚舉重定位(存在和值)。一些内部的非UAPI核心enum會在核心版本之間發生變化,甚至取決于用于核心編譯的确切配置(例如,enum cgroup_subsys_id,請參閱BPF自我測試處理它),是以無法可靠地寫死任何特定值。枚舉重定位(bpf_core_enum_value_exists()和bpf_core_Enum_value()宏,由libbpf提供)允許檢查特定枚舉值的存在并捕獲其值。其中一個重要的應用是檢測新BPF助手的可用性,如果核心太舊,則傳回到舊的BPF助手。

當使用隻讀全局變量進行編譯時,這兩者對于從BPF端執行簡單可靠的核心特征檢測是必不可少的。

現在還有一個專門的BPF CO-RE參考指南文章,為BPF CO-ReE的所有功能提供實用指導,并提供如何在開發實際BPF應用程式時應用這些功能的提示。

開啟BTF的系統清單

  • Fedora 31+
  • RHEL 8.2+
  • OpenSUSE Tumbleweed (in the next release, as of 2020-06-04)
  • Arch Linux (from kernel 5.7.1.arch1-1)
  • Manjaro (from kernel 5.4 if compiled after 2021-06-18)
  • Ubuntu 20.10
  • Debian 11 (amd64/arm64)

支援Clang/LLVM 10+的系統清單

  • Fedora 32+
  • Ubuntu 20.04+
  • Arch Linux
  • Ubuntu 20.10 (LLVM 11)
  • Debian 11 (LLVM 11)
  • Alpine 3.13+

參考資料

  1. BPF CO-RE presentation from LSF/MM2019 conference:summary, slides.
  2. Arnaldo Carvalho de Melo’s presentation “BPF: The Status of BTF” dives deep into BPF CO-RE and dissects the runqslower tool quite nicely.
  3. BTF deduplication algorithm

繼續閱讀