本文記錄瞭如何零基礎通過 `BCC` 框架,入門 `eBPF` 程式的開發,並實現幾個簡易的程式。 拋磚引玉,如有論述錯誤之處,還請斧正。 ...
閑言少敘,本文記錄瞭如何零基礎通過 BCC
框架,入門 eBPF
程式的開發,並實現幾個簡易的程式。
有關 eBPF
的介紹,網路上的資料有很多,本文暫且先不深入討論,後面會再出一篇文章詳細分析其原理和功能。
我們目前只需要知道,eBPF
實際上是一種過濾器,這種過濾器幾乎可以插入內核源碼的任意的流程和環節中,實現自定義的邏輯。由於 eBPF
自身的若幹限制,使它最常見的用法是,附著在內核某些關鍵流程上,抓取一些關鍵數據,用於監控、統計和分析。
1 一個簡單的例子
下麵是一個簡單的例子,我想實現一個程式,用來實時監控內核可執行文件(ELF)的載入。這個程式運行如下:
如圖所示,每當有一個 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
被執行時,將會返回數據給用戶態控製程序。
流程可以描述如下:
接下來我們對這個程式進行億點點擴展,讓它變得規範一些,代碼如下:
#!/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
程式傳出的輸出信息;其中,msg
為 bpf_trace_printk()
的列印信息。
3)通過無限迴圈,監測 clone
系統調用的執行;增加了異常輸出。
這段程式運行後,輸出結果如下:
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
。
運行這段程式,輸出如下:
可以看到,這段程式可以實時監控內核進程執行,並輸出執行的進程和被執行的文件名。
5 總結
本文通過幾個程式 demo,簡單介紹了 eBPF BCC 框架的編程方法,並最終實現了一個簡單的進程執行的監視工具,可以實時列印被執行的進程信息。
本文開篇所引出的實時監控內核可執行文件(ELF)的載入程式,也就沒那個高深莫測了。