容器技术原理(一):从根本上认识容器镜像

从 OCI 规范说起

OCI(Open Container Initiative)规范是事实上的容器标准,已经被大部分容器实现以及容器编排系统所采用,包括 Docker 和 Kubernetes。它的出现是一段关于开源商业化的有趣历史:它由 Dokcer 公司作为领头者在 2015 年推出,但如今 Docker 公司在容器行业中已经成了打工仔。

从 OCI 规范开始了解容器镜像,可以让我们对容器技术建立更全面清晰的认知,而不是囿于实现细节。OCI 规范分为 Image specRuntime spec 两部分,它们分别覆盖了容器生命周期的不同阶段:

镜像规范

镜像规范定义了如何创建一个符合 OCI 规范的镜像,它规定了镜像的构建系统需要输出的内容和格式,输出的容器镜像可以被解包成一个 runtime bundleruntime bundle 是由特定文件和目录结构组成的一个文件夹,从中可以根据运行时标准运行容器。

镜像里面都有什么

规范要求镜像内容必须包括以下 3 部分:

  • Image Manifest:提供了镜像的配置和文件系统层定位信息,可以看作是镜像的目录,文件格式为 json
  • Image Layer Filesystem Changeset:序列化之后的文件系统和文件系统变更,它们可按顺序一层层应用为一个容器的 rootfs,因此通常也被称为一个 layer(与下文提到的镜像层同义),文件格式可以是 targzip 等存档或压缩格式。
  • Image Configuration:包含了镜像在运行时所使用的执行参数以及有序的 rootfs 变更信息,文件类型为 json

rootfs (root file system)即 / 根挂载点所挂载的文件系统,是一个操作系统所包含的文件、配置和目录,但并不包括操作系统内核,同一台机器上的所有容器都共享宿主机操作系统的内核。

接下来我们以 Dockernginx 为例探索一个镜像的实际内容。拉取一个最新版本的 nginx 镜像将其 save 为 tar 包后解压:

1
2
3
4
$ docker pull nginx
$ docker save nginx -o nginx-img.tar
$ mkdir nginx-img
$ tar -xf nginx-img.tar --directory=nginx-img

得到 nginx-img 目录中的内容如下:

 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
nginx-img
├── 013a6edf61f54428da349193e7a2077a714697991d802a1c5298b07dbe0519c9
│   ├── json
│   ├── layer.tar
│   └── VERSION
├── 2bf70c858e6c8243c4713064cf43dea840866afefe52089a3b339f06576b930e
│   ├── json
│   ├── layer.tar
│   └── VERSION
├── 490a3e67a61048564048a15d501b8e075d951d0dbba8098d5788bb8453f2371f
│   ├── json
│   ├── layer.tar
│   └── VERSION
├── 4cdc5dd7eaadff5080649e8d0014f2f8d36d4ddf2eff2fdf577dd13da85c5d2f.json
├── 761c908ee54e7ccd769e815f38e3040f7b3ff51f1c04f55aac12b9ea3d544cfe
│   ├── json
│   ├── layer.tar
│   └── VERSION
├── 96bfd5bf4ab4c2513fb43534d51e816c4876620767858377d14dcc5a7de5f1fd
│   ├── json
│   ├── layer.tar
│   └── VERSION
├── d18832ef411b346c36b7ba42a6c2e3f77097026fb80651c2d870f19c6fd9ccef
│   ├── json
│   ├── layer.tar
│   └── VERSION
├── manifest.json
└── repositories

首先查看 manifest.json 文件的内容,即该镜像的 Image Manifest:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
$ python -m json.tool manifest.json

[
    {
        "Config": "4cdc5dd7eaadff5080649e8d0014f2f8d36d4ddf2eff2fdf577dd13da85c5d2f.json",
        "Layers": [
            "490a3e67a61048564048a15d501b8e075d951d0dbba8098d5788bb8453f2371f/layer.tar",
            "2bf70c858e6c8243c4713064cf43dea840866afefe52089a3b339f06576b930e/layer.tar",
            "013a6edf61f54428da349193e7a2077a714697991d802a1c5298b07dbe0519c9/layer.tar",
            "761c908ee54e7ccd769e815f38e3040f7b3ff51f1c04f55aac12b9ea3d544cfe/layer.tar",
            "d18832ef411b346c36b7ba42a6c2e3f77097026fb80651c2d870f19c6fd9ccef/layer.tar",
            "96bfd5bf4ab4c2513fb43534d51e816c4876620767858377d14dcc5a7de5f1fd/layer.tar"
        ],
        "RepoTags": [
            "nginx:latest"
        ]
    }
]

其中记载了 ConfigLayers 的文件定位信息,也就是标准中所规定的 Image Layer Filesystem Changeset 和 Image Configuration。

Config 存放在另一个 json 文件中,内容较多我们不做展示,具体包含了以下信息:

  • 镜像的配置,在镜像解压成 runtime bundle 后将写入运行时配置文件。
  • 镜像的 layers 之间的 Diff ID。
  • 镜像的构建历史等元信息。

Layers 列表中的 tar 包共同组成了生成容器的 rootfs,容器的镜像是分层构建的, Layers 中的元素顺序还代表了镜像层叠加的顺序,所有 layer 组成一个由下往上叠加的栈式的结构。首先看一下基础层即第一条记录中的内容:

1
2
$ mkdir base
$ tar -xf 490a3e67a61048564048a15d501b8e075d951d0dbba8098d5788bb8453f2371f/layer.tar --directory=base

base 目录中解压得到的文件内容如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
drwxr-xr-x  2 root root 4096 6月  21 08:00 bin
drwxr-xr-x  2 root root    6 6月  13 18:30 boot
drwxr-xr-x  2 root root    6 6月  21 08:00 dev
drwxr-xr-x 28 root root 4096 6月  21 08:00 etc
drwxr-xr-x  2 root root    6 6月  13 18:30 home
drwxr-xr-x  7 root root   85 6月  21 08:00 lib
drwxr-xr-x  2 root root   34 6月  21 08:00 lib64
drwxr-xr-x  2 root root    6 6月  21 08:00 media
drwxr-xr-x  2 root root    6 6月  21 08:00 mnt
drwxr-xr-x  2 root root    6 6月  21 08:00 opt
drwxr-xr-x  2 root root    6 6月  13 18:30 proc
drwx------  2 root root   37 6月  21 08:00 root
drwxr-xr-x  3 root root   30 6月  21 08:00 run
drwxr-xr-x  2 root root 4096 6月  21 08:00 sbin
drwxr-xr-x  2 root root    6 6月  21 08:00 srv
drwxr-xr-x  2 root root    6 6月  13 18:30 sys
drwxrwxrwt  2 root root    6 6月  21 08:00 tmp
drwxr-xr-x 10 root root  105 6月  21 08:00 usr
drwxr-xr-x 11 root root  139 6月  21 08:00 var

这已经是一个完整的 rootfs,再观察最上面一层 layer 所得到的文件内容:

1
2
3
96bfd5bf4ab4c2513fb43534d51e816c4876620767858377d14dcc5a7de5f1fd/
└── docker-entrypoint.d
    └── 30-tune-worker-processes.sh

其中只有一个 shell 脚本文件,这说明镜像的构建过程是增量的,每一层都只包含了和更低一层相比所变更的文件内容,这也是容器镜像得以保持较小体积的原因。

如何在镜像层中删除一个文件

Layers 中的每一层都是文件系统的变更集(ChangeSet),变更集包含新增、修改和删除三种变更,新增或修改(替换)文件的情况较好处理,但如何在应用变更集时删除一个文件呢,答案是用 Whiteouts 表示要删除的文件或文件夹。

Whiteouts 文件是一个具有特殊文件名的空文件,文件名中通过在要删除的路径基本名称添加前缀 .wh. 标志一个(更低一层中的)路径应该被删除。假如在某个 layer 有以下文件:

1
2
3
4
./etc/my-app.d/
./etc/my-app.d/default.cfg
./bin/my-app-tools
./etc/my-app-config

如果在应用的更高层 layer 中含有 ./etc/.wh.my-app-config ,应用该层变更时原有的 ./etc/my-app-config 路径将被删除。

如何将多个镜像层合并成一个文件系统

规范中对于如何将多个镜像层应用成一个文件系统只有原理性的描述,假如我们要在 layer A 的基础上应用 Layer B

  • 首先将 Layer A 中的文件系统目录以保留文件属性的方式复制到另一个快照目录 A.snapshot
  • 然后在快照目录中执行 Layer B 所包含的文件变更,所有的更改不会影响原有的变更集。

在实践中会采用联合文件系统等更为高效的实现。

什么是联合文件系统

联合文件系统(Union File System)也叫 UnionFS,主要的功能是将多个不同位置的目录联合挂载(union mount)到同一个目录下。

下面以 Ubuntu 发行版以及 unionfs-fuse 实现为例演示联合挂载的效果:

  1. 首先使用包管理器安装 unionfs-fuse,这是 UnionFS 的一个实现:

    1
    
    $ apt install unionfs-fuse
    
  2. 然后创建如下目录结构:

    1
    2
    3
    4
    5
    6
    
    A
    ├── a
    └── x
    B
    ├── b
    └── x
    
  3. 创建目录 C 并将 A、B 目录联合挂载到 C 下:

    1
    
    $ unionfs ./B:./A ./C
    
  4. 挂载后 C 目录内容如下:

    1
    2
    3
    4
    
    C
    ├── a
    ├── b
    └── x
    
  5. 如果我们分别编辑 A、B 目录中的 x 文件,会发现访问目录 C 中 x 文件得到的是 B/x 的内容(因为 B 在挂载时位于更上层)。

Docker 中的 OverlayFS 是如何工作的

Docker 目前在大部分发行版本中使用的联合文件系统实现是 overlay2 ,相比其他实现它更加的轻量和高效,下面以实例来简单了解其工作方式。

接着上面 nginx 镜像的例子,拉取镜像后相应的 layer 解压在 /var/lib/docker/overlay2 目录中:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
$ ll /var/lib/docker/overlay2 | tee layers.a

drwx-----x 4 root root     72 7月  20 17:20 335aaf02cbde069ddf7aa0077fecac172d4b2f0240975ab0ebecc3f94f1420cc
drwx-----x 3 root root     47 7月  19 10:04 560df35d349e6a750f1139db22d4cb52cba2a1f106616dc1c0c68b3cf11e3df6
drwx-----x 4 root root     72 7月  20 17:20 769a9f5d698522d6e55bd9882520647bd84375a751a67a8ccad1f7bb1ca066dd
drwx-----x 4 root root     72 7月  20 17:20 97aaf293fef495f0f06922d422a6187a952ec6ab29c0aa94cd87024c40e1a7e8
drwx-----x 4 root root     72 7月  20 17:20 a91fb6955249dadfb34a3f5f06d083c192f2774fbec5fbb1db42a04e918432c0
brw------- 1 root root 253, 1 7月  19 10:00 backingFsBlockDev
drwx-----x 4 root root     72 7月  20 17:20 fa29ec8cfe5a6c0b2cd1486f27a20a02867126edf654faad7f3520a220f3705f
drwx-----x 2 root root    278 7月  20 17:25 l

我们将输出结果保存到 layers.a 文件中供之后对比。其中 6 个名称特别长的目录中存放了镜像的 6 个 layer (目录名称和 manifest.json 中的名称并不对应), l 目录中包含了指向 layers 文件夹的软链接,主要目的是在执行 mount 命令时缩短目录标识符的长度以避免超出页大小限制。

每个 layer 文件夹包含的内容如下:

1
2
3
4
5
6
7
8
$ cd /var/lib/docker/overlay2/
$ ll 335aaf02cbde069ddf7aa0077fecac172d4b2f0240975ab0ebecc3f94f1420cc

-rw------- 1 root root  0 7月  15 17:00 committed
drwxr-xr-x 3 root root 33 7月  15 17:00 diff
-rw-r--r-- 1 root root 26 7月  15 17:00 link
-rw-r--r-- 1 root root 86 7月  15 17:00 lower
drwx------ 2 root root  6 7月  15 17:00 work

link 记录了 l 目录中的短链接, lower 中记录该 layer 的更低一层(如果没有该文件说明当前 layer 已经是最底下一层即基础层), work 目录被 overlay2 内部所使用, diff 目录中存放了该 layer 所包含的文件系统内容:

1
2
3
4
5
6
7
$ ll 335aaf02cbde069ddf7aa0077fecac172d4b2f0240975ab0ebecc3f94f1420cc/diff/
drwxr-xr-x  2 root root    6 7月   7 03:39 docker-entrypoint.d
drwxr-xr-x 20 root root 4096 7月   7 03:39 etc
drwxr-xr-x  5 root root   56 7月   7 03:39 lib
drwxrwxrwt  2 root root    6 7月   7 03:39 tmp
drwxr-xr-x  7 root root   66 6月  21 08:00 usr
drwxr-xr-x  5 root root   41 6月  21 08:00 var

现在我们尝试基于该镜像运行一个容器,看看在容器阶段的联合挂载效果:

1
$ docker run -d --name nginx_container  nginx

执行 mount 命令可确认新增了一个可读写的 overlay 挂载点:

1
2
3
$ mount | grep overlay

overlay on /var/lib/docker/overlay2/bab121ecb1d54b787b7b1834810baf212b035e28ca8d7875a09b1af837116011/merged type overlay (rw,relatime,lowerdir=/var/lib/docker/overlay2/l/6Y7DPCTGLB6JHUPVBGTOEK2QFN:/var/lib/docker/overlay2/l/QMEEOSTJM2QON4M7PJJBB4KDEF:/var/lib/docker/overlay2/l/XNN2MRN4KWITFTZYLFUSLBP322:/var/lib/docker/overlay2/l/6DC6VDOMBZMLBZBT3QSOWLCR37:/var/lib/docker/overlay2/l/NXYWG253WSMELQKF2E2NH2GWCG:/var/lib/docker/overlay2/l/M4SO5XMO4VXRIJIGUHDMTATWH3:/var/lib/docker/overlay2/l/QI3P6ONJSLQI26DVPFGWIZI2EW,upperdir=/var/lib/docker/overlay2/bab121ecb1d54b787b7b1834810baf212b035e28ca8d7875a09b1af837116011/diff,workdir=/var/lib/docker/overlay2/bab121ecb1d54b787b7b1834810baf212b035e28ca8d7875a09b1af837116011/work)

该挂载点中即包含了所有镜像层 layer 组合而成的一个 rootfs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
$ ll /var/lib/docker/overlay2/bab121ecb1d54b787b7b1834810baf212b035e28ca8d7875a09b1af837116011/merged

drwxr-xr-x 2 root root 4096 6月  21 08:00 bin
drwxr-xr-x 2 root root    6 6月  13 18:30 boot
drwxr-xr-x 1 root root   43 7月  16 16:52 dev
drwxr-xr-x 1 root root   41 7月   7 03:39 docker-entrypoint.d
-rwxrwxr-x 1 root root 1202 7月   7 03:39 docker-entrypoint.sh
drwxr-xr-x 1 root root   19 7月  16 16:52 etc
drwxr-xr-x 2 root root    6 6月  13 18:30 home
drwxr-xr-x 1 root root   56 7月   7 03:39 lib
drwxr-xr-x 2 root root   34 6月  21 08:00 lib64
drwxr-xr-x 2 root root    6 6月  21 08:00 media
drwxr-xr-x 2 root root    6 6月  21 08:00 mnt
drwxr-xr-x 2 root root    6 6月  21 08:00 opt
drwxr-xr-x 2 root root    6 6月  13 18:30 proc
drwx------ 2 root root   37 6月  21 08:00 root
drwxr-xr-x 1 root root   23 7月  16 16:52 run
drwxr-xr-x 2 root root 4096 6月  21 08:00 sbin
drwxr-xr-x 2 root root    6 6月  21 08:00 srv
drwxr-xr-x 2 root root    6 6月  13 18:30 sys
drwxrwxrwt 1 root root    6 7月   7 03:39 tmp
drwxr-xr-x 1 root root   66 6月  21 08:00 usr
drwxr-xr-x 1 root root   19 6月  21 08:00 var

除了将原来的镜像层联合挂载到如上所示的 merged 目录,通过 diff 命令可以看到,容器运行成功后 /var/lib/docker/overlay2 还会新增两个 layer 目录,merged 也位于其中一个目录下:

1
2
3
4
5
6
7
8
$ ll /var/lib/docker/overlay2 | tee layers.b
$ diff layers.a layers.b

> drwx-----x 5 root root     69 7月  19 10:08 bab121ecb1d54b787b7b1834810baf212b035e28ca8d7875a09b1af837116011
> drwx-----x 4 root root     72 7月  19 10:08 bab121ecb1d54b787b7b1834810baf212b035e28ca8d7875a09b1af837116011-init
< drwx-----x 2 root root    210 7月  19 10:08 l
---
> drwx-----x 2 root root    278 7月  19 10:08 l

通过 inspect 命令探查已运行容器的 GraphDriver,可以更清晰地看到与镜像相比容器的 layers 所发生的变化 :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
$ docker inspect nginx_container

....
"GraphDriver": {
    "Data": {
        "LowerDir": "/var/lib/docker/overlay2/bab121ecb1d54b787b7b1834810baf212b035e28ca8d7875a09b1af837116011-init/diff:/var/lib/docker/overlay2/97aaf293fef495f0f06922d422a6187a952ec6ab29c0aa94cd87024c40e1a7e8/diff:/var/lib/docker/overlay2/fa29ec8cfe5a6c0b2cd1486f27a20a02867126edf654faad7f3520a220f3705f/diff:/var/lib/docker/overlay2/769a9f5d698522d6e55bd9882520647bd84375a751a67a8ccad1f7bb1ca066dd/diff:/var/lib/docker/overlay2/a91fb6955249dadfb34a3f5f06d083c192f2774fbec5fbb1db42a04e918432c0/diff:/var/lib/docker/overlay2/335aaf02cbde069ddf7aa0077fecac172d4b2f0240975ab0ebecc3f94f1420cc/diff:/var/lib/docker/overlay2/560df35d349e6a750f1139db22d4cb52cba2a1f106616dc1c0c68b3cf11e3df6/diff",
        "MergedDir": "/var/lib/docker/overlay2/bab121ecb1d54b787b7b1834810baf212b035e28ca8d7875a09b1af837116011/merged",
        "UpperDir": "/var/lib/docker/overlay2/bab121ecb1d54b787b7b1834810baf212b035e28ca8d7875a09b1af837116011/diff",
        "WorkDir": "/var/lib/docker/overlay2/bab121ecb1d54b787b7b1834810baf212b035e28ca8d7875a09b1af837116011/work"
    },
    "Name": "overlay2"
}
....

LowerDir 中记录了原有的镜像层文件系统,另外在最上层还新增了一个 init 层,它们在容器运行阶段都是只读的;MergedDir 中记录了将 LowerDir 的所有目录进行联合挂载的挂载点;UpperDir 也是新增的一个 layer ,此时位于以上所有 layers 的最上层,与其他镜像层相比它是可读写的。容器阶段的 layers 示意图如下:

创建容器时所新增的可写 layer 我们称为 container layer,容器运行阶段对文件系统的变更都只会写入到该 layer 中,包括对文件的新增、修改和删除,而不会改变更低层的原有镜像内容,这极大提升了镜像的分发效率。在镜像层和 container layer 之间的 init 层记录了容器在启动时写入的一些配置文件,这一过程发生在新增读写层之前,我们不希望把这些数据写入到原始镜像中。

这两个新增的层仅在容器运行阶段存在,容器删除后它们也会被删除。同一个镜像可以创建多个不同的容器,仅需要创建多个不同的可写层;运行时更改过的容器也可以重新打包为一个新的镜像,将可写层添加到新镜像的只读层中即可。

为什么容器的读写效率不如原生文件系统

为了最小化 I/O 以及缩减镜像体积,容器的联合文件系统在读写文件时会采取写时复制策略(copy-on-write),如果一个文件或目录存在于镜像中的较低层,而另一个层(包括可写层)需要对其进行读取访问时,会直接访问较低层的文件。当另一个层第一次需要写入该文件时(在构建镜像或运行容器时),该文件会被复制到该层并被修改。这一举措大大减少了容器的启动时间(启动时新建的可写层只有很少的文件写入),但容器运行后每次第一次修改某个文件都需要先将整个文件复制到 container layer 中。

以上原因导致容器运行时的读写效率不如原生文件系统(尤其是写入效率),在 container layer 中不适合进行大量的文件读写,通常建议将频繁写入的数据库、日志文件或目录等单独挂载出去,如使用 Docker 提供的 Volume,此时目录将通过绑定挂载(Bind Mount)直接挂载在可读写层中,绕过了写时复制带来的性能损耗。

运行时规范

运行时规范描述了容器的配置、执行环境和生命周期。它详细描述了不同容器运行时架构的配置文件 config.json 的字段格式,如何在执行环境中应用、注入这些配置,以确保容器内运行的程序在不同运行时之间环境一致,并通过容器的生命周期定义了一套统一的操作行为。

容器的生命周期

规范所定义的容器生命周期,描述了容器从创建到退出所发生的事件构成的时间线,该时间线中定义了 13 个不同的事件,下图描述了时间线中容器状态的变化:

规范中只定义了4种容器状态,运行时实现可以在规范的基础上添加其他状态,同时标准还规定了运行时必须支持的操作

  • Query State,查询容器的当前状态
  • Create,根据镜像及配置创建一个新的容器,但是不运行用户指定程序
  • Start,在一个已创建的容器中运行用户指定程序
  • Kill,发送特定信号终止容器进程
  • Delete,删除已停止容器所创建的资源

每个操作之前或之后还会触发不同的 hooks,符合规范的运行时必须执行这些 hooks。

容器的本质是进程

运行时规范中使用了 container process 这一概念,container process 等同于上文提到的用户指定程序和容器进程,有些场景也会称该进程为容器的 init 进程。运行一个容器必须在 config.json 中定义容器的 container process,可定义的字段包括命令参数、环境参数和执行路径等等。

容器状态的变化,实际上反映的是 container process 的变化。我们可以将容器的生命周期和状态变化划分为以下几个阶段:

  1. container process 执行之前。

    • 运行时执行 create 命令,根据 config.json 创建指定的资源。

    • 资源创建成功后,容器进入 created 状态。

  2. 执行 container process

    • 运行时执行 start 命令。

    • 运行时运行用户指定程序,即 container process

    • 容器进入 running 状态。

  3. container process 进程结束,结束的原因可能是该程序执行结束、出错或崩溃,以及运行时通过 kill 命令向其发出终止信号。

    • 容器进入 stoppped 状态。

    • 运行时执行 delete 命令,所有通过 create 命令创建的资源被清除。

容器运行时的核心就是 container process:镜像文件系统满足运行进程所需的依赖,运行时所作的准备工作是为了正确的运行该进程,运行时持续的监测该进程的状态,一旦该进程结束即宣告容器(暂时)死亡,运行时进行收尾的清理工作。

当然容器中也可以运行其他进程,但这些进程只是共用 container process 的环境。

实现和生态

Docker 向 OCI 规范捐献了其容器运行时 runC 项目,作为该规范的标准实现。目前已有的大部分容器项目都直接将 runC 作为运行时实现。

下图可以概括容器生态内 Docker 相关的组织及项目之间的关系:

Kubernetes 定义了 CRI(Container Runtime Interface)以实现可替换的容器运行时,目前有 cri-containerdcri-odocker 等几种实现,但它们实际上也都基于 runC

参考链接

updatedupdated2023-06-062023-06-06