为什么需要 CRI
对于 1.6 版本之前的 Kubernetes 来说,它是直接调用 Docker 的 API 来创建和管理容器的。
但是,Docker 项目风靡全球后不久,CoreOS 公司就推出了 rkt 项目来与 Docker 正面竞争。CoreOS 公司在 2016 年成功将对 rkt 容器的支持,直接添加进了 kubelet 的主干代码里。在这种情况下, kubelet 任何一次重要功能的更新,都不得不考虑 Docker 和 rkt 这两种容器运行时的处理场景,然后分别更新 Docker 和 rkt 两部分代码。另外,由于 rkt 项目实在太小众,kubelet 团队所有与 rkt 相关的代码修改,都必须依赖于 CoreOS 的员工才能做到。这不仅拖慢了 kubelet 的开发周期,也给项目的稳定性带来了巨大的隐患。
与此同时,在 2016 年,Kata Containers 项目的前身 runV 项目也开始逐渐成熟,这种基于虚拟化技术的强隔离容器,与 Kubernetes 和 Linux 容器项目之间具有良好的互补关系。所以,在 Kubernetes 中,对虚拟化容器的支持很快就被提上了日程。虽然虚拟化容器运行时有各种优点,但它与 Linux 容器截然不同的实现方式,使得它跟 Kubernetes 的集成工作,比 rkt 要复杂得多。如果此时,再把对 runV 支持的代码也一起添加到 kubelet 当中,那么接下来 kubelet 的维护工作就可以说完全没办法正常进行了。
因此,在 2016 年,SIG-Node 决定把 Kubelet 中对容器的操作,统一抽象为一组接口。这样,Kubelet 就只需要跟这个接口打交道即可,便于维护。作为具体的容器项目,比如 Docker、 rkt、runV 则需要提供关于这组接口的具体实现,然后以 gRPC 服务的方式对 Kubelet 暴露即可。而这一组统一的容器操作接口,就是 CRI。
CRI 基础
有了 CRI 之后,Kubelet 调用下层容器运行时的执行过程中,并不是直接调用容器提供的 API(比如 Docker API),而是通过 CRI(Container Runtime Interface,容器运行时接口)接口来间接调用容器的 API。整个过程,如下图所示,
- Kubelet 首先通过 SyncLoop 判断需要执行的具体操作,然后调用 GenericRuntime 通用组件来发起创建 Pod 的 CRI 请求。
- 实现了 CRI 接口规范的 gRPC 服务会响应该 CRI 请求。如果容器运行时是 docker 的话,则由 dockershim 响应,dockershim 则是 CRI 接口规范的具体实现。其他容器运行时,则也会有相应的 CRI shim 也 gRPC 服务的方式运行,并注册到 Kubelet 中。
- CRI 接口具体实现中会将 CRI 请求里面的内容拿出来,然后调用具体的容器 API。比如 dockershim 就会调用 docker 提供的 API。
通过 CRI,Kubernetes 屏蔽了下层容器运行时的差异,可以更方便支持其他容器运行时。
CRI 接口的定义
讲了这么多,接下来来看一下 CRI 接口有哪些,详细可看 Github-CRI API。如下所示,具体地说 CRI 分为两组:
- 第一组是 RuntimeService。它提供的接口,主要是跟容器相关的操作。比如,创建、启动、删除容器、执行 exec 命令等。
- 第二组是 ImageService。它提供的接口,主要是容器镜像相关的操作。比如拉取、删除镜像等。
service RuntimeService {
rpc Version(VersionRequest) returns (VersionResponse) {}
rpc RunPodSandbox(RunPodSandboxRequest) returns (RunPodSandboxResponse) {}
rpc StopPodSandbox(StopPodSandboxRequest) returns (StopPodSandboxResponse) {}
rpc RemovePodSandbox(RemovePodSandboxRequest) returns (RemovePodSandboxResponse) {}
rpc PodSandboxStatus(PodSandboxStatusRequest) returns (PodSandboxStatusResponse) {}
rpc ListPodSandbox(ListPodSandboxRequest) returns (ListPodSandboxResponse) {}
rpc PortForward(PortForwardRequest) returns (PortForwardResponse) {}
rpc CreateContainer(CreateContainerRequest) returns (CreateContainerResponse) {}
rpc StartContainer(StartContainerRequest) returns (StartContainerResponse) {}
rpc StopContainer(StopContainerRequest) returns (StopContainerResponse) {}
rpc RemoveContainer(RemoveContainerRequest) returns (RemoveContainerResponse) {}
rpc ListContainers(ListContainersRequest) returns (ListContainersResponse) {}
rpc ContainerStatus(ContainerStatusRequest) returns (ContainerStatusResponse) {}
rpc UpdateContainerResources(UpdateContainerResourcesRequest) returns (UpdateContainerResourcesResponse) {}
rpc ReopenContainerLog(ReopenContainerLogRequest) returns (ReopenContainerLogResponse) {}
rpc ExecSync(ExecSyncRequest) returns (ExecSyncResponse) {}
rpc Exec(ExecRequest) returns (ExecResponse) {}
rpc Attach(AttachRequest) returns (AttachResponse) {}
rpc CheckpointContainer(CheckpointContainerRequest) returns (CheckpointContainerResponse) {}
rpc GetContainerEvents(GetEventsRequest) returns (stream ContainerEventResponse) {}
rpc ContainerStats(ContainerStatsRequest) returns (ContainerStatsResponse) {}
rpc ListContainerStats(ListContainerStatsRequest) returns (ListContainerStatsResponse) {}
rpc PodSandboxStats(PodSandboxStatsRequest) returns (PodSandboxStatsResponse) {}
rpc ListPodSandboxStats(ListPodSandboxStatsRequest) returns (ListPodSandboxStatsResponse) {}
rpc ListMetricDescriptors(ListMetricDescriptorsRequest) returns (ListMetricDescriptorsResponse) {}
rpc ListPodSandboxMetrics(ListPodSandboxMetricsRequest) returns (ListPodSandboxMetricsResponse) {}
rpc UpdateRuntimeConfig(UpdateRuntimeConfigRequest) returns (UpdateRuntimeConfigResponse) {}
rpc Status(StatusRequest) returns (StatusResponse) {}
rpc RuntimeConfig(RuntimeConfigRequest) returns (RuntimeConfigResponse) {}
}
service ImageService {
rpc ListImages(ListImagesRequest) returns (ListImagesResponse) {}
rpc ImageStatus(ImageStatusRequest) returns (ImageStatusResponse) {}
rpc PullImage(PullImageRequest) returns (PullImageResponse) {}
rpc RemoveImage(RemoveImageRequest) returns (RemoveImageResponse) {}
rpc ImageFsInfo(ImageFsInfoRequest) returns (ImageFsInfoResponse) {}
}
在 RuntimeService 中 CRI 设计的一个重要原则,就是确保这个接口本身,只关注容器,不关注 Pod。这样做的原因是,
-
Pod 是 Kubernetes 的编排概念,而不是容器运行时的概念。不能假设所有容器项目都能够暴露出可以直接映射为 Pod 的 API。
-
如果 CRI 里引入了关于 Pod 的概念,那么接下来只要 Pod API 对象的字段发生变化,那么 CRI 就很有可能需要变更。而在 Kubernetes 开发的前期,Pod 对象的变化还是比较频繁的,对于 CRI 这样的标准接口来说,这个变更频率会有点麻烦。
因此在 CRI 的设计里,并没有一个直接创建 Pod 或者启动 Pod 的接口,而是将 Pod 的概念拆成了 Sandbox 和 Container。也就是说一个 Pod 是一个 Sandbox 和多个 Container 组成,其中 Sandbox 的主要作用是 hold 住了 Pod 的网络配置等,是 Pod 中所有的容器共享,而 Container 则是实际运行业务的容器。可以看到,CRI 中确实有 PodSandbox 和 Container 相关的接口。
接口调用示例
接下去以几个实际的例子,来讲解下这些接口被调用的顺序等。
创建 Pod
以执行 kubectl run 创建了一个名叫 foo 的、包括了 A、B 两个容器的 Pod 为例。整个顺序如下所示,
-
首先调用 RunPodSandbox 接口创建 Sandbox。针对不同容器运行时,CRI shim 在实现上可能会有不同,
- 如果是 Docker 项目,dockershim 就会创建出一个名叫 foo 的 Infra 容器(pause 容器),用来“hold”住整个 Pod 的 Network Namespace。
- 如果是基于虚拟化技术的容器,比如 Kata Containers 项目,它的 CRI shim 就会直接创建出一个轻量级虚拟机来充当 Pod。
在 RunPodSandbox 接口的实现中,还需要调用 networkPlugin.SetUpPod(…) 为这个 Sandbox 设置网络。这个 SetUpPod(…) 方法,实际上就是执行 CNI 插件里的 add(…) 方法把 Infra 容器加入到网络中。
-
接下去依次调用 CreateContainer 和 StartContainer 接口来创建和启动容器 A、B。针对不同容器运行时,CRI shim 在实现上可能会有不同,
- 如果是 Docker 项目,dockershim 直接创建并启动 A,B 两个 Docker 容器。最后,宿主机上会出现三个 Docker 容器组成这一个 Pod。
- 如果是 Kata Containers,CreateContainer 和 StartContainer 接口则会在前面创建的轻量级虚拟机里创建两个 A、B 容器对应的 Mount Namespace。最后在宿主机上只会有一个叫作 foo 的轻量级虚拟机在运行。
exec/logs 等接口
除了上述对容器生命周期的实现之外,CRI shim 还有一个重要的工作,就是实现 exec、logs 等接口。这些接口跟前面的操作有一个很大的不同,就是这些 gRPC 接口调用期间,kubelet 需要跟容器项目维护一个长连接来传输数据。这种 API 称之为 Streaming API。
CRI shim 里对 Streaming API 的实现,依赖于一套独立的 Streaming Server 机制。整个原理如下所示,
-
当对一个容器执行 kubectl exec 命令的时候,这个请求首先交给 API Server,API Server 会调用 kubelet 的 Exec API。kubelet 的 Exec 接口会调用 CRI shim 的 Exec 接口。
-
CRI shim 并不会直接调用后端的容器项目(比如 Docker )来进行处理,而只会返回一个 URL 给 kubelet。这个 URL 是该 CRI shim 对应的 Streaming Server 的地址和端口,Streaming Server 会在 CRI shim 启动时就一块启动。
-
kubelet 在拿到这个 URL 之后,会以重定向的方式返回给 API Server。此时,API Server 是通过重定向来向 Streaming Server 发起真正的 exec 请求,与它建立长连接。
-
kubelet 为方便各个 container runtime 实现 CRI shim 中的 Streaming Server,提供了统一的包,位于
kubelet/pkg/cri/streaming
中,各个 CRI shim 只需要实现其中的 steaming.Runtime 接口即可创建一个 Streaming Server。Runtime 接口包含三个方法,Exec、Attach、PortForward。对于 dockershim 来说,它的 Exec 方法实现就是直接调用 Docker 的 Exec API 来实现的。
总结
CRI 接口的设计,实际上还是比较宽松的。也就意味着,作为容器项目的维护者,在实现 CRI 的具体接口时,可以拥有很高的自由度,这个自由度不仅包括了容器的生命周期管理,也包括了如何将 Pod 映射成为自己的实现,还包括了如何调用 CNI 插件来为 Pod 设置网络的过程。
因此当对容器这一层有特殊的需求时,优先建议考虑实现一个特定的 CRI shim ,而不是修改 kubelet 甚至容器项目的代码。这样通过插件的方式定制 Kubernetes 的做法,也是整个 Kubernetes 社区最鼓励和推崇的一个最佳实践。这也正是为什么像 Kata Containers、gVisor 甚至虚拟机这样的“非典型”容器,都可以无缝接入到 Kubernetes 项目里的重要原因。
相关资料
Github-CRI API:https://github.com/kubernetes/cri-api