Skip to content

搭建最小化的 xdp 實驗環境

davidlei

前言

一般來說學習 XDP 都會參考 xdp-tutorial,這個教學非常完整,雖然有些範例可能需要額外查閱資料才能完成,但仍被許多文章譽為學習 XDP 的最佳起點。

不過這個專案封裝了一些 testenv 腳本,讓學習者不需要自己搭建環境或煩惱編譯問題,學習過程中的確很方便。但如果想從零建立一個新的 XDP 專案,就需要真正理解編譯過程用到哪些工具、依賴的 header 要如何引入。本篇文章想要搭建一個最精簡的實驗環境,滿足以下幾點要求:

我認為只要符合以上幾點,就能建立一個最小化的實驗環境。選擇用 Go 語言是因為可以省去 C++ 編譯環境的前置作業。

理想的最終目錄結構如下,符合我們希望的最小化專案需求:

實際上可能會因為 go.mod 等配置文件多出幾個檔案,或者需要存放編譯用的 header,但本文會盡量保持目錄整潔,讓學習 XDP 的人不會被眼花撩亂的配置文件搞得不知所措。

下面我們會參考 xdp-tutorial 編寫一個簡單的 XDP 程式,編譯成 .o 檔後,由用 Go 語言編寫的 user space 程式負責將其載入 kernel。

編寫 xdp 範例

這裡參考 xdp-tutorialpacket01-parsing 出現的一個範例:接收 ICMP 封包,並將 sequence number 為偶數的封包丟棄,不需要修改封包內容,是個很基本的入門範例。

#include <linux/if_ether.h>
#include <linux/ip.h>
#include <linux/icmp.h>
#include <linux/in.h>

#define __linux__
#include "../includes/bpf.h"
#include "../includes/bpf_helpers.h"
#include "../includes/bpf_endian.h"

SEC("xdp")
int xdp_main(struct xdp_md *ctx)
{
  void *data = (void *)(long)ctx->data;
  void *data_end = (void *)(long)ctx->data_end;

  // L2
  struct ethhdr *eth = data;
  if (data + sizeof(*eth) > data_end) {
    return XDP_ABORTED;
  }

  // Only Ipv4 supported for this example
  // L3
  if (eth->h_proto != bpf_htons(ETH_P_IP)) {
    return XDP_PASS;
  }
  data += sizeof(*eth);
  
  struct iphdr *ip = data;
  if (data + sizeof(*ip) > data_end) {
    return XDP_ABORTED;
  }

  // Only need ICMP packet
  // L4
  if (ip->protocol != IPPROTO_ICMP) {
    return XDP_PASS;
  }
  data += sizeof(*ip);

  struct icmphdr *icmp = data;
  if (data + sizeof(*icmp) > data_end) {
    return XDP_ABORTED;
  }

  if (bpf_ntohs(icmp->un.echo.sequence) % 2 == 0)
    return XDP_DROP;

  return XDP_PASS;
}

char _license[] SEC("license") = "GPLv2";

在寫 eBPF 程式時會用到許多 bpf helper functions,由於範例沒有使用 libbpf,所以需要建立一個資料夾存放編譯所需的標頭檔,才能順利編譯。這些常用的 helper functions 或符號表通常是固定不變的,可以看到許多基於 eBPF 的專案都有 headers/includes/ 目錄來存放這些文件。

接著寫一個 Makefile 腳本負責編譯 xdp_main.c 程式

TARGET := xdp_main.o

.PHONY: all
all: build

build: $(TARGET)

$(TARGET): xdp_main.c
    clang -I../includes -O2 -target bpf -c $^ -o $@

clean:
    rm $(TARGET)

user space 編寫

user space 部分使用 Go 語言負責載入編譯好的 ELF 檔案,同時也需要提供 API 讓我們能在 user space 操作 eBPF map。

Go 語言有非常多 eBPF library 可供選擇,最主流的有:Cilium 與 Cloudflare 維護的 ebpf-go、IO Visor 提供的 gobpf,以及 Dropbox 開源的 goebpf

其中 IO Visor 維護的 gobpf 屬於 BCC 框架體系,比較偏向 trace 方向,但筆者沒有使用經驗,若有描述不正確的地方歡迎指正。

ebpf-go 應該是使用者最多的一套函式庫,Cilium 本身也大量使用這個庫,整體可靠度有目共睹。

本文選擇 Dropbox 維護的 goebpf,主要有兩個考量:第一,goebpf 提供了一套非常簡潔的 API,涵蓋載入 eBPF program 到 kernel、操作 eBPF map 等功能,用起來與原生的 libbpf 很像。這種簡潔的 API 讓學習者可以專注在 eBPF 本身,不需要另外花心力研究函式庫的用法;第二,環境架設也很簡單,只要把編譯好的 ELF 檔案路徑傳入,就能幫你載入或移除 eBPF program。

API 設計的簡潔也體現在範例上,光看範例就能大致了解用法,這種開發體驗個人覺得可以用直觀來形容。以下提供範例參考:

package main

import (
	"fmt"
	"log"
	"os"
	"os/signal"
	"time"

	"github.com/dropbox/goebpf"
)

func main() {
	bpf := goebpf.NewDefaultEbpfSystem()
	err := bpf.LoadElf("bpf/xdp_main.o")
	if err != nil {
	    log.Fatalln(err)
	}
	printXdpProgramInfo(bpf)

	xdp := bpf.GetProgramByName("xdp_root")
	if xdp == nil {
	    log.Fatalln("Program 'xdp_main' not found.")
	}

	err = xdp.Load()
	if err != nil {
	    log.Fatalln(err)
	}

	err = xdp.Attach("lo")
	if err != nil {
	    log.Fatalln(err)
	}
	defer xdp.Detach()

	// Add CTRL+C handler
	ctrlC := make(chan os.Signal, 1)
	signal.Notify(ctrlC, os.Interrupt)

	fmt.Println("XDP program successfully loaded and attached. Counters refreshed every second.")
	fmt.Println("Press CTRL+C to stop.")
	fmt.Println()

	<-ctrlC:
  fmt.Println("\nDetaching program and exit")
}

func printXdpProgramInfo(bpfProgram goebpf.System) {
	fmt.Println("Maps:")
	for _, item := range bpfProgram.GetMaps() {
		fmt.Printf("\t%s: %v, Fd %v\n", item.GetName(), item.GetType(), item.GetFd())
	}
	fmt.Println("\nPrograms:")
	for _, prog := range bpfProgram.GetPrograms() {
		fmt.Printf("\t%s: %v, size %d, license \"%s\"\n",
			prog.GetName(), prog.GetType(), prog.GetSize(), prog.GetLicense(),
		)
	}
	fmt.Println()
}

詳細的程式碼請參考 GitHub,實際跑一次才能真正理解整個運作流程。

實測

首先需要編譯 xdp 程式

$ cd bpf/
$ make
$ cd ..

成功後會出現一個 xdp_main.o

接著我們需要執行 main.go 來載入編譯好的 xdp_main.o,因為需要載入 Kernel 所以必須加上 sudo

$ sudo go run main.go

正常執行會顯示

按下 CTRL+C 後會自動 detach 剛剛載入的 xdp_main.o。因為範例只是 attach 到 lo(loopback)上,用 ping 127.0.0.1 就能測試效果。

可以看到,sequence number 為偶數的 ICMP 封包都被丟棄了,剛好有 50% 的封包收到回應,效果完全符合預期。

完整程式碼可以看這邊,實際跑一次學習效果會更好!

reference

Edit this post
Previous
Bypassing the Load Balancer Without Regrets - SoCC ’20
Next
查找動態庫(.so) 是否安裝過以及路徑使用 ldconfig