【干货分享】Kubernetes容器网络之CNI漫谈

image.png

前言linux


容器技术的出现,对传统的应用程序架构、应用开发发布流程等提供了新的思路,容器技术能将应用程序及其依赖进行打包,能提供跨环境的一致性,拥有良好的可移植性。而Kubernetes的出现,解决了企业中大规模运行容器的管理问题,它能提供容器的生命周期管理、容器编排的能力。但这两种技术自己不具有完整的容器网络功能,须要依靠第三方提供容器网络功能,CNI(Container Network Interface)则为第三方容器网络技术与Kubernetes的集成提供了标准。git


本文主要经过如下几个方面介绍下CNI的功能和原理:首先经过介绍CNI接口规范和CNI插件类型使你们简单了解CNI的概念;而后介绍Kubernetes对CNI的调用流程以及CNI插件的开发方式;最后会结合一个开发的案例,来分享一些CNI开发中须要注意到的事项。github


CNI简介docker

Kubernetes不少容器网络功能的实现都依赖于单独的网络插件,现阶段的网络插件主要有两类:Kubenet与CNI。其中,Kubenet是一个基础的、极其简单的网络插件,自己并不提供跨主机的容器网络转发或网络策略功能;通常Kubernetes的应用场景中,使用较广泛的是CNI插件。
json


CNI是一个通用接口的标准,定义了一系列用于链接容器编排系统与网络插件的规范,CNI插件经过实现CNI规范,来提供对容器网络的配置功能,CNI插件能够建立管理容器网卡、配置容器DNS、配置容器路由、为容器分配IP等。CNI最初并非为Kubernetes开发的,而是来自于rkt的runtime中,而除了CNI外,由Docker主导的CNM(Container network model)也为容器网络提供方的接入提供了标准,但因为包括设计灵活性在内的种种因素,Kubernetes最终选择了CNI做为容器网络的接口规范。后端

图片

图 1 CNI架构api


Kubernetes中的Kubelet组件在进行pod生命周期的管理时,会调用CNI插件的接口,为Pod配置或释放容器网络。CNI的调用并不像通常组件,经过HTTP、RPC等方式调用,而是经过执行二进制文件的方式进行调用。网络


CNI接口规范架构

为了丰富、完善CNI插件的功能,CNI的接口规范是不断的在更新迭代的,最新的版本是0.4.0版本,包括下面4个操做:
app


1)ADD,用于将容器添加到CNI网络中。2)DEL,用于将容器从CNI网络中清除。3)CHECK,用于判断容器的网络是否如预期设置的。4)VERSION,用于返回插件自身支持的CNI规范版本。


与上一个0.3.1版本的规范最大的区别在于,新添加了CHECK接口。这是因为在以往的CNI规范中,只有ADD、DEL的接口,缺乏GET、LIST之类的状态检索接口,这样一来,Kubernetes在调用ADD与DEL接口后,仅依靠这两个接口返回的信息,很难准确的获取到容器网络如今的状态。


详细的操做参数和规范能够参考https://github.com/containernetworking/cni/blob/master/SPEC.md


CNI插件类型

CNI插件根据其实现的功能的不一样,分为4类,社区为每一类CNI插件都提供了一些标准CNI实现,实现了一些基础的网络功能:


1)Main:主要的CNI网络插件,通常负责网络设备的建立删除等,能够单独使用。例如bridge插件,能够为容器建立veth pair,并链接到linux bridge上。


2)IPAM:用于管理容器IP资源的CNI插件,通常配合其余插件共同使用。例如host-local插件,能够根据预先设置的IP池范围、分配要求等,为容器分配释放IP资源。


3)Meta:这类插件功能较杂,好比提供端口映射的portmap插件,能够利用iptables将宿主机端口与容器端口进行映射;提供带宽控制的bandwidth插件,能够利用TC(Traffic Control)对容器的网络接口进行带宽的限制。但这类插件须要与Main插件配合使用,没法单独使用。另外,广泛使用的用于提供完整的容器网络功能的Flannel网络插件也属于这一类,通常会配合bridge插件与host-local插件共同使用。


4)Windows:专门用于Windows平台的CNI插件。

CNI插件能够经过插件链的方式被调用,经过设置CNI的配置文件,能够自由组合各类CNI插件的功能,知足容器网络的需求。以提供完整容器网络解决方案Canal为例,Canal是容器网络插件Flannel与Calico经过特定方式组合部署的,Canal具备Calico的网络策略功能以及Flannel的容器网络路由功能,官方提供的CNI配置文件以下:

{
       "name": "canal",
       "cniVersion": "0.3.1",
       "plugins": [
           {
               "type": "flannel",
               "delegate": {
                   "type": "calico",
                   "include_default_routes": true,
                   "etcd_endpoints": "__ETCD_ENDPOINTS__",
                   "etcd_key_file": "__ETCD_KEY_FILE__",
                   "etcd_cert_file": "__ETCD_CERT_FILE__",
                   "etcd_ca_cert_file": "__ETCD_CA_CERT_FILE__",
                   "log_level": "info",
                   "policy": {
                       "type": "k8s",
                       "k8s_api_root": "https://__KUBERNETES_SERVICE_HOST__:__KUBERNETES_SERVICE_PORT__",
                       "k8s_auth_token": "__SERVICEACCOUNT_TOKEN__"
                   },
                   "kubernetes": {
                       "kubeconfig": "/etc/cni/net.d/__KUBECONFIG_FILENAME__"
                   }
               }
           },
           {
               "type": "portmap",
               "capabilities": {"portMappings": true},
               "snat": true
           }
       ]
   }

在plugins字段下包含了使用的插件,其中type字段表示使用的插件类型,能够看到配置文件里包括了两个CNI插件:flannel与portmap,两个插件会经过插件链的方式被调用。首先是flannel插件,flannel中的delegate字段表示flannel会将一些容器网络的配置工做交给calico插件完成,这里主要是容器的网络设备的建立与配置,而原始的flannel配置文件中,这部分为bridge插件的配置;接着是portmap插件,portmap中的capabilities字段用来表示此插件具备的一些特殊功能,Kubernetes若是须要对Pod设置hostport功能,则会在调用CNI插件时,带上portMappings所需的参数。


Kubernetes对CNI的调用

因为Kubernetes最新的release版本v1.15.1中使用的仍然是CNI 0.3.1规范,所以下面以CNI release 0.6.0版本(对应CNI 0.3.1规范)进行介绍。


在Kubernetes中,要使用CNI插件做为network plugin时,须要设置Kubelet的--network-plugin、--cni-conf-dir、--cni-bin-dir参数,分别对应:network-plugin的名称(现阶段只有kubenet、cni两个值能够设置);CNI配置的文件夹;CNI二进制的文件夹。


Kubernete对CNI的调用是经过Kubelet完成的,而kubelet经过CRI(Container Runtime Interface,容器运行时的接口规范)来操做容器,所以CNI的调用最终是由CRI完成的,之内置的一种CRI实现——dockershim为例,调用流程以下图。其中须要说明的是,Kubernetes中的Pod是一组容器的集合,而Kubernetes将这一组容器分为sandbox与container,建立sandbox时,会建立NetworkNamespace,而其余的container,会与sandbox共享这个NetworkNamespace,所以,只有在CRI操做sandbox类型的容器时,才会调用CNI。


图片

图 2 Kubelet对CNI的调用流程


另外,Kubelet不支持多CNI,这里说的多CNI是指多套CNI网络方案,而不是多个CNI插件,多个CNI插件能够经过插件链的方式进行调用。Kubelet会在--cni-conf-dir指定的目录下查找后缀名为.conf、.conflist、.json的文件,按字符顺序,选择第一个有效的CNI配置文件,来进行NetworkPlugin的初始化,所以Kubelet只会将容器加入一个CNI的容器网络中。


回到上面的图中,能够看到,最终Kubernetes调用了CNI的AddNetworkList()接口与DelNetWorkList()接口来分别进行容器网络的建立与删除,这两个接口其实是由CNI库中的CNIConfig结构实现。理解了这两个方法,就能理解CNI的调用流程。

func (c *CNIConfig) AddNetworkList(list *NetworkConfigList, rt *RuntimeConf) (types.Result, error) {}

func (c *CNIConfig) DelNetworkList(list *NetworkConfigList, rt *RuntimeConf) error {}

首先来看下接口的参数,参数有两个:一是NetworkConfigList,包含CNI配置文件的内容。为何叫List呢,实际上是对应的conflist后缀的CNI配置文件,conflist后缀的配置文件表示的是一组CNI插件的配置,与conf后缀的CNI配置文件相对应,上面介绍的Canal的CNI配置文件就是conflist,包含了2个plugin:flannel与portmap。二是RuntimeConf,是由Kubernetes生成的,提供了容器网络配置的必要参数以及规则。RuntimeConf结构以下所示:

type RuntimeConf struct {
   ContainerID string
   NetNS       string
   IfName      string
   Args        [][2]string
   // A dictionary of capability-specific data passed by the runtime
   // to plugins as top-level keys in the 'runtimeConfig' dictionary
   // of the plugin's stdin data.  libcni will ensure that only keys
   // in this map which match the capabilities of the plugin are passed
   // to the plugin
   CapabilityArgs map[string]interface{}
}

其中,ContainerID、NetNS分别为须要配置的容器ID以及容器对应的NetworkNamespace路径,IfName为须要建立的容器网络接口名称,Args包含一些必要的参数。


而Kubernetes生成的RuntimeConf值以下,须要提到的是,Kubernetes传递的IfName始终为“eth0”,这是因为现阶段Kubernetes不会经过AddNetworkList接口返回的Results获取Pod的IP值,而是经过执行nsenter命令去获取容器里eth0网卡的IP,但这种方式限制了Pod多网卡、多CNI插件的场景(根据相关的注释能够看出,后续Kubernetes会使用AddNetworkList接口返回的IP,只有当返回的Results中IP丢失时,才会采用nsenter命令去获取)。在Args方面,kubernetes会将Pod的Name与Pod所在的Namespace做为参数传递,CNI插件可使用Namespace/Name的组合做为容器的惟一标识。

rt := &libcni.RuntimeConf{
      ContainerID: podSandboxID.ID,
      NetNS:       podNetnsPath,
      IfName:      network.DefaultInterfaceName,
      Args: [][2]string{
          {"IgnoreUnknown", "1"},
          {"K8S_POD_NAMESPACE", podNs},
          {"K8S_POD_NAME", podName},
          {"K8S_POD_INFRA_CONTAINER_ID", podSandboxID.ID},
      },
   }

AddNetworkList()方法会顺序执行CNI配置文件里的CNI插件的二进制文件,执行ADD操做,每次执行都会将NetworkConfigList、RuntimeConf以及上一个插件返回的Results,编码成Json格式,以命令行参数的方式传递到CNI插件中。DelNetworkList()与AddNetworkList()相似,不一样在于:是逆序执行DEL操做,同时不会传递上一个插件返回的Results。


CNI插件开发

CNI插件的开发比较简单,须要使用到skel包(github.com/containernetworking/cni/pkg/skel),实现以下的两个接口并注册便可。从接口的名称中就能够看出,两个接口分别对应了CNI规范里的ADD操做和DEL操做。

func cmdAdd(args *skel.CmdArgs) error {}
func cmdDel(args *skel.CmdArgs) error {}

skel包实现了CNI插件的命令行参数的设置、解析,根据命令行的参数调用注册的cmdAdd方法与cmdDel方法,其中skel.CmdArgs包含了完整的Json格式的命令行参数。经过skel包,能够很方便的按照CNI规范开发本身的CNI插件。

func main() {
   skel.PluginMain(cmdAdd, cmdDel, version.All)
}
func cmdAdd(args *skel.CmdArgs) error {
//add network
}
func cmdDel(args *skel.CmdArgs) error {
//del network
}


案例:hostport随机分配

在Kubernetes中,Pod的生命周期都是短暂的,能够随时删除后重启,而每次重启,Pod的ip地址又会被分配。所以Kubernetes中访问Pod主要是依赖服务发现机制,Kubernetes提供了Cluster IP、Nodeport、Ingress、DNS等机制,用于将流量转发到后端的一组Pod中。


除了这类一个地址对应后端多个Pod的访问方式外,Kubernetes还为Pod提供了一种一对一的访问方式,用户能够为Pod设置hostport,将Pod的端口映射到宿主机端口。但hostport有以下的缺点:


1)须要手动设定,并且还不能和Nodeport冲突,而Nodeport是支持随机分配的,这样就致使手动设定hostport较复杂。


2)一个Deployment的全部Pod都只能设置为同一个hostport,那么Pod数量就会受到Kubernetes集群的节点数量的限制,当Pod数量超过节点数量,若是但愿全部Pod都能正常运行,则一定有两个Pod会调度到同一个节点,出现端口的冲突。


3)不像Nodeport,hostport只能映射到Pod所在宿主机的端口,若是Pod发生迁移,访问地址须要从新获取。


所以,咱们但愿能实现一种hostport方式,能自动分配端口进行映射,同时可以将完整的访问地址更新在Pod的annotation中。最终咱们选择使用CNI完成这项工做,而不是将这个逻辑添加在Kubernetes中,主要是考虑到版本升级的影响,选择了对Kubernetes侵入性最小的方案。因为portmap插件已经实现了端口映射的功能,咱们须要作的只有管理、分配映射端。这个功能自己实现起来并不难,但有些设计上的细节能够和你们分享下。


参数如何传递


portmap插件须要具体的端口参数进行iptables配置,这些参数其实来自于RuntimeConf,而前面介绍过,参数的传递是以下图所示的,RuntimeConf由kubernetes设置好发送到各个CNI,各个CNI之间只会经过PreResults(即前一个CNI插件的结果)传递,所以采用插件链的方式是不可行的。


图片

图 3 CNI的参数传递


咱们选择了在Kubelet与原始的CNI之间添加一层CNI,经过这层CNI插件,能够灵活的控制传递的参数,hostport随机分配的功能就能够在这里实现。


另外,这层CNI也能解决Kubelet仅使用“第一个有效的CNI配置文件”的问题,由于后续怎么调用CNI彻底由咱们来控制,这也是目前不少的多CNI插件的实现方式。固然,多CNI会更加复杂,里面还涉及多CNI之间的路由配置冲突等问题(这主要仍是因为CNI接口给了各个CNI插件足够的权限,去彻底配置容器的网络),而咱们这里只须要进行传递参数的修改。


图片

图 4 hostport随机分配组件采用的参数传递方式


更新Pod的annotation


通常来讲,Pod对象的修改会引发Kube-scheduler对pod的从新调度,而后Pod会在新的节点进行Pod的建立、CNI的调用等,但Pod的annotation的更改不会致使从新调度。所以,除非你的CNI插件有特殊的使用场景,不然CNI插件最多只修改Pod的annotation。好比在hostport随机分配的CNI中,咱们将Pod当前所在的宿主机IP与分配的Hostport,做为Pod的访问方式写入Pod的annotation。


Del接口的健壮性

Kubelet调用CNI的Del接口的场景有多种,好比用户删除Pod,Kubernetes GC进行资源释放,Pod状态和预期设定的不一致等,为了使Del接口在这些场景中都能正常运行,须要尽量的知足一些要求。


1)须要考虑到短期内使用相同的参数屡次调用Del接口的状况,Del接口要可以正常运行。通常当Del接口一次调用,须要删除或更新多种资源时,须要特别注意。好比咱们在释放hostport的时候,须要进行删除本地的分配记录、更新用于记录port资源的位图等操做,即便在更新位图的时候发现端口已经被释放,也会尝试继续进行后面分配记录的删除等流程。


2)能容许Del空的资源,当须要释放的资源未找到的时候,能够认为资源已经进行过释放了。这个和上面一条说的有些相似,在Kubelet中,若是CNI返回的错误中有“no such file or directory”(代码逻辑以下),会忽略错误,但CNI插件最好能本身完成这个逻辑。所以,即便在释放hostport的过程当中,找不到port被分配的状况,接口也会返回释放成功,只须要最终的状态符合预期。

 err = cniNet.DelNetworkList(netConf, rt)
   // The pod may not get deleted successfully at the first time.
   // Ignore "no such file or directory" error in case the network has already been deleted in previous attempts.
   if err != nil && !strings.Contains(err.Error(), "no such file or directory") {
      klog.Errorf("Error deleting %s from network %s/%s: %v", pdesc, netConf.Plugins[0].Network.Type, netConf.Name, err)
      return err
   }

3)Del接口不要经过查询Pod对象来获取相关参数,须要考虑到执行Del操做时,kube-apiserver中已删除相应的Pod对象的状况。通常来讲,Kubelet会把要释放的资源传递给CNI,好比Pod的IP、hostport端口等,但在咱们作的hostport随机分配插件中,Kubelet是不感知咱们分配的端口的,虽然咱们在Pod的annotation中有存储端口,但咱们仍是须要本地存储一份Pod与端口的分配记录,以供Del接口使用。


总结

CNI规范为CNI插件提供了很大的灵活性,使得Kubernetes与容器网络的实现解耦,文章介绍了一些基础的CNI开发,而较复杂的容器网络方案,除了CNI插件外,通常还须要配合Controller进行资源的同步(好比Kubernetes Networkpolicy的同步),甚至须要开发组件接管Kubernetes的Service网络,代替Kube-proxy的功能,以实现一个完整的容器网络实现方案。


End


往期精选

1

【干货分享】硬件加速介绍及Cyborg项目代码分析

2

【干货分享】BC-MQ大云消息队列高可用设计之谈

3

【大云制造】为云而生 - 大云BEK内核

图片