深入篇(5):volume

1,587 阅读5分钟

容器技术使用了 rootfs 机制和 Mount Namespace,构建出了一个同宿主机完全隔离开的文件系统环境。这时我们就需要考虑这样两个问题:

  1. 容器里进程新建的文件,怎么才能让宿主机获取到?
  2. 宿主机上的文件和目录,怎么才能让容器里的进程访问到?

这正是 Docker Volume 要解决的问题:Volume 机制,允许你将宿主机上指定的目录或者文件,挂载到容器里面进行读取和修改操作。

那么,Docker 又是如何做到把一个宿主机上的目录或者文件,挂载到容器里面去呢?

当容器进程被创建之后,尽管开启了 Mount Namespace,但是在它执行 chroot 之前,容器进程一直可以看到宿主机上的整个文件系统。

而宿主机上的文件系统,也包括了我们要使用的容器镜像。这个镜像的各个可以在lowerDir中找到,在容器进程启动后,它们会被联合挂载在 /var/lib/docker/overlay2/xxx/merged 目录中,这样容器所需的 rootfs 就准备好了。

docker image inspect resource_monitor:0.3	
...	
"GraphDriver": {
            "Data": {
                "LowerDir": "/var/lib/docker/overlay2/8zluiov26yhy96sz3aviu93qu/diff:/var/lib/docker/overlay2/feqypcrm1mjsg9zqziftgp7t3/diff:/var/lib/docker/overlay2/a5m5rxcli1kauedyx7yqzly17/diff:/var/lib/docker/overlay2/8uoxp60prqu0wfso93wjqjds5/diff:/var/lib/docker/overlay2/9ixnorz9yrl3gxwpku89ktw2m/diff:/var/lib/docker/overlay2/xx4fdjpjqbbebgamdzfge11i3/diff:/var/lib/docker/overlay2/d95e9dd8b4ab3103a62246eafe0ce668362472b234d61d363135ec5018ff823e/diff",
                "MergedDir": "/var/lib/docker/overlay2/jsvw4qtm14w4s2l0dsgjzxpap/merged",
                "UpperDir": "/var/lib/docker/overlay2/jsvw4qtm14w4s2l0dsgjzxpap/diff",
                "WorkDir": "/var/lib/docker/overlay2/jsvw4qtm14w4s2l0dsgjzxpap/work"
            },

只需要在 rootfs 准备好之后,在执行 chroot 之前,把 Volume 指定的宿主机目录挂载到指定的容器目录,这个 Volume 的挂载工作就完成了。

由于执行挂载操作时,“容器进程”已经创建了,也就意味着此时 Mount Namespace 已经开启了。所以,这个挂载事件只在这个容器里可见。宿主机是看不见容器内部的这个挂载点的。这就保证了容器的隔离性不会被 Volume 打破

注意:这里提到的 " 容器进程 ",是 Docker 创建的一个容器初始化进程 (dockerinit),而不是应用进程 (ENTRYPOINT + CMD)。dockerinit 会负责完成根目录的准备、挂载设备和目录、配置 hostname 等一系列需要在容器内进行的初始化操作。最后,它通过 execv() 系统调用,让应用进程取代自己,成为容器里的 PID=1 的进程。

这里使用的挂载技术是 Linux 的绑定挂载(bind mount)机制。它允许你将一个目录或文件,而不是整个设备,挂载到一个指定的目录上。并且这时你在该挂载点上进行的任何操作,只是发生在被挂载的目录或者文件上,而原挂载点的内容则会被隐藏起来且不受影响。

绑定挂载实际上是一个 inode 替换的过程。在 Linux 中,inode 可以理解为存放文件内容的“对象”,而 dentry,也叫目录项,就是访问这个 inode 所使用的“指针”。

image.png

mount --bind /home /test,会将 /home 挂载到 /test 上。其实相当于将 /test 的 dentry 重定向到 /home 的 inode。这样修改 /test 目录时,实际修改的是 /home 目录的 inode。这也就是为何执行 umount 命令,/test 目录原先的内容就会恢复:因为修改真正发生在的,是 /home 目录里。

所以,在一个正确的时机,进行一次绑定挂载,Docker 就可以成功地将一个宿主机上的目录或文件,不动声色地挂载到容器中。

这样,进程在容器里对这个 /test 目录进行的所有操作,都实际发生在宿主机的对应目录里,而不会影响容器镜像的内容。

test 目录里的内容,既然挂载在容器 rootfs 的可读写层,它会不会被 docker commit 提交掉呢?

不会。

容器的镜像操作,比如 docker commit,是发生在宿主机空间的。由于 Mount Namespace 的隔离作用,宿主机并不知道这个绑定挂载的存在。在宿主机看来,容器中可读写层的 /test 目录始终是空的。

不过由于一开始还是要创建 /test 目录作为挂载点,所以 commit 后新镜像里会多出来一个空的 /test 目录。

结合以上的讲解,我们现在来亲自验证一下:

首先,启动一个 helloworld 容器,给它声明一个 Volume,挂载在容器里的 /test 目录上:

$ docker run -d -v /test hello-world

cf53b766fa6f

容器启动之后,我们来查看一下这个 Volume 的 ID:

$ docker volume ls

DRIVER              VOLUME NAME
local               cb1c2f7221fa9b0971cc35f68aa1034824755ac44a034c0c0a1dd318838d3a6d

然后,使用这个 ID,可以找到它在 Docker 工作目录下的 volumes 路径:

$ ls /var/lib/docker/volumes/cb1c2f7221fa/_data/

这个 _data 文件夹,就是容器的 Volume 在宿主机上对应的临时目录了。

我们在容器的 Volume 里添加一个文件 text.txt:

docker exec -it cf53b766fa6f /bin/sh

cd test/
touch text.txt

这时,我们再回到宿主机,就会发现 text.txt 已经出现在了宿主机上对应的临时目录里:

ls /var/lib/docker/volumes/cb1c2f7221fa/_data/
	
text.txt

可是,如果你在宿主机上查看该容器的可读写层,虽然可以看到这个 /test 目录,但其内容是空的

可以确认,容器 Volume 里的信息,并不会被 docker commit 提交掉;但这个挂载点目录 /test 本身,则会出现在新的镜像当中。