Docker 中的镜像采用分层构建设计,每个层可以称之为 “layer”,这些 layer 被存放在了 / var/lib/docker/
通常 ubuntu 类的系统默认采用的是 AUFS,centos7.1 + 系列采用的是 OverlayFS。而本文将介绍以 OverlayFS 作为存储驱动的镜像存储原理以及存储结构。
OverlayFS 是一种堆叠文件系统,它依赖并建立在其它的文件系统之上(例如 ext4fs 和 xfs 等等),并不直接参与磁盘空间结构的划分,仅仅将原来底层文件系统中不同的目录进行 “合并”,然后向用户呈现,这也就是联合挂载技术,对比于 AUFS,OverlayFS 速度更快,实现更简单。 而 Linux 内核为 Docker 提供的 OverlayFS 驱动有两种:overlay 和 overlay2。而 overlay2 是相对于 overlay 的一种改进,在 inode 利用率方面比 overlay 更有效。但是 overlay 有环境需求:docker 版本 17.06.02+,宿主机文件系统需要是 ext4 或 xfs 格式。
overlayfs 通过三个目录:lower 目录、upper 目录、以及 work 目录实现,其中 lower 目录可以是多个,work 目录为工作基础目录,挂载后内容会被清空,且在使用过程中其内容用户不可见,最后联合挂载完成给用户呈现的统一视图称为为 merged 目录。以下使用 mount 将演示其如何工作的。
使用 mount 命令挂载 overlayfs 语法如下:
mount -t overlay overlay -o lowerdir=lower1:lower2:lower3,upperdir=upper,workdir=work merged_dir
创建三个目录 A、B、C, 以及 worker 目录:
然后使用 mount 联合挂载到 / tmp/test 下:
然后我们再去查看 / tmp/test 目录,你会发现目录 A、B、C 被合并到了一起, 并且相同文件名的文件会进行 “覆盖”,这里覆盖并不是真正的覆盖,而是当合并时候目录中两个文件名称都相同时,merged 层目录会显示离它最近层的文件:
同时我们还可以通过 mount 命令查看其挂载的选项:
以上这样的方式也就是联合挂载技术。
介绍了 overlay 驱动原理以后再来看 Docker 中的 overlay 存储驱动,以下是来自 docker 官网关于 overlay 的工作原理图:
在上述图中可以看到三个层结构,即:lowerdir、uperdir、merged,其中 lowerdir 是只读的 image layer,其实就是 rootfs,对比我们上述演示的目录 A 和 B,我们知道 image layer 可以分很多层,所以对应的 lowerdir 是可以有多个目录。而 upperdir 则是在 lowerdir 之上的一层,这层是读写层,在启动一个容器时候会进行创建,所有的对容器数据更改都发生在这里层,对比示例中的 C。最后 merged 目录是容器的挂载点,也就是给用户暴露的统一视角,对比示例中的 / tmp/test。而这些目录层都保存在了 / var/lib/docker/overlay2 / 或者 / var/lib/docker/overlay/(如果使用 overlay)。
启动一个容器
查看其 overlay 挂载点, 可以发现其挂载的 merged 目录、lowerdir、upperdir 以及 workdir:
overlay2 的 lowerdir 可以有多个,并且是软连接方式挂载,后续我们会进行说明。
当容器中发生数据修改时候 overlayfs 存储驱动又是如何进行工作的?以下将阐述其读写过程:
读:
如果文件在容器层(upperdir),直接读取文件;
如果文件不在容器层(upperdir),则从镜像层(lowerdir)读取;
修改:
首次写入: 如果在 upperdir 中不存在,overlay 和 overlay2 执行 copy_up 操作,把文件从 lowdir 拷贝到 upperdir,由于 overlayfs 是文件级别的(即使文件只有很少的一点修改,也会产生的 copy_up 的行为),后续对同一文件的在此写入操作将对已经复制到容器的文件的副本进行操作。这也就是常常说的写时复制(copy-on-write)
删除文件和目录: 当文件在容器被删除时,在容器层(upperdir)创建 whiteout 文件,镜像层 (lowerdir) 的文件是不会被删除的,因为他们是只读的,但 without 文件会阻止他们显示,当目录在容器内被删除时,在容器层(upperdir)一个不透明的目录,这个和上面 whiteout 原理一样,阻止用户继续访问,即便镜像层仍然存在。
注意事项
copy_up 操作只发生在文件首次写入,以后都是只修改副本,
overlayfs 只适用两层目录,, 相比于比 AUFS,查找搜索都更快。
容器层的文件删除只是一个 “障眼法”,是靠 whiteout 文件将其遮挡, image 层并没有删除,这也就是为什么使用 docker commit 提交保存的镜像会越来越大,无论在容器层怎么删除数据,image 层都不会改变。
从仓库 pull 一个 ubuntu 镜像,结果显示总共拉取了 4 层镜像如下:
此时 4 层被存储在了 / var/lib/docker/overlay2 / 目录下:
这里面多了一个 l 目录包含了所有层的软连接,软链接使用短名称,避免 mount 时候参数达到页面大小限制(演示中 mount 命令查看时候的短目录):
处于底层的镜像目录包含了一个 diff 和一个 link 文件,diff 目录存放了当前层的镜像内容,而 link 文件则是与之对应的短名称:
在这之上的镜像还多了 work 目录和 lower 文件,lower 文件用于记录父层的短名称,work 目录用于联合挂载指定的工作目录。而这些目录和镜像的关系是怎么组织在的一起呢?答案是通过元数据关联。元数据分为 image 元数据和 layer 元数据。
镜像元数据存储在了 / var/lib/docker/image/
查看其对应的元数据 (使用 vim :%!python -m json.tool 格式化成 json) 截取了其 rootfs 的构成:
上面的 diff_id 对应的的是一个镜像层(镜像包含多个镜像层),其排列也是有顺序的,从上到下依次表示镜像层的最低层到最顶层:
diff_id 如何关联镜像层内容?具体说来,docker 利用 rootfs 中的每个 diff_id 和历史信息计算出与之对应的内容寻址的索引 (chainID) ,而 chaiID 则关联了 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、storage_driver 存储当前镜像层文件的 cacheID、该镜像层的 size 等内容。这些元数据被保存在 /var/lib/docker/image/
每个 chainID 目录下会存在四或五个文件 cache-id、diff、size、parent(最底层的chainID不存在此文件)、tar-split.json.gz:
cache-id 文件:
docker 随机生成的 uuid,内容是保存镜像层的目录索引,也就是 / var/lib/docker/overlay2 / 中的目录,这就是为什么通过 chainID 能找到对应的 layer 目录。以 chainID 为 d801a12f6af7beff367268f99607376584d8b2da656dcd8656973b7ad9779ab4 对应的目录为 130ea10d6f0ebfafc8ca260992c8d0bef63a1b5ca3a7d51a5cd1b1031d23efd5, 也就保存在 / var/lib/docker/overlay2/130ea10d6f0ebfafc8ca260992c8d0bef63a1b5ca3a7d51a5cd1b1031d23efd5
diff 文件:
保存了镜像元数据中的 diff_id(与元数据中的 diff_ids 中的 uuid 对应)
size 文件:
保存了镜像层的大小,单位是字节。
parent文件:
parent文件存放当前layer的父layer的chainID.
注意:对于最底层的layer来说,由于没有父layer,所以没有这个文件。
tar-split.json.gz文件:
layer压缩包的是split文件,通过这个文件可以还原layer的tar包。在docker save导出image的时候会用到。详情请参考GitHub - vbatts/tar-split: checksum-reproducible tar archives (utility/library)
在 layer 的所有属性中,diffID 采用 SHA256 算法,基于镜像层文件包的内容计算得到。而 chainID 是基于内容存储的索引,它是根据当前层与所有祖先镜像层 diffID 计算出来的,具体算如下:
如果该镜像层是最底层 (没有父镜像层),该层的 diffID 便是 chainID。
该镜像层的 chainID 计算公式为 chainID(n)=SHA256(chain(n-1) diffID(n)),也就是根据父镜像层的 chainID 加上一个空格和当前层的 diffID,再计算 SHA256 校验码。
计算chainID的具体方法:$echo -n "sha256:babe7ce.....(自己补全) sha256:283fb.....(自己补全)"|sha256sum //其余chainID以此类推
mountedLayer 信息存储的可读 init 层以及容器挂载点信息包括:容器 init 层 ID(init-id)、联合挂载使用的 ID(mount-id)以及容器层的父层镜像的 chainID(parent)。相关文件位于 / var/lib/docker/image/
查看其对应的 mountedLayer 三个文件:
创建容器时,docker 会为每个容器创建两个新的 layer,一个是只读的 init layer,里面包含 docker 为容器准备的一些文件(容器中的/ etc/hostname、/etc/resolv.conf 等),另一个是容器的可写 mount layer,以后在容器里面对 rootfs 的所有增删改操作的结果都会存在这个 layer 中。
新加的这两层 layer 比较特殊,只保存在 layerdb/mounts 下面,在 layerdb/sha256 目录下没有相关信息,说明 docker 将 container 的 layer 和 image 的 layer 的元数据放在了不同的两个目录中
mount-id文件包含了mount layer的cacheid.
init-id文件包含了init layer的cacheid,init layer的cacheid就是在mount layer的cacheid后面加上了一个“-init”.init layer包含了docker为每个容器所预先准备的文件
parent里面包含的是image的最上一层layer的chainid,表示这个容器的init layer的父layer是image的最顶层layer.
可以看到 initID 是在 mountID 后加了一个 - init,同时 initID 就是存储在 / var/lib/docker/overlay2 / 的目录名称:
查看 mountID 还可以直接通过 mount 命令查看对应挂载的 mountID,对应着 / var/lib/docker/overlay2 / 目录,这也是 overlayfs 呈现的 merged 目录:
在容器中创建了一文件:
此时到宿主的 merged 目录就能看到对应的文件:
init 层是以一个 uuid+-init 结尾表示,夹在只读层和读写层之间,作用是专门存放 / etc/hosts、/etc/resolv.conf 等信息,需要这一层的原因是当容器启动时候,这些本该属于 image 层的文件或目录,比如 hostname,用户需要修改,但是 image 层又不允许修改,所以启动时候通过单独挂载一层 init 层,通过修改 init 层中的文件达到修改这些文件目的。而这些修改往往只读当前容器生效,而在 docker commit 提交为镜像时候,并不会将 init 层提交。该层文件存放的目录为 / var/lib/docker/overlay2/
这几个文件都是 Linux 运行时必须的文件,如果缺少的话会导致某些程序或者库出现异常,所以 docker 需要为容器准备好这些文件:
/dev/console: 在 Linux 主机上,该文件一般指向主机的当前控制台,有些程序会依赖该文件。在容器启动的时候,docker 会为容器创建一个 pts,然后通过 bind mount 的方式将 pts 绑定到容器里面的 / dev/console 上,这样在容器里面往这个文件里面写东西就相当于往容器的控制台上打印数据。这里创建一个空文件相当于占个坑,作为后续 bind mount 的目的路径。
hostname,hosts,resolv.conf:对于每个容器来说,容器内的这几个文件内容都有可能不一样,这里也只是占个坑,等着 docker 在外面生成这几个文件,然后通过 bind mount 的方式将这些文件绑定到容器中的这些位置,即这些文件都会被宿主机中的文件覆盖掉。
/etc/mtab:这个文件在新的 Linux 发行版中都指向 / proc/mounts,里面包含了当前 mount namespace 中的所有挂载信息,很多程序和库会依赖这个文件。
注意: 这里 mtab 指向的路径是固定的,但内容是变化的,取决于你从哪里打开这个文件,当在宿主机上打开时,是宿主机上 / proc/mounts 的内容,当启动并进入容器后,在容器中打开看到的就是容器中 / proc/mounts 的内容。
docker 将用户指定的参数和 image 配置文件中的部分参数进行合并,然后将合并后生成的容器的配置文件放在 / var/lib/docker/containers / 下面,目录名称就是容器的 ID
root@dev:/var/lib/docker/containers/967438113fba0b7a3005bcb6efae6a77055d6be53945f30389888802ea8b0368# tree
.
├── checkpoints
├── config.v2.json
└── hostconfig.json
1 directory, 2 files
config.v2.json: 通用的配置,如容器名称,要执行的命令等
hostconfig.json: 主机相关的配置,跟操作系统平台有关,如 cgroup 的配置
checkpoints: 容器的 checkpoint 这个功能在当前版本还是 experimental 状态。
checkpoints 这个功能很强大,可以在当前 node 做一个 checkpoint,然后再到另一个 node 上继续运行,相当于无缝的将一个正在运行的进程先暂停,然后迁移到另一个 node 上并继续运行。
通过以上的内容介绍,一个容器完整的层应由三个部分组成,如下图:
镜像层:也称为 rootfs,提供容器启动的文件系统
init 层: 用于修改容器中一些文件如 / etc/hostname、/etc/resolv.conf 等
容器层:使用联合挂载统一给用户提供的可读写目录。
本文介绍了以 overlayfs 作为存储驱动的的镜像存储原理其中每层的镜像数据保存在 / var/lib/docker/overlay2/
/var/lib/docker/image/aufs/imagedb/metadata:里面存放的是本地 image 的一些信息,从服务器上 pull 下来的 image 不会存数据到这个目录