2020 年 9 月,UCloud 上线了 Serverless 容器产品 Cube,它具备了虚拟机级别的安全隔离、轻量化的系统占用、秒级的启动速度,高度自动化的弹性伸缩,以及简洁明了的易用性。结合虚拟节点技术(Virtual Kubelet),Cube 可以和 UCloud 容器托管产品 UK8S 无缝对接,极大地丰富了 Kubernetes 集群的弹性能力。如下图所示,Virtual Node 作为一个虚拟 Node 在 Kubernetes 集群中,每个 Cube 实例被视为 VK 节点上的一个 Pod。
然而,Virtual Kubelet 仅仅实现了集群中 Cube 实例的弹性伸缩。要使得 Cube 实例正式成为 K8s 集群大家庭的一员,运行在 Cube 中的应用需要能利用 K8s 的服务发现能力,即访问 Service 地址。
众所周知, kube-proxy 为 K8s 实现了 service 流量负载均衡。kube-proxy 不断感知 K8s 内 Service 和 Endpoints 地址的对应关系及其变化,生成 ServiceIP 的流量转发规则。它提供了三种转发实现机制:userspace, iptables 和 ipvs, 其中 userspace 由于较高的性能代价已不再被使用。
然而,我们发现,直接把 kube-proxy 部署在 Cube 虚拟机内部并不合适,有如下原因:
1 、kube-proxy 采用 go 语言开发,编译产生的目标文件体积庞大。以 K8s v1.19.5 linux 环境为例,经 strip 过的 kube-proxy ELF 可执行文件大小为 37MB。对于普通 K8s 环境来说,这个体积可以忽略不计;但对于 Serverless 产品来说,为了保证秒起轻量级虚拟机,虚拟机操作系统和镜像需要高度裁剪,寸土寸金,我们想要一个部署体积不超过 10MB 的 proxy 控制程序。
2 、kube-proxy 的运行性能问题。同样由于使用 go 语言开发,相对于 C/C++和 Rust 等无 gc、具备精细控制底层资源能力的高级语言来说,要付出更多的性能代价。Cube 通常存在较细粒度的资源交付配额,例如 0.5C 500MiB,我们不希望 kube-proxy 这类辅助组件喧宾夺主。
3 、ipvs 的问题。在 eBPF 被广为周知之前,ipvs 被认为是最合理的 K8s service 转发面实现。iptables 因为扩展性问题被鞭尸已久,ipvs 却能随着 services 和 endpoints 规模增大依然保持稳定的转发能力和较低的规则刷新间隔。
但事实是,ipvs 并不完美,甚至存在严重的问题。
例如,同样实现 nat , iptables 是在 PREROUTING 或者 OUTPUT 完成 DNAT;而 ipvs 需要经历 INPUT 和 OUTPUT,链路更长。因此,较少 svc 和 ep 数量下的 service ip 压测场景下,无论是带宽还是短连接请求延迟,ipvs 都会获得全场最低分。此外,conn_reuse_mode 的参数为 1 导致的滚动发布时服务访问失败的问题至今(2021 年 4 月)也解决的不太干净。
4 、iptables 的问题。扩展差,更新慢,O(n)时间复杂度的规则查找(这几句话背不出来是找不到一份 K8s 相关的工作的), 同样的问题还会出现在基于 iptables 实现的 NetworkPolicy 上。1.6.2 以下 iptables 甚至不支持 full_random 端口选择,导致 SNAT 的性能在高并发短连接的业务场景下雪上加霜。
eBPF 近年来被视为 linux 的革命性技术,它允许开发者在 linux 的内核里动态实时地加载运行自己编写的沙盒程序,无需更改内核源码或者加载内核模块。同时,用户态的程序可以通过 bpf(2)系统调用和 bpf map 结构与内核中的 eBPF 程序实时交换数据,如下图所示。
编写好的 eBPF 程序在内核中以事件触发的模式运行,这些事件可以是系统调用入出口,网络收发包的关键路径点(xdp, tc, qdisc, socket),内核函数入出口 kprobes/kretprobes 和用户态函数入出口 uprobes/uretprobes 等。加载到网络收发路径的 hook 点的 eBPF 程序通常用于控制和修改网络报文, 来实现负载均衡,安全策略和监控观测。
cilium 的出现使得 eBPF 正式进入 K8s 的视野,并正在深刻地改变 k8s 的网络,安全,负载均衡,可观测性等领域。从 1.6 开始,cilium 可以 100%替换 kube-proxy,真正通过 eBPF 实现了 kube-proxy 的全部转发功能。让我们首先考察一下 ClusterIP(东西流量)的实现。
无论对于 TCP 还是 UDP 来说,客户端访问 ClusterIP 只需要实现针对 ClusterIP 的 DNAT,把 Frontend 与对应的 Backends 地址记录在 eBPF map 中,这个表的内容即为后面执行 DNAT 的依据。那这个 DNAT 在什么环节实现呢?
对于一般环境,DNAT 操作可以发生在 tc egress,同时在 tc ingress
中对回程的流量进行反向操作,即将源地址由真实的 PodIP 改成 ClusterIP, 此外完成 NAT 后需要重新计算 IP 和 TCP 头部的 checksum。
如果是支持 cgroup2 的 linux 环境,使用 cgroup2 的 sockaddr hook 点进行 DNAT。cgroup2 为一些需要引用 L4 地址的 socket 系统调用,如 connect(2), sendmsg(2), recvmsg(2)提供了一个 BPF 拦截层(BPF_PROG_TYPE_CGROUP_SOCK_ADDR)。这些 BPF 程序可以在 packet 生成之前完成对目的地址的修改,如下图所示。
对于 tcp 和有连接的 udp 的流量(即针对 udp fd 调用过 connect(2))来说, 只需要做一次正向转换,即利用 bpf 程序,将出向流量的目的地址改成 Pod 的地址。这种场景下,负载均衡是最高效的,因为开销一次性的,作用效果则持续贯穿整个通信流的生命周期。
而对于无连接的 udp 流量,还需要做一次反向转换,即将来自 Pod 的入向流量做一个 SNAT,将源地址改回 ClusterIP。如果缺了这一步操作,基于 recvmsg 的 UDP 应用会无法收到来自 ClusterIP 的消息,因为 socket 的对端地址被改写成了 Pod 的地址。流量示意图如下所示。
综述,这是一种用户无感知的地址转换。用户认为自己连接的地址是 Service, 但实际的 tcp 连接直接指向 Pod。一个能说明问题的对比是,当你使用 kube-proxy 的时候,在 Pod 中进行 tcpdump 时,你能发现目的地址依然是 ClusterIP,因为 ipvs 或者 iptables 规则在 host 上;当你使用 cilium 时,在 Pod 中进行 tcpdump,你已经能发现目的地址是 Backend Pod。NAT 不需要借助 conntrack 就能完成,相对于 ipvs 和 iptables 来说,转发路径减少,性能更优。而对比刚才提到的 tc-bpf,它更轻量,无需重新计算 checksum。
Cube 为每个需要开启 ClusterIP 访问功能的 Serverless 容器组启动了一个叫 cproxy 的 agent 程序来实现 kube-proxy 的核心功能。由于 Cube 的轻量级虚拟机镜像使用较高版本的 linux 内核,cproxy 采用了上述 cgroup2 socket hook 的方式进行 ClusterIP 转发。cproxy 使用 Rust 开发,编译后的目标文件只有不到 10MiB。运行开销相比 kube-proxy 也有不小优势。部署结构如下所示。
以下是一些测试情况对比。我们使用 wrk 对 ClusterIP 进行 2000 并发 HTTP 短连接测试,分别比较 svc 数量为 10 和 svc 数量为 5000,观察请求耗时情况(单位 ms)。
结论是 cproxy 无论在 svc 数量较少和较多的情况下,都拥有最好的性能;ipvs 在 svc 数量较大的情况下性能远好于 iptables,但在 svc 数量较小的情况下,性能不如 iptables。
svc数量=10
svc数量=5000
后续我们会继续完善基于 eBPF 实现 LoadBalancer(南北流量)转发,以及基于 eBPF 的网络访问策略(NetworkPolicy)。
eBPF 正在改变云原生生态, 未来 UCloud 容器云产品 UK8S 与 Serverless 容器产品 Cube 将紧密结合业内最新进展,挖掘 eBPF 在网络,负载均衡,监控等领域的应用,为用户提供更好的观测、定位和调优能力。
你可能还喜欢
点击下方图片即可阅读
基于 WireGuard 和 OpenVPN 的混合云基础架构建设