譯者序
本文翻譯自 2019 年 DigitalOcean 的工程師 Nate Sweet 在 KubeCon 的一篇分享: Understanding (and Troubleshooting) the eBPF Datapath in Cilium 。
由於譯者水平有限,本文不免存在遺漏或錯誤之處。如有疑問,請查閱原文。
- 譯者序
- 1 為什么要關注 eBPF?
- 2 eBPF 是什么?
- 3 為什么 eBPF 如此強大?
- 4 eBPF 簡史
- 5 Cilium 是什么,為什么要關注它?
- 6 內核默認 datapath
- 7 Kubernets、Cilium 和 Kernel:原子對象對應關系
- 8 Demo
以下是譯文。
1 為什么要關注 eBPF?
1.1 網絡成為瓶頸
大家已經知道網絡成為瓶頸,但我是從下面這個角度考慮的:近些年業界使用網絡的方式 ,使其成為瓶頸(it is the bottleneck in a way that is actually pretty recent) 。
- 網絡一直都是 I/O 密集型的,但直到最近,這件事情才變得尤其重要。
- 分布式任務(workloads)業界一直都在用,但直到近些年,這種模型才成為主流。 雖然何時成為主流眾說紛紜,但我認為最早不會早於 90 年代晚期。
- 公有雲的崛起,我認為可能是網絡成為瓶頸的最主要原因。
這種情況下,用於管理依賴和解決瓶頸的工具都已經過時了。
但像 eBPF 這樣的技術使得網絡調優和整流(tune and shape this traffic)變得簡單很多。 eBPF 提供的許多能力是其他工具無法提供的,或者即使提供了,其代價也要比 eBPF 大 的多。
1.2 eBPF 無處不在
eBPF 正在變得無處不在,我們可能會爭論這到底是一件好事還是壞事(eBPF 也確實帶了一 些安全問題),但當前無法忽視的事實是:Linux 內核的網絡開發者們正在將 eBPF 應用 於各種地方(putting it everywhere)。其結果是,eBPF 與內核的默認收發包路徑( datapath)耦合得越來越緊(more and more tightly coupled with the default datapath)。
1.3 性能就是金錢
“Metrics are money”, 這是今年 Paris Kernel Recipes 峰會上,來自 Synthesio 的 Aurelian Rougemont 的 精彩分享。
他展示了一些史詩級的調試(debugging)案例,感興趣的可以去看看;但更重要的是,他 從更高層次提出了這樣一個觀點:理解這些東西是如何工作的,最終會產生資本收益( understanding how this stuff works translates to money)。為客戶節省金錢,為 自己帶來收入。
如果你能從更少的資源中榨取出更高的性能,使軟件運行更快,那 顯然你對公司的貢獻就更大。Cilium 就是這樣一個能讓你帶來更大價值的工具。
在進一步討論之前,我先簡要介紹一下 eBPF 是什么,以及為什么它如此強大。
2 eBPF 是什么?
BPF 程序有多種類型,圖 2.1 是其中一種,稱為 XDP BPF 程序。
- XDP 是 eXpress DataPath(特快數據路徑)。
- XDP 程序可以直接加載到網絡設備上。
- XDP 程序在數據包收發路徑上很前面的位置就開始執行,下面會看到例子。
BPF 程序開發方式:
- 編寫一段 BPF 程序
- 編譯這段 BPF 程序
- 用一個特殊的系統調用將編譯后的代碼加載到內核
這實際上就是編寫了一段內核代碼,並動態插入到了內核(written kernel code and dynamically inserted it into the kernel)。
圖 2.1. eBPF 代碼示例:丟棄源 IP 命中黑名單的 ARP 包
圖 2.1 中的程序使用了一種稱為 map 的東西,這是一種特殊的數據結構,可用於 在內核和用戶態之間傳遞數據,例如通過一個特殊的系統從用戶態向 map 里插入數據。
這段程序的功能:丟棄所有源 IP 命中黑名單的 ARP 包。右側四個框內的代碼功能:
- 初始化以太幀結構體(ethernet packet)。
- 如果不是 ARP 包,直接退出,將包交給內核繼續處理。
- 至此已確定是 ARP,因此初始化一個 ARP 數據結構,對包進行下一步處理。例 如,提取出 ARP 中的源 IP,去之前創建好的黑名單中查詢該 IP 是否存在。
- 如果存在,返回丟棄判決(
XDP_DROP
);否則,返回允許通行判決(XDP_PASS
),內核會進行后續處理。
你可能不會相信,就這樣一段簡單的程序,會讓服務器性能產生質的飛躍,因為它此時已 經擁有了一條極為高效的網絡路徑(an extremely efficient network path)。
3 為什么 eBPF 如此強大?
三方面原因:
- 快速(fast)
- 靈活(flexible)
- 數據與功能分離(separates data from functionality)
3.1 快速
eBPF 幾乎總是比 iptables 快,這是有技術原因的。
- eBPF 程序本身並不比 iptables 快,但 eBPF 程序更短。
- iptables 基於一個非常龐大的內核框架(Netfilter),這個框架出現在內核 datapath 的多個地方,有很大冗余。
因此,同樣是實現 ARP drop 這樣的功能,基於 iptables 做冗余就會非常大,導致性能很低。
3.2 靈活
這可能是最主要的原因。你可以用 eBPF 做幾乎任何事情。
eBPF 基於內核提供的一組接口,運行 JIT 編譯的字節碼,並將計算結果返回給內核。例如 內核只關心 XDP 程序的返回是 PASS, DROP 還是 REDIRECT。至於在 XDP 程序里做什么, 完全看你自己。
3.3 數據與功能分離
eBPF separates data from functionality.
nftables
和 iptables
也能干這個事情,但功能沒有 eBPF 強大。例如,eBPF 可以使 用 per-cpu 的數據結構,因此能取得更極致的性能。
eBPF 真正的優勢是將“數據與功能分離”這件事情做地非常干凈(clean separation):可以在 eBPF 程序不中斷的情況下修改它的運行方式。具體方式是修改它訪 問的配置數據或應用數據,例如黑名單里規定的 IP 列表和域名。
4 eBPF 簡史
這里是簡單介紹幾句,后面 datapath 才是重點。
兩篇論文,可讀性還是比較好的,感興趣的自行閱讀:
- Steven McCanne, et al, in 1993 - The BSD Packet Filter
- Jeffrey C. Mogul, et al, in 1987 - first open source implementation of a packet filter.
5 Cilium 是什么,為什么要關注它?
我認為理解 eBPF 代碼還比較簡單,多看看內核代碼就行了,但配置和編寫 eBPF 就要難多了。
Cilium 是一個很好的 eBPF 之上的通用抽象,覆蓋了分布式系統的絕大多數場景。Cilium 封裝了 eBPF,提供一個更上層的 API。如果你使用的是 Kubernetes,那你至少應該聽說過 Cilium。
Cilium 提供了 CNI 和 kube-proxy replacement 功能,相比 iptables 性能要好很多。
接下來開始進入本文重點。
6 內核默認 datapath
本節將介紹數據包是如何穿過 network datapath(網絡數據路徑)的:包括從硬件到 內核,再到用戶空間。
這里將只介紹 Cilium 所使用的 eBPF 程序,其中有 Cilium logo 的地方,都是 datapath 上 Cilium 重度使用 BPF 程序的地方。
本文不會過多介紹硬件相關內容,因為理解 eBPF 基本不需要硬件知識,但顯然理解了硬件 原理也並無壞處。另外,由於時間限制,我將只討論接收部分。
6.1 L1 -> L2(物理層 -> 數據鏈路層)
網卡收包簡要流程:
- 網卡驅動初始化。
- 網卡獲得一塊物理內存,作用收發包的緩沖區(ring-buffer)。這種方式稱為 DMA(直接內存訪問)。
- 驅動向內核 NAPI(New API)注冊一個輪詢(poll )方法。
- 網卡從雲上收到一個包,將包放到 ring-buffer。
- 如果此時 NAPI 沒有在執行,網卡就會觸發一個硬件中斷(HW IRQ),告訴處理器 DMA 區域中有包等待處理。
- 收到硬中斷信號后,處理器開始執行 NAPI。
- NAPI 執行網卡注冊的 poll 方法開始收包。
關於 NAPI poll 機制:
- 這是 Linux 內核中的一種通用抽象,任何等待不可搶占狀態發生(wait for a preemptible state to occur)的模塊,都可以使用這種注冊回調函數的方式。
- 驅動注冊的這個 poll 是一個主動式 poll(active poll),一旦執行就會持續處理 ,直到沒有數據可供處理,然后進入 idle 狀態。
- 在這里,執行 poll 方法的是運行在某個或者所有 CPU 上的內核線程(kernel thread)。 雖然這個線程沒有數據可處理時會進入 idle 狀態,但如前面討論的,在當前大部分分布 式系統中,這個線程大部分時間內都是在運行的,不斷從驅動的 DMA 區域內接收數據包。
- poll 會告訴網卡不要再觸發硬件中斷,使用軟件中斷(softirq)就行了。此后這些 內核線程會輪詢網卡的 DMA 區域來收包。之所以會有這種機制,是因為硬件中斷代價太 高了,因為它們比系統上幾乎所有東西的優先級都要高。
我們接下來還將多次看到這個廣義的 NAPI 抽象,因為它不僅僅處理驅動,還能處理許多 其他場景。內核用 NAPI 抽象來做驅動讀取(driver reads)、epoll 等等。
NAPI 驅動的 poll 機制將數據從 DMA 區域讀取出來,對數據做一些准備工作,然后交給比 它更上一層的內核協議棧。
6.2 L2 續(數據鏈路層 - 續)
同樣,這里不會深入展開驅動層做的事情,而主要關注內核所做的一些更上層的事情,例如
- 分配 socket buffers(skb)
- BPF
- iptables
- 將包送到網絡棧(network stack)和用戶空間
Step 1:NAPI poll
首先,NAPI poll 機制不斷調用驅動實現的 poll 方法,后者處理 RX 隊列內的包,並最終 將包送到正確的程序。這就到了我們前面的 XDP 類型程序。
Step 2:XDP 程序處理
如果驅動支持 XDP,那 XDP 程序將在 poll 機制內執行。如果不支持,那 XDP 程序將只能在更后面執行(run significantly upstack,見 Step 6),性能會變差, 因此確定你使用的網卡是否支持 XDP 非常重要。
XDP 程序返回一個判決結果給驅動,可以是 PASS, TRANSMIT, 或 DROP。
-
Transmit 非常有用,有了這個功能,就可以用 XDP 實現一個 TCP/IP 負載均衡器。 XDP 只適合對包進行較小修改,如果是大動作修改,那這樣的 XDP 程序的性能 可能並不會很高,因為這些操作會降低 poll 函數處理 DMA ring-buffer 的能力。
-
更有趣的是 DROP 方法,因為一旦判決為 DROP,這個包就可以直接原地丟棄了,而 無需再穿越后面復雜的協議棧然后再在某個地方被丟棄,從而節省了大量資源。如果本次 分享我只能給大家一個建議,那這個建議就是:在 datapath 越前面做 tuning 和 dropping 越好,這會顯著增加系統的網絡吞吐。
-
如果返回是 PASS,內核會繼續沿着默認路徑處理包,到達
clean_rx()
方法。
Step 3:clean_rx()
:創建 skb
如果返回是 PASS,內核會繼續沿着默認路徑處理包,到達 clean_rx()
方法。
這個方法創建一個 socket buffer(skb)對象,可能還會更新一些統計信息,對 skb 進行硬件校驗和檢查,然后將其交給 gro_receive()
方法。
Step 4:gro_receive()
GRO 是一種較老的硬件特性(LRO)的軟件實現,功能是對分片的包進行重組然后交給更 上層,以提高吞吐。
GRO 給協議棧提供了一次將包交給網絡協議棧之前,對其檢查校驗和 、修改協議頭和發送應答包(ACK packets)的機會。
- 如果 GRO 的 buffer 相比於包太小了,它可能會選擇什么都不做。
- 如果當前包屬於某個更大包的一個分片,調用
enqueue_backlog
將這個分片放到某個 CPU 的包隊列。當包重組完成后,會交給receive_skb()
方法處理。 - 如果當前包不是分片包,直接調用
receive_skb()
,進行一些網絡棧最底層的處理。
Step 5:receive_skb()
receive_skb()
之后會再次進入 XDP 程序點。
6.3 L2 -> L3(數據鏈路層 -> 網絡層)
Step 6:通用 XDP 處理(gXDP)
receive_skb()
之后,我們又來到了另一個 XDP 程序執行點。這里可以通過 receive_xdp()
做一些通用(generic)的事情,因此我在圖中將其標注為 (g)XDP
Step 2 中提到,如果網卡驅動不支持 XDP,那 XDP 程序將延遲到更后面執行,這個 “更后面”的位置指的就是這里的 (g)XDP
。
Step 7:Tap 設備處理
圖中有個 *check_taps
框,但其實並沒有這個方法:receive_skb()
會輪詢所有的 socket tap,將包放到正確的 tap 設備的緩沖區。
tap 設備監聽的是三層協議(L3 protocols),例如 IPv4、ARP、IPv6 等等。如果 tap 設 備存在,它就可以操作這個 skb 了。
Step 8:tc
(traffic classifier)處理
接下來我們遇到了第二種 eBPF 程序:tc eBPF。
tc(traffic classifier,流量分類器)是 Cilium 依賴的最基礎的東西,它提供了多種功 能,例如修改包(mangle,給 skb 打標記)、重路由(reroute)、丟棄包(drop),這 些操作都會影響到內核的流量統計,因此也影響着包的排隊規則(queueing discipline )。
Cilium 控制的網絡設備,至少被加載了一個 tc eBPF 程序。
譯者注:如何查看已加載的 eBPF 程序,可參考 Cilium Network Topology and Traffic Path on AWS。
Step 9:Netfilter 處理
如果 tc BPF 返回 OK,包會再次進入 Netfilter。
Netfilter 也會對入向的包進行處理,這里包括 nftables
和 iptables
模塊。
有一點需要記住的是:Netfilter 是網絡棧的下半部分(the “bottom half” of the network stack),因此 iptables 規則越多,給網絡棧下半部分造成的瓶頸就越大。
*def_dev_protocol
框是二層過濾器(L2 net filter),由於 Cilium 沒有用到任何 L2 filter,因此這里我就不展開了。
Step 10:L3 協議層處理:ip_rcv()
最后,如果包沒有被前面丟棄,就會通過網絡設備的 ip_rcv()
方法進入協議棧的三層( L3)—— 即 IP 層 —— 進行處理。
接下來我們將主要關注這個函數,但這里需要提醒大家的是,Linux 內核也支持除了 IP 之 外的其他三層協議,它們的 datapath 會與此有些不同。
6.4 L3 -> L4(網絡層 -> 傳輸層)
Step 11:Netfilter L4 處理
ip_rcv()
做的第一件事情是再次執行 Netfilter 過濾,因為我們現在是從四層(L4)的 視角來處理 socker buffer。因此,這里會執行 Netfilter 中的任何四層規則(L4 rules )。
Step 12:ip_rcv_finish()
處理
Netfilter 執行完成后,調用回調函數 ip_rcv_finish()
。
ip_rcv_finish()
立即調用 ip_routing()
對包進行路由判斷。
Step 13:ip_routing()
處理
ip_routing()
對包進行路由判斷,例如看它是否是在 lookback 設備上,是否能 路由出去(could egress),或者能否被路由,能否被 unmangle 到其他設備等等。
在 Cilium 中,如果沒有使用隧道模式(tunneling),那就會用到這里的路由功能。相比 隧道模式,路由模式會的 datapath 路徑更短,因此性能更高。
Step 14:目的是本機:ip_local_deliver()
處理
根據路由判斷的結果,如果包的目的端是本機,會調用 ip_local_deliver()
方法。
ip_local_deliver()
會調用 xfrm4_policy()
。
Step 15:xfrm4_policy()
處理
xfrm4_policy()
完成對包的封裝、解封裝、加解密等工作。例如,IPSec 就是在這里完成的。
最后,根據四層協議的不同,ip_local_deliver()
會將最終的包送到 TCP 或 UDP 協議 棧。這里必須是這兩種協議之一,否則設備會給源 IP 地址回一個 ICMP destination unreachable
消息。
接下來我將拿 UDP 協議作為例子,因為 TCP 狀態機太復雜了,不適合這里用於理解 datapath 和數據流。但不是說 TCP 不重要,Linux TCP 狀態機還是非常值得好好學習的。
6.5 L4(傳輸層,以 UDP 為例)
Step 16:udp_rcv()
處理
udp_rcv()
對包的合法性進行驗證,檢查 UDP 校驗和。然后,再次將包送到 xfrm4_policy()
進行處理。
Step 17:xfrm4_policy()
再次處理
這里再次對包執行 transform policies 是因為,某些規則能指定具體的四層協議,所以只 有到了協議層之后才能執行這些策略。
Step 18:將包放入 socket_receive_queue
這一步會拿端口(port)查找相應的 socket,然后將 skb 放到一個名為 socket_receive_queue
的鏈表。
Step 19:通知 socket 收數據:sk_data_ready()
最后,udp_rcv()
調用 sk_data_ready()
方法,標記這個 socket 有數據待收。
本質上,一個 socket 就是 Linux 中的一個文件描述符,這個描述符有一組相關的文件操 作抽象,例如 read
、write
等等。
網絡棧下半部分小結
以上 Step 1~19 就是 Linux 網絡棧下半部分(bottom half of the network stack)的全部內容。
接下來我們還會介紹幾個內核函數,但它們都是與進程上下文相關的。
6.6 L4 - User Space
下圖左邊是一段 socket listening 程序,這里省略了錯誤檢查,而且 epoll
本質上也 是不需要的,因為 UDP 的 recv 方法以及在幫我們 poll 了。
由於大家還是對 TCP 熟悉一些,因此在這里我假設這是一段 TCP 代碼。事實上當我們調 用 recvmsg()
方法時,內核所做的事情就和上面這段代碼差不多。對照右邊的圖:
- 首先初始化一個 epoll 實例和一個 UDP socket,然后告訴 epoll 實例我們想 監聽這個 socket 上的 receive 事件,然后等着事件到來。
- 當 socket buffer 收到數據時,其 wait queue 會被上一節的
sk_data_ready()
方法置位(標記)。 - epoll 監聽在 wait queue,因此 epoll 收到事件通知后,提取事件內容,返回給用戶空間。
-
用戶空間程序調用
recv
方法,它接着調用udp_recv_msg
方法,后者又會 調用 cgroup eBPF 程序 —— 這是本文出現的第三種 BPF 程序。Cilium 利用 cgroup eBPF 實現 socket level 負載均衡,這非常酷:- 一般的客戶端負載均衡對客戶端並不是透明的,即,客戶端應用必須將負載均衡邏輯內置到應用里。
- 有了 cgroup BPF,客戶端根本感知不到負載均衡的存在。
- 本文介紹的最后一種 BPF 程序是 sock_ops BPF,用於 socket level 整流(traffic shaping ),這對某些功能至關重要,例如客戶端級別的限速(rate limiting)。
- 最后,我們有一個用戶空間緩沖區,存放收到的數據。
以上就是 Cilium 基於 eBPF 的內核收包之旅(traversing the kernel’s datapath)。太壯觀了!
7 Kubernets、Cilium 和 Kernel:原子對象對應關系
Kubernetes | Cilium | Kernel |
---|---|---|
Endpoint (includes Pods) | Endpoint | tc, cgroup socket BPF, sock_ops BPF, XDP |
Network Policy | Cilium Network Policy | XDP, tc, sock-ops |
Service (node ports, cluster ips, etc) | Service | XDP, tc |
Node | Node | ip-xfrm (for encryption), ip tables for initial decapsulation routing (if vxlan), veth-pair, ipvlan |
以上就是 Kubernetes 的所有網絡對象(the only artificial network objects)。什么意思? 這就是 k8s CNI 所依賴的全部網絡原語(network primitives)。例如,LoadBalancer 對象只是 ClusterIP 和 NodePort 的組合,而后二者都屬於 Service 對象,所以他們並不 是一等對象。
這張圖非常有價值,但不幸的是,實際情況要比這里列出的更加復雜,因為 Cilium 本身的 實現是很復雜的。這有兩個主要原因,我覺得值得拿出來討論和體會:
首先,內核 datapath 要遠比我這里講的復雜。
- 前面只是非常簡單地介紹了協議棧每個位置(Netfilter、iptables、eBPF、XDP)能執行的動作。
-
這些位置提供的處理能力是不同的。例如
- XDP 可能是能力最受限的,因為它只是設計用來做快速丟包(fast dropping)和 非本地重定向(non-local redirecting);但另一方面,它又是最快的程序,因為 它在整個 datapath 的最前面,具備對整個 datapath 進行短路處理(short circuit the entire datapath)的能力。
- tc 和 iptables 程序能方便地 mangle 數據包,而不會對原來的轉發流程產生顯著影響。
理解這些東西非常重要,因為這是 Cilium 乃至廣義 datapath 里非常核心的東西。如 果遇到底層網絡問題,或者需要做 Cilium/kernel 調優,那你必須要理解包的收發/轉發 路徑,有時你會發現包的某些路徑非常反直覺。
第二個原因是,eBPF 還非常新,某些最新特性只有在 5.x 內核中才有。尤其是 XDP BPF, 可能一個節點的內核版本支持,調度到另一台節點時,可能就不支持。