本文作者:strickland
本文鏈接:https://www.strickland.cloud/post/1
版權(quán)聲明:本博客所有文章除特別聲明外鼠哥,均采用 BY-NC-SA 許可協(xié)議。轉(zhuǎn)載請注明出處呈础!
在最近這一段時(shí)間粗恢,我們使用 eBPF 實(shí)現(xiàn)了一些監(jiān)控組件,在這過程中因?yàn)?eBPF 的一些兼容性遇到不少問題在最近這一段時(shí)間,我們使用 eBPF 實(shí)現(xiàn)了一些監(jiān)控組件,在這過程中因?yàn)?eBPF 的一些兼容性遇到不少問題考蕾。包括頭文件的引入、低版本內(nèi)核使用 BTF 等棵癣,同時(shí)我們沒用使用 bcc 來作為 eBPF 前端辕翰,而是使用了cilium/ebpf(下文簡稱ebpf-go),它提供了一層 Go 的接口來操作 eBPF 程序,不過因?yàn)樵擁?xiàng)目和 repo 內(nèi)示例程序較為簡單狈谊,在實(shí)際開發(fā)中還是遇到了一些編程上的問題。本篇的主要是圍繞著 BPF CO-RE的相關(guān)介紹,并且將我們在實(shí)際開發(fā)中使用 BPF CO-RE 和 ebpf-go相關(guān)的問題以及解決辦法分享出來河劝。
限于篇幅壁榕,本篇文章并不是 eBPF 的入門文章,為了能夠徹底理解文章的內(nèi)容赎瞎,讀者最好有 eBPF牌里、bcc、ebpf-go 的基本使用經(jīng)驗(yàn)务甥。
為什么需要 BPF CO-RE
雖然本篇的內(nèi)容是介紹 BPF CO-RE牡辽,但是不對ebpf-go的使用方式做簡要介紹,就不容易理解為什么需要使用 CO-RE來解決一些痛點(diǎn)問題敞临。ebpf-go的作用就是提供一個(gè)可編程的Go 語言接口态辛,提供操作 eBPF 程序的基本函數(shù): 掛載、釋放挺尿,對 eBPF map進(jìn)行增刪改查等奏黑。
它的基本使用方法是讓程序員編寫好一段純的 eBPF 程序,ebpf-go會負(fù)責(zé)對這段程序的翻譯编矾,能夠讓我們使用 Go 進(jìn)行 eBPF 程序的掛載和 map 數(shù)據(jù)的處理熟史。下面是一個(gè)簡單的示例,參考自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;
}
上面這段程序的插樁點(diǎn)在sys_execve
窄俏,作用是記錄sys_execve
被調(diào)用的次數(shù)蹂匹。不過SEC("kprobe/sys_execve")
只是一個(gè)提示性的宏,實(shí)際的函數(shù)掛載位于下面這段程序凹蜈。實(shí)際使用中需要將上面這段ebpf程序編譯為.o
文件怒详,然后被下面程序中的loadBpfObjects
函數(shù)加載到內(nèi)核,臟活累活 ebpf-go都做完了踪区。//go:generate
這行就就是預(yù)處理指令昆烁,將 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)
}
}
從這個(gè)例子來看缎岗,ebpf-go無法動(dòng)態(tài)的在程序運(yùn)行時(shí)進(jìn)行判斷某些條件是否成立静尼。比如說struct task_struct
結(jié)構(gòu)體內(nèi)部的state
在這個(gè)commit被重命名為了__state
,我們很難根據(jù)內(nèi)核版本去判斷是否有某個(gè)結(jié)構(gòu)體(不過確實(shí)libbpf后來也提供了判斷內(nèi)核版本的功能传泊,還有一些別的奇技淫巧也可以做鼠渺,不過使用BPF CO-RE有更好的實(shí)現(xiàn)方式)。甚至如果一個(gè)結(jié)構(gòu)體的成員被重命名眷细、被移除那么就會導(dǎo)致在本地開發(fā)的 eBPF 程序在線上環(huán)境無法使用拦盹。ebpf-go的實(shí)現(xiàn)是靜態(tài)的,而不像bcc那樣可以在運(yùn)行時(shí)期間進(jìn)行字符替換溪椎。
BPF CO-RE
現(xiàn)如今 eBPF 在很多方便都有它的身影,cilium普舆、skywalking恬口、bpftrace、pixie等等在各個(gè)方面上都發(fā)揮著作用沼侣,不過 eBPF 存在的缺陷是功能隨著內(nèi)核版本的更新而更新祖能,老版本內(nèi)核無法使用相當(dāng)多基于 eBPF 的組件,另外一個(gè)問題是 eBPF 程序的移植性較不好蛾洛,這一點(diǎn)已經(jīng)從上一小節(jié)得到了驗(yàn)證养铸,結(jié)構(gòu)體成員的更新、重命名等操作都會讓一個(gè) eBPF 到一個(gè)新的內(nèi)核版無法在使用轧膘。
BPF CO-RE(Compile Once – Run Everywhere)的目的是能夠讓 eBPF 程序有更好的可移植性钞螟,在多個(gè)不同版本的內(nèi)核之間可以做兼容。除了前面的示例以外谎碍,還可能存在結(jié)構(gòu)體成員的偏移量改變鳞滨、結(jié)構(gòu)體成員被移除、類型被改變等等兼容問題椿浓。那么太援,盡可能的讓bpf 程序有更好的兼容性,可以用tracepoint來替代kprobe扳碍,不過tracepoint缺陷是可以插樁的函數(shù)點(diǎn)相當(dāng)少提岔,另外一個(gè)可行的方法根據(jù)目標(biāo)內(nèi)核版本在運(yùn)行時(shí)做一些工作,這就是BCC所做的事情笋敞,不過BCC依賴于內(nèi)核的頭文件碱蒙,它需要機(jī)器上內(nèi)核版本所對應(yīng)的內(nèi)核頭文件是安裝的,而且bcc內(nèi)置了clang/llvm夯巷,這無疑也會增加一個(gè)程序所需的內(nèi)存(這一點(diǎn)是十分顯而易見的赛惩,使用bcc做好的鏡像比ebpf-core要大很多),bcc在運(yùn)行時(shí)進(jìn)行程序的編譯趁餐,加載bpf程序到內(nèi)核喷兼,所以程序的一點(diǎn)小問題都只能到運(yùn)行時(shí)才能發(fā)覺。
CO-RE的目的就是解決上述的問題后雷,CO-RE的實(shí)現(xiàn)依賴于BTF(BPF type formation) 它可以認(rèn)為是面向 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 在較新的內(nèi)核版本是默認(rèn)自帶的季惯,否則的話需要手動(dòng)的指定CONFIG_DEBUG_INFO_BTF=y
,在某些線上的低版本內(nèi)核當(dāng)中沒有BTF支持那么使用CO-RE就相當(dāng)棘手臀突,在后文我們將會介紹如何在低版本內(nèi)核使用BTF勉抓。除此以外,CO-RE的實(shí)現(xiàn)來依賴于Clang提供的一些結(jié)構(gòu)體重定位等輔助信息候学,libbpf會將btf與clang所提供的信息相結(jié)合完成整個(gè)重定位的過程藕筋。限于知識水平,對于這些更加底層的內(nèi)容了解的很少梳码,不過多展開隐圾。
本小節(jié)的不少例子都來自于這篇博客伍掀,作者是CO-RE項(xiàng)目的核心開發(fā)者,這里只是挑選了部分內(nèi)容作為示例翎承。
遠(yuǎn)離頭文件
如果有過編寫bcc腳本的經(jīng)驗(yàn)硕盹,就會有我要使用的結(jié)構(gòu)體到底在哪個(gè)頭文件這種問題符匾。如果內(nèi)核有 BTF 的支持1,那么可以根據(jù)當(dāng)前內(nèi)核版本生成一個(gè)囊括了所有結(jié)構(gòu)體的頭文件叨咖,不過它沒有所需要的宏,大部分宏的取值直接參考內(nèi)核頭文件手動(dòng)編寫。生成頭文件的命令如下2:
bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h
1: BTF 應(yīng)該是從linux 5.4開始默認(rèn)支持的啊胶,不過可以要驗(yàn)證當(dāng)前內(nèi)核是否具有BTF支持只需要查看/sys/kernel/btf
路徑是否存在甸各。
2: 該命令對 bpftool 的版本有要求,通過 apt 下載的 bpftool 可能版本較低焰坪,會提示 failed to load BTF from /sys/kernel/btf/vmlinux: Unknown error -4001
這樣類似的錯(cuò)誤趣倾,最好的方法是手動(dòng)編譯,參考bpftool repo某饰。
3: 對于沒有 BTF 支持的內(nèi)核如果使用vmlinux.h程序?qū)o法通過編譯儒恋。
讀取結(jié)構(gòu)體成員
在bcc當(dāng)中訪問結(jié)構(gòu)體的方法就如同普通的C語言程序那樣,如下示例程序是bcc程序訪問結(jié)構(gòu)體的方法:
pid_t pid = task->pid;
實(shí)際上黔漂,bcc對這段程序進(jìn)行改寫诫尽,設(shè)置BPF(debug=4)可以看到被bcc重寫過后的代碼,這會給人一種誤導(dǎo)炬守,誤以為實(shí)際的這種形式就是正確的結(jié)構(gòu)訪問牧嫉。bcc使用了bpf_probe_read
函數(shù)對上面這行代碼進(jìn)行了改寫,示例程序改寫過后的代碼如下:
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其他相關(guān) debug 參數(shù)參考bcc reference-guide
BPF CO-RE引入了一個(gè)新的函數(shù)bpf_core_read
,用法與bpf_probe_read
减途,只不過它會針對內(nèi)核的版本做兼容性的調(diào)整(比如說pid
在不同的內(nèi)核版本所處的偏移量不同)酣藻。該函數(shù)只是一個(gè)宏,它的內(nèi)部還是調(diào)用了bpf_probe_read
,下面是示例:
pid_t pid; bpf_core_read(&pid, sizeof(pid), &task->pid);
該函數(shù)的源碼如下,位于bpf_core_read.h
如果本機(jī)上沒有該頭文件鳍置,需要手動(dòng)安裝libbpf辽剧。
#define bpf_core_read(dst, sz, src) \
bpf_probe_read_kernel(dst, sz, (const void *)__builtin_preserve_access_index(src))
不過無論是使用原生的bpf helper系列函數(shù),還是使用bpf_core_read
税产,面對鏈?zhǔn)阶x取的場景就顯得十分麻煩怕轿。如將 eBPF 程序掛載到vfs_open
函數(shù),讀取被打開的文件名砖第,使用原生的 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;
};
可以看到大部分的程序篇幅都在調(diào)用bpf_probe_read_kernel
撤卢,CO-RE 提供了一個(gè)宏,對這個(gè)過程進(jìn)一步地封裝,那么這個(gè)程序就可以簡化為:
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;
// 一行語句就實(shí)現(xiàn)了鏈?zhǔn)降淖x取
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
大差不差梧兼,看博客里邊的講解就行放吩。值得一提的是,最近的內(nèi)核版本中新增的
一種 eBPF程序類型:BPF_PROG_TYPE_TRACING
羽杰,它支持如同c語言語法那樣直接訪問結(jié)構(gòu)體成員渡紫。
處理結(jié)構(gòu)體問題
回到最開始的例子到推,我們提到過struct task_struct
內(nèi)的state
成員新一點(diǎn)的內(nèi)核版本已經(jīng)被重命名為__state
(在21年被合并進(jìn)了內(nèi)核),那么如何編寫一個(gè)使用到該成員的程序并且能夠在不同的內(nèi)核版本兼容就是一個(gè)值得關(guān)注的問題惕澎。BPF CO-RE提供了ignored suffix rule
的功能莉测,它的作用是對于任何的符號只要包含著三下劃線,那么下劃線以及它所有的字符都會被忽略唧喉。我們定義一個(gè)struct task_struct___my_own
對于 BPF CO-RE而言捣卤,這完全地等價(jià)于struct task_struct
。這是一個(gè)十分重要的特性意味著我們可以通過定義不同類型的結(jié)構(gòu)體來處理不同版本的內(nèi)核兼容問題八孝,示例如下:
struct task_struct {
pid_t pid;
int __state;
} __attribute__((preserve_access_index));
// 兼容老版本內(nèi)核
struct task_struct___old {
pid_t pid;
long state;
} __attribute__((preserve_access_index));
Note: 在后文會對__attribute__((preserve_access_index))
介紹董朝。
我們定義了兩個(gè)結(jié)構(gòu)體分別作用于新版本與老版本的內(nèi)核,在結(jié)合BPF CO-RE提供的另外一個(gè)函數(shù)bpf_core_field_exists
用于判斷某個(gè)結(jié)構(gòu)體內(nèi)是否具有某個(gè)成員干跛,如下語句就就很好的處理了兼容問題:
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 這幾個(gè)字符會被 libbpf 忽略子姜,它實(shí)際等同于struct task_struct
// 這就解決了不同版本的內(nèi)核兼容性問題。
struct task_struct___old *t_old = (void *)prev;
state = BPF_CORE_READ(t_old, state);
}
我們使用ebpf-go重寫了bcc-runqlat就用到了該特性楼入,很好地解決了我們線上集群當(dāng)中內(nèi)核版本較低不兼容問題哥捕。
判斷內(nèi)核版本
另外一種解決辦法是手動(dòng)判斷內(nèi)核版本,針對不同的內(nèi)核版本做處理嘉熊。在BPF CO-RE 之前據(jù)我所知沒有這方便的支持遥赚,不過該功能沒有實(shí)際的在項(xiàng)目中使用,在此只是拋磚引玉地做介紹记舆。BPF CO-RE 提供了extern Kconfig variables
, 能夠讀取一些位于/proc/config.gz
內(nèi)的kconfig 配置并且在ebpf程序當(dāng)中使用鸽捻,如下的示例讀取了內(nèi)核版本信息,那么也可以根據(jù)不同的內(nèi)核版本做對應(yīng)的處理泽腮,遺憾的是目前 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的結(jié)合并不是很好,會出現(xiàn)如下錯(cuò)誤:
can't load BPF from ELF: load BTF: reference to .kconfig: not supported
issue有人反饋了該問題诊赊,不過社區(qū)一直沒有支持厚满,有一個(gè)最近的PR提交對LINUX_KERNEL_VERSION
支持,cilium社區(qū)對應(yīng)支持kconfig的意見是它的移植性不好碧磅,因?yàn)樵赿ebian系列的內(nèi)核中沒有/proc/config.gz
文件碘箍,而是位于/boot/config-$(uname-r)
,可以參考這issue鲸郊。
自定義結(jié)構(gòu)體
在前文-遠(yuǎn)離頭文件-一小節(jié)介紹過有BTF支持下的內(nèi)核可以產(chǎn)生一個(gè)大型頭文件囊括了所有內(nèi)核數(shù)據(jù)結(jié)構(gòu)丰榴,問題是在低版本機(jī)器上都沒有 BTF 支持,那么如何使用各種頭文件呢秆撮? 最笨的方法是從所需的內(nèi)核源碼中逐個(gè)復(fù)制結(jié)構(gòu)體四濒,然而內(nèi)核結(jié)構(gòu)體往往層級嵌套很深,這種方法不切實(shí)際。另外一種方法是直接下載當(dāng)前內(nèi)核版本所對應(yīng)的頭文件然后手動(dòng)的鏈接(bcc就是這種做法)盗蟆,不過在實(shí)踐中我們發(fā)現(xiàn)手動(dòng)的鏈接無法通過編譯戈二。
這一切有了 BPF CO-RE都迎刃而解,無論我們需要什么結(jié)構(gòu)體喳资,只要秉持著我要什么觉吭,就定義什么,BPF CO-RE 會處理好各個(gè)結(jié)構(gòu)體的重定位仆邓。以獲取vfs_read
的文件名為例鲜滩,vfs_read
的源碼第一個(gè)參數(shù)struct file
包含著文件名,路徑為struct file -> struct path -> struct dentry -> struct qstr
宏赘,只需要使用一個(gè)編譯器的 attribute 就可以實(shí)現(xiàn)要啥就定義啥绒北,結(jié)構(gòu)體的源碼定義如下:
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解釋可以參考文檔察署,簡單來說它讓編譯器期間保留了被修飾的結(jié)構(gòu)體的debuginfo,讓 BPF CO-RE 可以完成對所需結(jié)構(gòu)體的重定位峻汉,這也依賴于 BTF 的支持贴汪。實(shí)際上對于BPF_CORE_READ
這些宏調(diào)用來說,自己定義的結(jié)構(gòu)體也不需要使用這個(gè)attribute休吠,這個(gè)宏的源碼實(shí)際調(diào)用的是bpf_core_read
函數(shù)扳埂,而它的源碼中使用這個(gè)特性,如下:
#define bpf_core_read(dst, sz, src) \
bpf_probe_read_kernel(dst, sz, (const void *)__builtin_preserve_access_index(src))
一個(gè)十分hack的技巧:
我們知道CPU訪問結(jié)構(gòu)體成員的方式就是簡單的首地址+offset瘤礁,而BPF CO-RE 的實(shí)現(xiàn)上也是記錄所需結(jié)構(gòu)體成員偏移量是多少阳懂,也因此只需定義我們關(guān)注的結(jié)構(gòu)體成員加上__attribute__((preserve_access_index));
就可以工作。那么很自然的柜思,如果沒有這個(gè)attribuet岩调,我們認(rèn)為地在結(jié)構(gòu)體內(nèi)進(jìn)行字節(jié)的填充,也可以達(dá)到類似的效果赡盘,上一小節(jié)的struct qstr
我們其實(shí)只關(guān)注的它的*name
号枕,前面8個(gè)字節(jié)并不重要,搖身一變可以使用手工地填充8個(gè)字節(jié)達(dá)到相同的效果陨享,如下:
struct qstr {
char padding[8]; // 人為填充 8 個(gè)字節(jié)
const unsigned char *name;
};
BTFhub
歸根到底葱淳,BPF CO-RE 十分依賴于 BTF,而老版本的內(nèi)核甚至一些5.x版本內(nèi)核在build的時(shí)候如果沒有指定CONFIG_DEBUG_BTF=y
內(nèi)核都沒有 BTF 的支持抛姑。BTFhub的出現(xiàn)解決了這個(gè)問題赞厕,除此以外它還能夠?qū)?BTF 文件進(jìn)行剪裁,讓它只包含我們所需要的結(jié)構(gòu)體所相關(guān)的debuginfo定硝。btfhub的使用也相當(dāng)簡單皿桑,參考文檔。它還依賴于BTF archive,該 repo 就是將很多低版本內(nèi)核的 BTF 文件制作好了,使用btfhub/tools/btfgen.sh
腳本將這些 BTF 文件按需裁剪唁毒,命令如下:
./tools/btfgen.sh -a x86_64 -o foo.o
foo.o
就是C語言編寫的 eBPF 程序經(jīng)過//go genrate
預(yù)處理指令編譯過后的.o
文件蒜茴,裁剪過后的btf文件會位于btfhub/custom-archive
目錄內(nèi)。值得一提的是浆西,repo中的btfgen.sh
會將btfarchive倉庫內(nèi)所有的 BTF 都進(jìn)行裁剪粉私,這個(gè)過程十分耗時(shí),我們對 btfgen.sh 做了一點(diǎn)修改近零,可以指定發(fā)行版來裁剪 BTF 文件加快這個(gè)過程诺核,實(shí)現(xiàn)思路就是在腳本內(nèi)判斷此時(shí)被裁剪的BTF路徑是否包含我們所期望的發(fā)行版,關(guān)鍵代碼如下:
# 使用方法: /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 新增加了一個(gè)
echo "target release: ${OPTARG}"
r=${OPTARG}
;;
*)
usage
;;
esac
done
# 省略一些代碼
# $file 是 btf 全文件名久信,只要判斷它是否包含所期望的發(fā)行版名稱即可
if [[ ! $file =~ "${r}" ]];then
continue
fi
# 省略部分代碼
值得一提的是窖杀,實(shí)際開發(fā)中制作 BTF 文件可以一次性的傳入多個(gè).o
文件,也就是:btfgen.sh -a x86_64 -r debian -o foo.o -o bar.o
裙士。對于btfgen的原理參考這篇文檔入客。
ebpf-go 的使用心得
雖然本篇文章的主要目的是介紹如何使用 BPF CO-RE,不過我仍然認(rèn)為在開發(fā) eBPF 監(jiān)控組件過程中遇到的一些問題是有借鑒意義腿椎,另外由于 ebpf-go 的文檔缺失也不可避免的帶來一些學(xué)習(xí)上的成本桌硫。
如何判讀 BTF 文件是否有效
使用 BPF hub 生成的 btf 文件驗(yàn)證,可以使用bpftool btf dump
命令來驗(yàn)證啃炸,示例如下:
$ bpftool btf dump file 4.19.0-21-amd64.btf
# 省略一些內(nèi)容
[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
我實(shí)際的 eBPF 程序當(dāng)中所定義的 struct task_struct
只包含了state,pid,nsproxy
三個(gè)結(jié)構(gòu)體這也在上面的數(shù)據(jù)得到了反映铆隘。
使用自定的 BTF
前文介紹的 BPF hub 能為低版本的內(nèi)核生成對應(yīng)的 BTF 文件,不過對于如何在 ebpf-go 當(dāng)中使用自定的 BTF 文件文檔中并沒有提及南用。在ebpfgo源碼的prog.go
內(nèi)有相關(guān)的信息膀钠,ProgramOptions
結(jié)構(gòu)體內(nèi)的KernelTypes
就是用于傳遞自定 BTF 相關(guān)的內(nèi)容。測試代碼prog_test.go展示了用法裹虫≈壮埃總結(jié)一下在實(shí)際開發(fā)中,如下這段代碼就是標(biāo)準(zhǔn)的加載自定的 BTF 過程:
spec, err := loadBpf()
if err != nil {
log.Fatalf("loading BPF error %v", err)
}
var options *ebpf.CollectionOptions
// 自定的 BTF 文件路徑嗎恒界,由 BTFHub 內(nèi)的工具制作而成
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: 雖然未找到官方的文檔描述睦刃,不過實(shí)際開發(fā)中發(fā)現(xiàn)可以使用vmlinux.h
+自定義 BTF 文件的實(shí)現(xiàn)所有的結(jié)構(gòu)結(jié)構(gòu)體引用,不在需要自己手動(dòng)的定義結(jié)構(gòu)體十酣。
變量替換
對于所監(jiān)控的指標(biāo)涩拙,我們往往期望它是更加動(dòng)態(tài)的,可以在運(yùn)行時(shí)指定的或者是以配置文件的形式傳入耸采。在bcc當(dāng)中有相當(dāng)多的例子都是在進(jìn)行時(shí)進(jìn)行字符替換兴泥,如runqslower.py,它用于發(fā)現(xiàn)那些處于調(diào)度隊(duì)列中太久的進(jìn)程虾宇,變量min_us
是等待時(shí)間的閾值搓彻,其中關(guān)鍵代碼如下:
# 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))
因?yàn)?ebpf-go 本身的實(shí)現(xiàn)方式(eBPF 程序需要經(jīng)過靜態(tài)編譯再被加載),進(jìn)行變量替換不能像bcc那樣簡便旭贬,這里介紹兩種方式的變量替換:
一怔接、使用RewriteConst函數(shù)
它的主要用法是在 eBPF C 程序中以 volatile const
定義變量,在包含著 eBPF 程序被加載以后進(jìn)行運(yùn)行時(shí)的重寫稀轨。下面是監(jiān)控vfd_read
函數(shù)的調(diào)用時(shí)長是否超過了預(yù)設(shè)的閾值示例代碼:
// 這個(gè)值會被重寫
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 當(dāng)中取得進(jìn)入到 vfs_read 的時(shí)間戳
u64 pid = bpf_get_current_pid_tgid();
u64 *tsp = bpf_map_lookup_elem(&start_map,&pid);
if (tsp == 0) {
return 0;
}
// tsp 是進(jìn)入到 vfs_read 的時(shí)間戳扼脐,使用 kprobe/vfs_read 記錄到 map
u64 latency = bpf_ktime_get_ns() - *tsp;
if (latency > latency_thresh) {
// 進(jìn)行數(shù)據(jù)的采集
}
}
在 ebpf-go 的程序中對latency_thresh
進(jìn)行重寫,下面的示例程序?qū)㈤撝翟O(shè)置為了 1ms。
// 內(nèi)核時(shí)間以 ns 為單位奋刽,將 ms 轉(zhuǎn)為 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)
}
對于該函數(shù)的使用可以參考文檔和issue瓦侮。注意,該方法只能在5.2+的內(nèi)核可以使用佣谐,對于低版本內(nèi)核使用該操作會提示如下類似的錯(cuò)誤信息:
map .rodata: map create: read- and write-only maps not supported (requires >= v5.2)
確實(shí)肚吏,大部分的 eBPF 程序的錯(cuò)誤信息都很不直觀。該錯(cuò)誤的原因是對于全局變量(global constant)clang會在編譯期間將這些變量放到.rodata
這個(gè)節(jié)(ELF section)狭魂,libbpf 會將這個(gè)節(jié)的數(shù)據(jù)寫入到一個(gè).rodata
的map當(dāng)中并且在正式被加載到內(nèi)核之前被重寫罚攀,不過這個(gè)功能在 linux 5.2 內(nèi)核被引入。對于該問題的討論參考以下三個(gè)鏈接:
https://github.com/cilium/ebpf/discussions/592
https://arthurchiao.art/blog/bpf-advanced-notes-4-zh/
https://nakryiko.com/posts/bpf-tips-printk/
二趁蕊、通過內(nèi)聯(lián)匯編
這種方式比較 hack坞生,通過內(nèi)聯(lián)匯編在 ELF 文件的符號表內(nèi)寫入了一個(gè)符號,然后在 ebpf-go 程序內(nèi)進(jìn)行變量重寫掷伙。示例代碼如下:
u64 latency_thresh;
asm("%0 = thresh ll" : "=r"(latency_thresh));
該代碼會在 ELF 文件的符號表內(nèi)插入一個(gè)名為thresh
的符號,這行代碼的意思是又兵,將thresh
的賦值給latency_thresh
任柜,即相當(dāng)于latency_tresh=thresh
,那么我們要做的就是重寫thresh
的值即可沛厨。
查看 ELF 文件的符號表宙地,確實(shí)內(nèi)聯(lián)匯編插入了一個(gè)新的符號。
# 省略了一些輸出
13: 0000000000000000 504 FUNC GLOBAL DEFAULT 7 kprobe_finish_ta[...]
14: 0000000000000000 0 NOTYPE GLOBAL DEFAULT UND thresh # 內(nèi)聯(lián)匯編插入的符號
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
結(jié)構(gòu)體宅粥,它內(nèi)部有一個(gè)Name
成員對應(yīng)于 eBPF 程序的函數(shù)名,所以我們只需要遍歷所有要掛載的 eBPF 程序电谣,按需重寫變量。還是以前面的 vfs_read
函數(shù)為例,需要對kretprobe_vfs_read
函數(shù)內(nèi)的latency_thresh
進(jìn)行重寫丰涉,ebpf-go中關(guān)鍵部分程序如下颜价。
for _, prog := range spec.Programs {
if prog.Name == "kretprobe_vfs_read" { // 選擇在哪個(gè)程序內(nèi)進(jìn)行變量重寫
for i, ins := range spec.Programs[prog.Name].Instructions {
if ins.Reference() == "thresh" {
// 內(nèi)核時(shí)間是 ns,閾值為 1ms晒来,要將 ms 轉(zhuǎn)為 ns
spec.Programs[prog.Name].Instructions[i].Constant = time.Millisecond.Nanoseconds()
spec.Programs[prog.Name].Instructions[i].Offset = 0
}
}
}
}
eBPF 程序的debug
對于 eBPF 程序的 debug 是相當(dāng)麻煩的钞诡,在這里我們演示一個(gè)訪存錯(cuò)誤的例子,在 eBPF 內(nèi)使用未初始化來作為 map 的 value 是不允許的,還是以 vfs_read
為例荧降,我們記錄每一個(gè)執(zhí)行vfs_read
的時(shí)間戳并且記錄到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;
}
運(yùn)行后程序提示如下的錯(cuò)誤信息:
invalid indirect read from stack R3 off -40+29 size 32
至于為什么內(nèi)核不允許這樣做朵诫,參考這里列牺。顯然這樣的提示信息對于如何發(fā)覺程序問題出在哪毫無用處,好在 ebpf-go 提供了方法能夠輸出 eBPF 的校驗(yàn)日志拗窃,代碼如下:
objs := bpfObjects{}
if err := loadBpfObjects(&objs, nil); err != nil {
var ve *ebpf.VerifierError
// 輸出校驗(yàn)日志瞎领,
if errors.As(err, &ve) {
fmt.Printf("Verifier error: %+v\n", ve)
}
log.Fatalf("loading objects: %v", err)
}
defer objs.Close()
運(yùn)行帶有bug的程序,terminal 會輸出校驗(yàn)日志:
; 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
結(jié)果表明該bug的問題出現(xiàn)在bpf_map_update_elem
這行代碼中随夸,結(jié)合前面的描述 eBPF 不允許 map 的 value 是未初始化的結(jié)構(gòu)體九默,這一切恰好對應(yīng)。相類似的問題還會出現(xiàn)在結(jié)構(gòu)體字節(jié)對齊的問題當(dāng)中宾毒,這個(gè)可以參考cilium文檔描述驼修。
對于 Debug 的討論: https://github.com/cilium/ebpf/discussions/838
官方文檔: https://pkg.go.dev/github.com/cilium/ebpf#example-Program-VerifierError
如何正確的操作 Map
參考了一些開源項(xiàng)目的寫法,他們使用 ebpf-go 讀取 eBPF map 的偽代碼如下诈铛,使用定時(shí)器來以某個(gè)時(shí)間間隔的讀取乙各。
for {
select {
case <- timer expier:
return
case <- ticker:
readMapData()
}
}
因此每一輪間隔結(jié)束后都會對整個(gè) map 重新遍歷,那么就會讀取到重復(fù)的數(shù)據(jù)幢竹。當(dāng)然會想到在遍歷的過程中耳峦,然而直接在遍歷過程中刪除數(shù)據(jù)是不安全的,這一點(diǎn)不同于go原生的map。所以可行的方法是焕毫,遍歷過程中記錄本輪所迭代的key蹲坷,遍歷完了再刪除。示例代碼如下:
var iter = objs.TsMap.Iterate()
var keys []uint64 // 記錄已經(jīng)遍歷過的entry的key
var key uint64
var val Data // 結(jié)構(gòu)體邑飒,用于
for iter.Next(&key, &val) {
// 注意: 不能再Iterator當(dāng)中刪除entry循签,這是不安全的
keys = append(keys, key)
fmt.Println(val.Pid, val.Latency, string(val.Comm[:]))
}
// 在本輪迭代中,將遍歷過的數(shù)據(jù)從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 結(jié)構(gòu)體到 Go 結(jié)構(gòu)體
在 ebpf-go 的底層使用的是反射來將 eBPF map內(nèi)的數(shù)據(jù)轉(zhuǎn)為 go 結(jié)構(gòu)體(所以如果手動(dòng)定義結(jié)構(gòu)體務(wù)必要保證Go結(jié)構(gòu)體的成員是大寫字母開頭)县匠。因此我們在定義的時(shí)候也必須保證 Go 結(jié)構(gòu)體定義順序、成員字節(jié)數(shù)與 eBPF 程序內(nèi)結(jié)構(gòu)體是完全一致的撒轮。但是手動(dòng)定義結(jié)構(gòu)體存在一些問題乞旦,會導(dǎo)致結(jié)構(gòu)體成員所反射出來的結(jié)果是錯(cuò)誤的,為此我向 ebpf-go的作者提了相關(guān)issue腔召,具體的整個(gè)過程可以查看 issue杆查,限于篇幅只闡述這個(gè)問題的基本表現(xiàn):
// c 結(jié)構(gòu)體
struct data_t {
u32 pid;
u64 latency;
char comm[16];
};
// go 結(jié)構(gòu)體,會導(dǎo)致有bug
type Data struct {
Pid uint32
Latency uint64
Comm [16]uint8
}
這兩個(gè)結(jié)構(gòu)體定義會讓 Go 程序所得到的 Latency
成員是不正確的值臀蛛。該問題歸根到底的原因是編譯器對c結(jié)構(gòu)體的字節(jié)填充以及ebpf-go將c結(jié)構(gòu)體反射為Go結(jié)構(gòu)體并不是簡單的memcpy亲桦,它使用的是go-binary庫崖蜜。為了徹底地避免這個(gè)問題,可以使用 ebpf-go 的-type
參數(shù)客峭,它的作用就是根據(jù) c 結(jié)構(gòu)體自動(dòng)地生成一個(gè) Go 結(jié)構(gòu)體豫领,它會負(fù)責(zé)處理好必要的字節(jié)填充問題,這一用法參考這個(gè)例子舔琅。注意等恐,該例子內(nèi)的這行語句是必須的:
struct rtt_event *unused_event __attribute__((unused));
否則不能生成所需的 Go 結(jié)構(gòu)體。此外备蚓,相類似的用例還可以參考這里课蔬。
參考資料
eBPF 的系統(tǒng)性資料相對零散、復(fù)雜郊尝,下面是我個(gè)人在學(xué)習(xí)當(dāng)中認(rèn)為相當(dāng)不錯(cuò)的參考文檔二跋。
https://github.com/iovisor/bcc/blob/master/docs/kernel-versions.md eBPF 的某些特性都是隨著內(nèi)核的變化而變化的,這個(gè)文檔列出了各種特性在哪個(gè)版本被加入到內(nèi)核流昏。
https://github.com/iovisor/bcc/blob/master/docs/reference_guide.md bcc 倉庫的 API 文檔扎即,相較于 man page來說好懂一點(diǎn)。
https://github.com/iovisor/bcc/blob/master/docs/tutorial_bcc_python_developer.md 入門 eBPF 很好的一個(gè)教程
https://arthurchiao.art/index.html 個(gè)人博客况凉,有相當(dāng)多的 eBPF 文章谚鄙,寫的很不錯(cuò)
https://nakryiko.com/ BPF CO-RE 核心開發(fā)者的個(gè)人博客
https://docs.cilium.io/en/latest/bpf/ cilium文檔,詳盡的對bpf做了描述
https://elixir.bootlin.com/linux/latest/source/samples/bpf Linux 內(nèi)核中 ebpf 程序的示例代碼
https://www.brendangregg.com/ bcc 的核心開發(fā)者之一 Brendan Gregg的博客
https://www.ebpf.top/ 一個(gè)關(guān)于 ebpf 的中文網(wǎng)站
https://github.com/cilium/ebpf ebpf-go刁绒,相較于其他的go實(shí)現(xiàn)闷营,這個(gè)做的最好,社區(qū)活躍度也高
https://man7.org/linux/man-pages/man2/bpf.2.html man page,內(nèi)容大而全就是有些晦澀膛锭,擇需參考
https://libbpf.readthedocs.io/en/latest/api.html libbpf 的 API 文檔
總結(jié)
本文先對 BPF CO-RE 做了基本介紹粮坞,描述了什么是 BPF CO-RE 和 它的一些使用樣例以及簡單地描述了背后的原理。還介紹了如何使用 BTFHub 解決低版本內(nèi)核不支持 BTF 的方案初狰。雖然的出發(fā)點(diǎn)是關(guān)注于 BPF CO-RE,但是我們使用 ebpf-go 開發(fā)監(jiān)控組件的過程中還是遇到了一些開發(fā)上的問題互例,我們將所遇到的問題奢入、解決辦法都一并地分享了出來。
最后媳叨,eBPF 是一個(gè)相對較新并且仍在快速變化的技術(shù)腥光,限于我的個(gè)人知識面未能完全的將所有的內(nèi)容都一一解釋清楚,對于文中相關(guān)內(nèi)容所存在的任何問題糊秆、技術(shù)上的誤解武福,歡迎大家反饋、討論痘番。