导读:
在学习Containerd之前,我们需要去了解Docker与Kubernetes这两个使用Containerd最多的技术,也需要明白什么是容器,什么是容器运行时,以及里面涉及的组件,这些组件是用来干什么的,及容器领域的概念,如libcontainer、runc、OCI、CRI、shim等。
什么是容器?
在 Linux 内核中,容器不是一类对象。容器本质上由几个底层的内核原语组成:namespace(允许你跟谁交谈),cgroup(允许使用的资源量),和 LSM(Linux 安全模块 —— 允许你做的事情)。这些凑在一起能够为我们的进程设置安全、隔离和可计量的执行环境。
每次创建隔离进程时,都不需要手动隔离、自定义命名空间等,把这些组件捆绑在一起,我们称之为容器。但是每次手动执行所有的操作将很麻烦,因此出现了容器运行时工具,它能将这些部分组合成一个隔离的、安全的执行环境变得很容易,让我们能以重复的方式部署。
什么是容器运行时?
容器运行时是掌控容器运行的整个生命周期,以docker为例,其主要提供功能如下:
-
制定容器镜像格式
-
构建容器镜像
-
管理容器镜像
-
管理容器实例
-
运行容器
-
实现容器镜像共享
这些功能均可由小的组件单独实现,因此容器运行时是运行和管理容器运行所需要的组件。
随着容器运行时的发展,Docker公司与CoreOS和Google共同创建了OCI(开放容器标准),并提供了两种规范:
-
运行时规范:该规范目标是定义容器的配置、执行环境和生命周期
-
镜像规范:该规范的目标创建可互操作的工具,用于构建、传输和准备要运行的容器镜像
在runc作为了OCI的一种实现参考之后,各种运行时工具和库也慢慢出现。而根据这些运行时的功能不同,比如有的只运行容器(runc,lxc),有的还可以对镜像进行管理(Containerd,cri-o),因此通俗的分为高级运行时(high-level)和低级运行时(low-level)。
低级运行时:侧重于运行容器,为容器设置namespace和cgroup
-
lxc
-
rkt
-
runc
-
kata
-
gVisor
高级运行时:包含更多上层功能,如为开发人员提供API,镜像存储管理等
-
Containerd
-
cri-o
-
docker
Docker
Docker是第一个流行的容器技术,最初Docker使用的是LXC(0.7版本之前)但是隔离的层次不完善,后来Docker开发了libcontainer(0.7~1.10版本),最后演变为runc和Containerd(Docker被逼无奈将libcontainer捐献出来改名为runc)。
从1.11版本之后,Docker容器运行开始通过集成Containerd和runc等多个组件完成。现在的架构中,Containerd负责容器的生命周期管理,提供了在一个节点上执行容器和管理镜像的最小功能集,并向上为Docker Daemon提供grpc接口。
当请求创建一个容器时,Docker Daemon并不会直接去创建,而且请求containerd创建容器,containerd在收到请求后,也不会去直接操作容器,而是创建containerd-shim的进程去操作容器(因为需要一个父进程去做状态收集、维持stdin、stdout、stderr打开等工作,如果父进程是contaienrd,当containerd挂掉时,整个宿主机的容器都会退出),而containerd-shim会去调用runc来启动容器,runc在启动完容器后会直接退出,此时containerd-shim成为容器的父进程,负责收集容器进程的状态上报给containerd,并在容器中 pid 为 1 的进程退出后接管容器中的子进程进行清,确保不会出现僵尸进程。
runc创建容器则是根据上述的OCI去做操作,例如namespaces、cgroups的配置,以及挂载root文件系统等操作。
Docker 将容器操作都迁移到 containerd 中去是因为当时做 Swarm,想要进军 PaaS 市场,做了这个架构切分,让 Docker Daemon 专门去负责上层的封装编排,当然后面的结果我们知道 Swarm 在 Kubernetes 面前是惨败,然后 Docker 公司就把 containerd 项目捐献给了 CNCF 基金会,这个也是现在的 Docker 架构。
Kubernetes
2014年Kubernetes诞生,由于当时Docker很流行,因此很自然的选择了Docker,在CRI出现之前,Kubelet通过内嵌的dockershim操作Docker API来操作容器,进而达到一个面向终态的效果。
而随着Docker将Containerd开源出以及更多的容器运行时出来,Kubernetes为了精简和支持更多的容器运行时,Google和Redhat推出了CRI标准,用于Kubernetes平台和容器运行时解耦CRI(容器运行时接口)。
CRI本质上是Kubernetes定义的一组与容器运行时进行交互的接口,因此容器运行时只要实现了CRI,就可以对接到Kubernetes平台中。但是当时Kubernetes的地位不高,所以一些容器运行时不会去实现CRI接口,于是就出现了shim,shim的职责是作为适配器将各种容器运行时本身的接口适配到 Kubernetes 的 CRI 接口上,上图的dockershim就是Kubernetes对接Docker到CRI接口的实现。
在引入CRI后,Kubelet的架构如图所示:
通过观察分析能够发现,Kubernetes使用Docker的调用链比较长,而Docker的一些功能对于Kubernetes来说又不需要,所以自然的将容器运行时切换到Containerd。切换到Containerd后取消掉了中间环节,但操作体验和以前一样,在Containerd1.0时,对CRI的适配是通过一个单独的CRI-Containerd实现(因为最开始containerd还会去适配其他系统,所以没有直接实现CRI)。到了Containerd1.1版本后就去掉了CRI-Containerd,直接把适配逻辑作为插件集成到Containerd主进程中,变得更加简洁。
CRI的接口主要分为两类:
-
ImageService:镜像相关的操作
-
RuntimeService:容器和Sandbox运行时管理
RuntimeService 中 CRI 设计的一个重要原则,就是确保这个接口本身,只关注容器,不关注 Pod,这么做是因为:
-
Pod 是 Kubernetes 的编排概念,而不是容器运行时的概念。所以,我们就不能假设所有下层容器项目,都能够暴露出可以直接映射为 Pod 的 API;
-
如果 CRI 里引入了关于 Pod 的概念,那么接下来只要 Pod API 对象的字段发生变化,那么 CRI 就很有可能需要变更。而在 Kubernetes 开发的前期,Pod 对象的变化还是比较频繁的,但对于 CRI 这样的标准接口来说,这个变更频率就有点麻烦了。
虽然 CRI 里还是有一组叫做 RunPodSandbox 的接口。但是,这个 PodSandbox,对应的并不是 Kubernetes 里的 Pod API 对象,而只是抽取了 Pod 里的一部分与容器运行时相关的字段,比如 HostName、DnsConfig、CgroupParent 等。所以说,PodSandbox 这个接口描述的其实是 Kubernetes 将 Pod 这个概念映射到容器运行时层面所需要的字段,或者说是一个 Pod 对象的子集。而创建、管理 Pod 的逻辑则放置在 kubernetes 中,而不是 CRI 要实现的接口中。
随着 CRI 方案的发展,以及其他容器运行时对 CRI 的支持越来越完善,Kubernetes 社区在2020年7月份就开始着手移除 dockershim 方案了,现在的移除计划是在 1.20 版本中将 kubelet 中内置的 dockershim 代码分离,将内置的 dockershim 标记为维护模式,当然这个时候仍然还可以使用 dockershim,目标是在 1.24 版本发布没有 dockershim 的版本(代码还在,但是要默认支持开箱即用的 docker 需要自己构建 kubelet,会在某个宽限期过后从 kubelet 中删除内置的 dockershim 代码)。
CRI的实现
目前,CRI领域有两个主要的参与者,一个是Docker的高级运行时Containerd,一个是RedHat专门为Kubernetes设计的运行时CRI-O。
CRI-O
当容器运行时的标准被提出以后,RedHat的一些人开始想他们可以构建一个更简单的运行时,而且这个运行时仅仅为Kubernetes所用。这样就有了skunkworks项目,最后定名为 CRI-O, 它实现了一个最小的CRI接口,旨在充当CRI和支持的OCI运行时的轻量级桥梁。
CONTAINERD
Containerd 是一个工业级标准的容器运行时,它强调简单性、健壮性和可移植性,可以在宿主机中管理完整的容器生命周期:容器镜像的传输和存储、容器的执行和管理、存储和网络等,主要有以下功能:
-
管理容器的生命周期(从创建容器到销毁容器)
-
拉取/推送容器镜像
-
存储管理(管理镜像及容器数据的存储)
-
调用 runc 运行容器(与 runc 等容器运行时交互)
-
管理容器网络接口及网络(CNI)
Containerd在Docker或者Kunernetes中都是使用最多的运行时,同时也是我们环境中接触最多的,因此后续着重学习Containerd。
Containerd
Containerd 可用作 Linux 和 Windows 的守护程序,它管理其主机系统完整的容器生命周期,从镜像传输和存储到容器执行和监测,再到底层存储到网络附件等等。
为了解耦,Containerd 将系统划分成了不同的组件,每个组件都由一个或多个模块协作完成(Core 部分),每一种类型的模块都以插件的形式集成到 Containerd 中,而且插件之间是相互依赖的,例如,上图中的每一个长虚线的方框都表示一种类型的插件,包括 Service Plugin、Metadata Plugin、GC Plugin、Runtime Plugin 等,其中 Service Plugin 又会依赖 Metadata Plugin、GC Plugin 和 Runtime Plugin。每一个小方框都表示一个细分的插件,例如 Metadata Plugin 依赖 Containers Plugin、Content Plugin 等。比如:
-
Content Plugin: 提供对镜像中可寻址内容的访问,所有不可变的内容都被存储在这里
-
Snapshot Plugin: 用来管理容器镜像的文件系统快照,镜像中的每一层都会被解压成文件系统快照,类似于 Docker 中的 graphdriver
总体来看 Containerd 可以分为三个大块:
-
Storage 管理镜像文件的存储
-
Metadata 管理镜像和容器的元数据
-
Runtime 由Task触发的运行时
Containerd被设计成可以很容易的嵌入到更大的系统中,例如Docker使用containerd运行容器,Kubernetes通过CRI使用containerd管理单个 节点上的容器 除了编程方式使用外,它还可以通过命令行使用,但不像docker全面,主要用于调试和学习目的,主要有:
-
ctr Containerd依据自身开发的命令行工具
-
nerdctl 与docker命令行风格兼容的命令行工具
-
crictl K8S根据CRI规范定义的命令行工具
GRPC API
Containerd通过暴露的gRPC API给外部管理容器,而Containerd中主要提供的API有:
其他还包括events、diffs等,具体见containerd的gRPC API
创建容器流程
Docker、ctr、nerdctl都是通过Containerd提供的API进行容器的管理,Kubernetes、crictl则是通过CRI接口实现。
使用gRPC API创建容器
-
分配一个新的读写快照(snapshot),使得容器可以存储持久化数据(为容器创建新快照时,需要提供快照ID以及容器使用的镜像)
-
创建一个Container对象,用于分配数据
-
创建一个Task,用于实际的运行容器(当Task已创建时,意味着命名空间、根文件系统和各种容器级别的设置已被初始化,但容器定义的进程尚未启动)
-
在启动Task之前需要等待Task创建成功,然后再调用Start去启动Task
Kubelet创建Pod流程
-
调用CRI插件,通过RuntimeService创建Pod
-
CRI调用CNI接口创建和配置Pod的网络命令空间
-
CRI调用Containerd内部接口创建特殊的pause容器,并将该容器放入Pod的cgroups和namespace中(使用不同的容器运行时,PodSandbox的实现方式也不一样,比如使用kata作为runtime,PodSandbox被实现为一个虚拟机;而使用runc作为runtime,PodSandbox就是一个独立的namespace和cgroups)
-
调用CRI插件,通过ImageServie拉取应用容器镜像
-
如果节点上不存在镜像,则使用Contianerd拉取镜像
-
调用CRI插件,使用RuntimeService创建和启动应用容器
-
CRI调用Containerd内部接口创建容器,放到Pod的cgroups和namespace中
Containerd创建任务流程
上述说的创建容器流程和创建Pod流程都是调用Containerd内部接口的逻辑,实际的过程由Containerd启动Containerd-shim进程调用runc创建容器,具体步骤如下:
-
Containerd调用Containerd-shim start 启动用于创建runc的Containerd-shim,这样Containerd-shim就与Containerd脱离了关系,重启Containerd也不会影响Containerd-shim进程
-
通过ttrpc调用Containerd-shim的Newtask方法,之后调用runc create
-
再通过ttrpc调用Containerd-shim的Start方法,之后调用runc start启动pause容器
-
以同样的方式启动Pod中定义的container
1. Containerd 被设计成嵌入到一个更大的系统中,而不是直接由开发人员或终端用户使用
2. Docker有网络功能模块,比如它会创建 docker0 网桥,所以在使用 docker 时可以直接实现端口映射等功能,而这些网络能力都是 Docker Daemon 实现的。但是Containerd 中不包含相应的网络功能,想要启动的容器有网络能力,需要额外安装 CNI 相关的工具和插件(bridge、flannel 等)
*Containerd一切皆插件
总结
本文通过引入Docker和Kubernetes的发展介绍容器、容器运行时,将容器领域c常见的概念OCI、CRI、shim、runc,containerd串联起来,能够帮组我们进一步理解Docker和Kubernetes背后是怎么创建容器的,以及Containerd的实际运行原理。
参考
https://github.com/containerd/containerd
https://github.com/kubernetes
https://github.com/moby/moby
https://github.com/kubernetes/enhancements/tree/master/keps/sig-node/2221-remove-dockershim
一文搞懂容器运行时
containerd shim原理深入解读