如何使用 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.jsPython等,這意味着您必須從源代碼構建時,就設定好參數。也就是說,編譯 python 這個解釋語言時,就需要在參數中指定。將--with -dtrace標誌傳遞給編譯器。當然,這不是必要條件。對於 ELF 文件,只要符號表可用,就可以對它Section段中的任何符號進行應用動態跟蹤。對GoRust 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 技術

有多種方法可以跟蹤用戶空間進程:

  1. 靜態聲明的USDT

  2. 動態聲明USDT

  3. 使用uprobe進行動態跟蹤

靜態聲明 USDT

USDT(Userland Statically Defined Tracing)體現了直接在用戶代碼中嵌入探針的想法。該技術的起源可以追溯到Solaris/BSD DTrace時代,包括使用DTRACE_PROBE()宏來聲明策略代碼位置的跟蹤點。與普通符號不同,USDT 鉤子可以保證在代碼被重構的情況下保持穩定。下圖描述了在用戶代碼中聲明USDT跟蹤點,以及其在內核中執行的整個過程。

開發人員將首先通過DTRACE_PROBEDTRACE_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 festget 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