ebpf 開發入門之核心概念篇

我是 LEE,老李,一個在 IT 行業摸爬滾打 16 年的技術老兵。

事件背景

在完成了第一章的編寫《ebpf 開發入門之 helloworld》后,繼續往下寫多少怎么寫都是我最近思考的問題。跟周邊的小伙伴一起溝通后,認為 epbf 還在繼續發展,重點應該關注它的核心概念,而不是重點關注它底層的內部實現,畢竟我的環境是對 epbf 最大程度的使用,而不是深入開發。所以經過一段時間的思考,覺得為了使用 cilium 深入 ebpf 研究無可厚非,但是過分深入則可能不太合適。

有了上面的觀點,那么我就再寫一個文章輸出 epbf 的核心概念,后續深入的小伙伴可以根據自己實際需要找資料深入。

世界觀

在講解具體概念之前,我們先科普下 epbf 的整體世界觀。

eBPF 世界觀

Hook

中文名

鉤子

大白話

在 epbf 的世界里看 Linux 內核所有核心調用都可以 Hook,可以理解成為萬物皆可掛鉤子做 Callback。

具體解釋

eBPF 程序都是事件驅動的,它們會在內核或者應用程序經過某個確定的 Hook 點的時候運行,這些 Hook 點都是提前定義的,包括系統調用、函數進入/退出、內核 tracepoints、網絡事件等。

eBPF 鉤子

如果針對某個特定需求的 Hook 點不存在,可以通過 kprobe 或者 uprobe 來在內核或者用戶程序的幾乎所有地方掛載 eBPF 程序。

隨意的鉤子

Verifier

中文名

驗證器

大白話

生成應用內核層的 bytescode 要想進入到內核中去運行,必然要有個“安全檢查員”對這個 bytescode 的安全和合法性進行檢測。

具體解釋

每一個 eBPF 程序加載到內核都要經過 Verification,用來保證 eBPF 程序的安全性,主要包括:

  • 要保證 加載 eBPF 程序的進程有必要的特權級,除非節點開啟了 unpriviledged 特性,只有特權級的程序才能夠加載 eBPF 程序

    1. 內核提供了一個配置項 /proc/sys/kernel/unprivileged_bpf_disabled 來禁止非 特權用戶使用 bpf(2) 系統調用,可以通過 sysctl 命令修改

    2. 比較特殊的一點是,這個配置項特意設計為一次性開關(one-time kill switch), 這 意味著一旦將它設為 1,就沒有辦法再改為 0 了,除非重啟內核

    3. 一旦設置為 1 之后,只有初始命名空間中有 CAP_SYS_ADMIN 特權的進程才可以調用 bpf(2) 系統調用 。Cilium 啟動后也會將這個配置項設為 1

       # echo 1 > /proc/sys/kernel/unprivileged_bpf_disabled
      
  • 要保證 eBPF 程序不會崩潰或者使得系統出故障

  • 要保證 eBPF 程序不能陷入死循環,能夠 runs to completion

  • 要保證 eBPF 程序必須滿足系統要求的大小,過大的 eBPF 程序不允許被加載進內核

  • 要保證 eBPF 程序的復雜度有限,Verifier 將會評估 eBPF 程序所有可能的執行路徑,必須能夠在有限時間內完成 eBPF 程序復雜度分析

JIT Compiler

中文名

JIT 編譯器

大白話

跟 java 的 JVM 有點類似,就是把 bytescode 編譯成本機能夠運行的二進制代碼。

具體解釋

Just-In-Time(JIT) 編譯用來將通用的 eBPF 字節碼翻譯成與機器相關的指令集,從而極大加速 BPF 程序的執行:

  • 與解釋器相比,它們可以降低每個指令的開銷。通常,指令可以 1:1 映射到底層架構的原生指令
  • 這也會減少生成的可執行鏡像的大小,因此對 CPU 的指令緩存更友好
  • 特別地,對于 CISC 指令集(例如 x86),JIT 做了很多特殊優化,目的是為給定的指令產生可能的最短操作碼,以降低程序翻譯過程所需的空間

概念講解

有了世界觀的上的認識,同時這篇文章是作為入門,核心概念不應該說的太細太深,這樣容易勸退很多小伙伴。所以這里采用“一句話”說明白的方式解釋和介紹 epbf 的核心概念。

Helper Functions

中文名

輔助函數

大白話

應用在用戶層不能直接訪問內核層的數據,那么就需要一個代理人幫忙去執行,等待執行完畢后獲得返回結果。

具體解釋

eBPF 程序不能夠隨意調用內核函數,如果這么做的話會導致 eBPF 程序與特定的內核版本綁定,相反它內核定義的一系列 Helper functions。Helper functions 使得 BPF 能夠通過一組內核定義的穩定的函數調用來從內核中查詢數據,或者將數據推送到內核。所有的 BPF 輔助函數都是核心內核的一部分,無法通過內核模塊來擴展或添加。

輔助函數

不同類型的 BPF 程序能夠使用的輔助函數可能是不同的,例如:

  • 與 attach 到 tc 層的 BPF 程序相比,attach 到 socket 的 BPF 程序只能夠調用前者可以調用的輔助函數的一個子集
  • lightweight tunneling 使用的封裝和解封裝輔助函數,只能被更低的 tc 層使用;而推送通知到用戶態所使用的事件輸出輔助函數,既可以被 tc 程序使用也可以被 XDP 程序使用

Maps

中文名

映射存儲

大白話

ebpf Map 是駐留在內核空間中的高效 Key/Value store,包含多種類型的 Map,由內核實現其功能。用來作為用戶層和內核層之間數據交換的媒介,同時可以在不同程序之間共享數據。

具體解釋

epbf Map

BPF Map 的交互場景有以下幾種:

  • BPF 程序和用戶態程序的交互:BPF 程序運行完,得到的結果存儲到 map 中,供用戶態程序通過文件描述符訪問
  • BPF 程序和內核態程序的交互:和 BPF 程序以外的內核程序交互,也可以使用 map 作為中介
  • BPF 程序間交互:如果 BPF 程序內部需要用全局變量來交互,但是由于安全原因 BPF 程序不允許訪問全局變量,可以使用 map 來充當全局變量
  • BPF Tail call:Tail call 是一個 BPF 程序跳轉到另一 BPF 程序,BPF 程序首先通過 BPF_MAP_TYPE_PROG_ARRAY 類型的 map 來知道另一個 BPF 程序的指針,然后調用 tail_call() 的 helper function 來執行 Tail call
  • 共享 map 的 BPF 程序不要求是相同的程序類型,例如 tracing 程序可以和網絡程序共享 map,單個 BPF 程序目前最多可直接訪問 64 個不同 map。
ebpf Map 共享數據

內核中的 通用 map 有

  • BPF_MAP_TYPE_HASH
  • BPF_MAP_TYPE_ARRAY
  • BPF_MAP_TYPE_PERCPU_HASH
  • BPF_MAP_TYPE_PERCPU_ARRAY
  • BPF_MAP_TYPE_LRU_HASH
  • BPF_MAP_TYPE_LRU_PERCPU_HASH
  • BPF_MAP_TYPE_LPM_TRIE

內核中的 非通用 map 有

  • BPF_MAP_TYPE_PROG_ARRAY:一個數組 map,用于 hold 其他的 BPF 程序
  • BPF_MAP_TYPE_PERF_EVENT_ARRAY
  • BPF_MAP_TYPE_CGROUP_ARRAY:用于檢查 skb 中的 cgroup2 成員信息
  • BPF_MAP_TYPE_STACK_TRACE:用于存儲棧跟蹤的 MAP
  • BPF_MAP_TYPE_ARRAY_OF_MAPS:持有(hold) 其他 map 的指針,這樣整個 map 就可以在運行時實現原子替換
  • BPF_MAP_TYPE_HASH_OF_MAPS:持有(hold) 其他 map 的指針,這樣整個 map 就可以在運行時實現原子替換

Object Pinning

中文名

釘住對象 (非常奇怪的翻譯,但是看源代碼,翻譯 pin 為固定和被釘在那里還是滿合適的)

大白話

ebpf map 和程序作為內核資源只能通過文件描述符訪問(fd),這個映射實際就是 fd 到內存對象的屬性路徑的一個映射,這個映射過程叫 pin。

具體解釋

(★)ebpf map 和程序作為內核資源只能通過文件描述符訪問,其背后是內核中的匿名 inode。 這個觀點很重要,因為 pin 這個行為都是依據這個概念來的。

這樣做的優點:

  • 用戶空間應用程序能夠使用大部分文件描述符相關的 API
  • 傳遞給 Unix socket 的文件描述符是透明工作等等

這樣做的缺點:

文件描述符受限于進程的生命周期,使得 map 共享之類的操作非常笨重,這給某些特定的場景帶來了很多復雜性。

解法

為了解決這個問題,內核實現了一個最小內核空間 BPF 文件系統,BPF map 和 BPF 程序 都可以 pin 到這個文件系統內,這個過程稱為 object pinning。BPF 相關的文件系統不是單例模式(singleton),它支持多掛載實例、硬鏈接、軟連接等等。

相應的,BPF 系統調用擴展了兩個新命令,如下圖所示:

  • BPF_OBJ_PIN:釘住一個對象
  • BPF_OBJ_GET:獲取一個被釘住的對象
ebpf Pinning

Tail Calls

中文名

尾調用

大白話

一個 BPF 程序可以調用另一個 BPF 程序,并且調用完成后不用返回到原來的程序。

具體解釋

尾調用的機制是指:一個 BPF 程序可以調用另一個 BPF 程序,并且調用完成后不用返回到原來的程序。

  • 和普通函數調用相比,這種調用方式開銷最小,因為它是用長跳轉(long jump)實現的,復用了原來的棧幀 (stack frame)
  • BPF 程序都是獨立驗證的,因此要傳遞狀態,要么使用 per-CPU map 作為 scratch 緩沖區 ,要么如果是 tc 程序的話,還可以使用 skb 的某些字段(例如 cb[])
  • 相同類型的程序才可以尾調用,而且它們還要與 JIT 編譯器相匹配,因此要么是 JIT 編譯執行,要么是解釋器執行(invoke interpreted programs),但不能同時使用兩種方式
尾調用

Hardening

中文名

硬化 (明明說的就是安全,但是用 Hardening 這個單詞,覺得有點奇怪)

大白話

硬化實際是對 epbf 運行狀態的值和數據進行保護,防止以外被篡改和破壞,是一種暗轉防護機制。

具體解釋

在程序的生命周期內,BPF 將內核中的整個 BPF 解釋器映像(struct bpf_prog)以及 JIT 編譯映像(struct bpf_binary_header)鎖定為只讀,以防止代碼被破壞。例如,由于某些內核 bug 而發生的任何損壞都會導致一般的保護故障,從而導致內核崩潰,而不是讓損壞靜靜地發生。

對于 x86_64 JIT 編譯器,如果 CONFIG_RETPOLINE 已經設置(大多數 Linux 發行版在編寫時都是默認設置),則通過 retpoline 實現從使用尾部調用的間接跳轉的 JIT。

在/proc/sys/net/core/bpf_jit_harden 設置為 1 的情況下,JIT 編譯的額外加固步驟將對非特權用戶生效。在不受信任的用戶對系統進行操作的情況下,通過減少(潛在的)攻擊面,可以有效地略微權衡它們的性能。與完全切換到解釋器相比,程序執行時間的減少仍然會帶來更好的性能。

通過將實際指令隨機化,這意味著通過將值的實際負載分成兩個步驟來重寫指令,將操作從基于即時的源操作數轉換為基于寄存器的操作數:

  1. 加載一個盲化后的(blinded)立即數 rnd ^ imm 到寄存器
  2. 將寄存器和 rnd 進行異或操作(xor)

Offloads

中文名

卸載

大白話

就是把 eBPF 的網絡程序內核層 bytescode 從 CPU 運行改為由網卡的 MPU 來執行。

具體解釋

eBPF 網絡程序,尤其是 tc 和 XDP BPF 程序在內核中都有一個 offload 到硬件的接口,這樣就可以直接在網卡上執行 BPF 程序。

Offloads

參考文檔

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容