eBPF 的發展歷史和核心設計


前言

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

Daniel 是 eBPF 的核心開發之一, 文章從技術層面介紹了 eBPF 的發展歷史、核心設計,以及更重要的 —— 在 eBPF 基礎之上 ,cls_bpf 如何使 tc 分類器變得完全可編程。

由於 eBPF 發展很快,文中有些描述今天已經過時(例如單個 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] 等。

Kernel Connection Multiplexor (KCM) is a facility that provides a message based interface over TCP for generic application protocols. With KCM an application can efficiently send and receive application protocol messages over TCP using datagram sockets.

For more information see the included Documentation/networking/kcm.txt

除了替換掉解釋器之外,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), 因此要在兩個程序之間傳遞狀態,就需要用到:

  1. per-CPU maps,作爲自定義數據的存儲區(as scratch buffers),或者
  2. skb 的某些可以存儲自定義數據的字段,例如 cb(control buffer)字段

只有同類型的程序之間纔可以尾調用,而且它們要麼都是通過解釋器執行, 要麼都是通過 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)]({% link _posts/2021-02-21-understanding-tc-da-mode-zh.md %} "(譯) 深入理解 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 進行處理,接下來可能會執行

    • eBPF 解釋器( bpf_prog_run()
    • JIT 編譯器生成的 JIT image

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)]({% link _posts/2021-02-21-understanding-tc-da-mode-zh.md %} "(譯) 深入理解 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() 在 egress 路徑中重定向,或者
  • dev_forward_skb() 中重定向回 ingress path。

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

  • 方式一:在 eBPF 程序運行時(runtime)複製一份數據包(clone skb)

  • 方式二:無需複製數據包,性能更好

    需要 cls_bpf 運行在 da 模式,並且返回值爲 TC_ACT_REDIRECTsch_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 函數,例如,

  • 讀取 skb 的 cgroup classid( net_cls cgroup),
  • 讀取 dst 的 routing realm ( dst->tclassid)
  • 獲取一個隨機數(例如用於採樣)
  • 獲取當前包正在被哪個 CPU 處理
  • 獲取納秒爲單位的當前時間( 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]。

可進一步參考:

  • [(譯) 深入理解 tc ebpf 的 direct-action (da) 模式(2020)]({% link _posts/2021-02-21-understanding-tc-da-mode-zh.md %} "(譯) 深入理解 tc ebpf 的 direct-action (da) 模式(2020)")
  • [(譯) 爲容器時代設計的高級 eBPF 內核特性(FOSDEM, 2021)]({% link _posts/2021-02-13-advanced-bpf-kernel-features-for-container-age-zh.md %} "(譯) 爲容器時代設計的高級 eBPF 內核特性(FOSDEM, 2021)")

譯註。

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

  1. /sys/fs/bpf/tc/globals:全局命名空間
  2. /sys/fs/bpf/tc/<obj-sha>:對象命名空間(object namespace)
  3. 自定義位置

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[2](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].
  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 [4].
  5. Borkmann, D. 2013a. filter: bpf_asm: add minimal bpf asm tool. Linux kernel, commit 3f356385e8a4 [5].
  6. Borkmann, D. 2013b. net: sched: cls_bpf: add bpf-based classifier. Linux kernel, commit 7d1d65cb84e1 [6].
  7. Borkmann, D. 2015a. act bpf: add initial ebpf support for actions. Linux kernel, commit a8cb5f556b56 [7].
  8. Borkmann, D. 2015b. cls bpf: add initial ebpf support for programmable classifiers. Linux kernel, commit e2e9b6541dd4 [8].
  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 [9].
  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 [10].
  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 [11].
  21. Starovoitov, A., and Borkmann, D. 2014. net: filter: rework/optimize internal bpf interpreter’s instruction set.  Linux kernel, commit bd4cf0ed331a [12].
  22. Starovoitov, A. 2014a. bpf: expand bpf syscall with program load/unload. Linux kernel, commit 09756af46893 [13].
  23. Starovoitov, A. 2014b. bpf: introduce bpf syscall and maps. Linux kernel, commit 99c55f7d47c0 [14].
  24. Starovoitov, A. 2014c. bpf: verifier (add verifier core).  Linux kernel, commit 17a5267067f3 [15].
  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 [16].
  27. Starovoitov, A. 2015b. tracing, perf: Implement bpf programs attached to kprobes. Linux kernel, commit 2541517c32be [17].
  28. Starovoitov, A. 2016. bpf: introduce bpf map type stack trace. Linux kernel, commit d5a3b1f69186 [18].

參考資料

[1]

On getting tc classifier fully programmable with cls_bpf: https://www.netdevconf.org/1.1/proceedings/papers/On-getting-tc-classifier-fully-programmable-with-cls-bpf.pdf

[2]

CRIU: https://criu.org/Main_Page

[3]

60a3b2253c41: https://github.com/torvalds/linux/commit/60a3b2253c41

[4]

b2197755b263: https://github.com/torvalds/linux/commit/b2197755b263

[5]

3f356385e8a4: https://github.com/torvalds/linux/commit/3f356385e8a4

[6]

7d1d65cb84e1: https://github.com/torvalds/linux/commit/7d1d65cb84e1

[7]

a8cb5f556b56: https://github.com/torvalds/linux/commit/a8cb5f556b56

[8]

e2e9b6541dd4: https://github.com/torvalds/linux/commit/e2e9b6541dd4

[9]

1f211a1b929c: https://github.com/torvalds/linux/commit/1f211a1b929c

[10]

ab7ac4eb9832: https://github.com/torvalds/linux/commit/ab7ac4eb9832

[11]

d23b8ad8ab23: https://github.com/torvalds/linux/commit/d23b8ad8ab23

[12]

bd4cf0ed331a: https://github.com/torvalds/linux/commit/bd4cf0ed331a

[13]

09756af46893: https://github.com/torvalds/linux/commit/09756af46893

[14]

99c55f7d47c0: https://github.com/torvalds/linux/commit/99c55f7d47c0

[15]

17a5267067f3: https://github.com/torvalds/linux/commit/17a5267067f3

[16]

04fd61ab36ec: https://github.com/torvalds/linux/commit/04fd61ab36ec

[17]

2541517c32be: https://github.com/torvalds/linux/commit/2541517c32be

[18]

d5a3b1f69186: https://github.com/torvalds/linux/commit/d5a3b1f69186


原文鏈接:https://arthurchiao.art/blog/on-getting-tc-classifier-fully-programmable-zh/



你可能還喜歡

點擊下方圖片即可閱讀

不好,WireGuard 與 Kubernetes CNI 摩擦生火了。。

雲原生是一種信仰 🤘


關注公衆號

後臺回覆◉k8s◉獲取史上最方便快捷的 Kubernetes 高可用部署工具,只需一條命令,連 ssh 都不需要!



點擊 "閱讀原文" 獲取更好的閱讀體驗!


發現朋友圈變“安靜”了嗎?

本文分享自微信公衆號 - 雲原生實驗室(cloud_native_yang)。
如有侵權,請聯繫 [email protected] 刪除。
本文參與“OSC源創計劃”,歡迎正在閱讀的你也加入,一起分享。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章