使用 eBPF 和 XDP 高速處理數據包

前言

XDP 是一種特殊的 eBPF 程序,在數據包處理上因爲在協議棧之前就可以處理數據,所以有非常高的性能。

這篇文章先在原理上對 XDP 進行了介紹,並由 2 個簡單的例子來對使用場景進行說明。另外還介紹了作者所在公司的一個開源項目:https://github.com/sematext/oxdpus。裏面有幾個 XDP 的應用程序,有相關的應用空間程序和內核相關的代碼。用戶空間命令工具是 golang 寫的,並且 eBPF 的操作是使用 gobpf ,對於學習瞭解 XDP 有幫助,命令基本可以直接使用。

XDP 介紹

XDP 或 Express Data Path 的興起是因爲 Linux 內核需要一個高性能的包處理能力。很多繞過內核的技術(DPDK 是最突出的一個)目標都是通過把包處理遷移到用戶空間來加速網絡操作。

這就意味着要消除內核 - 用戶空間邊界之間的上下文切換、系統調用轉換或 IRQ 請求所引起的開銷。操作系統將網絡堆棧的控制權交給用戶空間進程,這些進程通過自己的驅動程序直接與 NIC 交互。

雖然這種做法的帶來了明顯的高性能,但是它也帶來了一系列的缺陷,包括在用戶空間要重新實現 TCP/IP 協議棧以及其它網絡功能,或者是放棄了內核中強大的資源抽象管理和安全管理。

XDP 的目的是在內核中也達到可編程的包處理,並且仍然保留基礎的網絡協議棧模塊。實際上,XDP 代表了 eBPF 指令的自然擴展能力。它使用 maps,可管理的幫助函數,沙箱字節運行器來做到可編程,這些字節碼會被檢測安全之後纔會加載到內核中運行。

XDP 高速處理路徑的關鍵點在於這些編程字節碼被加載到網絡協議棧最早期的可能處理點上,就在網絡包接受隊列(RX)之後。在網絡協議棧的這一階段中,還沒有構建網絡包的任何內核屬性,所以非常有利於提升網絡處理速度。

如果你沒有看過我之前關於 eBPF 基礎的博文,我建議你首先應該讀一下,這篇我也翻譯了:基於 eBPF 的 Linux 可觀測性。爲了強調 XDP 在網絡協議棧中的位置,讓我們來一起看看一個 TCP 包的生命過程,從它到達 NIC 知道它發送到用戶空間的目的 socket。始終要記住這是一個高級別的視圖。我們將只觸及這個複雜的核心網絡堆棧的表面層。

通過網絡協議棧的入包

網卡在收到一幀(所有校驗和正常檢查)時,網卡就會使用 DMA 來轉發數據包到對於的內存區域。這意味着數據包是由驅動做了映射後直接從網卡隊列拷貝到主內存區。當環形接受隊列有數據進入的時候,網卡會產生一個硬中斷,並且 CPU 會把處理事件下發到中斷向量表中,執行驅動代碼。

因爲驅動的執行路徑必須非常短快,具體數據處理可以延遲到驅動中斷上下文之外,使用軟中斷來觸發處理(NET_RX_SOFTIRQ)。在中斷處理的時候中斷請求是被屏蔽的,內核更願意把這種長時間處理的任務放在中斷上下文之外,以避免在中斷處理的時候丟失中斷事件。設備驅動開始使用 NAPI 循環和一個 CPU 一個內核線程(ksoftirqd)來從環形緩衝區中消費數據包。NAPI 循環的責任主要就是觸發軟中斷(NET_RX_SOFTIRQ),由軟中斷處理程序處理數據包並且發送數據到網絡協議棧。

設備驅動申請一個新的 socket 緩衝區(sk_buff)來存放入流量包。socket 緩衝區是內核中對數據包緩衝 / 處理抽象出來的一個最基礎的數據結構。在整個網絡協議棧中的上層中都在使用。

socket 緩衝區的結構體由多個字段,來標識不同的網絡層。從 CPU 隊列上消費緩衝數據後,內核會填充這些元數據,複製 sk_buff 並且把它推到上游的網絡層的自隊列中做進一步處理。這是 IP 協議層在堆棧中註冊的位置。IP 層執行一些基本的完整型檢測,並且把包發送給 netfilter 的鉤子函數。如果包沒有被 netfilter 丟棄,IP 層會檢測高級協議,並且爲之前提取的協議把處理交給響應的處理函數。

數據最終被拷貝到 socket 關聯的用戶空間緩衝區。進程通過阻塞系統調用(recv、read)函數或通過某種輪詢機制(epoll)主動接收數據。

在網卡把數據包拷貝到接受隊列之後就觸發了 XDP 的鉤子函數,在這一點上我們可以高效的阻止申請各種各樣的元數據結構,包括 sk_buffer。如果我們看一下非常簡單的可能使用場景,比如在高流量網絡中的包過濾或者阻止 DDos 攻擊,傳統的網絡防火牆方案(iptables)由於網絡堆棧中的每個階段都會引入大量的工作負載,這將不可避免地給機器造成壓力。

在裸機速度下的 eBPF 和 XDP 包處理流程

在網絡協議棧中的 XDP 的鉤子 

具體上來看在軟中斷任務中調度順序執行的 iptables 規則,會在 IP 協議層中去匹配指定的 IP 地址,以決定是否丟棄這個數據包。和 iptables 不一樣的是 XDP 會直接操作一個從 DMA 後端環形緩衝區中拿的原始的以太幀包,所以丟棄邏輯可以很早的執行,這樣就節省了內核時間,避免了會導致協議棧執行導致的延時。

XDP 組成

正如你已經知道的,eBPF 的字節碼可以掛載在各種策略執行點上,比如內核函數,socket,tracepoint,cgroup 層級或者用戶空間符號。這樣的話,每個 eBPF 程序操作特定的上下文 - kprobes 場景下的 CPU 寄存器狀態,socket 程序的 socket 緩衝區等等。用 XDP 的說法,生成的 eBPF 字節碼的主幹是圍繞 XDP 元數據上下文建模的(xdp_md)。XDP 上下文包含了所有需要在原始形式下訪問數據包的信息。

爲了更好地理解 XDP 程序的關鍵模塊,讓我們剖析以下章節:

#include <linux/bpf.h>
#define SEC(NAME) __attribute__((section(NAME), used))

SEC("prog")
int xdp_drop(struct xdp_md *ctx) {
   return XDP_DROP;
}

char __license[] SEC("license") = "GPL";

這個小 XDP 程序一旦加載到網卡上就會丟棄所有數據包。我們引入了 bpf 頭文件,它裏面包含了數據結構定義,包括 xdp_md 結構體。接下來,聲明瞭 SEC 宏來存放 map,函數,許可證元信息和其它 ELF 段中的元素(可以被 eBPF 加載器解析)。

現在來看我們 XDP 程序中處理數據包邏輯最相關的部分。XDP 做了預定義的一組判定可以決定內核處理數據包流。例如,我們可以讓數據包通過,從而發送到常規的網絡協議棧中,或者丟棄它,或者重定向數據包到其它的網卡等。在我們的例子中,XDP_DROP 是說超快速的丟棄數據包。同時注意,我們聲明瞭是在 prog 段中加載執行,eBPF 加載會檢測加載(如果段名稱沒有找到會加載失敗,但是我們可以根據 IP 來使用非標準段名稱 )。下面我們來編譯試運行一下上面的代碼。

$ clang -Wall -target bpf -c xdp-drop.c -o xdp-drop.o

我們可以使用不同的用戶空間工具把二進制目標代碼加載到內核中(iproute2 的部分工具就可以),tc 或者 ip 是是常用的。XDP 支持虛擬網卡,所以要直接看出上面程序的作用,我們可以把代碼加載到一個已經存在的容器網卡上。我們會啓動一個 nginx 容器,並且在加載 XDP 程序之前和之後分別啓動一組 curl 請求。之前的 curl 請求會返回一個成功的 HTTP 狀態碼:

$ curl --write-out '%{http_code}' -s --output /dev/null 172.17.0.4:80
200

加載 XDP 字節碼可以使用下面的命令:

$ sudo ip link set dev veth74062a2 xdp obj xdp-drop.o

我們會看到虛擬網卡上有 xdp 被激活的標識:

veth74062a2@if16: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 xdp/id:37 qdisc noqueue master docker0 state UP group default
link/ether 0a:5e:36:21:9e:63 brd ff:ff:ff:ff:ff:ff link-netnsid 2
inet6 fe80::85e:36ff:fe21:9e63/64 scope link
valid_lft forever preferred_lft forever

curl 請求將會被阻塞一段時間直到返回如下的錯誤信息,這就說明 XDP 代碼生效了,也是我們預期的效果:

curl: (7) Failed to connect to 172.17.0.4 port 80: No route to host

我們在測試完整之後,可以使用下面的命令卸載 XDP 程序:

$ sudo ip link set dev veth74062a2 xdp off

使用 GO 編寫 XDP 程序

上面的代碼片段演示了一些基本的概念,但是爲了充分利用 XDP 的強大功能,我們將使用 Go 語言來製作稍微複雜點的軟件 - 圍繞某種規範用例構建的小工具: 針對一些指定的黑名單 IP 地址進行包丟棄。完整的代碼以及如何構建這個工具的文檔說明在這裏。如上一篇博文所介紹,我們使用 gobpf 包,它提供了和 eBPF VM 交互的支持(加載程序到內核,訪問 / 操作 eBPF map 以及其它功能)。大量的 eBPF 程序都可以直接由 C 編寫,並且編譯爲 ELF 目標文件。但是可惜的是,基於 ELF 的 XDP 程序還不行。另外一種方法就是,通過 BCC 模塊加載 XDP 程序仍然是可以的,但要是要依賴 libbcc。

不管怎麼處理,BCC maps 有一個非常重要的限制:不能把他們掛到 bpffs 上面(事實上,你可以從用戶空間掛 maps,但是啓動 BCC 模塊的是,它就很容易忽略任何的掛載對象)。我們的工具需要侵入黑名單的 map,同時需要在 XDP 程序加載到網卡上之後仍然可以有能力從 map 中添加或者刪除元素。

我們就有足夠的動力來考慮使用 ELF 目標文件支持 XDP 程序,所以我們給上游倉庫提了這方面的 pr,並期望能合進去(目前這個 pr 已經被合併到 gobpf 了)。我們認爲這個功能對 XDP 程序的可移植性非常有價值,就像內核探測可以跨機器分佈一樣,即使它們不附帶 clang、LLVM 和其他依賴項。

不用多說了,讓我們從下面 XDP 代碼開始瀏覽最重要的代碼片段:

SEC("xdp/xdp_ip_filter")
int xdp_ip_filter(struct xdp_md *ctx) {
    void *end = (void *)(long)ctx->data_end;
    void *data = (void *)(long)ctx->data;
    u32 ip_src;
    u64 offset;
    u16 eth_type;

    struct ethhdr *eth = data;
    offset = sizeof(*eth);

    if (data + offset > end) {
    return XDP_ABORTED;
    }
    eth_type = eth->h_proto;

    /* handle VLAN tagged packet 處理 VLAN 標記的數據包*/
       if (eth_type == htons(ETH_P_8021Q) || eth_type == 
htons(ETH_P_8021AD)) {
             struct vlan_hdr *vlan_hdr;

          vlan_hdr = (void *)eth + offset;
          offset += sizeof(*vlan_hdr);
          if ((void *)eth + offset > end)
               return false;
          eth_type = vlan_hdr->h_vlan_encapsulated_proto;
    }

    /* let's only handle IPv4 addresses 只處理 IPv4 地址*/
    if (eth_type == ntohs(ETH_P_IPV6)) {
        return XDP_PASS;
    }

    struct iphdr *iph = data + offset;
    offset += sizeof(struct iphdr);
    /* make sure the bytes you want to read are within the packet's range before reading them 
    * 在讀取之前,確保你要讀取的子節在數據包的長度範圍內
    */
    if (iph + 1 > end) {
        return XDP_ABORTED;
    }
    ip_src = iph->saddr;

    if (bpf_map_lookup_elem(&blacklist, &ip_src)) {
        return XDP_DROP;
    }

    return XDP_PASS;
}

代碼看起來是稍微有點多,但是可以先忽略代碼中負責處理 VLAN 標籤的數據包的代碼。我們先從 XDP 元信息中訪問包數據開始,並且把這個指針轉換成 ethddr 的內核結構。你同時會注意到檢測包邊界的幾個條件。如果你忽略了他們,檢查器會拒絕加載 XDP 子節代碼。這個強制規則保證了 XDP 代碼在內核中的的正常運行,避免有無效指針或者違反安全策略的代碼被加載到內核。剩下的代碼從 IP 協議頭中提取了源 IP 地址,並且檢測是否在黑名單 map 中。如果從 map 中查找到了,就會丟棄這個包。

Hook 結構體是負責在網絡協議棧中加載或者卸載 XDP 程序。它實例化並且從對象文件中加載 XDP 模塊,最終調用 AttachXDP 或者 RemoveXDP 方法。

IP 地址黑名單是通過標準的 eBPF maps 來管理的。我們調用 UpdateElement 和 DeleteElement 來分別註冊或者刪除 IP 信息。黑名單管理者也包含了獲取 map 中可用的 IP 地址列表的方法。

其它的代碼把所有的代碼片段組合起來,以提供良好的 CLI 體驗,用戶可以利用這種體驗執行 XDP 程序附加 / 刪除和操作 IP 黑名單。要了解更多細節,請看源碼。

總結

XDP 在 Linux 內核中慢慢以高速包處理標準出現。通過這篇博文,我介紹了組成數據包處理系統的基本構建模塊。雖然網絡協議棧是一個非常複雜的主題,由於 eBPF/XDP 的編程特性,創建 XDP 程序已經是相對比較輕鬆了。

附錄

原文地址:https://sematext.com/blog/ebpf-and-xdp-for-processing-packets-at-bare-metal-speed/

源碼地址:https://github.com/sematext/oxdpus

基於 eBPF 的 Linux 可觀測性:http://www.helight.info/blog/2020/linux-kernel-observability-ebpf/

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