【eBPF-01】初見:基於 BCC 框架的第一個 eBPF 程式

_hong發表於2023-12-25

閒言少敘,本文記錄瞭如何零基礎透過 BCC 框架,入門 eBPF 程式的開發,並實現幾個簡易的程式。

有關 eBPF 的介紹,網路上的資料有很多,本文暫且先不深入討論,後面會再出一篇文章詳細分析其原理和功能。

我們目前只需要知道,eBPF 實際上是一種過濾器,這種過濾器幾乎可以插入核心原始碼的任意的流程和環節中,實現自定義的邏輯。由於 eBPF 自身的若干限制,使它最常見的用法是,附著在核心某些關鍵流程上,抓取一些關鍵資料,用於監控、統計和分析。

1 一個簡單的例子

下面是一個簡單的例子,我想實現一個程式,用來實時監控核心可執行檔案(ELF)的載入。這個程式執行如下:

image

如圖所示,每當有一個 ELF 檔案被載入時,可以顯示這個 ELF 載入時的一些核心資訊,如:載入時間、載入程式名、載入程式 PID、以及被載入的 ELF 檔名。

這個程式就是基於 eBPF 實現的。接下來,我們就逐步瞭解一下,如何透過 BCC 框架,成功編寫執行這個 eBPF 程式。

2 BCC 框架

進行 eBPF 程式設計,有很多種方式。例如:

1)libbpf:使用原生的 C 語言,基於 libbpf 庫,編寫使用者態程式和 BPF 程式的載入;

2)libbpf-bootstrap:使用 libbpf-bootstrap 腳手架,輕而易舉地編寫 BPF 程式;

3)BCC:使用 BCC 框架,基於 python/Lua 指令碼,實現 BPF 和使用者態程式,上手容易,簡化了 BPF 的開發;

4)Bpftrace:一種用於eBPF的高階跟蹤語言,使用LLVM作為後端,將指令碼編譯為BPF位元組碼;

5)eunomia-bpf:較新的基於 libbpf 的 CO-RE 輕量級框架,簡化了 eBPF 程式的開發、構建、分發、執行

選擇 BCC 框架作為第一個學習的框架的原因是,BCC 封裝較好,上手容易,使用者態和核心態的區分明顯,使用者態支援 Python,易於理解。

安裝過程很簡單,直接透過對應軟體包管理器安裝即可。

本文的實驗環境是 REHL 8(x86),因此,執行 yum 命令來安裝。

yum install -y python3-bcc.x86_64

2.1 編寫 hello world

安裝好 Python BCC 依賴包後,在工作目錄中建立一個 py 指令碼檔案,輸入以下程式碼:

#!/bin/python3
from bcc import BPF

bpf_code = '''
int kprobe__sys_clone(void *ctx) {
    bpf_trace_printk("Hello world!\\n");
    return 0;
}
'''

b = BPF(text=bpf_code)
b.trace_print()

執行這個 py 指令碼,當有程式被建立時,列印一條 Hello world 記錄。

這就是一個最簡單的 eBPF 程式。

3 擴充套件這個 Hello world

上面給出的這個程式結構很清晰,分為兩個部分:以 C 編寫的 eBPF 核心態程式,和以 Python 編寫的使用者態控制程式。eBPF 核心態程式被 BCC 框架編譯到核心中,等待預設的觸發條件,——這裡是 sys_clone 即程式建立的系統呼叫,eBPF 被執行時,將會返回資料給使用者態控制程式。

流程可以描述如下:

image

接下來我們對這個程式進行億點點擴充套件,讓它變得規範一些,程式碼如下:

#!/bin/python3
from bcc import BPF
from bcc.utils import printb

# define BPF program
prog = """
int hello(void *ctx) {
    bpf_trace_printk("Hello, World!\\n");
    return 0;
}
"""

# load BPF program
b = BPF(text=prog)
b.attach_kprobe(event=b.get_syscall_fnname("clone"), fn_name="hello")

# header
print("%-18s %-16s %-6s %s" % ("TIME(s)", "COMM", "PID", "MESSAGE"))

# format output
while 1:
    try:
        (task, pid, cpu, flags, ts, msg) = b.trace_fields()
    except ValueError:
        continue
    except KeyboardInterrupt:
        exit()
    printb(b"%-18.9f %-16s %-6d %s" % (ts, task, pid, msg))

在這段程式中,我們做出了以下幾點變動:

1)使用 event=b.get_syscall_fnname("clone") 來繫結核心中的系統呼叫監視點,這裡繫結了 clone 程式建立呼叫;使用 fn_name="hello" 繫結了 eBPF 程式中的自定義檢查邏輯;使用 b.attach_kprobe() 函式將 eBPF 程式載入到核心中。

2)使用 b.trace_fields() 函式按欄位的形式,接收核心 eBPF 程式傳出的輸出資訊;其中,msgbpf_trace_printk() 的列印資訊。

3)透過無限迴圈,監測 clone 系統呼叫的執行;增加了異常輸出。

這段程式執行後,輸出結果如下:

image

4 進一步擴充套件,監視 do_execve

第 3 節的程式碼,輸出核心欄位的方式是 bpf_trace_printk() + trace_fields(),比較靈活,但效能較差。實際上,還有一種比較常見的輸出方式,那就是透過一段共享記憶體 Ring buffer 來實現。

此外,這次我們更換一個核心監視點,不再關注程式的建立,而關注程式的執行。

接下來,對上面的程式碼進行大刀闊斧的修改吧。

檔案拆分:

// do_execve.c
#include <uapi/linux/limits.h>		// #define NAME_MAX		255
#include <linux/fs.h>			// struct filename;
#include <linux/sched.h>		// #define TASK_COMM_LEN	16

// 定義 Buffer 中的資料結構,用於核心態和使用者態的資料交換
struct data_t {
	u32     pid;
	char    comm[TASK_COMM_LEN];
	char    fname[NAME_MAX];
};
BPF_PERF_OUTPUT(events);
// 自定義 hook 函式
int check_do_execve(struct pt_regs *ctx, struct filename *filename,
                                const char __user *const __user *__argv,
                                const char __user *const __user *__envp) {
	truct data_t data = { };
	
	data.pid = bpf_get_current_pid_tgid();
	bpf_get_current_comm(&data.comm, sizeof(data.comm));
	bpf_probe_read_kernel_str(&data.fname, sizeof(data.fname), (void *)filename->name);
	// 提交 buffer 資料
	events.perf_submit(ctx, &data, sizeof(data));
	return 0;
}
# do_execve.py
#!/bin/python3
from bcc import BPF
from bcc.utils import printb
# 指定 eBPF 原始碼檔案
b = BPF(src_file="do_execve.c")
# 以核心函式的方式繫結 eBPF 探針
b.attach_kprobe(event="do_execve", fn_name="check_do_execve")

print("%-6s %-16s %-16s" % ("PID", "COMM", "FILE"))
# 自定義回撥函式
def print_event(cpu, data, size):
	event = b["events"].event(data)
	printb(b"%-6d %-16s %-16s" % (event.pid, event.comm, event.fname))

# 指定 buffer 名稱,為 buffer 的修改新增回撥函式
b["events"].open_perf_buffer(print_event)
while 1:
	try:
		# 迴圈監聽
		b.perf_buffer_poll()
	except KeyboardInterrupt:
		exit()

這一次,我們又進行了億點點修改:

1)首先,對 eBPF BCC 程式的使用者態和核心態程式碼進行拆分,並在使用者態程式中,透過 b = BPF(src_file="do_execve.c") 對核心態原始碼檔案進行繫結。

2)以核心函式的方式繫結 eBPF 程式,繫結點為 do_execve(),自定義處理函式為 check_do_execve()

注意:

可以看到,check_do_execve() 函式的引數分為兩部分:

① struct pt_regs *ctx;
② struct filename *filename, const char __user *const __user *__argv, const char __user *const __user *__envp

這是因為,②所代表的,正是核心 do_execve()函式的引數。do_execve()函式簽名如下:

// fs/exec.c
int do_execve(struct filename *filename, const char __user *const __user *__argv, const char __user *const __user *__envp) {...}

是的,透過這種方式,幾乎可以監控任意一個核心中的函式

3)核心態程式中,使用了一些 eBPF Helper 函式來進行一些基礎的操作和資料獲取,例如:

bpf_get_current_pid_tgid()								// 獲取當前程式 pid
bpf_get_current_comm(&data.comm, sizeof(data.comm));					// 獲取當前程式名 comm
bpf_probe_read_kernel_str(&data.fname, sizeof(data.fname), (void *)filename->name);	// 將資料從核心空間複製到使用者空間

4)核心態程式中,使用 BPF_PERF_OUTPUT(events) 宣告 buffer 中的共享變數;使用 events.perf_submit(ctx, &data, sizeof(data)) 提交資料。
使用者態程式中,使用 b["events"].open_perf_buffer(print_event) 指定 buffer 名稱,為 buffer 的修改新增回撥函式 print_event

執行這段程式,輸出如下:

image

可以看到,這段程式可以實時監控核心程式執行,並輸出執行的程式和被執行的檔名。

5 總結

本文透過幾個程式 demo,簡單介紹了 eBPF BCC 框架的程式設計方法,並最終實現了一個簡單的程式執行的監視工具,可以實時列印被執行的程式資訊。

本文開篇所引出的實時監控核心可執行檔案(ELF)的載入程式,也就沒那個高深莫測了。

相關文章