Docker containerd runc

Docker、containerd、runc 关联和区别

简介

关于 Docker 是什么大家可能并不陌生,但是对于 Docker 到底怎么运行的,内部包含的组件我们可能并不是特别清楚,这里我们要先引入一个概念-容器运行时

(容器运行时 == Docker)?

传统上,计算机程序员可能将“运行时”称为程序运行时的生命周期阶段,或者是支持其执行的语言的特定实现。Java HotSpot运行时就是一个例子。后一个含义最接近“容器运行时”。容器运行时负责运行容器的所有部分,而容器实际上并未在运行程序本身。正如我们将在本系列文章中看到的那样,运行时实现了各种级别的功能,但是实际上运行容器是调用容器运行时所需的全部。

为什么容器运行时如此混乱

Docker 2013 年发布后,很大程度上解决了开发人员端到端运行容器的许多问题,它的主要功能包含如下:

  • 容器镜像格式
  • 构建容器镜像的方法(Dockerfile、docker 构建)
  • 管理容器镜像的方法(docker image、docker rmi 等)
  • 管理容器实例的方法(docker ps、docker rm 等)
  • 共享容器镜像的方法(docker pull、docker push 等)
  • 一种运行容器的方法(docker run)

当时的 Docker 还是一个整体的系统,但是上面提到的这些功能也没有相互以来,每一个都可以用更小、更集中的工具来实现,这些工具可以一起使用,每种工具都可以通过一种通用格式(容器标准协同工作)。

因此 Docker、Google、CoreOS 和其他供应商创建了 Open Container Initiative(OCI),然后开源了一个真正用于运行容器的工具和库-runc,并将其捐赠给OCI,作为OCI运行时规范的参考实现

Docker 对 OCI 贡献的是一种 “运行” 容器的标准方法,仅此而已,不包括镜像规范或 registry pull/push 规范,运行 Docker 容器的时候真正的流程如下:

  1. 拉取镜像
  2. 解压镜像包,展开镜像层数为单个文件系统
  3. 从解压“包”中运行容器

Docker 标准化的是 《从解压包中运行容器》 这条,之前可能大家都以为容器运行时就支持 Docker 支持的所有的功能,最后 Docker 官方澄清了原始规范(original spec)只说明了组成 runtime 的 “运行容器” 部门,今天这种认为容器运行时 = Docker 的想法仍然存在,就导致了容器运行时成为一个令人困惑的话题。

Low-Level 和 High-Level 容器运行时

如果你了解过容器运行时的话,你可能会想到很多,比如 runc、lxc、lmctfy、Docker(containerd)、rkt、cri-o,这些都是针对不同情况构建的,并且实现了不同的功能,有些容器运行时比如 containerd 和 cri-o 就是使用的 runc 来实现真正的容器运行,runc 其实主要是作为底层实现,上层镜像管理(image 传输、image 管理、image 解压)和 API 可以视为比较高级的功能。

经过上面的分析我们可以了解到容器运行时相当的复杂,每个运行时涵盖了从低级到高级的不同部分,下面这个图可以比较直观的展示这一点

runtimes

因此出于实际母的我们把通常只关注正在运行的容器的实际容器运行时通常称为“低级容器运行时”。支持更多高级功能(镜像管理、gRPC、WebAPI) 的容器运行时通常称为 “高级容器运行时”,通常仅称为“容器运行时”。需要注意的是,低级运行时和高级运行时是解决不同问题的根本不同的事物。

容器是通过 Linux NamespaceCgroups 来实现的。

  • Linux 命名空间可以为每个容器虚拟化系统资源,比如文件系统和网格,让你看不到其他的东西,让你以为你就是真正的宿主机空间
  • Cgroups 可以限制每个容器的使用资源,比如 CPU 和内存

在最低 Level 中,容器运行时为这些容器设置命名空间和 cgroups,然后在这些名称空间和 cgroups 中运行命令,Low-Level 容器运行时支持使用这些操作系统功能。

Low-Level

通常使用容器的开发人员需要的不仅是 low-level 容器运行时提供的功能,他们还需要围绕 上层镜像管理(image 传输、image 管理、image 解压)和 API。这些特性是由 high-level 容器运行时提供的,low-level 容器运行时通常是那些实现 high-level 容器运行时的开发人员用到的。

可能 low-level 容器运行时的开发人员会觉得 containerd 和 cri-o 这样的 high-level 容器运行时不算事容器运行时,因为他们的底层使用的还是 runc 这样的 low-elevel 容器运行时,但是对用户来说他们是提供运行容器能力的单个组件,实现上可以相互替换,因此从这个角度来看称其为容器运行时仍然有意义。

虽然 containerd 和 cri-o 都是用了 runc,但是他们还是两个具有很多不同特性的项目。

Low-Level Container Runtime

Low-Level 容器运行时通常不会被开发人员接触到,作为实现底层运行容器的它通常是作为作为底层的工具和库来使用,让开发人员来实现 High-Level 容器运行时和工具。

正如上文所说,容器是用 Linux 的 namespaces 和 cgroups 实现的。namespaces 可以虚拟化系统资源,比如容器的文件系统和网络。cgroups 提供了一种方法来限制容器使用的资源量,例如 CPU 和 内容。Low-Level 容器运行时核心就是为容器设置这些 namespaces 和 cgroups,然后再 namespaces 和 cgroups 中运行命令,大多数容器运行时都实现了更多的功能,但是这些底层的东西是必不可少的。

可以看看 Liz Rice 的精彩演讲 “Go 中如何从头开始构建容器-Youtube视频,Youtube中文弹幕真香”,她的这个视频介绍了如何实现低级的容器运行时,Liz 讲到了很多的步骤步骤,但是你可以想到最简单的容器运行时可能还是会执行如下的几个步骤:

  • 创建 cgroup
  • 在 crgroup 中运行命令
  • Unshare 取消命名空间共享 移至自己的命名空间
  • 命令完成后清理 cgroup(如果正在运行的进程没有引用命名空间,则将自动删除它们)

视频中的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
package main

import (
"fmt"
"os"
"os/exec"
"syscall"
)

// docker run <container> cmd args
// go run main.go run cmd args
func main() {
switch os.Args[1] {
case "run":
parent()
case "child":
child()
default:
panic("wat should I do")
}
}

func parent() {
cmd := exec.Command("/proc/self/exe", append([]string{"child"}, os.Args[2:]...)...)
cmd.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS,
}
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr

if err := cmd.Run(); err != nil {
fmt.Println("ERROR", err)
os.Exit(1)
}
}

func child() {
must(syscall.Mount("rootfs", "rootfs", "", syscall.MS_BIND, ""))
must(os.MkdirAll("rootfs/oldrootfs", 0700))
must(syscall.PivotRoot("rootfs", "rootfs/oldrootfs"))
must(os.Chdir("/"))

cmd := exec.Command(os.Args[2], os.Args[3:]...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr

if err := cmd.Run(); err != nil {
fmt.Println("ERROR", err)
os.Exit(1)
}
}

func must(err error) {
if err != nil {
panic(err)
}
}

Namespaces:

  • Unix 分时系统,不同主机名
  • 进程 ID
  • 文件系统(挂载点)
  • 用户
  • IPC
  • 网络

Cgroup:

  • CPU
  • 内存
  • 磁盘 I/O
  • 网络
  • Device permissions

Linux名称空间包含了大多数现代容器实现背后的一些基本技术。
在较高的级别上,它们允许隔离独立进程之间的全局系统资源。
例如,PID名称空间隔离了进程ID号空间。
这意味着在同一主机上运行的两个进程可以具有相同的PID!
这种隔离级别在容器世界中显然很有用。
如果没有命名空间,则在容器A中运行的进程可以例如在容器B中卸载重要的文件系统,或者更改容器C的主机名,或者从容器D中删除网络接口。通过命名这些资源的空间,容器A中的进程可以
甚至不知道容器B,C和D中的进程存在。

由此可见,如果您看不到某些东西,您就不会干涉。
而这正是名称空间所提供的-一种限制进程可以看到的内容的方法,以使其看起来像是主机上唯一运行的进程。
请注意,名称空间并不限制对物理资源(例如CPU,内存和磁盘)的访问。
这种访问是通过称为“ cgroups”的内核功能来计量和限制的。

使用unshare命令,您可以运行带有某些从其父级“未共享”命名空间的程序。从本质上讲,这意味着取消共享将运行在新的名称空间集中传递的任何程序。

构建一个简单的运行时

让我们逐步使用一个简单的临时容器运行时来启动一个容器,我们可以使用标准 Linux 命令像 cgcreate、cgset、cgexec、chroot、unshare 等。你需要以 root 身份运行以下大多数的命令。

首先我们为容器设置一个根文件系统,我们将 busybox 容器作为基础,在这里我们创建一个临时目录并将 busybox 提取到其中,但是这些命令大多都需要以 root 来运行。

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

现在我们来使用 cgroup 设置对内存和 CPU 的限制,设置内存限制以字节为单位设置,这里我们将内存设置为 100MB。(Centos 的同学请先安装一下 libcgroup-tools)

1
2
3
4
UUID=$(uuidgen)
cgcreate -g cpu,memory:$UUID
cgset -r memory.limit_in_bytes=100000000 $UUID
cgset -r cpu.shares=512 $UUID

可以通过如下的方式设置 CPU 使用率,我们使用 CPU shares 来设置 CPU 限制,shares 是相对同时其他运行的进程 CPU 时间。自己运行的容器可以使用整个 CPU,但是如果其他容器正在运行,则他们可以使用与其 CPU 份额成比例的 CPU。

基于 CPU 核心数的 CPU 限制会更加的复杂一些,它们可以使你可以对容器的可用 CPU 核心数量设置硬性限制,要限制 CPU 核心需要在 cgroup 上设置两个选项,cfs_period_us 和 cfs_quota_us,cfs_period_us 指定检查 CPU 使用率的频率,而 cfs_quota_us 指定一个任务在一个周期内在一个内核上运行的时间,两者都是以毫秒为单位的。

比如,我们想限制容器使用两个核心,我们可以使用上面的两个参数来设置一秒的时间使用两秒的配额,一秒钟是 1000000 毫秒,这就能让我们在我们的进程内在一秒内使用两个内核。

1
2
cgset -r cpu.cfs_period_us=1000000 $UUID
cgset -r cpu.cfs_quota_us=2000000 $UUID

接下来我们可以在容器中使用命令,这将在我们使用 cgroup 创建的空间中执行命令,取消指定的命名空间共享,设置主机名,并且使用 chroot 切换至我们的文件系统

1
2
3
cgexec -g cpu,memory:$UUID \ 
> unshare -uinpUrf --mount-proc \
> sh -c "/bin/hostname $UUID && chroot $ROOTFS /bin/sh"

执行完成之后我们可以通过删除 cgroup 创建的 cgroup 和临时目录进行清理

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

为了进一步演示这是如何工作的,这有一个 bash 编写了一个名为 execc 的简单运行时。支持 mount, user, pid, ipc, uts, and network namespaces;设置内存的限制;按核数设置 CPU 限制;挂载 proc 文件系统;并在其自己的根文件系统中运行容器。

Low-Level 容器运行时示例

Imctfy

Imctfy 没有被广泛的使用,但是他是 Kubernetes 的前身 Borg 使用的容器运行时,它支持容器名称使用 cgroup 层次结构的容器层次结构,例如一个名为 busybox 的 root 容器可以创建名为 busybox/sub1 或 busybox/sub2 的子容器,名称构成一种路径结构。因此每个子容器可以有自己的 cgroup,受父容器 cgroup 的限制,这是受 Borg 启发的,它使 Imctfy 中的容器能够在服务器上预先分配一组资源下运行子任务容器,从而实现了比运行时本身所提供的更为严格的 SLO(服务目标等级)。

虽然 Imctfy 有一些有趣的特性和想法,但是其他的运行时可用性更好,因此谷歌决定让社区将重点放在 Docker 的 libcontainer 上,而不是 Imctfy。

runc

runc 是目前使用最广泛的容器运行时,最初是作为 Docker 的一部分开发的,后来被提取出来作为一个单独的工具和库。

runc 实现了 OCI runtime 规范,这意味着他将运行来自特定的 “OCI bundle” 格式的容器,包含了 config.json 文件和容器的根文件系统,可以阅读 Github 上 OCI runtime spec 了解更多。可以通过阅读 runc Github Project 了解任何安装 runc。

首先我们创建 root filesystem,再次使用 busybox。

1
2
mkdir rootfs
docker export $(docker create busybox) | tar -xf - -C rootfs

创建一个 config.json 文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
runc spec

# 输出 config.json 可以看到
cat config.json
{
"ociVersion": "1.0.0",
"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 中运行 sh 命令,这正是我们想要的设置,所以我们可以继续运行容器。

1
2
3
sudo runc run mycontainerid

echo "Hello from in a container"

rkt

rkt 是 CoreOS 开发的一个 Docker/runc 的替代方案,rkt 不好说是 low-level 容器运行时还是 high-level 容器运行时,因为它提供了其他 low-level 容器运行时比如 runc 的全部特性,但是也提供了 high-level 容器运行时的典型特性。

rkt 最初使用 appc 标准来开发,appc 标准是想作为替代 Docker 的标准来开发的,但是并没有成功,所以 rkt 后会使用 OCI 作为标准容器格式。

参考链接