Docker 原理剖析(三)rootfs

文章目录

前言

我们之前介绍过了 Linux 最基础的两种技术,Namespace 和 Cgroups。Namespace 的作用是隔离,它可以让进程只能看到 Namespace 里面的世界;Cgroups 的作用是限制,给这个世界围上了一堵墙。这样,这个进程便真的与世隔绝了。

现在世界已经有了墙,那么如果容器低头看向了地面,它能看到什么呢?或者说,容器里的进程看到的文件系统又是什么样子的呢?

Change Root

你可能会说这是一个 Mount Namespace 的问题,容器里的应用进程,理应看到一份完全独立的文件系统。这样,它就可以在自己的容器目录下进行操作,而完全不会受宿主机以及其他容器的影响。

真的嘛?当然是假的,即使开启了 Mount Namespace,容器进程看到的文件系统也跟宿主机完全一样。

为什么呢?其实,Mount Namespace 修改的,是容器进程对文件系统挂载点的认知。但是,这也就意味着,只有在挂载这个操作发生之后,进程的视图才会被改变。而在此之前,新创建的容器会直接继承宿主机的各个挂载点。

也许你会说,在创建新进程时,除了声明要启用 Mount Namespace 之外,我们还可以告诉容器进程,有哪些目录需要重新挂载。

更重要的是,因为我们创建的新进程启用了 Mount Namespace,所以这次重新挂载的操作,只在容器进程的 Mount Namespace 中有效。如果在宿主机检查这个挂载,你会发现它其实是不存在的。

这就是 Mount Namespace 跟其他 Namespace 的使用略有不同的地方:它对容器进程视图的改变,一定是伴随着挂载操作才能生效。

如果我们希望它更加友好,每当创建一个新容器时,我希望容器进程看到的文件系统就是一个独立的隔离环境,而不是继承自宿主机的文件系统。这应该怎么做?

很简单,我们可以在容器进程启动之前重新挂载它的整个根目录“/”。而由于 Mount Namespace 的存在,这个挂载对宿主机不可见。

在 Linux 操作系统里,有一个名为 chroot 的命令可以帮助你在 shell 中方便地完成这个工作。顾名思义,它的作用就是帮你改变进程的根目录到你指定的位置。对于被 chroot 的进程来说,它并不会感受到自己的根目录已经被修改。

一般来说,为了能够让容器的这个根目录看起来更真实,我们一般会在这个容器的根目录下挂载一个完整操作系统的文件系统,所以我们在容器中通过 ls 命令查看根目录下的内容时可以看到操作系统的所有目录和文件。

而这个挂载在容器根目录上、用来为容器进程提供隔离后执行环境的文件系统,就是所谓的容器镜像。它还有一个更为专业的名字,叫作:rootfs(根文件系统)。

所以,一个最常见的 rootfs,或者说容器镜像,会包括一些目录和文件,比如 /bin,/etc,/proc 等等。而你进入容器之后执行的 /bin/bash,就是 /bin 目录下的可执行文件,与宿主机的 /bin/bash 完全不同。

总结一下,Docker 最核心的原理实际上就是为待创建的用户进程:

  1. 启用 Linux Namespace 配置
  2. 设置指定的 Cgroups 参数
  3. 切换进程的根目录(Change Root)

rootfs

rootfs 是一个操作系统所包含的文件、配置和目录,并不包括操作系统内核。在 Linux 操作系统中,这两部分是分开存放的,操作系统只有在开机启动时才会加载指定版本的内核镜像。

所以说,rootfs 只包括了操作系统的躯壳,并没有包括操作系统的灵魂。那么,灵魂在哪里呢?实际上,同一台机器上的所有容器,都共享宿主机操作系统的内核。这意味着,当应用程序需要配置内核参数、加载额外的内核模块,以及跟内核进行直接的交互时,这些操作和依赖的对象,都是宿主机操作系统的内核,它对于该机器上的所有容器来说是一个全局变量,牵一发而动全身。

由于有了 rootfs 的存在,容器便有了一个重要特性 – 一致性。什么是一致性呢?之前没有容器的时候,由于云端与本地服务器环境不同,应用的打包过程异常困难。但有了 rootfs 之后,这个问题便被优雅地解决了。由于 rootfs 里打包的不只是应用,而是整个操作系统的文件和目录,也就意味着,应用以及它运行所需要的所有依赖,都被封装在了一起。

现在,你可能会有一个新的问题,每开发一个应用,都要重复制作一次 rootfs 吗?

其实并不是这样的,我们可以用增量的方式去做修改。Docker 在镜像的设计中,引入了层(layer)的概念。也就是说,用户制作镜像的每一步操作,都会生成一个层,也就是一个增量 rootfs。

它的原来源于联合文件系统,主要的功能是将多个不同位置的目录联合挂载到同一个目录下。例如,我有一个 A 目录(有 a 文件与 b 文件),一个 B 目录(有 a 文件与 c 文件)。然后,我们用联合挂载的方式把这两个目录挂载到 C 目录下(有 a 文件,b 文件,c 文件),可见此时 A 目录与 B 目录的文件被合并到了一起。

我们可以看一个 Ubuntu 镜像,实际上它是 Ubuntu 操作系统的 rootfs,包含了 Ubuntu 操作系统的所有文件和目录。不过这个 rootfs,由多个层组成,每一个层都是一个增量 rootfs,每一层都是 Ubuntu 操作系统文件与目录的一部分。在使用镜像时,Docker 会把这些增量联合挂载在一个统一的挂载点上,这个挂载点就是 /var/lib/docker/aufs/mnt/。(镜像的层都放置在 /var/lib/docker/aufs/diff 目录下)

rootfs 的组成

rootfs 由三部分组成,由上往下分别是:可读写层,init 层,只读层。

我们以之前使用的 Ubuntu 镜像为例。
Docker 原理剖析(三)rootfs
只读层是容器的 rootfs 的下五层,它们的挂载方式都是只读的,可见这些层都以增量的方式分别包含了 Ubuntu 操作系统的一部分。

可读写层是容器的 rootfs 的最上面一层,在没有写入文件之前,这个目录是空的。而一旦在容器里做了写操作,你修改产生的内容就会以增量的方式出现在这个层中。但是,如果我现在要做的,是删除只读层里的一个文件呢?为了实现这样的删除操作,会在可读写层创建一个 whiteout 文件,把只读层里的文件遮挡起来。比如,你要删除只读层里一个名叫 foo 的文件,那么这个删除操作实际上是在可读写层创建了一个名叫.wh.foo 的文件。这样,当这两个层被联合挂载之后,foo 文件就会被.wh.foo 文件遮挡起来,消失了。综上所述,最上面这个可读写层的作用,就是专门用来存放你修改 rootfs 后产生的增量,无论是增、删、改,都发生在这里,而原先的只读层里的内容则不会有任何变化。

Init 层在只读层与可读写层的中间,是 Docker 项目单独生成的一个内部层,专门用来存放 /etc/hosts、/etc/resolv.conf 等信息。需要这样一层的原因是,这些文件本来属于只读的 Ubuntu 镜像的一部分,但是用户往往需要在启动容器时写入一些指定的值比如 hostname,所以就需要在可读写层对它们进行修改。可是,这些修改往往只对当前的容器有效,我们并不希望执行 docker commit 时,把这些信息连同可读写层一起提交掉。所以,Docker 做法是,在修改了这些文件之后,以一个单独的层挂载了出来。而用户执行 docker commit 只会提交可读写层,所以是不包含这些内容的。

最终,这 7 个层都被联合挂载到 /var/lib/docker/aufs/mnt 目录下,表现为一个完整的 Ubuntu 操作系统供容器使用。

数据卷的原理

容器技术使用了 rootfs 机制和 Mount Namespace,构建出了一个同宿主机完全隔离开的文件系统环境。那么,容器里进程新建的文件,怎么才能让宿主机获取到?又或者说,宿主机上的文件和目录,怎么才能让容器里的进程访问到?这就需要使用 Volume 机制,将宿主机上指定的目录或者文件,挂载到容器里面进行读取和修改操作。

现在有一个问题,Docker 是如何做到把一个宿主机上的目录或者文件,挂载到容器里面去呢?

我们之前说过,容器进程被创建之后,尽管开启了 Mount Namespace,但是在它执行 chroot 之前,容器进程一直可以看到宿主机上的整个文件系统。而宿主机上的文件系统,也自然包括了我们要使用的容器镜像。这个镜像的各个层,保存在 /var/lib/docker/aufs/diff 目录下,在容器进程启动后,它们会被联合挂载在 /var/lib/docker/aufs/mnt/ 目录中,这样容器所需的 rootfs 就准备好了。所以,我们只需要在 rootfs 准备好之后,在执行 chroot 之前,把 Volume 指定的宿主机目录(比如 /home 目录),挂载到指定的容器目录(比如 /test 目录)在宿主机上对应的目录(即 /var/lib/docker/aufs/mnt/[可读写层 ID]/test)上,这个 Volume 的挂载工作就完成了。

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

那么,什么是挂载呢?

绑定挂载实际上是一个 inode 替换的过程。在 Linux 操作系统中,inode 可以理解为存放文件内容的对象,而 dentry,也叫目录项,就是访问这个 inode 所使用的指针。
Docker 原理剖析(三)rootfs
正如上图所示,mount --bind /home /test,会将 /home 挂载到 /test 上。其实相当于将 /test 的 dentry,重定向到了 /home 的 inode。这样当我们修改 /test 目录时,实际修改的是 /home 目录的 inode。这也就是为何,一旦执行 umount 命令,/test 目录原先的内容就会恢复:因为修改真正发生在的,是 /home 目录里。

所以,在一个正确的时机,进行一次绑定挂载,Docker 就可以成功地将一个宿主机上的目录或文件,不动声色地挂载到容器中。这样,进程在容器里对这个 /test 目录进行的所有操作,都实际发生在宿主机的对应目录(比如,/home,或者 /var/lib/docker/volumes/[VOLUME_ID]/_data)里,而不会影响容器镜像的内容。

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

也不会。容器的镜像操作,比如 docker commit,都是发生在宿主机空间的。而由于 Mount Namespace 的隔离作用,宿主机并不知道这个绑定挂载的存在。所以,在宿主机看来,容器中可读写层的 /test 目录(/var/lib/docker/aufs/mnt/[可读写层 ID]/test),始终是空的。

不过,由于 Docker 一开始还是要创建 /test 这个目录作为挂载点,所以执行了 docker commit 之后,你会发现新产生的镜像里,会多出来一个空的 /test 目录。毕竟,新建目录操作,又不是挂载操作,Mount Namespace 对它可起不到障眼法的作用。

docker commit 的原理

docker commit,实际上就是在容器运行起来后,把最上层的可读写层,加上原先容器镜像的只读层,打包组成了一个新的镜像。当然,下面这些只读层在宿主机上是共享的,不会占用额外的空间。

而由于使用了联合文件系统,你在容器里对镜像 rootfs 所做的任何修改,都会被操作系统先复制到这个可读写层,然后再修改。这就是所谓的:Copy-on-Write。

最后,我想用一句话结束本篇文章:

继Namespace构建了四周的围墙(进程隔离),Cgroups构建了受控的天空优先使用阳光雨露(资源限制),Mount namespace与rootfs构建了脚下的大地,这片土地是你熟悉和喜欢的,不管你走到哪里,都可以带着它,就好像你从未离开过家乡,没有丝毫的陌生感(容器的一致性)~ (@Jeff.W 的评论)

07 | 白话容器基础(三):深入理解容器镜像

上一篇:docker镜像之base镜像


下一篇:在tinycolinux上组建子目录引导和混合32位64位的rootfs系统