如何基於 eBPF 實現跨語言、無侵入的流量錄製?

測試是產品發佈上線的一個重要環節,但隨着業務規模和複雜度不斷提高,每次上線需要回歸的功能越來越多,給測試工作帶來了巨大的壓力。在這樣的大背景下,越來越多的團隊開始使用流量回放對服務進行迴歸測試。

在建設流量回放能力之前,我們必須將線上服務的流量錄製下來。通常要結合對流量特徵的要求、實現成本、對業務的侵入性等方面綜合考慮,選擇不同的實現方式。

對於 Java 和 PHP 語言,目前業界已經有比較成熟的解決方案 jvm-sandbox-repeater、rdebug,基本可以做到低成本、無侵入式的流量錄製;但 Go 語言由於缺少像 jvm 或 libc 等可利用的中間層,現有的方案 sharingan 需要修改官方 Go 源碼並且侵入業務代碼,穩定性風險較大;並且隨着官方 Go 版本升級,需要持續維護迭代,使用和維護成本較高。

鑑於滴滴多語言的技術棧,我們經過調研發現可以通過 eBPF 實現一種跨語言無侵入的流量錄製方案,大幅降低流量錄製的使用和維護成本。        

流量錄製原理

錄製內容

流量回放時需要對下游依賴服務進行 mock,因此錄製的一條完整流量中不僅需要包含入口調用的請求 / 響應,還需要包含處理這次請求時所調用依賴服務的請求 / 響應。

實現思路

在介紹流量錄製方案之前,我們先來看一個請求的處理過程(簡化後):

觀察上述流程我們發現目標服務處理一個請求的大致流程如下:

爲了實現流量錄製,我們需要把圖中所有的請求和響應數據保存下來。傳統的流量錄製方法需要跟蹤服務框架、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 程序只需要加載一次即可錄製到所有進程的數據。整體方案如下:              

選擇插樁點

根據前面的討論,我們需要跟蹤的 socket 操作包括:

對於 Go 語言,還需要獲取執行上述 socket 操作的 goroutine id 和跟蹤 goroutine 的父子關係。

在開發 eBPF 程序之前,需要選擇合適的 eBPF 程序掛載位置,不同的 eBPF 程序類型,能夠獲取到的上下文不同,可調用的 bpf-helper 函數也不同。我們需要錄製的數據只有 TCP 和 UDP 兩種協議,因此可以通過 kprobe 掛載到內核的以下函數:

爲了跟蹤 goroutine 之間的關係,我們可以通過 uprobe 掛載到 Go 運行時的 runtime.newproc1 函數,從 callergp 和 newg 中獲取對應的 goroutine 信息。

開發 eBPF 程序

流量錄製雖然涉及了多個內核函數,但流程基本是一樣的,下面以錄製 socket 發送數據爲例進行詳細介紹。

函數簽名:

int inet_sendmsg(struct socket *sock, struct msghdr *msg, size_t 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 語言開發過程有較大的差別,增加了很多限制。

以下爲開發時遇到的比較關鍵的問題和解決思路:

隨着 clang 和內核對 ebpf 支持的逐漸完善,很多問題也在逐步得到解決,後續的開發體驗也會變得更順暢。

安全機制

爲了保障流量數據的安全性,降低數據脫敏對線上機器的性能影響,我們選擇在流量採集階段進行加密:          

總結

本文介紹了 eBPF 在流量錄製方向的應用,希望可以幫助大家降低流量錄製的實現和接入成本,快速建設流量回放能力。由於篇幅原因,流量錄製的很多細節不能展開分享,後續計劃將該方案開源,歡迎大家持續關注滴滴開源項目。更多關於 eBPF 的應用場景,感興趣的同學也可以進一步閱讀《eBPF 內核技術在滴滴雲原生的落地實踐》進行了解。

限於作者技術水平,文中難免有所錯漏,大家可以在評論區留言指正,期待後續更多的交流和討論。

作者及部門介紹 

本篇文章作者王超鋒,來自滴滴網約車出行技術團隊,出行技術作爲網約車業務研發團隊,通過建設終端用戶體驗平臺、C 端用戶產品生態、B 端運力供給生態、出行安全生態、服務治理生態、核心保障體系,打造安全可靠、高效便捷、用戶可信賴的出行平臺。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/6vD0cckviqLQidFb6Yo71Q