这是 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(端点)。↩