邊緣網絡 eBPF 超能力:eBPF map 原理與性能解析

衆所周知,大型 eBPF 程序構建過程中 eBPF map 必不可少。火山引擎邊緣計算在數據面也大量使用了 eBPF 及其 map 機制。如何用好 map 是 eBPF 網絡編程中關鍵的一環,不同 map 的性能差異也較大。本文組織 eBPF map 相關的底層實現,爲大家詳細解析 eBPF map 的原理及性能。

  1. 什麼是 eBPF map

  2. eBPF map 原理

  3. eBPF map 性能

  4. 總結

背景

衆所周知,大型 eBPF 程序構建過程中 eBPF map 必不可少。map 是打通數據面和控制面的關鍵機制。在實際使用過程中,我們可以通過 map 存儲彈性公網 IP 配置數據、在數據面匹配時通過 map 來查詢彈性公網 IP,然後執行限速、NAT 等邏輯,以及通過 map 來存儲鏈接等。

火山引擎邊緣計算在數據面也大量使用了 eBPF 及其 map 機制,並基於 eBPF 實現了 VPC 網絡、負載均衡、彈性公網 IP、外網防火牆等一系列高性能、高可用的雲原生網絡解決方案。

火山引擎邊緣計算雲平臺架構圖

eBPF map 有多種不同類型,支持不同的數據結構,最常見的例如 Array、Percpu Array、Hash、Percpu Hash、lru Hash、Percpu lru Hash、lpm 等等。那麼選取哪個類型的 map,如何用好 map 就是 eBPF 網絡編程中關鍵的一環,不同 map 的性能也是相差很大的。本文組織 eBPF map 相關的底層實現,爲大家詳細解析 eBPF map 的原理及性能。

什麼是 eBPF map

eBPF map 是一個通用的數據結構存儲不同類型的數據,提供了用戶態和內核態數據交互、數據存儲、多程序共享數據等功能。官方描述 [1]:

eBPF maps are a generic data structure for storage of different data types. Data types are generally treated as binary blobs, so a user just specifies the size of the key and the size of the value at map-creation time. In other words, a key/value for a given map can have an arbitrary structure.

A user process can create multiple maps (with key/value-pairs being opaque bytes of data) and access them via file descriptors. Different eBPF programs can access the same maps in parallel. It's up to the user process and eBPF program to decide what they store inside maps.

eBPF 數據面中怎麼使用 map

在 eBPF 數據面中,我們使用 eBPF map 只需要按照規範定義 map 的結構,然後使用 bpf_map_lookup_elem、bpf_map_update_elem、bpf_map_delete_elem 等 helper function 就可以對 map 進行查詢、更新、刪除等操作。

下面以開源項目 cilium[2] 展示了一個 map 的使用例子:

1、map 的定義:定義全局的變量 ENDPOINTS_MAP,定義了 map 相關屬性,比如類型 hash、key value 的大小、map 的大小等等。

struct bpf_elf_map __section_maps ENDPOINTS_MAP = {
        .type           = BPF_MAP_TYPE_HASH,
        .size_key       = sizeof(struct endpoint_key),
        .size_value     = sizeof(struct endpoint_info),
        .pinning        = PIN_GLOBAL_NS,
        .max_elem       = ENDPOINTS_MAP_SIZE,
        .flags          = CONDITIONAL_PREALLOC,
};

2、查詢 map:

static __always_inline __maybe_unused struct endpoint_info *
__lookup_ip4_endpoint(__u32 ip)
{
        struct endpoint_key key = {};

        key.ip4 = ip;
        key.family = ENDPOINT_KEY_IPV4;

        return map_lookup_elem(&ENDPOINTS_MAP, &key);
}

可以看到:map_lookup_elem 幫助函數只需要傳入 &ENDPOINTS_MAP 和 key 即可。

那麼問題來了:

eBPF map 原理

eBPF 加載器與 map

eBPF 編程繞不開的是:將編寫好的 eBPF 程序加載到內核,然後在內核態執行 eBPF 程序。因此需要有一個加載器將 eBPF 程序以及程序使用的 eBPF map 加載到內核中(或者複用已存在的 map)。

eBPF 加載器介紹

eBPF 程序加載的本質是 BPF 系統調用,Linux 內核通過 BPF 系統調用提供 eBPF 相關的一切操作,比如:程序加載、map 創建刪除等。常見的 loader 都是對這個系統調用的封裝,部分 loader 提供更加原生接近系統調用的操作,部分 loader 則是進行了更多封裝使得編程更便捷。下面介紹一下常見的 loader。

iproute2[3] 提供 ip 命令、tc 命令將 eBPF 程序加載到內核並掛載到網口,提供更多封裝能力。開發者只需要實現 eBPF 代碼,使用 ip/tc 命令指定掛載的 eBPF 程序和掛載的網口即可。

# xdp程序類型
ip link set dev eth0 xdp obj XXX.o sec PROG_SEC_NAME
# sched_cls程序類型:加載程序到tc ingress
tc filter dev eth0 ingress prio 5 handle 1 bpf da obj XXXX.o sec PROG_SEC_NAME

iproute2 提供了更便利的使用方式,比如:上面看到定義的 ENDPOINTS_MAP 中,定義了 pinning 屬性爲 PIN_GLOBAL_NS。iproute2 就會將這個 map pin 到 eBPF 文件系統中,如果 eBPF 文件系統已存在一個 pinned 的 map 則直接複用,實現多個程序共享一個 map 的效果。

典型案例:cilium 項目使用 iproute2 加載 BPF 程序。

內核實現的 libbpf 庫 [4],封裝了 BPF 系統調用,使得加載 BPF 程序更便捷。libbpf 不像 iproute2,它能夠使 BPF 相關操作更爲便捷,沒有做過多封裝。如果要將程序加載到內核,則需要自己實現一個用戶態程序,調用 libbpf 的 API 去加載到內核。如果要複用 pinned 在 BPF 文件系統的 MAP,也需要用戶態程序調用 libbpf 的 API,在加載程序時進行相關處理。

典型案例:Facebook 開源的 katran 項目使用 libbpf 加載 eBPF 程序。

cilium/ebpf 庫 [5] 是一個 GO 語言版本的 libbpf 庫,它封裝了 BPF 系統調用,與內核提供的 libbpf 類似。使用 cilium/ebpf 庫實現用戶態程序加載 eBPF 到內核,在很多方面都類似 libbpf,區別在於這個庫是 GO 語言的,更加方便使用 GO 語言構建一套 eBPF 程序的控制面方案。

bcc[6] 實現了將用戶態編譯、加載、綁定的功能都集成了起來,方便用戶使用,對用戶的接口更友好。支持 Python 接口以及很多基於 eBPF 實現的分析工具。

BPF 系統調用

Linux 內核通過 BPF 系統調用並提供 BPF 相關的能力。對於 eBPF 編程中的 map,當然也有 BPF 系統調用提供的能力。BPF 系統調用定義:

SYSCALL_DEFINE3(bpf, int, cmd, union bpf_attr __user *, uattr, unsigned int, size)
{
        return __sys_bpf(cmd, USER_BPFPTR(uattr), size);
}

BPF 系統調用通過第一個參數 cmd 來區分相關的 BPF 操作,map 常見的 cmd 有:創建 MAP、查詢 MAP 中元素、更新 MAP 中元素、刪除 MAP 中元素等等。cmd 如下:

enum bpf_cmd {
        BPF_MAP_CREATE,
        BPF_MAP_LOOKUP_ELEM,
        BPF_MAP_UPDATE_ELEM,
        BPF_MAP_DELETE_ELEM,
        BPF_MAP_GET_NEXT_KEY,
        // ...
}

eBPF map 的創建

細心的你可能已經發現 BPF 系統調用有一個 BPF_MAP_CREATE 的 cmd,這就能回答我們上面的第一個問題:在內核中,ENDPOINTS_MAP 的內存是怎麼分配的?

map 是需要調用 BPF 系統調用來創建的。內核態的系統調用執行過程有幾步:

**在我們的 eBPF 代碼中,僅需要定義 map 全局變量,即可在代碼中直接使用了,沒有相關調用 bpf syscall 創建 map 的邏輯。**那麼其內部機制是怎樣的?是 map 創建的過程然後由 loader 加載器完成的,編譯器和加載器根據同一個約定完成這項工作。

上面 cilium 的例子中,ENDPOINTS_MAP 的全局變量定義時有一個關鍵字 __section_maps,這個關鍵字是一個宏,最終展示是 attribute((section("maps")))。這個編譯器屬性告訴編譯器將 ENDPOINTS_MAP 變量放在編譯生成的 .o 文件 (elf) 中,名爲 maps 的 section。

在使用 iproute2 加載程序時,打開 .o 文件時,會讀取 maps 命名的 section,並將其中存儲的一個個 map 讀取出來,然後調用 BPF 系統調用在內核創建 eBPF map。

如果是 libbpf 或 cilium/ebpf 庫,需要調用 API 將 .o 文件打開,也支持相應的解析 elf 文件工作。並根據 API 調用規範,後續調用相關 API 創建 MAP。所以,這些 lib 庫需要我們自己實現用戶態的程序加載 BPF 代碼到內核,而 iproute2 完全封裝了這些流程。當然使用 lib 庫能夠更加靈活去做一些工作。

比如,在 libbpf 的 API bpf_object__open 打開 .o 文件的過程,就會解析文件中 section 名字爲 maps 的段,將每個 map 解析出來。調用鏈:

bpf_object__open
    __bpf_object__open_xattr
        __bpf_object__open
            bpf_object__init_maps
                bpf_object__init_user_maps // 解析一個個MAP

另外有一個點值得注意,libbpf 和 iproute2 對 map 的結構定義是不一樣的。libbpf 是:

struct bpf_map_def {
        unsigned int type;
        unsigned int key_size;
        unsigned int value_size;
        unsigned int max_entries;
        unsigned int map_flags;
};

而 iproute2 是:

struct bpf_elf_map {
        __u32 type;
        __u32 size_key;
        __u32 size_value;
        __u32 max_elem;
        __u32 flags;
        __u32 id;
        __u32 pinning;
};

可以看到 iproute2 的定義是增加了 id 和 pinning 兩個字段,用於提供更加便捷的功能。比如:pinning 用於指定這個 map 是否需要 pin 到 BPF 文件系統,用於複用 map。

其實內核提供的 BPF 系統調用,只需要 5 個關鍵屬性作爲參數(type/key_size/value_size/max_entries/map_flags),這個結構叫什麼是什麼並不重要,如果你自己實現 loader 來解析 .o 文件,也可以自定義 map 結構,只需要在 loader 最終調用 BPF 系統調用時有這幾個參數即可。

總結來說,loader 會調用 BPF 的系統調用到內核創建 eBPF map,然後內核返回創建 map 的 fd。

eBPF 程序與 map 關聯

我們程序代碼中直接使用 map 時,直接使用 map 全局變量,怎麼與 loader 通過系統調用創建的 map 關聯?

事實上,程序訪問 map,關鍵的實現如下:

  1. 在 loader 加載 BPF 程序到內核之前,loader 都會先將所有定義在 “maps” section 中的 map 創建在內核中(也可能是複用內核已有的 map),內核會返回 map 的 fd。

  2. loader 將內核返回的 map 的 fd,替換到使用的 map eBPF 指令的常量字段中,相當於直接修改編譯後的 BPF 指令。

  3. loader 在指令替換 map fd 後,纔會調用 cmd 爲 BPF_PROG_LOAD 的 bpf syscall,將程序加載到內核。

  4. 內核在加載 eBPF 程序系統調用過程中,會根據 eBPF 指令中常量字段存儲的 MAP fd,找到內核態中的 map,然後將 map 內存地址替換到對應的 BPF 指令。

  5. 最終,BPF 程序在內核執行階段能根據指令存儲的內存地址訪問到 map。

上面一句話聽起來有點抽象,我們將實現剖析一下。

首先,在上文已經介紹了 loader 會解析 “maps” 命名的 section,libbpf 會將 map 數據保存在一個 bpf_object 結構並返回。然後,用戶態的程序還需要調用 libbpf 的 API bpf_object__load 將 bpf_object 結構真正加載到內核(注:iproute2 不需要你調用這麼多 API,只需要根據其編碼規範定義相關結構,然後它會幫你完成這一切)。

bpf_object__load API 關於 MAP 的處理調用鏈:

bpf_object__load
    bpf_object__load_xattr
        bpf_object__create_maps // 將解析的map創建
        bpf_object__relocate // 將map的fd替換到指令
        bpf_object__load_progs // 將程序加載到內核

loader 是怎麼將 map 的 fd 替換到指令中的呢?

在 BPF 程序編譯後,生成的 .o 文件是可重定位的對象文件 (Relocatable file),在一般的程序編譯過程中,還需要一步鏈接,鏈接器將各個目標文件組裝在一起,解決符號依賴,庫依賴關係,最終生成可執行文件。對於 BPF 編程來說,只需要生成 .o 文件,然後將文件加載到內核。對於 Loader,在將文件加載到內核前,會把 .o 文件中的 BPF 指令讀取出來,解決 map 的“重定位”,然後再將指令加載到內核。Loader 算是完成了 map 的“鏈接” 工作。

下面以一個程序實際例子來說明 map 重定位過程,有興趣可以查看各個 loader 的實現,最終的結果是一致的。對於 BPF 編程,定義一個程序的格式會在函數前定義 SEC()。以 Facebook katran 代碼爲例子:

SEC("xdp-root")
int xdp_root(struct xdp_md *ctx) {
    return root_tail_call(ctx, &root_array);
}

__attribute__((__always_inline__))
static inline int root_tail_call(struct xdp_md *ctx, void *root_array){
  #pragma clang loop unroll(full)
  for (__u32 i = 0; i < ROOT_ARRAY_SIZE; i++) { // ROOT_ARRAY_SIZE是4
    bpf_tail_call(ctx, root_array, i);
  }
  return XDP_PASS;
}

而 SEC 宏:實際上是告訴編譯器將下面函數代碼放到 .o 文件中以 NAME 命名的 section。

#define SEC(NAME) __attribute__((section(NAME), used))

所以,通過 readelf -S 查看編譯生成的 .o 文件,可以看到一個以 xdp-root 命名的 section,這個 section 存放的就是 xdp_root 的 BPF 程序編譯後的 BPF 指令。同時,我們可以看到有一個 .rel 前綴加上 xdp-root 命名的 section,這個 section 存放的就是 xdp-root 代碼中需要重定位的符號 symbol。這個 .relxdp-root section 的頭部信息可以看到 type 是 REL,Info 是 13(對應 xdp-root 的 section id):

Section Headers:
  [Nr] Name              Type             Address           Offset
       Size              EntSize          Flags  Link  Info  Align
  [13] xdp-root          PROGBITS         0000000000000000  00006740
       00000000000000b0  0000000000000000  AX       0     0     8
  [14] .relxdp-root      REL              0000000000000000  00024388
       0000000000000040  0000000000000010          67    13     8

通過命令 readelf -x 14 可以將可重定位對象文件 (.o) 的 .relxdp-root section 讀取出來:

Hex dump of section '.relxdp-root':
  0x00000000 08000000 00000000 01000000 44030000 ............D...
  0x00000010 30000000 00000000 01000000 44030000 0...........D...
  0x00000020 58000000 00000000 01000000 44030000 X...........D...
  0x00000030 80000000 00000000 01000000 44030000 ............D...

根據 Section Headers 信息可知 .relxdp-root 這個 section 每個 entry 的大小是 0x10(16 字節),對應上述 Hex dump 出來的每一行的大小。所以 .relxdp-root section 共有 4 個 entry。

對應上述 dump 處理的數據,每一個重定位的 entry 的組成:

由於字節序問題,上述 dump 對應 entry 結構反過來看:

以第一個 entry 爲例子:symbol index 是 44030000(字節序轉換後:0x0344),通過 readelf -s 命令查看符號表 836(0x344) 對應的符號是 root_array,符合我們上 xdp-root 的代碼。我們看到重定向 entry 有 4 條,都是對應 root_array 符號,代碼共 4 次循環調用 root_array 符號(BPF 編譯會將循環展開):

# readelf -s
Symbol table '.symtab' contains 847 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
   836: 000000000000001c    28 OBJECT  GLOBAL DEFAULT   19 root_array

通過 readelf 直接將 xdp-root section 打印出來(都是 BPF 指令):

Hex dump of section 'xdp-root':
 NOTE: This section has relocations against it, but these have NOT been applied to this dump.
  0x00000000 bf160000 00000000 18020000 00000000 ................
  0x00000010 00000000 00000000 b7030000 00000000 ................
  0x00000020 85000000 0c000000 bf610000 00000000 .........a......
  0x00000030 18020000 00000000 00000000 00000000 ................
  0x00000040 b7030000 01000000 85000000 0c000000 ................
  0x00000050 bf610000 00000000 18020000 00000000 .a..............
  0x00000060 00000000 00000000 b7030000 02000000 ................
  0x00000070 85000000 0c000000 bf610000 00000000 .........a......
  0x00000080 18020000 00000000 00000000 00000000 ................
  0x00000090 b7030000 03000000 85000000 0c000000 ................
  0x000000a0 b7000000 02000000 95000000 00000000 ................

根據第一個重定位 entry 的 offset 是 08000000 00000000(字節序轉換後是 0x8),找到對應指令(編譯後 .o 文件中的 BPF 指令都是 8 字節爲單位):18020000 00000000。

在編譯後的彙編指令結構是:

struct bpf_insn {
        __u8        code;                /* opcode */
        __u8        bpf_registers;        /* dest & source register */
        __s16        off;                /* signed offset */
        __s32        imm;                /* signed immediate constant */
};

18020000 00000000 指令的 opcode 是 0x18。opCode 意義是:load memory、immediate value、size 是 double(64 bits),用於訪問 map 的指令。

在 loader 層面對於根據重定向 entry 找到的指令,且這個重定向 entry 關聯的 symbol 是 map 情況下,會將 map 的 fd 寫到這條指令的常量字段 (imm),並將指令的 src 寄存器設置爲 BPF_PSEUDO_MAP_FD。最終,loader 將程序加載到內核時,對於訪問 map 的 BPF 指令的常量字段就存儲了 map 的 fd。

內核系統調用的處理

加載 eBPF 程序的系統調用處理過程中,會針對替換到指令的 map fd 進行處理,將其改爲 MAP 的內存地址。

系統調用 eBPF 程序加載調用過程中,會調用 resolve_pseudo_ldimm64 函數:遍歷全部指令,找到訪問 MAP 的指令(opcode 是 0x18、src_reg 是 1),將 MAP 的 fd 替換爲 MAP 的內存地址(寫到指令的常量字段),這樣 BPF 指令執行過程中就能直接訪問 MAP。核心代碼:

static int resolve_pseudo_ldimm64(struct bpf_verifier_env *env){
        struct bpf_insn *insn = env->prog->insnsi;
        int insn_cnt = env->prog->len;
        int i, j, err;

        for (i = 0; i < insn_cnt; i++, insn++) { // 遍歷全部指令
                if (insn[0].code == (BPF_LD | BPF_IMM | BPF_DW)) { // opcode是0x18
                        struct bpf_map *map;
                        struct fd f;
                        u64 addr;
                        u32 fd
                        // ...校驗邏輯
                        switch (insn[0].src_reg) {
                        case BPF_PSEUDO_MAP_IDX_VALUE:
                        case BPF_PSEUDO_MAP_IDX:
                                // ...
                        default:
                                fd = insn[0].imm; // 重常量字段拿到loader寫入的MAP的fd
                                break;
                        }

                        f = fdget(fd); // 根據fd找到f
                        map = __bpf_map_get(f); // 創建map時,會將map指針存儲到file的private_data中
                        if (insn[0].src_reg == BPF_PSEUDO_MAP_FD ||
                            insn[0].src_reg == BPF_PSEUDO_MAP_IDX) {
                                addr = (unsigned long)map; // 拿到map的內存地址(內核態)
                        }
                        insn[0].imm = (u32)addr; // 將64位內存地址分別存儲到兩條指令的常量字段,這也解釋了上面彙編看到的0x18 code的8字節指令後面緊跟8字節全0指令的意義
                        insn[1].imm = addr >> 32;
                        // ...
                        insn++;
                        i++;
                        continue;
                }
        }
        /* now all pseudo BPF_LD_IMM64 instructions load valid
         * 'struct bpf_map *' into a register instead of user map_fd.
         * These pointers will be used later by verifier to validate map access.
         */
        return 0;
}

eBPF map 原理小結

結合上述的分析,可以說 eBPF map 是編譯器、加載器、Linux 內核協同實現的

另外,多個 eBPF 程序共享一個 MAP 的原理也就不難理解了:

eBPF map 性能

eBPF map 位於在內核的內存中,eBPF 程序在執行時是直接訪問內存的,一般來說我們通過 helper function 來訪問 map,比如查詢 map 是通過 bpf_map_lookup_elem。不同類型的 map 查詢、更新、刪除的實現都是不同的,性能也是差別很大,針對不同場景使用不同類型的 map,以及瞭解不同 map 性能差異對於 eBPF 編程來說相當重要。

這裏給出經過測試驗證的結論及建議:

總結

通過上述原理的剖析以及實際的性能測試調優,我們對於如何寫好 eBPF 程序有了更深刻的理解。在此基礎之上,我們通過 eBPF 實現了一套高性能高可用的雲原生網絡解決方案,歡迎大家使用火山引擎邊緣計算相關產品。也歡迎優秀的你加入我們,探索無限可能。

參考資料:

[1] https://man7.org/linux/man-pages/man2/bpf.2.html

[2] https://github.com/cilium/cilium

[3] https://github.com/shemminger/iproute2

[4] https://github.com/torvalds/linux/blob/master/tools/lib/bpf/libbpf.c

[5] https://github.com/cilium/ebpf

[6]https://github.com/iovisor/bcc

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/rIeuGkiRxl7oAGON99JCZg