虽然容器领域的创业随着CoreOS、Docker的卖身,而逐渐归于平寂,但随着Rust语言的兴起,Firecracker、youki项目在容器领域泛起涟漪,对于云原生从业者来说,面试等场景中或多或少都会谈论到容器一些的历史与技术背景。
文|ianlewis
编辑|zouyee
技术深度|简单
需求简介
注: Container runtime统称为容器运行时
在Docker时代,关于容器运行时术语的定义是非常明确的,其为运行和管理容器的软件。但随着Docker涵盖的内容日益增多,以及多种容器编排工具的引入,该定义变得日益模糊了。
当你运行一个Docker容器时,一般的步骤是:
- 下载镜像
- 将镜像解压成一个bundle,即将各层文件平铺到一个单一的文件系统中。
- 运行容器
最初的规范规定,只有运行容器的部分定义为容器运行时,但一般用户,将上述三个步骤都默认为容器运行时所必须的能力,从而让容器运行时的定义成为一个令人困惑的话题。
当人们想到容器运行时,可能会想到一连串的相关概念;runc、runv、lxc、lmctfy、Docker(containerd)、rkt、cri-o。每一个都是基于不同的场景而实现的,均实现了不同的功能。如containerd和cri-o,实际均可使用runc来运行容器,但其实现了如镜像管理、容器API等功能,可以将这些看作是比runc具备的更高级的功能。
可以发现,容器运行时是相当复杂的。每个运行时都涵盖了从低级到高级的不同部分,如下图所示。
根据功能范围划分,将其分为低级容器运行时 (Low level Container Runtime)和高级容器运行时 (High level Container Runtime),其中只关注容器的本身运行通常称为低级容器运行时(Low level Container Runtime)。支持更多高级功能的运行时,如镜像管理及一些gRPC/Web APIs,通常被称为 高级容器运行时 (High level Container Runtime)。需要注意的是,低级运行时和高级运行时有本质区别,各自解决的问题也不同。
低级容器运行时
低级运行时的功能有限,通常执行运行容器的低级任务。大多数开发者日常工作中不会使用到。其一般指按照 OCI 规范、能够接收可运行roofs文件系统和配置文件并运行隔离进程的实现。这种运行时只负责将进程运行在相对隔离的资源空间里,不提供存储实现和网络实现。但是其他实现可以在系统中预设好相关资源,低级容器运行时可通过 config.json 声明加载对应资源。低级运行时的特点是底层、轻量,限制也很一目了然:
- 只认识 rootfs 和 config.json,没有其他镜像能力
- 不提供网络实现
- 不提供持久实现
- 无法跨平台等
低级运行时demo
通过以root方式使用Linux cgcreate、cgset、cgexec、chroot和unshare命令来实现简单容器。
首先,以busybox容器镜像作为基础,设置一个根文件系统。然后,创建一个临时目录,并将busybox解压到该目录中。
代码语言:javascript复制$ CID=$(docker create busybox)
$ ROOTFS=$(mktemp -d)
$ docker export $CID | tar -xf - -C $ROOTFS
紧接着创建uuid,并对内存和CPU设置限制。内存限制是以字节为单位设置的。在这里,将内存限制设置为100MB。
代码语言:javascript复制$ UUID=$(uuidgen)
$ cgcreate -g cpu,memory:$UUID
$ cgset -r memory.limit_in_bytes=100000000 $UUID
$ cgset -r cpu.shares=512 $UUID
例如,如果我们想把我们的容器限制在两个cpu core上,可以设定一秒钟的周期和两秒钟的配额(1s=1,000,000us),这将允许进程在一秒钟的时间内使用两个cpu core。
代码语言:javascript复制$ cgset -r cpu.cfs_period_us=1000000 $UUID
$ cgset -r cpu.cfs_quota_us=2000000 $UUID
接下来在容器中执行命令。
代码语言:javascript复制$ cgexec -g cpu,memory:$UUID
> unshare -uinpUrf --mount-proc
> sh -c "/bin/hostname $UUID && chroot $ROOTFS /bin/sh"
/ # echo "Hello from in a container"
Hello from in a container
/ # exit
最后,删除前面创建的cgroup和临时目录。
代码语言:javascript复制$ cgdelete -r -g cpu,memory:$UUID
$ rm -r $ROOTFS
低级运行时demo
为了更好地理解低级容器运行时,以下列举了几个低级运行时代表,各自实现了不同的功能。
runC
runC是目前使用最广泛的容器运行时。它最初是集成在Docker的内部,后来作为一个单独的工具,并以公共库的方式提取出来。
在2015 年,在 Linux 基金会的支持下有了 Open Container Initiative (OCI)(就是负责制定容器标准的组织),Docker 将自己容器格式和运行时 runC 捐给了 OCI。OCI 在此基础上制定了 2 个标准:运行时标准 Runtime Specification (runtime-spec) 和 镜像标准 Image Specification (image-spec) ,下面通过示例,简要介绍一下 runC。
首先创建根文件系统。这里我们将再次使用busybox。
代码语言:javascript复制$ mkdir rootfs
$ docker export $(docker create busybox) | tar -xf - -C rootfs
接下来创建一个config.json文件
代码语言:javascript复制$ runc spec
这个命令为容器创建一个模板config.json。
代码语言:javascript复制$ cat config.json
{
"ociVersion": "1.0.2",
"process": {
"terminal": true,
"user": {
"uid": 0,
"gid": 0
},
"args": [
"sh"
],
"env": [
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"TERM=xterm"
],
"cwd": "/",
"capabilities": {
...
默认情况下,它在根文件系统位于./rootfs的目录下运行命令。
代码语言:javascript复制$ sudo runc run mycontainerid
/ # echo "Hello from in a container"
Hello from in a container
rkt(已废弃)
rkt是一个同时具有低级和高级功能的运行时。例如,很像Docker,rkt允许你构建容器镜像,获取和管理本地存储库中的容器镜像,并通过一个命令运行它们。
runV
runv 是 OCF 基于管理程序的(Hypervisor-based )运行时 Runtime.runV 兼容 OCF。作为虚拟容器运行时引擎的runV已被淘汰。 runV团队与英特尔一起在OpenInfra Foundation中创建了Kata Containers项目
youki
Rust是时下最流行的编程语言,而容器开发也是一个时兴的应用领域。将两者结合使用Rust来做容器开发是一个值得尝鲜的体验。youki是使用Rust的实现OCI运行时规范,类似于runc。
高级容器运行时
高级运行时负责容器镜像的传输和管理,解压镜像,并传递给低级运行时来运行容器。通常情况下,高级运行时提供一个守护程序和一个API,远程应用程序可以使用它来运行容器并监控它们,它们位于低层运行时或其他高级运行时之上。
高层运行时也会提供一些看似很低级的功能。例如,管理网络命名空间,并允许容器加入另一个容器的网络命名空间。
这里有一个类似逻辑分层图,可以帮助理解这些组件是如何结合在一起工作的。
高级运行时代表
Docker
Docker是最早的开源容器运行时之一。它是由平台即服务的公司dotCloud开发的,用于在容器中运行用户的应用。
Docker是一个容器运行时,包含了构建、打包、共享和运行容器。Docker基于C/S架构实现,最初是由一个守护程序dockerd和docker客户端应用程序组成。守护程序提供了构建容器、管理镜像和运行容器的大部分逻辑,以及一些API。命令行客户端可以用来发送命令和从守护进程中获取信息。
它是第一个流行开来的运行时间,毫不过分的说,Docker对容器的推广做出了巨大的贡献。
Docker最初实现了高级和低级的运行时功能,但这些功能后来被分解成单独的项目,如runc和containerd,以前Docker的架构如下图所示,现有架构中,docker-containerd变成了containerd,docker-runc变成了runc。
dockerd提供了诸如构建镜像的功能,而dockerd使用containerd来提供诸如镜像管理和运行容器的功能。例如,Docker的构建步骤实际上只是一些逻辑,它解释Docker文件,使用containerd在容器中运行必要的命令,并将产生的容器文件系统保存为一个镜像。
Containerd
containerd是从Docker中分离出来的高级运行时。containerd实现了下载镜像、管理镜像和运行镜像中的容器。当需要运行一个容器时,它会将镜像解压到一个OCI运行时bundle中,并向runc发送init以运行它。
Containerd还提供了API,可以用来与它交互。containerd的命令行客户端是ctr和nerdctl。
可以通过ctr拉取一个容器镜像。
代码语言:javascript复制$ sudo ctr images pull docker.io/library/redis:latest
列出所有的镜像:
代码语言:javascript复制$ sudo ctr images list
运行容器:
代码语言:javascript复制$ sudo ctr container create docker.io/library/redis:latest redis
列出运行容器:
代码语言:javascript复制$ sudo ctr container list
停止容器:
代码语言:javascript复制$ sudo ctr container delete redis
这些命令类似于用户与Docker的互动方式。
rkt(已废弃)
rkt是一个同时具有低级和高级功能的运行时。例如,很像Docker,rkt允许你构建容器镜像,获取和管理本地存储库中的容器镜像,并通过一个命令运行它们。
Kubernetes CRI
CRI在Kubernetes 1.5中引入,作为kubelet和容器运行时之间的桥梁。社区希望Kubernetes集成的高级容器运行时实现CRI。该运行时处理镜像的管理,支持Kubernetes pods,并管理容器,因此根据高级运行时的定义,支持CRI的运行时必须是一个高级运行时。低级别的运行时并不具备上述功能。
为了进一步了解CRI,可以看看整个Kubernetes架构。kubelet代表工作节点,位于Kubernetes集群的每个节点上,kubelet负责管理其节点的工作负载。当需要运行工作负载时,kubelet通过CRI与运行时进行通信。由此可以看出,CRI只是一个抽象层,允许切换不同的容器运行时。
CRI规范
CRI定义了gRPC API,该规范定义在Kubernetes仓库中cri-api目录中。CRI定义了几个远程程序调用(RPC)和消息类型。这些RPC用于管理工作负载等内容,如 "拉取镜像"(ImageService.PullImage)、"创建pod"(RuntimeService.RunPodSandbox)、"创建容器"(RuntimeService.CreateContainer)、"启动容器"(RuntimeService.StartContainer)、"停止容器"(RuntimeService.StopContainer)等操作。
例如,通过CRI启动一个新的Pod(篇幅有限,进行了一些简化工作)。RunPodSandbox和CreateContainer RPCs在其响应中返回ID,在后续请求中使用。
代码语言:javascript复制ImageService.PullImage({image: "image1"})
ImageService.PullImage({image: "image2"})
podID = RuntimeService.RunPodSandbox({name: "mypod"})
id1 = RuntimeService.CreateContainer({
pod: podID,
name: "container1",
image: "image1",
})
id2 = RuntimeService.CreateContainer({
pod: podID,
name: "container2",
image: "image2",
})
RuntimeService.StartContainer({id: id1})
RuntimeService.StartContainer({id: id2})
可以直接使用crictl工具与CRI运行时交互,可以用它来调试和测试CRI的相关实现。
代码语言:javascript复制cat <<EOF | sudo tee /etc/crictl.yaml
runtime-endpoint: unix:///run/containerd/containerd.sock
EOF
或者通过命令行指定:
代码语言:javascript复制crictl --runtime-endpoint unix:///run/containerd/containerd.sock …
关于crictl的使用参见官网。
支持CRI的运行时
Containerd
containerd应该是目前最流行的CRI运行时。它以插件的方式实现CRI,默认是启用的。它默认在unix套接字上监听消息。
从1.2版本开始,它通过 runtime handler来支持多种低级运行时。运行时处理程序是通过CRI中的字段传递,根据该运行时处理程序,containerd运行shim的应用程序来启动容器。这可以用来运行 runc及其他的低级运行时的容器,如 gVisor、Kata Containers等。在Kubernetes API中通过RuntimeClass进行运行时配置。
下图是Containerd的发展史。
Docker
docker-shim是K8s社区第一个被开发的,作为kubelet和Docker之间的shim。随着Docker将其许多功能分解到containerd中,现在通过containerd支持CRI。当现代版本的Docker被安装时,containerd也一起被安装,CRI直接与containerd对话,随着docker-shim正式废弃,是时候考虑相关迁移的工作了,K8s在这方面做了大量的工作,具体可参看官方文档。
CRI-O
cri-o是一个轻量级的CRI运行时,它支持OCI,并提供镜像的管理、容器进程管理、监控日志及资源隔离等工作。
cri-o的通信地址默认是在/var/run/crio/crio.sock。
下图为CRI插件的演变史。
由于笔者时间、视野、认知有限,本文难免出现错误、疏漏等问题,期待各位读者朋友、业界专家指正交流。
参考文献
1.https://blog.mobyproject.org/where-are-containerds-graph-drivers-145fc9b7255
2.https://insujang.github.io/2019-10-31/container-runtime/
3.https://github.com/cri-o/cri-o