CNI From Scratch

Write a simple CNI plugin from scratch.

在编写自己的 CNI 插件前,我们需要先了解一下 CNI Spec

规范规定,CNI v0.4.0 必须实现的接口有 ADDDELCHECKVERSION 四个。不过,对于学习目的来说,我们主要关注 ADD 接口即可。

  • ADD:将容器添加到网络中。
    • 参数:
      • 容器 ID。需要是唯一的非空字符串,由运行时生成。
      • Network Namespace 路径。如 /proc/[pid]/ns/net
      • 网络配置。
      • 额外参数。
      • 容器内的设备名。
    • 返回值:
      • 设备列表。
      • IP 列表。
      • DNS 信息。

在调用 CNI 插件时,运行时会设置如下环境变量:

  • CNI_COMMAND:需要执行的命令,如 ADD
  • CNI_CONTAINERID: 容器 ID。
  • CNI_NETNS: Network Namespace 路径。
  • CNI_IFNAME:设备名。
  • CNI_ARGS: 额外参数。
  • CNI_PATH: CNI 插件的 PATH 列表。

我们主要关注前四个参数。


使用双 KVM 节点的 Minikube 作为实验环境。

minikube start -n 2 --driver kvm2 --kubernetes-version v1.20.0 --memory 2000

在 minikube 启动后,默认是使用 Kindnet 作为 CNI 插件,可在 kube-system 中看到。

检查节点的 Pod CIDR:

kubectl describe nodes | grep CIDR

以 master 节点为 10.244.0.0/24,worker 节点为 10.244.1.0/24 为例。


先来几个工具函数。

MINIKUBE_HOME=$HOME/.minikube

profile() {
    MINIKUBE_PROFILE=${1}
    IP=$(jq -r ".Driver.IPAddress" $MINIKUBE_HOME/machines/$MINIKUBE_PROFILE/config.json)
    SSH_OPT="-o PasswordAuthentication=no -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -o LogLevel=quiet -i $MINIKUBE_HOME/machines/$MINIKUBE_PROFILE/id_rsa"
    SSH="ssh $SSH_OPT docker@$IP"
    SCP="scp $SSH_OPT"
}

函数 profile 可以设置 SSHSCP 两个环境变量,方便后续脚本编写。


首先,我们需要为每个节点创建一个与容器网络连接的网桥 cni0,占据 .1 的 IP。

profile "minikube"
$SSH sudo brctl addbr cni0
$SSH sudo ip link set cni0 up
$SSH sudo ip addr add 10.244.0.1/24 dev cni0

profile "minikube-m02"
$SSH sudo brctl addbr cni0
$SSH sudo ip link set cni0 up
$SSH sudo ip addr add 10.244.1.1/24 dev cni0

由于两个 VM 在一个子网下,此时在任意一个 VM 里应该都能 ping 通另一个 VM 的 cni0

然后就可以开始编写 CNI 插件了。

#!/bin/bash -e

exec 3>&1
exec &>> /var/log/bash-cni-plugin.log

echo "CNI command: $CNI_COMMAND" 

stdin=`cat /dev/stdin`
echo "stdin: $stdin"

到这一步为止,将日志打到 /var/log/bash-cni-plugin.log 中。

考虑 IP 分配的问题。我们简单地假设 netmask 为 24,将已分配的 IP 记录到一个文件中,每次分配时读取一次这个文件,将其加一。

IP_STORE=/tmp/reserved_ips

allocate_ip() {
  if [[ ! -f $IP_STORE ]]; then
    echo "1" > $IP_STORE # 1 被 cni0 占据
  fi

  local LAST_USED=$(cat $IP_STORE)
  local NEXT=$(($LAST_USED + 1))
  echo $NEXT > $IP_STORE
  echo $NEXT
  return
}

get_ip_from_subnet() {
  local SUBNET=${1}
  local D=${2}
  local IP_PREFIX=$(echo "$SUBNET" | cut -d. -f1-3)
  echo "${IP_PREFIX}.$D"
}

由于在这个简单实现中,不考虑 DEL 命令,因此使用简单的递增实现。


在此,先跳过 DELCHECK 命令,并实现 VERSION

case $CNI_COMMAND in
ADD)
  # TODO
;;

DEL)
  exit 0
;;

CHECK)
  exit 0
;;

VERSION)
echo '{
  "cniVersion": "0.4.0", 
  "supportedVersions": [ "0.3.0", "0.3.1", "0.4.0" ] 
}' >&3
;;

*)
  echo "Unknown command: $CNI_COMMAND" 
  exit 1
;;

esac

考虑 ADD 实现。

首先提取子网和掩码,并分配容器 IP:

  subnet=$(echo "$stdin" | jq -r ".subnet")
  subnet_mask_size=$(echo $subnet | awk -F  "/" '{print $2}')

  gateway_ip=$(get_ip_from_subnet $subnet 1)
  container_ip=$(get_ip_from_subnet $subnet $(allocate_ip))

随机生成宿主机(VM)的设备名:

  rand=$(tr -dc 'A-F0-9' < /dev/urandom | head -c4)
  host_if_name="bashveth$rand"

绑定 network namespace:

  mkdir -p /var/run/netns/
  ln -sfT $CNI_NETNS /var/run/netns/$CNI_CONTAINERID

为容器网络创建网络设备:

  ip -n $CNI_CONTAINERID link add $CNI_IFNAME type veth peer name $host_if_name netns 1
  ip -n $CNI_CONTAINERID link set $CNI_IFNAME up
  ip -n $CNI_CONTAINERID addr add $container_ip/$subnet_mask_size dev $CNI_IFNAME

  ip link set $host_if_name up
  ip link set $host_if_name master cni0

创建设备时,需要注意使用的是 ip -n $CNI_CONTAINERID link add,而非 ip link add $CNI_IFNAME netns $CNI_CONTAINERID

从 man page 中可以得知,

   netns NETNSNAME | PID
         move the device to the network namespace associated with name
         NETNSNAME or process PID.

参数 netns 是将设备“移动”到目标 netns,这意味着它首先是在当前 netns 中创建设备,这可能存在潜在的设备冲突。

为容器网络创建默认路由:

  ip -n $CNI_CONTAINERID route add default via $gateway_ip dev $CNI_IFNAME

获取 Mac 地址:

  mac=$(ip -n $CNI_CONTAINERID link show $CNI_IFNAME | awk '/ether/ {print $2}')

至此,容器网络即设置完毕。

输出结果:

echo "{
  \"cniVersion\": \"0.4.0\",
  \"interfaces\": [                                            
      {
          \"name\": \"$CNI_IFNAME\",
          \"mac\": \"$mac\",                            
          \"sandbox\": \"$CNI_NETNS\" 
      }
  ],
  \"ips\": [
      {
          \"version\": \"4\",
          \"address\": \"$container_ip/$subnet_mask_size\",
          \"gateway\": \"$gateway_ip\",          
          \"interface\": 0 
      }
  ]
}" >&3

将该脚本命名为 bcni.sh,将其转移到 Minikube VM 中。

setup_executable() {
  $SCP ./bcni.sh docker@$IP:/home/docker/bcni
  $SSH sudo mv /home/docker/bcni /opt/cni/bin/bcni
  $SSH sudo chmod +x /opt/cni/bin/bcni
  $SSH sudo chown root:root /opt/cni/bin/bcni
}

profile "minikube"
setup_executable

profile "minikube-m02"
setup_executable

此外,还需要配置 cni conf,以 master 为例:

{
        "cniVersion": "0.4.0",
        "name": "bcni",
        "type": "bcni",
        "network": "10.244.0.0/16",
        "subnet": "10.244.0.0/24"
}

需要注意 type 字段需与可执行文件名相同。

将该配置文件保存到 /etc/cni/net.d/ 路径下,并使用一个优先级最高的文件名即可。

至此,CNI 插件已经能够正确配置容器网络,不同 VM 节点上的 Pod 已经可以正常通信。


使用如下配置可进行简单的测试。

YAML
apiVersion: v1
kind: Pod
metadata:
  name: nginx-master
spec:
  containers:
  - name: nginx
    image: nginx
    imagePullPolicy: Never
    ports:
    - containerPort: 80
  nodeSelector:
    kubernetes.io/hostname: minikube
---
apiVersion: v1
kind: Pod
metadata:
  name: bash-master
spec:
  containers:
  - name: ubuntu
    image: smatyukevich/ubuntu-net-utils
    imagePullPolicy: Never
    command:
      - "bin/bash"
      - "-c"
      - "sleep 10000"
  nodeSelector:
    kubernetes.io/hostname: minikube
---
apiVersion: v1
kind: Pod
metadata:
  name: nginx-worker
spec:
  containers:
  - name: nginx
    image: nginx
    imagePullPolicy: Never
    ports:
    - containerPort: 80
  nodeSelector:
    kubernetes.io/hostname: minikube-m02
---
apiVersion: v1
kind: Pod
metadata:
  name: bash-worker
spec:
  containers:
  - name: ubuntu
    image: smatyukevich/ubuntu-net-utils
    imagePullPolicy: Never
    command:
      - "bin/bash"
      - "-c"
      - "sleep 10000"
  nodeSelector:
    kubernetes.io/hostname: minikube-m02

需要注意,因为在启动 Minikube 时,没有指定 --cni=false,因此一些其他的配置,例如 NAT 与 VM 的路由规则,都是已经设置好的。