一、講座內容簡要描述
本次講座內容分為兩部分:
當初學者接觸到Linux平臺的tracing系統時,常常被各種詞語弄得暈頭轉向:比如 Kprobe,Tracepoint,Linux Auditing subsystem(auditd),systemtap,LTTng,perf,trace-cmd,eBPF,bpftrace,BCC等等。初學者往往會有以下疑問:這些專業詞語是什么意思?它們之間有什么關系?每種tracing技術的優缺點是什么?應該選擇哪種技術?為什么eBPF從中脫穎而出,近年來得到廣泛關注?
本次講座嘗試從統一的視角來梳理和對比這些技術的異同點,并嘗試回答這些問題。
2)eBPF開發經驗分享
eBPF目前正在高速發展,很多坑和解決辦法缺乏官方文檔。本次講座主要介紹主講人在eBPF開發實踐中經常遇到的問題,包括開發框架的選擇,多內核版本兼容性問題,如何為低版本內核生成BTF文件,eBPF驗證機制與編譯器優化機制的不一致問題,eBPF在ARM架構遇到的問題等等。
二、Linux Tracing System淺析
對于Linux Tracing System尤其是目前最火的eBPF技術來說,主要是通過探針技術,實現特定事件的追蹤和采樣,達到增強內核行為可觀測性、優化系統性能、動態監測網絡和加固系統安全的目的。如下圖所示,我將Linux Tracing System細分為三個維度,包括1)數據源(內核態),負責提供數據地來源;2)Tracing框架(內核態),負責對接數據源,采集解析發送數據,并對用戶態提供接口;3)以及前端工具/庫(用戶態),對接Tracing內核框架,直接與用戶交互,負責采集配置和數據分析。
下面將從這三個維度自下而上地對Linux Tracing System進行梳理和分析。
1.1.數據源(內核態)介紹
如下圖所示,從數據提供方的角度來看,數據源可以分成硬件探針、軟件探針(又分為動態探針以及靜態探針),也就是獲取底層數據源的方式和手段。顧名思義,硬件探針技術就是通過在硬件設備上(比如芯片)插入探針,捕獲硬件層次行為;而軟件探針技術則是通過軟件的方式插入探針,捕獲軟件層次的行為。這些探針技術負責提供數據,上層的Tracing工具和框架則基于這些探針技術來采集數據,并對數據進一步整理、分析、和展現給用戶。
硬件探針
HPC: Hardware Performance Counter是CPU硬件提供的一種常見的數據源,如下圖所示,它能夠監控CPU級別的事件,比如執行的 指令數,跳轉指令數,Cache Miss等等,被廣泛應用于性能調試(Vtune, Perf)、攻擊檢測等等
1)HPC事件列表
2)HPC數據案例
對于此類硬件數據,我們通常使用用戶態工具perf來進行采集,下圖展示了一個具體的案例。
LBR: Last Branch RecordCPU硬件提供的另一種特性,它能夠記錄每條分支(跳轉)指令的源地址和目的地址。基于LBR硬件特性,可實現調用棧信息的記錄
在系統性能優化領域以及調試程序時經常使用的性能分析利器:火焰圖(Flame Graph)也可以基于LBR的數據生成,使用命令perf record -F 99 -a --call-graph lbr即可得到完整直觀的火焰圖數據。
火焰圖示例
軟件探針(靜態探針):
靜態內核探針指的是在內核運行之前,在內核源代碼或者二進制中插入預先設置好的鉤子函數,內核運行時觸發生效的探針方案。
Tracepoint:Tracepoint是一種典型的靜態探針。它通過在內核源代碼中插入預先定義的靜態鉤子函數來實現內核行為的監控。簡單地來看,大家可以把Tracepoint的原理等同于調試程序時加入的printf函數。
下圖展示了2012年,內核引入sched_process_execTracepoint時的commit,可以看到,首先用TRACE_EVENT宏定義了新增Tracepoint的名字和參數等信息,然后在內核函數exec_binprm的源代碼中加入了鉤子函數trace_sched_process_exec。每當程序執行二進制時,都會觸發exec_binprm函數,繼而觸發trace_sched_process_exec鉤子函數。Tracing工具和框架將自定義的函數掛載到該鉤子函數上,來采集程序執行行為日志。
靜態探針的優點:
穩定(內核開發者會負責維護該函數的穩定性)
性能好
靜態探針的缺點
需要修改內核代碼來添加新的靜態探針
內核支持的靜態探針數量有限
軟件探針(動態探針):
有了靜態探針,為什么還需要動態探針呢?主要原因是靜態探針都是人工添加的,支持的數量有限,而動態探針就是為了解決這個問題,它能夠支持Hook幾乎所有的內核函數。
Kprobes:Kprobe是一個典型的動態探針,如下圖所示,在內核運行時,Kprobe技術將需要監控的內核函數的指令動態替換,使得該函數的控制流跳轉到用戶自定義的處理函數上。當內核執行到該監控函數時,相應的用戶自定義處理函數被執行,然后繼續執行正常的代碼路徑。
動態探針的優點:
可以Hook幾乎所有的內核函數
動態探針的缺點
不穩定(函數的變更、編譯器的優化等都可能導致采集程序的失效)
性能相對較差
軟件探針(動/靜態探針):
靜態探針性能好,但支持的數量有限,動態探針支持的數量多,但不穩定、性能相對較差,那么是否存在一種技術,能同時兼顧靜態和動態的優勢呢?答案是動靜態結合的探針方案。
Function Hooks(Ftrace): Function Hooks是Ftrace引入的一種動靜態結合的探針方案。如下圖所示,靜態指的是它通過gcc編譯器,在內核編譯階段,在內核函數的入口處插入了預留的特定指令,當內核運行時,它會將預留的特定指令替換為跳轉指令(callftrace_caller),使得內核函數的控制流跳轉到用戶自定義函數上,達到數據監控的目的。
Ftrace和Function Tracer
Function Hooks(Ftrace)的優點
相比于Tracepoint和Kprobe,Function Hooks最顯著的功能性特點是它能夠方便地監控內核函數的調用關系,如下圖所示,監控了內核函數exec_binprm的所有子函數調用關系。
上面分析完各種動態和靜態探針的方案和優缺點后,從開發者代碼多功能可控的角度出發,建議優先使用靜態探針方案。
1.2.Linux Tracing System 發展歷程
? 2004年4月,Linux Auditing subsystem(auditd)被引入內核2.6.6-rc1
? 2005年4月,Kprobe被引入內核2.6.11.7
? 2006年,LTTng發布(至今沒有合入內核)
? 2008年10月 ,Kernel Tracepoint 被引入內核(v2.6.28)。
? 2008年,Ftrace被引入內核(包括compile time function hooks)。
? 2009年,perf被引入內核
? 2009年,SystemTap發布(至今沒有合入內核)
? 2014年,Alexei Starovoitov將eBPF引入內核
1.3.Linux Tracing 框架方案對比
eBPF的優勢對比:
穩定:通過驗證器,防止用戶編寫的程序導致內核崩潰
免安裝:eBPF內置于linux內核,無需安裝額外以來
內核編程:支持開發者插入自定義的代碼邏輯(包括數據采集、分析和過濾)到內核中運行
2.eBPF框架開發分析
2.1 eBPF基礎架構
eBPF程序分為兩部分: 用戶態和內核態代碼。
eBPF內核代碼:
這個代碼首先需要經過編譯器(比如LLVM)編譯成eBPF字節碼,然后字節碼會被加載到內核執行。所以 這部分代碼理論上用什么語言編寫都可以,只要編譯器支持將該語言編譯為eBPF字節碼即可。
目前絕大多數工具都是用的C語言來編寫eBPF內核代碼,包括BCC。
bpftrace提供了一種易用的腳本語言來幫助用戶快速高效的使用eBPF功能,其背后的原理還是利用LLVM 將腳本轉為eBPF字節碼。
eBPF用戶態代碼:
這部分代碼負責將eBPF內核程序加載到內核,與eBPF MAP交互,以及接收eBPF內核程序發送出來的數據。這個功能的本質上是通過Linux OS提供的syscall(bpf syscall + perf_event_open syscall)完成的,因此這 部分代碼你可以用任何語言實現。比如BCC使用python,libbpf使用c或者c++,TRACEE使用Go等等。
2.2 eBPF數據源
性能分析大師Brendan Gregg(Intel Fellow)總結的Linux BPF Tracing Tools上展示了豐富多彩的eBPF鉤子類型,這些鉤子類型提供了可以加載BPF程序的范圍。
fentry/fexit
Tracepoints
network devices (tc/xdp)
network routes
TCP congestion algorithms
sockets (data level)
kernel functions (kprobes)
userspace functions (uprobes)
system calls
2.3 eBPF框架的發展歷程
2014年9月 引入了bpf() syscall,將eBPF引入用戶態空間。
自帶迷你libbpf庫,簡單對bpf()進行了封裝,功能是將eBPF字節碼加載到內核。
2015年2月份 Kernel 3.19 引入bpf_load.c/h文件,對上述迷你libbpf庫再進行封裝,功能是將eBPF elf二進制文件加載到內核(目前已過時,不建議使用)。
2015年4月 BCC項目創建,提供了eBPF一站式編程。
1.創建之初,基于上述迷你libbpf庫來加載eBPF字節碼。
2. 提供了Python接口。
2015年11月 Kernel 4.3 引入標準庫 libbpf
1. 該標準庫由Huawei 2012 OS內核實驗室的王楠提交。
2018年 為解決BCC的缺陷,CO-RE(Compile Once, Run Everywhere)的想法被提出并實現,最后達成共識:libbpf + BTF + CO-RE代表了eBPF的未來,BCC底層實現逐步轉向libbpf。
2.4 eBPF可移植性痛點和解決方案
技術痛點:
在內核版本A上編譯的eBPF程序,無法直接在另外一個內核版本B上運行。造成可以執行差的根本原因在于eBPF程序訪問的內核數據結構(內存空間)是不穩定的,經常隨內核版本更迭而變化。
目前使用BCC的方案通過在部署機器上動態編譯eBPF源代碼可以來解決移植性問題。每一次eBPF程序運行都需要進行一次編譯,而且需要在部署機器上按照上百兆大小的依賴,如編譯器和頭文件Clang/LLVM + Linux headers等。同時在Clang/LLVM編譯過程中需要消耗大量的資源(CPU/內存),對業務性能也會造成很大影響。
解決方案(CO-RE Compile Once,Run Everywhere):
1)BTF:將內核數據結構信息高效壓縮和存儲(相比于DWARF,可達到超過100倍的 壓縮比)
2)LLVM/Clang編譯器:編譯eBPF代碼的時候記錄下relocation相關的信息
3)Libbpf:基于BTF和編譯器提供的信息,動態relocate數據結構
其中BTF為重要組成部分,Linux Kernel 5.2及以上版本自帶BTF文件,低版本需要手動移植。
通過分析內核源碼,可以發現BTF文件的生成并不需要改動內核,只依賴:
帶有debug info的vmlinux image
pahole
LLVM
這意味著,我們可以自己為低版本內核生產BTF文件,以此讓低內核版本支持CORE。
為低版本內核生成BTF文件
準備工作:
·安裝pahole軟件(1.16+)
·https://git.kernel.org/pub/scm/devel/pahole/pahole.git
·安裝LLVM(11+)
·獲取目標低版本內核的vmlinux文件(帶有debug info),文件保存在{vmlinux_file_path}
·通過源下載
·比如對于CentOS,通過yum install kernel-debuginfo可以下載vmlinux
·源碼編譯內核,獲取vmlinux
生成BTF:
·利用pahole在vmlinux文件中生成BTF信息,執行以下命令:
·pahole -J {vmlinux_file_path}
·將BTF信息單獨輸出到新文件{BTF_file_path},執行以下命令:
·llvm-objcopy --only-section=.BTF --set-section-flags .BTF=alloc,readonly --strip- all {vmlinux_file_path} {BTF_file_path}
·去除非必要的符號信息,降低BTF文件的大小,得到最終的BTF文件(大小約2~3MB):
·strip -x {BTF_file_path}
2.5 eBPF程序實例分析(一個Print引發的慘案)
eBPF程序會被LLVM編譯為eBPF字節碼,eBPF字節碼需要通過eBPF Verifier的(靜態)驗證后,才能真正運行。邊界檢查是eBPF Verifier的重點工作,目的是為了防止eBPF程序內存越界訪問。接下來通過在eBPF程序中簡單的增加、刪減print打印信息觸發不同原因的幾種邊界檢查異常導致驗證失敗的例子,進一步講解深層的原理。
程序實驗環境:
1)LLVM 11
2)Linux Kernel 5.8
3)Libbpf commit @9c44c8a
邊界檢查案例:
1)內存越界:
SEC("kprobe/do_unlinkat")int BPF_KPROBE(do_unlinkat, int dfd, struct filename *name){ // 獲取一個數組指針array(數組MAX_SIZE為16個字節) u32 key = 0; char *array = bpf_map_lookup_elem(&array_map, &key); if (array == NULL) return 0; // 獲取當前運行程序的CPU編號(當前機器的CPU有16個核) unsigned int pos = bpf_get_smp_processor_id(); // 根據下表修改數組的值 array[pos] = 1; return 0;}
上述代碼編譯運行后,提示Verifier失敗,然后使用objdump命令來看一下具體的字節碼,通過以下字節碼程序,可以看到Verifier失敗的原因在于第14行R6寄存器(變量pos)沒有進行邊界檢查導致。
Root Cause:
當eBPF Verifier走到第14行的時候嘗試去訪問array數組,但是此時數組的下標pos是來自bpf_get_smp_processor_id獲取到的unsigned int 類型的動態變量,此時Verifier無法判斷變量的具體數值,所以會保守認為可能會達到最大值,這樣的話就會超出array數組的范圍,造成內存越界。
0000000000000000:;intBPF_KPROBE(do_unlinkat,intdfd,structfilename*name)0:r1=0; u32 key = 0;1: *(u32 *)(r10 - 4) = r12: r2 = r103: r2 += -4; char *array = bpf_map_lookup_elem(&array_map, &key); 4:r1 = 0 ll6: call 17: r6 = r0; if (array == NULL)8: if r6 == 0 goto +6 ; unsigned int pos = bpf_get_smp_processor_id();; 9:call 8; array[pos] = 1; 10:r0 <<= 3211: r0 >>= 3212: r6 += r013: r1 = 1; array[pos] = 1;14: *(u8 *)(r6 + 0) = r1
解決方案:
添加邊界檢查代碼
if (pos < MAX_SIZE)???if?r0?>15goto+3
2)Verifier驗證機制和編譯器優化機制不一致導致邊界檢查不通過
①使用錯誤寄存器做邊界檢查:
SEC("kprobe/do_unlinkat") int BPF_KPROBE(do_unlinkat, int dfd, struct filename *name){ // 獲取一個數組指針array(數組MAX_SIZE為16個字節) u32 key = 0; char *array = bpf_map_lookup_elem(&array_map, &key); if (array == NULL) return 0; // 獲取當前運行程序的CPU編號(當前機器的CPU有16個核) unsigned int pos = bpf_get_smp_processor_id();; // 修改數值 if (pos < MAX_SIZE){ array[pos] = 1; pos += 1; } // debug代碼,輸出一些上下文信息 bpf_printk("debug %d %d %d ", bpf_get_current_pid_tgid() >> 32, bpf_get_current_pid_tgid(), array[1]); // 修改數值 if (pos < MAX_SIZE) array[pos] = 1; return 0;}
編譯這個代碼后Verifier驗證通過,可以正常運行。但是此時如果把bpf_printk打印信息刪掉,竟然提示Verifier驗證失敗,原因是R0寄存器(變量pos)沒有通過邊界檢查,但是明明已經加了邊界檢查代碼,怎么還會出現問題,這么神奇!
Root Cause:
由于編譯器的優化策略,導致刪減bpf_printk后編譯生成的eBPF字節碼使用寄存器r1(表示pos變量)來進行邊界檢查,但是卻用r0+1(同樣表示pos變量)來訪問數組array。
相比之下,從eBPF verifier的角度來看,由于在編譯過程中,r1和r0+1的關聯性丟失了,導致eBPF verifier無法知道pos變量已經通過了檢查,因此錯誤的認為pos變量沒有進行邊界檢查,不允許程序運行。
②寄存器溢出或重新加載后,狀態丟失:
SEC("kprobe/do_unlinkat") int BPF_KPROBE(do_unlinkat, int dfd, struct filename *name){ //獲取一個數組指針array(數組MAX_SIZE為16個字節) u32 key = 0; char *array = bpf_map_lookup_elem(&array_map, &key); if (array == NULL) return 0; // 獲取當前運行程序的CPU編號(當前機器的CPU有16個核) unsigned long pos = bpf_get_smp_processor_id();; // 修改數值 if (pos < MAX_SIZE){ for (unsigned long i = 0; i < MAX_SIZE; i++) bpf_printk("debug %d %d %d ", bpf_get_current_pid_tgid() >> 32, bpf_get_current_pid_tgid(), array[i]); array[pos] = 1; } return 0;}
在上述邊界檢查代碼中添加一段print調試打印信息后編譯驗證又會出現Verifier失敗,通過排查發現不是已知的兩類問題,依然使用objdump查看添加后的字節碼信息。
Root Cause:
加入bpf_printk后通過字節碼可以看到,代碼先使用R0(表示pos變量)進行邊界檢查。由于當前寄存器數量不足,編譯器決定將將R0臨時保存到棧上的空間(R10-16,在eBPF字節碼中,R10存儲存放著 eBPF 棧空間的棧幀指針的地址),這樣R0就可以空閑出來,留給其他代碼使用,我們稱這種行為為寄存器溢出(register spill)。當真正需要使用pos變量的時候,編譯器會從棧上(R10-16)將之前保存的內容取出來賦給R1(也表示pos變量),然后使用R1對數組array進行訪問。但神奇的是,當寄存器溢出發生時,pos變量的狀態丟失了,eBPF忘記了該變量曾經進行了邊界檢查,導致程序無法通過驗證。
解決方案:
在源碼中加入 &= 操作符,引導編譯器生成理想的eBPF字節碼
array[pos &= MAX_SIZE - 1] = 1;
如果上述方法失效,無法引導編譯器,那么針對出錯的部分源代碼人工編寫eBPF字節碼,替代編譯器生成的字節碼
#defineSTR(s)#s#defineXSTR(s)STR(s)#defineasm_variable_bound_check(variable)({ asmvolatile( "%[tmp]&="XSTR(MAX_SIZE-1)" " :[tmp]"+&r"(variable) );})asm_check(pos);array[pos] = 1;
3.總結
本文總結了從動靜態探針的角度梳理分析Linux Tracing System以及實例解決eBPF程序中遇到的問題。eBPF目前正在高速發展,很多坑和解決辦法缺乏官方文檔。本文在以下幾點上做了自己的分析和分享,希望對大家更清晰的認識Linux Tracing System和eBPF有所幫助。
1.自下而上的方式分析動靜態探針
2.各種場景下動靜態探針的選擇
3.BPF開發框架的選擇
4.多內核版本兼容性問題
5.如何為低版本內核生成BTF文件
6.eBPF邊界檢查問題分析
7.eBPF Verifier驗證機制與編譯器優化機制不一致問題
原文標題:Linux Tracing System淺析和eBPF開發經驗分享
文章出處:【微信公眾號:Linux閱碼場】歡迎添加關注!文章轉載請注明出處。
-
cpu
+關注
關注
68文章
10826瀏覽量
211160 -
接口
+關注
關注
33文章
8504瀏覽量
150840 -
Linux
+關注
關注
87文章
11230瀏覽量
208933 -
硬件
+關注
關注
11文章
3255瀏覽量
66122
原文標題:Linux Tracing System淺析和eBPF開發經驗分享
文章出處:【微信號:LinuxDev,微信公眾號:Linux閱碼場】歡迎添加關注!文章轉載請注明出處。
發布評論請先 登錄
相關推薦
評論