开始之前说点题外话,距离上一篇 Flannel CNI 的发布已经快一个月了。这篇本想趁着势头在去年底完成的,正好在一个月内完成计划的所有内容。但上篇发布后不久,我中招了花了一个多周的时间才恢复。然而,恢复后的状态让我有点懵,总感觉很难集中精力,很容易精神涣散。可能接近网上流传的“脑雾”吧,而且 Cilium 也有点类似一团迷雾。再叠加网络知识的不足,eBPF 也未从涉足,学习的过程中断断续续,我曾经一度怀疑这篇会不会流产。
文章中不免会有问题,如果有发现问题或者建议,望不吝赐教。
背景
去年曾经写过一篇文章 《使用 Cilium 增强 Kubernetes 网络安全》[1] 接触过 Cilium,借助 Cilium 的网络策略从网络层面对 pod 间的通信进行限制。但当时我不曾深入其实现原理,对 Kubernetes 网络和 CNI 的了解也不够深入。这次我们通过实际的环境来探寻 Cilium 的网络。
这篇文章使用的 Cilium 版本是 v1.12.3,操作系统是 Ubuntu 20.04,内核版本是 5.4.0-91-generic。
Cilium 简介
Cilium[2] 是一个开源软件,用于提供、保护和观察容器工作负载(云原生)之间的网络连接,由革命性的内核技术 eBPF[3] 推动。
cilium-on-kubernetes
eBPF 是什么?
Linux 内核一直是实现监控/可观测性、网络和安全功能的理想地方。 不过很多情况下这并非易事,因为这些工作需要修改内核源码或加载内核模块, 最终实现形式是在已有的层层抽象之上叠加新的抽象。 eBPF 是一项革命性技术,它能在内核中运行沙箱程序(sandbox programs), 而无需修改内核源码或者加载内核模块。
将 Linux 内核变成可编程之后,就能基于现有的(而非增加新的)抽象层来打造更加智能、 功能更加丰富的基础设施软件,而不会增加系统的复杂度,也不会牺牲执行效率和安全性。
Linux 的内核在网络栈上提供了一组 BPF 钩子,通过这些钩子可以触发 BPF 程序的执行。Cilium datapah 使用这些钩子加载 BPF 程序,创建出更高级的网络结构。
通过阅读 Cilium 参考文档 eBPF Datapath[4] 得知 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[5],因为 k8e 使用 Cilium 作为默认的 CNI 实现。
还是在我的 homelab 上做个双节点(ubuntu-dev2: 192.168.1.12、ubuntu-dev3: 192.168.1.13)的集群。
Master 节点:
Worker 节点:
部署示例应用,将其调度到不同的节点上:
为了使用方便,将示例应用、cilium pod 等信息设置为环境变量:
Debug 流量
还是以前的套路,从请求发起方开始一路追寻网络包。这次使用 Service 来进行访问:curl http://10.42.0.51:80/get。
第 1 步:容器发送请求
检查 pod curl 的路由表:
可知网络包就发往以太接口 eth0,然后从使用 arp 查到其 MAC 地址 ae:36:76:3e:c3:03:
查看接口 eth0 的信息:
发现其 MAC 地址并不是 ae:36:76:3e:c3:03,从名字上的 @if43 可以得知其 veth 对的索引是 43,接着 登录到节点 NODE1 查询该索引接口的信息:
我们看到这个接口 lxc48c4aa0637ce 的 MAC 正好就是 ae:36:76:3e:c3:03。
按照 过往的经验[6],这个虚拟的以太接口 lxc48c4aa0637ce 是个 虚拟以太网口,位于主机的根网络命名空间,一方面与容器的以太接口 eth0 间通过隧道相连,发送到任何一端的网络包都会直达对端;另一方面应该与主机命名空间上的网桥相连,但是从上面的结果中并未找到网桥的名字。
通过 ip link 查看:
我们看到了多个以太接口:cilium_net、cilium_host、cilium_vxlan、cilium_health 以及与容器网络命名空间的以太接口的隧道对端 lxcxxxx。
cilium-cross-node
网络包到了 lxcxxx 这里再怎么走?接下来就轮到 eBPF 出场了。
注意 cilium_net、cilium_host 和 cilium_health 在文中不会涉及,因此不在后面的图中体现。
第 2 步:Pod1 LXC BPF Ingress
进入到当前节点的 cilium pod 也就是前面设置的变量 $cilium1 中使用 bpftool 命令检查附加该 veth 上 BPF 程序。
也可以登录到节点 $NODE1 上使用 tc 命令来查询。注意,这里我们指定了 ingress,在文章开头 datapath 部分。因为容器的 eth0 与主机网络命名空间的 lxc 组成通道,因此容器的出口(Egress)流量就是 lxc 的入口 Ingress 流量。同理,容器的入口流量就是 lxc 的出口流量。
可以通过程序 id 2901 查看详细信息。
可以看出,这里加载了 BPF 程序 bpf_lxc.o 的 from-container 部分。到 Cilium 的源码 bpf_lxc.c[7]的 __section("from-container") 部分,程序名 handle_xgress:
(1):网络包的头信息发送给 handle_xgress,然后检查其 L3 的协议。
(2):所有 IPv4 的网络包都交由 tail_handle_ipv4 来处理。
(3):核心的逻辑都在 handle_ipv4_from_lxc。tail_handle_ipv4 是如何跳转到 handle_ipv4_from_lxc,这里用到了 Tails Call[8] 。Tails call 允许我们配置在某个 BPF 程序执行完成并满足某个条件时执行指定的另一个程序,且无需返回原程序。这里不做展开有兴趣的可以参考 官方的文档[9]。
(4):接着从 eBPF map cilium_ipcache 中查询目标 endpoint,查询到 tunnel endpoint 192.168.1.13,这个地址是目标所在的节点 IP 地址,类型是。
(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 程序。
容器的出口流量对 cilium_vxlan 来说也是 engress,因此这里的程序是 to-overlay。
程序位于 `bpf_overlay.c`[10] 中,这个程序的处理很简单,如果是 IPv6 协议会将封包使用 IPv6 的地址封装一次。这里是 IPv4 ,直接返回 CTX_ACT_OK。将网络包交给内核网络栈,进入 eth0 接口。
第 4 步:NODE1 NIC BPF Egress
先看看 BPF 程序。
egress 程序 to-netdev 位于 `bpf_host.c`[11]。实际上没做重要的处理,只是返回 CTX_ACT_OK 交给内核网络栈继续处理:将网络包发送到 vxlan 隧道发送到对端,也就是节点 192.168.1.13 。中间数据的传输,实际上用的还是 underlaying 网络,从主机的 eth0 接口经过 underlaying 网络到达目标主机的 eth0接口。
第 5 步:NODE2 NIC BPF Ingress
vxlan 网络包到达节点的 eth0 接口,也会触发 BPF 程序。
这次触发的是 from-netdev,位于 bpf_host.c[12] 中。
对 vxlan tunnel 模式来说,这里的逻辑很简单。当判断网络包是 vxlan 的并确认允许 vlan 后,直接返回 CTX_ACT_OK 将处理交给内核网络栈。
第 6 步:NODE2 vxlan BPF Ingress
网络包通过内核网络栈来到了接口 cilium_vxlan。
程序位于 `bpf_overlay.c`[13] 中。
(1):lookup_ip4_endpoint 会在 eBPF map cilium_lxc 中检查目标地址是否在当前节点中(这个 map 只保存了当前节点中的 endpoint)。
这里查到目标 endpoint 的信息:id、以太网口索引、mac 地址。在 NODE2 的节点上,查看接口信息发现,这个网口是虚拟以太网设备 lxc65015af813d1,正好是 pod httpbin 接口 eth0 的对端。
(2):ipv4_local_delivery 的逻辑位于 `l3.h`[14] 中,这里会 tail-call 通过 endpoint 的 LXC ID(29)定位的 BPF 程序。
第 7 步:Pod2 LXC BPF Egress
执行下面的命令并不会找到想想中的 egress to-container(与 from-container)。
前面用的 BPF 程序都是附加到接口上的,而这里是直接有 vxlan 附加的程序直接 tail call 的。to-container 可以在 `bpf-lxc.c`[15] 中找到。
(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(端点)。
- [^2]: eBPF 的 map 可以用来存储数据,在 Cilium 中 cilium-agent 监控 api-server,将信息写入 map 中。比如这里cilium_lb4_services_v2 中维护着所有 Kubernetes Service 的信息。
参考资料
[1] 《使用 Cilium 增强 Kubernetes 网络安全》: https://atbug.com/enhance-kubernetes-network-security-with-cilium/
[2] Cilium: https://cilium.io
[3] eBPF: https://ebpf.io
[4] Cilium 参考文档 eBPF Datapath: https://docs.cilium.io/en/stable/concepts/ebpf/intro/
[5] k8e: https://getk8e.com
[6] 过往的经验: https://atbug.com/deep-dive-k8s-network-mode-and-communication/
[7] bpf_lxc.c: https://github.com/cilium/cilium/blob/1c466d26ff0edfb5021d024f755d4d00bc744792/bpf/bpf_lxc.c#L1320
[8] Tails Call: https://docs.cilium.io/en/stable/bpf/#tail-calls
[9] 官方的文档: https://docs.cilium.io/en/stable/bpf/#tail-calls
[10] bpf_overlay.c: https://github.com/cilium/cilium/blob/1c466d26ff0edfb5021d024f755d4d00bc744792/bpf/bpf_overlay.c#L528
[11] bpf_host.c: https://github.com/cilium/cilium/blob/1c466d26ff0edfb5021d024f755d4d00bc744792/bpf/bpf_host.c#L1081
[12] bpf_host.c: https://github.com/cilium/cilium/blob/1c466d26ff0edfb5021d024f755d4d00bc744792/bpf/bpf_host.c#L1040
[13] bpf_overlay.c: https://github.com/cilium/cilium/blob/1c466d26ff0edfb5021d024f755d4d00bc744792/bpf/bpf_overlay.c#L430
[14] l3.h: https://github.com/cilium/cilium/blob/1c466d26ff0edfb5021d024f755d4d00bc744792/bpf/lib/l3.h#L114
[15] bpf-lxc.c: https://github.com/cilium/cilium/blob/1c466d26ff0edfb5021d024f755d4d00bc744792/bpf/bpf_lxc.c#L2131