浅谈容器运行时

警告
本文最后更新于 2022-07-01,文中内容可能已过时。

我们都知道,容器技术其实在很久以前就已经出现,但只是在最近十年由于云计算的发展才逐渐进入大众的视野。对于容器运行时,传统意义上来说就是代表容器从拉取镜像到启动运行再到中止的整个生命周期,较类似于 Java 中的 Java hotspot 运行时。在本文中我会介绍容器运行时相关概念及组件原理,梳理下我们常听到的 OCI、runc、containerd 等名词之间的关系。

容器运行时顾名思义就是要掌控容器运行的整个生命周期,以 docker 为例,其作为一个整体的系统,主要提供的功能如下:

  • 制定容器镜像格式
  • 构建容器镜像 docker build
  • 管理容器镜像 docker images
  • 管理容器实例 docker ps
  • 运行容器 docker run
  • 实现容器镜像共享 docker pull/push

然而这些功能均可由小的组件单独实现,且没有相互依赖。而后 Docker 公司与 CoreOS 和 Google 共同创建了 OCI (Open Container Initial),并提供了两种规范:

filesystem bundle(文件系统束): 定义了一种将容器编码为文件系统束的格式,即以某种方式组织的一组文件,并包含所有符合要求的运行时对其执行所有标准操作的必要数据和元数据,即config.json与 根文件系统。

而后,Docker、Google等开源了用于运行容器的工具和库 runc,作为 OCI 的一种实现参考。在此之后,各种运行时工具和库也慢慢出现,例如 rkt、containerd、cri-o 等,然而这些工具所拥有的功能却不尽相同,有的只有运行容器(runc、lxc),而有的除此之外也可以对镜像进行管理(containerd、cri-o)。目前较为流行的说法是将容器运行时分成了 low-level 和 high-level 两类。

low-level: 指的是仅关注运行容器的容器运行时,调用操作系统,使用 namespace 和 cgroup 实现资源隔离和限制。 high-level: 指包含了更多上层功能,例如 grpc调用,镜像存储管理等。

不同工具的关系如下图:

工具关系

low-level runtime 关注如何与操作系统交互,创建并运行容器。目前常见的 low-level runtime有:

  • lmctfy – 是Google的一个项目,它是Borg使用的容器运行时
  • runc – 目前使用最广泛的容器运行时。它最初是作为Docker的一部分开发的,后来被提取出来作为一个单独的工具和库。其实现了 OCI 规范,包含config.json文件和容器的根文件系统。
  • rkt – CoreOS开发的Docker/runc的一个流行替代方案,提供了其他 low-level runtimes (如runc)所提供的所有特性。

容器在 linux 中使用 namesapce 实现资源隔离,使用 cgroup 实现资源限制,这部分在k8s基础–容器篇中对原理详细介绍,此处不做赘述。这里我们详细介绍下如何创建一个简单的 runtime。

我们以 busybox 镜像作为运行时的一个根文件系统,首先创建一个临时目录并将 busybox 中的所有文件解压缩到目录中

1
2
3
$ CID=$(docker create busybox)
$ ROOTFS=$(mktemp -d)
$ docker export $CID | tar -xf - -C $ROOTFS

限制我们需要创建 cgroup 对内存和cpu进行限制

1
2
3
4
5
6
$ UUID=$(uuidgen)
$ cgcreate -g cpu,memory:$UUID
# 内存限制设置为 100MB
$ cgset -r memory.limit_in_bytes=100000000 $UUID
# cpu 限制设置为 512m
$ cgset -r cpu.shares=512 $UUID

上面 cpu.shares 是相对于同时运行的其他进程的CPU。单独运行的容器可以使用整个CPU,但是如果其他容器正在运行,它们会按照比例分配cpu资源。除此以外,还可以对cpu内核数量的使用进行限制:

1
2
3
4
# 设置检查CPU使用情况的频率,单位是微秒
$ cgset -r cpu.cfs_period_us=1000000 $UUID
# 设置任务在一个时间段内在一个核心上运行的时间量,单位是微秒
$ cgset -r cpu.cfs_quota_us=2000000 $UUID

然后我们使用 unshare 命令在 cgroug 中执行命令,它可以实现 namespace 的隔离。

1
2
3
4
5
6
$ 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

最后,在执行结束后,通过下面的指令清理环境

1
2
$ cgdelete -r -g cpu,memory:$UUID
$ rm -r $ROOTFS

这一部分的实践,主要参考自文章Container Runtimes Part 2: Anatomy of a Low-Level Container Runtime

High-level runtimes相较于low-level runtimes位于堆栈的上层。low-level runtimes负责实际运行容器,而High-level runtimes负责传输和管理容器镜像,解压镜像,并传递给low-level runtimes来运行容器。目前主流的 high-level runtime 有:

  • docker
  • containerd
  • rkt

这里我们以 containerd 为例具体解析整个架构以及工作原理。

containerd 的架构图如图

架构图

其中,grpc 模块向上层提供服务接口,metrics 则提供监控数据(cgroup 相关数据),两者均向上层提供服务。containerd 包含一个守护进程,该进程通过本地 UNIX 套接字暴露 grpc 接口。

storage 部分负责镜像的存储、管理、拉取等 metadata 管理容器及镜像的元数据,通过bootio存储在磁盘上 task – 管理容器的逻辑结构,与 low-level 交互 event – 对容器操作的事件,上层通过订阅可以知道发生了什么事情 Runtimes – low-level runtime(对接 runc)

containerd 主要流程如下:

containerd流程

(图片来源于阿里云的公开课)

图中的 containerEngine 在 docker 中就是 docker-containerd 组件,创建容器记录的metadata,并请求 containerd 的 task 模块,task 模块会在 runtime 中创建 task 实例,分别会加入 task list, 监控 cgroup 等操作,每个 task 实例则调用 shim 去创建container。

containerd-shim 是 containerd 的一个组件,主要是用于剥离 containerd 守护进程与容器进程。containerd 通过 shim 调用 runc 的包函数来启动容器。当我们执行 pstree 命令时,可以看到如下的进程关系:

进程关系

引入shim,允许runc 在创建和运行容器之后退出,并将 shim 作为容器的父进程,而不是 containerd 作为父进程,这样做的目的是当 containerd 进程挂掉,由于 shim 还正常运行,因此可以保证容器不受影响。此外,shim 也可以收集和报告容器的退出状态,不需要 containerd 来 wait 容器进程。

当我们有需求去替换 runc 运行时工具库时,例如替换为安全容器 kata container 或 Google 研发的 gViser,则需要增加对应的shim(kata-shim等),以上两者均有自己实现的 shim。

相关内容