镜像是怎样炼成的

原文:How are docker images built? A look into the Linux overlay file-systems and the OCI specification
要使用 Docker,就不可避免地要和 Docker 镜像打交道。本文将会讲述 Docker 镜像的基石: Overlay 文件系统。首先我会简单介绍一下这个文件系统,接下来会看看如何把这个技术用在 Docker 镜像上,以及 Docker 是怎样从 Dockerfile 构建出 Docker 镜像的。最后还会介绍分层缓存以及 OCI 格式的容器镜像。
遵循我的一贯风格,我会尽可能的让本文具备更好的操作性。
Overlay 文件系统是什么
Overlay 文件系统(也被称为联合文件系统),能够使用两个或更多的目录创建一个联合:它由低层和高层的目录组成。文件系统中低层的目录是只读的,而高层的文件系统则是可读可写的。我们可以试试加载一个,看看操作效果。
创建 Overlay 文件系统
我们可以创建几个目录然后把它们联合起来。首先会创建一个叫做 “mount” 的目录,我们将它作为这个联合的父目录。接下来会创建 “layer-1”、“layer-2”、“layer-3”、“layer-4” 着几个目录。最后还要创建一个叫做 “workdir” 的目录, Overlay 文件系统必须有这个目录才能正常工作。
这些目录可以随意命名,不过 “layer-1”、“layer-2” 这样的命名方式,和 Docker 镜像对比起来会比较容易理解。
$ cd /tmp && mkdir overlay-example && cd overlay-example
[2020-04-19 16:02:35] [ubuntu] [/tmp/overlay-example]
> mkdir mount layer-1 layer-2 layer-3 layer-4 workdir
[2020-04-19 16:02:38] [ubuntu] [/tmp/overlay-example]
$ ls
layer-1 layer-2 layer-3 layer-4 mount workdir
然后要在除 "layer-4" 之外的每个目录下创建文件,这个步骤也不是必要的,只是为了更像镜像:
[2020-04-19 16:02:40] [ubuntu] [/tmp/overlay-example]
$ echo "Layer-1 file" > ./layer-1/some-file-in-layer-1
[2020-04-19 16:03:36] [ubuntu] [/tmp/overlay-example]
$ echo "Layer-2 file" > ./layer-2/some-file-in-layer-2
[2020-04-19 16:03:53] [ubuntu] [/tmp/overlay-example]
$ echo "Layer-3 file" > ./layer-3/some-file-in-layer-3
我们来挂载这个文件系统:
sudo mount -t overlay overlay-example \
-o lowerdir=/tmp/overlay-example/layer-1:/tmp/overlay-example/layer-2:/tmp/overlay-example/layer-3,upperdir=/tmp/overlay-example/layer-4,workdir=/tmp/overlay-example/workdir \
/tmp/overlay-example/mount
看看挂载目录的内容:
[2020-04-19 16:13:28] [ubuntu] [/tmp/overlay-example]
> cd mount/
[2020-04-19 16:13:31] [ubuntu] [/tmp/overlay-example/mount]
> ls -la
total 20
drwxr-xr-x 1 napicell domain^users 4096 Apr 19 16:07 .
drwxr-xr-x 8 napicell domain^users 4096 Apr 19 16:07 ..
-rw-r--r-- 1 napicell domain^users 13 Apr 19 16:03 some-file-in-layer-1
-rw-r--r-- 1 napicell domain^users 13 Apr 19 16:03 some-file-in-layer-2
-rw-r--r-- 1 napicell domain^users 13 Apr 19 16:03 some-file-in-layer-3
不出所料,前三层的文件都被加载到了挂载根目录。可以看到我们之前写入文件的内容:
$ cat some-file-in-layer-3
Layer-3 file
试试创建文件
$ echo "new content" > new-file
$ ls
new-file some-file-in-layer-1 some-file-in-layer-2 some-file-in-layer-3
新文件在哪里呢?自然是在上层,我们的例子里就是 "layer-4":
[2020-04-19 16:23:49] [ubuntu] [/tmp/overlay-example]
pactvm > tree
.
├── layer-1
│ └── some-file-in-layer-1
├── layer-2
│ └── some-file-in-layer-2
├── layer-3
│ └── some-file-in-layer-3
├── layer-4
│ └── new-file
├── mount
│ ├── new-file
│ ├── some-file-in-layer-1
│ ├── some-file-in-layer-2
│ └── some-file-in-layer-3
└── workdir
└── work [error opening dir]
7 directories, 8 files
试试看删除文件:
[2020-04-19 16:27:33] [ubuntu] [/tmp/overlay-example/mount]
> rm some-file-in-layer-2
[2020-04-19 16:28:58] [ubuntu] [/tmp/overlay-example/mount]
> ls
new-file some-file-in-layer-1 some-file-in-layer-3
你猜猜,原始文件系统中的 "layer-2" 目录会怎么样:
[2020-04-19 16:29:57] [ubuntu] [/tmp/overlay-example]
pactvm > tree
.
├── layer-1
│ └── some-file-in-layer-1
├── layer-2
│ └── some-file-in-layer-2
├── layer-3
│ └── some-file-in-layer-3
├── layer-4
│ ├── new-file
│ └── some-file-in-layer-2
├── mount
│ ├── new-file
│ ├── some-file-in-layer-1
│ └── some-file-in-layer-3
└── workdir
└── work [error opening dir]
7 directories, 8 files
"layer-4" 中出现了个新文件 "some-file-in-layer-2"。奇怪的是这个文件的属性(”Character file“),这种文件在 Overlay 文件系统中被称为 ”Whitout“,用于表达被删除的文件。
[2020-04-19 16:31:09] [ubuntu] [/tmp/overlay-example/layer-4]
pactvm > ls -la
total 12
drwxr-xr-x 2 napicell domain^users 4096 Apr 19 16:28 .
drwxr-xr-x 8 napicell domain^users 4096 Apr 19 16:07 ..
-rw-r--r-- 1 napicell domain^users 12 Apr 19 16:23 new-file
c--------- 1 root root 0, 0 Apr 19 16:28 some-file-in-layer-2
完成之后,卸载这个文件系统,然后删除目录:
[2020-04-19 16:37:11] [ubuntu] [/tmp/overlay-example]
$ sudo umount /tmp/overlay-example/mount && rm -rf *
理顺概念
正如开篇所说, Overlay 文件系统上可以把多个目录联合在一起。在前边的例子里,这个联合过程由 “layer-{1,2,3,4}” 在 “mount” 目录里组成。对文件的修改、创建和删除都在上层发生——也就是这里的 “layer-4”,因此这一层也被称为差异层。上层的文件会对下层文件造成遮盖。假设 “layer-2” 和 “layer-1” 中,在相同的相对目录下有同名的文件,那么在 “mount” 目录中就会以 “layer-2” 为准。下一节将会看看这一技术在 Docker 镜像中的应用。
什么是 Docker 镜像
简单总结,Docker 镜像就是一个 Tar 文件,其中包含一个根文件系统和一些愿数据。你可能听说过,Dockerfile 中的每一行都会生成一个层。例如下面的代码就会生成一个三层的镜像:
FROM scratch
ADD my-files /doc
ADD hello /
CMD ["/hello"]
“docker run” 的过程很复杂,但是本文中只会关注和镜像有关的一点点内容。概括的说,Docker 会下载这个文件包,把每个层解压到单独的目录中,然后用 Overlay 文件系统将这些目录以及用于进行写入的一个上层空目录联合起来。当你在容器中进行修改、创建或者删除操作时,这些变更都会保存到这个空目录中。容器退出时,Docker 会清理这个目录——这就是在容器中的变更无法保持的原因。
层缓存
要运行容器,就要构建镜像,Docker 将这两个步骤分离开来独立运作,是它得以流行的重要原因。OCI 就是业界公认的规范。
OCI 当前包括两个规范:运行规范和镜像规范。运行规范描述了如何运行一个解压到磁盘上的 “复合文件系统” 。简单说来,OCI 实现会把 OCI 镜像下载回来,然后解压到一个 OCI 运行时复合文件系统之中。这一操作完成后就可以让 OCI 运行时运行了。
标准化的意义就是让其他人可以自己开发容器的构建工具和运行时。例如 jess/img
、Buildah
以及 Skopeo
都是可以脱离 Docker 构建镜像的工具。类似地还有很多容器运行时,例如 runc(Docker 使用) 和 rkt。
其他的 Overlay 文件系统
Docker 能够使用的联合文件系统不止这一种。任何有差异层和联合特性的文件系统都是可能的候选者。例如 Docker 还能运行在 aufs、btrfs、zfs 和 devicemapper 系统上。
构建镜像时发生了什么
假设我们要使用下面的 Dockerfile 来构建镜像:
FROM ubuntu
RUN apt-get update
...
简单描述一下这个过程:
Docker 下载 FROM 语句中指定的 tar 文件,这是目标镜像的第一层。
加载一个联合文件系统,其底层就是刚下载的部分,在上面创建一个空目录。
在 chroot 中启动一个 bash,运行 RUN 语句中的命令:
RUN: chroot . /bin/bash -c "apt get update"
。命令结束后,会把上层目录压缩,形成新镜像中的新的一层。
如果 Dockerfile 中包含其它命令,就以之前构建的层次为基础,从第二步开始重复创建新层,直到完成所有语句后退出。
上述过程是个极度简化的过程,其中缺乏一些常见指令,例如 ENTRYPOINT
、ENV
等。这些内容会被写入元数据,和文件层封装在一起。
结论
这种将根文件系统和每个差异层都进行打包的思路非常强大。它不仅是 Docker 的基础,我想还能用在其它一些领域里,以后可能会诞生更多这类工具。
Subscribe to my newsletter
Read articles from 崔秀龙 directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
