![934acf6862c819b0a352328897c7705c.png](https://i-blog.csdnimg.cn/blog_migrate/10a63701d3c655af5303600725dec948.png)
经过上次学习的容器镜像命令,相信大家已经可以通过 Dockerfile 的语法糖构建自己的镜像了。知道怎么用还不够,还要了解它是如何实现的。
今天从以下三个问题入手,来深入了解容器镜像的设计与实现。
- 容器镜像是如何保证运行环境的一致性的呢?
- 容器镜像为什么要使用分层存储,分层存储的本质是什么?
- 在容器内修改文件时,发生了什么?
我们都知道,容器是利用镜像来保证应用生命周期的环境一致性,即保证应用在本地、云端或是其他机器上运行所需完整的执行环境的一致性,说白了就是避免了在不同的环境上出现“在我本地没问题啊”的这种情况。
那么容器镜像是如何保证应用的运行环境的一致性呢?
应用在运行时,依赖的不仅仅是编程语言方面的一些依赖库,如 Nodejs 下处理 Excel 的 xlsx 包,最根本的依赖其实是操作系统本身。想要保证应用运行环境的一致性,需要将操作系统也一起“打包”到镜像中。实际上,镜像“打包操作系统“的操作,只会对操作系统所包含的文件、配置和目录”打包“,并不包括操作系统的内核。这就意味着,同一台机器上的所有容器,其实都是共享宿主机操作系统的内核。当你的应用需要和操作系统内核做一些交互时,需要格外注意,例如:容器使用的内核和宿主机内核不一致,容器内修改宿主机操作系统的内核导致宿主机上的内核信息改变(可能会影响其他容器)。所幸的是,对于大部分应用程序而言,不会依赖操作系统的内核。
容器镜像为什么要使用分层存储,分层存储的本质是什么?
让我们设想这样一个场景,你的同事 A 在 Ubuntu 上,安装了 Node 环境,来部署他/她写的 Node 应用。而你也使用 Node 新建了另一个应用,在你需要制作镜像的时候,肯定会想要直接使用他/她安装好环境的镜像,而不是自己再重复之前的操作。
于是 Docker 在镜像的设计中,引入了层(layer)的概念。制作镜像的每一步都会生成一个对应的层,每一层构建完就不会再发生改变,后一层以一种增量的方式对前一层做一些修改。这样一来,只需要维护增量修改的内容即可,而不是全部的镜像。在最终的镜像中可以看到全部层的文件,这样的描述像不像“将多个文件夹下的文件统一放在另一个文件夹中”,这就是联合文件系统(Union File System)。Overlay2 是 Ubuntu 上最新的 Docker CE 版本 18.06.0 上的默认存储驱动,如果没有的话,则默认的存储驱动为 aufs。
让我们来下载一个 Ubuntu 镜像,看看 Docker 使用的存储驱动 overlay2 是如何将层(layer)组成一个镜像(image)的,顺便复习一下 docker 的基本命令。
# docker pull ubuntu 默认拉取 latest
docker pull ubuntu:版本号
![9e189eaceaa1a08b6c80748b5bfb9811.png](https://i-blog.csdnimg.cn/blog_migrate/71674aa08ff0a42d1bf53bf0d24b18fc.jpeg)
# 查看 docker 使用的存储驱动
docker info | grep Storage
# Storage Driver: overlay2 存储驱动为 overlay2
如果你使用的存储驱动不是 overlay2,存储镜像的路径是不同的,具体可以查看官网。
Docker storage driversdocs.docker.com![f40de4b6a7510ff297eabed69a626ed1.png](https://i-blog.csdnimg.cn/blog_migrate/a31283f110413716d3b2e43f49a094d1.png)
# 查看目录结构
tree -L 3 /var/lib/docker/overlay2
![9e01863954962ff38981bf32dbdd1fdb.png](https://i-blog.csdnimg.cn/blog_migrate/2471bbe86d43e4ae3ac277a4c99c1749.jpeg)
l(L 的小写)文件夹下有四个软链接,分别链接到和它同级文件夹下的 diff 文件夹。这些软链接用于避免在使用 mount 命令的参数时达到页面大小的限制。
其他四个文件夹,代表镜像的四“层”(layer)。每一层都包含了”该层独有的文件”(diff) 以及”软链接信息”(link 文件中的内容)。
cf5c... 文件夹是最下层的 layer,它只有一个 diff 文件夹和一个 link 文件,cat link 可以看到其内容和 l(L 的小写)下指向 diff 文件夹的软链接相同(74NC...)。
从第二层开始,每一层都包含 diff、link,lower 文件包含了在它之上的全部层的软链接信息,work 文件夹包含了 OverlayFS 内部使用的一些信息。
我们随便查看某层中的 lower 文件
cat /var/lib/docker/overlay2/5b29.../lower
# 输出信息
l/WBADA3IZNSBSGMQ7EM453HYCCF:l/7KCYJDJHEJJSENTEOX5K6ISZ6C:l/74NCM7VTANC7WKNES722GTSY3Z
由 lower 文件中的内容可以看出,74NC... 为最底层,7KCY... 为倒数第二层,WBAD... 为倒数第三层,剩下的 CUGU... 为最顶层。
显然,这四层就是镜像层,它的挂载方式为只读(ro+wh)。(wh 会在后面有详细的解释)
现在用这个镜像将容器启动起来。
docker run -d ubuntu:latest sleep 10000
# 再次查看目录结构
tree -L 3 /var/lib/docker/overlay2
![86c1ce0f40bd22d1f9cc6bea7582de71.png](https://i-blog.csdnimg.cn/blog_migrate/94500e9d29e0037261ab9b1c5338ee8a.jpeg)
可以看到将容器启动后,多了两层,一个为 67d9... ,一个为 67d9... - init。
利用 lower 文件中的内容,来判断下这两层之间的顺序关系。cat 67d9 .../lower,发现内容为【l/5TVT... :l/CUGU... :l/WBADA...:l/7KCY... :l/74NC... 】,其中 5TVT... 是 67d9... - init 层的软链接标识,所以 67d9... 在 67d9... - init 之上。
67d9... 是整个容器的最上层,称为容器层,它的挂载方式为读写(rw)。相比其他层而言,在它下面多了一个 merged 文件夹,merged 是容器层和镜像层的联合挂载目录,容器内的视角就是 merged 目录下的内容。
67d9... - init 层夹在容器层和镜像层之间。Init 层是 Docker 项目单独生成的一个内部层,专门用来存放 /etc/hosts、/etc/resolv.conf 等配置信息。某些配置信息是属于只读的 Ubuntu 镜像的一部分,但是用户往往需要在启动容器时写入一些指定的值比如 hostname,这就需要能在可读写层对它们进行修改。这些配置信息只针对当前容器有效,利用单独的层将这些信息挂载出来,当用户执行 docker commit 时,只会提交可读写层,而不会将这些配置信息一同提交。
关于 overlay2 更详细的内容可以查看如下的官方文档。
Use the OverlayFS storage driverdocs.docker.com![f40de4b6a7510ff297eabed69a626ed1.png](https://i-blog.csdnimg.cn/blog_migrate/a31283f110413716d3b2e43f49a094d1.png)
在容器内修改文件时,发生了什么?
让我们直接进入到容器内部,试着做一些操作。
# 进入容器前,在镜像层创建一个 test-game 文件
touch /var/lib/docker/overlay2/cf5c.../diff/usr/games/test-game
# 进入容器内部
docker run -i -t ubuntu:latest /bin/bash
# 在容器内部中
# 创建 /home/test-file 文件
touch /home/test-file
此时新开个终端查看文件变化情况。
![d873066f6bd1bfadb1b39e37fe11521e.png](https://i-blog.csdnimg.cn/blog_migrate/7ac3327d72c96aded0c0404f0bccf40f.jpeg)
新建容器并进入到容器内部后,会新增一套 init 层和容器层,也就是 b88a...- init 和 b88a...。经过我们上面一系列的折腾,不禁会想以下几个问题:
- 在容器内部中创建的 /home/test-file 文件,存放在容器层还是镜像层?
- 在镜像层中手动添加的 test-game 文件是否能在容器里看到呢?
- 在容器中对 test-game 修改时,镜像层的 test-game 文件会发生变化吗?在容器中对镜像层的文件修改时,发生了什么?
我们知道镜像层中的 cf5c... 层是包括 /home 和 /usr/games 文件夹的,容器层 b88a... 也包括 /home 和 /usr/games,所以我们着重来看这两层。
# 查看镜像层 /home 的内容
ls cf5c.../diff/home # 没有 test-file 文件
# 查看容器层 /home 的内容
ls b88a.../diff/home # 有 test-file 文件
很显然,在容器内部新创建的文件 /home/test-file 只存储在容器层,镜像层没有改动。再来看看,在容器中修改 test-game 文件之后,容器层和镜像层是怎样的场景。
# 在容器中,查看 /usr/games 中的内容
ls /usr/games # 可以看到之前创建的 test-game 文件
# 在容器中,修改 test-game 文件内容
echo "I am a test game." > /usr/games/test-game
# 在容器中,查看 test-game 文件内容
cat /usr/games/test-game # I am a test game.
在新开的终端中查看文件的变化。
# 查看镜像层 /usr/games 中的内容
ls cf5c.../diff/usr/games # 有 test-game 文件
# 查看镜像层 /usr/games/test-game 文件内容
cat cf5c.../diff/usr/games/test-game # 文件内容为空
# 查看容器层 /usr/games 中的内容
ls b88a.../diff/usr/games # 有 test-game 文件
# 查看容器层 /usr/games/test 中的内容
cat b88a.../diff/user/games/test-game # I am a test game.
当容器层和镜像层同时存在 test-game 文件时,容器读取的是容器层中 test-game 的内容。镜像层的 test-game 没有改动,而在容器层中新增了一个 test-game 文件,对 test-game 的修改发生在容器层,这就是 copy-on-write 。找到镜像层中的文件复制到容器层中,在容器层进行修改。
如果在容器中删除 test-game 文件,真的将文件完全删除了吗?
# 在容器中,删除 test-game 文件
rm /usr/games/test-game
ls /usr/games # 删除成功,没有 test-game 文件
在新开的终端中查看文件变化。
# 查看镜像层 /usr/games 中的内容
ls cf5c.../diff/usr/games # 有 test-game 文件
# 查看镜像层 /usr/games/test-game 文件内容
cat cf5c.../diff/usr/games/test-game # 文件内容为空
# 查看容器层 /usr/games 的内容
ls b88a.../diff/usr/games # 有 test-game 文件
# 查看容器层 /usr/games/test-game 的内容
cat b88a.../diff/user/games/test-game # !报错 No such device or address
# 查看容器层 /usr/games/test-game 的详细属性
stat b88a.../diff/user/games/test-game # 可以看到文件类型是 character special file
我们能够猜到,镜像层中 test-game 仍旧存在且没有发生变化,这也与我们看到的现象相符。但是容器中已经看不到的 test-game 文件,在镜像层中仍旧存在,而之前已经验证过,如果容器层中不存在的文件在镜像层中存在,在容器中也能够看到这个文件。这么看来,一定是容器层中 test-game 搞的鬼了。没错,在容器层中的 test-game 是一个 whiteout 文件,会把镜像层的 test-game “遮盖”起来,这样在容器中就看不到 test-game 了。如果删除镜像层目录的话, 会在容器层创建 opaque 目录,它和whiteout 文件有相同的效果。当然,如果是删除存在容器层而镜像层中没有的文件,就会直接删除容器层的文件,而不需要 whiteout 文件“遮盖”。
总结一下,在容器中对文件进行操作时,发生了什么
读取文件时
- 容器层存在这个文件,直接读取容器层的文件。
- 容器层不存在这个文件,会从镜像层读取。
- 容器层和镜像层都存在这个文件,还是会直接读取容器层的文件。容器层的文件会遮盖镜像层的文件。
写/删/重命名文件时
- 写文件时,如果容器层存在这个文件,则直接在容器层修改。
- 写文件时,如果容器层不存在这个文件,而镜像层存在,会将镜像层的这个文件完全复制到容器层。之后再对此文件进行修改时,直接在容器层的复制文件中修改。
- 删除文件时,会在容器层创建一个 whiteout 文件,把镜像层的文件“遮挡”起来。镜像层的文件仍旧存在。
- 删除目录时,会在容器层创建一个 opaque 文件夹,把镜像层的文件夹“遮挡”起来。镜像层的文件夹仍旧存在。
- 重命名文件夹时,只有在源路径和目标路径都在容器层,才能成功。
具体请参见
Use the OverlayFS storage driverdocs.docker.com![f40de4b6a7510ff297eabed69a626ed1.png](https://i-blog.csdnimg.cn/blog_migrate/a31283f110413716d3b2e43f49a094d1.png)
经过一系列的摸索和实践,我们大致了解了容器镜像分层存储的本质是什么(UnionFS)、在容器内部对镜像文件的“折腾”是怎么做到不会影响镜像的(copy-on-write)。知道容器在自己的“小世界”里能够看到什么(merged 联合挂载目录),看到的规则是什么。下一篇我们来看看,容器是如何拥有自己的“小世界”的 — Namespace 和 CGroups。