一文詳解用 eBPF 觀測 HTTP
前言
隨着 eBPF 推出,由於具有高性能、高擴展、安全性等優勢,目前已經在網絡、安全、可觀察等領域廣泛應用,同時也誕生了許多優秀的開源項目,如 Cilium、Pixie 等,而 iLogtail 作爲阿里內外千萬實例可觀測數據的採集器,eBPF 網絡可觀測特性也預計會在未來 8 月發佈。下文主要基於 eBPF 觀測 HTTP 1、HTTP 1.1 以及 HTTP2 的角度介紹 eBPF 的針對可觀測場景的應用,同時回顧 HTTP 協議自身的發展。
eBPF 基本介紹
eBPF 是近幾年 Linux Networkworking 方面比較火的技術之一,目前在安全、網絡以及可觀察性方面應用廣泛,比如 CNCF 項目 Cilium 完全是基於 eBPF 技術實現,解決了傳統 Kube-proxy 在大集羣規模下 iptables 性能急劇下降的問題。從基本功能上來說 eBPF 提供了一種兼具性能與靈活性來自定義交互內核態與用戶態的新方式,具體表現爲 eBPF 提供了友好的 api,使得可以通過依賴 libbpf、bcc 等 SDK,將自定義業務邏輯安全的嵌入內核態執行,同時通過 BPF Map 機制(不需要多次拷貝)直接在內核態與用戶態傳遞所需數據。
當聚焦在可觀測性方面,我們可以將 eBPF 類比爲 Javaagent 進行介紹。Javaagent 的基本功能是程序啓動時對於已存在的字節碼進行代理字節碼織入,從而在無需業務修改代碼的情況下,自動爲用戶程序加入 hook 點,比如在某函數進入和返回時添加 hook 點可以計算此函數的耗時。而 eBPF 類似,提供了一系列內核態執行的切入點函數,無需修改代碼,即可觀測應用的內部狀態,以下爲常用於可觀測性的切入點類型:
-
kprobe:動態附加到內核調用點函數,比如在內核 exec 系統調用前檢查參數,可以 BPF 程序設置 SEC("kprobe/sys_exec") 頭部進行切入。
-
tracepoints:內核已經提供好的一些切入點,可以理解爲靜態的 kprobe,比如 syscall 的 connect 函數。
-
uprobe:與 krobe 對應,動態附加到用戶態調用函數的切入點稱爲 uprobe,相比如 kprobe 內核函數的穩定性,uprobe 的函數由開發者定義,當開發者修改函數簽名時,uprobe BPF 程序同樣需要修改函數切入點簽名。
-
perf_events:將 BPF 代碼附加到 Perf 事件上,可以依據此進行性能分析。
TCP 與 eBPF
由於本文觀測協議 HTTP 1、HTTP1.1 以及 HTTP2 都是基於 TCP 模型,所以先回顧一下 TCP 建立連接的過程。首先 Client 端通過 3 次握手建立通信,從 TCP 協議上來說,連接代表着狀態信息,比如包含 seq、ack、窗口 / buffer 等,而 tcp 握手就是協商出來這些初始值;而從操作系統的角度來說,建立連接後,TCP 創建了 INET 域的 socket,同時也佔用了 FD 資源。對於四次揮手,從 TCP 協議上來說,可以理解爲釋放終止信號,釋放所維持的狀態;而從操作系統的角度來說,四次揮手後也意味着 Socket FD 資源的回收。
而對於應用層的角度來說,還有一個常用的概念,這就是長連接,但長連接對於 TCP 傳輸層來說,只是使用方式的區別:
-
應用層短連接:三次握手 + 單次傳輸數據 + 四次揮手,代表協議 HTTP 1
-
應用層長連接:三次握手 + 多次傳輸數據 + 四次揮手,代表協議 HTTP 1.1、HTTP2
參考下圖 TCP 建立連接過程內核函數的調用,對於 eBPF 程序可以很容易的定義好 tracepoints/kprobe 切入點。例如建立連接過程可以切入 accept 以及 connect 函數,釋放鏈接過程可以切入 close 過程,而傳輸數據可以切入 read 或 write 函數。
基於 TCP 大多數切入點已經被靜態化爲 tracepoints,因此 BPF 程序定義如下切入點來覆蓋上述提到的 TCP 核心函數(sys_enter 代表進入時切入,sys_exit 代表返回時切入)。
SEC("tracepoint/syscalls/sys_enter_connect") SEC("tracepoint/syscalls/sys_exit_connect") SEC("tracepoint/syscalls/sys_enter_accept") SEC("tracepoint/syscalls/sys_exit_accept") SEC("tracepoint/syscalls/sys_enter_accept4") SEC("tracepoint/syscalls/sys_exit_accept4") SEC("tracepoint/syscalls/sys_enter_close") SEC("tracepoint/syscalls/sys_exit_close") SEC("tracepoint/syscalls/sys_enter_write") SEC("tracepoint/syscalls/sys_exit_write") SEC("tracepoint/syscalls/sys_enter_read") SEC("tracepoint/syscalls/sys_exit_read") SEC("tracepoint/syscalls/sys_enter_sendmsg") SEC("tracepoint/syscalls/sys_exit_sendmsg") SEC("tracepoint/syscalls/sys_enter_recvmsg") SEC("tracepoint/syscalls/sys_exit_recvmsg") ....
結合上述概念,我們以 iLogtail 的 eBPF 工作模型爲例,介紹一個可觀測領域的 eBPF 程序是如何真正工作的。更多詳細內容可以參考此分享: 基於 eBPF 的應用可觀測技術實踐。如下圖所示,iLogtaileBPF 程序的工作空間分爲 Kernel Space 與 User Space。
Kernel Space 主要負責數據的抓取與預處理:
-
抓取:Hook 模塊會依據 KProbe 定義攔截網絡數據,虛線中爲具體的 KProbe 攔截的內核函數(使用上述描述的 SEC 進行定義),如 connect、accept 以及 write 等。
-
預處理:預處理模塊會根據用戶態配置進行數據的攔截丟棄以及數據協議的推斷,只有符合需求的數據纔會傳遞給 SendToUserSpace 模塊,而其他數據將會被丟棄。其後 SendToUserSpace 模塊通過 eBPF Map 將過濾後的數據由內核態數據傳輸到用戶態。
User Space 的模塊主要負責數據分析、聚合以及管理:
-
分析:Process 模塊會不斷處理 eBPF Map 中存儲的網絡數據,首先由於 Kernel 已經推斷協議類型,Process 模塊將根據此類型進行細粒度的協議分析,如分析 MySQL 協議的 SQL、分析 HTTP 協議的狀態碼等。其次由於 Kernel 所傳遞的連接元數據信息只有 Pid 與 FD 等進程粒度元信息,而對於 Kubernetes 可觀測場景來說,Pod、Container 等資源定義更有意義,所以 Correlate Meta 模塊會爲 Process 處理後的數據綁定容器相關的元數據信息。
-
聚合:當綁定元數據信息後,Aggreate 模塊會對數據進行聚合操作以避免重複數據傳輸,比如聚合週期內某 SQL 調用 1000 次,Aggreate 模塊會將最終數據抽象爲 XSQL:1000 的形式進行上傳。
-
管理:整個 eBPF 程序交互着大量着進程與連接數據,因此 eBPF 程序中對象的生命週期需要與機器實際狀態相符,當進程或鏈接釋放,相應的對象也需要釋放,這也正對應着 Connection Management 與 Garbage Collection 的職責。
eBPF 數據解析
HTTP 1 、HTTP1.1 以及 HTTP2 數據協議都是基於 TCP 的,參考上文,一定有以下函數調用:
-
connect 函數:函數簽名爲 int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen), 從函數簽名入參可以獲取使用的 socket 的 fd,以及對端地址等信息。
-
accept 函數:函數簽名爲 int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen), 從函數簽名入參同樣可以獲取使用的 socket 的 fd,以及對端地址等信息。
-
sendmsg 函數:函數簽名爲 ssize_t sendmsg(int sockfd, const struct msghdr *msg, int flags), 從函數簽名可以看出,基於此函數可以拿到發送的數據包,以及使用的 socket 的 fd 信息,但無法直接基於入參知曉對端地址。
-
recvmsg 函數:函數簽名爲 ssize_t recvmsg(int sockfd, struct msghdr *msg, int flags), 從函數簽名可以看出,基於此函數我們拿到接收的數據包,以及使用的 socket 的 fd 信息,但無法直接基於入參知曉對端地址。
-
close 函數:函數簽名爲 int close(int fd), 從函數簽名可以看出,基於此函數可以拿到即將關閉的 fd 信息。
HTTP 1 / HTTP 1.1 短連接模式
HTTP 於 1996 年推出,HTTP 1 在用戶層是短連接模型,也就意味着每一次發送數據,都會伴隨着 connect、accept 以及 close 函數的調用,這就以爲這 eBPF 程序可以很容易的尋找到 connect 的起始點,將傳輸數據與地址進行綁定,進而構建服務的上下游調用關係。
可以看出 HTTP 1 或者 HTTP1.1 短連接模式是對於 eBPF 是非常友好的協議,因爲可以輕鬆的關聯地址信息與數據信息,但回到 HTTP 1/HTTP1.1 短連接模式 本身來說,‘友好的代價’不僅意味着帶來每次 TCP 連接與釋放連接的消耗,如果兩次傳輸數據的 HTTP Header 頭相同,Header 頭也存在冗餘傳輸問題,比如下列數據的頭 Host、Accept 等字段。
HTTP 1.1 長連接
HTTP 1.1 於 HTTP 1.0 發佈的一年後發佈(1997 年),提供了緩存處理、帶寬優化、錯誤通知管理、host 頭處理以及長連接等特性。而長連接的引入也部分解決了上述 HTTP1 中每次發送數據都需要經過三次握手以及四次揮手的過程,提升了數據的發送效率。但對於使用 eBPF 觀察 HTTP 數據來說,也帶來了新的問題,上文提到建立地址與數據的綁定依賴於在 connect 時進行 probe,通過 connect 參數拿到數據地址,從而與後續的數據包綁定。但回到長連接情況,假如 connect 於 1 小時之前建立,而此時才啓動 eBPF 程序,所以我們只能探測到數據包函數的調用,如 send 或 recv 函數。此時應該如何建立地址與數據的關係呢?
首先可以回到探測函數的定義,可以發現此時雖然沒有明確的地址信息,但是可以知道此 TCP 報文使用的 Socket 與 FD 信息。因此可以使用 netlink 獲取此 Socket 的元信息,進行對長連接補充對端地址,進而在 HTTP 1.1 長連接協議構建服務拓撲與分析數據明細。
ssize_t sendmsg(int sockfd, const struct msghdr *msg, int flags) ssize_t recvmsg(int sockfd, struct msghdr *msg, int flags)
HTTP 2
在 HTTP 1.1 發佈後,由於冗餘傳輸以及傳輸模型串行等問題,RPC 框架基本上都是進行了私有化協議定義,如 Dubbo 等。而在 2015 年,HTTP2 的發佈打破了以往對 HTTP 協議的很多詬病,除解決在上述我們提到的 Header 頭冗餘傳輸問題,還解決 TCP 連接數限制、傳輸效率、隊頭擁塞等問題,而 gRPC 正式基於 HTTP2 構建了高性能 RPC 框架,也讓 HTTP 1 時代層出不窮的通信協議,也逐漸走向了歸一時代,比如 Dubbo3 全面兼容 gRPC/HTTP2 協議。
特性
以下內容首先介紹一些 HTTP2 與 eBPF 可觀察性相關的關鍵特性。
多路複用
HTTP 1 是一種同步、獨佔的協議,客戶端發送消息,等待服務端響應後,才進行新的信息發送,這種模式浪費了 TCP 全雙工模式的特性。因此 HTTP2 允許在單個連接上執行多個請求,每個請求相應使用不同的流,通過二進制分幀層,爲每個幀分配一個專屬的 stream 標識符,而當接收方收到信息時,接收方可以將幀重組爲完整消息,提升了數據的吞吐。此外可以看到由於 Stream 的引入,Header 與 Data 也進行了分離設計,每次傳輸數據 Heaer 幀發送後爲此後 Data 幀的統一頭部,進一步提示了傳輸效率。
首部壓縮
HTTP 首部用於發送與請求和響應相關的額外信息,HTTP2 引入首部壓縮概念,使用與正文壓縮不同的技術,支持跨請求壓縮首部,可以避免正文壓縮使用算法的安全問題。HTTP2 採用了基於查詢表和 Huffman 編碼的壓縮方式,使用由預先定義的靜態表和會話過程中創建的動態表,沒有引用索引表的首部可以使用 ASCII 編碼或者 Huffman 編碼傳輸。
但隨着性能的提升,也意味着越來越多的數據避免傳輸,這也同時意味着對 eBPF 程序可感知的數據會更少,因此 HTTP2 協議的可觀察性也帶來了新的問題,以下我們使用 gRPC 不同模式以及 Wireshark 分析 HTTP2 協議對 eBPF 程序可觀測性的挑戰。
GRPC
Simple RPC
Simple RPC 是 GRPC 最簡單的通信模式,請求和響應都是一條二進制消息,如果保持連接可以類比爲 HTTP 1.1 的長連接模式,每次發送收到響應,之後再繼續發送數據。
但與 HTTP 1 不同的是首部壓縮的引入,如果維持長連接狀態,後續發的數據包 Header 信息將只存儲索引值,而不是原始值,我們可以看到下圖爲 Wirshark 抓取的數據包,首次發送是包含完整 Header 幀數據,而後續 Heders 幀長度降低爲 15,減少了大量重複數據的傳輸。
Stream 模式
Stream 模式是 gRPC 常用的模式,包含 Server-side streaming RPC,Client-side streaming RPC,Bidirectional streaming RPC,從傳輸編碼上來說與 Simple RPC 模式沒有不同,都分爲 Header 幀、Data 幀等。但不同的在於 Data 幀的數量,Simple RPC 一次發送或響應只包含一個 Data 幀 模式,而 Stream 模式可以包含多個。
1、Server-side streaming RPC:與 Simple RPC 模式不同,在 Server-side streaming RPC 中,當從客戶端接收到請求時,服務器會發回一系列響應。此響應消息序列在客戶端發起的同一 HTTP 流中發送。如下圖所示,服務器收到來自客戶端的消息,並以幀消息的形式發送多個響應消息。最後,服務器通過發送帶有呼叫狀態詳細信息的尾隨元數據來結束流。
2、Client-side streaming RPC: 在客戶端流式 RPC 模式中,客戶端向服務器發送多條消息,而服務器只返回一條消息。
3、Bidirectional streaming RPC:客戶端和服務器都向對方發送消息流。客戶端通過發送標頭幀來設置 HTTP 流。建立連接後,客戶端和服務器都可以同時發送消息,而無需等待對方完成。
tracepoint/kprobe 的挑戰
從上述 wirshark 報文以及協議模式可以看出,歷史針對 HTTP1 時代使用的 tracepoint/kprobe 會存在以下挑戰:
- Stream 模式: 比如在 Server-side stream 下,假如 tracepoint/kprobe 探測的點爲 Data 幀,因 Data 幀因爲無法關聯 Header 幀,都將變成無效 Data 幀,但對於 gRPC 使用場景來說還好,一般 RPC 發送數據和接受數據都很快,所以很快就會有新的 Header 幀收到,但這時會遇到更大的挑戰,長連接下的首部壓縮。
- 長連接 + 首部壓縮:當 HTTP2 保持長連接,connect 後的第一個 Stream 傳輸的 Header 會爲完整數據,而後續 Header 幀如與前置 Header 幀存在相同 Header 字段,則數據傳輸的爲地址信息,而真正的數據信息會交給 Server 或 Client 端的應用層 SDK 進行維護,而如下圖 eBPF tracepoints/kprobe 在 stream 1 的尾部幀才進行 probe,對於後續的 Header2 幀大概率不會存在完整的 Header 元數據,如下圖 Wireshark 截圖,包含了很多 Header 信息的 Header 長度僅僅爲 15,可以看出 eBPF tracepoints/kprobe 對於這種情況很難處理。
從上文可知,HTTP2 可以歸屬於有狀態的協議,而 Tracepoint/Kprobe 對有狀態的協議數據很難處理完善,某些場景下只能做到退化處理,以下爲使用 Tracepoint/Kprobe 處理的基本流程。
Uprobe 可行嗎?
從上述 tracepoint/kprobe 的挑戰可以看到,HTTP 2 是一種很難被觀測的協議,在 HTTP2 的協議規範上,爲減少 Header 的傳輸,client 端以及 server 端都需要維護 Header 的數據,下圖是 grpc 實現的 HTTP2 客戶端維護 Header 元信息的截圖,所以在應用層可以做到拿到完整 Header 數據,也就繞過來首部壓縮問題,而針對應用層協議,eBPF 提供的探測手段是 Uprobe(用戶態),而 Pixie 項目也正是基於 Uprobe 實踐了 gRPC HTTP2 流量的探測,詳細內容可以參考此文章 1。
下圖展示了使用 Uprobe 觀測 Go gRPC 流量的基本流程,如其中 writeHeader 的函數定義爲 func (l *loopyWriter) writeHeader(streamID uint32, endStream bool, hf []hpack.HeaderField, onWrite func()), 可以看到明確的 Header 文本。
Kprobe 與 Uprobe 對比
從上文可以看出 Uprobe 實現簡單,且不存在數據退化的問題,但 Uprobe 真的完美嗎?
-
兼容性:上述方案僅僅是基於 Golang gRPC 的 特定方法進行探測,也就意味着上述僅能覆蓋 Golang gRPC 流量的觀察,對於 Golang 其他 HTTP2 庫無法支持。
-
多語言性:Uprobe 只能基於方法簽名進行探測,更適用於 C/GO 這種純編譯型語言,而對於 Java 這種 JVM 語言,因爲運行時動態生成符號表,雖然可以依靠一些 javaagent 將 java 程序用於 Uprobe,但是相對於純編譯型語言,用戶使用成本或改造成本還是會更高一些。
-
穩定性:Uprobe 相對於 tracepoint/kprobe 來說是不穩定的,假如探測的函數函數簽名有改變,這就意味着 Uprobe 程序將無法工作,因爲函數註冊表的改變將使得 Uprobe 無法找到切入點。
綜合下來 2 種方案對比如下,可以看到 2 種方案對於 HTTP2(有狀態)的觀測都存在部分取捨:
總結
上述我們回顧了 HTTP1 到 HTTP2 時代的協議變遷,也看到 HTTP2 提升傳輸效率做的種種努力,而正是 HTTP2 的巨大效率提升,也讓 gRPC 選擇了直接基於 HTTP2 協議構建,而也是這種選擇,讓 gRPC 成爲了 RPC 百家爭鳴後是隱形事實協議。但我們也看到了協議的進步意味着更少的數據交互,也讓數據可觀察變得更加困難,比如 HTTP2 使用 eBPF 目前尚無完美的解決方法,或使用 Kprobe 觀察,選擇的多語言性、流量拓撲分析、但容許了失去流量細節的風險;或使用 Uprobe 觀察,選擇了數據的細節,拓撲,但容許了多語言的兼容性問題。
iLogtail 致力於打造覆蓋 Trace、Metrics 以及 Logging 的可觀測性的統一 Agent,而 eBPF 作爲目前可觀測領域的熱門採集技術,提供了無侵入、安全、高效觀測流量的能力,預計 8 月份,我們將在 iLogtail Cpp 正式開源後發佈此部分功能,歡迎大家關注和互相交流。
參考:
TCP 的幾個狀態: https://www.s0nnet.com/archives/tcp-status
HTTP2.0 的總結: https://liyaoli.com/2015-04-18/HTTP-2.0.html
Transmission Control Protocol:https://en.wikipedia.org/wiki/Transmission_Control_Protocol
Computer Networks:https://www.cse.iitk.ac.in/users/dheeraj/cs425/lec18.html
Hypertext_Transfer_Protocol:https://en.wikipedia.org/wiki/Hypertext_Transfer_Protocol
gRPC: A Deep Dive into the Communication Pattern:https://thenewstack.io/grpc-a-deep-dive-into-the-communication-pattern/
ebpf2-http2-tracing:https://blog.px.dev/ebpf-http2-tracing/
深入理解 Linux socket:https://www.modb.pro/db/153725
基於 eBPF 的應用可觀測技術實踐: https://www.bilibili.com/video/BV1Gg411d7tq
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/2ncM-PvN06lSwScvc2Zueg