使用 Golang 開發 eBPF 的應用

大多數時候, 我們在開發軟件或使用軟件時, 都在操作系統的安全邊界內運行。我們甚至不知道 IP 數據包是如何從網絡接口被接收的, 或者當我們保存文件時, inode 是如何被文件系統處理的。

這個邊界被稱爲用戶空間, 我們在這裏編寫應用程序、庫和工具。但還有另一個世界, 即內核空間。操作系統內核就駐留在這裏, 負責管理系統資源, 如內存、CPU 和 I/O 設備。

通常我們不需要深入到 socket 或文件描述符之下, 但有時我們需要這樣做。比如說, 你想分析一個應用程序以查看它消耗了多少資源。

如果從用戶空間分析應用程序, 你不僅會錯過許多有用的細節, 而且還會消耗大量資源進行分析本身, 因爲每一層都會在 CPU 或內存上引入一些開銷

深入內核的需求

假設你想深入到內核堆棧中, 以某種方式將自定義代碼插入內核, 以分析應用程序、跟蹤系統調用或監控網絡數據包。你會怎麼做呢?

傳統上你有兩個選擇。

選項 1: 編輯內核源代碼

如果你想修改 Linux 內核源代碼, 然後將同一內核發佈給客戶機器, 你需要說服 Linux 內核社區這個更改是必需的。然後, 你需要等待幾年時間, 等待新的內核版本被 Linux 發行版採用。

對於大多數情況來說, 這不是一種實用的方法, 僅僅爲了分析一個應用程序或監控網絡數據包, 這也有點過頭了。

選項 2: 編寫內核模塊

你可以編寫內核模塊, 這是一段可以加載到內核中並執行的代碼。這是一種更實用的方法, 但也有自己的風險和缺點。

首先, 你需要編寫內核模塊, 這並不容易。然後, 你需要定期維護它, 因爲內核是一個不斷變化的東西。如果你不維護內核模塊, 它就會過時, 無法與新的內核版本一起工作。

其次, 你有可能破壞 Linux 內核, 因爲內核模塊沒有安全邊界。如果你編寫的內核模塊有 bug, 它可能會導致整個系統崩潰。

eBPF 的引入

eBPF(Extended Berkeley Packet Filter) 是一項革命性技術, 允許你在幾分鐘內重新編程 Linux 內核, 甚至無需重啓系統。

eBPF 允許你跟蹤系統調用、用戶空間函數、庫函數、網絡數據包等等。它是一個強大的工具, 可用於系統性能、監控、安全等多個領域。

但是如何做到呢?

eBPF 是由幾個組件組成的系統:

注意, 我在文中使用了 "BPF" 和 "eBPF" 這兩個術語。eBPF 代表 "Extended Berkeley Packet Filter"。BPF 最初被引入到 Linux 中用於過濾網絡數據包, 但 eBPF 擴展了原始 BPF, 允許它用於其他目的。如今它與 Berkeley 無關, 也不僅僅用於過濾數據包。

下圖說明了 eBPF 在用戶空間和內核空間下的工作原理。eBPF 程序使用高級語言 (如 C) 編寫, 然後編譯爲eBPF字節碼。之後, eBPF 字節碼被加載到內核中, 由eBPF虛擬機執行。

eBPF 程序被附加到內核中特定的代碼路徑上, 例如系統調用。這些代碼路徑被稱爲"鉤子"。當鉤子被觸發時, eBPF 程序就會執行, 現在它執行你編寫的自定義邏輯。通過這種方式, 我們可以在內核空間中運行自定義代碼。

eBPF Hello World 示例

在深入細節之前, 讓我們編寫一個簡單的 eBPF 程序來跟蹤execve系統調用。我們將用 C 語言編寫程序, 用 Go 編寫用戶空間程序, 然後運行用戶空間程序將 eBPF 程序加載到內核中, 並在實際執行execve系統調用之前輪詢我們將從 eBPF 程序發出的自定義事件。

編寫 eBPF 程序

讓我們首先編寫 eBPF 程序。我將分部分編寫以更好地解釋細節, 但您可以在我的 GitHub 存儲庫中找到整個程序: ozansz/intro-ebpf-with-go[2] 。

 1#include "vmlinux.h"
 2#include <bpf/bpf_helpers.h>
 3
 4struct event {
 5    u32 pid;
 6    u8  comm[100];
 7};
 8
 9struct {
10	__uint(type, BPF_MAP_TYPE_RINGBUF);
11	__uint(max_entries, 1000);
12} events SEC(".maps");

在這裏, 我們導入vmlinux.h頭文件, 其中包含內核的數據結構和函數原型。然後我們包含bpf_helpers.h頭文件, 其中包含 eBPF 程序的輔助函數。

然後我們定義一個struct來保存事件數據, 然後我們定義一個 BPF 映射 [3] 來存儲事件。我們將使用此映射在 eBPF 程序 (將在內核空間運行) 和用戶空間程序之間通信事件。

稍後我們將深入探討 BPF 映射的細節, 所以如果您不理解爲什麼我們使用BPF_MAP_TYPE_RINGBUF, 或者SEC(".maps")是什麼, 請不要擔心。

我們現在準備編寫第一個程序並定義它將附加到的鉤子:

 1SEC("kprobe/sys_execve")
 2int hello_execve(struct pt_regs *ctx) {
 3    u64 id = bpf_get_current_pid_tgid();
 4    pid_t pid = id >> 32;
 5    pid_t tid = (u32)id;
 6
 7    if (pid != tid)
 8        return 0;
 9
10    struct event *e;
11
12	e = bpf_ringbuf_reserve(&events, sizeof(struct event), 0);
13	if (!e) {
14		return 0;
15	}
16
17	e->pid = pid;
18	bpf_get_current_comm(&e->comm, 100);
19
20	bpf_ringbuf_submit(e, 0);
21
22	return 0;
23}

在這裏, 我們定義一個函數hello_execve, 並使用kprobe鉤子將其附加到sys_execve系統調用。kprobe是 eBPF 提供的許多鉤子之一, 用於跟蹤內核函數。此鉤子將在執行sys_execve系統調用之前觸發我們的hello_execve函數。

hello_execve函數內部, 我們首先獲取進程 ID 和線程 ID, 然後檢查它們是否相同。如果它們不相同, 那意味着我們在一個線程中, 我們不想跟蹤線程, 所以我們通過返回零退出 eBPF 程序。

然後, 我們在events映射中預留空間來存儲事件數據, 然後我們用進程 ID 和進程的命令名稱填充事件數據。然後我們將事件提交到events映射。

到目前爲止還算簡單, 對嗎?

編寫用戶空間程序

在開始編寫用戶空間程序之前, 讓我先簡要解釋一下程序在用戶空間需要做什麼。我們需要一個用戶空間程序來將 eBPF 程序加載到內核中, 創建 BPF 映射, 附加到 BPF 映射, 然後從 BPF 映射中讀取事件。

要執行這些操作, 我們需要使用一個特定的系統調用。這個系統調用稱爲bpf(), 用於執行幾個 eBPF 相關操作, 例如讀取 BPF 映射的內容。

我們自己也可以從用戶空間調用這個系統調用, 但這意味着太多低級操作。謝天謝地, 有一些庫提供了對bpf()系統調用的高級接口。其中之一是 Cilium[4] 的 ebpf-go[5] 包, 我們將在本例中使用它。

讓我們深入研究一些 Go 代碼。

 1//go:generate go run github.com/cilium/ebpf/cmd/bpf2go -type event ebpf hello_ebpf.c
 2
 3func main() {
 4	stopper := make(chan os.Signal, 1)
 5	signal.Notify(stopper, os.Interrupt, syscall.SIGTERM)
 6
 7	// Allow the current process to lock memory for eBPF resources.
 8	if err := rlimit.RemoveMemlock(); err != nil {
 9		log.Fatal(err)
10	}
11
12	objs := ebpfObjects{}
13	if err := loadEbpfObjects(&objs, nil); err != nil {
14		log.Fatalf("loading objects: %v", err)
15	}
16	defer objs.Close()
17
18	kp, err := link.Kprobe(kprobeFunc, objs.HelloExecve, nil)
19	if err != nil {
20		log.Fatalf("opening kprobe: %s", err)
21	}
22	defer kp.Close()
23
24	rd, err := ringbuf.NewReader(objs.Events)
25	if err != nil {
26		log.Fatalf("opening ringbuf reader: %s", err)
27	}
28	defer rd.Close()
29
30    ...

第一行是 Go 編譯器指令go:generate。在這裏, 我們告訴 Go 編譯器從github.com/cilium/ebpf/cmd/bpf2go包運行bpf2go工具, 並從hello_ebpf.c文件生成一個 Go 文件。

生成的 Go 文件將包括 eBPF 程序的 Go 表示、我們在 eBPF 程序中定義的類型和結構等。然後我們將在 Go 代碼中使用這些表示來將 eBPF 程序加載到內核中, 並與 BPF 映射交互。

然後我們使用生成的類型加載 eBPF 程序 (loadEbpfObjects)、附加到 kprobe 鉤子 (link.Kprobe) 和從 BPF 映射讀取事件 (ringbuf.NewReader)。所有這些函數都使用生成的類型。

是時候與內核端交互了:

 1    ...
 2
 3	go func() {
 4		<-stopper
 5
 6		if err := rd.Close(); err != nil {
 7			log.Fatalf("closing ringbuf reader: %s", err)
 8		}
 9	}()
10
11	log.Println("Waiting for events..")
12
13	var event ebpfEvent
14	for {
15		record, err := rd.Read()
16		if err != nil {
17			if errors.Is(err, ringbuf.ErrClosed) {
18				log.Println("Received signal, exiting..")
19				return
20			}
21			log.Printf("reading from reader: %s", err)
22			continue
23		}
24
25		if err := binary.Read(bytes.NewBuffer(record.RawSample), binary.LittleEndian, &event); err != nil {
26			log.Printf("parsing ringbuf event: %s", err)
27			continue
28		}
29
30		procName := unix.ByteSliceToString(event.Comm[:])
31		log.Printf("pid: %d\tcomm: %s\n", event.Pid, procName)
32	}
33}

在這裏, 我們使用events.Reader從 BPF 映射中讀取事件。每次有新事件時, 我們都會打印出進程 ID 和命令名稱。我們將無限期地運行這個循環, 直到用戶中斷程序。

就是這樣! 我們編寫了一個簡單的 eBPF 程序來跟蹤execve系統調用, 並編寫了一個用戶空間程序來加載 eBPF 程序並從 BPF 映射中讀取事件。

您可以在我的 GitHub 存儲庫中找到完整的代碼示例。在下一節中, 我們將深入探討 BPF 映射以及如何使用它們在內核和用戶空間之間傳遞數據。

我們開始一個 goroutine 來監聽stopper通道, 這個通道我們在前面的 Go 代碼片段中定義。當我們收到中斷信號時, 這個通道將用於優雅地停止程序。

然後我們開始一個循環從 BPF 映射中讀取事件。我們使用ringbuf.Reader類型來讀取事件, 然後我們使用binary.Read函數將事件數據解析到ebpfEvent類型中, 這個類型是從 eBPF 程序生成的。

接着我們將進程 ID 和進程命令名稱打印到標準輸出。

運行程序

現在我們已經準備好運行程序了。首先, 我們需要編譯 eBPF 程序, 然後運行用戶空間程序。

1$ go generate
2Compiled /Users/sazak/workspace/gocode/src/github.com/ozansz/intro-ebpf-with-go/0x01-helloworld/ebpf_bpfel.o
3Stripped /Users/sazak/workspace/gocode/src/github.com/ozansz/intro-ebpf-with-go/0x01-helloworld/ebpf_bpfel.o
4Wrote /Users/sazak/workspace/gocode/src/github.com/ozansz/intro-ebpf-with-go/0x01-helloworld/ebpf_bpfel.go
5Compiled /Users/sazak/workspace/gocode/src/github.com/ozansz/intro-ebpf-with-go/0x01-helloworld/ebpf_bpfeb.o
6Stripped /Users/sazak/workspace/gocode/src/github.com/ozansz/intro-ebpf-with-go/0x01-helloworld/ebpf_bpfeb.o
7Wrote /Users/sazak/workspace/gocode/src/github.com/ozansz/intro-ebpf-with-go/0x01-helloworld/ebpf_bpfeb.go
8
9$ go build -o hello_ebpf

我們首先運行go generate命令來編譯 eBPF 程序, 然後運行go build命令來編譯用戶空間程序。

然後我們運行用戶空間程序:

1sudo ./hello_ebpf
2hello_ebpf: 01:20:54 Waiting for events..

我正在 Lima[6] 中的一個虛擬機裏運行這個程序, 爲什麼不打開另一個 shell 看看會發生什麼?

1limactl shell intro-ebpf
2
3$

同時在第一個 shell 中:

1hello_ebpf: 01:22:22 pid: 3360	comm: sshd
2hello_ebpf: 01:22:22 pid: 3360	comm: bash
3hello_ebpf: 01:22:22 pid: 3361	comm: bash
4hello_ebpf: 01:22:22 pid: 3362	comm: bash
5hello_ebpf: 01:22:22 pid: 3363	comm: bash
6hello_ebpf: 01:22:22 pid: 3366	comm: bash
7hello_ebpf: 01:22:22 pid: 3367	comm: lesspipe
8hello_ebpf: 01:22:22 pid: 3369	comm: lesspipe
9hello_ebpf: 01:22:22 pid: 3370	comm: bash

如預期, 我們看到sshd進程正在啓動, 然後是bash進程, 然後是lesspipe進程, 等等。

這是一個簡單的例子, 說明我們如何使用 eBPF 來跟蹤execve系統調用, 然後在用戶空間中從 BPF 映射讀取事件。我們編寫了一個相當簡單但功能強大的程序, 並且在不修改內核源代碼或重啓系統的情況下攔截了execve系統調用。

eBPF 鉤子和映射

那麼, 在前面的例子中實際發生了什麼? 我們使用kprobe鉤子將 eBPF 程序附加到sys_execve系統調用上, 以便在執行原始系統調用代碼之前每次調用sys_execve系統調用時運行hello_execve函數。

eBPF 是事件驅動的, 這意味着它期望我們將 eBPF 程序附加到內核中特定的代碼路徑上。這些代碼路徑被稱爲 "鉤子",eBPF 提供了幾種類型的鉤子。最常見的是:

鉤子kprobeuprobe用於在函數 / 系統調用執行之前調用附加的 eBPF 程序, 而kretprobeuretprobe用於在函數 / 系統調用執行之後調用附加的 eBPF 程序。

我們還使用了一個 BPF 映射來存儲事件。BPF 映射是用於存儲和傳遞不同類型數據的數據結構。我們也用它們來進行狀態管理。支持太多種類的 BPF 映射, 我們爲不同的目的使用不同類型的映射。一些最常見的 BPF 映射類型是:

其中一些映射類型也有每 CPU 變體, 例如BPF_MAP_TYPE_PERCPU_HASH, 它是一個哈希映射, 每個 CPU 內核都有一個單獨的哈希表。

更進一步: 跟蹤傳入的 IP 數據包

讓我們再進一步, 編寫一個更復雜的 eBPF 程序。這次我們將使用XDP鉤子在網絡接口將網絡數據包發送到內核之後立即調用 eBPF 程序, 甚至在內核處理數據包之前

編寫 eBPF 程序

我們將編寫一個 eBPF 程序來統計按源 IP 地址和端口號計算的傳入 IP 數據包數量, 然後我們將在用戶空間中讀取 BPF 映射中的計數。我們將解析每個數據包的以太網、IP 和 TCP/UDP 頭, 並將有效的 TCP/UDP 數據包的計數存儲在 BPF 映射中。

首先, eBPF 程序:

 1#include "vmlinux.h"
 2#include <bpf/bpf_helpers.h>
 3#include <bpf/bpf_endian.h>
 4
 5#define MAX_MAP_ENTRIES 100
 6
 7/* Define an LRU hash map for storing packet count by source IP and port */
 8struct {
 9	__uint(type, BPF_MAP_TYPE_LRU_HASH);
10	__uint(max_entries, MAX_MAP_ENTRIES);
11	__type(key, u64); // source IPv4 addresses and port tuple
12	__type(value, u32); // packet count
13} xdp_stats_map SEC(".maps");

與第一個示例一樣, 我們將包含vmlinux.h和 BPF 幫助程序頭文件。我們還定義了一個映射xdp_stats_map, 用於存儲IP:ports和數據包計數信息。然後我們將在鉤子函數中填充此映射, 並在用戶空間程序中讀取其內容。

我所說的IP:ports基本上是一個u64值, 其中打包了源 IP、源端口和目標端口。IP 地址 (IPv4, 特別是) 爲 32 位長, 每個端口號爲 16 位長, 因此我們需要恰好 64 位來存儲這三個 - 這就是我們在這裏使用u64的原因。我們只處理入站 (傳入) 數據包, 因此不需要存儲目標 IP 地址。

與上一個示例不同, 我們現在使用BPF_MAP_TYPE_LRU_HASH作爲映射類型。此類型的映射允許我們將(key, value)對作爲具有 LRU 變體的哈希映射存儲。

看看我們是如何定義映射的, 我們明確設置了最大條目數, 以及映射鍵和值的類型。對於鍵, 我們使用 64 位無符號整數, 對於值, 我們使用 32 位無符號整數。

u32的最大值是2^32 - 1, 對於本示例而言, 這已經足夠多的數據包了。

要了解 IP 地址和端口號, 我們首先需要解析數據包並讀取以太網、IP, 然後是 TCP/UDP 頭

由於 XDP 位於網絡接口卡之後, 我們將以字節形式獲得原始數據包數據, 因此我們需要手動遍歷字節數組並解組以太網、IP 和 TCP/UDP 頭。

希望我們在vmlinux.h頭文件中有所有的頭定義 (struct ethhdrstruct iphdrstruct tcphdrstruct udphdr)。我們將使用這些結構體在一個單獨的函數parse_ip_packet中提取 IP 地址和端口號信息:

 1#define ETH_P_IP		0x0800	/* Internet Protocol packet	*/
 2
 3#define PARSE_SKIP 			0
 4#define PARSED_TCP_PACKET	1
 5#define PARSED_UDP_PACKET	2
 6
 7static __always_inline int parse_ip_packet(struct xdp_md *ctx, u64 *ip_metadata) {
 8	void *data_end = (void *)(long)ctx->data_end;
 9	void *data     = (void *)(long)ctx->data;
10
11	// First, parse the ethernet header.
12	struct ethhdr *eth = data;
13	if ((void *)(eth + 1) > data_end) {
14		return PARSE_SKIP;
15	}
16
17	if (eth->h_proto != bpf_htons(ETH_P_IP)) {
18		// The protocol is not IPv4, so we can't parse an IPv4 source address.
19		return PARSE_SKIP;
20	}
21
22	// Then parse the IP header.
23	struct iphdr *ip = (void *)(eth + 1);
24	if ((void *)(ip + 1) > data_end) {
25		return PARSE_SKIP;
26	}
27
28	u16 src_port, dest_port;
29	int retval;
30
31	if (ip->protocol == IPPROTO_TCP) {
32		struct tcphdr *tcp = (void*)ip + sizeof(*ip);
33		if ((void*)(tcp+1) > data_end) {
34			return PARSE_SKIP;
35		}
36		src_port = bpf_ntohs(tcp->source);
37		dest_port = bpf_ntohs(tcp->dest);
38		retval = PARSED_TCP_PACKET;
39	} else if (ip->protocol == IPPROTO_UDP) {
40		struct udphdr *udp = (void*)ip + sizeof(*ip);
41		if ((void*)(udp+1) > data_end) {
42			return PARSE_SKIP;
43		}
44		src_port = bpf_ntohs(udp->source);
45		dest_port = bpf_ntohs(udp->dest);
46		retval = PARSED_UDP_PACKET;
47	} else {
48		// The protocol is not TCP or UDP, so we can't parse a source port.
49		return PARSE_SKIP;
50	}
51
52	// Return the (source IP, destination IP) tuple in network byte order.
53	// |<-- Source IP: 32 bits -->|<-- Source Port: 16 bits --><-- Dest Port: 16 bits -->|
54	*ip_metadata = ((u64)(ip->saddr) << 32) | ((u64)src_port << 16) | (u64)dest_port;
55	return retval;
56}

該函數:

注意函數簽名開頭的__always_inline。這告訴編譯器始終將此函數內聯爲靜態代碼, 這樣可以節省我們執行函數調用的開銷。

現在是時候編寫鉤子函數並使用parse_ip_packet了:

 1SEC("xdp")
 2int xdp_prog_func(struct xdp_md *ctx) {
 3	u64 ip_meta;
 4	int retval = parse_ip_packet(ctx, &ip_meta);
 5	
 6	if (retval != PARSED_TCP_PACKET) {
 7		return XDP_PASS;
 8	}
 9
10	u32 *pkt_count = bpf_map_lookup_elem(&xdp_stats_map, &ip_meta);
11	if (!pkt_count) {
12		// No entry in the map for this IP tuple yet, so set the initial value to 1.
13		u32 init_pkt_count = 1;
14		bpf_map_update_elem(&xdp_stats_map, &ip_meta, &init_pkt_count, BPF_ANY);
15	} else {
16		// Entry already exists for this IP tuple,
17		// so increment it atomically.
18		__sync_fetch_and_add(pkt_count, 1);
19	}
20
21	return XDP_PASS;
22}

xdp_prog_func相當簡單, 因爲我們已經在parse_ip_packet中編寫了大部分程序邏輯。我們在這裏做的是:

最後, 我們使用SEC("xdp")宏將此函數附加到XDP子系統。

編寫用戶空間程序

現在是時候深入研究 Go 代碼了。

 1//go:generate go run github.com/cilium/ebpf/cmd/bpf2go ebpf xdp.c
 2
 3var (
 4    ifaceName = flag.String("iface", "", "network interface to attach XDP program to")
 5)
 6
 7func main() {
 8	log.SetPrefix("packet_count: ")
 9	log.SetFlags(log.Ltime | log.Lshortfile)
10    flag.Parse()
11
12	// Subscribe to signals for terminating the program.
13	stop := make(chan os.Signal, 1)
14	signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
15
16	iface, err := net.InterfaceByName(*ifaceName)
17	if err != nil {
18		log.Fatalf("network iface lookup for %q: %s", *ifaceName, err)
19	}
20
21	// Load pre-compiled programs and maps into the kernel.
22	objs := ebpfObjects{}
23	if err := loadEbpfObjects(&objs, nil); err != nil {
24		log.Fatalf("loading objects: %v", err)
25	}
26	defer objs.Close()
27
28	// Attach the program.
29	l, err := link.AttachXDP(link.XDPOptions{
30		Program:   objs.XdpProgFunc,
31		Interface: iface.Index,
32	})
33	if err != nil {
34		log.Fatalf("could not attach XDP program: %s", err)
35	}
36	defer l.Close()
37
38	log.Printf("Attached XDP program to iface %q (index %d)", iface.Name, iface.Index)
39
40    ...

在這裏, 我們首先使用loadEbpfObjects函數加載生成的 eBPF 程序和映射。然後, 我們使用link.AttachXDP函數將程序附加到指定的網絡接口。與上一個示例一樣, 我們使用一個通道來監聽中斷信號並正常關閉程序。

接下來, 我們將每秒讀取一次映射內容並將數據包計數打印到標準輸出:

 1    ...
 2
 3    ticker := time.NewTicker(time.Second)
 4	defer ticker.Stop()
 5	for {
 6		select {
 7		case <-stop:
 8			if err := objs.XdpStatsMap.Close(); err != nil {
 9				log.Fatalf("closing map reader: %s", err)
10			}
11			return
12		case <-ticker.C:
13			m, err := parsePacketCounts(objs.XdpStatsMap, excludeIPs)
14			if err != nil {
15				log.Printf("Error reading map: %s", err)
16				continue
17			}
18			log.Printf("Map contents:\n%s", m)
19			srv.Submit(m)
20		}
21	}
22}

我們將使用一個實用函數 parsePacketCounts 來讀取映射內容並解析數據包計數。該函數將在循環中讀取映射內容。

由於我們將從映射中獲取原始字節, 我們需要解析字節並將其轉換爲人類可讀的格式。我們將定義一個新類型 PacketCounts 來存儲解析後的映射內容。

 1type IPMetadata struct {
 2	SrcIP   netip.Addr
 3	SrcPort uint16
 4	DstPort uint16
 5}
 6
 7func (t *IPMetadata) UnmarshalBinary(data []byte) (err error) {
 8	if len(data) != 8 {
 9		return fmt.Errorf("invalid data length: %d", len(data))
10	}
11	if err = t.SrcIP.UnmarshalBinary(data[4:8]); err != nil {
12		return
13	}
14	t.SrcPort = uint16(data[3])<<8 | uint16(data[2])
15	t.DstPort = uint16(data[1])<<8 | uint16(data[0])
16	return nil
17}
18
19func (t IPMetadata) String() string {
20	return fmt.Sprintf("%s:%d => :%d", t.SrcIP, t.SrcPort, t.DstPort)
21}
22
23type PacketCounts map[string]int
24
25func (i PacketCounts) String() string {
26	var keys []string
27	for k := range i {
28		keys = append(keys, k)
29	}
30	sort.Strings(keys)
31
32	var sb strings.Builder
33	for _, k := range keys {
34		sb.WriteString(fmt.Sprintf("%s\t| %d\n", k, i[k]))
35	}
36
37	return sb.String()
38}

我們定義了一個新類型 IPMetadata 來存儲 IP:ports 元組。我們還定義了一個 UnmarshalBinary 方法來解析原始字節並將其轉換爲人類可讀的格式。我們還定義了一個 String 方法來以人類可讀的格式打印 IP:ports 元組。

然後, 我們定義了一個新類型 PacketCounts 來存儲解析後的映射內容。我們還定義了一個 String 方法來以人類可讀的格式打印映射內容。

最後, 我們將使用 PacketCounts 類型來解析映射內容並打印數據包計數:

 1func parsePacketCounts(m *ebpf.Map, excludeIPs map[string]bool) (PacketCounts, error) {
 2	var (
 3		key    IPMetadata
 4		val    uint32
 5		counts = make(PacketCounts)
 6	)
 7	iter := m.Iterate()
 8	for iter.Next(&key, &val) {
 9		if _, ok := excludeIPs[key.SrcIP.String()]; ok {
10			continue
11		}
12		counts[key.String()] = int(val)
13	}
14	return counts, iter.Err()
15}

運行程序

我們首先需要編譯 eBPF 程序, 然後運行用戶空間程序。

1$ go generate
2Compiled /Users/sazak/workspace/gocode/src/github.com/ozansz/intro-ebpf-with-go/0x03-packet-count/ebpf_bpfel.o
3Stripped /Users/sazak/workspace/gocode/src/github.com/ozansz/intro-ebpf-with-go/0x03-packet-count/ebpf_bpfel.o
4Wrote /Users/sazak/workspace/gocode/src/github.com/ozansz/intro-ebpf-with-go/0x03-packet-count/ebpf_bpfel.go
5Compiled /Users/sazak/workspace/gocode/src/github.com/ozansz/intro-ebpf-with-go/0x03-packet-count/ebpf_bpfeb.o
6Stripped /Users/sazak/workspace/gocode/src/github.com/ozansz/intro-ebpf-with-go/0x03-packet-count/ebpf_bpfeb.o
7Wrote /Users/sazak/workspace/gocode/src/github.com/ozansz/intro-ebpf-with-go/0x03-packet-count/ebpf_bpfeb.go
8
9$ go build -o packet_count

現在我們可以運行它:

1$ sudo ./packet_count --iface eth0
2packet_count: 22:11:10 main.go:107: Attached XDP program to iface "eth0" (index 2)
3packet_count: 22:11:10 main.go:132: Map contents:
4192.168.5.2:58597 => :22	| 51
5packet_count: 22:11:11 main.go:132: Map contents:
6192.168.5.2:58597 => :22	| 52
7packet_count: 22:11:11 main.go:132: Map contents:
8192.168.5.2:58597 => :22	| 53

來自 IP 地址 192.168.5.2 到端口 22 的數據包是 SSH 數據包, 因爲我在虛擬機內部運行這個程序, 我正在通過 SSH 連接到它。

讓我們在另一個終端中在虛擬機內運行 curl, 看看會發生什麼:

1$ curl https://www.google.com/

同時在第一個終端中:

 1packet_count: 22:14:07 main.go:132: Map contents:
 2172.217.22.36:443 => :38324	| 12
 3192.168.5.2:58597 => :22	| 551
 4packet_count: 22:14:08 main.go:132: Map contents:
 5172.217.22.36:443 => :38324	| 12
 6192.168.5.2:58597 => :22	| 552
 7packet_count: 22:14:08 main.go:132: Map contents:
 8172.217.22.36:443 => :38324	| 30
 9192.168.5.2:58597 => :22	| 570
10packet_count: 22:14:09 main.go:132: Map contents:
11172.217.22.36:443 => :38324	| 30
12192.168.5.2:58597 => :22	| 571

我們看到來自 IP 地址 172.217.22.36 到端口 38324 的數據包是來自 curl 命令的數據包。

結論

eBPF 在許多方面都非常強大, 我認爲在系統編程、可觀測性或安全性方面投資時間學習它是一個不錯的選擇。在本文中, 我們已經看到了 eBPF 是什麼、它是如何工作的, 以及我們如何開始使用 Go 來使用它。

我希望您喜歡這篇文章並學到了一些新東西。如果您有任何疑問, 歡迎隨時 ping[7] 我。

資源

參考鏈接

  1. Go Konf Istanbul '24: https://sazak.io/talks/an-applied-introduction-to-ebpf-with-go-2024-02-17
  2. ozansz/intro-ebpf-with-go: https://github.com/ozansz/intro-ebpf-with-go/tree/main/0x01-helloworld
  3. BPF 映射: https://docs.kernel.org/bpf/maps.html
  4. Cilium: https://cilium.io/
  5. ebpf-go: https://sazak.io/articles/github.com/cilium/ebpf
  6. Lima: https://github.com/lima-vm/lima
  7. ping: https://twitter.com/oznszk
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/ceaTR9tEivG3MeW9I9hErw