Kubernetes 網路學習之 Cilium 與 eBPF

雲原生指北發表於2023-01-12

這是 Kubernetes 網路學習的第五篇筆記,也是之前計劃中的最後一篇。

開始之前說點題外話,距離上一篇 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 推動。

cilium-on-kubernetes

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.12ubuntu-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_netcilium_hostcilium_vxlancilium_health 以及與容器網路名稱空間的以太介面的隧道對端 lxcxxxx

cilium-cross-node

網路包到了 lxcxxx 這裡再怎麼走?接下來就輪到 eBPF 出場了。

注意 cilium_netcilium_hostcilium_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.ofrom-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_lxctail_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-packet-flow

總結

說說個人看法吧,本文設計的內容還只是 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 下是當前使用的配置,可以結合這些配置來閱讀程式碼。

腳註


  1. Cilium 透過為容器分配 IP 地址使其在網路上可用。多個容器可以共享同一個 IP 地址,就像 一個 Kubernetes Pod 中可以有多個容器,這些容器之間共享網路名稱空間,使用同一個 IP 地址。這些共享同一個地址的容器,Cilium 將其組合起來,成為 Endpoint(端點)。

相關文章