eBPF 解決的問題
從上一篇你帶著一個清晰的區分:可觀測性是對一個執行中的系統提出新問題的能力,而不只是盯著一組固定的儀表。但最有意思的問題就住在核心裡面——為什麼這次磁碟寫入這麼慢、哪一個行程在灌爆網路、每一次系統呼叫究竟花多久。你那支普通的程式住在使用者空間,被一道牆隔開在這一切之外。要伸手越過那道牆,經典的辦法是寫一個可載入核心模組:完整特權、沒有安全網,一個亂跑的指標就讓整台機器恐慌。為了只是量測某件事,這是一件沉重又危險的工具。
eBPF——延伸的柏克萊封包過濾器(extended Berkeley Packet Filter)——是走出那個兩難的出路。它讓你把一支你自己的小程式載入執行中的核心,並把它掛到一個精確的事件上:一個封包抵達、一個函式被進入、一個追蹤點觸發、一個系統呼叫回傳。當那個事件發生時,你的程式碼就跑,就在那裡、在核心模式裡,能直接存取核心的資料——然後核心繼續往下走。不必重新開機、不必對著特定核心版本編譯一個模組,而且關鍵是,你的程式沒有任何辦法讓機器當掉。這個名字是歷史的意外;最初的 BPF(1992 年)是給 tcpdump 用的一個小小過濾語言,而 eBPF 把那個想法延伸成一個通用的核心內執行引擎。封包過濾的血統,只是它誕生的地方。
一台住在核心裡的虛擬機器
核心怎麼能在不信任你程式碼的情況下執行它?答案是它根本不執行你那些原始的機器指令。eBPF 定義了它自己的一台小小的虛擬機器——一顆抽象的 CPU,有十一個 64 位元的暫存器(叫做 r0 到 r10)、一個固定大小 512 位元組的堆疊、以及它自己一套精簡的指令集。你用一種受限的 C 方言寫你的邏輯;像 clang 這樣的編譯器把它變成的不是 x86 或 ARM 的機器碼,而是 eBPF 位元組碼——給這顆想像出來的 CPU 的指令。因為核心定義了那台機器,它就精確地控制每一條指令被允許做什麼。這和你也許遇過的 WebAssembly 執行環境是同一個想法:一套可攜、被沙箱包住的指令集,由宿主用自己的條件來解讀。
不過,為每一個封包跑一個直譯器會太慢——那就違背了「量測而不打擾」的本意。所以核心走第二步:一支程式一旦被接受,一個核心內的 JIT 編譯器(just-in-time,即時編譯)就把驗證過的 eBPF 位元組碼,一個暫存器對一個暫存器地,翻譯成宿主 CPU 真正的原生機器碼。從那之後,你的追蹤處理常式幾乎以原生速度執行,是一串緊湊的 x86 或 ARM 指令,而不是一個被直譯的迴圈。所以位元組碼是一種傳輸與驗證的格式——小、可檢查、可攜——而 JIT 是讓它變快的東西。慢慢地、仔細地驗證一次;之後就永遠快速地執行。
restricted C source
| clang -target bpf
v
eBPF bytecode (instructions for the in-kernel VM)
| bpf() syscall: load into kernel
v
+-------------------- VERIFIER --------------------+
| walks every path; proves: no out-of-bounds |
| memory, no infinite loop, no leaked kernel ptr, |
| every map access bounds-checked. REJECT or PASS. |
+--------------------------------------------------+
| pass
v
JIT -> native x86 / ARM machine code
| attach to event (tracepoint, kprobe, packet hook)
v
event fires --> your code runs in-kernel at native speed
--> writes results into a BPF map
| read out from user space
v
your tool prints the histogram / count / latency驗證器:核心如何證明你的程式碼安全
一切都繫於驗證器,而值得去理解它實際上做什麼,而不是把它當成魔法。當你透過 bpf() 系統呼叫載入一支程式時,驗證器在任何一條指令執行之前就做一次靜態分析:它走過你位元組碼裡每一條可能的路徑,模擬每個暫存器在每一步可能持有的值的範圍。它是在對你的程式證明定理——而它拒絕任何它無法證明安全的東西。它堅持的幾件大事:程式必須終止(歷史上迴圈被直接禁止;現代核心允許驗證器能證明會結束的有界迴圈),所以一支 eBPF 程式不能把核心卡死。每一次記憶體存取都必須可被證明在範圍內——你不能讀過封包的尾端、或越過你那 512 位元組的堆疊。而你也不能把一個原始的核心指標洩漏回使用者空間,那會破壞核心位址空間的保護。
這是 eBPF 誠實的核心,而它是雙面的。好的一面:一支驗證過的程式真的不能破壞核心記憶體、也不能永遠迴圈下去,這正是為什麼營運者在「核心模組太冒險」的生產環境裡信任它。痛的一面,每個 eBPF 作者都會遇到:驗證器是保守的。它拒絕許多其實正確的程式,只因為在它的分析預算內無法證明它們安全。你會寫一個你知道會終止的迴圈,卻看著驗證器拒絕它;你會加上一個對人類來說看似多餘、但驗證器在讓你碰一個位元組之前堅持要有的邊界檢查。它的錯誤訊息以晦澀難懂著稱。這不是缺陷——這是那份保證的代價。一個會接受你「正確但無法被證明」的程式的驗證器,也會接受別人那支「細微地壞掉」的程式。
Map:核心內的程式如何跟你對話
你的 eBPF 程式在核心裡執行、每秒觸發數千次、而且只有它那個小小的堆疊——那麼它的結果去哪了,你又怎麼讀得到?答案是 BPF map。一個 map 是一個住在核心裡、由你的 eBPF 程式與你的使用者空間工具共享的鍵值資料結構。它有好幾種樣式——一個雜湊表、一個陣列、一個 per-CPU 陣列、一個給事件串流用的環形緩衝區——但核心的想法只有一個:核心內的程式在事件發生時寫入那個 map,而你的使用者空間程式從同一個 map 讀出它所發現的。那個 map 就是橫跨那道使用者/核心邊界的橋,而追蹤本身是過不了這道橋的。
這個設計正是讓 eBPF 便宜到能在生產環境裡一直開著的原因——而這很重要,因為你前面遇過的觀察者效應:任何量測都會擾動被量測的東西。想想看計算每個行程的系統呼叫數。天真的做法是把每一次呼叫都記錄下來、讓使用者空間去累計——但那會把數百萬筆記錄從核心複製出來,把效能壓垮。eBPF 的做法是維持一個以行程 id 為鍵的雜湊 map,讓核心內的程式在每次呼叫時做 count = count + 1。昂貴的工作——計數——在核心裡以原生速度發生,只碰一個 map 項目;使用者空間每秒醒來一次,讀取那些已經彙總好的總數。你把彙總搬到了資料所在的地方,而不是把資料搬到彙總的地方。這就是 eBPF 能側錄一台繁忙的生產伺服器、卻不會把它逼到跪下的全部原因。
從位元組碼到一行工具:bpftrace
寫受限的 C、編譯成位元組碼、透過 bpf() 載入、管理 map、跟驗證器搏鬥——只為了數一樣東西,這是好大一套機器。多數時候你不直接碰它,因為更高層的工具會替你產生這一切。最友善的是 bpftrace,一個給臨時追蹤用的小小高階語言。你描述要追蹤什麼、要記錄什麼,而 bpftrace 就把你的一行命令編譯成 eBPF 位元組碼、載入它、設好那些 map、並印出結果——前面那張圖裡整條流水線,都藏在一行後面。它之於 eBPF,就像你會為了一個用過即丟的問題,抓一個快速的 awk 指令來用,而不是去寫一支 C 程式。
一個具體的例子能讓它落地。假設你想要一張關於 read() 系統呼叫花多久的直方圖,橫跨整台機器、即時的。在 bpftrace 裡那大概是一行:掛到 read 那個追蹤點的進入與離開,在進入時記一個時間戳,在離開時相減得到耗時、再餵進一個直方圖彙總。在那一行背後,bpftrace 建了兩支 eBPF 程式、一個以執行緒為鍵暫存進入時間戳的 map、以及一個給耗時用的直方圖 map——然後在離開時印出一張漂亮的、以 2 的次方為桶的直方圖。你問了一個全新的問題,學到一個關於活生生的生產系統、原本很難取得的真實數字,而機器幾乎沒察覺。這就是被交付出來的可觀測性。
它落在哪裡,以及接下來是什麼
退一步,把整個形狀握住。eBPF 是一台小小的核心內虛擬機器;你把受限的 C 編譯成它的位元組碼;一個驗證器在它執行之前證明位元組碼是安全的;一個 JIT 把它變成原生碼,讓它跑得快;它掛到核心事件上;而 map 把結果帶出到使用者空間。那一段就是全部的機制。它之所以特別的深層理由,是它達成的那筆交易——核心內的觸及,不需核心內的信任——代價是接受一個保守的驗證器,它有時會拒絕正確的程式碼。知道這些,你就能帶著對「它們花什麼、保證什麼」的真實理解去使用以 eBPF 為基礎的工具,而不是把它們當成黑盒子。
但我們對這整篇所倚靠的一個問題輕輕帶過了:你掛上去的事件究竟是什麼?我們含糊地說了「一個函式被進入」和「一個追蹤點觸發」,而那些正是下一篇要拆開的真實掛點。它走到 eBPF 底下、走進核心自己的追蹤管路——ftrace 作為內建的函式追蹤器、能動態插樁幾乎任何核心或使用者函式的 kprobe 與 uprobe、以及開發者刻意放下的靜態追蹤點與 USDT 探針。eBPF 常常就是你掛在那些點上的那支程式;下一篇講的是你能把它掛上去的那些地方。兩者合起來,就是這一級後面幾篇——談連續側錄與當機分析、談分散式追蹤——悄悄建立於其上的地基。