背景

2022 年 5 月 3 日, Kubernetes 1.24 版本发布,正式移除对 Dockershim 的支持,这意味着 Docker 已经不能直接作为容器运行时运行在之后版本的 Kubernetes 中。此时就需要一个替代品,containerd 就是其中之一。

Docker

Docker 从 1.11 版本开始,其容器的运行就不再是简单的通过 Docker Daemon 来启动了,而是通过集成 containerd、runc 等多个组件来协同完成。

虽然 Docker Daemon 守护进程模块在不停的重构,但是基本功能和定位没有太大的变化,一直都是 CS 架构,守护进程负责和 Docker Client 端交互,并管理 Docker 镜像和容器。

而 containerd 则负责对集群节点上的容器的生命周期进行管理,并为 Docker Daemon 提供 gRPC 接口,架构如图所示:

目前的 Docker 创建容器的过程:

  1. 当需要创建一个容器时,Docker Daemon 并不能直接创建,而是请求 containerd 来创建一个容器。

  2. containerd 收到请求后,也并不会直接去操作容器,而是创建一个叫做 containerd-shim 的进程,让它去操作容器。原因在于:

容器进程是需要一个父进程做状态收集、维持 stdin 等工作的,假如这个父进程就是 containerd,那 containerd 挂掉,整个宿主机上所有的容器都得退出,而引入 containerd-shim 这个垫片就是为了规避这个问题。

  1. 创建容器需要按照开放容器标准 OCI 做一些 namespaces 和 cgroups 的配置,以及挂载 root 文件系统等操作。而 runc (Docker 被逼无耐将 libcontainer 捐献出来改名为 runc)就是它的一个参考实现,可以通过它来创建一个符合规范的容器

该标准其实就是一个文档,主要规定了容器镜像的结构、以及容器需要接收哪些操作指令,比如 create、start、stop、delete 等这些命令。runc 只是其中的一种实现,其它的还有 Kata、gVisor 等。

所以,真正启动容器是通过 containerd-shim 去调用 runc 来启动容器的,runc 启动完容器后本身会直接退出,containerd-shim 则会成为容器进程的父进程(通过 ps -ef 命令可以直接在宿主机上看到),负责收集容器进程的状态,上报给 containerd,并在容器中 pid 为 1 的进程退出后接管容器中的子进程进行清理,确保不会出现僵尸进程。

而 Docker 将容器操作都迁移到 containerd 中去是为了做 Swarm,进军 PaaS 市场。可惜最终 Swarm 惨败,最后 Docker 公司就把 containerd 项目捐献给了 CNCF 基金会。

CRI

Kubernetes 提供了一个 CRI 的容器运行时接口,那么这个 CRI 到底是啥?

在 Kubernetes 早期,当时 Docker 大火,Kubernetes 当然就先选择支持 Docker,而且是通过硬编码的方式直接调用 Docker API。

后面随着 Docker 的不断发展以及 Google 的主导,出现了更多容器运行时,Kubernetes 为了支持更多更精简的容器运行时,Google 就和红帽主导推出了 CRI 标准,用于将 Kubernetes 平台和特定的容器运行时解耦。

CRI(Container Runtime Interface,容器运行时接口),本质上就是 Kubernetes 定义的一组与容器运行时进行交互的接口,所以只要实现了这套接口的容器运行时都可以对接到 Kubernetes 平台上来。

不过 Kubernetes 推出 CRI 这套标准的时候还没有现在的统治地位,所以有一些容器运行时可能不会自身就去实现 CRI 接口,于是就有了 shim(垫片),一个 shim 的职责就是作为适配器将各种容器运行时本身的接口适配到 Kubernetes 的 CRI 接口上,其中 dockershim 就是 Kubernetes 对接 Docker 到 CRI 接口上的一个垫片实现。简单的架构如下:

Kubelet 通过 gRPC 框架与容器运行时或 shim 进行通信,其中 kubelet 作为客户端,CRI shim(也可能是容器运行时本身)作为服务端。

CRI 定义的 API 主要包括两个 gRPC 服务,ImageServiceRuntimeService

  • ImageService:拉取、查看和删除镜像等操作。
  • RuntimeService: 用来管理 Pod 和容器的生命周期,以及与容器交互的调用(exec/attach/port-forward)等操作。

可以通过 kubelet 中的标志 --container-runtime-endpoint--image-service-endpoint 来配置这两个服务的套接字。

不过 Kubernetes 1.24 版本之前的 Docker 是一个例外,由于 Docker 当时的江湖地位很高,Kubernetes 是直接内置了 dockershim 在 kubelet 中的,所以之前的版本如果你使用 Docker 作为容器运行时是不需要单独去安装配置适配器之类的。

从上图可以看出,使用 Docker 作为容器运行时,其调用链比较长的:当在 Kubernetes 中创建一个 Pod 时,首先就是 kubelet 通过 CRI 接口调用 dockershim。dockershim 在收到请求后,转化成 Docker Daemon 能识别的请求并发到 Docker Daemon 上请求创建一个容器,后续才就是 Docker 创建容器的流程。

说到底,真正容器相关的操作其实 containerd 就完全足够了,Docker 太过于复杂笨重,只是对于用户操作比较友好而已,但是对于 Kubernetes 来说压根不需要这些功能,所以自然也就可以将容器运行时切换到 containerd 来。调用链对比图如下:

通过切换到 containerd 可以消除掉中间环节,操作体验也和以前一样,但由于是直接用容器运行时调度容器,所以以前用来检查这些容器的 Docker 命令行工具就不能使用了。

当然,用户仍然可以下载镜像,或者用 docker 命令构建,下载镜像,但对于容器运行时和 Kubernetes 来说,这样的镜像均不可见。如果想让 Kubernetes 使用,需要把镜像推送到镜像仓库中去。

在 containerd 1.0 中,对 CRI 的适配是通过一个单独的 CRI-Containerd 进程来完成的,这是因为最开始 containerd 还会去适配其他的系统(比如 swarm),所以没有直接实现 CRI。

到了 containerd 1.1 版本后就去掉了 CRI-Containerd 这个 shim,直接把适配逻辑作为插件的方式集成到了 containerd 主进程中,调用变得更加简洁。

与此同时,Kubernetes 社区也做了一个专门用于 Kubernetes 的 CRI 运行时 CRI-O,直接兼容 CRI 和 OCI 规范。

虽然 Kubernetes 1.24 版本移除对 Dockershim 的支持,但只是废弃了内置的 dockershim 功能而已,只是意味着 Docker 和其他容器运行时将一视同仁,不会单独对待内置支持。

如果还想直接使用 Docker,可以将 dockershim 的功能单独提取出来独立维护一个 cri-dockerd 即可,类似于 containerd 1.0 版本中提供的 CRI-Containerd。但这种方法显然没有直接调用 Containerd 来的便捷。