编者按
本文是一篇介绍容器运行时和管理工具的文章。文中对主要的容器管理项目和技术做了较为详细的介绍和横向对比,并给出了项目的代码库供读者参考。
前言
容器带来了更高级的服务端架构和更复杂的部署技术。目前已经有一堆类似标准的规范(1, 2, 3, 4, ……)描述了容器领域的方方面面。当然,它的底层是Linux的基本单元,如namespace和cgroups。容器化软件已经变得非常的庞大,如果没有它自己关注的分离层,几乎是不可能实现的。在这个持续努力的过程中,我尝试引导自己从最底层到最高层尽可能多的实践(代码、安装、配置、集成等等),当然还有尽可能多的获得乐趣。本篇内容会随着时间的推移而改变,并反映出我对这一主题的理解。
容器运行时
我想从最底层的非内核原语说起——容器运行时。在容器服务里,运行时这个词是有歧义的。每个项目、公司或社区对术语容器运行时都有自己的、通常是基于上下文的特定理解。大多数情况下,运行时的特征是由一组职责定义的,从最基本的职责(创建namespace、启动*init*进程)到复杂的容器管理,包括(但不限于)镜像操作。这篇文章对运行时有一个很好的概述。
本节专门讨论低阶容器运行时。在OCI运行时规范中,组成Open Container Initiative的一些重要参与者对底层运行时进行了标准化。长话短说,低阶容器运行时是一个软件,作为一个包含rootfs和配置的目录输入,来描述容器参数(如资源限制、挂载点、流程开始等),并作为运行时启动一个独立进程,即容器。
到2019年,使用最广泛的容器运行时是runc。这个项目最初是Docker的一部分(因此它是用Go编写的),但最终被提取并转换为一个独立的CLI工具。很难高估这个组件的重要性——基本上runc是OCI运行时规范的一个参考实现。在我们的实践中将大量使用runc,下面是一篇介绍性文章(编者注:页面暂无内容)。
一个更值得注意的OCI运行时实现是crun。它用C语言编写,既可以作为可执行文件,也可以作为库使用。
容器管理
在命令行中可以使用runc启动任意数量的容器。但是如果我们需要让这个过程自动化呢?假设我们需要启动数十个容器来跟踪它们的状态,其中一些在失败时需要重启,在终止时需要释放资源,必须从注册中心提取镜像,需要配置容器间网络等等。这是一个稍微高级的任务,并且是“容器管理器”的职责。老实说,我不知道这个词是否常用,但我发现用它来描述很合适。我将以下项目归类为“容器管理器”:containerd, cri-o, dockerd 和 podman.
containerd
与runc一样,我们可以再次看到Docker的遗产——containerd曾经是Docker项目的一部分,现在它是一个独立的软件,自称为容器运行时。但显然,它与运行时runc不是同一种类型的运行时。不仅它们的职责不同,其组织形式也不同。runc只是一个命令行工具,containerd是一个长活的守护进程。一个runc实例不能比底层容器进程活得更久。通常,它在create
调用时启动,然后在start
时从容器的rootfs中exec
特定文件。另一方面,containerd可以运行的比成千上万个容器更长久。它更像是一个服务器,侦听传入的请求来启动、停止或报告容器的状态。在幕后,containerd使用runc。它不仅仅是一个容器生命周期管理器,还负责镜像管理(从注册表中拉取和提交镜像,本地存储镜像等等),跨容器联网管理和其他一些功能。
cri-o
另一个容器管理器是cri-o。containerd是Docker重构后的结果,但cri-o却源于Kubernetes领域。在过去,Kubernetes使用Docker管理容器。然而,随着rkt的崛起,一些人增加了在Kubernetes中可互换容器运行时的支持,允许Docker和/或rkt完成容器管理。这种变化导致Kubernetes中有大量的条件判断,没有人喜欢代码中有太多的“if”。因此,容器运行时接口(CRI)被引入到Kubernetes中,这使得任何兼容CRI的高阶运行时(例如容器管理器)都可以在Kubernetes中使用,而无需任何代码的更改。cri-o是RedHat实现的兼容CRI的运行时,与containerd一样,它也是一个守护进程,通过开放一个gRPC服务接口来创建、启动、停止(以及许多其他操作)容器。在底层,cri-o可以使用任何符合OCI标准的低阶运行时和容器工作,默认的运行时仍然是runc。cri-o的主要目标是作为Kubernetes的容器运行时,版本控制也与K8S一致,项目的范围界定的很好,其代码库也比期望的更小(截止2019年7月大约是20个CLOC,近似于containerd的5倍)。
cri-o 架构 (图像来自 cri-o.io)
规范的好处是所有符合规范的技术都可以互换使用。一旦CRI被引入,一个containerd的插件就可以在它的功能之上实现CRI的gRPC服务。这个想法是可行的,所以后来containerd获得了原生的CRI支持。因此,Kubernetes可以同时使用cri-o和containerd作为运行时。
dockerd
还有一个守护进程是dockerd,它是个多面手。一方面,它为Docker命令行 开放了一个API,为我们提供了所有这些常用的Docker命令(Docker pull
、Docker push
、Docker run
、Docker stats
等)。但是我们已经知道这部分功能被提取到了containerd中,所以在底层dockerd依赖于containerd就不足为奇了。但这基本上意味着dockerd只是一个前端适配器,它将containerd的API转换为广泛使用的docker引擎的API。
dockerd(编者注:原文链接是moby项目)也提供了compose
和swarm
功能,试图解决容器编配问题,包括容器的多机器集群。正如我们在Kubernetes上看到的,这个问题相当难解决。对于一个单dockerd守护进程来说,同时承担两大职责并不好。
dockerd 是 containerd 前端的一部分(图片来源于Docker Blog)
podman
守护进程中一个有趣的例外是podman。这是另一个Red Hat项目,目的是提供一个名为libpod
的库(而不是守护进程)来管理镜像、容器生命周期和pod(容器组)。podman
是一个构建在这个库之上的命令行管理工具。作为一个低阶的容器运行时,这个项目也使用runc。从代码角度来看,podman和cri-o (都是Red Hat项目)有很多共同点。例如,它们都在内部使用storage和image库。另一项正在进行的工作是在cri-o中直接使用libpod而不是runc。podman的另一个有趣的特性是用drop-in替换一些(最流行的?)日常工作流程中的docker
命令。该项目声称兼容(在一定程度上)docker CLI API。
既然我们已经有了dockerd、containerd和cri-o,为什么还要开发这样的项目呢?守护进程作为容器管理器的问题是,它们大多数时候必须使用root权限运行。尽管由于守护进程的整体性,系统中没有root权限也可以完成其90%的功能,但是剩下的10%需要以root启动守护进程。使用podman,最终有可能使Linux用户的namespace拥有无根(rootless)容器。这可能是一个大问题,特别是在广泛的CI或多租户环境中,因为即使是没有权限的Docker容器实际上也只是系统上的一个没有root访问权限的内核错误。
conman
这是我正在做的项目,目的是实现一个微型容器管理器。它主要用于教学目的,但是最终的目标是使它兼容CRI,并作为容器运行时运行在Kubernetes集群上。
运行时垫片(runtime shims)
如果你自己尝试一下就会很快发现,以编程方式从容器管理器使用runc是一项相当棘手的任务。以下是需要解决的困难清单。
在容器管理器重启时保证容器存活
容器可以长时间运行,而容器管理器可能由于崩溃或更新(或无法预见的原因)而需要重新启动。这意味着我们需要使每个容器实例独立于启动它的容器管理器进程。幸运的是,runc提供了一种方式通过命令runc run --detach
从正在运行的容器中分离。我们也可能需要能够附加到一个正在运行的容器上。为此,runc可以运行一个由Linux伪终端控制的容器。通过Unix套接字传递PTY主文件描述符,可以将PTY的master端回传到启动进程(请参阅runc create --console-socket
选项)。这意味着,只要底层容器实例存在,我们就可以保持启动进程的活动状态,以保存PTY文件描述符。如果我们决定在容器管理器进程中存储主PTY文件描述符,则重新启动该管理器将导致文件描述符的丢失,从而失去重新附着到正在运行的容器的能力。这意味着我们需要一个专用的(轻量级的)包装进程来负责转化和保持运行容器的附属状态。
同步容器管理器和包装的runc实例
由于我们通过添加包装器进程对runc进行了转化,所以需要一个side-channel(也可能是Unix套接字)来将容器的启动传回容器管理器。
持续追踪容器退出码(exit code)
分离容器会导致缺少容器状态更新。我们需要有一种方式将状态反馈给管理器。出于这个目的,文件系统听起来也是一个不错的选择。我们可以让包装器进程等待子runc进程终止,然后将它的退出码写到磁盘上预定义的位置。
为了解决所有这些问题(可能还有其他一些问题),通常使用所谓的runtime shims。shim是一个轻量级守护进程,控制一个正在运行的容器。shims的实现有conmon和containerd的 runtime shim。我花了一些时间实现了自己的shim作为conman项目的一部分,可以在文章“实现容器运行时shim”中找到(编者注:此文章还未撰写)。
容器网络接口 (CNI)
我们有多个责任重叠的容器运行时(或管理器),很明显需要提取网络相关的代码到一个专门的项目来复用它,或者每个运行时都应该有自己的方式来配置NIC设备,IP路由,防火墙和网络的其他方面。例如,cri-o和containerd都必须创建Linux网络名称空间,并设置Linux bridge
和veth
设备来为Kubernetes pods创建沙箱。为了解决这个问题,引入了容器网络接口项目。
CNI项目提供了一个定义CNI插件的容器网络接口规范。插件是一个可执行的sic,容器运行时(或管理器)会调用它来安装(或释放)网络资源。插件可以用来创建网络接口,管理IP地址分配,或者对系统进行一些自定义配置。CNI项目与语言无关,由于插件被定义为可执行的,它可以用于任何编程语言实现的运行时管理系统。CNI项目还为作为一个名为plugins的用于存放最流行的用例的独立的代码库提供了一组参考插件实现。例如bridge、loopback、flannel等。
一些第三方项目将其网络相关的功能实现为CNI插件。一些最著名的项目如Project Calico和Weave。
编排
容器的编排是一个非常大的主题。实际上,Kubernetes代码中最大的部分就是解决编排问题,而不是容器化问题。因此,编排应该有自己单独的文章(或几篇)而不在本文描述。希望他们能很快跟进。
值得注意的项目
buildah
Buildah是一个和OCI容器镜像一起使用的命令行工具。它是RedHat发起的一组项目(podman、skopeo、buildah)的一部分,目的是重新设计Docker处理容器的方法(主要是将单体和基于守护进程的方法转换为更细粒度的方法)。
cni
CNI项目定义了一个容器网络接口插件规范以及一些Go工具。有关更深入的解释,请参见这篇文章相应的部分。
cni-plugins
一个最流行的CNI插件(如网桥、主机设备、环回、dhcp、防火墙等)的主库。有关更深入的解释,请参见文章的相应部分。
containerd
高级容器运行时(或容器管理器)作为Docker的一部分启动,并提取到了独立的项目中。有关更深入的解释,请参见相应的部分。
conmon
一个用C语言编写的小型OCI运行时shim,主要由crio使用。它提供了父进程(crio)与启动容器之间的同步、容器启动、退出码追踪、PTY转发和其他一些功能。有关更深入的解释,请参见相应的部分。
cri-o
专注于Kubernetes容器管理器,遵循Kubernetes容器运行时接口(CRI)规范。版本控制与k8s版本控制相同。有关更深入的解释,请参见相应的部分。
crun
另一个OCI运行时规范实现。它声称是”快速和低内存占用的OCI容器运行时,完全用C编写“。但最重要的是,它可以用作任何C/C++代码(或提供绑定其他语言)的库。它允许避免一些由它的守护进程特性引起的特定的“runc”缺陷。有关更多信息,请参见Runtime Shims一节。
image
一个被低估的(主观评价)Go工具库,为*crio*、*podman*和*skopeo*等知名项目提供了支持。通过它的名字就很容易猜到——其目的是用各种方式来处理容器镜像和镜像注册表。
lxc
一个由C编写的可替换的低级容器运行时。
lxd
一个由Go编写的高级容器运行时(或容器管理器)。底层使用lxc作为低级运行时。
moby
高级容器运行时(或容器管理器),以前称为docker/docker
。提供一个著名的基于*containerd*功能的Docker引擎API。有关更深入的解释,请参见相应的部分。
OCI distribution spec
一个容器镜像发布规范(开发中)。
OCI image spec
一个容器镜像规范。
OCI runtime spec
一个低阶容器运行时规范。
podman
一个无守护进程的Docker替代品。Docker重新设计的frontman项目。更多信息参见 RedHat developers blog。
rkt
另一个容器管理系统。它提供了一个低阶运行时和一个高阶管理接口。它宣称是Pod原生的。向Kubernetes添加*rkt*支持的想法催生了CRI规范。该项目由CoreOS团队于5年前启动,在被RedHat收购后却停滞不前。截止到2019年8月,该项目的最后一次提交已经是大约两个月前了。更新:8月16日,CNCF宣布技术监督委员会(TOC)投票决定将rkt项目存档。
runc
一个低阶容器运行时和OCI运行时规范的参考实现。一开始作为Docker的一部分现在提取到了一个独立的项目中,普及度很高。有关更深入的解释,请参见相应的部分。
skopeo
Skopeo是一个命令行工具集,对容器镜像和镜像库执行各种操作。这是RedHat重新设计Docker(参见podman和buildah)工作的一部分,它将自己的职责抽取为专用的和独立的工具。
storage
一个被低估的Go类库,为crio、podman和skopeo等知名项目提供了支持。其目的是为存储文件系统层、容器镜像和容器(磁盘上的)提供方法。它还管理bundle的加载。