CNI(Container Network Interface)是 CNCF 旗下的一个项目,最早是由CoreOS发起的容器网络规范,由一组用于配置 Linux 容器的网络接口的规范和库组成,同时还包含了一些插件。

基本概念

官方的CNI项目主要是接口的定义和实现,以及基本操作的注册集成,我们如果需要实现自己的插件,我们就要实现这个操作的注册(CNI项目提供)与逻辑实现即可,对应的官方也有实现,也就是CNI-plugin项目,很多第三方库也是和自研的一样实现了CNI规范的插件,当然在这些实现中也可以集成基本网络方案。其实CNI就是一个框架,对kubelet实现基本接口,对后端的CNI插件实现基本的操作逻辑,至于接口和操作的转化和执行,交互的内容都已经做了很好的封装。

其基本思想为:Container Runtime在创建容器时,先创建好network namespace,然后调用CNI插件为这个netns配置网络,其后再启动容器内的进程。

CNI目前已经发布到了0.4.0版本,实现了ADD,DEL,CHECK,VERSION四个基本操作,我们开发也就是需要实现这个操作的逻辑。

为什么会有CNI?

各种各样的容器网络解决方案包括新的解决方案层出不穷。如果每出现一个新的解决方案,我们都需要进行两者的适配,那么工作量必然是巨大的,而且也是重复和不必要的。事实上,我们只要提供一个标准的接口,更准确的说是一种协议,就能完美地解决上述问题。一旦有新的网络方案出现时,只要它能满足这个标准的协议,那么它就能为同样满足该协议的所有容器平台提供网络功能,而CNI正是这样的标准接口协议。

Kubernetes 之所以要设置这样一个与 docker0 网桥功能几乎一样的 CNI 网桥,主要原因包括两个方面:

  • 一方面,Kubernetes 项目并没有使用 Docker 的网络模型(CNM),所以它并不希望、也不具备配置 docker0 网桥的能力;
  • 另一方面,这还与 Kubernetes 如何配置 Pod,也就是 Infra 容器的 Network Namespace 密切相关。

基本使用

环境变量

运行cni的二进制文件的启动参数是通过下面的环境变量进行传递的

  • CNI_PATH cni二进制文件的路径,可以配置多个,在linux环境下使用”:“分割
  • CNI_ARGS cni二进制文件的启动参数,以”FOO=BAR;ABC=123”配置,主要是为了自定义协议。
  • CNI_IFNAME 接口名,其实也就是网卡名
  • CNI_COMMAND 操作类型,目前有ADD,DEL,CHECK,VERSION,最重要的环境变量参数
  • CNI_CONTAINERID 容器ID
  • CNI_NETNS 网络命名空间的文件路径

不同的操作,需要的参数不一样,可以在CNI的getCmdArgsFromEnv函数中查看,总结如下

CMD\OPT CNI_COMMAND CNI_ARGS CNI_IFNAME CNI_PATH CNI_CONTAINERID CNI_NETNS
ADD true false true true true true
DEL true false true true true false
CHECK true false true true true true
VERSION true false true true true true

可见CNI环境变量核心的传递参数,首先是操作类型,然后就是namespace,网卡名,containerid,当然自定义的可以通过args传递。

启动

直接使用上面的环境变量加二进制文件就可以启动,官方提供来一个工具CNItool可以模拟运行,只有一个go文件直接编译就可以得到工具cnitool

先新建一个network namespace:testing

sudo ip netns add testing

然后新建一个网络配置文件

$ mkdir -p /etc/cni/net.d

$ cat >/etc/cni/net.d/10-mynet.conflist <<EOF
{
        "cniVersion": "0.3.0",
        "name": "mynet",
        "plugins": [
          {
                "type": "bridge",
                "bridge": "cni0",
                "isGateway": true,
                "ipMasq": true,
                "ipam": {
                        "type": "host-local",
                        "subnet": "10.22.0.0/16",
                        "routes": [
                                { "dst": "0.0.0.0/0" }
                        ]
                }
          }
        ]
}
EOF

然后使用cnitool将这个网络增加到testing中,使用plugins的bridge插件,所以指定CNI_PATH环境变量

CNI_PATH=$GOPATH/src/github.com/containernetworking/plugins/bin ./cnitool add mynet /var/run/netns/ns

其他的环境变量其实都是在cni代码中转化了,其实可以都像CNI_PATH这样来启动插件。

kubelet使用

安装配置

K8s 通过 CNI 配置文件来决定使用什么 CNI插件,基本的使用方法为:

  • 首先在每个结点上配置 CNI 配置文件(/etc/cni/net.d/xxnet.conf),其中 xxnet.conf 是某一个网络配置文件的名称;配置文件内容
  • 安装 CNI 配置文件中所对应的二进制插件(就是我们实现的CNI操作的插件,可以是官方维护的,也可以是第三方,也可以是自研的),就是将插件的二进制文件放在/opt/cni/bin/目录下;
  • 在这个节点上创建 Pod 之后,Kubelet 就会根据 CNI 配置文件执行前两步所安装的 CNI 插件;
  • 上步执行完之后,Pod 的网络就配置完成了。

流程详解

具体的流程如下图所示:

在集群里面创建一个 Pod 的时候,首先会通过 apiserver 将 Pod 的配置写入。apiserver 的一些管控组件(比如 Scheduler)会调度到某个具体的节点上去。Kubelet 监听到这个 Pod 的创建之后,会在本地进行一些创建的操作。kubelet 先创建pause容器生成network namespace,执行到创建网络这一步骤时,它首先会读取刚才我们所说的配置目录中的配置文件,配置文件里面会声明所使用的是哪一个插件,然后去执行具体的 CNI 插件的二进制文件,再由 CNI 插件进入 Pod 的pause 容器网络空间去配置 Pod 的网络,pod 中其他的容器都使用 pause 容器的网络。配置完成之后,Kuberlet 也就完成了整个 Pod 的创建过程,这个 Pod 就在线了。

使用 CNI 插件比较简单,因为很多 CNI 插件都已提供了一键安装的能力。以我们常用的 Flannel 为例,如下图所示:只需要我们使用 kubectl apply Flannel 的一个 Deploying 模板,它就能自动地将配置、二进制文件安装到每一个节点上去。

配置详解

CNI的配置文件

cat > mybridge.conf <<"EOF"
{
    "cniVersion": "0.4.0",
    "name": "mybridge",
    "type": "bridge",
    "bridge": "cni_bridge0",
    "isGateway": true,
    "ipMasq": true,
    "hairpinMode":true,
    "ipam": {
        "type": "host-local",
        "subnet": "10.15.20.0/24",
        "routes": [
            { "dst": "0.0.0.0/0" },
            { "dst": "1.1.1.1/32", "gw":"10.15.20.1"}
        ]
    }
}
EOF

其中:

  • cniVersion: CNI规范的版本
  • name: 这个网络的名字叫mybridge
  • type:使用brige插件
  • isGateway:如果是true,为网桥分配ip地址,以便连接到它的容器可以将其作为网关
  • ipMasq:在插件支持的情况的,设置ip伪装。当宿主机充当的网关无法路由到分配给容器的IP子网的网关的时候,这个参数是必须有的。
  • ipam:
    • type:IPAM可执行文件的名字
    • subnet:要分配给容器的子网
    • routes
      • dst: 目的子网
      • gw:到达目的地址的下一跳ip地址,如果不指定则为默认网关
  • hairpinMode: 让网络设备能够让数据包从一个端口发进来一个端口发出去

Kubernetes 目前不支持多个 CNI 插件混用。如果你在 CNI 配置目录(/etc/cni/net.d)里放置了多个 CNI 配置文件的话,dockershim 只会加载按字母顺序排序的第一个插件。

但另一方面,CNI 允许你在一个 CNI 配置文件里,通过 plugins 字段,定义多个插件进行协作。比如,在我们下面这个例子里,Flannel 项目就指定了 flannel 和 portmap 这两个插件。

$ cat /etc/cni/net.d/10-flannel.conflist
{
  "name": "cbr0",
  "plugins": [
    {
      "type": "flannel",
      "delegate": {
        "hairpinMode": true,
        "isDefaultGateway": true
      }
    },
    {
      "type": "portmap",
      "capabilities": {
        "portMappings": true
      }
    }
  ]
}

这时候,dockershim 会把这个 CNI 配置文件加载起来,并且把列表里的第一个插件、也就是 flannel 插件,设置为默认插件。而在后面的执行过程中,flannel 和 portmap 插件会按照定义顺序被调用,从而依次完成“配置容器网络”和“配置端口映射”这两步操作。

不过,需要注意的是,Flannel 的 CNI 配置文件( /etc/cni/net.d/10-flannel.conflist)里有这么一个字段,叫作 delegate,Delegate 字段的意思是,这个 CNI 插件并不会自己做事儿,而是会调用 Delegate 指定的某种 CNI 内置插件来完成。对于 Flannel 来说,它调用的 Delegate 插件,就是前面介绍到的 CNI bridge 插件。也就是我们下面说明的cni插件会调用基础二进制文件和ipam二进制文件。

所以说,dockershim 对 Flannel CNI 插件的调用,其实就是走了个过场。Flannel CNI 插件唯一需要做的,就是对 dockershim 传来的 Network Configuration 进行补充。比如,将 Delegate 的 Type 字段设置为 bridge,将 Delegate 的 IPAM 字段设置为 host-local 等。经过 Flannel CNI 插件补充后的、完整的 Delegate 字段如下所示:

{
    "hairpinMode":true,
    "ipMasq":false,
    "ipam":{
        "routes":[
            {
                "dst":"10.244.0.0/16"
            }
        ],
        "subnet":"10.244.1.0/24",
        "type":"host-local"
    },
    "isDefaultGateway":true,
    "isGateway":true,
    "mtu":1410,
    "name":"cbr0",
    "type":"bridge"
}

其中,ipam 字段里的信息,比如 10.244.1.0/24,读取自 Flannel 在宿主机上生成的 Flannel 配置文件,即:宿主机上的 /run/flannel/subnet.env 文件。接下来,Flannel CNI 插件就会调用 CNI bridge 插件,也就是执行:/opt/cni/bin/bridge 二进制文件。CNI bridge 插件就可以“代表”Flannel,进行“将容器加入到 CNI 网络里”这一步操作了。

各种插件

CNI可以支持很多类型的插件,CNI本也只是做网络的增删改查的基本操作,也就是实现了CNI的基本接口,当然也可以在这些接口里面实现组件的原来网络方案,也可以调用实现网络的组件,可以说是基于组件之上,我们也说过一些组件。

我们来看一下CNI实现有哪些,如下图

官方单独开辟了一个repo来表示官方实现的插件类型.

  • CNI社区
    • Main插件:用来创建具体的网络设备的二进制文件,比如创建bridge(网桥设备)、ipvlan、loopback(lo 设备)、macvlan、ptp(Veth Pair 设备),以及 vlan。比如下面的flannel最终会调用这里的bridge二进制文件来创建。包括:
      • bridge: 在宿主机上创建网桥然后通过veth pair的方式连接到容器
      • macvlan:虚拟出多个macvtap,每个macvtap都有不同的mac地址
      • ipvlan:和macvlan相似,也是通过一个主机接口虚拟出多个虚拟网络接口,不同的是ipvlan虚拟出来的是共享MAC地址,ip地址不同
      • loopback: lo设备(将回环接口设置成up)
      • ptp: Veth Pair设备
      • vlan: 分配vlan设备
      • host-device: 移动宿主上已经存在的设备到容器中
    • IPAM(IP Address Management)插件: 负责分配IP地址的二进制文件
      • dhcp: 宿主机上运行的守护进程,代表容器发出DHCP请求
      • host-local: 使用提前分配好的IP地址段来分配
      • static:用于为容器分配静态的IP地址,主要是调试使用
    • Meta插件: 由CNI社区维护的内部插件
      • flannel: 专门为Flannel项目提供的插件
      • tuning:通过sysctl调整网络设备参数的二进制文件
      • portmap:通过iptables配置端口映射的二进制文件
      • bandwidth:使用 Token Bucket Filter (TBF)来进行限流的二进制文件
      • firewall:通过iptables或者firewalled添加规则控制容器的进出流量
  • 3rd party plugins
    • Project Calico - a layer 3 virtual network
    • Weave - a multi-host Docker network
    • Contiv Networking - policy networking for various use cases
    • SR-IOV
    • Cilium - BPF & XDP for containers
    • Infoblox - enterprise IP address management for containers
    • Multus - a Multi plugin
    • Romana - Layer 3 CNI plugin supporting network policy for Kubernetes
    • CNI-Genie - generic CNI network plugin
    • Nuage CNI - Nuage Networks SDN plugin for network policy kubernetes support
    • Silk - a CNI plugin designed for Cloud Foundry
    • Linen - a CNI plugin designed for overlay networks with Open vSwitch and fit in SDN/OpenFlow network environment
    • Vhostuser - a Dataplane network plugin - Supports OVS-DPDK & VPP
    • Amazon ECS CNI Plugins - a collection of CNI Plugins to configure containers with Amazon EC2 elastic network interfaces (ENIs)
    • Bonding CNI - a Link aggregating plugin to address failover and high availability network
    • ovn-kubernetes - an container network plugin built on Open vSwitch (OVS) and Open Virtual Networking (OVN) with support for both Linux and Windows
    • Juniper Contrail / TungstenFabric - Provides overlay SDN solution, delivering multicloud networking, hybrid cloud networking, simultaneous overlay-underlay support, network policy enforcement, network isolation, service chaining and flexible load balancing
    • Knitter - a CNI plugin supporting multiple networking for Kubernetes
    • DANM - a CNI-compliant networking solution for TelCo workloads running on Kubernetes
    • VMware NSX – a CNI plugin that enables automated NSX L2/L3 networking and L4/L7 Load Balancing; network isolation at the pod, node, and cluster level; and zero-trust security policy for your Kubernetes cluster.
    • cni-route-override - a meta CNI plugin that override route information
    • Terway - a collection of CNI Plugins based on alibaba cloud VPC/ECS network product
    • Cisco ACI CNI - for on-prem and cloud container networking with consistent policy and security model.
    • Kube-OVN - a CNI plugin that bases on OVN/OVS and provides advanced features like subnet, static ip, ACL, QoS, etc.
    • Project Antrea - an Open vSwitch k8s CNI
    • OVN4NFV-K8S-Plugin - a OVN based CNI controller plugin to provide cloud native based Service function chaining (SFC), Multiple OVN overlay networking

这些插件都是直接使用项目中的编译脚本build_linux.sh就可以生成相关的二进制文件使用,当然自研的插件也一样编译成二进制文件放到上面使用的位置。

如果要实现一个给 Kubernetes 用的容器网络方案,其实需要做两部分工作,以 Flannel 项目为例:

  • 首先,实现这个网络方案本身。这一部分需要编写的,其实就是 flanneld 进程里的主要逻辑。比如,创建和配置 flannel.1 设备、配置宿主机路由、配置 ARP 和 FDB 表里的信息等等。其实就是我们最初的网络方案。
  • 然后,实现该网络方案对应的 CNI 插件。这一部分主要需要做的,就是配置 Infra 容器里面的网络栈,并把它连接在 CNI 网桥上。这就是CNI插件要实现的。

CNI插件和组件

CNI网络插件是通过实现CNI的操作逻辑来调用相关组件实现网络通信,是基于组件之上的,可以说是组件的一个前置,当然也可以集成组件,最终实现网络的思路都是一样的,而且是目前比较推崇的一种标准,希望各大组件都能向它靠拢。

组件是基于不同实现思路实现的容器网络解决方案,只不过不一定通过CNI插件调用来处理网络,比如直接启动守护进程就可以完成基本操作,目前各大组件正在极力拥抱CNI。

源码解析

目录结构

├── Documentation               文档目录,存储这一些重要文档
│   ├── cnitool.md              cni工具的说明,cni工具可以在没有docker的情况来模拟测试
│   └── spec-upgrades.md        插件升级维护的一个简单指导
├── SPEC.md                     插件的使用说明
├── cnitool                     cni工具的实现
│   ├── README.md
│   └── cnitool.go
├── libcni
│   ├── api.go
│   ├── api_test.go
│   ├── backwards_compatibility_test.go
│   ├── conf.go
│   ├── conf_test.go
│   └── libcni_suite_test.go
├── pkg
│   ├── invoke
│   │   ├── args.go
│   │   ├── args_test.go
│   │   ├── delegate.go
│   │   ├── delegate_test.go
│   │   ├── exec.go
│   │   ├── exec_test.go
│   │   ├── fakes
│   │   │   ├── cni_args.go
│   │   │   ├── raw_exec.go
│   │   │   └── version_decoder.go
│   │   ├── find.go
│   │   ├── find_test.go
│   │   ├── get_version_integration_test.go
│   │   ├── invoke_suite_test.go
│   │   ├── os_unix.go
│   │   ├── os_windows.go
│   │   ├── raw_exec.go
│   │   └── raw_exec_test.go
│   ├── skel
│   │   ├── skel.go
│   │   ├── skel_suite_test.go
│   │   └── skel_test.go
│   ├── types
│   │   ├── 020
│   │   │   ├── types.go
│   │   │   ├── types_suite_test.go
│   │   │   └── types_test.go
│   │   ├── 040
│   │   │   ├── types.go
│   │   │   ├── types_suite_test.go
│   │   │   └── types_test.go
│   │   ├── 100
│   │   │   ├── types.go
│   │   │   ├── types_suite_test.go
│   │   │   └── types_test.go
│   │   ├── args.go
│   │   ├── args_test.go
│   │   ├── create
│   │   │   └── create.go
│   │   ├── internal
│   │   │   ├── convert.go
│   │   │   └── create.go
│   │   ├── types.go
│   │   ├── types_suite_test.go
│   │   └── types_test.go
│   ├── utils
│   │   ├── utils.go
│   │   └── utils_test.go
│   └── version
│       ├── conf.go
│       ├── conf_test.go
│       ├── legacy_examples
│       │   ├── example_runtime.go
│       │   ├── examples.go
│       │   ├── legacy_examples_suite_test.go
│       │   └── legacy_examples_test.go
│       ├── plugin.go
│       ├── plugin_test.go
│       ├── reconcile.go
│       ├── reconcile_test.go
│       ├── testhelpers
│       │   ├── testhelpers.go
│       │   ├── testhelpers_suite_test.go
│       │   └── testhelpers_test.go
│       ├── version.go
│       ├── version_suite_test.go
│       └── version_test.go
├── plugins
│   └── test
│       ├── noop
│       │   ├── debug
│       │   │   └── debug.go
│       │   ├── main.go
│       │   ├── noop_suite_test.go
│       │   └── noop_test.go
│       └── sleep
│           └── main.go
├── scripts
│   ├── docker-run.sh
│   ├── exec-plugins.sh
│   ├── priv-net-run.sh
│   └── release.sh
└── test.sh

接口

CNI的核心是定义了网络操作的基本接口

type CNI interface {
    AddNetworkList(ctx context.Context, net *NetworkConfigList, rt *RuntimeConf) (types.Result, error)
    CheckNetworkList(ctx context.Context, net *NetworkConfigList, rt *RuntimeConf) error
    DelNetworkList(ctx context.Context, net *NetworkConfigList, rt *RuntimeConf) error
    GetNetworkListCachedResult(net *NetworkConfigList, rt *RuntimeConf) (types.Result, error)
    GetNetworkListCachedConfig(net *NetworkConfigList, rt *RuntimeConf) ([]byte, *RuntimeConf, error)

    AddNetwork(ctx context.Context, net *NetworkConfig, rt *RuntimeConf) (types.Result, error)
    CheckNetwork(ctx context.Context, net *NetworkConfig, rt *RuntimeConf) error
    DelNetwork(ctx context.Context, net *NetworkConfig, rt *RuntimeConf) error
    GetNetworkCachedResult(net *NetworkConfig, rt *RuntimeConf) (types.Result, error)
    GetNetworkCachedConfig(net *NetworkConfig, rt *RuntimeConf) ([]byte, *RuntimeConf, error)

    ValidateNetworkList(ctx context.Context, net *NetworkConfigList) ([]string, error)
    ValidateNetwork(ctx context.Context, net *NetworkConfig) ([]string, error)
}

CNI库对这些接口进行实现包装,到插件使用的时候,直接就是对应的我们的ADD,DEL,CHECK,VERSION四个基本操作,CNI库就是这样连接kubelet和CNI插件的。

kubelet

首先在 Kubernetes 中,处理容器网络相关的逻辑并不会在 kubelet 主干代码里执行,而是会在具体的 CRI(Container Runtime Interface,容器运行时接口)实现里完成。对于 Docker 项目来说,它的 CRI 实现叫作 dockershim,你可以在 kubelet 的代码里找到它。所以,接下来 dockershim 会加载上述的 CNI 配置文件。

当 kubelet 组件需要创建 Pod 的时候,它第一个创建的一定是 Infra 容器。所以在这一步,dockershim 就会先调用 Docker API 创建并启动 Infra 容器,紧接着执行一个叫作 SetUpPod 的方法。这个方法的作用就是:为 CNI 插件准备参数,然后调用 CNI 插件为 Infra 容器配置网络。这里要调用的 CNI 插件,而调用它所需要的参数,分为两部分。

  • 是由 dockershim 设置的一组 CNI 环境变量。在上面已经说明
  • dockershim 从 CNI 配置文件里加载到的、默认插件的配置信息。

kubelet中已经将对应的参数和配置调用的CNIConfig的AddNetworkList。

我们先来看一下CNIConfig

type CNIConfig struct {
    Path     []string
    exec     invoke.Exec
    cacheDir string
}

调用这个实例的AddNetworkList

// AddNetworkList executes a sequence of plugins with the ADD command
func (c *CNIConfig) AddNetworkList(ctx context.Context, list *NetworkConfigList, rt *RuntimeConf) (types.Result, error) {
    var err error
    var result types.Result
    for _, net := range list.Plugins {
        result, err = c.addNetwork(ctx, list.Name, list.CNIVersion, net, result, rt)
        if err != nil {
            return nil, err
        }
    }

    if err = c.cacheAdd(result, list.Bytes, list.Name, rt); err != nil {
        return nil, fmt.Errorf("failed to set network %q cached result: %v", list.Name, err)
    }

    return result, nil
}

调用的是其还是addNetwork操作,只不过有一个list循环处理,我们还是看addNetwork

func (c *CNIConfig) addNetwork(ctx context.Context, name, cniVersion string, net *NetworkConfig, prevResult types.Result, rt *RuntimeConf) (types.Result, error) {
    c.ensureExec()
    pluginPath, err := c.exec.FindInPath(net.Network.Type, c.Path)
    if err != nil {
        return nil, err
    }
    if err := utils.ValidateContainerID(rt.ContainerID); err != nil {
        return nil, err
    }
    if err := utils.ValidateNetworkName(name); err != nil {
        return nil, err
    }
    if err := utils.ValidateInterfaceName(rt.IfName); err != nil {
        return nil, err
    }

    newConf, err := buildOneConfig(name, cniVersion, net, prevResult, rt)
    if err != nil {
        return nil, err
    }

    return invoke.ExecPluginWithResult(ctx, pluginPath, newConf.Bytes, c.args("ADD", rt), c.exec)
}

可以看出先找出需要执行的文件,然后执行ADD操作,这边的ADD就是后面调用CNI插件的cmd操作,我们来看一下ExecPluginWithResult

func ExecPluginWithResult(ctx context.Context, pluginPath string, netconf []byte, args CNIArgs, exec Exec) (types.Result, error) {
    if exec == nil {
        exec = defaultExec
    }

    stdoutBytes, err := exec.ExecPlugin(ctx, pluginPath, netconf, args.AsEnv())
    if err != nil {
        return nil, err
    }

    // Plugin must return result in same version as specified in netconf
    versionDecoder := &version.ConfigDecoder{}
    confVersion, err := versionDecoder.Decode(netconf)
    if err != nil {
        return nil, err
    }

    return version.NewResult(confVersion, stdoutBytes)
}

就是简单的执行二进制文件,将执行结果返回给kubelet。

CNI插件

虽然各个CNI插件实现容器网络的方式是多种多样的,但是它们编写的套路基本是一致的。其中一定会存在三个函数:main(),cmdAdd(),cmdDel()。这个就是CNI定义的规范,ADD,DEL,CHECK,VERSiON接口。我们就以CNI官方插件库的bridge插件为例,进一步说明CNI插件应该如何实现的。其实上面我们在配置的时候也说明了最后也是会调用bridge插件来处理网络。

main函数

1、CNI的skel.PluginMain这个函数将函数cmdAdd和cmdDel以及支持插件支持的CNI版本作为参数传递给它。

func main() {
    skel.PluginMain(cmdAdd, cmdDel, version.All)
}
  

2、PluginMain函数是一个包裹函数,它直接对PluginMainWithError进行调用,当有错误发生的时候,会将错误以json的形式输出到标准输出,并退出插件的执行。

func PluginMain(cmdAdd, cmdDel func(_ *CmdArgs) error, versionInfo version.PluginInfo) {
    if e := PluginMainWithError(cmdAdd, cmdDel, versionInfo); e != nil {
        if err := e.Print(); err != nil {
            log.Print("Error writing error JSON to stdout: ", err)
        }
        os.Exit(1)
    }
}
  

3、PluginMainWithError函数也非常简单,其实就是用环境变量,标准输入输出构造了一个dispatcher结构,再执行其中的pluginMain方法而已。

func PluginMainWithError(cmdAdd, cmdDel func(_ *CmdArgs) error, versionInfo version.PluginInfo) *types.Error {
    return (&dispatcher{
        Getenv: os.Getenv,
        Stdin:  os.Stdin,
        Stdout: os.Stdout,
        Stderr: os.Stderr,
    }).pluginMain(cmdAdd, cmdDel, versionInfo)
}
  

dispatcher结构如下所示:

type dispatcher struct {
    Getenv func(string) string
    Stdin  io.Reader
    Stdout io.Writer
    Stderr io.Writer

    ConfVersionDecoder version.ConfigDecoder
    VersionReconciler  version.Reconciler
}

4、接着dispatcher结构的pluginMain方法执行具体的操作。该函数的操作分为如下两步:

  • 首先调用cmd, cmdArgs, err := t.getCmdArgsFromEnv()从环境变量和标准输入中解析出操作信息cmd和配置信息cmdArgs,这个cmd就是在kubelet调用的时候封装的ADD。
  • 接着根据操作信息cmd的不同,调用checkVersionAndCall(),该函数会首先从标准输入中获取配置信息中的CNI版本,再和之前main函数中指定的插件支持的CNI版本信息进行比对。如果版本匹配,则调用相应的回调函数cmdAdd或cmdDel并以cmdArgs作为参数,否则,返回错误

看代码

func (t *dispatcher) pluginMain(cmdAdd, cmdDel func(_ *CmdArgs) error, versionInfo version.PluginInfo) *types.Error {
    cmd, cmdArgs, err := t.getCmdArgsFromEnv()
        .....
    switch cmd {
    case "ADD":
        err = t.checkVersionAndCall(cmdArgs, versionInfo, cmdAdd)
    case "DEL":
                ......
    }
        ......
}

5、下面我们来看看dispatcher的getCmdArgsFromEnv()方法是如何从环境变量和标准输入中获取配置信息的。首先来看一下cmdArgs的具体结构:

type CmdArgs struct {
    ContainerID string
    Netns       string
    IfName      string
    Args        string
    Path        string
    StdinData   []byte
}
  

分析了上述结构之后,不难想象,getCmdArgsFromEnv()所做的工作就是从环境变量中提取出配置信息用以填充CmdArgs,再将容器网络的配置信息,也就是标准输入中的内容,存入StdinData字段。具体代码如下所示:

func (t *dispatcher) getCmdArgsFromEnv() (string, *CmdArgs, error) {
    var cmd, contID, netns, ifName, args, path string

    vars := []struct {
        name      string
        val       *string
        reqForCmd reqForCmdEntry
    }{
        {
            "CNI_COMMAND",
            &cmd,
            reqForCmdEntry{
                "ADD": true,
                "DEL": true,
            },
        },
                ....
        {
            "CNI_NETNS",
            &netns,
            reqForCmdEntry{
                "ADD": true,
                "DEL": false,
            },
        },
                ....
    }

    argsMissing := false
    for _, v := range vars {
        *v.val = t.Getenv(v.name)
        if *v.val == "" {
            if v.reqForCmd[cmd] || v.name == "CNI_COMMAND" {
                fmt.Fprintf(t.Stderr, "%v env variable missing\n", v.name)
                argsMissing = true
            }
        }
    }

    if argsMissing {
        return "", nil, fmt.Errorf("required env variables missing")
    }

    stdinData, err := ioutil.ReadAll(t.Stdin)
    if err != nil {
        return "", nil, fmt.Errorf("error reading from stdin: %v", err)
    }

    cmdArgs := &CmdArgs{
        ContainerID: contID,
        Netns:       netns,
        IfName:      ifName,
        Args:        args,
        Path:        path,
        StdinData:   stdinData,
    }
    return cmd, cmdArgs, nil
}
  

虽然getCmdArgsFromEnv()要完成的工作非常简单,但仔细分析代码之后,我们可以发现它的实现非常精巧。首先,它定义了一系列想要获取的参数,例如cmd,contID,netns等等。之后再定义了一个匿名结构的数组,匿名结构中包含了环境变量的名字,一个字符串指针(把该环境变量对应的参数赋给它,例如cmd对应CNI_COMMAND)以及一个reqForCmdEntry类型的成员reqForCmd。类型reqForCmdEntry其实是一个map,它在这里的作用是定义该环境变量是否为对应操作所必须的。例如,上文中的环境变量”CNI_NETNS”,对于”ADD”操作为true,而对于”DEL”操作则为false,这说明在”ADD”操作时,该环境变量不能为空,否则会报错,但是在”DEL”操作时则无所谓。最后,遍历该数组进行参数的提取即可。

到此为止,main函数的任务完成。总的来说它做了三件事

  • CNI版本检查。
  • 提取配置参数构建cmdArgs。
  • 调用对应的回调函数,cmdAdd或者cmdDel。

下面我们以cmdAdd看看是怎么增加网络的。

cmdAdd函数

1、如下所示cmdAdd函数一般分为三个步骤执行:

  • 首先调用函数conf, err := loadNetConf(args.StdinData)(注:loadNetConf是插件自定义的,各个插件都不一样),从标准输入,也就是参数args.StdinData中获取容器网络配置信息
  • 接着根据具体的配置信息进行网络的配置工作,这一步就是调用相关的组件来完成相关的配置。
  • 最后,调用函数types.PrintResult(result, conf.CNIVersion)输出配置结果

代码如下

func cmdAdd(args *skel.CmdArgs) error {
    n, cniVersion, err := loadNetConf(args.StdinData)
        ......
        return PrintResult(result, cniVersion)
}      

2、接着我们对loadNetConf函数进行分析。因为每个CNI插件配置容器网络的方式各有不同,因此它们所需的配置信息一般也是不同的,除了大家共有的信息被包含在types.NetConf结构中,每个插件还定义了自己所需的字段。例如,对于bridge插件,它用于存储配置信息的结构如下所示:

type NetConf struct {
    types.NetConf
    BrName       string `json:"bridge"`
    IsGW         bool   `json:"isGateway"`
    IsDefaultGW  bool   `json:"isDefaultGateway"`
    ForceAddress bool   `json:"forceAddress"`
    IPMasq       bool   `json:"ipMasq"`
    MTU          int    `json:"mtu"`
    HairpinMode  bool   `json:"hairpinMode"`
    PromiscMode  bool   `json:"promiscMode"`
}

而loadNetConf函数所做的操作也非常简单,就是调用json.Unmarshal(bytes, n)函数将配置信息从标准输入的字节流中解码到一个NetConf结构,具体代码如下:

func loadNetConf(bytes []byte) (*NetConf, string, error) {
    n := &NetConf{
        BrName: defaultBrName,
    }
    if err := json.Unmarshal(bytes, n); err != nil {
        return nil, "", fmt.Errorf("failed to load netconf: %v", err)
    }
    return n, n.CNIVersion, nil
}

然后,CNI bridge 插件会在宿主机上检查 CNI 网桥是否存在。如果没有的话,那就创建它。这相当于在宿主机上执行:

# 在宿主机上
$ ip link add cni0 type bridge
$ ip link set cni0 up

接下来,CNI bridge 插件会通过 Infra 容器的 Network Namespace 文件,进入到这个 Network Namespace 里面,然后创建一对 Veth Pair 设备。紧接着,它会把这个 Veth Pair 的其中一端,“移动”到宿主机上。这相当于在容器里执行如下所示的命令:

#在容器里

# 创建一对Veth Pair设备。其中一个叫作eth0,另一个叫作vethb4963f3
$ ip link add eth0 type veth peer name vethb4963f3

# 启动eth0设备
$ ip link set eth0 up

# 将Veth Pair设备的另一端(也就是vethb4963f3设备)放到宿主机(也就是Host Namespace)里
$ ip link set vethb4963f3 netns $HOST_NS

# 通过Host Namespace,启动宿主机上的vethb4963f3设备
$ ip netns exec $HOST_NS ip link set vethb4963f3 up

这样,vethb4963f3 就出现在了宿主机上,而且这个 Veth Pair 设备的另一端,就是容器里面的 eth0。

接下来,CNI bridge 插件会调用 CNI ipam 插件,从 ipam.subnet 字段规定的网段里为容器分配一个可用的 IP 地址。然后,CNI bridge 插件就会把这个 IP 地址添加在容器的 eth0 网卡上,同时为容器设置默认路由。这相当于在容器里执行:

# 在容器里
$ ip addr add 10.244.0.2/24 dev eth0
$ ip route add default via 10.244.0.1 dev eth0

最后,CNI bridge 插件会为 CNI 网桥添加 IP 地址。这相当于在宿主机上执行:

# 在宿主机上
$ ip addr add 10.244.0.1/24 dev cni0

在执行完上述操作之后,CNI 插件会把容器的 IP 地址等信息返回给 dockershim,然后被 kubelet 添加到 Pod 的 Status 字段。

3、最后,我们对配置结果的输出进行分析。由于不同的CNI版本要求的输出结果的内容是不太一样的,因此这部分内容其实是比较复杂的。下面我们就进入PrintResult函数一探究竟。

func PrintResult(result Result, version string) error {
    newResult, err := result.GetAsVersion(version)
    if err != nil {
        return err
    }
    return newResult.Print()
}

从上面的代码中我们可以看出,该函数就做了两件事,一件是调用newResult, err := result.GetAsVersion(version),根据指定的版本信息,进行结果信息的版本转换。第二件就是调用newResult.Print()将结果信息输出到标准输出。

事实上,Result如下所示,是一个interface类型。每个版本的CNI都是定义了自己的Result结构的,而这些结构都是满足Result接口的。

// Result is an interface that provides the result of plugin execution
type Result interface {
    // The highest CNI specification result verison the result supports
    // without having to convert
    Version() string

    // Returns the result converted into the requested CNI specification
    // result version, or an error if conversion failed
    GetAsVersion(version string) (Result, error)

    // Prints the result in JSON format to stdout
    Print() error

    // Returns a JSON string representation of the result
    String() string
}

而其中的GetAsVersion()方法则用于将当前版本的CNI Result信息转化到对应的CNI Result信息。我们来举个具体的例子,应该就很清晰了。

func (r *Result) GetAsVersion(version string) (types.Result, error) {
    switch version {
    case "0.3.0", ImplementedSpecVersion:
        r.CNIVersion = version
        return r, nil
    case types020.SupportedVersions[0], types020.SupportedVersions[1], types020.SupportedVersions[2]:
        return r.convertTo020()
    }
    return nil, fmt.Errorf("cannot convert version 0.3.x to %q", version)
}

假设现在我们的result的版本0.3.0, 但是插件要求返回的result版本是0.2.0的,根据上文中的代码,显然此时我们会调用r.convertTo020()函数进行转换,如下所示:

// Convert to the older 0.2.0 CNI spec Result type
func (r *Result) convertTo020() (*types020.Result, error) {
    oldResult := &types020.Result{
        CNIVersion: types020.ImplementedSpecVersion,
        DNS:        r.DNS,
    }

    for _, ip := range r.IPs {
        // Only convert the first IP address of each version as 0.2.0
        // and earlier cannot handle multiple IP addresses
               ......
    }

    for _, route := range r.Routes {
               ......
    }
        ......
    return oldResult, nil
}

该函数所做的操作,简单来说,就是定义了相应版本具体的Result结构,然后用当前版本的Result结构中的信息进行填充,从而完成Result版本的转化。

而Print方法对于各个版本的Result都是一样的,都是将Result进行json编码后,输出到标准输出而已。

到此为止,cmdAdd函数操作完成。其他的套路这个都是一样的,就不一一说明了。

交互协议

ADD

  • Parameters:
    • Version. 调用者使用的CNI 配置的版本信息
    • Container ID. 这个字段是可选的,但是建议使用,在容器活着的时候要求该字段全局唯一的。比如,存在IPAM的环境可能会要求每个container都分配一个独立的ID,这样每一个IP的分配都能和一个特定的容器相关联。例如,在appc implementations中,container ID其实就是pod ID
    • Network namespace path. 这个字段表示要加入的network namespace的路径。例如,/proc/[pid]/ns/net或者对于该目录的bind-mount/link。
    • Network configuration. 这是一个JSON文件用于描述container可以加入的network,具体内容在下文中描述
    • Extra arguments. 该字段提供了可选的机制,从而允许基于每个容器进行CNI插件的简单配置
    • Name of the interface inside the container. 该字段提供了在container (network namespace)中的interface的名字;因此,它也必须符合Linux对于网络命名的限制
  • Result:
    • Interface list. 根据插件的不同,这个字段可以包括sandbox (container or hypervisor) interface的name,以及host interface的name,每个interface的hardware address,以及interface所在的sandbox(如果存在的话)的信息。
    • IP configuration assigned to each interface. IPv4和/或者IPv6地址,gateways以及为sandbox或host interfaces中添加的路由
    • DNS inormation. 包含nameservers,domains,search domains和options的DNS information的字典

DEL

  • Parameter:
    • Version. 调用者使用的CNI 配置的版本信息
    • ContainerID. 定义同上
    • Network namespace path. 定义同上
    • Network configuration. 定义同上
    • Extra argument. 定义同上
    • Name of the interface inside the container. 定义同上

VERSION

  • Parameter: 无
  • Result: 返回插件支持的所有CNI版本,如下

    {
      "cniVersion": "1.0.0", // the version of the CNI spec in use for this output
      "supportedVersions": [ "0.1.0", "0.2.0", "0.3.0", "0.3.1", "0.4.0", "1.0.0" ] // the list of CNI spec versions that this plugin supports
    }
    

CHECK

  • Parameter:
    • Version. 调用者使用的CNI 配置的版本信息
    • ContainerID. 定义同上
    • Network namespace path. 定义同上
    • Network configuration. 定义同上
    • Extra argument. 定义同上
    • Name of the interface inside the container. 定义同上
  • result
    • The plugin must return either nothing or an error.

如何开发自己的 CNI 插件

首先就是注册我们的操作,比如ADD,DEL等调用的函数,然后在对应的操作中做网络的配置,这一部分我们在上面的源码解析中已经说明,主要看一下配置

这一块的的实现一般都是组件实现的,我们也可以集成在CNI插件中,通常包含两个部分:

  • 一个二进制的 CNI 插件去配置 Pod 网卡和 IP 地址。这一步配置完成之后相当于给 Pod 上插上了一条网线,就是说它已经有自己的 IP、有自己的网卡了;
  • 一个 Daemon 进程去管理 Pod 之间的网络打通。这一步相当于说将 Pod 真正连上网络,让 Pod 之间能够互相通信。

1、给 Pod 插上网线

那么如何实现第一步,给 Pod 插上网线呢?通常是这样一个步骤:

  1. 给 Pod 准备一个网卡

    通常我们会用一个 “veth” 这种虚拟网卡,一端放到 Pod 的网络空间,一端放到主机的网络空间,这样就实现了 Pod 与主机这两个命名空间的打通。

  2. 给 Pod 分配 IP 地址

    这个 IP 地址有一个要求,我们在之前介绍网络的时候也有提到,就是说这个 IP 地址在集群里需要是唯一的。如何保障集群里面给 Pod 分配的是个唯一的 IP 地址呢?

    一般来说我们在创建整个集群的时候会指定 Pod 的一个大网段,按照每个节点去分配一个 Node 网段。比如说上图右侧创建的是一个 172.16 的网段,我们再按照每个节点去分配一个 /24 的段,这样就能保障每个节点上的地址是互不冲突的。然后每个 Pod 再从一个具体的节点上的网段中再去顺序分配具体的 IP 地址,比如 Pod1 分配到了 172.16.0.1,Pod2 分配到了 172.16.0.2,这样就实现了在节点里面 IP 地址分配的不冲突,并且不同的 Node 又分属不同的网段,因此不会冲突。

    这样就给 Pod 分配了集群里面一个唯一的 IP 地址。

  3. 配置 Pod 的 IP 和路由

    第一步,将分配到的 IP 地址配置给 Pod 的虚拟网卡; 第二步,在 Pod 的网卡上配置集群网段的路由,令访问的流量都走到对应的 Pod 网卡上去,并且也会配置默认路由的网段到这个网卡上,也就是说走公网的流量也会走到这个网卡上进行路由; 最后在宿主机上配置到 Pod 的 IP 地址的路由,指向到宿主机对端 veth1 这个虚拟网卡上。这样实现的是从 Pod 能够到宿主机上进行路由出去的,同时也实现了在宿主机上访问到 Pod 的 IP 地址也能路由到对应的 Pod 的网卡所对应的对端上去。

2、给 Pod 连上网络

刚才我们是给 Pod 插上网线,也就是给它配了 IP 地址以及路由表。那怎么打通 Pod 之间的通信呢?也就是让每一个 Pod 的 IP 地址在集群里面都能被访问到。

一般我们是在 CNI Daemon 进程中去做这些网络打通的事情。通常来说是这样一个步骤:

  • 首先 CNI 在每个节点上运行的 Daemon 进程会学习到集群所有 Pod 的 IP 地址及其所在节点信息。学习的方式通常是通过监听 K8s APIServer,拿到现有 Pod 的 IP 地址以及节点,并且新的节点和新的 Pod 的创建的时候也能通知到每个 Daemon;
  • 拿到 Pod 以及 Node 的相关信息之后,再去配置网络进行打通。
    • 首先 Daemon 会创建到整个集群所有节点的通道。这里的通道是个抽象概念,具体实现一般是通过 Overlay 隧道、阿里云上的 VPC 路由表、或者是自己机房里的 BGP 路由完成的;
    • 第二步是将所有 Pod 的 IP 地址跟上一步创建的通道关联起来。关联也是个抽象概念,具体的实现通常是通过 Linux 路由、fdb 转发表或者OVS 流表等完成的。Linux 路由可以设定某一个 IP 地址路由到哪个节点上去。fdb 转发表是 forwarding database 的缩写,就是把某个 Pod 的 IP 转发到某一个节点的隧道端点上去(Overlay 网络)。OVS 流表是由 Open vSwitch 实现的,它可以把 Pod 的 IP 转发到对应的节点上。

由于以CNI插件的方式来扩展网络并不是很复杂,很多公司都会自研自己的插件和组件。