如何使用 eBPF 觀測用戶空間應用程序
譯者注
這一年來,很多剛接觸 eBPF 的朋友會問我,eCapture[1] 的原理是什麼,爲什麼區分 OpenSSL、Gnutls、Nspr 等類庫實現?爲什麼要設定 OpenSSL 類庫地址?爲什麼 C、JAVA、Go 實現的 https 通訊程序,在 eCapture 上實現卻不一樣。對於這些問題,我覺得核心問題是大家對「eBPF 實現用戶空間的行爲跟蹤」原理不瞭解,一直想寫一篇文章介紹這個知識點,但總是太忙,沒時間。這幾天看到外網一篇簡單的介紹,文章名是 How to Instrument UserLand Apps with eBPF,我在這裏翻譯、調整一下,分享給大家。
前言
eBPF 徹底改變了 Linux 內核中的可觀察性。在之前的博客文章中,我介紹了 eBPF 生態系統的基本構建,揭開了 XDP 的面紗,並展示了它與 eBPF 基礎設施如何緊密合作,以便在網絡堆棧中引入快速處理的數據路徑。然而,eBPF 並不是kernel-space內核空間
跟蹤所獨有的。如果我們能夠檢測在生產環境中運行的應用程序,同時享受 eBPF 驅動的跟蹤的好處,那不是很贊嗎?
這就是eBPF uprobe
的價值所在。可以直白地把它們看成附加到用戶空間的跟蹤點,跟內核符號的kprobe
類似。
許多語言的運行時、數據庫系統以及其他軟件堆棧都包含可供 BCC 工具使用的鉤子。具體地說,ustat
工具會收集有價值的事件,例如垃圾回收
事件、對象創建
統計信息、方法調用
等等。
但是 “,很多官方語言運行時版本,都不附帶對DTrace
支持,比如Node.js
和Python
等,這意味着您必須從源代碼構建時,就設定好參數。也就是說,編譯 python 這個解釋語言時,就需要在參數中指定。將--with -dtrace
標誌傳遞給編譯器。當然,這不是必要條件。對於 ELF 文件,只要符號表可用,就可以對它Section段
中的任何符號進行應用動態跟蹤。對Go
或Rust stdlib
的函數調用是通過這種方式完成的。
也就是說,對於 eCapture 來說,哪怕是 TLS 類庫是靜態編譯的或者沒有符號表的,也是可以通過自行確定 Offset 的方式,來實現對指定偏移地址進行動態跟蹤。在 eHIDS-Agent 也有過一個例子,user/probe_ujava_rasp.go[2] 的 92 行:
/*
openjdk version "1.8.0_292"
OpenJDK Runtime Environment (build 1.8.0_292-8u292-b10-0ubuntu1-b10)
OpenJDK 64-Bit Server VM (build 25.292-b10, mixed mode)
*/
//ex, err := link.OpenExecutable("/usr/lib/jvm/java-8-openjdk-amd64/jre/lib/amd64/libjava.so")
// sub_19C30 == JDK_execvpe(p->mode, p->argv[0], p->argv, p->envv);
// md5sum : 38590d0382d776234201996e99487110 /usr/lib/jvm/java-8-openjdk-amd64/jre/lib/amd64/libjava.so
Probes: []*manager.Probe{
{
Section: "uprobe/JDK_execvpe",
EbpfFuncName: "java_JDK_execvpe",
AttachToFuncName: "JDK_execvpe",
UprobeOffset: 0x19C30,
BinaryPath: "/usr/lib/jvm/java-8-openjdk-amd64/jre/lib/amd64/libjava.so",
},
}
對於相應 JVM 的libjava.so
中,符號表是少了JDK_execvpe
這個函數。但是,依舊可以通過 IDA pro 等工具,對 so 文件進行靜態分析,定位到JDK_execvpe
的偏移地址是0x19C30
,從而使用 eBPF uprobe 的 HOOK 方式完成 HOOK。
其實,在 eBPF 的加載器類庫中,不管是 C 的 libbbpf 還是 Go 的
cilium/ebpf
,都會自行讀取 uprobe 的二進制 ELF 文件,自行讀取符號表,進行被 HOOK 函數的偏移地址定位,最終依舊使用偏移地址作爲 HOOK 參數。
用於檢測應用程序的 eBPF 技術
有多種方法可以跟蹤用戶空間進程:
-
靜態聲明的
USDT
-
動態聲明
USDT
-
使用
uprobe
進行動態跟蹤
靜態聲明 USDT
USDT(Userland Statically Defined Tracing)體現了直接在用戶代碼中嵌入探針的想法。該技術的起源可以追溯到Solaris/BSD DTrace
時代,包括使用DTRACE_PROBE()
宏來聲明策略代碼位置的跟蹤點。與普通符號不同,USDT 鉤子可以保證在代碼被重構的情況下保持穩定。下圖描述了在用戶代碼中聲明USDT
跟蹤點,以及其在內核中執行的整個過程。
開發人員將首先通過DTRACE_PROBE
和DTRACE_PROBE1
宏來植入跟蹤點,用來圈定感興趣的代碼塊。這兩個宏都接受幾個強制性參數,例如provider/probe
的名稱,然後是你想從追蹤點了解的任何值。編譯器會在目標二進制文件的 ELF 部分中壓制 USDT 追蹤點。同時,編譯器和追蹤工具之間有個契約規定,也就是 USDT 的元數據所在的.note.stapstd
段必須存在。
USDT 跟蹤工具會對 ELF 部分進行自檢,並在跟蹤點得位置放置一個斷點,該斷點將轉換爲int 3
中斷。每當在跟蹤點的標記處執行控制流時,都會觸發中斷處理程序,並在內核中調用與 uprobe 關聯的程序來處理事件並將它們發送到用戶空間,執行相應的數據聚合等處理。
動態聲明 USDT
由於 USDT 被推入靜態生成的 ELF 部分,對於在解釋型語言或基於 JIT 的語言上運行的軟件來說,它違背了聲明USDT
的目的。幸運的是,可以通過libstapsdt
在運行時定義跟蹤點。它生成一個帶有 USDT 信息的小型共享對象,該對象映射到進程的地址空間,因此跟蹤工具可以附加到期望的目標跟蹤點。libstapsdt
的綁定在大部分語言中都有。可以閱讀這個示例 [3],來了解如何在Node.js
中安裝 USDT
探針。
使用 uprobes 進行動態跟蹤
這種類型的跟蹤機制除了目標程序的符號表是可訪問以外,不需要何額外功能。這是最通用、最強大的插樁方法,因爲它允許在任意指令上注入斷點,甚至無需重新啓動目標進程。
跟蹤示例
在簡單的理論介紹之後,讓我們看看一些具體的例子,看看如何針對不同的語言的應用程序進行插樁。
C 語言程序
Redis
是用 C 語言實現的熱門 KV 對數據結構服務器。查看一下 Redis
符號表會發現大量函數可以通過 uprobes
捕獲。
$ objdump -tT /usr/bin/redis-server
…
000000000004c160 g DF .text 00000000000000cc Base
addReplyDouble
0000000000090940 g DF .text 00000000000000b0 Base sha1hex
00000000000586e0 g DF .text 000000000000007c Base
replicationSetMaster
00000000001b39e0 g DO .data 0000000000000030 Base
dbDictType
00000000000ace20 g DF .text 0000000000000030 Base
RM_DictGetC
0000000000041bc0 g DF .text 0000000000000073 Base
sdsull2str
00000000000bba00 g DF .text 0000000000000871 Base raxSeek
00000000000ac8c0 g DF .text 000000000000000c Base
RM_ThreadSafeContextUnlock
00000000000e3900 g DF .text 0000000000000059 Base
mp_encode_lua_string
00000000001cef60 g DO .bss 0000000000000438 Base rdbstate
0000000000047110 g DF .text 00000000000000b5 Base
zipSaveInteger
000000000009f5a0 g DF .text 0000000000000055 Base
addReplyDictOfRedisInstances
0000000000069200 g DF .text 000000000000004a Base
zzlDelete
0000000000041e90 g DF .text 00000000000008ba Base
sdscatfmt
000000000009ac40 g DF .text 000000000000003a Base
sentinelLinkEstablishedCallback
00000000000619d0 g DF .text 0000000000000045 Base
psetexCommand
00000000000d92f0 g DF .text 00000000000000fc Base
luaL_argerror
00000000000bc360 g DF .text 0000000000000328 Base
raxRandomWalk
0000000000096a00 g DF .text 00000000000000c3 Base
rioInitWithFdset
000000000003d160 g DF .text 0000000000000882 Base
serverCron
0000000000032907 g DF .text 0000000000000000 Base
je_prof_thread_name_set
0000000000043960 g DF .text 0000000000000031 Base zfree
00000000000a2a40 g DF .text 00000000000001ab Base
sentinelFailoverDetectEnd
00000000001b8500 g DO .data 0000000000000028 Base
je_percpu_arena_mode_names
00000000000b5f90 g DF .text 0000000000000018 Base
geohashEstimateStepsByRadius
00000000000d95e0 g DF .text 0000000000000039 Base
luaL_checkany
0000000000048850 g DF .text 00000000000002d4 Base
createClient
...
Redis 內部使用了一個有趣的createStringObject
函數來分配robj
結構的字符串。Redis 命令是以createStringObject
調用名義生成的。我們可以通過掛鉤這個函數來監視發送到 Redis 服務器的任何命令。爲此,我將使用 BCC 工具箱中的跟蹤工具來演示。
$ /usr/share/bcc/tools/trace '/usr/bin/redis-server:createStringObject "%s" arg1'
PID TID COMM FUNC -
8984 8984 redis-server createStringObject b'COMMANDrn'
8984 8984 redis-server createStringObject
b'setrn$4rnoctirn$4rnfestrn'
8984 8984 redis-server createStringObject b'octirn$4rnfestrn'
8984 8984 redis-server createStringObject b'festrn'
8984 8984 redis-server createStringObject b'getrn$4rnoctirn'
8984 8984 redis-server createStringObject b'octirn'
以上是在 Redis CLI 客戶端執行set octi fest
和get octi
所產生的輸出。
JAVA 語言程序
現代的 JVM 版本帶有對 USDT 的內置支持。所有的探針都是以 libjvm 共享對象的名義帶來的。我們可以在 ELF 部分挖掘出可用的追蹤點。
$ readelf -n /usr/lib/jvm/jdk-11-oracle/lib/server/libjvm.so
...
stapsdt 0x00000037 NT_STAPSDT (SystemTap probe
descriptors)
Provider: hs_private
Name: cms__initmark__end
Location: 0x0000000000e2420c, Base: 0x0000000000f725b4, Semaphore: 0x0000000000000000
Arguments:
stapsdt 0x00000037 NT_STAPSDT (SystemTap probe descriptors)
Provider: hs_private
Name: cms__remark__begin
Location: 0x0000000000e24334, Base: 0x0000000000f725b4, Semaphore: 0x0000000000000000
Arguments:
stapsdt 0x00000035 NT_STAPSDT (SystemTap probe descriptors)
Provider: hs_private
Name: cms__remark__end
Location: 0x0000000000e24418, Base: 0x0000000000f725b4, Semaphore: 0x0000000000000000
Arguments:
stapsdt 0x0000002f NT_STAPSDT (SystemTap probe descriptors)
Provider: hotspot
Name: gc__begin
Location: 0x0000000000e2b262, Base: 0x0000000000f725b4, Semaphore: 0x0000000000000000
Arguments: 1@$1
stapsdt 0x00000029 NT_STAPSDT (SystemTap probe descriptors)
Provider: hotspot
Name: gc__end
Location: 0x0000000000e2b31a, Base: 0x0000000000f725b4, Semaphore: 0x0000000000000000
Arguments:
...
要捕獲所有class load
類加載事件,我們可以使用以下命令:
$ /usr/share/bcc/tools/trace
'u:/usr/lib/jvm/jdk-11-oracle/lib/server/libjvm.so:class__loaded "%s", arg1'
同樣,我們可以觀察線程創建事件:
$ /usr/share/bcc/tools/trace
'u:/usr/lib/jvm/jdk-11-oracle/lib/server/libjvm.so:thread__start "%s", arg1'
結果輸出
PID TID COMM FUNC
27390 27398 java thread__start b'Reference Handler'
27390 27399 java thread__start b'Finalizer'
27390 27400 java thread__start b'Signal Dispatcher'
27390 27401 java thread__start b'C2 CompilerThread0'
27390 27402 java thread__start b'C1 CompilerThread0'
27390 27403 java thread__start b'Sweeper thread'
27390 27404 java thread__start b'Service Thread'
當擴展探針啓用時(即-XX:+ExtendedDTraceProbes
屬性),uflow
工具能夠實時跟蹤並繪製所有方法的執行過程。
$ /usr/share/bcc/tools/lib/uflow -l java 27965
Tracing method calls in java process 27965... Ctrl-C to quit.
CPU PID TID TIME(us) METHOD
5 27965 27991 0.736 <- jdk/internal/misc/Unsafe.park
5 27965 27991 0.736 ->
java/util/concurrent/locks/LockSupport.setBlocker'
5 27965 27991 0.736 -> jdk/internal/misc/Unsafe.putObject
5 27965 27991 0.736 <- jdk/internal/misc/Unsafe.putObject
5 27965 27991 0.736 <-
java/util/concurrent/locks/LockSupport.setBlocker'
5 27965 27991 0.736 <-
java/util/concurrent/locks/LockSupport.parkNanos
5 27965 27991 0.736 ->
java/util/concurrent/locks/AbstractQueuedSynchronizer$ConditionObject.checkInterruptWhileWaiting
5 27965 27991 0.737 -> java/lang/Thread.interrupted
5 27965 27991 0.737 -> java/lang/Thread.isInterrupted
5 27965 27991 0.737 <- java/lang/Thread.isInterrupted
5 27965 27991 0.737 <- java/lang/Thread.interrupted
5 27965 27991 0.737 <-
java/util/concurrent/locks/AbstractQueuedSynchronizer$ConditionObject.checkInterruptWhileWaiting
5 27965 27991 0.737 -> java/lang/System.nanoTime
5 27965 27991 0.737 <- java/lang/System.nanoTime
但是,擴展探針所產生的系統開銷,是特別特別大的,所以,它們不適合生產環境,僅用於調試。
Go 語言程序
我將用 Go 語言中的一個例子來完成對追蹤技術的演示。由於 Go 語言是一種原生編譯語言,因此嘗試使用 trace 工具在目標符號上附加 uprobe 程序。 您可以使用以下簡單的代碼片段親自嘗試:
package main
import "fmt"
func main() {
fmt.Println("Hi")
}
$ go build -o hi build.go
$/usr/share/bcc/tools/trace '/tmp/hi:fmt.Println "%s" arg1'
PID TID COMM FUNC -
31545 31545 hi fmt.Println b'xd6x8dK'
我們在參數列中得到不是我們期望的 “Hi” 字符串,而是一些隨機的垃圾數據。這是由於trace
不能處理Println
變量參數造成的,但也是有關ABI
調用約定的錯誤假設。與 C/C++ 不同的是,Go 語言在堆棧上傳遞參數,而 C/C++ 更喜歡在普通的寄存器中傳遞參數。
由於我們不能依靠 trace 來演示如何插樁 Go 代碼,我將構建一個簡單的工具來跟蹤所有由http.Get
函數發出的HTTP GET
請求。你可以很容易地修改它,來捕獲其他 HTTP 請求。但我只是用這個例子演示, 完整的源代碼可以在 https://github.com/sematext/uprobe-http-tracer[4] 這個 repo 中找到。
由於我們使用libbcc
的 Go 綁定來完成繁重的工作,所以我不會去討論關於uprobe
attach/load
過程的細節。OK,一起來看看真實的 uprobe 程序。
在所需的包含include
之後,我們定義了負責通過偏移處理從堆棧中讀取參數的宏。
#define SP_OFFSET(offset) (void *)PT_REGS_SP(ctx) + offset * 8
接下來,我們聲明用於封裝通過reqs map
流傳輸的事件結構。這個 map 是用BPF_PERF_OUTPUT
宏定義的。我們的程序的核心是__uprobe_http_get
函數。每當調用http.Get
時,在內核空間中觸發這個函數。我們知道http.Get
有一個參數,它表示 HTTP 請求被髮送到的 URL。C 和 Go 語言的另一個區別是它們在內存中如何佈局字符串。
C 語言的字符串是以 null
結尾的序列,但 Go 將字符串視爲包含指向內存緩衝區的指針和字符串長度的兩個字值。這說明我們需要對 bpf_probe_read 進行兩次調用,一次用於讀取字符串,第二次用於讀取其長度。
bpf_probe_read(&url, sizeof(url), SP_OFFSET(1));
bpf_probe_read(&req.len, sizeof(req.len), SP_OFFSET(2));
觸發之後,在用戶空間中,URL 從 slice 切片被修剪到其相應的長度。順便說一下,這工具 demo 能夠通過注入uretprobe
來發現每個 HTTP GET 請求的延遲。然而,事實證明,每次 Go 運行時決定收縮 / 增長堆棧時,都會產生災難性的影響,因爲uretprobe
會將堆棧上的返回地址修補到在 eBPF VM 的上下文中執行的trampoline
函數。在退出uretprobe
函數時,指令指針恢復到原始返回地址,這個地址可能指向一個無效地址,從而擾亂堆棧並導致進程崩潰。有一些提議來解決這個問題:Go crash with uretprobe #1320[5]。
結論
在這篇文章中,我們介紹了用於User Space
用戶空間進程插樁的 eBPF 特性。通過幾個實際案例,我們已經展示了 BCC 框架在捕獲可觀測性信號方面的通用性。
至此,相信你對 eBPF uprobe 的動態插樁有了一定的瞭解。也可以閱讀 eCapture 源碼,更好的實戰。在 golang 語言的二進制程序插樁實現中,一定要考慮 ABI 的規範差異,不過,golang 官方也在考慮調整參數傳遞方式,從堆棧改到寄存器,你可以查看提案:基於寄存器的 Go 調用約定 [6] 瞭解更多詳情。
參考資料
[1]
eCapture: https://ecapture.cc/
[2]
user/probe_ujava_rasp.go: https://github.com/ehids/ehids-agent/blob/master/user/probe_ujava_rasp.go
[3]
這個示例: https://github.com/sthima/node-usdt#example
[4]
https://github.com/sematext/uprobe-http-tracer: https://github.com/sematext/uprobe-http-tracer
[5]
Go crash with uretprobe #1320: https://github.com/iovisor/bcc/issues/1320#issuecomment-407927542
[6]
提案:基於寄存器的 Go 調用約定: https://go.googlesource.com/proposal/+/master/design/40724-register-calling.md
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/7SRHUPer58KlJZhn9y8kDg