這是 Kubernetes 網路學習的第五篇筆記,也是之前計劃中的最後一篇。
- 深入探索 Kubernetes 網路模型和網路通訊
- 認識一下容器網路介面 CNI
- 原始碼分析:從 kubelet、容器執行時看 CNI 的使用
- 從 Flannel 學習 Kubernetes VXLAN 網路
- Cilium CNI 與 eBPF(本篇)
- ...
開始之前說點題外話,距離上一篇 Flannel CNI 的釋出已經快一個月了。這篇本想趁著勢頭在去年底完成的,正好在一個月內完成計劃的所有內容。但上篇釋出後不久,我中招了花了一個多周的時間才恢復。然而,恢復後的狀態讓我有點懵,總感覺很難集中精力,很容易精神渙散。可能接近網上流傳的“腦霧”吧,而且 Cilium 也有點類似一團迷霧。再疊加網路知識的不足,eBPF 也未從涉足,學習的過程中斷斷續續,我曾經一度懷疑這篇會不會流產。
文章中不免會有問題,如果有發現問題或者建議,望不吝賜教。
背景
去年曾經寫過一篇文章 《使用 Cilium 增強 Kubernetes 網路安全》 接觸過 Cilium,藉助 Cilium 的網路策略從網路層面對 pod 間的通訊進行限制。但當時我不曾深入其實現原理,對 Kubernetes 網路和 CNI 的瞭解也不夠深入。這次我們透過實際的環境來探尋 Cilium 的網路。
這篇文章使用的 Cilium 版本是 v1.12.3,作業系統是 Ubuntu 20.04,核心版本是 5.4.0-91-generic。
Cilium 簡介
Cilium 是一個開源軟體,用於提供、保護和觀察容器工作負載(雲原生)之間的網路連線,由革命性的核心技術 eBPF 推動。
eBPF 是什麼?
Linux 核心一直是實現監控/可觀測性、網路和安全功能的理想地方。 不過很多情況下這並非易事,因為這些工作需要修改核心原始碼或載入核心模組, 最終實現形式是在已有的層層抽象之上疊加新的抽象。 eBPF 是一項革命性技術,它能在核心中執行沙箱程式(sandbox programs), 而無需修改核心原始碼或者載入核心模組。
將 Linux 核心變成可程式設計之後,就能基於現有的(而非增加新的)抽象層來打造更加智慧、 功能更加豐富的基礎設施軟體,而不會增加系統的複雜度,也不會犧牲執行效率和安全性。
Linux 的核心在網路棧上提供了一組 BPF 鉤子,透過這些鉤子可以觸發 BPF 程式的執行。Cilium datapah 使用這些鉤子載入 BPF 程式,建立出更高階的網路結構。
透過閱讀 Cilium 參考文件 eBPF Datapath 得知 Cilium 使用了下面幾種鉤子:
- XDP:這是網路驅動中接收網路包時就可以觸發 BPF 程式的鉤子,也是最早的點。由於此時還沒有執行其他操作,比如將網路包寫入記憶體,所以它非常適合執行刪除惡意或意外流量的過濾程式,以及其他常見的 DDOS 保護機制。
- Traffic Control Ingress/Egress:附加到流量控制(traffic control,簡稱 tc)ingress 鉤子上的 BPF 程式,可以被附加到網路介面上。這種鉤子在網路棧的 L3 之前執行,並可以訪問網路包的大部分後設資料。適合處理本節點的操作,比如應用 L3/L4 的端點 1 策略、轉發流量到端點。CNI 通常使用虛擬機器以太介面對
veth
將容器連線到主機的網路名稱空間。使用附加到主機端veth
的 tc ingress 鉤子,可以監控離開容器的所有流量,並執行策略。同時將另一個 BPF 程式附加到 tc egress 鉤子,Cilium 可以監控所有進出節點的流量並執行策略 . - Socket operations:套接字操作鉤子附加到特定的 cgroup 並在 TCP 事件上執行。Cilium 將 BPF 套接字操作程式附加到根 cgroup,並使用它來監控 TCP 狀態轉換,特別是 ESTABLISHED 狀態轉換。當套接字狀態變為 ESTABLISHED 時,如果 TCP 套接字的對端也在當前節點(也可能是本地代理),則會附加 Socket send/recv 程式。
- Socket send/recv:這個鉤子在 TCP 套接字執行的每個傳送操作上執行。此時鉤子可以檢查訊息並丟棄訊息、將訊息傳送到 TCP 層,或者將訊息重定向到另一個套接字。Cilium 使用它來加速資料路徑重定向。
因為後面會用到,這裡著重介紹了這幾種鉤子。
環境搭建
前面幾篇文章,我都是使用 k3s 並手動安裝 CNI 外掛來搭建實驗環境。這次,我們直接使用 k8e,因為 k8e 使用 Cilium 作為預設的 CNI 實現。
還是在我的 homelab 上做個雙節點(ubuntu-dev2: 192.168.1.12
、ubuntu-dev3: 192.168.1.13
)的叢集。
Master 節點:
curl -sfL https://getk8e.com/install.sh | API_SERVER_IP=192.168.1.12 K8E_TOKEN=ilovek8e INSTALL_K8E_EXEC="server --cluster-init --write-kubeconfig-mode 644 --write-kubeconfig ~/.kube/config" sh -
Worker 節點:
curl -sfL https://getk8e.com/install.sh | K8E_TOKEN=ilovek8e K8E_URL=https://192.168.1.12:6443 sh -
部署示例應用,將其排程到不同的節點上:
NODE1=ubuntu-dev2
NODE2=ubuntu-dev3
kubectl apply -n default -f - <<EOF
apiVersion: v1
kind: Pod
metadata:
labels:
app: curl
name: curl
spec:
containers:
- image: curlimages/curl
name: curl
command: ["sleep", "365d"]
nodeName: $NODE1
---
apiVersion: v1
kind: Pod
metadata:
labels:
app: httpbin
name: httpbin
spec:
containers:
- image: kennethreitz/httpbin
name: httpbin
nodeName: $NODE2
EOF
為了使用方便,將示例應用、cilium pod 等資訊設定為環境變數:
NODE1=ubuntu-dev2
NODE2=ubuntu-dev3
cilium1=$(kubectl get po -n kube-system -l k8s-app=cilium --field-selector spec.nodeName=$NODE1 -o jsonpath='{.items[0].metadata.name}')
cilium2=$(kubectl get po -n kube-system -l k8s-app=cilium --field-selector spec.nodeName=$NODE2 -o jsonpath='{.items[0].metadata.name}')
Debug 流量
還是以前的套路,從請求發起方開始一路追尋網路包。這次使用 Service
來進行訪問:curl http://10.42.0.51:80/get
。
kubectl get po httpbin -n default -o wide
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
httpbin 1/1 Running 0 3m 10.42.0.51 ubuntu-dev3 <none> <none>
第 1 步:容器傳送請求
檢查 pod curl
的路由表:
kubectl exec curl -n default -- ip route get 10.42.0.51
10.42.0.51 via 10.42.1.247 dev eth0 src 10.42.1.80
可知網路包就發往以太介面 eth0
,然後從使用 arp 查到其 MAC 地址 ae:36:76:3e:c3:03
:
kubectl exec curl -n default -- arp -n
? (10.42.1.247) at ae:36:76:3e:c3:03 [ether] on eth0
檢視介面 eth0
的資訊:
kubectl exec curl -n default -- ip link show eth0
42: eth0@if43: <BROADCAST,MULTICAST,UP,LOWER_UP,M-DOWN> mtu 1500 qdisc noqueue state UP qlen 1000
link/ether f6:00:50:f9:92:a1 brd ff:ff:ff:ff:ff:ff
發現其 MAC 地址並不是 ae:36:76:3e:c3:03
,從名字上的 @if43
可以得知其 veth
對的索引是 43
,接著 登入到節點 NODE1
查詢該索引介面的資訊:
ip link | grep -A1 ^43
43: lxc48c4aa0637ce@if42: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP mode DEFAULT group default qlen 1000
link/ether ae:36:76:3e:c3:03 brd ff:ff:ff:ff:ff:ff link-netns cni-407cd7d8-7c02-cfa7-bf93-22946f923ffd
我們看到這個介面 lxc48c4aa0637ce
的 MAC 正好就是 ae:36:76:3e:c3:03
。
按照 過往的經驗,這個虛擬的以太介面 lxc48c4aa0637ce
是個 虛擬乙太網口,位於主機的根網路名稱空間,一方面與容器的以太介面 eth0
間透過隧道相連,傳送到任何一端的網路包都會直達對端;另一方面應該與主機名稱空間上的網橋相連,但是從上面的結果中並未找到網橋的名字。
透過 ip link
檢視:
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP mode DEFAULT group default qlen 1000
link/ether fa:cb:49:4a:28:21 brd ff:ff:ff:ff:ff:ff
3: cilium_net@cilium_host: <BROADCAST,MULTICAST,NOARP,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP mode DEFAULT group default qlen 1000
link/ether 36:d5:5a:2a:ce:80 brd ff:ff:ff:ff:ff:ff
4: cilium_host@cilium_net: <BROADCAST,MULTICAST,NOARP,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP mode DEFAULT group default qlen 1000
link/ether 12:82:fb:78:16:6a brd ff:ff:ff:ff:ff:ff
5: cilium_vxlan: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
link/ether fa:42:4d:22:b7:d0 brd ff:ff:ff:ff:ff:ff
25: lxc_health@if24: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP mode DEFAULT group default qlen 1000
link/ether 3e:4f:b3:56:67:2b brd ff:ff:ff:ff:ff:ff link-netnsid 0
33: lxc113dd6a50a7a@if32: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP mode DEFAULT group default qlen 1000
link/ether 32:3a:5b:15:44:ff brd ff:ff:ff:ff:ff:ff link-netns cni-07cffbd8-83dd-dcc1-0b57-5c59c1c037e9
43: lxc48c4aa0637ce@if42: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP mode DEFAULT group default qlen 1000
link/ether ae:36:76:3e:c3:03 brd ff:ff:ff:ff:ff:ff link-netns cni-407cd7d8-7c02-cfa7-bf93-22946f923ffd
我們看到了多個以太介面:cilium_net
、cilium_host
、cilium_vxlan
、cilium_health
以及與容器網路名稱空間的以太介面的隧道對端 lxcxxxx
。
網路包到了 lxcxxx
這裡再怎麼走?接下來就輪到 eBPF 出場了。
注意 cilium_net
、cilium_host
和 cilium_health
在文中不會涉及,因此不在後面的圖中體現。
第 2 步:Pod1 LXC BPF Ingress
進入到當前節點的 cilium pod 也就是前面設定的變數 $cilium1
中使用 bpftool
命令檢查附加該 veth 上 BPF 程式。
kubectl exec -n kube-system $cilium1 -c cilium-agent -- bpftool net show dev lxc48c4aa0637ce
xdp:
tc:
lxc48c4aa0637ce(43) clsact/ingress bpf_lxc.o:[from-container] id 2901
flow_dissector:
也可以登入到節點 $NODE1
上使用 tc
命令來查詢。注意,這裡我們指定了 ingress
,在文章開頭 datapath 部分。因為容器的 eth0
與主機網路名稱空間的 lxc
組成通道,因此容器的出口(Egress)流量就是 lxc
的入口 Ingress
流量。同理,容器的入口流量就是 lxc
的出口流量。
#on NODE1
tc filter show dev lxc48c4aa0637ce ingress
filter protocol all pref 1 bpf chain 0
filter protocol all pref 1 bpf chain 0 handle 0x1 bpf_lxc.o:[from-container] direct-action not_in_hw id 2901 tag d578585f7e71464b jited
可以透過程式 id 2901
檢視詳細資訊。
kubectl exec -n kube-system $cilium1 -c cilium-agent -- bpftool prog show id 2901
2901: sched_cls name handle_xgress tag d578585f7e71464b gpl
loaded_at 2023-01-09T19:29:52+0000 uid 0
xlated 688B jited 589B memlock 4096B map_ids 572,86
btf_id 301
可以看出,這裡載入了 BPF 程式 bpf_lxc.o
的 from-container
部分。到 Cilium 的原始碼 bpf_lxc.c 的 __section("from-container")
部分,程式名 handle_xgress
:
handle_xgress #1
validate_ethertype(ctx, &proto)
tail_handle_ipv4 #2
handle_ipv4_from_lxc #3
lookup_ip4_remote_endpoint => ipcache_lookup4 #4
policy_can_access #5
if TUNNEL_MODE #6
encap_and_redirect_lxc
ctx_redirect(ctx, ENCAP_IFINDEX, 0)
if ENABLE_ROUTING
ipv4_l3
return CTX_ACT_OK;
(1):網路包的頭資訊傳送給 handle_xgress
,然後檢查其 L3 的協議。
(2):所有 IPv4 的網路包都交由 tail_handle_ipv4
來處理。
(3):核心的邏輯都在 handle_ipv4_from_lxc
。tail_handle_ipv4
是如何跳轉到 handle_ipv4_from_lxc
,這裡用到了 Tails Call 。Tails call 允許我們配置在某個 BPF 程式執行完成並滿足某個條件時執行指定的另一個程式,且無需返回原程式。這裡不做展開有興趣的可以參考 官方的文件。
(4):接著從 eBPF map cilium_ipcache
中查詢目標 endpoint,查詢到 tunnel endpoint 192.168.1.13
,這個地址是目標所在的節點 IP 地址,型別是。
kubectl exec -n kube-system $cilium1 -c cilium-agent -- cilium map get cilium_ipcache | grep 10.42.0.51
10.42.0.51/32 identity=15773 encryptkey=0 tunnelendpoint=192.168.1.13 sync
(5):policy_can_access
這裡是執行出口策略的檢查,本文不涉及故不展開。
(6):之後的處理會有兩種模式:
- 直接路由:交由核心網路棧進行處理,或者 underlaying SDN 的支援。
- 隧道:會將網路包再次封裝,透過隧道傳輸,比如 vxlan。
這裡我們使用的也是隧道模式。網路包交給 encap_and_redirect_lxc
處理,使用 tunnel endpoint 作為隧道對端。最終轉發給 ENCAP_IFINDEX
(這個值是介面的索引值,由 cilium-agent 啟動時獲取的),就是乙太網介面 cilium_vxlan
。
第 3 步:NODE 1 vxlan BPF Egress
先看下這個介面上的 BPF 程式。
kubectl exec -n kube-system $cilium1 -c cilium-agent -- bpftool net show dev cilium_vxlan
xdp:
tc:
cilium_vxlan(5) clsact/ingress bpf_overlay.o:[from-overlay] id 2699
cilium_vxlan(5) clsact/egress bpf_overlay.o:[to-overlay] id 2707
flow_dissector:
容器的出口流量對 cilium_vxlan
來說也是 engress,因此這裡的程式是 to-overlay
。
程式位於 bpf_overlay.c
中,這個程式的處理很簡單,如果是 IPv6 協議會將封包使用 IPv6 的地址封裝一次。這裡是 IPv4 ,直接返回 CTX_ACT_OK
。將網路包交給核心網路棧,進入 eth0
介面。
第 4 步:NODE1 NIC BPF Egress
先看看 BPF 程式。
kubectl exec -n kube-system $cilium1 -c cilium-agent -- bpftool net show dev eth0
xdp:
tc:
eth0(2) clsact/ingress bpf_netdev_eth0.o:[from-netdev] id 2823
eth0(2) clsact/egress bpf_netdev_eth0.o:[to-netdev] id 2832
flow_dissector:
egress 程式 to-netdev
位於 bpf_host.c
。實際上沒做重要的處理,只是返回 CTX_ACT_OK
交給核心網路棧繼續處理:將網路包傳送到 vxlan 隧道傳送到對端,也就是節點 192.168.1.13
。中間資料的傳輸,實際上用的還是 underlaying 網路,從主機的 eth0
介面經過 underlaying 網路到達目標主機的 eth0
介面。
第 5 步:NODE2 NIC BPF Ingress
vxlan 網路包到達節點的 eth0
介面,也會觸發 BPF 程式。
kubectl exec -n kube-system $cilium2 -c cilium-agent -- bpftool net show dev eth0
xdp:
tc:
eth0(2) clsact/ingress bpf_netdev_eth0.o:[from-netdev] id 4556
eth0(2) clsact/egress bpf_netdev_eth0.o:[to-netdev] id 4565
flow_dissector:
這次觸發的是 from-netdev
,位於 bpf_host.c 中。
from_netdev
if vlan
allow_vlan
return CTX_ACT_OK
對 vxlan tunnel 模式來說,這裡的邏輯很簡單。當判斷網路包是 vxlan 的並確認允許 vlan 後,直接返回 CTX_ACT_OK
將處理交給核心網路棧。
第 6 步:NODE2 vxlan BPF Ingress
網路包透過核心網路棧來到了介面 cilium_vxlan
。
kubectl exec -n kube-system $cilium2 -c cilium-agent -- bpftool net show dev cilium_vxlan
xdp:
tc:
cilium_vxlan(5) clsact/ingress bpf_overlay.o:[from-overlay] id 4468
cilium_vxlan(5) clsact/egress bpf_overlay.o:[to-overlay] id 4476
flow_dissector:
程式位於 bpf_overlay.c
中。
from_overlay
validate_ethertype
tail_handle_ipv4
handle_ipv4
lookup_ip4_endpoint 1#
map_lookup_elem
ipv4_local_delivery 2#
tail_call_dynamic 3#
(1):lookup_ip4_endpoint
會在 eBPF map cilium_lxc
中檢查目標地址是否在當前節點中(這個 map 只儲存了當前節點中的 endpoint)。
kubectl exec -n kube-system $cilium2 -c cilium-agent -- cilium map get cilium_lxc | grep 10.42.0.51
10.42.0.51:0 id=2826 flags=0x0000 ifindex=29 mac=96:86:44:A6:37:EC nodemac=D2:AD:65:4D:D0:7B sync
這裡查到目標 endpoint 的資訊:id、乙太網口索引、mac 地址。在 NODE2 的節點上,檢視介面資訊發現,這個網口是虛擬乙太網裝置 lxc65015af813d1
,正好是 pod httpbin
介面 eth0
的對端。
ip link | grep -B1 -i d2:ad
29: lxc65015af813d1@if28: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP mode DEFAULT group default qlen 1000
link/ether d2:ad:65:4d:d0:7b brd ff:ff:ff:ff:ff:ff link-netns cni-395674eb-172b-2234-a9ad-1db78b2a5beb
kubectl exec -n default httpbin -- ip link
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
28: eth0@if29: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP mode DEFAULT group default qlen 1000
link/ether 96:86:44:a6:37:ec brd ff:ff:ff:ff:ff:ff link-netnsid
(2):ipv4_local_delivery
的邏輯位於 l3.h
中,這裡會 tail-call 透過 endpoint 的 LXC ID(29
)定位的 BPF 程式。
第 7 步:Pod2 LXC BPF Egress
執行下面的命令並不會找到想想中的 egress to-container
(與 from-container
)。
kubectl exec -n kube-system $cilium2 -c cilium-agent -- bpftool net show | grep 29
lxc65015af813d1(29) clsact/ingress bpf_lxc.o:[from-container] id 4670
前面用的 BPF 程式都是附加到介面上的,而這裡是直接有 vxlan 附加的程式直接 tail call 的。to-container
可以在 bpf-lxc.c
中找到。
handle_to_container
tail_ipv4_to_endpoint
ipv4_policy #1
policy_can_access_ingress
redirect_ep
ctx_redirect
(1):ipv4_policy
會執行配置的策略
(2):如果策略透過,會呼叫 redirect_ep
將網路包傳送到虛擬以太介面 lxc65015af813d1
,進入到 veth 後會直達與其相連的容器 eth0
介面。
第 8 步:到達 Pod2
網路包到達 pod2,附上一張完成的圖。
總結
說說個人看法吧,本文設計的內容還只是 Cilium 的冰山一角,對於核心知識和 C 語言欠缺的我來說研究起來非常吃力。Cilium 除此之外還有很多的內容,也還沒有深入去研究。不得不感嘆,Cilium 真是複雜,以我目前的瞭解,Cilium 維護了一套自己的資料在 BPF map 中,比如 endpoint、節點、策略、路由、連線狀態等相當多的資料,這些都是儲存在核心中;再就是 BPF 程式的開發和維護成本會隨著功能的複雜度而膨脹,很難想象如果用 BPF 程式去開發 L7 的功能會多複雜。這應該是為什麼會藉助代理去處理 L7 的場景。
最後分享下學習 Cilium 過程中的經驗吧。
首先是 BPF 程式的閱讀,在專案的 bpf
的程式碼都是靜態的程式碼,裡面分佈著很多的與配置相關的 if else
,執行時會根據配置進行編譯。這種情況下可以進入 Cilium pod,在目錄 /run/cilium/state/templates
下有應用配置後的原始檔,程式碼量會少很多;在 /run/cilium/state/globals/node_config
下是當前使用的配置,可以結合這些配置來閱讀程式碼。
腳註
- Cilium 透過為容器分配 IP 地址使其在網路上可用。多個容器可以共享同一個 IP 地址,就像 一個 Kubernetes Pod 中可以有多個容器,這些容器之間共享網路名稱空間,使用同一個 IP 地址。這些共享同一個地址的容器,Cilium 將其組合起來,成為 Endpoint(端點)。 ↩