46丨解读CRI与容器运行时

思考并回答以下问题:

在上一篇文章中,我为你详细讲解了kubelet的工作原理和CRI的来龙去脉。在今天这篇文章中,我们就来进一步地、更深入地了解一下CRI的设计与工作原理。

首先,我们先来简要回顾一下有了CRI之后,Kubernetes的架构图,如下所示。

在上一篇文章中我也提到了,CRI机制能够发挥作用的核心,就在于每一种容器项目现在都可以自己实现一个CRIshim,自行对CRI请求进行处理。这样,Kubernetes就有了一个统一的容器抽象层,使得下层容器运行时可以自由地对接进入Kubernetes当中。

所以说,这里的CRIshim,就是容器项目的维护者们自由发挥的“场地”了。而除了dockershim之外,其他容器运行时的CRIshim,都是需要额外部署在宿主机上的。

举个例子。CNCF里的containerd项目,就可以提供一个典型的CRIshim的能力,即:将Kubernetes发出的CRI请求,转换成对containerd的调用,然后创建出runC容器。而runC项目,才是负责执行我们前面讲解过的设置容器Namespace、Cgroups和chroot等基础操作的组件。所以,这几层的组合关系,可以用如下所示的示意图来描述。

而作为一个CRIshim,containerd对CRI的具体实现,又是怎样的呢?

我们先来看一下CRI这个接口的定义。下面这幅示意图,就展示了CRI里主要的待实现接口。

具体地说,我们可以把CRI分为两组:

  • 第一组,是RuntimeService。它提供的接口,主要是跟容器相关的操作。比如,创建和启动容器、删除容器、执行exec命令等等。
  • 而第二组,则是ImageService。它提供的接口,主要是容器镜像相关的操作,比如拉取镜像、删除镜像等等。

关于容器镜像的操作比较简单,所以我们就暂且略过。接下来,我主要为你讲解一下RuntimeService部分。

在这一部分,CRI设计的一个重要原则,就是确保这个接口本身,只关注容器,不关注Pod。这样做的原因,也很容易理解。

第一,Pod是Kubernetes的编排概念,而不是容器运行时的概念。所以,我们就不能假设所有下层容器项目,都能够暴露出可以直接映射为Pod的API。

第二,如果CRI里引入了关于Pod的概念,那么接下来只要PodAPI对象的字段发生变化,那么CRI就很有可能需要变更。而在Kubernetes开发的前期,Pod对象的变化还是比较频繁的,但对于CRI这样的标准接口来说,这个变更频率就有点麻烦了。

所以,在CRI的设计里,并没有一个直接创建Pod或者启动Pod的接口。

不过,相信你也已经注意到了,CRI里还是有一组叫作RunPodSandbox的接口的。

这个PodSandbox,对应的并不是Kubernetes里的PodAPI对象,而只是抽取了Pod里的一部分与容器运行时相关的字段,比如HostName、DnsConfig、CgroupParent等。所以说,PodSandbox这个接口描述的,其实是Kubernetes将Pod这个概念映射到容器运行时层面所需要的字段,或者说是一个Pod对象子集。

而作为具体的容器项目,你就需要自己决定如何使用这些字段来实现一个Kubernetes期望的Pod模型。这里的原理,可以用如下所示的示意图来表示清楚。

比如,当我们执行kubectlrun创建了一个名叫foo的、包括了A、B两个容器的Pod之后。这个Pod的信息最后来到kubelet,kubelet就会按照图中所示的顺序来调用CRI接口。

在具体的CRIshim中,这些接口的实现是可以完全不同的。比如,如果是Docker项目,dockershim就会创建出一个名叫foo的Infra容器(pause容器),用来“hold”住整个Pod的NetworkNamespace。

而如果是基于虚拟化技术的容器,比如KataContainers项目,它的CRI实现就会直接创建出一个轻量级虚拟机来充当Pod。

此外,需要注意的是,在RunPodSandbox这个接口的实现中,你还需要调用networkPlugin.SetUpPod(…)来为这个Sandbox设置网络。这个SetUpPod(…)方法,实际上就在执行CNI插件里的add(…)方法,也就是我在前面为你讲解过的CNI插件为Pod创建网络,并且把Infra容器加入到网络中的操作。

备注:这里,你可以再回顾下第34篇文章《Kubernetes网络模型与CNI网络插件》中的相关内容。

接下来,kubelet继续调用CreateContainer和StartContainer接口来创建和启动容器A、B。对应到dockershim里,就是直接启动A,B两个Docker容器。所以最后,宿主机上会出现三个Docker容器组成这一个Pod。

而如果是KataContainers的话,CreateContainer和StartContainer接口的实现,就只会在前面创建的轻量级虚拟机里创建两个A、B容器对应的MountNamespace。所以,最后在宿主机上,只会有一个叫作foo的轻量级虚拟机在运行。关于像KataContainers或者gVisor这种所谓的安全容器项目,我会在下一篇文章中为你详细介绍。

除了上述对容器生命周期的实现之外,CRIshim还有一个重要的工作,就是如何实现exec、logs等接口。这些接口跟前面的操作有一个很大的不同,就是这些gRPC接口调用期间,kubelet需要跟容器项目维护一个长连接来传输数据。这种API,我们就称之为StreamingAPI。

CRIshim里对StreamingAPI的实现,依赖于一套独立的StreamingServer机制。这一部分原理,可以用如下所示的示意图来为你描述。

可以看到,当我们对一个容器执行kubectl exec命令的时候,这个请求首先交给API Server,然后API Server就会调用kubelet的ExecAPI。

这时,kubelet就会调用CRI的Exec接口,而负责响应这个接口的,自然就是具体的CRIshim。

但在这一步,CRIshim并不会直接去调用后端的容器项目(比如Docker)来进行处理,而只会返回一个URL给kubelet。这个URL,就是该CRIshim对应的StreamingServer的地址和端口。

而kubelet在拿到这个URL之后,就会把它以Redirect的方式返回给API Server。所以这时候,API Server就会通过重定向来向StreamingServer发起真正的/exec请求,与它建立长连接。

当然,这个StreamingServer本身,是需要通过使用SIG-Node为你维护的StreamingAPI库来实现的。并且,StreamingServer会在CRIshim启动时就一起启动。此外,StreamServer这一部分具体怎么实现,完全可以由CRIshim的维护者自行决定。比如,对于Docker项目来说,dockershim就是直接调用Docker的ExecAPI来作为实现的。

以上,就是CRI的设计以及具体的工作原理了。

总结

在本篇文章中,我为你详细解读了CRI的设计和具体工作原理,并为你梳理了实现CRI接口的核心流程。

从这些讲解中不难看出,CRI这个接口的设计,实际上还是比较宽松的。这就意味着,作为容器项目的维护者,我在实现CRI的具体接口时,往往拥有着很高的自由度,这个自由度不仅包括了容器的生命周期管理,也包括了如何将Pod映射成为我自己的实现,还包括了如何调用CNI插件来为Pod设置网络的过程。

所以说,当你对容器这一层有特殊的需求时,我一定优先建议你考虑实现一个自己的CRIshim,而不是修改kubelet甚至容器项目的代码。这样通过插件的方式定制Kubernetes的做法,也是整个Kubernetes社区最鼓励和推崇的一个最佳实践。这也正是为什么像KataContainers、gVisor甚至虚拟机这样的“非典型”容器,都可以无缝接入到Kubernetes项目里的重要原因。

思考题

请你思考一下,我前面讲解过的DevicePlugin为容器分配的GPU信息,是通过CRI的哪个接口传递给dockershim,最后交给DockerAPI的呢?

0%