eBPF (Extended Berkeley Packet Filter) 技術的動態特性讓我們能夠根據特定需求自定義核心行為,這在網路領域尤其重要。網路應用場景多種多樣,從電信營運商需要處理 SRv6 等特定協定,到 Kubernetes 環境需要與傳統應用整合,再到用 XDP 程式在商用硬體上替代專用負載平衡器。eBPF 允許開發者針對特定需求開發網路功能,而不必將這些功能強加給所有上游核心使用者。

根據 eBPF 的網路工具現在被廣泛使用,並在大規模環境中證明瞭其效能。例如,CNCF 的 Cilium 專案將 eBPF 作為 Kubernetes 網路、獨立負載平衡等功能的平台,被各行各業的雲原生採用者使用。Meta 自 2017 年以來,每一個出入 Facebook 的封包都會經過 XDP 程式處理。另一個公開的超大規模應用案例是 Cloudflare 使用 eBPF 進行 DDoS 防護。

這些都是複雜與生產就緒的解決方案,雖然它們的細節超出了本文的範圍,但透過理解本文的範例,可以感受到這些 eBPF 網路解決方案的基本構建方式。

封包過濾與丟棄機制

網路安全功能中,有許多涉及丟棄特定傳入封包並允許其他封包透過的應用。這些功能包括防火牆、DDoS 防護和減輕「死亡封包」漏洞:

  • 防火牆:根據來源和目標 IP 地址和/或連線埠號,決定是否允許每個封包透過。

  • DDoS 防護:增加了一些複雜性,可能會追蹤從特定來源接收封包的速率,和/或檢測封包內容的某些特徵,以確定攻擊者或一組攻擊者是否試圖用流量淹沒介面。

  • 死亡封包漏洞:這是一類別核心漏洞,核心無法安全處理特定方式構建的封包。傳送具有特定格式封包的攻擊者可能會利用此漏洞,這可能導致核心當機。傳統上,當發現此類別核心漏洞時,需要安裝含有修復程式的新核心,這需要機器停機。但可以動態安裝檢測並丟棄這些惡意封包的 eBPF 程式,立即保護主機而不影響機器上執行的任何應用程式。

雖然這些功能的決策演算法超出本文範圍,但讓我們探討如何將 eBPF 程式附加到網路介面的 XDP 掛鉤上以丟棄特定封包,這是實作這些使用案例的基礎。

XDP 程式的回傳碼

XDP 程式由網路封包的到達觸發。程式檢查封包,當完成時,回傳碼給出一個判斷,指示接下來如何處理該封包:

  • XDP_PASS:表示封包應該以正常方式傳送到網路堆積積疊(就像沒有 XDP 程式時一樣)。
  • XDP_DROP:導致封包立即被丟棄。
  • XDP_TX:將封包傳送回它到達的相同介面。
  • XDP_REDIRECT:用於將封包傳送到不同的網路介面。
  • XDP_ABORTED:導致封包被丟棄,但其使用暗示錯誤情況或意外情況,而不是「正常」的丟棄封包決定。

對於某些使用案例(如防火牆),XDP 程式只需要決定是否透過或丟棄封包。一個決定是否丟棄封包的 XDP 程式大致如下:

SEC("xdp")
int hello(struct xdp_md *ctx) {
    bool drop;
    drop = // 檢查封包並決定是否丟棄;
    if (drop)
        return XDP_DROP;
    else
        return XDP_PASS;
}

XDP 程式也可以操作封包內容,我們稍後會討論這一點。

XDP 程式在介面上收到入站網路封包時被觸發。ctx 引數是指向 xdp_md 結構的指標,該結構儲存有關傳入封包的中繼資料。讓我們看如何使用這個結構來檢查封包的內容,以便做出判斷。

XDP 封包解析

以下是 xdp_md 結構的定義:

struct xdp_md {
    __u32 data;
    __u32 data_end;
    __u32 data_meta;
    /* 以下存取透過 struct xdp_rxq_info */
    __u32 ingress_ifindex; /* rxq->dev->ifindex */
    __u32 rx_queue_index; /* rxq->queue_index */
    __u32 egress_ifindex; /* txq->dev->ifindex */
};

不要被前三個欄位的 __u32 型別所迷惑,它們實際上是指標。data 欄位指示封包開始的記憶體位置,data_end 顯示它結束的位置。為了透過 eBPF 驗證器,你必須明確檢查對封包內容的任何讀取或寫入是否在 datadata_end 的範圍內。

在封包前面的記憶體區域(data_metadata 之間)用於儲存有關此封包的中繼資料。這可用於協調可能在封包透過堆積積疊的各個位置處理同一封包的多個 eBPF 程式。

為了說明網路封包解析的基礎,範例程式碼中有一個名為 ping() 的 XDP 程式,它會在檢測到 ping (ICMP) 封包時生成一行追蹤。以下是該程式的程式碼:

SEC("xdp")
int ping(struct xdp_md *ctx) {
    long protocol = lookup_protocol(ctx);
    if (protocol == 1) // ICMP
    {
        bpf_printk("Hello ping");
    }
    return XDP_PASS;
}

這段程式碼定義了一個 XDP 程式,它會檢查封包的協定。如果是 ICMP 協定(協定號為 1),就會使用 bpf_printk 函式輸出 “Hello ping” 到追蹤管道中。無論封包是什麼協定,最終都會回傳 XDP_PASS,允許封包繼續透過網路堆積積疊。

你可以按照以下步驟檢視這個程式的運作:

  1. 在 chapter8 目錄中執行 make。這不僅構建程式碼,還將 XDP 程式附加到迴環介面(稱為 lo)。
  2. 在一個終端視窗中執行 ping localhost
  3. 在另一個終端視窗中,透過執行 cat /sys/kernel/tracing/trace_pipe 檢視追蹤管道中生成的輸出。

你應該會看到大約每秒生成兩行追蹤,它們看起來像這樣:

ping-26622 [000] d.s11 276880.862408: bpf_trace_printk: Hello ping
ping-26622 [000] d.s11 276880.862459: bpf_trace_printk: Hello ping
ping-26622 [000] d.s11 276881.889575: bpf_trace_printk: Hello ping
ping-26622 [000] d.s11 276881.889676: bpf_trace_printk: Hello ping
ping-26622 [000] d.s11 276882.910777: bpf_trace_printk: Hello ping
ping-26622 [000] d.s11 276882.910930: bpf_trace_printk: Hello ping

每秒有兩行追蹤是因為迴環介面同時接收 ping 請求和 ping 回應。

你可以輕鬆修改此程式碼以丟棄 ping 封包,只需在協定比對時增加一行程式碼回傳 XDP_DROP,如下所示:

if (protocol == 1) // ICMP
{
    bpf_printk("Hello ping");
    return XDP_DROP;
}
return XDP_PASS;

這個修改版本會在檢測到 ICMP 協定封包時,不僅輸出日誌,還會立即丟棄該封包,防止它繼續在網路堆積積疊中傳遞。這意味著 ping 請求會被丟棄,因此不會生成回應。

如果你嘗試這樣做,你會看到追蹤輸出中大約每秒只生成一次類別似以下的輸出:

ping-26639 [002] d.s11 277050.589356: bpf_trace_printk: Hello ping
ping-26639 [002] d.s11 277051.615329: bpf_trace_printk: Hello ping
ping-26639 [002] d.s11 277052.637708: bpf_trace_printk: Hello ping

這是因為迴環介面收到 ping 請求,XDP 程式丟棄它,所以請求永遠不會到達網路堆積積疊中足夠遠的位置來引發回應。

深入理解封包解析機制

要實際解析網路封包,我們需要理解封包的結構。網路封包是按照網路協定堆積積疊的各層組織的,通常從較低層(如乙太網)開始,然後是 IP 層,再到傳輸層(如 TCP 或 UDP)。

封包結構解析

讓我們看 lookup_protocol 函式如何工作:

static long lookup_protocol(struct xdp_md *ctx) {
    void *data_end = (void *)(long)ctx->data_end;
    void *data = (void *)(long)ctx->data;
    
    struct ethhdr *eth = data;
    
    // 確保我們可以存取乙太網頭部
    if (data + sizeof(struct ethhdr) > data_end)
        return -1;
    
    // 如果不是 IP 封包,則回傳
    if (eth->h_proto != htons(ETH_P_IP))
        return -1;
    
    struct iphdr *iph = data + sizeof(struct ethhdr);
    
    // 確保我們可以存取 IP 頭部
    if ((void *)iph + sizeof(struct iphdr) > data_end)
        return -1;
    
    return iph->protocol;
}

這個函式執行以下步驟來解析封包並找出其協定型別:

  1. 首先,它取得封包的起始地址和結束地址。
  2. 將起始地址轉換為乙太網頭部結構(struct ethhdr)。
  3. 進行邊界檢查,確保乙太網頭部完全在封包資料範圍內。
  4. 檢查封包是否為 IP 封包(透過檢查 h_proto 欄位)。
  5. 如果是 IP 封包,則取得 IP 頭部的地址。
  6. 再次進行邊界檢查,確保 IP 頭部完全在封包資料範圍內。
  7. 最後回傳 IP 協定欄位,這表示封包的上層協定(如 ICMP、TCP 或 UDP)。

這種逐層解析的方式是網路封包處理中的標準做法,它遵循網路協定堆積積疊的結構。

實作更複雜的封包過濾

在實際應用中,我們可能需要根據更多條件來過濾封包,例如源 IP 地址、目標連線埠等。讓我們看一個更完整的例子:

SEC("xdp")
int firewall(struct xdp_md *ctx) {
    void *data_end = (void *)(long)ctx->data_end;
    void *data = (void *)(long)ctx->data;
    
    struct ethhdr *eth = data;
    
    // 確保我們可以存取乙太網頭部
    if (data + sizeof(struct ethhdr) > data_end)
        return XDP_PASS;
    
    // 如果不是 IP 封包,則透過
    if (eth->h_proto != htons(ETH_P_IP))
        return XDP_PASS;
    
    struct iphdr *iph = data + sizeof(struct ethhdr);
    
    // 確保我們可以存取 IP 頭部
    if ((void *)iph + sizeof(struct iphdr) > data_end)
        return XDP_PASS;
    
    // 如果源 IP 在黑名單中,丟棄封包
    if (iph->saddr == BLOCKED_IP)
        return XDP_DROP;
    
    // 如果是 TCP 封包,檢查連線埠
    if (iph->protocol == IPPROTO_TCP) {
        struct tcphdr *tcph = (void *)iph + sizeof(struct iphdr);
        
        // 確保我們可以存取 TCP 頭部
        if ((void *)tcph + sizeof(struct tcphdr) > data_end)
            return XDP_PASS;
        
        // 如果目標連線埠是 22(SSH),丟棄封包
        if (ntohs(tcph->dest) == 22)
            return XDP_DROP;
    }
    
    return XDP_PASS;
}

這個更複雜的防火牆程式不僅檢查協定型別,還檢查源 IP 地址和目標連線埠。它會丟棄來自特定 IP 地址的封包,以及所有嘗試連線到 SSH 連線埠(22)的 TCP 封包。這種型別的過濾可以幫助防止未授權存取和某些型別的攻擊。

注意每一步都有邊界檢查,這是 eBPF 程式設計中的關鍵要求,以確保程式不會存取超出封包範圍的記憶體。

使用 BPF 對映跟蹤連線狀態

在更進階的應用中,我們可能需要跟蹤連線狀態或維護計數器。這可以透過 BPF 對映實作:

struct {
    __uint(type, BPF_MAP_TYPE_HASH);
    __uint(max_entries, 1024);
    __type(key, __u32); // IP 地址
    __type(value, __u64); // 封包計數
} ip_stats SEC(".maps");

SEC("xdp")
int count_packets(struct xdp_md *ctx) {
    void *data_end = (void *)(long)ctx->data_end;
    void *data = (void *)(long)ctx->data;
    
    struct ethhdr *eth = data;
    
    // 邊界檢查
    if (data + sizeof(struct ethhdr) > data_end)
        return XDP_PASS;
    
    if (eth->h_proto != htons(ETH_P_IP))
        return XDP_PASS;
    
    struct iphdr *iph = data + sizeof(struct ethhdr);
    
    // 邊界檢查
    if ((void *)iph + sizeof(struct iphdr) > data_end)
        return XDP_PASS;
    
    __u32 ip = iph->saddr;
    __u64 *counter = bpf_map_lookup_elem(&ip_stats, &ip);
    if (counter) {
        // 如果 IP 已在對映中,增加計數
        (*counter)++;
    } else {
        // 否則增加新條目
        __u64 init_val = 1;
        bpf_map_update_elem(&ip_stats, &ip, &init_val, BPF_ANY);
    }
    
    return XDP_PASS;
}

這個程式使用 BPF 對映來跟蹤來自每個源 IP 地址的封包數量。它首先定義了一個名為 ip_stats 的雜湊對映,用 IP 地址作為鍵,封包計數作為值。當接收到封包時,程式查詢源 IP 地址,如果找到則增加計數器,否則增加一個新條目。

這種方法可以用於實作更複雜的邏輯,例如速率限制(如果特定 IP 在短時間內傳送太多封包,則開始丟棄其封包)或連線跟蹤(記錄已建立的 TCP 連線)。

實作追蹤點程式

除了 XDP 程式,我們還可以使用 eBPF 附加到核心的追蹤點。追蹤點提供了一種觀察核心內部事件的方法,而不會對效能產生太大影響。

你可以在 /sys/kernel/tracing/available_events 中找到可用的追蹤點。讓我們實作一個簡單的追蹤點程式,它會在每次系統呼叫時記錄:

SEC("tracepoint/syscalls/sys_enter_execve")
int trace_execve(struct trace_event_raw_sys_enter *ctx) {
    char comm[16];
    bpf_get_current_comm(&comm, sizeof(comm));
    
    bpf_printk("Process %s is executing a new program", comm);
    return 0;
}

這個程式附加到 syscalls/sys_enter_execve 追蹤點,每當程式使用 execve 系統呼叫執行新程式時觸發。它使用 bpf_get_current_comm 函式取得當前程式的名稱,然後使用 bpf_printk 輸出一條訊息。

這種型別的程式對於系統監控和除錯非常有用,可以幫助你瞭解系統中正在發生的事情,而不必修改或重啟核心。

XDP 程式的附加限制

值得注意的是,一個網路介面一次只能附加一個 XDP 程式。如果你嘗試附加多個 XDP 程式到同一個介面,你會看到類別似這樣的錯誤:

libbpf: Kernel error message: XDP program already attached
Error: interface xdpgeneric attach failed: Device or resource busy

這是因為 XDP 設計用於高效能封包處理,允許多個程式會增加複雜性並可能影響效能。如果你需要實作多個功能,最好將它們組合到一個 XDP 程式中。

eBPF 在網路應用中的實際案例

eBPF 和 XDP 在現實世界中有許多應用案例。以下是一些值得注意的例子:

負載平衡

Cilium 使用 eBPF 實作了高效的負載平衡功能,可以在不需要專用硬體的情況下處理大量流量。透過在 XDP 層實作負載平衡,它可以在封包到達網路堆積積疊之前重定向它們,從而提高效能。

DDoS 防護

Cloudflare 使用 XDP 程式來防止 DDoS 攻擊。當檢測到攻擊時,XDP 程式可以在最早的階段丟棄惡意封包,大減少了系統資源的消耗。

網路監控

eBPF 可用於實作高效的網路監控解決方案,收集有關網路流量的詳細訊息,而不會對系統效能產生顯著影響。這些訊息可用於故障排除、效能最佳化和安全分析。

封包修改與重定向

除了丟棄封包外,XDP 程式還可以修改封包內容或將封包重定向到不同的介面。這對於實作網路地址轉換 (NAT)、封包標記或自定義路由非常有用。

以下是一個簡單的 NAT 實作範例:

SEC("xdp")
int simple_nat(struct xdp_md *ctx) {
    void *data_end = (void *)(long)ctx->data_end;
    void *data = (void *)(long)ctx->data;
    
    struct ethhdr *eth = data;
    
    // 邊界檢查
    if (data + sizeof(struct ethhdr) > data_end)
        return XDP_PASS;
    
    if (eth->h_proto != htons(ETH_P_IP))
        return XDP_PASS;
    
    struct iphdr *iph = data + sizeof(struct ethhdr);
    
    // 邊界檢查
    if ((void *)iph + sizeof(struct iphdr) > data_end)
        return XDP_PASS;
    
    // 修改源 IP 地址
    iph->saddr = PUBLIC_IP;
    
    // 重新計算 IP 校驗和
    update_ip_checksum(iph);
    
    // 如果是 TCP 或 UDP,還需要更新傳輸層校驗和
    if (iph->protocol == IPPROTO_TCP || iph->protocol == IPPROTO_UDP) {
        update_l4_checksum(iph, data_end);
    }
    
    return XDP_TX; // 傳送修改後的封包
}

這個簡化的 NAT 實作修改了出站封包的源 IP 地址,然後使用 XDP_TX 將修改後的封包傳送回同一網路介面。在實際的 NAT 實作中,我們還需要跟蹤連線狀態並相應地修改入站封包。

注意,當修改封包內容時,需要更新相關的校驗和,以確保封包仍然有效。這通常涉及到重新計算 IP 頭部的校驗和,以及如果修改了 IP 地址,還需要更新 TCP 或 UDP 校驗和。

多層次網路安全架構

在生產環境中,通常會使用多層次的網路安全架構,結合 eBPF 的不同能力:

  1. XDP 層:在最底層使用 XDP 程式進行高速封包過濾,丟棄明顯的惡意流量。

  2. TC(流量控制)層:在 TC 層使用 eBPF 程式進行更細粒度的封包處理,包括封包修改和統計訊息收集。

  3. Socket 層:在 socket 層使用 eBPF 程式控制應用程式的網路存取許可權。

  4. Cgroup 層:使用 eBPF 程式實作容器級別的網路策略。

這種多層次方法可以提供全面的網路安全保護,同時保持高效能。

效能考量與最佳實踐

在使用 eBPF 進行網路程式設計時,效能是一個重要的考量因素。以下是一些最佳實踐:

  1. 盡早丟棄:如果需要丟棄封包,應該在盡可能早的階段(如 XDP)進行,以減少系統資源的消耗。

  2. 最小化封包檢查:只檢查決策所需的封包部分,避免不必要的解析。

  3. 使用尾呼叫:對於複雜的處理邏輯,考慮使用尾呼叫將程式分解為多個部分。

  4. 批處理對映更新:如果需要更新多個對映條目,考慮使用批處理 API 來減少系統呼叫開銷。

  5. 考慮硬體解除安裝:某些網路卡支援 XDP 硬體解除安裝,可以進一步提高效能。

結合 eBPF 與現有網路堆積積疊

eBPF 不僅可以獨立使用,還可以與現有的網路堆積積疊和工具整合。例如,你可以將 eBPF 程式與 iptables 結合使用,或者將 eBPF 整合到 Kubernetes 網路策略中。

這種整合能力使 eBPF 成為一個強大的工具,可以逐步改進現有的網路基礎設施,而不需要一次性進行大規模的架構變更。

eBPF 為網路程式設計帶來了前所未有的靈活性和效能。透過允許開發者在核心中安全地執行自定義程式碼,eBPF 使得實作高效的封包處理、網路監控和安全功能變得更加簡單。從簡單的封包過濾到複雜的負載平衡和 DDoS 防護,eBPF 已經證明瞭它在各種網路應用中的價值。隨著技術的不斷發展,我們可以期待看到更多根據 eBPF 的創新網路解決方案出現。

XDP 網路封包處理的核心機制

在網路世界中,效能與安全性往往是最關鍵的考量因素。XDP(eXpress Data Path)作為 Linux 核心中的高效能網路處理技術,讓我們能夠在網路封包剛進入系統時就對其進行處理,大幅提升效能表現。

網路封包解析的基礎實作

在 XDP 程式中,大部分的工作都集中在解析網路封包的結構上。以下我將分析一個名為 lookup_protocol() 的函式,它負責判斷第 4 層(Layer 4)協定型別。雖然這只是一個範例而非生產級的實作,但足以讓我們理解 eBPF 中的封包解析原理。

當網路封包被接收時,它本質上是一串按照特定結構排列的位元組。一個典型的網路封包結構從乙太網路標頭(Ethernet header)開始,接著是 IP 標頭(IP header),然後是第 4 層的資料。

以下是 lookup_protocol() 函式的實作,它接收一個包含網路封包記憶體位置資訊的 ctx 結構,並回傳在 IP 標頭中找到的協定型別:

unsigned char lookup_protocol(struct xdp_md *ctx)
{
    unsigned char protocol = 0;
    void *data = (void *)(long)ctx->data;
    void *data_end = (void *)(long)ctx->data_end;
    struct ethhdr *eth = data;
    if (data + sizeof(struct ethhdr) > data_end)
        return 0;
    // Check that it's an IP packet
    if (bpf_ntohs(eth->h_proto) == ETH_P_IP)
    {
        // Return the protocol of this packet
        // 1 = ICMP
        // 6 = TCP
        // 17 = UDP
        struct iphdr *iph = data + sizeof(struct ethhdr);
        if (data + sizeof(struct ethhdr) + sizeof(struct iphdr) <= data_end)
            protocol = iph->protocol;
    }
    return protocol;
}

這個函式執行了幾個關鍵步驟:

  1. 首先,設定指向網路封包起始和結束位置的指標(datadata_end)。
  2. 假設封包以乙太網路標頭開始,但不能直接假設封包大小足以包含完整標頭,因此需要明確檢查。
  3. 乙太網路標頭包含一個 2 位元組的欄位,指示第 3 層協定型別。
  4. 如果協定型別表明這是 IP 封包,則 IP 標頭緊接在乙太網路標頭之後。
  5. 同樣,需要明確檢查封包中是否有足夠空間容納 IP 標頭。
  6. 最後,從 IP 標頭中提取協定位元組並回傳給呼叫者。

值得注意的是 bpf_ntohs() 函式的使用,它確保兩個位元組按照主機預期的順序排列。網路協定使用大端序(big-endian),但大多數處理器使用小端序(little-endian),這意味著它們以不同順序儲存多位元組值。這個函式在必要時將網路排序轉換為主機排序。當從網路封包中提取超過一個位元組的欄位值時,應該使用此函式。

這個簡單的例子展示了幾行 eBPF 程式碼如何能對網路功能產生顯著影響。不難想像,更複雜的封包透過或丟棄規則可以實作防火牆、DDoS 保護和封包漏洞緩解等功能。接下來,讓我們看如何利用 eBPF 程式修改網路封包來提供更多功能。

XDP 負載平衡與轉發實作

XDP 程式的功能不僅限於檢查封包內容,還可以修改封包。這讓我們能夠構建諸如負載平衡器等複雜網路功能,將傳送到特定 IP 地址的封包分發到多個後端伺服器。

簡易負載平衡器的實作

以下是一個簡單負載平衡器的實作範例。設定包括在同一主機上執行的多個容器:一個客戶端、一個負載平衡器和兩個後端容器。負載平衡器接收來自客戶端的流量,然後將其轉發到兩個後端容器之一。

負載平衡功能透過附加到負載平衡器 eth0 網路介面的 XDP 程式實作。該程式的回傳碼是 XDP_TX,表示封包應該從進入的介面傳送回去。但在此之前,程式需要更新封包標頭中的地址資訊。

雖然這個例子對學習有幫助,但離生產環境還很遠。它使用硬編碼的地址,假設特定的 IP 地址設定;假設收到的 TCP 流量只有來自客戶端的請求或對客戶端的回應;還利用了 Docker 設定虛擬 MAC 地址的方式,使用每個容器的 IP 地址作為每個容器虛擬乙太網路介面的 MAC 地址的最後四個位元組。

以下是負載平衡器程式碼範例:

SEC("xdp_lb")
int xdp_load_balancer(struct xdp_md *ctx)
{
    void *data = (void *)(long)ctx->data;
    void *data_end = (void *)(long)ctx->data_end;
    struct ethhdr *eth = data;
    if (data + sizeof(struct ethhdr) > data_end)
        return XDP_ABORTED;
    if (bpf_ntohs(eth->h_proto) != ETH_P_IP)
        return XDP_PASS;
    struct iphdr *iph = data + sizeof(struct ethhdr);
    if (data + sizeof(struct ethhdr) + sizeof(struct iphdr) > data_end)
        return XDP_ABORTED;
    if (iph->protocol != IPPROTO_TCP)
        return XDP_PASS;
    if (iph->saddr == IP_ADDRESS(CLIENT))
    {
        char be = BACKEND_A;
        if (bpf_get_prandom_u32() % 2)
            be = BACKEND_B;
        iph->daddr = IP_ADDRESS(be);
        eth->h_dest[5] = be;
    }
    else
    {
        iph->daddr = IP_ADDRESS(CLIENT);
        eth->h_dest[5] = CLIENT;
    }
    iph->saddr = IP_ADDRESS(LB);
    eth->h_source[5] = LB;
    iph->check = iph_csum(iph);
    return XDP_TX;
}

這個負載平衡器函式的工作流程如下:

  1. 首先,類別似前一個例子,定位封包中的乙太網路標頭和 IP 標頭。
  2. 此程式只處理 TCP 封包,其他型別的封包會直接傳遞給網路堆積積疊。
  3. 檢查來源 IP 地址。如果封包來自客戶端,則假設這是一個請求。
  4. 使用偽隨機函式在後端 A 和後端 B 之間進行選擇。
  5. 根據選擇的後端,更新目標 IP 和 MAC 地址。
  6. 如果封包不是來自客戶端,則假設它是從後端傳送給客戶端的回應,並相應地更新目標地址。
  7. 無論封包去向何處,都需要更新來源地址,使其看起來像是從負載平衡器發出的。
  8. 由於 IP 標頭中的來源和目標 IP 地址都已更新,因此需要重新計算 IP 標頭校驗和。

這個例子展示瞭如何使用 XDP 程式實作網路流量的動態轉發和負載平衡。透過修改封包標頭,我們可以控制封包的路由和分發,從而實作高效的負載平衡功能。

XDP 程式的載入與附加

Makefile 中包含了不僅構建程式碼,還使用 bpftool 將 XDP 程式載入並附加到介面的指令:

xdp: $(BPF_OBJ)
    bpftool net detach xdpgeneric dev eth0
    rm -f /sys/fs/bpf/$(TARGET)
    bpftool prog load $(BPF_OBJ) /sys/fs/bpf/$(TARGET)
    bpftool net attach xdpgeneric pinned /sys/fs/bpf/$(TARGET) dev eth0

這個 make 指令需要在負載平衡器容器內執行,以便 eth0 對應於其虛擬乙太網路介面。這引出了一個有趣的觀點:eBPF 程式載入到核心中(只有一個核心),但附加點可能位於特定的網路名稱空間內,並且只在該網路名稱空間內可見。

XDP 解除安裝技術

XDP 的概念源於一個設想:如果能在網路卡上執行 eBPF 程式,在封包甚至到達核心網路堆積積疊之前就對其進行處理,會有多麼實用。如今,某些網路介面卡確實支援完整的 XDP 解除安裝功能,它們可以在硬體上直接執行 eBPF 程式。

在實際應用中,這種解除安裝技術能夠大幅提升網路處理效能,尤其是在高流量環境下。透過將封包處理邏輯直接下放到網路卡,可以避免不必要的資料複製和核心處理開銷,從而實作極低的延遲和極高的吞吐量。

XDP 技術的實際應用價值

透過前面的例子,我們可以看到 XDP 技術在網路領域的強大潛力。特別是在以下幾個方面,XDP 展現出顯著優勢:

  1. 高效率的封包過濾:在網路堆積積疊的最前端進行封包過濾,可以顯著提高防火牆和安全裝置的效能。

  2. DDoS 防護:能夠在攻擊流量進入系統之前就將其丟棄,大幅減輕系統負擔。

  3. 負載平衡:如例子所示,可以實作高效的第 4 層負載平衡,無需複雜的硬體裝置。

  4. 網路監控與統計:可以在不影響效能的情況下收集詳細的網路統計資料。

  5. 自定義網路功能:可以實作專門針對特定應用最佳化的網路處理邏輯。

在實際佈署中,結合 XDP 與其他 eBPF 技術,可以構建出既靈活又高效的網路解決方案,滿足現代雲端環境和高效能計算場景的嚴格要求。

實作技巧與注意事項

在使用 XDP 進行網路程式設計時,有幾點技術細節值得特別關注:

  1. 邊界檢查的重要性:eBPF 驗證器要求對所有記憶體存取進行嚴格的邊界檢查,這不僅是為了透過驗證,也是為了確保程式的安全性和穩定性。

  2. 網路位元順序處理:始終使用 bpf_ntohs()bpf_ntohl() 等函式處理多位元組網路資料,確保正確解釋封包內容。

  3. 校驗和計算:修改封包內容後,記得重新計算相關的校驗和,否則封包可能會被下游網路裝置丟棄。

  4. 效能考量:XDP 程式應盡可能簡潔高效,避免複雜的邏輯和迴圈,以發揮其效能優勢。

  5. 測試與除錯:使用 bpftool 和 tcpdump 等工具進行測試和除錯,確保程式行為符合預期。

透過深入理解這些技術細節,我們可以充分利用 XDP 的強大功能,構建出高效、可靠的網路應用。

XDP 技術為網路程式設計開闢了新的可能性,它結合了接近硬體的效能與靈活的軟體可程式設計性,使我們能夠以前所未有的方式處理網路流量。隨著更多網路卡支援 XDP 解除安裝功能,以及 eBPF 生態系統的不斷發展,我們可以期待看到更多創新的網路應用和解決方案。

支援 XDP 解除安裝的網路介面卡能力

網路介面卡若支援 XDP 解除安裝功能,能夠在不需要主機 CPU 參與的情況下處理、丟棄和重新傳輸封包。這意味著當封包被丟棄或重新導向回相同實體介面時(如本文前面提到的封包丟棄和負載平衡範例),主機的核心完全不會看到這些封包,主機 CPU 也不會花費任何週期處理它們,因為所有工作都直接在網路卡上完成。

即使實體網路介面卡不支援完整的 XDP 解除安裝,許多 NIC 驅動程式仍支援 XDP 掛鉤,這能最小化 eBPF 程式處理封包所需的記憶體複製操作。這可帶來顯著的效能提升,讓負載平衡等功能夠在一般商用硬體上高效執行。

流量控制 (TC)

在前面我們看到 XDP 如何處理入站網路封包,在封包剛抵達機器時盡早存取它們。eBPF 也能在網路堆積積疊的其他點處理流量,無論流量流向為何。現在讓我們來看附加在 TC 子系統中的 eBPF 程式。

當網路封包到達流量控制點時,它已經以 sk_buff 的形式存在於核心記憶體中。這是一個貫穿整個核心網路堆積積疊的資料結構。附加在 TC 子系統中的 eBPF 程式會收到指向 sk_buff 結構的指標作為上下文引數。

注意:你可能好奇為何 XDP 程式不使用相同結構作為上下文。答案是 XDP 掛鉤發生在網路資料到達網路堆積積疊之前,也就是在 sk_buff 結構被設定之前。

TC 子系統的目的是規範網路流量的排程方式。例如,你可能想限制每個應用程式可用的頻寬,讓所有應用程式都有公平的機會。但當你考慮排程個別封包時,頻寬並不是一個非常有意義的詞彙,因為它用於描述傳送或接收的平均資料量。某些應用程式可能有突發性流量,另一些應用程式可能對網路延遲非常敏感,所以 TC 提供了更精細的控制,來處理和優先排序封包。

eBPF 程式被引入 TC 以提供對其演算法的自定義控制。但由於能夠操作、丟棄或重定向封包,附加在 TC 中的 eBPF 程式也可作為複雜網路行為的構建基礎。

網路堆積積疊中的網路資料流向有兩種:ingress(從網路介面入站)或 egress(向網路介面出站)。eBPF 程式可以附加在任一方向,與隻影響該方向的流量。與 XDP 不同,TC 可以附加多個 eBPF 程式,它們會按順序處理。

傳統流量控制分為分類別器(classifier)和動作(action)。分類別器根據某些規則對封包進行分類別,而動作則根據分類別器的輸出決定對封包做什麼。可以有一系列分類別器,都定義為排隊規則(qdisc)的一部分。

eBPF 程式作為分類別器附加,但它們也可以在同一個程式中決定要採取什麼行動。行動透過程式的回傳碼指示(在 linux/pkt_cls.h 中定義):

  • TC_ACT_SHOT 告訴核心丟棄封包
  • TC_ACT_UNSPEC 行為如同 eBPF 程式未在此封包上執行(如果有的話,會傳遞給序列中的下一個分類別器)
  • TC_ACT_OK 告訴核心將封包傳遞給堆積積疊中的下一層
  • TC_ACT_REDIRECT 將封包傳送到不同網路裝置的入站或出站路徑

TC 程式範例

讓我們看一些可以附加在 TC 中的簡單程式範例。第一個只生成一行追蹤並告訴核心丟棄封包:

int tc_drop(struct __sk_buff *skb) {
    bpf_trace_printk("[tc] dropping packet\n");
    return TC_ACT_SHOT;
}

這個簡單的 TC 程式展示了最基本的封包丟棄功能。當封包透過 TC 子系統時,此程式會執行兩個操作:

  1. 使用 bpf_trace_printk() 函式記錄一條日誌,表明正在丟棄封包
  2. 回傳 TC_ACT_SHOT 常數,指示核心丟棄此封包

這種程式可用於實作基本的封包過濾,但由於它沒有檢查封包內容,會丟棄所有經過的封包。

現在讓我們考慮如何只丟棄一部分封包。下面的範例只丟棄 ICMP(ping)請求封包,與前面看到的 XDP 範例非常相似:

int tc(struct __sk_buff *skb) {
    void *data = (void *)(long)skb->data;
    void *data_end = (void *)(long)skb->data_end;
    
    if (is_icmp_ping_request(data, data_end)) {
        struct iphdr *iph = data + sizeof(struct ethhdr);
        struct icmphdr *icmp = data + sizeof(struct ethhdr) + sizeof(struct iphdr);
        
        bpf_trace_printk("[tc] ICMP request for %x type %x\n",
                         iph->daddr,
                         icmp->type);
        return TC_ACT_SHOT;
    }
    
    return TC_ACT_OK;
}

此程式展示瞭如何選擇性地丟棄特定型別封包:

  1. 首先取得封包資料的開始和結束指標
  2. 使用 is_icmp_ping_request() 函式檢查封包是否為 ICMP ping 請求
  3. 如果是 ping 請求,程式會:
    • 取得 IP 和 ICMP 標頭的指標
    • 記錄目標 IP 地址和 ICMP 型別
    • 回傳 TC_ACT_SHOT 丟棄封包
  4. 如果不是 ping 請求,則回傳 TC_ACT_OK 讓封包正常透過

sk_buff 結構包含指向封包資料開始和結束的指標,非常類別似於 xdp_md 結構,封包解析過程也基本相同。同樣,為了透過驗證,必須明確檢查對資料的任何存取是否在 datadata_end 範圍內。

你可能想知道,為什麼要在 TC 層實作這種功能,而不是像之前那樣使用 XDP。有幾個很好的理由:

  1. TC 程式可以處理出站流量,而 XDP 只能處理入站流量
  2. 由於 XDP 在封包剛到達時觸發,此時還沒有與封包相關的 sk_buff 核心資料結構。如果 eBPF 程式需要存取或操作核心為此封包建立的 sk_buff,TC 附加點更合適。

實作 Ping-Pong 回應的 TC 程式

現在讓我們看一個不只是丟棄特定封包的範例。這個範例識別接收到的 ping 請求並回應 ping 回應:

int tc_pingpong(struct __sk_buff *skb) {
    void *data = (void *)(long)skb->data;
    void *data_end = (void *)(long)skb->data_end;
    
    if (!is_icmp_ping_request(data, data_end)) {
        return TC_ACT_OK;
    }
    
    struct iphdr *iph = data + sizeof(struct ethhdr);
    struct icmphdr *icmp = data + sizeof(struct ethhdr) + sizeof(struct iphdr);
    
    swap_mac_addresses(skb);
    swap_ip_addresses(skb);
    
    // 將 ICMP 封包的型別更改為 0 (ICMP Echo Reply)
    // (原來是 8 表示 ICMP Echo request)
    update_icmp_type(skb, 8, 0);
    
    // 將修改後的 skb 複製並重定向回它到達的介面
    bpf_clone_redirect(skb, skb->ifindex, 0);
    
    return TC_ACT_SHOT;
}

這個程式展示瞭如何使用 eBPF 實作完整的網路協定回應功能:

  1. is_icmp_ping_request() 函式解析封包並檢查它是否是 ICMP echo (ping) 請求,如果不是則讓封包繼續正常處理
  2. 當確認是 ping 請求後,程式需要準備回應:
    • 由於要傳送回應給傳送者,需要交換源和目標地址 (swap_mac_addressesswap_ip_addresses 函式)
    • 這些函式還會更新 IP 標頭校驗和
  3. 將 ICMP 標頭中的型別欄位從 8 (echo 請求) 更改為 0 (echo 回應)
  4. 使用 bpf_clone_redirect() 輔助函式將封包的複製版傳送回它到達的介面
  5. 回傳 TC_ACT_SHOT 以丟棄原始封包,因為已經傳送了回應

在正常情況下,ping 請求會由核心的網路堆積積疊在稍後處理,但這個小例子展示了網路功能更一般地如何可以被 eBPF 實作所取代。當前許多網路功能由使用者空間服務處理,但如果它們可以被 eBPF 程式替代,對效能通常有很大好處。

在核心內處理的封包不需要完成穿越堆積積疊的餘下旅程;不需要轉換到使用者空間進行處理,回應也不需要轉換回核心。更重要的是,兩者可以平行執行——eBPF 程式可以對任何需要複雜處理與自己無法處理的封包回傳 TC_ACT_OK,使其被傳遞給使用者空間。