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添加规则控制容器的进出流量
- Main插件:用来创建具体的网络设备的二进制文件,比如创建bridge(网桥设备)、ipvlan、loopback(lo 设备)、macvlan、ptp(Veth Pair 设备),以及 vlan。比如下面的flannel最终会调用这里的bridge二进制文件来创建。包括:
- 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 插上网线呢?通常是这样一个步骤:
给 Pod 准备一个网卡
通常我们会用一个 “veth” 这种虚拟网卡,一端放到 Pod 的网络空间,一端放到主机的网络空间,这样就实现了 Pod 与主机这两个命名空间的打通。
给 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 地址。
配置 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插件的方式来扩展网络并不是很复杂,很多公司都会自研自己的插件和组件。