Docker 镜像之存储管理
笔者在《Docker 镜像之进阶篇》中介绍了镜像分层、写时复制以及内容寻址存储(content-addressable storage)等技术特性,为了支持这些特性,docker 设计了一套镜像元数据管理机制来管理镜像元数据。另外,为了能够让 docker 容器适应不同平台不同应用场景对存储的要求,docker 提供了各种基于不同文件系统实现的存储驱动来管理实际镜像文件。
本文我们就来介绍 docker 如何管理镜像元数据,以及如何通过存储驱动来管理实际的容器镜像文件。
Docker 镜像元数据管理
Docker 镜像在设计上将镜像元数据和镜像文件的存储完全隔离开了。Docker 在管理镜像层元数据时采用的是从上至下 repository、image 和 layer 三个层次。由于 docker 以分层的形式存储镜像,所以 repository 和 image 这两类元数据并没有物理上的镜像文件与之对应,而 layer 这种元数据则存在物理上的镜像层文件与之对应。接下来我们就介绍这些元数据的管理与存储。
repository 元数据
repository 是由具有某个功能的 docker 镜像的所有迭代版本构成的镜像库。Repository 在本地的持久化文件存放于 /var/lib/docker/image/<graph_driver>/repositories.json 中,下图显示了 docker 使用 aufs 存储驱动时 repositories.json 文件的路径:
我们可以通过 vim 查看 repositories.json 的内容,并通过命令 :%!python -m json.tool 进行格式化:
文件中存储了所有本地镜像的 repository 的名字,比如 ubuntu ,还有每个 repository 下的镜像的名字、标签及其对应的镜像 ID。当前 docker 默认采用 SHA256 算法根据镜像元数据配置文件计算出镜像 ID。上图中的两条记录本质上是一样的,第二条记录和第一条记录指向同一个镜像 ID。其中 sha256:c8c275751219dadad8fa56b3ac41ca6cb22219ff117ca98fe82b42f24e1ba64e 被称为镜像的摘要,在拉取镜像时可以看到它:
镜像的摘要(Digest)是对镜像的 manifest 内容计算 sha256sum 得到的。我们也可以直接指定一个镜像的摘要进行 pull 操作:
$ docker pull ubuntu@sha256:c8c275751219dadad8fa56b3ac41ca6cb22219ff117ca98fe82b42f24e1ba64e
这和 docker pull ubuntu:latest 是一样的(当然,如果镜像被更新了,就会有新的摘要来对应 ubuntu:latest)。
image 元数据
image 元数据包括了镜像架构(如 amd64)、操作系统(如 linux)、镜像默认配置、构建该镜像的容器 ID 和配置、创建时间、创建该镜像的 docker 版本、构建镜像的历史信息以及 rootfs 组成。其中构建镜像的历史信息和 rootfs 组成部分除了具有描述镜像的作用外,还将镜像和构成该镜像的镜像层关联了起来。Docker 会根据历史信息和 rootfs 中的 diff_ids 计算出构成该镜像的镜像层的存储索引 chainID,这也是 docker 1.10 镜像存储中基于内容寻址的核心技术。
镜像 ID 与镜像元数据之间的映射关系以及元数据被保存在文件 /var/lib/docker/image/<graph_driver>/imagedb/content/sha256/<image_id> 中。
452a96d81c30a1e426bc250428263ac9ca3f47c9bf086f876d11cb39cf57aeec 就是镜像的ID。其内容如下(简洁起见,省略中间大部分的内容):
它包含所有镜像层信息的 rootfs(见上图的 rootfs 部分),docker 利用 rootfs 中的 diff_id 计算出内容寻址的索引(chainID) 来获取 layer 相关信息,进而获取每一个镜像层的文件内容。注意,每个 diff_id 对应一个镜像层。上面的 diff_id 的排列也是有顺序的,从上到下依次表示镜像层的最低层到最顶层:
layer 元数据
layer 对应镜像层的概念,在 docker 1.10 版本以前,镜像通过一个 graph 结构管理,每一个镜像层都拥有元数据,记录了该层的构建信息以及父镜像层 ID,而最上面的镜像层会多记录一些信息作为整个镜像的元数据。graph 则根据镜像 ID(即最上层的镜像层 ID) 和每个镜像层记录的父镜像层 ID 维护了一个树状的镜像层结构。
在 docker 1.10 版本后,镜像元数据管理巨大的改变之一就是简化了镜像层的元数据,镜像层只包含一个具体的镜像层文件包。用户在 docker 宿主机上下载了某个镜像层之后,docker 会在宿主机上基于镜像层文件包和 image 元数据构建本地的 layer 元数据,包括 diff、parent、size 等。而当 docker 将在宿主机上产生的新的镜像层上传到 registry 时,与新镜像层相关的宿主机上的元数据也不会与镜像层一块打包上传。
Docker 中定义了 Layer 和 RWLayer 两种接口,分别用来定义只读层和可读写层的一些操作,又定义了 roLayer 和 mountedLayer,分别实现了上述两种接口。其中,roLayer 用于描述不可改变的镜像层,mountedLayer 用于描述可读写的容器层。
具体来说,roLayer 存储的内容主要有索引该镜像层的 chainID、该镜像层的校验码 diffID、父镜像层 parent、graphdriver 存储当前镜像层文件的 cacheID、该镜像层的 size 等内容。这些元数据被保存在 /var/lib/docker/image/<graph_driver>/layerdb/sha256/<chainID>/ 文件夹下。
/var/lib/docker/image/<graph_driver>/layerdb/sha256/ 目录下的目录名称都是镜像层的存储索引 chainID:
镜像层的存储索引 chainID 目录下的内容为:
其中 diffID 和 size 可以通过镜像层包计算出来(diff 文件的内容即 diffID,其内容就是 image 元数据中对应层的 diff_id)。chainID 和父镜像层 parent 需要从所属 image 元数据中计算得到。而 cacheID 是在当前 docker 宿主机上随机生成的一个 uuid,在当前的宿主机上,cacheID 与该镜像层一一对应,用于标识并索引 graphdriver 中的镜像层文件:
在 layer 的所有属性中,diffID 采用 SHA256 算法,基于镜像层文件包的内容计算得到。而 chainID 是基于内容存储的索引,它是根据当前层与所有祖先镜像层 diffID 计算出来的,具体算如下:
- 如果该镜像层是最底层(没有父镜像层),该层的 diffID 便是 chainID。
- 该镜像层的 chainID 计算公式为 chainID(n)=SHA256(chain(n-1) diffID(n)),也就是根据父镜像层的 chainID 加上一个空格和当前层的 diffID,再计算 SHA256 校验码。
mountedLayer 存储的内容主要为索引某个容器的可读写层(也叫容器层)的 ID(也对应容器层的 ID)、容器 init 层在 graphdriver 中的ID(initID)、读写层在 graphdriver 中的 ID(mountID) 以及容器层的父层镜像的 chainID(parent)。相关文件位于 /var/lib/docker/image/<graph_driver>/layerdb/mounts/<container_id>/ 目录下。
启动一个容器,查看 /var/lib/docker/image/<graph_driver>/layerdb/mounts/<container_id>/ 目录下的内容:
Docker aufs 存储驱动
存储驱动根据操作系统底层的支持提供了针对某种文件系统的初始化操作以及对镜像层的增、删、改、查和差异比较等操作。目前存储系统的接口已经有 aufs、btrfs、devicemapper、voerlay2 等多种。在启动 docker deamon 时可以指定使用的存储驱动,当然指定的驱动必须被底层操作系统支持。下面我们以 aufs 存储驱动为例介绍其工作方式。
先来简单认识一下 aufs,aufs(advanced multi layered unification filesystem)是一种支持联合挂载的文件系统。简单来说就是支持将不同目录挂载到同一个目录下,这些挂载操作对用户来说是透明的,用户在操作该目录时并不会觉得与其他目录有什么不同。这些目录的挂载是分层次的,通常来说最上层是可读写层,下面的层是只读层。所以,aufs 的每一层都是一个普通的文件系统。
当需要读取一个文件 A 时,会从最顶层的读写层开始向下寻找,本层没有,则根据层之间的关系到下一层开始找,直到找到第一个文件 A 并打开它。
当需要写入一个文件 A 时,如果这个文件不存在,则在读写层新建一个,否则像上面的过程一样从顶层开始查找,直到找到最近的文件 A,aufs 会把这个文件复制到读写层进行修改。
由此可以看出,在第一次修改某个已有文件时,如果这个文件很大,即使只要修改几个字节,也会产生巨大的磁盘开销。
当需要删除一个文件时,如果这个文件仅仅存在于读写层中,则可以直接删除这个文件,否则就需要先删除它在读写层中的备份,再在读写层中创建一个 whiteout 文件来标志这个文件不存在,而不是真正删除底层的文件。
当新建一个文件时,如果这个文件在读写层存在对应的 whiteout 文件,则先将 whiteout 文件删除再新建。否则直接在读写层新建即可。
那么镜像文件在本地存放在哪里呢?
以 aufs 驱动为例,我们先查看 /var/lib/docker/aufs 目录下的内容:
$ sudo su $ cd /var/lib/docker/aufs $ ls
其中 mnt 为 aufs 的挂载目录,diff 为实际的数据来源,包括只读层和可读写层,所有这些层最终一起被挂载在 mnt 下面的目录上,layers 下为与每层依赖有关的层描述文件。
最初,mnt 和 layers 都是空目录,文件数据都在 diff 目录下。一个 docker 容器创建与启动的过程中,会在 /var/lib/docker/aufs 下面新建出对应的文件和目录。由于 docker 镜像管理部分与存储驱动在设计上完全分离了,镜像层或者容器层在存储驱动中拥有一个新的标识 ID,在镜像层(roLayer)中称为 cacheID,容器层(mountedLayer)中为 mountID。在 Linux 环境下,mountID 是随机生成的并保存在 mountedLayer 的元数据 mountID 中,持久化在 image/aufs/layserdb/mounts/<container_id>/mount-id 中。下面以 mountID 为例,介绍创建一个新读写层的步骤:
第一步,分别在 mnt 和 diff 目录下创建与该层的 mountID 同名的子文件夹。
第二步,在 layers 目录下创建与该层的 mountID 同名的文件,用来记录该层所依赖的所有的其它层。
第三步,如果参数中的 parent 项不为空(这里介绍的是创建容器的情景,parent 就是镜像的最上层),说明该层依赖于其它的层。GraphDriver 就需要将 parent 的 mountID 写入到该层在 layers 下对应 mountID 的文件里。然后 GraphDriver 还需要在 layers 目录下读取与上述 parent 同 mountID 的文件,将 parent 层的所有依赖层也复制到这个新创建层对应的层描述文件中,这样这个文件才记录了该层的所有依赖。创建成功后,这个新创建的层描述文件如下:
上图中 6a2ef0693c2879347cc1a575c1db60765afb0cff47dcf3ab396f35d070fb240b 为 mountID。随后 GraphDriver 会将 diff 中属于容器镜像的所有层目录以只读方式挂载到 mnt 下,然后在 diff 中生成一个以当前容器对应的 <mountID>-init 命名的文件夹作为最后一层只读层,这个文件夹用于挂载并重新生成如下代码段所列的文件:
"/dev/pts":"dir",
"/dev/shm":"dir",
"/proc":"dir",
"/sys":"dir",
"/.dockerinit":"file",
"/.dockerenv":"file",
"/etc/resolv.conf":"file",
"/etc/hosts":"file",
"/etc/hastname":"file",
"/dev/console":"file",
"/etc/mtab":"/proc/mounts",
可以看到这些文件与这个容器内的环境息息相关,但并不适合被打包作为镜像的文件内容(毕竟文件里的内容是属于这个容器特有的),同时这些内容又不应该直接修改在宿主机文件上,所以 docker 容器文件存储中设计了 mountID-init 这么一层单独处理这些文件。这一层只在容器启动时添加,并会根据系统环境和用户配置自动生成具体的内容(如 DNS配置等),只有当这些文件在运行过程中被改动后并且 docker commit 了才会持久化这些变化,否则保存镜像时不会包含这一层的内容。
所以严格地说,docker 容器的文件系统有 3 层:可读写层、init 层和只读层。但是这并不影响我们传统认识上可读写层 + 只读层组成的容器文件系统:因为 init 层对于用户来说是完全透明的。
接下来会在 diff 中生成一个以容器对应 mountID 为名的可读写目录,也挂载到 mnt 目录下。所以,将来用户在容器中新建文件就会出现在 mnt 下一 mountID 为名的目录下,而该层对应的实际内容则保存在 diff 目录下。
至此我们需要明确,所有文件的实际内容均保存在 diff 目录下,包括可读写层也会以 mountID 为名出现在 diff 目录下,最终会整合到一起联合挂载到 mnt 目录下以 mountID 为名的文件夹下。接下来我们统一观察 mnt 对应的 mountID 下的变化。
第一步,先创建一个容器
$ docker container create -it --name mycon ubuntu bash
比如我们得到的容器 ID 为:059a01071ab7f51abdfbe9f78b95be06ad631d0e0d4be3153e4a1bc32ffa453a,此时容器的状态为 "Created"。
然后在 /var/lib/docker/image/aufs/layerdb/mounts 目录中,查看 059a01071ab7f51abdfbe9f78b95be06ad631d0e0d4be3153e4a1bc32ffa453a 目录下 mount-id 文件的内容如下:
819e3e9a67f4440cecf29086c559a57a1024a078eeee42f48d5d3472e59a6c94
这就是容器层对应的 mountID。接下来查看容器运行前对应的 mnt 目录:
$ du -h . --max-depth=1 |grep 819e
此时 mountID 对应的文件夹下是空的。
第二步,启动容器
$ docker container start -i mycon
现在再来查看 mnt 下对应目录的大小:
容器层变大了,进入到文件夹中可以看到挂载好的文件系统:
第三步,在容器中创建文件
下面我们进入到容器中,创建一个 1G 大小的文件:
此时再来查看 mnt 下对应目录的大小:
容器层目录的大小反映了我们对文件执行的操作。
第四步,停止容器
$ docker container stop mycon
停止容器后,/var/lib/docker/aufs/mnt 目录下对应的 mountID 目录被卸载(umount),此时该目录为空。但是 /var/lib/docker/aufs/diff 目录下对应的目录和文件都还存在。
综上所述,我们可以通过下图来理解 docker aufs 驱动的主要存储目录和作用:
最后,当我们用 docker container commit 命令把容器提交成镜像后,就会在 diff 目录下生成一个新的 cacheID 命名的文件夹,它存放了最新的差异变化文件,这时一个新的镜像层就诞生了。而原来的以 mountID 为名的文件夹会继续存在,直至对应容器被删除。
总结
本文结合实例介绍了 docker 镜像元数据的存储和 aufs 存储驱动下 docker 镜像层的文件存储。由于 docker 镜像管理部分与存储驱动在设计上的完全分离,使得这部分内容初看起来并不是那么直观。希望本文能对大家理解 docker 镜像及其存储有所帮助。
参考:
《docker 容器与容器云》