ebpf-go 初體驗

發表於2024-02-17

前言

我們在《用eBPF/XDP來替代LVS》系列、《一張圖感受真實的 TCP 狀態轉移》系列,以及《如何終結已存在的TCP連線?》系列文章中,均透過純 C 語言和 libbpf1 這個庫來運用 eBPF。

但是很多的場景中(尤其是雲原生場景),我們出於避免重複造輪子、更快的迭代速度、執行時安全等原因,會選擇 go 語言來進行開發,ebpf-go2 這個庫就是當前最好的選擇。

今天,我們就對 ebpf-go 進行一個初體驗,這個體驗不是按部就班的 API 文件,而是透過一個簡單的需求,讓大家得到一個真切的感受,這個需求就是:統計發向本機的每個 “連線” 的包數量,並且每新增 5 個 “連線” 就進行一次資料展示。

體驗

依賴

本次體驗需要許多前置條件:

  • Linux kernel 版本 5.7 以上,以支援 bpf_link(我是 6.6.5)
  • LLVM 版本 11 以上 (clang and llvm-strip,檢查命令 clang --version )
  • libbpf headers (Debian/Ubuntu 是 libbpf-dev,Fedora 是 libbpf-devel )
  • Linux kernel headers (Debian/Ubuntu 是 linux-headers-amd64,Fedora 是 kernel-devel )
  • Go compiler 版本需要支援 ebpf-go (我安裝了 GO 1.21,檢查命令 go version)

專案初始化

# 建立專案
mkdir ebpf-go-exp && cd ebpf-go-exp
go mod init ebpf-go-exp
go mod tidy
# 手動引入依賴
go get github.com/cilium/ebpf/cmd/bpf2go
如果依賴下載超時的話,可以設定下代理:go env -w GOPROXY=https://goproxy.cn,direct

程式碼

C 程式碼

...

//go:build ignore

struct event {
    __u32 count;
};
const struct event *unused __attribute__((unused));

SEC("xdp") 
int count_packets(struct xdp_md *ctx) {
    __u32 ip;
    __u16 sport;
    __u16 dport;
    if (!parse_ip_src_addr(ctx, &ip, &sport, &dport)){
        goto done;
    }
    __u16 r_sport = bpf_ntohs(sport);
    bpf_printk("Process a packet of tuple from %u|%pI4n:%u|%u",ip,&ip,sport,r_sport);
    if(8080 != bpf_ntohs(dport)){
        goto done;
    }
    
    struct tuple key;
    __builtin_memset(&key,0,sizeof(key));
    key.addr = ip;
    key.port = sport;

    __u32 *pkt_count = bpf_map_lookup_elem(&pkt_count_map, &key);
    if (!pkt_count) {
        __u32 init_pkt_count = 1;
        bpf_map_update_elem(&pkt_count_map, &key, &init_pkt_count, BPF_NOEXIST);
        __u32 key    = 0; 
        __u64 *count = bpf_map_lookup_elem(&tuple_num, &key); 
        if (count) { 
            __sync_fetch_and_add(count, 1); 
            if(*count % 5 == 0){
                struct event *e;
                e = bpf_ringbuf_reserve(&events, sizeof(struct event), 0);
                if (e){
                    e->count = *count;
                    bpf_ringbuf_submit(e, 0);
                }
            }
        }
    } else {
        __sync_fetch_and_add(pkt_count, 1);
    }

done:
    return XDP_PASS; 
}

...

Go 程式碼

...

//go:generate go run github.com/cilium/ebpf/cmd/bpf2go -type event counter counter.c -- -I headers

func main() {
    // Load the compiled eBPF ELF and load it into the kernel.
    var objs counterObjects
    if err := loadCounterObjects(&objs, nil); err != nil {
        log.Fatal("Loading eBPF objects:", err)
    }
    defer objs.Close()

    ifname := "lo"
    iface, err := net.InterfaceByName(ifname)
    if err != nil {
        log.Fatalf("Getting interface %s: %s", ifname, err)
    }

    // Attach count_packets to the network interface.
    link, err := link.AttachXDP(link.XDPOptions{
        Program:   objs.CountPackets,
        Interface: iface.Index,
    })
    if err != nil {
        log.Fatal("Attaching XDP:", err)
    }
    defer link.Close()

    rd, err := ringbuf.NewReader(objs.Events)
    if err != nil {
        log.Fatalf("opening ringbuf reader: %s", err)
    }
    defer rd.Close()

    stopper := make(chan os.Signal, 1)
    signal.Notify(stopper, os.Interrupt, syscall.SIGTERM)
    go func() {
        <-stopper
        if err := rd.Close(); err != nil {
            log.Fatalf("closing ringbuf reader: %s", err)
        }
    }()

    log.Println("Waiting for events..")

    // counterEvent is generated by bpf2go.
    var event counterEvent
    for {
        record, err := rd.Read()
        if err != nil {
            if errors.Is(err, ringbuf.ErrClosed) {
                log.Println("Received signal, exiting..")
                return
            }
            log.Printf("reading from reader: %s", err)
            continue
        }
        if err := binary.Read(bytes.NewBuffer(record.RawSample), binary.LittleEndian, &event); err != nil {
            log.Printf("parsing ringbuf event: %s", err)
            continue
        }
        log.Printf("tuple num: %d", event.Count)

        var (
            key counterTuple
            val uint32
        )
        iter := objs.PktCountMap.Iterate()
        for iter.Next(&key, &val) {
            sourceIP := key.Addr
            sourcePort := key.Port
            packetCount := val
            log.Printf("%d/%s:%d => %d\n", sourceIP, int2ip(sourceIP), sourcePort, packetCount)
        }
    }
}

...

完整程式碼在:https://github.com/MageekChiu/epbf-go-exp

執行

# 生成腳手架程式碼
go generate

# 利用生成的 GO 程式碼,進行編譯和執行
go build && sudo ./ebpf-go-exp

本機執行一個 openresty 並監聽 8080 埠,然後反覆訪問測試

openresty

curl localhost:8080
curl localhost:8080
...

檢視日誌

# bpf 輸出
bpftool prog tracelog
 bpf_trace_printk: Process a packet of tuple from 16777343|127.0.0.1:31876|33916
 ...
 bpf_trace_printk: Process a packet of tuple from 16777343|127.0.0.1:56449|33244
 ...

# go 輸出
Waiting for events..
tuple num: 5
16777343/127.0.0.1:31876 => 7
16777343/127.0.0.1:31364 => 7
16777343/127.0.0.1:56449 => 1
16777343/127.0.0.1:10909 => 7
16777343/127.0.0.1:30340 => 7
...

若迭代過程中,C 程式碼有變化,則需要執行 go generate,否則僅執行 Go 編譯部分即可。

重點解析

  1. c 程式碼主體和我們之前系列的文章類似,就是在 xdp hook 點解析出每個報文的四元組,然後存入 pkt_count_maptuple_num 這個 map 作為全域性變數,透過 __sync_fetch_and_add 安全地併發,每當新增 5 個 “連線” 就向使用者空間傳送事件。
  2. c 程式碼中 unused 這個 event 指標必須宣告,不然使用者態的 go 就拿不到這個資料結構。
  3. c 程式碼中開始的 ignore 是必須的,避免 go build 報錯: C source files not allowed when not using cgo or SWIG
  4. go generate 命令會根據 go 程式碼中的 go:generate 語句生成腳手架程式碼,就像 BPF Skeleton3,然後我們就可以在 go 程式碼中便捷地訪問 c 中定義的 map,prog 以及一些資料結構。-type event counter 使得 c 中定義的 event 結構體在 go 中就是 counterEvent 結構體。
  5. 核心空間向使用者空間傳送資料/事件可以透過 perfbuf 和 ringbuf 實現,從而避免使用者空間輪詢資料。這兩者雖然功能有不少差別,但是api都差不多4。當我們收到核心的通知後,透過 Iterate 來遍歷統計資料的 map,實現一種類似於推拉結合的架構。

小插曲

如果我們的 pkt_count_map 這樣寫的話:

struct tuple key = {ip,bpf_ntohs(sport)};
__u32 *pkt_count = bpf_map_lookup_elem(&pkt_count_map, &key);
if (!pkt_count) {
    ...
}else{
    ...
}

就可能會得出一個看起來很奇怪的結果:

Waiting for events..
tuple num: 5
16777343/127.0.0.1:60162 => 3
16777343/127.0.0.1:45076 => 3
16777343/127.0.0.1:45082 => 3
16777343/127.0.0.1:60162 => 4
16777343/127.0.0.1:45076 => 4

以及

bpftool map dump name pkt_count_map
[{
        "key": {
            "addr": 16777343,
            "port": 60162
        },
        "value": 3
    },{
        "key": {
            "addr": 16777343,
            "port": 45082
        },
        "value": 3
    },{
        "key": {
            "addr": 16777343,
            "port": 45076
        },
        "value": 3
    },{
        "key": {
            "addr": 16777343,
            "port": 45082
        },
        "value": 4
    },{
        "key": {
            "addr": 16777343,
            "port": 60162
        },
        "value": 4
    },{
        "key": {
            "addr": 16777343,
            "port": 45076
        },
        "value": 4
    }
]

乍一看你會發現 map 的 key 怎麼有些重複了?這裡先賣個關子,後面有機會再來分析。

總結

本文我們瞭解 ebpf-go 的一些常見用法,讓大家對 ebpf-go 有了一個模糊但整體的認識,更多的細節,可以透過官網的文件5以及 examples6 進行了解。

下一篇文章,我們就從實戰的角度,看看 cilium7 是怎麼透過 ebpf-go 來發揮 ebpf 威力的。

參考