本文作者:strickland
本文鏈接:https://www.strickland.cloud/post/1
版權聲明:本博客所有文章除特別聲明外,均采用 BY-NC-SA 許可協議。轉載請注明出處!
在最近這一段時間,我們使用 eBPF 實現了一些監控組件,在這過程中因為 eBPF 的一些兼容性遇到不少問題在最近這一段時間,我們使用 eBPF 實現了一些監控組件,在這過程中因為 eBPF 的一些兼容性遇到不少問題。包括頭文件的引入、低版本內核使用 BTF 等,同時我們沒用使用 bcc 來作為 eBPF 前端,而是使用了cilium/ebpf(下文簡稱ebpf-go),它提供了一層 Go 的接口來操作 eBPF 程序,不過因為該項目和 repo 內示例程序較為簡單,在實際開發中還是遇到了一些編程上的問題。本篇的主要是圍繞著 BPF CO-RE的相關介紹,并且將我們在實際開發中使用 BPF CO-RE 和 ebpf-go相關的問題以及解決辦法分享出來。
限于篇幅,本篇文章并不是 eBPF 的入門文章,為了能夠徹底理解文章的內容,讀者最好有 eBPF、bcc、ebpf-go 的基本使用經驗。
為什么需要 BPF CO-RE
雖然本篇的內容是介紹 BPF CO-RE,但是不對ebpf-go的使用方式做簡要介紹,就不容易理解為什么需要使用 CO-RE來解決一些痛點問題。ebpf-go的作用就是提供一個可編程的Go 語言接口,提供操作 eBPF 程序的基本函數: 掛載、釋放,對 eBPF map進行增刪改查等。
它的基本使用方法是讓程序員編寫好一段純的 eBPF 程序,ebpf-go會負責對這段程序的翻譯,能夠讓我們使用 Go 進行 eBPF 程序的掛載和 map 數據的處理。下面是一個簡單的示例,參考自ebpf-go-exmaple:
#include "common.h"
char __license[] SEC("license") = "Dual MIT/GPL";
struct bpf_map_def SEC("maps") kprobe_map = {
.type = BPF_MAP_TYPE_ARRAY,
.key_size = sizeof(u32),
.value_size = sizeof(u64),
.max_entries = 1,
};
SEC("kprobe/sys_execve")
int kprobe_execve() {
u32 key = 0;
u64 initval = 1, *valp;
valp = bpf_map_lookup_elem(&kprobe_map, &key);
if (!valp) {
bpf_map_update_elem(&kprobe_map, &key, &initval, BPF_ANY);
return 0;
}
__sync_fetch_and_add(valp, 1);
return 0;
}
上面這段程序的插樁點在sys_execve
,作用是記錄sys_execve
被調用的次數。不過SEC("kprobe/sys_execve")
只是一個提示性的宏,實際的函數掛載位于下面這段程序。實際使用中需要將上面這段ebpf程序編譯為.o
文件,然后被下面程序中的loadBpfObjects
函數加載到內核,臟活累活 ebpf-go都做完了。//go:generate
這行就就是預處理指令,將 C 語言編寫的 ebpf 程序編譯為.o
文件。
//go:generate go run github.com/cilium/ebpf/cmd/bpf2go -cc $BPF_CLANG -cflags $BPF_CFLAGS bpf kprobe.c -- -I../headers
const mapKey uint32 = 0
func main() {
objs := bpfObjects{}
if err := loadBpfObjects(&objs, nil); err != nil {
log.Fatalf("loading objects: %v", err)
}
defer objs.Close()
// 使用kprobe掛載
kp, err := link.Kprobe(fn, objs.KprobeExecve, nil)
if err != nil {
log.Fatalf("opening kprobe: %s", err)
}
defer kp.Close()
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
log.Println("Waiting for events..")
for range ticker.C {
var value uint64
if err := objs.KprobeMap.Lookup(mapKey, &value); err != nil {
log.Fatalf("reading map: %v", err)
}
log.Printf("%s called %d times\n", fn, value)
}
}
從這個例子來看,ebpf-go無法動態的在程序運行時進行判斷某些條件是否成立。比如說struct task_struct
結構體內部的state
在這個commit被重命名為了__state
,我們很難根據內核版本去判斷是否有某個結構體(不過確實libbpf后來也提供了判斷內核版本的功能,還有一些別的奇技淫巧也可以做,不過使用BPF CO-RE有更好的實現方式)。甚至如果一個結構體的成員被重命名、被移除那么就會導致在本地開發的 eBPF 程序在線上環境無法使用。ebpf-go的實現是靜態的,而不像bcc那樣可以在運行時期間進行字符替換。
BPF CO-RE
現如今 eBPF 在很多方便都有它的身影,cilium、skywalking、bpftrace、pixie等等在各個方面上都發揮著作用,不過 eBPF 存在的缺陷是功能隨著內核版本的更新而更新,老版本內核無法使用相當多基于 eBPF 的組件,另外一個問題是 eBPF 程序的移植性較不好,這一點已經從上一小節得到了驗證,結構體成員的更新、重命名等操作都會讓一個 eBPF 到一個新的內核版無法在使用。
BPF CO-RE(Compile Once – Run Everywhere)的目的是能夠讓 eBPF 程序有更好的可移植性,在多個不同版本的內核之間可以做兼容。除了前面的示例以外,還可能存在結構體成員的偏移量改變、結構體成員被移除、類型被改變等等兼容問題。那么,盡可能的讓bpf 程序有更好的兼容性,可以用tracepoint來替代kprobe,不過tracepoint缺陷是可以插樁的函數點相當少,另外一個可行的方法根據目標內核版本在運行時做一些工作,這就是BCC所做的事情,不過BCC依賴于內核的頭文件,它需要機器上內核版本所對應的內核頭文件是安裝的,而且bcc內置了clang/llvm,這無疑也會增加一個程序所需的內存(這一點是十分顯而易見的,使用bcc做好的鏡像比ebpf-core要大很多),bcc在運行時進行程序的編譯,加載bpf程序到內核,所以程序的一點小問題都只能到運行時才能發覺。
CO-RE的目的就是解決上述的問題,CO-RE的實現依賴于BTF(BPF type formation) 它可以認為是面向 eBPF 程序的 debuginfo,文檔開宗明義地介紹了它的作用:
BTF (BPF Type Format) is the metadata format which encodes the debug info related to BPF program/map. The name BTF was used initially to describe data types. The BTF was later extended to include function info for defined subroutines, and line info for source/line information.
BTF 在較新的內核版本是默認自帶的,否則的話需要手動的指定CONFIG_DEBUG_INFO_BTF=y
,在某些線上的低版本內核當中沒有BTF支持那么使用CO-RE就相當棘手,在后文我們將會介紹如何在低版本內核使用BTF。除此以外,CO-RE的實現來依賴于Clang提供的一些結構體重定位等輔助信息,libbpf會將btf與clang所提供的信息相結合完成整個重定位的過程。限于知識水平,對于這些更加底層的內容了解的很少,不過多展開。
本小節的不少例子都來自于這篇博客,作者是CO-RE項目的核心開發者,這里只是挑選了部分內容作為示例。
遠離頭文件
如果有過編寫bcc腳本的經驗,就會有我要使用的結構體到底在哪個頭文件這種問題。如果內核有 BTF 的支持1,那么可以根據當前內核版本生成一個囊括了所有結構體的頭文件,不過它沒有所需要的宏,大部分宏的取值直接參考內核頭文件手動編寫。生成頭文件的命令如下2:
bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h
1: BTF 應該是從linux 5.4開始默認支持的,不過可以要驗證當前內核是否具有BTF支持只需要查看/sys/kernel/btf
路徑是否存在。
2: 該命令對 bpftool 的版本有要求,通過 apt 下載的 bpftool 可能版本較低,會提示 failed to load BTF from /sys/kernel/btf/vmlinux: Unknown error -4001
這樣類似的錯誤,最好的方法是手動編譯,參考bpftool repo。
3: 對于沒有 BTF 支持的內核如果使用vmlinux.h程序將無法通過編譯。
讀取結構體成員
在bcc當中訪問結構體的方法就如同普通的C語言程序那樣,如下示例程序是bcc程序訪問結構體的方法:
pid_t pid = task->pid;
實際上,bcc對這段程序進行改寫,設置BPF(debug=4)可以看到被bcc重寫過后的代碼,這會給人一種誤導,誤以為實際的這種形式就是正確的結構訪問。bcc使用了bpf_probe_read
函數對上面這行代碼進行了改寫,示例程序改寫過后的代碼如下:
pid_t pid = ({
typeof(pid_t) _val;
__builtin_memset(&_val, 0, sizeof(_val));
bpf_probe_read(&_val, sizeof(_val), (u64) &task->pid);
_val;
});
Note: 對于bcc其他相關 debug 參數參考bcc reference-guide
BPF CO-RE引入了一個新的函數bpf_core_read
,用法與bpf_probe_read
,只不過它會針對內核的版本做兼容性的調整(比如說pid
在不同的內核版本所處的偏移量不同)。該函數只是一個宏,它的內部還是調用了bpf_probe_read
,下面是示例:
pid_t pid; bpf_core_read(&pid, sizeof(pid), &task->pid);
該函數的源碼如下,位于bpf_core_read.h
如果本機上沒有該頭文件,需要手動安裝libbpf。
#define bpf_core_read(dst, sz, src) \
bpf_probe_read_kernel(dst, sz, (const void *)__builtin_preserve_access_index(src))
不過無論是使用原生的bpf helper系列函數,還是使用bpf_core_read
,面對鏈式讀取的場景就顯得十分麻煩。如將 eBPF 程序掛載到vfs_open
函數,讀取被打開的文件名,使用原生的 API 程序如下:
SEC("kprobe/vfs_open")
int kprobe_func(struct pt_regs *ctx)
{
u64 pid = bpf_get_current_pid_tgid();
struct path *p = (struct path*)PT_REGS_PARM1(ctx);
struct dentry *de;
bpf_probe_read_kernel(&de,sizeof(void*),&p->dentry);
struct qstr d_name;
bpf_probe_read_kernel(&d_name,sizeof(d_name),&de->d_name);
char filename[32];
bpf_probe_read_kernel(&filename,sizeof(filename),d_name.name);
if (d_name.len == 0)
return 0;
char fmt_str[] = "path:%s";
bpf_trace_printk(fmt_str,sizeof(fmt_str),filename);
return 0;
};
可以看到大部分的程序篇幅都在調用bpf_probe_read_kernel
,CO-RE 提供了一個宏,對這個過程進一步地封裝,那么這個程序就可以簡化為:
SEC("kprobe/vfs_open")
int BPF_KPROBE(vfs_open, const struct path *path, struct file *file)
{
pid_t pid;
pid = bpf_get_current_pid_tgid() >> 32;
const unsigned char *filename;
// 一行語句就實現了鏈式的讀取
filename = BPF_CORE_READ(path,dentry,d_name.name);
bpf_printk("KPROBE ENTRY pid = %d, filename = %s\n", pid, filename);
return 0;
}
相類似的還有一些宏BPF_CORE_READ_STR_INTO
,BPF_CORE_READ_INTO
大差不差,看博客里邊的講解就行。值得一提的是,最近的內核版本中新增的
一種 eBPF程序類型:BPF_PROG_TYPE_TRACING
,它支持如同c語言語法那樣直接訪問結構體成員。
處理結構體問題
回到最開始的例子,我們提到過struct task_struct
內的state
成員新一點的內核版本已經被重命名為__state
(在21年被合并進了內核),那么如何編寫一個使用到該成員的程序并且能夠在不同的內核版本兼容就是一個值得關注的問題。BPF CO-RE提供了ignored suffix rule
的功能,它的作用是對于任何的符號只要包含著三下劃線,那么下劃線以及它所有的字符都會被忽略。我們定義一個struct task_struct___my_own
對于 BPF CO-RE而言,這完全地等價于struct task_struct
。這是一個十分重要的特性意味著我們可以通過定義不同類型的結構體來處理不同版本的內核兼容問題,示例如下:
struct task_struct {
pid_t pid;
int __state;
} __attribute__((preserve_access_index));
// 兼容老版本內核
struct task_struct___old {
pid_t pid;
long state;
} __attribute__((preserve_access_index));
Note: 在后文會對__attribute__((preserve_access_index))
介紹。
我們定義了兩個結構體分別作用于新版本與老版本的內核,在結合BPF CO-RE提供的另外一個函數bpf_core_field_exists
用于判斷某個結構體內是否具有某個成員,如下語句就就很好的處理了兼容問題:
struct task_struct *prev = (struct task_struct*)PT_REGS_PARM1(ctx);
unsigned int state;
if (bpf_core_field_exists(prev->__state)) {
state = BPF_CORE_READ(prev, __state);
} else {
// ___old 這幾個字符會被 libbpf 忽略,它實際等同于struct task_struct
// 這就解決了不同版本的內核兼容性問題。
struct task_struct___old *t_old = (void *)prev;
state = BPF_CORE_READ(t_old, state);
}
我們使用ebpf-go重寫了bcc-runqlat就用到了該特性,很好地解決了我們線上集群當中內核版本較低不兼容問題。
判斷內核版本
另外一種解決辦法是手動判斷內核版本,針對不同的內核版本做處理。在BPF CO-RE 之前據我所知沒有這方便的支持,不過該功能沒有實際的在項目中使用,在此只是拋磚引玉地做介紹。BPF CO-RE 提供了extern Kconfig variables
, 能夠讀取一些位于/proc/config.gz
內的kconfig 配置并且在ebpf程序當中使用,如下的示例讀取了內核版本信息,那么也可以根據不同的內核版本做對應的處理,遺憾的是目前 ebpf-go 不支持這一功能。
extern int LINUX_KERNEL_VERSION __kconfig;
if (LINUX_KERNEL_VERSION > KERNEL_VERSION(5, 15, 0)) {
/* we are on v5.15+ */
}
Note:
該功能與ebpf-go的結合并不是很好,會出現如下錯誤:
can't load BPF from ELF: load BTF: reference to .kconfig: not supported
issue有人反饋了該問題,不過社區一直沒有支持,有一個最近的PR提交對LINUX_KERNEL_VERSION
支持,cilium社區對應支持kconfig的意見是它的移植性不好,因為在debian系列的內核中沒有/proc/config.gz
文件,而是位于/boot/config-$(uname-r)
,可以參考這issue。
自定義結構體
在前文-遠離頭文件-一小節介紹過有BTF支持下的內核可以產生一個大型頭文件囊括了所有內核數據結構,問題是在低版本機器上都沒有 BTF 支持,那么如何使用各種頭文件呢? 最笨的方法是從所需的內核源碼中逐個復制結構體,然而內核結構體往往層級嵌套很深,這種方法不切實際。另外一種方法是直接下載當前內核版本所對應的頭文件然后手動的鏈接(bcc就是這種做法),不過在實踐中我們發現手動的鏈接無法通過編譯。
這一切有了 BPF CO-RE都迎刃而解,無論我們需要什么結構體,只要秉持著我要什么,就定義什么,BPF CO-RE 會處理好各個結構體的重定位。以獲取vfs_read
的文件名為例,vfs_read
的源碼第一個參數struct file
包含著文件名,路徑為struct file -> struct path -> struct dentry -> struct qstr
,只需要使用一個編譯器的 attribute 就可以實現要啥就定義啥,結構體的源碼定義如下:
struct qstr {
union {
struct {
u32 hash;
u32 len;
};
u64 hash_len;
};
const unsigned char *name;
}
struct dentry {
struct qstr d_name;
}__attribute__((preserve_access_index));
struct path {
struct dentry *dentry;
}__attribute__((preserve_access_index));
struct file {
struct path f_path;
}__attribute__((preserve_access_index));
這一切所有的魔法都在于__attribute__((preserve_access_index))
,該attribute解釋可以參考文檔,簡單來說它讓編譯器期間保留了被修飾的結構體的debuginfo,讓 BPF CO-RE 可以完成對所需結構體的重定位,這也依賴于 BTF 的支持。實際上對于BPF_CORE_READ
這些宏調用來說,自己定義的結構體也不需要使用這個attribute,這個宏的源碼實際調用的是bpf_core_read
函數,而它的源碼中使用這個特性,如下:
#define bpf_core_read(dst, sz, src) \
bpf_probe_read_kernel(dst, sz, (const void *)__builtin_preserve_access_index(src))
一個十分hack的技巧:
我們知道CPU訪問結構體成員的方式就是簡單的首地址+offset,而BPF CO-RE 的實現上也是記錄所需結構體成員偏移量是多少,也因此只需定義我們關注的結構體成員加上__attribute__((preserve_access_index));
就可以工作。那么很自然的,如果沒有這個attribuet,我們認為地在結構體內進行字節的填充,也可以達到類似的效果,上一小節的struct qstr
我們其實只關注的它的*name
,前面8個字節并不重要,搖身一變可以使用手工地填充8個字節達到相同的效果,如下:
struct qstr {
char padding[8]; // 人為填充 8 個字節
const unsigned char *name;
};
BTFhub
歸根到底,BPF CO-RE 十分依賴于 BTF,而老版本的內核甚至一些5.x版本內核在build的時候如果沒有指定CONFIG_DEBUG_BTF=y
內核都沒有 BTF 的支持。BTFhub的出現解決了這個問題,除此以外它還能夠對 BTF 文件進行剪裁,讓它只包含我們所需要的結構體所相關的debuginfo。btfhub的使用也相當簡單,參考文檔。它還依賴于BTF archive,該 repo 就是將很多低版本內核的 BTF 文件制作好了,使用btfhub/tools/btfgen.sh
腳本將這些 BTF 文件按需裁剪,命令如下:
./tools/btfgen.sh -a x86_64 -o foo.o
foo.o
就是C語言編寫的 eBPF 程序經過//go genrate
預處理指令編譯過后的.o
文件,裁剪過后的btf文件會位于btfhub/custom-archive
目錄內。值得一提的是,repo中的btfgen.sh
會將btfarchive倉庫內所有的 BTF 都進行裁剪,這個過程十分耗時,我們對 btfgen.sh 做了一點修改,可以指定發行版來裁剪 BTF 文件加快這個過程,實現思路就是在腳本內判斷此時被裁剪的BTF路徑是否包含我們所期望的發行版,關鍵代碼如下:
# 使用方法: /tools/btfgen.sh -a x86_64 -r debian -o foo.o
while getopts ":a:o:r:" opt; do
case "${opt}" in
a)
a=${OPTARG}
[[ "${a}" != "x86_64" && "${a}" != "arm64" ]] && usage
;;
o)
[[ ! -f ${OPTARG} ]] && { echo "error: could not find bpf object: ${OPTARG}"; usage; }
o+=("${OPTARG}")
;;
r) # 給 btfgen.sh 新增加了一個
echo "target release: ${OPTARG}"
r=${OPTARG}
;;
*)
usage
;;
esac
done
# 省略一些代碼
# $file 是 btf 全文件名,只要判斷它是否包含所期望的發行版名稱即可
if [[ ! $file =~ "${r}" ]];then
continue
fi
# 省略部分代碼
值得一提的是,實際開發中制作 BTF 文件可以一次性的傳入多個.o
文件,也就是:btfgen.sh -a x86_64 -r debian -o foo.o -o bar.o
。對于btfgen的原理參考這篇文檔。
ebpf-go 的使用心得
雖然本篇文章的主要目的是介紹如何使用 BPF CO-RE,不過我仍然認為在開發 eBPF 監控組件過程中遇到的一些問題是有借鑒意義,另外由于 ebpf-go 的文檔缺失也不可避免的帶來一些學習上的成本。
如何判讀 BTF 文件是否有效
使用 BPF hub 生成的 btf 文件驗證,可以使用bpftool btf dump
命令來驗證,示例如下:
$ bpftool btf dump file 4.19.0-21-amd64.btf
# 省略一些內容
[7] STRUCT 'task_struct' size=7104 vlen=3
'state' type_id=4 bits_offset=128
'pid' type_id=6 bits_offset=9792
'nsproxy' type_id=8 bits_offset=13824
[8] PTR '(anon)' type_id=9
[9] STRUCT 'nsproxy' size=56 vlen=1
'mnt_ns' type_id=10 bits_offset=192
[10] PTR '(anon)' type_id=12
[11] STRUCT 'ns_common' size=24 vlen=1
'inum' type_id=1 bits_offset=128
[12] STRUCT 'mnt_namespace' size=120 vlen=1
'ns' type_id=11 bits_offset=64
我實際的 eBPF 程序當中所定義的 struct task_struct
只包含了state,pid,nsproxy
三個結構體這也在上面的數據得到了反映。
使用自定的 BTF
前文介紹的 BPF hub 能為低版本的內核生成對應的 BTF 文件,不過對于如何在 ebpf-go 當中使用自定的 BTF 文件文檔中并沒有提及。在ebpfgo源碼的prog.go
內有相關的信息,ProgramOptions
結構體內的KernelTypes
就是用于傳遞自定 BTF 相關的內容。測試代碼prog_test.go展示了用法。總結一下在實際開發中,如下這段代碼就是標準的加載自定的 BTF 過程:
spec, err := loadBpf()
if err != nil {
log.Fatalf("loading BPF error %v", err)
}
var options *ebpf.CollectionOptions
// 自定的 BTF 文件路徑嗎,由 BTFHub 內的工具制作而成
btfSpec, err := btf.LoadSpec("/root/4.19.0-18-amd64.btf")
if err != nil {
log.Errorln(err)
return
}
options = &ebpf.CollectionOptions{Programs: ebpf.ProgramOptions{KernelTypes: btfSpec}}
if err = spec.LoadAndAssign(&objs, options); err != nil {
log.Fatalf("loading objects error %v", err)
}
defer objs.Close()
Note: 雖然未找到官方的文檔描述,不過實際開發中發現可以使用vmlinux.h
+自定義 BTF 文件的實現所有的結構結構體引用,不在需要自己手動的定義結構體。
變量替換
對于所監控的指標,我們往往期望它是更加動態的,可以在運行時指定的或者是以配置文件的形式傳入。在bcc當中有相當多的例子都是在進行時進行字符替換,如runqslower.py,它用于發現那些處于調度隊列中太久的進程,變量min_us
是等待時間的閾值,其中關鍵代碼如下:
# ebpf 程序, 省略了一些代碼
delta_us = (bpf_ktime_get_ns() - *tsp) / 1000;
if (FILTER_US)
return 0;
# 省略了一些代碼
# FILTER_US 將會被替換
if min_us == 0:
bpf_text = bpf_text.replace('FILTER_US', '0')
else:
bpf_text = bpf_text.replace('FILTER_US', 'delta_us <= %s' % str(min_us))
因為 ebpf-go 本身的實現方式(eBPF 程序需要經過靜態編譯再被加載),進行變量替換不能像bcc那樣簡便,這里介紹兩種方式的變量替換:
一、使用RewriteConst函數
它的主要用法是在 eBPF C 程序中以 volatile const
定義變量,在包含著 eBPF 程序被加載以后進行運行時的重寫。下面是監控vfd_read
函數的調用時長是否超過了預設的閾值示例代碼:
// 這個值會被重寫
const volatile u64 latency_thresh;
SEC("kprobe/vfs_read")
static int kprobe_vfs_read(struct pt_regs *ctx) {
u64 ts = bpf_ktime_get_ns();
u64 pid = bpf_get_current_pid_tgid();
bpf_map_update_elem(&start_map,&pid,&ts,BPF_ANY);
return 0;
}
SEC("kretprobe/vfs_read")
int kretprobe_vfs_read(struct pt_regs *ctx) {
// 從 map 當中取得進入到 vfs_read 的時間戳
u64 pid = bpf_get_current_pid_tgid();
u64 *tsp = bpf_map_lookup_elem(&start_map,&pid);
if (tsp == 0) {
return 0;
}
// tsp 是進入到 vfs_read 的時間戳,使用 kprobe/vfs_read 記錄到 map
u64 latency = bpf_ktime_get_ns() - *tsp;
if (latency > latency_thresh) {
// 進行數據的采集
}
}
在 ebpf-go 的程序中對latency_thresh
進行重寫,下面的示例程序將閾值設置為了 1ms。
// 內核時間以 ns 為單位,將 ms 轉為 ns
thresh := time.Millisecond.Nanoseconds()
spec, err := loadBpf()
if err != nil {
log.Fatalf("load bpf error %v", err)
}
consts := map[string]interface{}{
"latency_thresh": thresh.String(),
}
if err = spec.RewriteConstants(consts); err != nil {
log.Fatalf("RewriteConstants error:%v", err)
}
var objs = bpfObjects{}
if err = spec.LoadAndAssign(&objs, nil); err != nil {
log.Fatalf("loading objects error %v", err)
}
對于該函數的使用可以參考文檔和issue。注意,該方法只能在5.2+的內核可以使用,對于低版本內核使用該操作會提示如下類似的錯誤信息:
map .rodata: map create: read- and write-only maps not supported (requires >= v5.2)
確實,大部分的 eBPF 程序的錯誤信息都很不直觀。該錯誤的原因是對于全局變量(global constant)clang會在編譯期間將這些變量放到.rodata
這個節(ELF section),libbpf 會將這個節的數據寫入到一個.rodata
的map當中并且在正式被加載到內核之前被重寫,不過這個功能在 linux 5.2 內核被引入。對于該問題的討論參考以下三個鏈接:
https://github.com/cilium/ebpf/discussions/592
https://arthurchiao.art/blog/bpf-advanced-notes-4-zh/
https://nakryiko.com/posts/bpf-tips-printk/
二、通過內聯匯編
這種方式比較 hack,通過內聯匯編在 ELF 文件的符號表內寫入了一個符號,然后在 ebpf-go 程序內進行變量重寫。示例代碼如下:
u64 latency_thresh;
asm("%0 = thresh ll" : "=r"(latency_thresh));
該代碼會在 ELF 文件的符號表內插入一個名為thresh
的符號,這行代碼的意思是,將thresh
的賦值給latency_thresh
,即相當于latency_tresh=thresh
,那么我們要做的就是重寫thresh
的值即可。
查看 ELF 文件的符號表,確實內聯匯編插入了一個新的符號。
# 省略了一些輸出
13: 0000000000000000 504 FUNC GLOBAL DEFAULT 7 kprobe_finish_ta[...]
14: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND thresh # 內聯匯編插入的符號
15: 0000000000000014 20 OBJECT GLOBAL DEFAULT 10 latency_map
16: 0000000000000000 13 OBJECT GLOBAL DEFAULT 9 LICENSE
17: 0000000000000000 8 OBJECT GLOBAL DEFAULT 11 unused_data_t
下面要做的事情就是變量替換,ebpf-go 會將所要掛載的 eBPF 程序抽象為 ProgramSpec
結構體,它內部有一個Name
成員對應于 eBPF 程序的函數名,所以我們只需要遍歷所有要掛載的 eBPF 程序,按需重寫變量。還是以前面的 vfs_read
函數為例,需要對kretprobe_vfs_read
函數內的latency_thresh
進行重寫,ebpf-go中關鍵部分程序如下。
for _, prog := range spec.Programs {
if prog.Name == "kretprobe_vfs_read" { // 選擇在哪個程序內進行變量重寫
for i, ins := range spec.Programs[prog.Name].Instructions {
if ins.Reference() == "thresh" {
// 內核時間是 ns,閾值為 1ms,要將 ms 轉為 ns
spec.Programs[prog.Name].Instructions[i].Constant = time.Millisecond.Nanoseconds()
spec.Programs[prog.Name].Instructions[i].Offset = 0
}
}
}
}
eBPF 程序的debug
對于 eBPF 程序的 debug 是相當麻煩的,在這里我們演示一個訪存錯誤的例子,在 eBPF 內使用未初始化來作為 map 的 value 是不允許的,還是以 vfs_read
為例,我們記錄每一個執行vfs_read
的時間戳并且記錄到map,如下:
static int trace_enter(struct pt_regs *ctx) {
u64 pid = bpf_get_current_pid_tgid();
u64 ts = bpf_ktime_get_ns();
struct data_t data; // 沒有初始化,只是聲明,這種寫法是不被允許的
// struct data_t data = {}; 正確的寫法
data.pid = pid >> 32;
data.ts = ts;
bpf_get_current_comm(&data.comm,sizeof(data.comm));
bpf_map_update_elem(&start_map, &pid, &data, BPF_ANY);
return 0;
}
運行后程序提示如下的錯誤信息:
invalid indirect read from stack R3 off -40+29 size 32
至于為什么內核不允許這樣做,參考這里。顯然這樣的提示信息對于如何發覺程序問題出在哪毫無用處,好在 ebpf-go 提供了方法能夠輸出 eBPF 的校驗日志,代碼如下:
objs := bpfObjects{}
if err := loadBpfObjects(&objs, nil); err != nil {
var ve *ebpf.VerifierError
// 輸出校驗日志,
if errors.As(err, &ve) {
fmt.Printf("Verifier error: %+v\n", ve)
}
log.Fatalf("loading objects: %v", err)
}
defer objs.Close()
運行帶有bug的程序,terminal 會輸出校驗日志:
; bpf_map_update_elem(&start_map, &pid, &data, BPF_ANY);
17: (18) r1 = 0xffff8948118b5800
19: (b7) r4 = 0
20: (85) call bpf_map_update_elem#2
invalid indirect read from stack R3 off -40+29 size 32
結果表明該bug的問題出現在bpf_map_update_elem
這行代碼中,結合前面的描述 eBPF 不允許 map 的 value 是未初始化的結構體,這一切恰好對應。相類似的問題還會出現在結構體字節對齊的問題當中,這個可以參考cilium文檔描述。
對于 Debug 的討論: https://github.com/cilium/ebpf/discussions/838
官方文檔: https://pkg.go.dev/github.com/cilium/ebpf#example-Program-VerifierError
如何正確的操作 Map
參考了一些開源項目的寫法,他們使用 ebpf-go 讀取 eBPF map 的偽代碼如下,使用定時器來以某個時間間隔的讀取。
for {
select {
case <- timer expier:
return
case <- ticker:
readMapData()
}
}
因此每一輪間隔結束后都會對整個 map 重新遍歷,那么就會讀取到重復的數據。當然會想到在遍歷的過程中,然而直接在遍歷過程中刪除數據是不安全的,這一點不同于go原生的map。所以可行的方法是,遍歷過程中記錄本輪所迭代的key,遍歷完了再刪除。示例代碼如下:
var iter = objs.TsMap.Iterate()
var keys []uint64 // 記錄已經遍歷過的entry的key
var key uint64
var val Data // 結構體,用于
for iter.Next(&key, &val) {
// 注意: 不能再Iterator當中刪除entry,這是不安全的
keys = append(keys, key)
fmt.Println(val.Pid, val.Latency, string(val.Comm[:]))
}
// 在本輪迭代中,將遍歷過的數據從map中刪除。
for _, k := range keys {
err = objs.TsMap.Delete(&k)
if err != nil {
log.Errorln(err)
}
}
對于遍歷 map 的過程中刪除元素是不安全的描述查看文檔: https://pkg.go.dev/github.com/cilium/ebpf#MapIterator.Next
從 C 結構體到 Go 結構體
在 ebpf-go 的底層使用的是反射來將 eBPF map內的數據轉為 go 結構體(所以如果手動定義結構體務必要保證Go結構體的成員是大寫字母開頭)。因此我們在定義的時候也必須保證 Go 結構體定義順序、成員字節數與 eBPF 程序內結構體是完全一致的。但是手動定義結構體存在一些問題,會導致結構體成員所反射出來的結果是錯誤的,為此我向 ebpf-go的作者提了相關issue,具體的整個過程可以查看 issue,限于篇幅只闡述這個問題的基本表現:
// c 結構體
struct data_t {
u32 pid;
u64 latency;
char comm[16];
};
// go 結構體,會導致有bug
type Data struct {
Pid uint32
Latency uint64
Comm [16]uint8
}
這兩個結構體定義會讓 Go 程序所得到的 Latency
成員是不正確的值。該問題歸根到底的原因是編譯器對c結構體的字節填充以及ebpf-go將c結構體反射為Go結構體并不是簡單的memcpy,它使用的是go-binary庫。為了徹底地避免這個問題,可以使用 ebpf-go 的-type
參數,它的作用就是根據 c 結構體自動地生成一個 Go 結構體,它會負責處理好必要的字節填充問題,這一用法參考這個例子。注意,該例子內的這行語句是必須的:
struct rtt_event *unused_event __attribute__((unused));
否則不能生成所需的 Go 結構體。此外,相類似的用例還可以參考這里。
參考資料
eBPF 的系統性資料相對零散、復雜,下面是我個人在學習當中認為相當不錯的參考文檔。
https://github.com/iovisor/bcc/blob/master/docs/kernel-versions.md eBPF 的某些特性都是隨著內核的變化而變化的,這個文檔列出了各種特性在哪個版本被加入到內核。
https://github.com/iovisor/bcc/blob/master/docs/reference_guide.md bcc 倉庫的 API 文檔,相較于 man page來說好懂一點。
https://github.com/iovisor/bcc/blob/master/docs/tutorial_bcc_python_developer.md 入門 eBPF 很好的一個教程
https://arthurchiao.art/index.html 個人博客,有相當多的 eBPF 文章,寫的很不錯
https://nakryiko.com/ BPF CO-RE 核心開發者的個人博客
https://docs.cilium.io/en/latest/bpf/ cilium文檔,詳盡的對bpf做了描述
https://elixir.bootlin.com/linux/latest/source/samples/bpf Linux 內核中 ebpf 程序的示例代碼
https://www.brendangregg.com/ bcc 的核心開發者之一 Brendan Gregg的博客
https://www.ebpf.top/ 一個關于 ebpf 的中文網站
https://github.com/cilium/ebpf ebpf-go,相較于其他的go實現,這個做的最好,社區活躍度也高
https://man7.org/linux/man-pages/man2/bpf.2.html man page,內容大而全就是有些晦澀,擇需參考
https://libbpf.readthedocs.io/en/latest/api.html libbpf 的 API 文檔
總結
本文先對 BPF CO-RE 做了基本介紹,描述了什么是 BPF CO-RE 和 它的一些使用樣例以及簡單地描述了背后的原理。還介紹了如何使用 BTFHub 解決低版本內核不支持 BTF 的方案。雖然的出發點是關注于 BPF CO-RE,但是我們使用 ebpf-go 開發監控組件的過程中還是遇到了一些開發上的問題,我們將所遇到的問題、解決辦法都一并地分享了出來。
最后,eBPF 是一個相對較新并且仍在快速變化的技術,限于我的個人知識面未能完全的將所有的內容都一一解釋清楚,對于文中相關內容所存在的任何問題、技術上的誤解,歡迎大家反饋、討論。