笔者在《Docker 镜像之进阶篇》中介绍了镜像分层、写时复制以及内容寻址存储(content-addressable storage)等技术特性,为了支持这些特性,docker 设计了一套镜像元数据管理机制来管理镜像元数据。另外,为了可以让 docker 容器适应不一样平台不一样应用场景对存储的要求,docker 提供了各类基于不一样文件系统实现的存储驱动来管理实际镜像文件。html
本文咱们就来介绍 docker 如何管理镜像元数据,以及如何经过存储驱动来管理实际的容器镜像文件。python
Docker 镜像在设计上将镜像元数据和镜像文件的存储彻底隔离开了。Docker 在管理镜像层元数据时采用的是从上至下 repository、image 和 layer 三个层次。因为 docker 以分层的形式存储镜像,因此 repository 和 image 这两类元数据并无物理上的镜像文件与之对应,而 layer 这种元数据则存在物理上的镜像层文件与之对应。接下来咱们就介绍这些元数据的管理与存储。linux
repository 元数据
repository 是由具备某个功能的 docker 镜像的全部迭代版本构成的镜像库。Repository 在本地的持久化文件存放于 /var/lib/docker/image/<graph_driver>/repositories.json 中,下图显示了 docker 使用 aufs 存储驱动时 repositories.json 文件的路径:算法
咱们能够经过 vim 查看 repositories.json 的内容,并经过命令 :%!python -m json.tool 进行格式化:docker
文件中存储了全部本地镜像的 repository 的名字,好比 ubuntu ,还有每一个 repository 下的镜像的名字、标签及其对应的镜像 ID。当前 docker 默认采用 SHA256 算法根据镜像元数据配置文件计算出镜像 ID。上图中的两条记录本质上是同样的,第二条记录和第一条记录指向同一个镜像 ID。其中 sha256:c8c275751219dadad8fa56b3ac41ca6cb22219ff117ca98fe82b42f24e1ba64e 被称为镜像的摘要,在拉取镜像时能够看到它:json
镜像的摘要(Digest)是对镜像的 manifest 内容计算 sha256sum 获得的。咱们也能够直接指定一个镜像的摘要进行 pull 操做:
$ docker pull ubuntu@sha256:c8c275751219dadad8fa56b3ac41ca6cb22219ff117ca98fe82b42f24e1ba64e
这和 docker pull ubuntu:latest 是同样的(固然,若是镜像被更新了,就会有新的摘要来对应 ubuntu:latest)。ubuntu
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> 中。vim
452a96d81c30a1e426bc250428263ac9ca3f47c9bf086f876d11cb39cf57aeec 就是镜像的ID。其内容以下(简洁起见,省略中间大部分的内容):bash
它包含全部镜像层信息的 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 计算出来的,具体算以下:
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>/ 目录下的内容:
存储驱动根据操做系统底层的支持提供了针对某种文件系统的初始化操做以及对镜像层的增、删、改、查和差别比较等操做。目前存储系统的接口已经有 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 容器与容器云》