如何基於 eBPF 實現跨語言、無侵入的流量錄製?
測試是產品發佈上線的一個重要環節,但隨着業務規模和複雜度不斷提高,每次上線需要回歸的功能越來越多,給測試工作帶來了巨大的壓力。在這樣的大背景下,越來越多的團隊開始使用流量回放對服務進行迴歸測試。
在建設流量回放能力之前,我們必須將線上服務的流量錄製下來。通常要結合對流量特徵的要求、實現成本、對業務的侵入性等方面綜合考慮,選擇不同的實現方式。
對於 Java 和 PHP 語言,目前業界已經有比較成熟的解決方案 jvm-sandbox-repeater、rdebug,基本可以做到低成本、無侵入式的流量錄製;但 Go 語言由於缺少像 jvm 或 libc 等可利用的中間層,現有的方案 sharingan 需要修改官方 Go 源碼並且侵入業務代碼,穩定性風險較大;並且隨着官方 Go 版本升級,需要持續維護迭代,使用和維護成本較高。
鑑於滴滴多語言的技術棧,我們經過調研發現可以通過 eBPF 實現一種跨語言、無侵入的流量錄製方案,大幅降低流量錄製的使用和維護成本。
流量錄製原理
錄製內容
流量回放時需要對下游依賴服務進行 mock,因此錄製的一條完整流量中不僅需要包含入口調用的請求 / 響應,還需要包含處理這次請求時所調用依賴服務的請求 / 響應。
實現思路
在介紹流量錄製方案之前,我們先來看一個請求的處理過程(簡化後):
觀察上述流程我們發現目標服務處理一個請求的大致流程如下:
-
首先,調用 accept 獲得一個調用方的連接;
-
第二步,在這個連接上通過調用 recv 讀取請求數據,解析請求;
-
第三步,目標服務開始執行業務邏輯,過程中可能需要調用一個或多個依賴服務,對於每一次依賴服務調用,目標服務需要通過 connect 與依賴服務建立連接,然後在這個連接上通過 send 發送請求數據,通過 recv 接收依賴服務響應;
-
最後,目標服務通過 send 給調用方返回響應數據。
爲了實現流量錄製,我們需要把圖中所有的請求和響應數據保存下來。傳統的流量錄製方法需要跟蹤服務框架、RPC 框架、依賴服務 sdk 等所有涉及發送 / 接收數據的方法,將數據收集並保存下來。由於框架和 sdk 多種多樣,需要大量的代碼改造和開發工作,成本難以控制。
這裏我們考慮更通用的方式:跟蹤 socket 相關操作,例如 accept、connect、send、recv 等。通過這種方式我們可以不用關心業務中使用的應用層協議、框架、sdk 等,實現更通用的流量錄製方法。
但是,由於實現錄製的位置更底層,能夠獲取的上下文信息更少,只有每個 socket 發送和接收的數據是不夠的。我們需要藉助其他信息對原始數據進行串聯,從而組裝完整的一條流量。
區分不同的請求
線上服務處理的請求大多是併發的,同時會有多個請求交織在一起,我們錄製到原始數據是分散的,如何把同一個請求的數據合併,把不同請求的數據區分開呢?通過分析實際的請求處理過程,我們不難發現:
1、通常情況下,每個請求是在單獨的線程中進行處理的。
2、爲了提高處理速度,可能創建子線程併發調用依賴服務。
區分數據類型
在每一條流量中包含了兩類數據:入口調用的請求和響應,下游依賴調用的請求和響應。我們需要在流量錄製時進行區分。通過觀察請求處理流程,我們不難發現其中的規律:
1、入口調用的請求和響應是在 accept 獲得的 socket 上接收和發送的,recv 的數據是 request,send 的數據是 response。
2、下游依賴調用的請求和響應是在 connect 獲得的 socket 上接收和發送的,send 的數據是 request,recv 的數據是 response;不同的 socket 對應不同的下游調用。
因此,我們可以根據 socket 類型和標識區分出不同的數據類型和不同的下游依賴調用。
流量錄製實現
考慮到目前大部分服務已經上雲,因此方案需要支持容器化部署。eBPF 程序運行在內核中,而同一宿主機上的所有容器共享同一個內核,因此 eBPF 程序只需要加載一次即可錄製到所有進程的數據。整體方案如下:
-
錄製 agent:與目標進程部署在相同容器中,根據進程名找到要錄製的目標進程 pid,(1) 控制錄製 server 開啓 / 關閉錄製;(7) 從錄製 server 接收原始數據,解析成完整流量,(8) 保存到日誌文件中。
-
錄製 server:部署在宿主機上,負責 (2, 3) 加載 / 掛載 eBPF 程序、(6) 從 eBPF Map 中讀取原始數據。
-
eBPF 程序:負責在目標進程 (4) 發送和接收數據時,(5) 從掛載的函數中讀取原始數據並寫入 eBPF Map 中。
選擇插樁點
根據前面的討論,我們需要跟蹤的 socket 操作包括:
-
accept 和 connect 用於區分 socket 類型。
-
send 和 recv 用於捕獲發送和接收的數據。
-
close 用於識別調用的結束。
對於 Go 語言,還需要獲取執行上述 socket 操作的 goroutine id 和跟蹤 goroutine 的父子關係。
在開發 eBPF 程序之前,需要選擇合適的 eBPF 程序掛載位置,不同的 eBPF 程序類型,能夠獲取到的上下文不同,可調用的 bpf-helper 函數也不同。我們需要錄製的數據只有 TCP 和 UDP 兩種協議,因此可以通過 kprobe 掛載到內核的以下函數:
-
inet_accept
-
inet_stream_connect
-
inet_sendmsg
-
inet_recvmsg
-
inet_release
爲了跟蹤 goroutine 之間的關係,我們可以通過 uprobe 掛載到 Go 運行時的 runtime.newproc1 函數,從 callergp 和 newg 中獲取對應的 goroutine 信息。
開發 eBPF 程序
流量錄製雖然涉及了多個內核函數,但流程基本是一樣的,下面以錄製 socket 發送數據爲例進行詳細介紹。
函數簽名:
int inet_sendmsg(struct socket *sock, struct msghdr *msg, size_t size)
參數說明:
-
sock socket 指針
-
msg 要發送的數據
-
size 要發送數據的長度
返回值:
- 成功時返回發送的數據長度,失敗時返回錯誤碼。
由於實際發送的數據長度是在函數返回時才能獲取到的,因此我們需要開發兩個程序,分別完成以下工作:
-
在函數入口處記錄函數參數和上下文
-
在函數返回時記錄實際發送的數據內容
函數入口 eBPF 程序:
SEC("kprobe/inet_sendmsg")
int BPF_KPROBE(inet_sendmsg_entry, struct socket *sock, struct msghdr *msg)
{
struct probe_ctx pctx = {
.bpf_ctx = ctx,
.version = EVENT_VERSION,
.source = EVENT_SOURCE_SOCKET,
.type = EVENT_SOCK_SENDMSG,
.sr.sock = sock,
};
int err;
// 過濾掉不需要錄製的進程
if (pid_filter(&pctx)) {
return 0;
}
// 讀取 socket 類型信息
err = read_socket_info(&pctx, &pctx.sr.sockinfo, sock);
if (err) {
tm_err2(&pctx, ERROR_READ_SOCKET_INFO, __LINE__, err);
return 0;
}
// 記錄 msg 中的數據信息
err = bpf_probe_read(&pctx.sr.iter, sizeof(pctx.sr.iter), &msg->msg_iter);
if (err) {
tm_err2(&pctx, ERROR_BPF_PROBE_READ, __LINE__, err);
return 0;
}
// 將相關上下文信息保存到 map 中
pctx.id = bpf_ktime_get_ns();
err = save_context(pctx.pid, &pctx);
if (err) {
tm_err2(&pctx, ERROR_SAVE_CONTEXT, __LINE__, err);
}
return 0;
}
函數返回 eBPF 程序:
SEC("kretprobe/inet_sendmsg")
int BPF_KRETPROBE(inet_sendmsg_exit, int retval)
{
struct probe_ctx pctx = {
.bpf_ctx = ctx,
.version = EVENT_VERSION,
.source = EVENT_SOURCE_SOCKET,
.type = EVENT_SOCK_SENDMSG,
};
struct sock_send_recv_event event = {};
int err;
// 過濾掉不需要錄製的進程
if (pid_filter(&pctx)) {
return 0;
}
// 如果發送失敗, 跳過錄制數據
if (retval <= 0) {
goto out;
}
// 從 map 中讀取提前保存的上下文信息
err = read_context(pctx.pid, &pctx);
if (err) {
tm_err2(&pctx, ERROR_READ_CONTEXT, __LINE__, err);
goto out;
}
// 構造 sendmsg 報文
event.version = pctx.version;
event.source = pctx.source;
event.type = pctx.type;
event.tgid = pctx.tgid;
event.pid = pctx.pid;
event.id = pctx.id;
event.sock = (u64)pctx.sr.s;
event.sock_family = pctx.sr.sockinfo.sock_family;
event.sock_type = pctx.sr.sockinfo.sock_type;
// 從 msg 中讀取數據填充到 event 報文, 並通過 map 傳遞到用戶空間
sock_data_output(&pctx, &event, &pctx.sr.iter);
out:
// 清理上下文信息
err = delete_context(pctx.pid);
if (err) {
tm_err2(&pctx, ERROR_DELETE_CONTEXT, __LINE__, err);
}
return 0;
}
獲取 goid
對於 Go 語言,我們需要根據發送和接收數據時 goroutine id 進行數據串聯,如何在 eBPF 程序中獲取呢?通過分析 go 源碼,我們發現 goroutine id 是保存在 struct g 中的,並且可以通過 getg() 來獲取當前 g 的指針。
getg 函數:
// getg returns the pointer to the current g.
// The compiler rewrites calls to this function into instructions
// that fetch the g directly (from TLS or from the dedicated register).
func getg() *g
根據函數註釋,當前 g 的指針是放在線程本地存儲(TLS)中的,調用 getg() 的代碼由編譯器進行重寫。爲了找到 getg() 的實現方式,我們看到 runtime.newg 函數中調用了 getg,對它進行反彙編,發現 g 的指針保存在 fs 寄存器 -8 的內存地址上:
接下來,我們找到 struct g 中的 goid 字段(位於 runtime/runtime2.go):
type g struct {
.... 此處省略大量字段
goid int64
.... 此處省略大量字段
}
拿到 g 的指針後,只要加上 goid 字段的偏移量即可獲取到 goid。同時,考慮到不同的 go 版本之間,goid 偏移量可能不同,最終在 eBPF 程序中我們可以這樣獲取當前 goid:
static __always_inline
u64 get_goid()
{
struct task_struct *task = (struct task_struct *)bpf_get_current_task();
unsigned long fsbase = 0;
void *g = NULL;
u64 goid = 0;
bpf_probe_read(&fsbase, sizeof(fsbase), &task->thread.fsbase);
bpf_probe_read(&g, sizeof(g), (void*)fsbase-8);
bpf_probe_read(&goid, sizeof(goid), (void*)g+GOID_OFFSET);
return goid;
}
遇到的問題
eBPF 程序雖然可以使用 C 語言開發,但是與普通 C 語言開發過程有較大的差別,增加了很多限制。
以下爲開發時遇到的比較關鍵的問題和解決思路:
-
不允許使用全局變量、常量字符串或數組,可以保存到 map 中。
-
不支持函數調用,可以通過 inline 內聯解決。
-
棧空間不能超過 512 字節,必要時可通過 array 類型的 map 做緩衝區。
-
不能直接訪問用戶態和內核態內存,要通過 bpf-helper 的相關函數。
-
單個程序指令條數不能超過 1000000,儘量保持 eBPF 程序邏輯簡單,複雜的處理放在用戶態程序完成。
-
循環必須有明確的次數上限,不能只靠運行時判斷。
-
結構體成員要內存對齊,否則可能導致部分內存未初始化,引發 verifier 報錯。
-
代碼經過編譯器優化後 verifier 可能誤報內存訪問越界問題,可以在代碼中增加 if 判斷幫助 verifer 識別,必要時可通過內聯彙編的方式解決。
-
....
隨着 clang 和內核對 ebpf 支持的逐漸完善,很多問題也在逐步得到解決,後續的開發體驗也會變得更順暢。
安全機制
爲了保障流量數據的安全性,降低數據脫敏對線上機器的性能影響,我們選擇在流量採集階段進行加密:
總結
本文介紹了 eBPF 在流量錄製方向的應用,希望可以幫助大家降低流量錄製的實現和接入成本,快速建設流量回放能力。由於篇幅原因,流量錄製的很多細節不能展開分享,後續計劃將該方案開源,歡迎大家持續關注滴滴開源項目。更多關於 eBPF 的應用場景,感興趣的同學也可以進一步閱讀《eBPF 內核技術在滴滴雲原生的落地實踐》進行了解。
限於作者技術水平,文中難免有所錯漏,大家可以在評論區留言指正,期待後續更多的交流和討論。
作者及部門介紹
本篇文章作者王超鋒,來自滴滴網約車出行技術團隊,出行技術作爲網約車業務研發團隊,通過建設終端用戶體驗平臺、C 端用戶產品生態、B 端運力供給生態、出行安全生態、服務治理生態、核心保障體系,打造安全可靠、高效便捷、用戶可信賴的出行平臺。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/6vD0cckviqLQidFb6Yo71Q