动手实现 Calico CNI 插件示例
因为某种不可告人的原因,我们在 Calico 插件定制上有着非常重的执念。这次我将为大家介绍在虾皮云我们是怎么手撸网络插件的。为了方便这篇示例简化了很多细节,比如 CNI 命令行接口的详细参数和 Kubernetes 网络模型的准确翻译等,主要是为了让大家能快速的了解 CNI 插件的工作机制和 Calico 网络的原理。
CNI
首先抛开 CNI 和 CNM 的恩怨情仇和具体介绍,本质上 CNI 的网络插件实现就是一个命令行工具,实现这样的命令行接口:
zc-cni [add|del] $CONTAINER_ID $NETNS_PATH
输入容器 ID 和它对应的 network namespace path 后,插件就可以通过标准化的 CNI 接口完成 Kubernetes 网络配置,完成以下 2 个目标:
1. 多节点上的容器可以和任意容器互通
2. 节点本身可以和自己节点上的容器互通
在众多 SDN 方案里由于 Calico 的清新简约和不拘一格的实现,以及有着大规模部署的解决方案,因此很快就深入人心打成一片,顺理成章成为了虾皮云的基础设施之一。
Calico 做了啥
Calico 用人话来说,主要完成了这么几种任务:
1. 容器集群的网络配置 (Kubernetes, OpenShift)
2. 虚拟机集群的网络配置 (OpenStack)
3. non-cluster hosts, 物理机之间的网络配置
当然说到「配置」,至少是包含跨节点通讯和 ACL 权限控制两方面的。Calico 对跨节点通讯问题采用 BGP 路由方案实现了纯三层的方案,对权限控制则使用的 iptables 策略。
这里我们就不展开 CNI 的 Spec 和 Calico 的具体实现,让我们开始尝试构建一个 Calico CNI 插件吧。
Calico 基本环境和配置
搭建环境就得快刀斩乱码,骑兵变步兵,这里列一下基本过程和比较关键的配置。我这里假定各位对 Calico 配置有个大概的了解,也可以预先参考官网的文档了解下 Calico 网络初始化的过程。
首先我们需要跑一个 calico-node 容器,它会用 $HOSTNAME 注册 calico node,要注意这里的 hostname 必须是小写名字。
calicoctl node run --node-image="calico/node:release-v3.4" --disable-docker-networking
然后我们需要对这个 calico 网络配置一个 IPPool,这里就 XJB 设置 CIDR 为 10.1.0.0/16 。
cat <<! | calicoctl create -f -- apiVersion: projectcalico.org/v3kind: IPPoolmetadata:name: zcpoolspec:cidr: 10.1.0.0/16!
接着当你用 Calico 官方文档上写着的那些测试手段去测试的时候会发现哎哟这个跨机 ping 怎么就不行了,长者就说过嘛 profile 也是吼重要的!
cat <<! | calicoctl create -f -apiVersion: projectcalico.org/v3kind: Profilemetadata:name: zcpoolspec:egress:- action: Allowdestination: {}source: {}ingress:- action: Allowdestination: {}source: {}!
因为我们是使用的 CNI 体系,因此最后需要配一下 Calico CNI 的配置,把 ipam 部分指向刚才创建的 ippool:
cat <<! > /etc/cni/net.d/10-calico.conf{"name": "zc","cniVersion": "0.3.1","type": "calico","etcd_endpoints": "http://127.0.0.1:2379","ipam": {"type": "calico-ipam","ipv4_pools": ["zcpool"]},}!
什么,为什么是 /etc/cni/net.d/10-calico.conf ,唉 CNI 就喜欢搞些这莫名其妙的幺蛾子哪像隔壁 CNM 简洁明了,然而其实都不重要。好了,我们可以开始写这个插件了。
IPAM 的实现
首先,我们得实现 IPAM 的部分,因为它是用来分配 IP 的。那么什么是 IPAM,出门左拐 G 一下你就知道。由于这部分没有简单可用的命令行工具,我们只能自己用 Go 来实现一个简单的:
我把完整的代码放在了Github 上,有兴趣的可以访问这个链接: https://github.com/jschwinger23/zc-ipam/blob/master/main.go。在这我们只看最核心的 IP 分配部分:
poolsClient := client.IPPools()ipPool, err := poolsClient.Get(context.Background(), poolName, options.GetOptions{})_, ipNet, err := caliconet.ParseCIDR(ipPool.Spec.CIDR)poolV4 = []caliconet.IPNet{caliconet.IPNet{IPNet: ipNet.IPNet}}IPs, _, err := client.IPAM().AutoAssign(context.Background(),calicoipam.AutoAssignArgs{Num4: 1,Hostname: hostname,IPv4Pools: poolV4,},)
总之调用 libcalico-go/lib/ipam 提供的 IPAM().AutoAssign() 就完事了。
最终这个实现可以让我们通过命令行能够调用 zc-ipam 用于测试 IP 的分配:
# ETCD_ENDPOINTS=http://127.0.0.1:2379 ./zc-ipam addr request zcpool10.1.0.2
那么老湿能不能给力点,让我指定 IP 来分配?当然可以,不过嘛表演这个……得加钱。
设置 veth 和路由
说实话这部分讲清楚的话三天起步,想在这里短短千把字讲完那是不可能的,从 veth 的几种方式到路由的原理等,这里就不细说了。简单的来讲我们可以直接用 bash script 调用 iproute2(8) 来实现这个需求:
#! /bin/bash# /usr/local/bin/set-veth.shNS_PATH=$1IP=$2ns=$(head /dev/urandom | tr -dc a-z0-9 | head -c 8)veth=v-$nsln -s $NS_PATH /run/netns/$nsip l add $veth type veth peer name $veth-peerip l s $veth-peer upip l s $veth netns $ns name eth0ip netns exec $ns ip l s eth0 upip netns exec $ns ip a a $IP/32 dev eth0ip netns exec $ns ip r a default dev eth0echo $ns
用起来就特简单,如下所示:
# set-veth.sh $NETNS_PATH $IPV4o4z9s9lk
就这样,我们让这个 netns 和 host 之间建立起了三层的 py 关系,为之后的跨节点通讯配置做好了准备。
值得注意的是,我们在这里完全没有设置 veth peer 与 host 之间的路由,这是因为这部分工作是 calico-felix 完成的。简单的来说它是 calico-node 中的一个特殊组件,通过监听后端存储中(这里是 ETCD )里面一个叫做 WorkloadEndpoint(WEP)的数据变化来做当前 node 的路由规则设置,我们接下来来实现这部分的。
创建 Calico WEP
如上所说 calico-felix 的工作方式是通过 watch WorkloadEndpoint (WEP) 的变化对每一个 WEP 都进行内核路由表和 iptables 按照一定规则编程。WEP 本质上对应一个拥有 IPAM 分出去的 IP 的虚拟环境(容器/虚拟机/ Whatever)。我们这里通过一个脚本来做这件事情:
#! /bin/bash# /usr/local/bin/set-wep.shCONTAINER_ID=$1NS=$2IP=$3POOL=$4VETH=v-$NSMAC=$(ip l sh v-$NS-peer | grep -Po '(?<=link/ether )\w{2}(:\w{2}){5}')cat <<! | calicoctl create -f -apiVersion: projectcalico.org/v3kind: WorkloadEndpointmetadata:labels:projectcalico.org/namespace: defaultprojectcalico.org/orchestrator: zcname: localhost-zc-$NS-$NSnamespace: defaultspec:containerID: $CONT_IDworkload: $NSendpoint: $NSinterfaceName: $VETH-peeripNetworks:- $IP/32mac: $MACnode: $HOSTNAMEorchestrator: zcprofiles:- $POOL!
用这个脚本就可以隐藏一堆操作细节,这么简单的一执行:
set-wep.sh $CONT_ID $NS $IP $IPPOOL
然后你就会发现最早初始化机器后运行的的那个 calico-node 中 calico-felix 组件开始作妖了,给当前机器 X 了一堆路由表和转发规则。看不懂对吧,看不懂就对了……
完成 Calico CNI
最终我们的要实现的 zc-cni 就很简单:
#! /bin/bash# /usr/local/bin/zc-cni.shcontainer_id=$1netns_path=$2ippool=$(cat /etc/cni/net.d/10-calico.conf | jq -r '.ipam.ipv4_pool[0]')export ETCD_ENDPOINTS=$(cat /etc/cni/net.d/10-calico.conf | jq -r '.etcd_endpoints')ipv4=$(zc-ipam addr request $ippool)ns=$(set-veth.sh $netns_path $ipv4)set-wep.sh $container_id $ns $ipv4 $ippool
是的,虽然你看着它是个脚本,但通过 CNI 接口来调用的调用方其实不 care 的,白猫黑猫都是抓不到耗子的懒猫。通过以下命令我们可以绕开 Kubernetes 把这个插件用起来:
#! /bin/bash# docker-run-cni.bashcont_id=$(docker run -d --net=none busybox:latest /bin/sleep 10000000)pid=$(docker inspect -f '{{ .State.Pid }}' $cont_id)netns_path=/proc/$pid/ns/netzc-cni.sh add $cont_id $netns_pathdocker run --net=container:$cont_id ip a
在这里我们创建了两个容器,第一个 busybox 容器其实熟悉 Kubernetes 的小朋友应该能意识到这就是 pause 容器,Kubernetes Pod 模型的第一个容器。至于为什么有这个 pause 容器就说来话长了,大体上你可以理解为人类为什么会有盲肠。这个「pause」容器提供了 netns,然后我们的自定义 calico CNI 插件把这个 netns 加入到我们一开始初始化的 calico 网络,最后创建第二个真正的容器 attach 到这个 「pause」容器的 netns 上,完成整 Calico 网络的配置。这时候你按照 Calico 官网的说明,随便找个其他机器连一下,就可以看到效果了。
尾声
这里我们稍微 Dive into 了一下 Calico or whatever 网络的插件实现,基于的是 CNI 标准。其实 CNI 和 CNM 没本质上的区别,为了某些不可告人的 py 秘密 docker 啊 CNCF 撕逼的事也没少做过。至于为什么要定制网络插件呢这就涉及到像虾皮云这种规模的私有/公有云多租户固定 IP 等是必然需要考虑的需求。通过拆解整个网络的实现过程,我们可以轻易的定制化一些我们所需要的能力,如固定 IP 等,这里就不细说了。