45丨幕后英雄:SIG-Node与CRI

思考并回答以下问题:

在前面的文章中,我为你详细讲解了关于Kubernetes调度和资源管理相关的内容。实际上,在调度这一步完成后,Kubernetes就需要负责将这个调度成功的Pod,在宿主机上创建出来,并把它所定义的各个容器启动起来。这些,都是kubelet这个核心组件的主要功能。

在接下来三篇文章中,我就深入到kubelet里面,为你详细剖析一下Kubernetes对容器运行时的管理能力。

在Kubernetes社区里,与kubelet以及容器运行时管理相关的内容,都属于SIG-Node的范畴。如果你经常参与社区的话,你可能会觉得,相比于其他每天都热闹非凡的SIG小组,SIG-Node是Kubernetes里相对沉寂也不太发声的一个小组,小组里的成员也很少在外面公开宣讲。

不过,正如我前面所介绍的,SIG-Node以及kubelet,其实是Kubernetes整套体系里非常核心的一个部分。毕竟,它们才是Kubernetes这样一个容器编排与管理系统,跟容器打交道的主要“场所”。

而kubelet这个组件本身,也是Kubernetes里面第二个不可被替代的组件(第一个不可被替代的组件当然是kube-apiserver)。也就是说,无论如何,我都不太建议你对kubelet的代码进行大量的改动。保持kubelet跟上游基本一致的重要性,就跟保持kube-apiserver跟上游一致是一个道理。

当然,kubelet本身,也是按照“控制器”模式来工作的。它实际的工作原理,可以用如下所示的一幅示意图来表示清楚。

可以看到,kubelet的工作核心,就是一个控制循环,即:SyncLoop(图中的大圆圈)。而驱动这个控制循环运行的事件,包括四种:

1,Pod更新事件;

2,Pod生命周期变化;

3,kubelet本身设置的执行周期;

4,定时的清理事件。

所以,跟其他控制器类似,kubelet启动的时候,要做的第一件事情,就是设置Listers,也就是注册它所关心的各种事件的Informer。这些Informer,就是SyncLoop需要处理的数据的来源。

此外,kubelet还负责维护着很多很多其他的子控制循环(也就是图中的小圆圈)。这些控制循环的名字,一般被称作某某Manager,比如VolumeManager、ImageManager、NodeStatusManager等等。

不难想到,这些控制循环的责任,就是通过控制器模式,完成kubelet的某项具体职责。比如NodeStatusManager,就负责响应Node的状态变化,然后将Node的状态收集起来,并通过Heartbeat的方式上报给APIServer。再比如CPUManager,就负责维护该Node的CPU核的信息,以便在Pod通过cpuset的方式请求CPU核的时候,能够正确地管理CPU核的使用量和可用量。

那么这个SyncLoop,又是如何根据Pod对象的变化,来进行容器操作的呢?

实际上,kubelet也是通过Watch机制,监听了与自己相关的Pod对象的变化。当然,这个Watch的过滤条件是该Pod的nodeName字段与自己相同。kubelet会把这些Pod的信息缓存在自己的内存里。

而当一个Pod完成调度、与一个Node绑定起来之后,这个Pod的变化就会触发kubelet在控制循环里注册的Handler,也就是上图中的HandlePods部分。此时,通过检查该Pod在kubelet内存里的状态,kubelet就能够判断出这是一个新调度过来的Pod,从而触发Handler里ADD事件对应的处理逻辑。

在具体的处理过程当中,kubelet会启动一个名叫PodUpdateWorker的、单独的Goroutine来完成对Pod的处理工作。

比如,如果是ADD事件的话,kubelet就会为这个新的Pod生成对应的PodStatus,检查Pod所声明使用的Volume是不是已经准备好。然后,调用下层的容器运行时(比如Docker),开始创建这个Pod所定义的容器。

而如果是UPDATE事件的话,kubelet就会根据Pod对象具体的变更情况,调用下层容器运行时进行容器的重建工作。

在这里需要注意的是,kubelet调用下层容器运行时的执行过程,并不会直接调用Docker的API,而是通过一组叫作CRI(ContainerRuntimeInterface,容器运行时接口)的gRPC接口来间接执行的。

Kubernetes项目之所以要在kubelet中引入这样一层单独的抽象,当然是为了对Kubernetes屏蔽下层容器运行时的差异。实际上,对于1.6版本之前的Kubernetes来说,它就是直接调用Docker的API来创建和管理容器的。

但是,正如我在本专栏开始介绍容器背景的时候提到过的,Docker项目风靡全球后不久,CoreOS公司就推出了rkt项目来与Docker正面竞争。在这种背景下,Kubernetes项目的默认容器运行时,自然也就成了两家公司角逐的重要战场。

毋庸置疑,Docker项目必然是Kubernetes项目最依赖的容器运行时。但凭借与Google公司非同一般的关系,CoreOS公司还是在2016年成功地将对rkt容器的支持,直接添加进了kubelet的主干代码里。

不过,这个“赶鸭子上架”的举动,并没有为rkt项目带来更多的用户,反而给kubelet的维护人员,带来了巨大的负担。

不难想象,在这种情况下,kubelet任何一次重要功能的更新,都不得不考虑Docker和rkt这两种容器运行时的处理场景,然后分别更新Docker和rkt两部分代码。

更让人为难的是,由于rkt项目实在太小众,kubelet团队所有与rkt相关的代码修改,都必须依赖于CoreOS的员工才能做到。这不仅拖慢了kubelet的开发周期,也给项目的稳定性带来了巨大的隐患。

与此同时,在2016年,KataContainers项目的前身runV项目也开始逐渐成熟,这种基于虚拟化技术的强隔离容器,与Kubernetes和Linux容器项目之间具有良好的互补关系。所以,在Kubernetes上游,对虚拟化容器的支持很快就被提上了日程。

不过,虽然虚拟化容器运行时有各种优点,但它与Linux容器截然不同的实现方式,使得它跟Kubernetes的集成工作,比rkt要复杂得多。如果此时,再把对runV支持的代码也一起添加到kubelet当中,那么接下来kubelet的维护工作就可以说完全没办法正常进行了。

所以,在2016年,SIG-Node决定开始动手解决上述问题。而解决办法也很容易想到,那就是把kubelet对容器的操作,统一地抽象成一个接口。这样,kubelet就只需要跟这个接口打交道了。而作为具体的容器项目,比如Docker、rkt、runV,它们就只需要自己提供一个该接口的实现,然后对kubelet暴露出gRPC服务即可。

这一层统一的容器操作接口,就是CRI了。我会在下一篇文章中,为你详细讲解CRI的设计与具体的实现原理。

而在有了CRI之后,Kubernetes以及kubelet本身的架构,就可以用如下所示的一幅示意图来描述。

可以看到,当Kubernetes通过编排能力创建了一个Pod之后,调度器会为这个Pod选择一个具体的节点来运行。这时候,kubelet当然就会通过前面讲解过的SyncLoop来判断需要执行的具体操作,比如创建一个Pod。那么此时,kubelet实际上就会调用一个叫作GenericRuntime的通用组件来发起创建Pod的CRI请求。

那么,这个CRI请求,又该由谁来响应呢?

如果你使用的容器项目是Docker的话,那么负责响应这个请求的就是一个叫作dockershim的组件。它会把CRI请求里的内容拿出来,然后组装成DockerAPI请求发给DockerDaemon。

需要注意的是,在Kubernetes目前的实现里,dockershim依然是kubelet代码的一部分。当然,在将来,dockershim肯定会被从kubelet里移出来,甚至直接被废弃掉。

而更普遍的场景,就是你需要在每台宿主机上单独安装一个负责响应CRI的组件,这个组件,一般被称作CRIshim。顾名思义,CRIshim的工作,就是扮演kubelet与容器项目之间的“垫片”(shim)。所以它的作用非常单一,那就是实现CRI规定的每个接口,然后把具体的CRI请求“翻译”成对后端容器项目的请求或者操作。

总结

在本篇文章中,我首先为你介绍了SIG-Node的职责,以及kubelet这个组件的工作原理。

接下来,我为你重点讲解了kubelet究竟是如何将Kubernetes对应用的定义,一步步转换成最终对Docker或者其他容器项目的API请求的。

不难看到,在这个过程中,kubelet的SyncLoop和CRI的设计,是其中最重要的两个关键点。也正是基于以上设计,SyncLoop本身就要求这个控制循环是绝对不可以被阻塞的。所以,凡是在kubelet里有可能会耗费大量时间的操作,比如准备Pod的Volume、拉取镜像等,SyncLoop都会开启单独的Goroutine来进行操作。

思考题

请问,在你的项目中,你是如何部署kubelet这个组件的?为什么要这么做呢?

0%