[譯] 利用 eBPF 支撐大規模 K8s Service (LPC, 2019)

語言: CN / TW / HK

譯者序

本文翻譯自 2019 年 Daniel Borkmann 和 Martynas Pumputis 在 Linux Plumbers Conference 的一篇分享: Making the Kubernetes Service Abstraction Scale using eBPF 。 翻譯時對大家耳熟能詳或已顯陳舊的內容(K8s 介紹、Cilium 1.6 之前的版本對 Service 實現等)略有刪減,如有需要請查閲原 PDF。

實際上,一年之後 Daniel 和 Martynas 又在 LPC 做了一次分享,內容是本文的延續: 基於 BPF/XDP 實現 K8s Service 負載均衡 (LPC, 2020)

由於譯者水平有限,本文不免存在遺漏或錯誤之處。如有疑問,請查閲原文。

  • 1 K8s Service 類型及默認基於 kube-proxy 的實現
  • 2 用 Cilium/BPF 替換 kube-proxy
    • 2.1 ClusterIP Service
      • 某些 UDP 應用:存在的問題及解決方式
      • 2.2.1 後端 pod 在本節點
      • 2.2.2 後端 pod 在其他節點
      • 2.2.3 Client pods 和 backend pods 在同一節點
    • 2.3 Service 規則的規模及請求延遲對比
  • 3 相關的 Cilium/BPF 優化
    • 3.1 BPF UDP recvmsg() hook
    • 3.2 全局唯一 socket cookie
    • 3.4 LRU BPF callback on entry eviction
    • 3.5 LRU BPF eviction zones
    • 3.7 BPF getpeername hook
    • 3.8 繞過內核最大 BPF 指令數的限制
  • 4 Cilium 上手:用 kubeadm 搭建體驗環境

以下是譯文。

K8s 當前重度依賴 iptables 來實現 Service 的抽象。 對於每個 Service 及其 backend pods,在 K8s 裏會生成很多 iptables 規則。 例如 5K 個 Service 時,iptables 規則將達到 25K 條 ,導致的後果:

  • 較高、並且不可預測的轉發延遲 (packet latency),因為每個包都要遍歷這些規則 ,直到匹配到某條規則;
  • 更新規則的操作非常慢 :無法單獨更新某條 iptables 規則,只能將全部規則讀出來 ,更新整個集合,再將新的規則集合下發到宿主機。在動態環境中這一問題尤其明顯,因為每 小時可能都有幾千次的 backend pods 創建和銷燬。
  • 可靠性問題 :iptables 依賴 Netfilter 和系統的連接跟蹤模塊(conntrack),在 大流量場景下會出現一些競爭問題(race conditions); UDP 場景尤其明顯 ,會導 致丟包、應用的負載升高等問題。

本文將介紹如何基於 Cilium/BPF 來解決這些問題,實現 K8s Service 的大規模擴展。

1 K8s Service 類型及默認基於 kube-proxy 的實現

K8s 提供了 Service 抽象,可以將多個 backend pods 組織為一個 邏輯單元 (logical unit)。K8s 會為這個邏輯單元分配 虛擬 IP 地址 (VIP),客户端通過該 VIP 就 能訪問到這些 pods 提供的服務。

下圖是一個具體的例子,

  1. 右邊的 yaml 定義了一個名為 nginx 的 Service,它在 TCP 80 端口提供服務;

    • 創建: kubectl -f nginx-svc.yaml
  2. K8s 會給每個 Service 分配一個虛擬 IP,這裏給 nginx 分的是 3.3.3.3

    • 查看: kubectl get service nginx
  3. 左邊是 nginx Service 的兩個 backend pods(在 K8s 對應兩個 endpoint),這裏 位於同一台節點,每個 Pod 有獨立的 IP 地址;

    • 查看: kubectl get endpoints nginx

上面看到的是所謂的 ClusterIP 類型的 Service。實際上, 在 K8s 裏有幾種不同類型 的 Service

  • ClusterIP
  • NodePort
  • LoadBalancer
  • ExternalName

本文將主要關注前兩種類型。

K8s 裏實現 Service 的組件是 kube-proxy,實現的主要功能就是 將訪問 VIP 的請 求轉發(及負載均衡)到相應的後端 pods 。前面提到的那些 iptables 規則就是它創建 和管理的。

另外,kube-proxy 是 K8s 的可選組件,如果不需要 Service 功能,可以不啟用它。

1.1 ClusterIP Service

這是 K8s 的默認 Service 類型 ,使得 宿主機或 pod 可以通過 VIP 訪問一個 Service

  • Virtual IP to any endpoint (pod)
  • Only in-cluster access

kube-proxy 是通過如下的 iptables 規則來實現這個功能的:

-t nat -A {PREROUTING, OUTPUT} -m conntrack --ctstate NEW -j KUBE-SERVICES

# 宿主機訪問 nginx Service 的流量,同時滿足 4 個條件:
# 1. src_ip 不是 Pod 網段
# 2. dst_ip=3.3.3.3/32 (ClusterIP)
# 3. proto=TCP
# 4. dport=80
# 如果匹配成功,直接跳轉到 KUBE-MARK-MASQ;否則,繼續匹配下面一條(iptables 是鏈式規則,高優先級在前)
# 跳轉到 KUBE-MARK-MASQ 是為了保證這些包出宿主機時,src_ip 用的是宿主機 IP。
-A KUBE-SERVICES ! -s 1.1.0.0/16 -d 3.3.3.3/32 -p tcp -m tcp --dport 80 -j KUBE-MARK-MASQ
# Pod 訪問 nginx Service 的流量:同時滿足 4 個條件:
# 1. 沒有匹配到前一條的,(説明 src_ip 是 Pod 網段)
# 2. dst_ip=3.3.3.3/32 (ClusterIP)
# 3. proto=TCP
# 4. dport=80
-A KUBE-SERVICES -d 3.3.3.3/32 -p tcp -m tcp --dport 80 -j KUBE-SVC-NGINX

# 以 50% 的概率跳轉到 KUBE-SEP-NGINX1
-A KUBE-SVC-NGINX -m statistic --mode random --probability 0.50 -j KUBE-SEP-NGINX1
# 如果沒有命中上面一條,則以 100% 的概率跳轉到 KUBE-SEP-NGINX2
-A KUBE-SVC-NGINX -j KUBE-SEP-NGINX2

# 如果 src_ip=1.1.1.1/32,説明是 Service->client 流量,則
# 需要做 SNAT(MASQ 是動態版的 SNAT),替換 src_ip -> svc_ip,這樣客户端收到包時,
# 看到就是從 svc_ip 回的包,跟它期望的是一致的。
-A KUBE-SEP-NGINX1 -s 1.1.1.1/32 -j KUBE-MARK-MASQ
# 如果沒有命令上面一條,説明 src_ip != 1.1.1.1/32,則説明是 client-> Service 流量,
# 需要做 DNAT,將 svc_ip -> pod1_ip,
-A KUBE-SEP-NGINX1 -p tcp -m tcp -j DNAT --to-destination 1.1.1.1:80
# 同理,見上面兩條的註釋
-A KUBE-SEP-NGINX2 -s 1.1.1.2/32 -j KUBE-MARK-MASQ
-A KUBE-SEP-NGINX2 -p tcp -m tcp -j DNAT --to-destination 1.1.1.2:80
  1. Service 既要能被宿主機訪問,又要能被 pod 訪問( 二者位於不同的 netns ), 因此需要在 PREROUTINGOUTPUT 兩個 hook 點攔截請求,然後跳轉到自定義的 KUBE-SERVICES chain;
  2. KUBE-SERVICES chain 執行真正的 Service 匹配 ,依據協議類型、目的 IP 和目的端口號。當匹配到某個 Service 後,就會跳轉到專門針對這個 Service 創 建的 chain,命名格式為 KUBE-SVC-<Service>
  3. KUBE-SVC-<Service> chain 根據概率選擇某個後端 pod 然後將請 求轉發過去。這其實是一種 窮人的負載均衡器 —— 基於 iptables。選中某個 pod 後,會跳轉到這個 pod 相關的一條 iptables chain KUBE-SEP-<POD>
  4. KUBE-SEP-<POD> chain 會 執行 DNAT ,將 VIP 換成 PodIP。

譯註:以上解釋並不是非常詳細和直觀,因為這不是本文重點。想更深入地理解基於 iptables 的實現,可參考網上其他一些文章,例如下面這張圖所出自的博客 Kubernetes Networking Demystified: A Brief Guide

1.2 NodePort Service

這種類型的 Service 也能被宿主機和 pod 訪問,但與 ClusterIP 不同的是, 它還能被 集羣外的服務訪問

  • External node IP + port in NodePort range to any endpoint (pod), e.g. 10.0.0.1:31000
  • Enables access from outside

實現上,kube-apiserver 會 從預留的端口範圍內分配一個端口給 Service ,然後 每個宿主機上的 kube-proxy 都會創建以下規則

-t nat -A {PREROUTING, OUTPUT} -m conntrack --ctstate NEW -j KUBE-SERVICES

-A KUBE-SERVICES ! -s 1.1.0.0/16 -d 3.3.3.3/32 -p tcp -m tcp --dport 80 -j KUBE-MARK-MASQ
-A KUBE-SERVICES -d 3.3.3.3/32 -p tcp -m tcp --dport 80 -j KUBE-SVC-NGINX
# 如果前面兩條都沒匹配到(説明不是 ClusterIP service 流量),並且 dst 是 LOCAL,跳轉到 KUBE-NODEPORTS
-A KUBE-SERVICES -m addrtype --dst-type LOCAL -j KUBE-NODEPORTS

-A KUBE-NODEPORTS -p tcp -m tcp --dport 31000 -j KUBE-MARK-MASQ
-A KUBE-NODEPORTS -p tcp -m tcp --dport 31000 -j KUBE-SVC-NGINX

-A KUBE-SVC-NGINX -m statistic --mode random --probability 0.50 -j KUBE-SEP-NGINX1
-A KUBE-SVC-NGINX -j KUBE-SEP-NGINX2
  1. 前面幾步和 ClusterIP Service 一樣;如果沒匹配到 ClusterIP 規則,則跳轉到 KUBE-NODEPORTS chain。
  2. KUBE-NODEPORTS chain 裏做 Service 匹配,但 這次只匹配協議類型和目的端口號
  3. 匹配成功後,轉到對應的 KUBE-SVC-<Service> chain,後面的過程跟 ClusterIP 是一樣的。

1.3 小結

以上可以看到,每個 Service 會對應多條 iptables 規則。

Service 數量不斷增長時, iptables 規則的數量增長會更快 。而且, 每個包都需要 遍歷這些規則 ,直到最終匹配到一條相應的規則。如果不幸匹配到最後一條規則才命中, 那相比其他流量,這些包就會有 很高的延遲

有了這些背景知識,我們來看如何用 BPF/Cilium 來替換掉 kube-proxy,也可以説是 重新實現 kube-proxy 的邏輯。

2 用 Cilium/BPF 替換 kube-proxy

我們從 Cilium 早起版本開始,已經逐步用 BPF 實現 Service 功能,但其中仍然有些 地方需要用到 iptables。在這一時期,每台 node 上會同時運行 cilium-agent 和 kube-proxy。

到了 Cilium 1.6,我們已經能 完全基於 BPF 實現,不再依賴 iptables,也不再需要 kube-proxy

這裏有一些實現上的考慮:相比於在 TC ingress 層做 Service 轉換,我們優先利用 cgroupv2 hooks, 在 socket BPF 層直接做這種轉換 (需要高版本內核支持,如果不支 持則 fallback 回 TC ingress 方式)。

2.1 ClusterIP Service

對於 ClusterIP,我們在 BPF 裏 攔截 socket 的 connectsend 系統調用 ; 這些 BPF 執行時, 協議層還沒開始執行 (這些系統調用 handlers)。

BPF_PROG_TYPE_CGROUP_SOCK_ADDR
BPF_CGROUP_INET{4,6}_CONNECT

TCP & connected UDP

對於 TCP 和 connected UDP 場景,執行的是下面一段邏輯,

int sock4_xlate(struct bpf_sock_addr *ctx) {
	struct lb4_svc_key key = { .dip = ctx->user_ip4, .dport = ctx->user_port };
	svc = lb4_lookup_svc(&key)
		if (svc) {
			ctx->user_ip4 = svc->endpoint_addr;
			ctx->user_port = svc->endpoint_port;
		}
	return 1;
}

所做的事情:在 BPF map 中查找 Service,然後做地址轉換。但這裏的重點是(相比於 TC ingress BPF 實現):

  1. 不經過連接跟蹤(conntrack)模塊,也不需要修改包頭 (實際上這時候還沒有包 ),也不再 mangle 包。這也意味着, 不需要重新計算包的 checksum
  2. 對於 TCP 和 connected UDP, 負載均衡的開銷是一次性的 ,只需要在 socket 建立 時做一次轉換,後面都不需要了, 不存在包級別的轉換
  3. 這種方式是對宿主機 netns 上的 socket 和 pod netns 內的 socket 都是適用的。

某些 UDP 應用:存在的問題及解決方式

但這種方式 對某些 UDP 應用是不適用的 ,因為這些 UDP 應用會檢查包的源地址,以及 會調用 recvmsg 系統調用。

針對這個問題,我們引入了新的 BPF attach 類型:

BPF_CGROUP_UDP4_RECVMSG
BPF_CGROUP_UDP6_RECVMSG

另外還引入了用於 NAT 的 UDP map、rev-NAT map:

BPF rev NAT map
Cookie   EndpointIP  Port => ServiceID  IP       Port
-----------------------------------------------------
42       1.1.1.1     80   => 1          3.3.3.30 80
  • 通過 bpf_get_socket_cookie() 創建 socket cookie。

    除了 Service 訪問方式,還會有一些 客户端通過 PodIP 直連的方式建立 UDP 連接, cookie 就是為了防止對這些類型的流量做 rev-NAT

  • connect(2)sendmsg(2) 時更新 map。

  • recvmsg(2) 時做 rev-NAT。

2.2 NodePort Service

NodePort 會更復雜一些,我們先從最簡單的場景看起。

2.2.1 後端 pod 在本節點

後端 pod 在本節點時,只需要 在宿主機的網絡設備上 attach 一段 tc ingress bpf 程序 ,這段程序做的事情:

  1. Service 查找
  2. DNAT
  3. redirect 到容器的 lxc0。

對於應答包,lxc0 負責 rev-NAT,FIB 查找(因為我們需要設置 L2 地址,否則會被 drop), 然後將其 redirect 回客户端。

2.2.2 後端 pod 在其他節點

後端 pod 在其他節點時,會複雜一些,因為要轉發到其他節點。這種情況下, 需要在 BPF 做 SNAT ,否則 pod 會直接回包給客户端,而由於不同 node 之間沒有做連接跟蹤( conntrack)同步,因此直接回給客户端的包出 pod 後就會被 drop 掉。

所以需要 在當前節點做一次 SNATsrc_ip 從原來的 ClientIP 替換為 NodeIP),讓回包也經過 當前節點,然後在這裏再做 rev-SNAT( dst_ip 從原來的 NodeIP 替換為 ClientIP)。

具體來説,在 TC ingress 插入一段 BPF 代碼,然後依次執行:Service 查找、DNAT、 選擇合適的 egress interface、SNAT、FIB lookup,最後發送給相應的 node,

反向路徑是類似的,也是回到這個 node,TC ingress BPF 先執行 rev-SNAT,然後 rev-DNAT,FIB lookup,最後再發送回客户端,

現在跨宿主機轉發是 SNAT 模式,但將來我們打算支持 DSR 模式 (譯註,Cilium 1.8+ 已經支持了)。DSR 的好處是 backend pods 直接將包回給客户端 ,回包不再經過當前 節點轉發。

另外,現在 Service 的處理是在 TC ingress 做的, 這些邏輯其實也能夠在 XDP 層實現 , 那將會是另一件激動人心的事情(譯註,Cilium 1.8+ 已經支持了,性能大幅提升)。

SNAT

當前基於 BPF 的 SNAT 實現中,用一個 LRU BPF map 存放 Service 和 backend pods 的映 射信息。

需要説明的是,SNAT 除了替換 src_ip ,還可能會替換 src_port :不同客户端的 src_port 可能是相同的,如果只替換 src_ip ,不同客户端的應答包在反向轉換時就會失 敗。因此這種情況下需要做 src_port 轉換。現在的做法是,先進行哈希,如果哈希失敗, 就調用 prandom() 隨機選擇一個端口。

此外,我們還需要跟蹤宿主機上的流(local flows)信息,因此在 Cilium 裏 基於 BPF 實現了一個連接跟蹤器 (connection tracker),它會監聽宿主機的主物理網絡設備( main physical device);我們也會對宿主機上的應用執行 NAT,pod 流量 NAT 之後使用的 是宿主機的 src_port,而宿主機上的應用使用的也是同一個 src_port 空間,它們可能會 有衝突,因此需要在這裏處理。

這就是 NodePort Service 類型的流量到達一台節點後,我們在 BPF 所做的事情。

2.2.3 Client pods 和 backend pods 在同一節點

另外一種情況是:本機上的 pod 訪問某個 NodePort Service,而且 backend pods 也在本機。

這種情況下,流量會從 loopback 口轉發到 backend pods,中間會經歷路由和轉發過程, 整個過程對應用是透明的 —— 我們可以 在應用無感知的情況下,修改二者之間的通信方式 , 只要流量能被雙方正確地接受就行。因此,我們在這裏 使用了 ClusterIP,並對其進 行了一點擴展 ,只要連接的 Service 是 loopback 地址或者其他 local 地址,它都能正 確地轉發到本機 pods。

另外,比較好的一點是,這種實現方式是基於 cgroups 的,因此獨立於 netns。這意味着 我們不需要進入到每個 pod 的 netns 來做這種轉換。

2.3 Service 規則的規模及請求延遲對比

有了以上功能,基本上就可以避免 kube-proxy 那樣 per-service 的 iptables 規則了, 每個節點上只留下了少數幾條由 Kubernetes 自己創建的 iptables 規則:

$ iptables-save | grep ‘\-A KUBE’ | wc -l:
  • With kube-proxy: 25401
  • With BPF: 4

在將來,我們有希望連這幾條規則也不需要,完全繞開 Netfilter 框架(譯註:新版本已經做到了)。

此外,我們做了一些初步的基準測試,如下圖所示,

可以看到,隨着 Service 數量從 1 增加到 2000+, kube-proxy/iptables 的請求延 遲增加了將近一倍 ,而 Cilium/eBPF 的延遲幾乎沒有任何增加。

3 相關的 Cilium/BPF 優化

接下來介紹一些我們在實現 Service 過程中的優化工作,以及一些未來可能會做的事情。

3.1 BPF UDP recvmsg() hook

實現 socket 層 UDP Service 轉換時,我們發現如果只對 UDP sendmsg 做 hook ,會導致 DNS 等應用無法正常工作 ,會出現下面這種錯誤:

深入分析發現, nslookup 及其他一些工具會檢查 connect() 時用的 IP 地址 recvmsg() 讀到的 reply message 裏的 IP 地址 是否一致。如果不一致,就會 報上面的錯誤。

原因清楚之後,解決就比較簡單了:我們引入了一個做反向映射的 BPF hook,對 recvmsg() 做額外處理,這個問題就解決了:

983695fa6765 bpf: fix unconnected udp hooks。

這個 patch 能在不重寫包(without packet rewrite)的前提下,會對 BPF ClusterIP 做反向映射(reverse mapping)。

3.2 全局唯一 socket cookie

BPF ClusterIP Service 為 UDP 維護了一個 LRU 反向映射表(reverse mapping table)。

Socket cookie 是這個映射表的 key 的一部分,但 這個 cookie 只在每個 netns 內唯一 , 其背後的實現比較簡單:每次調用 BPF cookie helper,它都會增加計數器,然後將 cookie 存儲到 socket。因此不同 netns 內分配出來的 cookie 值可能會一樣,導致衝突。

為解決這個問題,我們將 cookie generator 改成了全局的,見下面的 commit。

cd48bdda4fb8 sock: make cookie generation global instead of per netns。

3.3 維護鄰居表

Cilium agent 從 K8s apiserver 收到 Service 事件時, 會將 backend entry 更新到 datapath 中的 Service backend 列表。

前面已經看到,當 Service 是 NodePort 類型並且 backend 是 remote 時,需要轉發到其 他節點(TC ingress BPF redirect() )。

我們發現 在某些直接路由(direct routing)的場景下,會出現 fib 查找失敗的問題fib_lookup() ),原因是系統中沒有對應 backend 的 neighbor entry(IP->MAC 映射 信息),並且接下來 不會主動做 ARP 探測 (ARP probe)。

Tunneling 模式下這個問題可以忽略,因為本來發送端的 BPF 程 序就會將 src/dst mac 清零,另一台節點對收到的包做處理時, VxLAN 設備上的另一段 BPF 程序會能夠正確的轉發這個包,因此這種方式更像是 L3 方式。

我們目前 workaround 了這個問題,解決方式有點醜陋:Cilium 解析 backend,然後直接 將 neighbor entry 永久性地( NUD_PERMANENT )插入鄰居表中。

目前這樣做是沒問題的,因為鄰居的數量是固定或者可控的(fixed/controlled number of entries)。但後面我們想嘗試的是讓內核來做這些事情,因為它能以最好的方式處理這個 問題。實現方式就是引入一些新的 NUD_* 類型,只需要傳 L3 地址,然後內核自己將解 析 L2 地址,並負責這個地址的維護。這樣 Cilium 就不需要再處理 L2 地址的事情了。 但到今天為止,我並沒有看到這種方式的可能性。

對於從集羣外來的訪問 NodePort Service 的請求,也存在類似的問題, 因為最後將響應流量回給客户端也需要鄰居表。由於這些流量都是在 pre-routing,因此我 們現在的處理方式是:自己維護了一個小的 BPF LRU map(L3->L2 mapping in BPF LRU map);由於這是主處理邏輯(轉發路徑),流量可能很高,因此將這種映射放到 BPF LRU 是更合適的,不會導致鄰居表的 overflow。

3.4 LRU BPF callback on entry eviction

我們想討論的另一件事情是:在每個 LRU entry 被 eviction(驅逐)時,能有一個 callback 將會更好。為什麼呢?

Cilium 中現在有一個 BPF conntrack table,我們支持到了一些非常老的內核版本 ,例如 4.9。Cilium 在啟動時會檢查內核版本,優先選擇使用 LRU,沒有 LRU 再 fallback 到普通的哈希表(Hash Table)。 對於哈希表,就需要一個不斷 GC 的過程

我們 有意將 NAT map 與 CT map 獨立開來 ,這是因 為我們要求在 cilium-agent 升級或降級過程中,現有的連接/流量不能受影響 。 如果二者是耦合在一起的,假如 CT 相關的東西有很大改動,那升級時那要麼 是將當前的連接狀態全部刪掉重新開始;要麼就是服務中斷,臨時不可用,升級完成後再將 老狀態遷移到新狀態表,但我認為,要輕鬆、正確地實現這件事情非常困難。 這就是為什麼將它們分開的原因。但實際上,GC 在回收 CT entry 的同時, 也會順便回收 NAT entry。

另外一個問題: 每次從 userspace 操作 conntrack entry 都會破壞 LRU 的正常工作流程 (因為不恰當地更新了所有 entry 的時間戳)。我們通過下面的 commit 解決了這個問題,但要徹底避免這個問題, 最好有一個 GC 以 callback 的方式在第一時 間清理掉這些被 evicted entry ,例如在 CT entry 被 evict 之後,順便也清理掉 NAT 映射。這是我們正在做的事情(譯註,Cilium 1.9+ 已經實現了)。

50b045a8c0cc (“bpf, lru: avoid messing with eviction heuristics upon syscall lookup”) fixed map walking from user space

3.5 LRU BPF eviction zones

另一件跟 CT map 相關的比較有意思的探討:未來 是否能根據流量類型,將 LRU eviction 分割為不同的 zone ?例如,

  • 東西向流量分到 zone1:處理 ClusterIP service 流量,都是 pod-{pod,host} 流量, 比較大;
  • 南北向流量分到 zone2:處理 NodePort 和 ExternalName service 流量,相對比較小。

這樣的好處是:當 對南北向流量 CT 進行操作時,佔大頭的東西向流量不會受影響

理想的情況是這種隔離是有保障的,例如:可以安全地假設,如果正在清理 zone1 內的 entries, 那預期不會對 zone2 內的 entry 有任何影響。不過,雖然分為了多個 zones,但在全局, 只有一個 map。

3.6 BPF 原子操作

另一個要討論的內容是原子操作。

使用場景之一是 過期 NAT entry 的快速重複利用 (fast recycling)。 例如,結合前面的 GC 過程,如果一個連接斷開時, 不是直接刪除對應的 entry,而是更 新一個標記,表明這條 entry 過期了;接下來如果有新的連接剛好命中了這個 entry,就 直接將其標記為正常(非過期),重複利用(循環)這個 entry,而不是像之前一樣從新創 建。

現在基於 BPF spinlock 可以實現做這個功能,但並不是最優的方式,因為如果有合適的原 子操作,我們就能節省兩次輔助函數調用,然後將 spinlock 移到 map 裏。將 spinlock 放到 map 結構體的額外好處是,每個結構體都有自己獨立的結構(互相解耦),因此更能 夠避免升級/降低導致的問題。

當前內核只有 BPF_XADD 指令,我認為它主要適用於計數(counting),因為它並不像原 子遞增(inc)函數一樣返回一個值。此外內核中還有的就是針對 maps 的 spinlock。

我覺得如果有 READ_ONCE/WRITE_ONCE 語義將會帶來很大便利,現在的 BPF 代碼中其實已 經有了一些這樣功能的、自己實現的代碼。此外,我們還需要 BPF_XCHG, BPF_CMPXCHG 指 令,這也將帶來很大幫助。

3.7 BPF getpeername hook

還有一個 hook —— getpeername() —— 沒有討論到,它 用在 TCP 和 connected UDP 場 景 ,對應用是透明的。

這裏的想法是:永遠返回 Service IP 而不是 backend pod IP,這樣對應用來説,它看到 就是和 Service IP 建立的連接,而不是和某個具體的 backend pod。

現在返回的是 backend IP 而不是 service IP。從應用的角度看,它連接到的對端並不是 它期望的。

3.8 繞過內核最大 BPF 指令數的限制

最後再討論幾個非內核的改動(non-kernel changes)。

內核對 BPF 最大指令數有 4K 條 的限制,現在這個限制已經放大到 1M (一百萬) 條(但需要 5.1+ 內核,或者稍低版本的內核 + 相應 patch)。

我們的 BPF 程序中包含了 NAT 引擎,因此肯定是超過這個限制的。 但 Cilium 這邊,我們目前還並未用到這個新的最大限制,而是通過“外包”的方式將 BPF 切分成了子 BPF 程序,然後通過尾調用(tail call)跳轉過去,以此來繞過這個 4K 的限 制。

另外,我們當前使用的是 BPF tail call,而不是 BPF-to-BPF call,因為 二者不能同時 使用 。更好的方式是,Cilium agent 在啟動時進行檢查,如果內核支持 1M BPF insns/complexity limit + bounded loops(我們用於 NAT mappings 查詢優化),就用這 些新特性;否則回退到尾調用的方式。

4 Cilium 上手:用 kubeadm 搭建體驗環境

有興趣嘗試 Cilium,可以參考下面的快速安裝命令:

$ kubeadm init --pod-network-cidr=10.217.0.0/16 --skip-phases=addon/kube-proxy
$ kubeadm join [...]
$ helm template cilium \
		 --namespace kube-system --set global.nodePort.enabled=true \
		 --set global.k8sServiceHost=$API_SERVER_IP \
		 --set global.k8sServicePort=$API_SERVER_PORT \
		 --set global.tag=v1.6.1 > cilium.yaml
		 kubectl apply -f cilium.yaml