[譯] 邁向完全可程式設計 tc 分類器(NetdevConf,2016)

語言: CN / TW / HK

譯者序

本文翻譯自 2016 年 Daniel Borkman 在 NetdevConf 大會上的一篇文章: On getting tc classifier fully programmable with cls_bpf

由於 eBPF 發展很快,文中一些內容今天看來已經過時,因此翻譯過程中對相應內容做了 適當更新。插入的一些核心程式碼基於 4.19。

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

以下是譯文。

摘要

Berkely Packet Filter(BPF)是 1993 年設計的一種 指令集架構 (instruction set architecture)[18] [1] —— 作為一種通用資料包過濾方案(generic packet filtering solution), 提供給 libpcap / tcpdump 等上層應用使用。 BPF 很早就已經出現在 Linux 核心 中,並且使用場景也 不再僅限於網路方面 , 例如有 對系統呼叫進行過濾 (system call filtering)的 seccomp BPF [15]。

近幾年,Linux 社群將這種經典 BPF(classic BPF, cBPF)做了升級,形成一個新的指令集架構, 稱為 “extended BPF” (eBPF) [21] [23] [22] [24]。與 cBPF 相比,eBPF 帶了 更大的靈活性和可程式設計性 ,也帶來了一些 新的使用場景 ,例如跟蹤(tracing)[27]、 KCM(Kernel Connection Multiplexor)[17] 等。 除了替換掉直譯器之外,JIT 編譯器也進行了升級,使 eBPF [25] 程式能達到 平臺原生的執行效能

核心流量控制層的 cls_bpf 分類器添加了對 eBPF 的支援之後 [8],tc 對 Linux 資料平面進行程式設計的能力更加強大,並且該過程與 核心網路棧、相關工具及底層程式設計正規化的聯絡也更緊密。

本文將介紹 eBPF、eBPF 與 tc 的互動 及核心網路社群在 eBPF 領域的一些最新工作。

本文內容不求大而全,而是希望作為一份入門材料,供那些對 eBPF 架構及其與 tc 關係 感興趣的人蔘考。

關鍵字 :eBPF, cls_bpf, tc, programmable datapath, Linux kernel

1 引言

經典 BPF(cBPF)多年前就已經在 Linux 核心中實現了,主要使用者是 PF_PACKET sockets。 在該場景中,cBPF 作為一種 通用、快速且安全 的方案,在 PF_PACKET收包路徑的早期位置(early point)解析資料包(packet parsing)。 其中,與安全執行(safe execution)相關的一個目標是: 從使用者程式向核心注入 非受信程式碼,但不能因此破壞核心的穩定性

1.1 cBPF 架構

cBPF 是 32bit 架構 [18],主要針對包解析(packet parsing)場景設計:

  • 兩個主暫存器 A 和 X
    • A 是主暫存器(main register),也稱作累加器(accumulator)。這裡執行大部分操作,例如 alu、load、store、comparison-for-jump 等。
    • X 主要用作臨時暫存器,也用於載入包內容(relative loads of packet contents)。
  • 一個 16word scratch space(存放臨時資料),通常稱為 M
  • 一個隱藏的程式計數器(PC)

使用 cBPF 時,包的內容只能讀取,不能修改。

cBPF 有 8 種的指令型別:

  1. ld
  2. ldx
  3. st
  4. stx
  5. alu
  6. jmp
  7. ret
  8. 其他一些指令:用於傳遞 A 和 X 中的內容。

幾點解釋:

  • 前四個是載入相關的指令, load 和 store 型別分別會用到暫存器 A 和 X
  • jump 只支援前向跳轉(forward jump)。
  • ret 結束 cBPF 程式執行,從程式返回。

每個 cBPF 程式最多隻能包含 4096 條指令(max instructions/programm), 程式碼在載入到核心執行之前,校驗器會對其進行靜態驗證(statically verify)。

具體到 bpf_asm 工具 [5],它包含 33 條指令、11 種定址模式和 16 個 Linux 相關的 cBPF 擴充套件(extensions)。

1.2 cBPF 使用場景

cBPF 程式的語義是由使用它的子系統定義的。由於其通用、最小化和快速執行的特點,如 今 cBPF 已經在 PF_PACKET socket 之外的一些場景找到了用武之地

  • seccomp BPF [15] 於 2012 年新增到核心,目的是提供一種 安全和快速的系統呼叫過濾 方式。
  • 網路領域,cBPF 已經能

    • 用作大部分協議(TCP、UDP、netlink 等)的 socket filter;
    • 用作 PF_PACKET socket 的 fanout demuxing facility [14] [13]
    • 用於 socket demuxing with SO REUSEPORT [16]
    • 用於 load balancing in team driver [19]
    • 用於本文將介紹的 tc 子系統中,作為 classifier [6] and action [20]
  • 其他一些場景

eBPF 作為對 cBPF 的擴充套件, 第一個 commit 於 2014 年合併到核心 。從那之後, BPF 的可程式設計特性已經發生了巨大變化。

2 eBPF 架構

與 cBPF 類似,eBPF 也可以被視為一個最小“虛擬”機(minimalistic ”virtual” machine construct)[21]。 eBPF 抽象的機器只有少量暫存器、很小的棧空間、一個隱藏的程式計數器以及一個所謂的輔助函式 (helper function)的概念。

在核心其他基礎設施的配合下, eBPF 能做一些有副作用(side effects)的事情

這裡的副作用是指:eBPF 程式能夠對攔截到的東西做(安全的)修改,而 cBPF 對攔截到的東西都是隻能讀、不能改的。譯註。

eBPF 程式是事件驅動的,觸發執行時,系統會傳給它一些引數,這些輸入(inputs)稱為“上下文”(context)。 對於 tc eBPF 程式來說,傳遞的上下文是 skb ,即網路裝置 tc 層的 ingress 或 egress 路徑上正在經過的資料包。

2.0 指令集架構

暫存器設計

eBPF 有

  • 11 個暫存器 (R0 ~ R10)
  • 每個暫存器都是 64bit,有相應的 32bit 子暫存器
  • 指令集是固定的 64bit 位寬, 參考了 cBPF、x86_64、arm64 和 risc 指令集的設計 , 目的是 方便 JIT 編譯 (將 eBPF 指令編譯成平臺原生指令)。

eBPF 相容 cBPF,並且與後者一樣,給使用者空間程式提供穩定的 ABI。

直譯器 和 JIT 編譯器

目前,x86_64、s390 和 arm64 平臺的 Linux 核心都自帶了 eBPF 直譯器和 JIT 編譯器 。還沒有將 cBPF JIT 轉換成 eBPF JIT 的平臺,只能通過直譯器執行。

此外,原來某些不支援 JIT 編譯的 cBPF 程式碼,現在也能夠在載入時自動轉換成 eBPF 指 令,接下來或者通過直譯器執行,或者通過 eBPF JIT 執行。一個例子就是 seccom BPF: 引入了 eBPF 指令之後,原來的 cBPF seccom 指令就自動被轉換成 eBPF 指令了。

指令編碼格式

eBPF指令編碼格式:

  • 8 bit code:存放真正的指令碼(instruction code)
  • 8 bit dst reg:存放指令用到的暫存器號(R0~R10)
  • 8 bit src reg:同上,存放指令用到的暫存器號(R0~R10)
  • 16 bit signed offset:取決於指令型別,可能是
    • a jump offset:in case the related condition is evaluated as true
    • a relative stack buffer offset for load/stores of registers into the stack
    • a increment offset:in case of an xadd alu instruction, it can be an
  • 32 bit signed imm:存放立即值(carries the immediate value)

新指令

eBPF 帶來了幾個新指令,例如

  1. 工作在 64 位模式的 alu 操作
  2. 有符號移位(signed shift)操作
  3. load/store of double words
  4. a generic move operation for registers and immediate values
  5. operators for endianness conversion,
  6. a call operation for invoking helper functions
  7. an atomic add (xadd) instruction.

單個程式的指令數限制

與 cBPF 類似,eBPF 中單個程式的最大指令數(instructions/programm)是 4096。

譯註:現在已經放大到了 100 萬條。

這些指令序列(instruction sequence)在載入到核心之前會進行靜態校驗(statically verified), 以確保它們不會包含破壞核心穩定性的程式碼,例如無限迴圈、指標或資料洩露、非法記憶體訪問等等。 cBPF 只支援前向跳轉,而 eBPF額外支援了受限的後向跳轉—— 只要後向跳轉不會產生迴圈,即保證程式能在有限步驟內結束。

除此之外,eBPF 還引入了一些新的概念,例如 helper functions、maps、tail calls、object pinning。 接下來分別詳細討論。

2.1 輔助函式(Helper Functions)

輔助函式是一組核心定義的函式集, 使 eBPF 程式能從核心讀取資料, 或者向核心寫入資料 (retrieve/push data from/to the kernel)。

不同型別的 eBPF 程式能用到的 helper function 集合是不同的,例如,

  • socket 層 eBPF 能使用的輔助函式,只是 tc 層 eBPF 能使用的輔助函式的一個子集。
  • flow-based tunneling 場景中,封裝/解封裝用的輔助函式只能用在比較低層的 tc ingress/egress 層。

函式簽名

與系統呼叫類似, 所有輔助函式的簽名是一樣的 ,格式為: u64 foo(u64 r1, u64 r2, u64 r3, u64 r4, u64 r5)

呼叫約定

輔助函式的呼叫約定(calling convention)也是固定的:

  • R0:存放程式返回值
  • R1 ~ R5:存放函式引數(function arguments)
  • R6 ~ R9: 被呼叫方 (callee)負責儲存的暫存器
  • R10:棧空間 load/store 操作用的只讀 frame pointer

帶來的好處

這樣的設計有幾方面好處:

  • JIT 更加簡單、高效。

    cBPF 中,為了呼叫某些特殊功能的輔助函式(auxiliary helper functions),對 load 指令進行了過載(overload), 在資料包的某個看似不可能的位置(impossible packet offset)載入資料,以這種方式呼叫到輔助函式;每個 cBPF JIT 都需要實現對這樣的 cBPF 擴充套件的支援。

    而在 eBPF 中,每個輔助函式都是以透明和高效地方式進行 JIT 編譯的,這意味著 JIT 編譯器只需要 emit 一個 call 指令 —— 因為暫存器對映(register mapping) 的設計中,eBPF 已經和底層架構的呼叫約定是匹配的了。

  • 函式簽名使校驗器能執行型別檢查(type checks)。

    每個輔助函式都有一個配套的 struct bpf_func_proto 型別變數,

    /* eBPF function prototype used by verifier to allow BPF_CALLs from eBPF programs
       * to in-kernel helper functions and for adjusting imm32 field in BPF_CALL instructions after verifying */
      struct bpf_func_proto {
      	u64 (*func)(u64 r1, u64 r2, u64 r3, u64 r4, u64 r5);
      	bool gpl_only;
      	bool pkt_access;
      	enum bpf_return_type ret_type;
      	enum bpf_arg_type arg1_type;
      	enum bpf_arg_type arg2_type;
      	enum bpf_arg_type arg3_type;
      	enum bpf_arg_type arg4_type;
      	enum bpf_arg_type arg5_type;
      };

    一個例子:

    // net/core/filter.c
    
      BPF_CALL_2(bpf_redirect, u32, ifindex, u64, flags)
      {
      	struct bpf_redirect_info *ri = this_cpu_ptr(&bpf_redirect_info);
      	if (unlikely(flags & ~(BPF_F_INGRESS)))
      		return TC_ACT_SHOT;
        
      	ri->ifindex = ifindex;
      	ri->flags = flags;
      	return TC_ACT_REDIRECT;
      }
    
      static const struct bpf_func_proto bpf_redirect_proto = {
      	.func           = bpf_redirect,
      	.gpl_only       = false,
      	.ret_type       = RET_INTEGER,
      	.arg1_type      = ARG_ANYTHING,
      	.arg2_type      = ARG_ANYTHING,
      };

    校驗器據此就能知道該 helper 函式的詳細資訊, 進而確保該 helper 的型別與當前 eBPF 程式用到的暫存器內的內容是匹配的。

    helper 函式的引數型別有很多種,如果是指標型別(例如 ARG_PTR_TO_MEM ), 校驗器還可以執行進一步的檢查,例如判斷這個緩衝區之前是否已經初始化了。

2.2 Maps

Map 是 eBPF 的另一個重要組成部分。 它是一種高效的 key/value 儲存,map 的內容駐留在核心空間, 但可以 在使用者空間通過檔案描述符訪問

Map 可以在多個 eBPF 程式之間共享,而且沒有什麼限制,例如,可以在一個 tc eBPF 程式和一個 tracing eBPF 程式之間共享。

map 型別

Map 後端是由核心核心(the core kernel)提供的,可能是通用型別 (generic),也可能是專用型別(specialized type); 某些專業型別的 map 只能用於特定的子系統 ,例如 [28]。

通用型別 map 當前是陣列或雜湊表結構(array or hash table), 可以是 per-CPU 的型別,也可以是 non-per-CPU 型別。

建立和訪問 map

  1. 建立 map:只能從使用者空間操作,通過 bpf(2) 系統呼叫完成。
  2. eBPF 程式中 訪問 map:通過輔助函式。
  3. 使用者空間 訪問 map:通過 bpf(2) 系統呼叫。

map 相關輔助函式呼叫

以上設計意味著,如果 eBPF 程式想呼叫某個 map 相關的輔助函式, 它需要將檔案描述符編碼到指令中 —— 檔案描述符會進一步對應到 map 引用, 並放到正確的暫存器 —— BPF_LD_MAP_FD(BPF_REG_1, fd) 就是一個例子。 核心能識別出這種特殊 src 暫存器的情況,然後從檔案描述符表中查詢該 fd,進而找到真 正的 eBPF map,然後在內部對指令進行重寫(rewrite the instruction)。

2.3 Object Pinning(目標檔案錨定)

eBPF map 和 eBPF program 都是核心資源(kernel resource), 只能通過檔案描述符(file descriptor)訪問 ;而檔案描述符背後是核心中的匿名 inode(backed by anonymous inodes in the kernel)。

檔案描述符方式的限制

以上這種方式有優點,例如:

  • 使用者空間程式 能使用 大部分檔案描述符相關的 API
  • 在 Unix domain socket 傳遞檔案描述符是 透明

但也有缺點:檔案描述符的生命週期在程序生命週期之內,因此不同程序之間 共享 map 之類的東西就比較困難

  • 這給 tc 等應用帶來了很多不便。因為 tc 的工作方式是: 將程式載入到核心之後就退出 (而不是持續執行的程序)。
  • 此外,從使用者空間也無法 直接訪問 map( bpf(2) 系統呼叫不算),否則這會很有用。 例如,第三方應用可能希望在 eBPF 程式執行時(runtime)監控或更新 map 的內容。

針對這些問題,提出了幾種 保持檔案描述符 alive 的設想 ,其中之一是重用 fuse,作為 tc 的 proxy。 這種情況下,檔案描述符被 fuse implementation 所擁有,tc 之類的工具可以通過 unix domain sockets 來獲取這些檔案描述符。但又也帶來了很大的新問題: 增加了新的依賴 fuse,而且需要作為額外的守護程序安裝和啟動。 大型部署中,都希望保持使用者空間最小化(maintain a minimalistic user space)以節省資源。 因此這樣的額外依賴難以讓使用者接受。

BPF 檔案系統(bpffs)

為了更好的解決以上問題,我們 在核心中實現了一個最小檔案系統 (a minimal kernel space file system)[4]。

eBPF map 和 eBPF program 可以 pin(固定)到這個檔案系統,這個過程稱為 object pinning。 bpf(2) 系統呼叫也新加了兩個命令用來 pin 或獲取一個已經 pinned 的 object。 例如,tc 之類的工具利用這個新功能 [9] 就能在 ingress 或 egress 上共享 map。

eBPF 檔案系統在每個 mount 名稱空間建立一個掛載例項(keep an instance per mount namespace), 並支援 bind mounts、hard links 等功能,並與網路命令空間無縫整合。

2.4 尾呼叫(Tail Calls)

eBPF 的另一個概念是尾呼叫 [26]:從一個程式呼叫到另一個程式,且後者執行完之後不再 返回到前者。

  • 不同於普通的函式呼叫,尾呼叫的開銷最小;
  • 底層通過 long jump 實現,複用原來是棧幀(reusing the same stack frame)。

程式之間傳遞狀態

尾呼叫的程式是獨立驗證的 (verified independently), 因此要在兩個程式之間傳遞狀態,就需要用到:

cb

只有同類型的程式之間才可以尾呼叫,而且它們 要麼都是通過直譯器執行, 要麼都是通過 JIT 編譯之後執行 ,不支援混合兩種模式。

底層實現

尾呼叫涉及兩個步驟:

  1. 首先設定一個特殊的、稱為程式陣列(program array)的 map。

    這個 map 可以從使用者空間通過 key/value 操作,其中 value 是各個 eBPF 程式的檔案描述符

  2. 第二步是執行 bpf_tail_call(void *ctx, struct bpf_map *prog_array_map, u32 index) 輔助函式,其中

    prog_array_map
    index
    

    下面是這個輔助函式的進一步說明:

    // include/uapi/linux/bpf.h
    
      * int bpf_tail_call(void *ctx, struct bpf_map *prog_array_map, u32 index)
      * 	Description
      * 		This special helper is used to trigger a "tail call", or in
      * 		other words, to jump into another eBPF program. The same stack
      * 		frame is used (but values on stack and in registers for the
      * 		caller are not accessible to the callee). This mechanism allows
      * 		for program chaining, either for raising the maximum number of
      * 		available eBPF instructions, or to execute given programs in
      * 		conditional blocks. For security reasons, there is an upper
      * 		limit to the number of successive tail calls that can be
      * 		performed.
      *
      * 		Upon call of this helper, the program attempts to jump into a
      * 		program referenced at index *index* in *prog_array_map*, a
      * 		special map of type **BPF_MAP_TYPE_PROG_ARRAY**, and passes
      * 		*ctx*, a pointer to the context.
      *
      * 		If the call succeeds, the kernel immediately runs the first
      * 		instruction of the new program. This is not a function call,
      * 		and it never returns to the previous program. If the call
      * 		fails, then the helper has no effect, and the caller continues
      * 		to run its subsequent instructions. A call can fail if the
      * 		destination program for the jump does not exist (i.e. *index*
      * 		is superior to the number of entries in *prog_array_map*), or
      * 		if the maximum number of tail calls has been reached for this
      * 		chain of programs. This limit is defined in the kernel by the
      * 		macro **MAX_TAIL_CALL_CNT** (not accessible to user space),
      * 		which is currently set to 32.
      * 	Return
      * 		0 on success, or a negative error in case of failure.

核心會將這個輔助函式呼叫轉換成一個特殊的 eBPF 指令 。另外,這個 program array 對於使用者空間是隻讀的。

核心根據檔案描述符( fd = prog_array_map[index] )查詢相關的 eBPF 程式,然後自動將相應 map slot 程式指標 進行替換。如果 prog_array_map[index] 為空,核心就繼續在原來的 eBPF 程式中繼續執行 bpf_tail_call() 之後的指令。

尾呼叫是一個非常強大的功能,例如,解析網路頭(network headers)可以通過 尾呼叫實現( 因為每解析一層就可以丟棄一層,沒有再返回來的需求)。 另外,尾呼叫還能夠在執行時(runtime)原子地新增或替換功能,改變執行行為。

2.5 安全:鎖定映象為只讀模式、地址隨機化

eBPF 有幾種 防止有意或無意的核心 bug 導致程式映象(program images)損壞 的技術 —— 即便這些 bug 跟 BPF 無關。

支援 CONFG_DEBUG_SET_MODULE_RONX 配置選項的平臺, 啟用這個配置後, 核心會將 eBPF 直譯器的映象設定為只讀的 [2]。

當啟用 JIT 編譯之後,核心還會將生成的 可執行映象 (generated executable images) 鎖定為只讀的,並且 對其地址進行隨機化 ,以使猜測更加困難。 映象中的縫隙(gaps in the images)會填充 trap 指令(例如,x86_64 平臺上填充的是 int3 opcode) ,用來捕獲跳轉探測(catching such jump probes)。

對於非特權程式(unprivileged programs),校驗器還會對能使用的 helper 函式、指標 等施加額外的限制,以確保不會發生資料洩露。

2.6 LLVM

至此,還有一個重要方面一直沒有討論:如何編寫 eBPF 程式。

cBPF 提供的選擇很少:libpcap 裡面的 cBPF compiler,bpf_asm,或者手寫 cBPF 程式; 相比之下,eBPF 支援使用更更高層的語言(例如 C 和 P4)來編寫,大大方便了 eBPF 程式的開發。

LLVM 有一個 eBPF 後端 (back end),能生成(emit)包含 eBPF 指令的 ELF 檔案。 Clang 這樣的前端(front ends)能用來編譯 eBPF 程式。

用 clang 來編譯 eBPF 程式非常簡單: clang -O2 -target bpf -o bpf prog.o -c bpf prog.c 。 一個很有用的選項是指定輸出彙編程式碼: clang -O2 -target bpf -o - -S -c bpf prog.c or ,或者用 readelf 之類的工具 dump 和 分析 ELF sections 和 relocations。

典型的工作流:

  1. 用 C 編寫 eBPF 程式碼
  2. 用 clang/llvm 編譯成目標檔案
  3. 用 tc 之類的載入器(能與 cls_bpf 分類器互動)將目標檔案載入到核心

3 tc cls_bpf 和 eBPF

3.0 cls_bpfact_bpf

可程式設計 tc 分類器 cls_bpf

cls_bpf 作為一種分類器(classifier,也叫 filter),2013 年就出現在了 cBPF 中[6]。 通過 bpf_asm 、libpcap/tcpdump 或其他一些 cBPF 位元組碼生成器能對它進行程式設計。 步驟:

  1. 使用工具生成位元組碼(byte code)
  2. 將位元組碼傳遞給 tc 前端
  3. tc 前端 通過 netlink 訊息將位元組碼下發到 tc cls_bpf 分類器

可程式設計 tc 動作(action) act_bpf

後來又出現 act_bpf [20],這是一種 tc action,因此與其他 tc action 一樣,act_bpf 能被 attach 到 tc 分類器 ,作為分類器執行完之後對包要執行的動作(即, 分類器執行完之後返回一個 action code,act_bpf 能根據這個 code 執行相應的行為, 例如丟棄包)。

act_bpf 功能與 cls_bpf 幾乎相同,區別在於二者的返回碼型別:

  • cls_bpf 返回的是 tc classid (major/minor)
  • act bpf 返回的是 tc action opcode

這裡對 cls_bpf/act_bpf 的解釋太簡單。想進一步瞭解,可參考: (譯) 深入理解 tc ebpf 的 direct-action (da) 模式(2020) 譯註。

act_bpf 的缺點是:

  1. 只適用用於 cBPF
  2. 無法對包進行修改(mangle)

因此通常需要用 action pipeline 做進一步處理,例如 act_pedit ,代價是 額外的包級別(packet-level)的效能開銷

eBPF 對 cls_bpf 的支援

eBPF 引入 BPF_PROG_TYPE_SCHED_CLS [8] 和 BPF_PROG_TYPE_SCHED_ACT [7] 之後也 支援了 cls_bpfact_bpf

  • 這兩種型別的 fast path 都在 RCU 內執行(run under RCU)
  • 二者做的主要事情也就是 呼叫 BPF_PROG_RUN() ,後者會解析到 (*filter->bpf_func)(ctx, filter->insnsi) ,其中 ctx 引數包含了 skb 資訊
  • bpf_func() 裡對 skb 進行處理,接下來可能會執行:

    bpf_prog_run()
    

eBPF cls_bpf 帶來的好處

cls_bpf_classify() 之類的函式感知不到底層 BPF 型別(eBPF 還是 cBPF), 因此對於 cBPF 和 eBPF,skb 的穿梭路徑是一樣的。

cls_bpf 相比於其他型別 tc 分類器的一個優勢:能實現高效、非線性分類功能(以及 direct actions,後面會介紹),這意味著 BPF 程式可以得到簡化,只解析一遍就能處理不同型別的 skb (a single parsing pass is enough to process skbs of different types)。

歷史上,tc 支援 attach 多個分類器 —— 前面的沒有匹配成功時,接著匹配下一個。 因此,如果一個包要經過多個分類器,那它的某些欄位就會在每個分類器中都要解析一遍,這顯然是非常低效的。

有了 cls_bpf ,使用單個 eBPF 程式(用作分類器)就可以輕鬆地避免這個問題, 或者是使用 eBPF 尾呼叫結構,後者支援 packet parser 的某些部分進行原子替換。 此時,eBPF 程式就能根據分類或動作結果(classification or action outcome), 來返回不同的 classid 或 opcodes 了,下面進一步介紹。

3.1 工作模式:傳統模式和 direct-action 模式

cls_bpf 在處理 action 方面有兩種工作模式:

  • 傳統模式:分類之後執行 tcf_exts_exec()
  • direct-action 模式

    隨著 eBPF 功能越來越強大,它能做的事情不止是分類,例如,分類器自己就 能夠(無需 action 參與)修改包的內容(mangle packet contents)、更新校驗和 (update checksums)等。

    因此,社群決定引入一個 direct action (da) mode [3]。使用 cls_bpf 時,這是推薦的模式。

在 da 模式中,cls_bpf 對 skb 執行 action,返回的是 tc opcode, 最終形成一個緊湊、輕量級的映象(compact, lightweight image)。 而在此之前,需要使用 tc action 引擎,必須穿越多層 indirection 和 list handling。 對於 eBPF 來說,classid 可以儲存在 skb->tc_classid ,然後返回 action opcode。 這個 opcode 對於 cBPF drop action 這樣的簡單場景也是適用的。

這裡對 da 的解釋過於簡單,很難理解。可參考 下面這篇文章,其對 da 模式的來龍去脈、工作原理和核心實現有更深入介紹: (譯) 深入理解 tc ebpf 的 direct-action (da) 模式(2020) 譯註。

此外,cls_bpf 也支援多個分類器,每個分類器可以工作在不同模式(da 和 non-da) —— 只要你有這個需要。 但建議 fast path 越緊湊越好,對應高效能應用,推薦使用單個 cls_bpf 分類器 並且工作在 da 模式,這足以滿足大部分需求了。

3.2 特性

eBPF cls_bpf 帶來了很多新特性,例如可以讀寫包的很多欄位、一些新的輔助函式。 這些特性或功能可以組合使用,產生強大的效果。

skb 可讀/寫欄位

For the context (skb here is of type struct sk_buff ), cls_bpf 允許讀寫下列欄位:

  • skb->mark
  • skb->priority
  • skb->tc_index
  • skb->cb[5]
  • skb->tc_classid members

允許讀下列欄位:

  • skb->len
  • skb->pkt type
  • skb->queue mapping
  • skb->protocol
  • skb->vlan tci
  • skb->vlan proto
  • skb->vlan present
  • skb->ifindex (translates to netdev’s ifindex)
  • skb->hash

輔助函式

cls_bpf 程式型別中有很多的 helper 函式可供使用。包括

  • 對 map 進行操作(get/update/delete)的輔助函式
  • 尾呼叫輔助函式
  • 對 skb 進行 mangle 的輔助函式(storing and loading bytes into the skb for parsing and packet mangling)
  • 重新計算 L3/L4 checksum 的輔助函式
  • 封裝/解封裝(VLAN、VxLAn 等隧道)相關輔助函式

重定向(redirection)

cls_bpf 還能對 skb 進行重定向,包括,

dev_queue_xmit()
dev_forward_skb()

重定向有兩種可能的方式:

  • 方式一:在 eBPF 程式執行時(runtime)複製一份資料包(clone skb)
  • 方式二:無需複製資料包,效能更好

    需要 cls_bpf 執行在 da 模式,並且 返回值為 TC_ACT_REDIRECT 。sch_clsact 等 qdisc 在 ingress/egress path 上支援這種這種 action。

    eBPF 程式在 runtime 將必要的重定向資訊放到一個 per-CPU scratch buffer, 然後返回相關的 opcode,接下來核心會通過 skb_do_redirect() 來完成重定向。 這種是一種效能優化方式,能顯著提升轉發效能。

除錯(Debug)

可以使用 bpf_trace_printk() 輔助函式,它能將訊息列印到 trace pipe,格式與 printk() 類似, 然後可以通過tc exec bpf dbg等命令讀取。

雖然它作為 helper 函式有一些限制, 能傳遞五個引數,其中前兩個是格式字串,但這個功能還是給編寫和除錯 eBPF 程式帶來了很大便利。

還有其他一些 helper 函式,例如,

net_cls
dst->tclassid
ktime_t

可以 attach 到的 tc hooks

cls_bpf 能 attach 到許多與 tc 相關的 hook 點。這些hook 點可分為三類:

  1. ingress hook
  2. egress hook,這是最近才引入的
  3. classification hook inside classful qdiscs on egress.

前兩種可以通過 sch_clsact qdisc (或 sch_ingress for the ingress-only part) 配置,而且是在 RCU 上下文中無鎖執行的 [12]。

egress hook 在 dev_queue_xmit() 中執行(before fetching the transmit queue from the device)。

3.3 前端(Front End)

tc cls_bpf 的 iproute2 前端 [10] [11] [9] 在將 cls_bpf 資料通過 netlink 傳送到核心之前,在背後做了很多工作。 iproute2 包含了一個通用 ELF 載入器後端,適用於下面幾個部分,實現了通用程式碼的共享:

  • f_bpf (classifier)
  • m_bpf (action)
  • e_bpf (exec)

編譯和載入所涉及到的 iproute2/tc 內部工作:

  • 當用 clang 編譯 eBPF 程式碼時,它會生成一個 ELF 格式的目標檔案, 接下來通過 tc 載入到核心。這個目標檔案就是一個容器(container), 其中包含了 tc 所需的所有資料:它會從中提取資料、重定位(relocate)並載入到 cls_bpf hook 點。

  • 在啟動時,tc 會檢查(如果有必要還會 mount)bpf 檔案系統,用於 object pinning。 預設目錄是 /sys/fs/bpf 。然後會載入和生成一個 pinning 配置用的雜湊表,給 map 共享用。

  • 之後,tc 會掃描目標檔案中的 ELF sections。一些預留的 section 名,

    • maps :for eBPF map specifications (e.g. map type, key and value size, maximum elements, pinning, etc)
    • license :for the licence string, specified similarly as in Linux kernel modules.
    • classifier :預設情況下,cls_bpf 分類器所在的 section
    • act :預設情況下,act_bpf 所在的 section
  • tc 首先讀取輔助功能區(ancillary sections),這包括 ELF 的符號表 .symtab 和字串表 .strtab

    由於 eBPF 中的所有東西都是通過檔案描述符來從使用者空間訪問的, 因此tc 前端首先需要基於 ELF 的 relocation entries 生成 maps, 它將檔案描述符作為立即值(immediate value)插入相應的指令。

    取決於 map 是否是 pinned,tc 或者從 bpffs 的指定位置載入一個 map 檔案描述符, 或者生成一個新的,並且如果有需要,將它 pin 到 bpffs。

處理 Object pinning

sharing maps 有三種不同的 scope:

/sys/fs/bpf/tc/globals
/sys/fs/bpf/tc/<obj-sha>

eBPF maps 可以在不同的 cls_bpf 例項之間共享。 不止通用型別 map(例如 array、hash table)可以共享,專業型別的 map,例如 tracing eBPF 程式(kprobes)使用的 eBPF maps 也與 cls_bpf/act_bpf 使用的 eBPF maps 實現共享。

Object pinning 時,tc 會在 ELF 的符號表和字串表中尋找 map name。 map 建立完成後,tc 會找到程式程式碼所在的 section,然後帶著 map 的檔案描述符信 息執行重定位,並將程式程式碼載入到核心。

處理尾呼叫

當用到了尾呼叫且尾呼叫 subsection 也在 ELF 檔案中時,tc 也會將它們載入到核心。 從 tc 載入器的角度看,尾呼叫可以任意巢狀,但核心執行時對巢狀是有限制的。 另外, 尾呼叫用到的程式陣列(program array)也能被 pin , 這樣能在使用者空間根據程式的執行時行為來修改這個陣列(決定尾呼叫到哪個程式)。

tc exec bpf graft

tc 有個 graft(嫁接) 選項,

tc exec bpf [ graft MAP_FILE ] [ key KEY ]

它能 在執行時替換 section (replacing such sections during runtime)。 Grafting 實際上 所做的事情和載入一個 cls_bpf 分類器差不多 ,區別在於 產生的檔案描述符並不是通過 netlink —— 而是通過相應的 map —— push 到核心。

tc cls_bpf 前端還允許通過 execvpe() 將新生成的 map 的檔案描述符傳遞給新建立的 shell, 這樣程式就能像 stdin、stdout、stderr 一樣全域性地使用它;或者,檔案描述符集合還能通過 Unix domain socket 傳遞給其他程序。 在這兩種情況下,cloned 檔案描述符的生命週期仍然與程序的生命週期緊密相連。通過 bpf fs 獲取檔案描述符是最靈活也是最推薦的方式,[9] 也適用於第三方使用者空間程式管理 eBPF map 的內容。

tc exec bpf dbg

tc 前端提供了列印 trace pipe 的命令列工具: tc exec bpf dbg 。這個命令 會用到 trace fs ,它會自動定位 trace fs 的掛載點。

3.4 工作流(Workflow)

一個典型的工作流是: cls_bpf 分類器以 da 模式載入到核心 ,整個過程簡單直接。

來看下面的例子:

  • 用 clang 編譯原始檔 foo.c,生成的目標檔案 foo.o;foo.o 中包含兩個 section p1p2
  • 啟用核心的 JIT 編譯功能
  • 給網路裝置 em1 新增一個 clsact qdisc
  • 將目標檔案分別載入到 em1 的 ingress 和 egress 路徑上
$ clang -O2 -target bpf -o foo.o -c foo.c
$ sysctl -w net.core.bpf_jit_enable=1
$ tc qdisc add dev em1 clsact
$ tc qdisc show dev em1
[...]
qdisc clsact ffff: parent ffff:fff1

$ tc filter add dev em1 ingress bpf da obj foo.o sec p1
$ tc filter add dev em1 egress bpf da obj foo.o sec p2
$ tc filter show dev em1 ingress
filter protocol all pref 49152 bpf
filter protocol all pref 49152 bpf handle
0x1 foo.o:[p1] direct-action

$ tc filter show dev em1 egress
filter protocol all pref 49152 bpf
filter protocol all pref 49152 bpf handle
0x1 foo.o:[p2] direct-action

最後將它們刪除:

$ tc filter del dev em1 ingress pref 49152
$ tc filter del dev em1 egress pref 49152

3.5 程式設計

iproute2 原始碼中 examples/bpf/ 目錄下包含很多入門示例,是用 restricted C 編寫的 eBPF 程式碼。 實現這樣的分類器還是比較簡單的。

與傳統使用者空間 C 程式相比,eBPF 程式在某些地方是受限的。 每個這樣的分類器都必須放到 ELF sections。因此,一個目標檔案會包含一個或多個 eBPF 分類器。

程式碼共享:行內函數或尾呼叫

分類器之間共享程式碼有兩種方式:

  1. __always_inline 宣告的行內函數

    clang 需要將整個扁平程式(the whole, flat program)程式設計成 eBPF 指令流, 分別放到各自的 ELF section。

    eBPF 不支援共享庫(shared libraries)或 可重入 eBPF 函式 (eBPF functions as relocation entries)。 像 tc 這樣的 eBPF 載入器 ,是無法將多個庫拼裝成單個扁平 eBPF 指令流陣列的 (a single flat array of eBPF instructions) —— 除非它實現 編譯器 的大部分功能。

    因此,載入器和 clang 之間有一份“契約”(contract),其中明確規定了生成的 ELF 檔案中, 特定 section 中必須包含什麼樣的 eBPF 指令。

    唯一允許的重定位項(relocation entries)是與 map 相關的,這種情況下需要先確定檔案描述符。

  2. 尾呼叫

    前面已經介紹過了。

有限棧空間和全域性變數

eBPF 程式的棧空間非常有限,只有 512KB,因此用 C 實現 eBPF 程式時需要特別注意這一點。 常規 C 程式中常見的全域性變數在這裡不支援的。

eBPF maps(在 tc 中對應的是 struct bpf_elf_map )定義在各自的 ELF sections 中,但可以在程式 sections 中訪問到。 因此,如果真的需要全域性“變數”,可以這樣實現:建立一個 per-CPU 或 non-per-CPU array map, 但其中只儲存有一個值,這樣這個變數就能被多個 section 中的程式訪問,例如 entry point sections、tail called sections 等。

動態迴圈

另一個限制是:eBPF 程式不支援動態迴圈(dynamic looping),只支援編譯時已知的常量迴圈 (compile-time known constant bounds),後者能被 clang 展開。

編譯時不能確定是否為常量次數的迴圈會被校驗器拒絕,因為這樣的程式無法靜態驗證 (statically verify)它們是否確定會終止。

4 總結及未來展望

cls_bpf 是 tc 家族中的一個靈活高效的分類器(及 action), 它提供了強大的 資料平面可程式設計能力 ,適用於大量不同場景,例如解析、查詢或更新 (例如 map state),以及對網路包進行修改(mangling)等。 當使用底層平臺的 eBPF JIT 後端進行編譯之後,這些 eBPF 程式能以平臺原生效能執行。 eBPF 是為 既要求高效能又要求高靈活性 的場景設計的。

雖然一些內部細節看上去有點複雜,讓人望而生畏,但瞭解了 eBPF 的限制條件之後, 編寫 cls_bpf eBPF 程式其實與編寫普通使用者空間程式並不會複雜多少。 另外,tc 命令列在設計時也考慮到了易用性,例如用 tc 處理 cls_bpf 前端只需要幾條命令。

cls_bpf 程式碼 及其 tc 前端、eBPF 內部實現及其 clang 編譯器後端全部都是開源的, 由社群開發和維護。

目前還有很多的增強特性和想法正在討論和評估之中,例如將 cls_bpf offload 到可程式設計網絡卡上。 CRIU (checkpoint restore in user space) 目前還只支援 cBPF,如果實現了對 eBPF 的支援,對容器遷移將非常有用。

參考資料

  1. Begel, A.; Mccanne, S.; and Graham, S. L. 1999. Bpf+: Exploiting global data-flow optimization in a generalized packet filter architecture. In In SIGCOMM, 123–134.
  2. Borkmann, D., and Sowa, H. F. 2014. net: bpf: make ebpf interpreter images read-only. Linux kernel, commit 60a3b2253c41.
  3. Borkmann, D., and Starovoitov, A. 2015. cls bpf: introduce integrated actions. Linux kernel, commit 045efa82ff56.
  4. Borkmann, D.; Starovoitov, A.; and Sowa, H. F. 2015. bpf: add support for persistent maps/progs. Linux kernel, commit b2197755b263 .
  5. Borkmann, D. 2013a. filter: bpf asm: add minimal bpf asm tool. Linux kernel, commit 3f356385e8a4.
  6. Borkmann, D. 2013b. net: sched: cls bpf: add bpf-based classifier. Linux kernel, commit 7d1d65cb84e1.
  7. Borkmann, D. 2015a. act bpf: add initial ebpf support for actions. Linux kernel, commit a8cb5f556b56 .
  8. Borkmann, D. 2015b. cls bpf: add initial ebpf support for programmable classifiers. Linux kernel, commit e2e9b6541dd4 .
  9. Borkmann, D. 2015c. ff,mg bpf: allow for sharing maps. iproute2, commit 32e93fb7f66d.
  10. Borkmann, D. 2015d. tc: add ebpf support to f bpf.
  11. Borkmann, D. 2015e. tc, bpf: finalize ebpf support for cls and act front-end. iproute2, commit 6256f8c9e45f.
  12. Borkmann, D. 2016. net, sched: add clsact qdisc. Linux kernel, commit 1f211a1b929c.
  13. de Bruijn, W. 2015a. packet: add classic bpf fanout mode. Linux kernel, commit 47dceb8ecdc1.
  14. de Bruijn, W. 2015b. packet: add extended bpf fanout mode. Linux kernel, commit f2e520956a1a.
  15. Drewry, W. 2012. seccomp: add system call filtering using bpf. Linux kernel, commit e2cfabdfd075.
  16. Gallek, C. 2016. soreuseport: setsockopt so attach reuseport [ce]bpf. Linux kernel, commit 538950a1b752.
  17. Herbert, T. 2016. kcm: Kernel connection multiplexor module. Linux kernel, commit ab7ac4eb9832.
  18. Mccanne, S., and Jacobson, V. 1992. The bsd packet filter: A new architecture for user-level packet capture. 259–269.
  19. Pirko, J. 2012. team: add loadbalance mode. Linux kernel, commit 01d7f30a9f96.
  20. Pirko, J. 2015. tc: add bpf based action. Linux kernel, commit d23b8ad8ab23.
  21. Starovoitov, A., and Borkmann, D. 2014. net: filter: rework/optimize internal bpf interpreter’s instruction set. Linux kernel, commit bd4cf0ed331a.
  22. Starovoitov, A. 2014a. bpf: expand bpf syscall with program load/unload. Linux kernel, commit 09756af46893.
  23. Starovoitov, A. 2014b. bpf: introduce bpf syscall and maps. Linux kernel, commit 99c55f7d47c0.
  24. Starovoitov, A. 2014c. bpf: verifier (add verifier core). Linux kernel, commit 17a5267067f3.
  25. Starovoitov, A. 2014d. net: filter: x86: internal bpf jit. Linux kernel, commit 622582786c9e.
  26. Starovoitov, A. 2015a. bpf: allow bpf programs to tail-call other bpf programs. Linux kernel, commit 04fd61ab36ec.
  27. Starovoitov, A. 2015b. tracing, perf: Implement bpf programs attached to kprobes. Linux kernel, commit 2541517c32be.
  28. Starovoitov, A. 2016. bpf: introduce bpf map type stack trace. Linux kernel, commit d5a3b1f69186 .

« [譯] 深入理解 tc ebpf 的 direct-action (da) 模式(2020)