圖解 eBPF socket level 重定向的內核實現(xiàn)細節(jié)
大家好,我是二哥。最近一直在研究 eBPF ,隨著研究的深入,我發(fā)現(xiàn)之前寫的這篇文章有點問題,所以重新修改了一下。圖也重新畫了,并添加了一些與 sidecar-less 相關的額外內容。
下面是正文。
上一篇《??利用eBPF實現(xiàn)socket level重定向??》,二哥從整體上介紹了 eBPF 的一個應用場景 socket level redirect:如果一臺機器上有兩個進程需要通過 loopback 設備相互收發(fā)數(shù)據(jù),我們可以利用 ebpf 在發(fā)送進程端將需要發(fā)送的數(shù)據(jù)跳過本機的底層 TCP/IP 協(xié)議棧,直接交給目的進程的 socket,從而縮短數(shù)據(jù)在內核的處理路徑和時間。
這個流程如圖 1 所示。本篇我們來詳細看下圖 1 右側在內核里的實現(xiàn)細節(jié)。
圖 1:利用 ebpf 進行 socket level redirect,從而跳過 TCP/IP 協(xié)議棧和 lo 設備
先來一張全局圖,我們再依次剖析這張圖上面的關鍵知識點。
圖 2:利用 ebpf 進行 socket level redirect 全局細節(jié)圖
1、準備階段
(1)插入 bpf_sock_ops 到 sock_hash map
我們的故事從圖 3 所示的插入 bpf_sock_ops 到 sock hash map 開始。這里給出一些代碼片段,完整可編譯、可安裝執(zhí)行的代碼位于 https://github.com/LanceHBZhang/socket-acceleration-with-ebpf 。另外,完整的 ebpf 程序的安裝過程還涉及到 cgroup,我就不展開來講這個話題了。
下面代碼中用到一種特殊的 map 類型:BPF_MAP_TYPE_HASH,也即本文提及的 sock_hash。在它里面存儲的是 KV 類型數(shù)據(jù),而 value 實際對應的是數(shù)據(jù)結構 struct bpf_sock_ops。除了存儲 bpf_sock_ops,在這類 map 上還可以 attach 一個用戶編寫的 sk_msg 類型的 bpf 程序,以便來查找接收數(shù)據(jù)的 socket,attach 語句請參考 github 代碼。
當我們通過 attach 一種 sock_ops 類型的 bpf 函數(shù),即上面代碼中的 bpf_sockmap(),到 cgroupv2 的根路徑后,當發(fā)生一些 socket 事件,如主動建立連接或者被動建立連接等,這個時候 bpf 函數(shù) bpf_sockmap() 將會被調用。這個過程如圖 3 執(zhí)行點 1.1 所示,更具體地說 1.1 發(fā)生的事情就是三次握手(SYN / SYNC-ACK / ACK),既然是三次握手,當然是與通信雙方都有關系,所以 bpf_sockmap() 函數(shù)里 bpf_sock_ops_ipv4(skops) 會被調用兩次 。
bpf_sockmap() 所做的事情非常簡單:以 source ip / source port / dest ip / dest port / family 為 key ,將 struct bpf_sock_ops 對象放入到 sock_hash 中。這個過程如圖 3 執(zhí)行點 1.2 所示。為了表示 bpf_sockmap() 與 ebpf 有關,我特意在 1.2 處畫出了 ebpf 的 logo。
上述代碼中 sock_hash_update() 函數(shù)調用看起來是在更新 sock_has map,其實它在內核中所做的事情更重要:精準動態(tài)替換 TCP 協(xié)議相關函數(shù)。
圖 3:插入 sock 到 sock_hash map
(2)精準動態(tài)替換 prot
如果大家關注過內核協(xié)議棧相關數(shù)據(jù)結構的話,一定會碰到如下圖所示的幾個關鍵角色:struct file / struct socket / struct sock / struct proto 。
其中 socket 如同設計模式里常用的轉接器(adaptor),一方面它適配了面向應用層的 struct file ,另一方面又通過引用 struct sock 的方式串聯(lián)起網(wǎng)絡協(xié)議棧。
仔細看這張圖,我們會發(fā)現(xiàn) struct sock 才是靈魂,你從它所包含的內容就能窺得一二了。struct sock 里有一個非常關鍵地方:對 Networking protocol 的引用,也即你看到的 sk_prot 。為什么說它關鍵呢?因為 sk_prot 作為一個指針所指向的結構體 tcp_prot 包含了一系列對 TCP 協(xié)議至關重要的函數(shù),包括本文需要重點關注的 recvmsg 和 sendmsg 。從它們的名字你也能看出來它們的使用場景:用于 TCP 層接收和發(fā)送數(shù)據(jù)。
當然除了 struct tcp_prot ,sk_prot 還可能指向 struct udp_prot / ping_prot / raw_prot 。
圖 4:file / socket / sock / operations(圖片來源:開發(fā)內功修煉公眾號)
那 ebpf 在這里面干了啥事呢?非常的巧妙,它把 struct proto 里面的 recvmsg / sendmsg 等函數(shù)動態(tài)替換掉了。比如把 recvmsg 由原來的 tcp_recvmsg 替換成了 tcp_bpf_recvmsg ,而把 tcp_sendmsg 替換為 tcp_bpf_sendmsg 。
單純的替換其實談不上巧妙,二哥說巧妙是因為這里的替換是“精準動態(tài)替換”。
首先為啥叫精準替換呢?你想啊,不是每個服務都需要通過 loopback 來進行本機進程間通信的,另外即使進程間通信是通過這種方式,也不是每一種場景都需要使用到我們這里說的 socket level redirect ,所以替換操作不能廣撒網(wǎng),只能在需要的時候替換。所謂“動態(tài)替換”也即不是在編譯內核的時候就直接替換掉了,而是在有需要的時候。
那這個“需要的時候”到底是什么時候呢?
答案是將 bpf_sock_ops 存儲到 sock_hash 的時候,也即圖 3 所涉及到過程。當系統(tǒng)函數(shù) bpf_sock_hash_update 被調用時,內核會調用位于 net/core/sock_map.c 中的 sock_hash_update_common 函數(shù),在它的調用鏈中完成了替換函數(shù) tcp_bpf_update_proto() 的調用。實際的替換結果是 sk->sk_prot 保存了替換后的版本,也即 tcp_bpf_prots[family][config],而 tcp_bpf_prots 則在很早的時候就已經(jīng)初始化好了。
強調一遍,這里的替換操作僅僅與確實需要用到 socket level redirect 的 sock 有關,不會影響到其它 sock,當然被替換的 sock 其實是一對,你一定猜到了,圖 3 中 envoy 進程和 Process B 各有一個自己的 sock 參與了這次通信過程,故而它們自己的 recvmsg / sendmsg 都需要被替換掉。
2、sk_psock
在圖 3 中,我們還能看到獨立于 TX queue 和 RX queue 的新 queue:ingress_msg。通信雙方的 socket 層都各有一個這樣的 queue 。queue 里面暫存的數(shù)據(jù)用結構體 struct sk_msg 表示,sk_msg 包含了我們之前非常熟悉的 skb ,我們略過它的具體定義。在下文講述數(shù)據(jù)發(fā)送和接收流程中我們會看到 ingress_msg queue 是如何發(fā)揮作用的。
這個 queue 位于結構體 struct sk_psock {} 里面。同樣被包含在 sk_psock 里面的,還有它的小伙伴 sock / eval / cork 等。
內核代碼里面我們會看到大量的 psock->eval 這樣的語句,即為對 sk_psock 的讀寫。另外你看這個結構體里面還有函數(shù)指針 psock_update_sk_prot ,它所指向的即為上一節(jié)所說的函數(shù) tcp_bpf_update_proto() 。
3、發(fā)送數(shù)據(jù)
在和 Process B 成功建立起 TCP 連接后,進程 envoy 開始寫數(shù)據(jù)了,如圖 5 中 2.1 所示。
正常情況下, write() 系統(tǒng)調用所傳遞的數(shù)據(jù)最終會由 tcp_sendmsg() 來進行 TCP 層的處理。不過還記得在“精準動態(tài)替換 prot” 一節(jié)我們提到 tcp_sendmsg() 已經(jīng)被替換成 tcp_bpf_sendmsg() 了嗎?所以這里的主角其實是 tcp_bpf_sendmsg() 。
圖 5:發(fā)送數(shù)據(jù)流程
我在圖 5 中畫出了 tcp_bpf_sendmsg() 所干的幾件重要的事情。
(1)執(zhí)行點2.3:執(zhí)行 ebp 程序
ebpf 程序其實需要老早就需要準備好,并 attach 到 sock_hash 上(再一次,這個過程請參考前文所附 github 代碼)。
程序的入口非常簡單:bpf_redir()。它同樣從 struct sk_msg_md 里面提取出 source ip / source port / dest ip / dest port / family ,并以其為 key 到 sock_hash 里面找到需要重定向的目標,也即通信對端的 struct sock,并將其存放于 psock->sk_redir 處。
代碼中 msg_redirect_hash() 這個名字有點誤導人。乍一看還以為是在這里就完成了重定向過程。其實它只干了 map 查找和確認是否允許重定向這兩個操作,真正的好戲還在后頭。它的代碼不長,我直接全部貼在這里了。
(2)執(zhí)行點2.4:enqueue sk_msg
在這里,我們第一次看到 ingress_msg queue 的使用場景。
struct sk_psock {} 里面有一個成員叫 eval ,從這個關鍵詞大概就能猜到它與評估結果有關,那評估的對象是誰呢?就是 2.3 處所需要執(zhí)行的 ebpf 程序。2.3 處的執(zhí)行結果會放到 psock->eval 里面供后面使用。
執(zhí)行結果有三種:__SK_PASS / __SK_REDIRECT / __SK_DROP 。當 psock->eval 等于我們重點關注的 __SK_REDIRECT 時,就開始了執(zhí)行點 2.4 的過程:將 sk_msg 放到 psock->ingress_msg 這個 queue 里面。
函數(shù)調用鏈如下所示:
需要注意的是,這個過程中 ingress_msg queue 是屬于 ProcessB 進程的。也就是說,在這一步,數(shù)據(jù)包被直接放入了對端進程的 queue 中去了。
(3)執(zhí)行點2.5:喚醒 ProcessB
在執(zhí)行點 2.4 把 sk_msg 放到 Process B 的 psock->ingress_msg 之后,內核沒有繼續(xù)往下調用其它函數(shù),直接通過 sk->sk_data_ready() 來喚醒在對端 sock 上等待數(shù)據(jù)而睡眠的進程 ProcessB。sk_data_ready 其實是個函數(shù)指針,真正被調用的函數(shù)是 sock_def_readable() 。
4、接收數(shù)據(jù)
上一節(jié)執(zhí)行點 2.5 我們說到,發(fā)送數(shù)據(jù)流程最終通過 sk_data_ready() 來喚醒 Process B 。那么到這里為止結束了發(fā)送數(shù)據(jù)流程,而從這里也開始了接收數(shù)據(jù)的流程。整個流程涉及到的關鍵步驟我畫在圖 6 里面了:執(zhí)行點 3.1 / 3.2 / 3.3 。
(1)執(zhí)行點3.2:tcp_bpf_recvmsg
3.1 處的 read() 函數(shù)最終會使得 tcp_bpf_recvmsg() 被調用,而不是 tcp_recvmsg() 。原因在文首“精準動態(tài)替換”那節(jié),二哥已經(jīng)做過鋪墊過。而 tcp_bpf_recvmsg() 所做的工作其實不復雜,消費 ingress_msg queue 里面的數(shù)據(jù)。
圖 6:接收數(shù)據(jù)流程
5、備胎
你再回頭看下圖 2 ,會發(fā)現(xiàn)二哥在圖中從 tcp_bpf_sendmsg() 以及 tcp_bpf_recvmsg() 分別拉了一條虛線到 tcp_sendmsg() 和 tcp_recvmsg()。我管 tcp_sendmsg() 和 tcp_recvmsg() 叫備胎。因為 tcp_bpf_sendmsg() 以及 tcp_bpf_recvmsg() 在處理一些異常情況時就直接走老路了。
圖 7:備胎 tcp_sendmsg() 和 tcp_recvmsg()
6、sock level 加速 + 跨 network ns = sidecarless
圖 8:socket level acceleration
講到這里,我們再來看下本篇所描述的對象:socket level redirection。
網(wǎng)絡包直接通過一個新的 queue 就發(fā)送到對端去了,中間沒有經(jīng)過復雜的 TCP 協(xié)議棧,更沒有使用到 IP 層的路由表、iptables 等耗時費力的組件。
這樣做帶來了一個非常簡單直接和粗暴的好處:socket level acceleration 。也就是說同一個主機上,進程間通信可以通過 socket level redirection 來使性能得到大幅提升。
如果你熟悉基于 sidecar 的 Service Mesh 的話,一定知道一個 Pod 內部存在基于 loopback 的進程間通信。我們也聊過即使通信雙方是走的 Loopback 這個虛擬的設備,從而省去了與真實網(wǎng)絡設備相關的排隊(Queue Descipline)等待時間,也省去了網(wǎng)絡包離開本機后的網(wǎng)絡延遲,但網(wǎng)絡包在 TCP/IP 協(xié)議棧上該走的路可一步都少不了,萬一路由表和 iptables 設置得比較復雜,那依舊需要在路由和 net filter 上面花去很多時間。
socket level acceleration 的出現(xiàn)可以說完美解決了 sidecar 所面臨的網(wǎng)絡剛性開銷難題。
它帶來的好處僅僅如此嗎?不!socket level acceleration 還有一個厲害的地方:它可以跨 network namespace 傳遞網(wǎng)絡包。這表示假如圖 8 里面 envoy 和 Process B 分別處于各自的 network ns 里面,也能充分利用 socket level acceleration 來完成高性能通信。為了突出這一點,圖 8 右側我把 Process B 所屬的 network ns 單獨畫在了一個灰色框里面。
高性能 + 跨 network ns,多么美麗的組合。Cilum 基于它打造出了 sidecarless 的 Service Mesh 。如圖 9 所示。注意看哦,圖中 Layer 7 的 Proxy 是獨立于每個 Pod 的,也是被這個 Work Node 上所有的 Pod 所共享的。
圖 9:基于 socket level redirection 所實現(xiàn)的 sidecarless Service Mesh