而正如我前面所说的,Namespace 的做用是“隔离”,它让应用进程只能看到该 Namespace 内的“世界”;而 Cgroups 的做用是“限制”,它给这个“世界”围上了一圈看不见的墙。这么一折腾,进程就真的被“装”在了一个与世隔绝的房间里,而这些房间就是 PaaS 项目赖以生存的应用“沙盒”。docker
但是,还有一个问题不知道你有没有仔细思考过:这个房间四周虽然有了墙,可是若是容器进程低头一看地面,又是怎样一副景象呢?shell
换句话说,容器里的进程看到的文件系统又是什么样子的呢?编程
可能你马上就能想到,这必定是一个关于 Mount Namespace 的问题:容器里的应用进程,理应看到一份彻底独立的文件系统。这样,它就能够在本身的容器目录(好比 /tmp)下进行操做,而彻底不会受宿主机以及其余容器的影响。json
那么,真实状况是这样吗?ubuntu
“左耳朵耗子”叔在多年前写的一篇关于 Docker 基础知识的博客里,曾经介绍过一段小程序。这段小程序的做用是,在建立子进程时开启指定的 Namespace。小程序
下面,咱们不妨使用它来验证一下刚刚提到的问题。bash
#define _GNU_SOURCE #include <sys/mount.h> #include <sys/types.h> #include <sys/wait.h> #include <stdio.h> #include <sched.h> #include <signal.h> #include <unistd.h> #define STACK_SIZE (1024 * 1024) static char container_stack[STACK_SIZE]; char* const container_args[] = { "/bin/bash", NULL }; int container_main(void* arg) { printf("Container - inside the container!\n"); execv(container_args[0], container_args); printf("Something's wrong!\n"); return 1; } int main() { printf("Parent - start a container!\n"); int container_pid = clone(container_main, container_stack+STACK_SIZE, CLONE_NEWNS | SIGCHLD , NULL); waitpid(container_pid, NULL, 0); printf("Parent - container stopped!\n"); return 0; }
这段代码的功能很是简单:在 main 函数里,咱们经过 clone() 系统调用建立了一个新的子进程 container_main,而且声明要为它启用 Mount Namespace(即:CLONE_NEWNS+标志)。编程语言
而这个子进程执行的,是一个“/bin/bash”程序,也就是一个 shell。因此这个 shell 就运行在了 Mount Namespace 的隔离环境中。ide
咱们来一块儿编译一下这个程序:函数
$ gcc -o ns ns.c $ ./ns Parent - start a container! Container - inside the container!
这样,咱们就进入了这个“容器”当中。但是,若是在“容器”里执行一下+ls+指令的话,咱们就会发现一个有趣的现象:/tmp 目录下的内容跟宿主机的内容是同样的。
$ ls /tmp # 你会看到好多宿主机的文件
也就是说: 即便开启了 Mount Namespace,容器进程看到的文件系统也跟宿主机彻底同样。
这是怎么回事呢?
仔细思考一下,你会发现这其实并不难理解:Mount Namespace 修改的,是容器进程对文件系统“挂载点”的认知。可是,这也就意味着,只有在“挂载”这个操做发生以后,进程的视图才会被改变。而在此以前,新建立的容器会直接继承宿主机的各个挂载点。
这时,你可能已经想到了一个解决办法:建立新进程时,除了声明要启用 Mount Namespace+以外,咱们还能够告诉容器进程,有哪些目录须要从新挂载,就好比这个 /tmp 目录。因而,咱们在容器进程执行前能够添加一步从新挂载 /tmp 目录的操做:
int container_main(void* arg) { printf("Container - inside the container!\n"); // 若是你的机器的根目录的挂载类型是 shared,那必须先从新挂载根目录 // mount("", "/", NULL, MS_PRIVATE, ""); mount("none", "/tmp", "tmpfs", 0, ""); execv(container_args[0], container_args); printf("Something's wrong!\n"); return 1; }
能够看到,在修改后的代码里,我在容器进程启动以前,加上了一句 mount(“none”,“/tmp”,“tmpfs”,0 “”) 语句。就这样,我告诉了容器以 tmpfs(内存盘)格式,从新挂载了 /tmp 目录。
这段修改后的代码,编译执行后的结果又如何呢?咱们能够试验一下:
$ gcc -o ns ns.c $ ./ns Parent - start a container! Container - inside the container! $ ls /tmp
能够看到,此次 /tmp 变成了一个空目录,这意味着从新挂载生效了。咱们能够用 mount -l 检查一下:
$ mount -l | grep tmpfs none on /tmp type tmpfs (rw,relatime)
能够看到,容器里的 /tmp 目录是以 tmpfs 方式单独挂载的。
更重要的是,由于咱们建立的新进程启用了 Mount Namespace,因此此次从新挂载的操做,只在容器进程的 Mount Namespace 中有效。若是在宿主机上用 mount -l 来检查一下这个挂载,你会发现它是不存在的:
# 在宿主机上 $ mount -l | grep tmpfs
这就是 Mount Namespace 跟其余 Namespace 的使用略有不一样的地方:它对容器进程视图的改变,必定是伴随着挂载操做(mount)才能生效。
但是,做为一个普通用户,咱们但愿的是一个更友好的状况:每当建立一个新容器时,我但愿容器进程看到的文件系统就是一个独立的隔离环境,而不是继承自宿主机的文件系统。怎么才能作到这一点呢?
不难想到,咱们能够在容器进程启动以前从新挂载它的整个根目录“/”。而因为 Mount Namespace 的存在,这个挂载对宿主机不可见,因此容器进程就能够在里面随便折腾了。
在 Linux 操做系统里,有一个名为 chroot 的命令能够帮助你在 shell 中方便地完成这个工做。顾名思义,它的做用就是帮你“change root file system”,即改变进程的根目录到你指定的位置。它的用法也很是简单。
假设,咱们如今有一个 /HOME/test 目录,想要把它做为一个 /bin/bash 进程的根目录。
首先,建立一个 test 目录和几个 lib 文件夹:
$ mkdir -p $HOME/test $ mkdir -p $HOME/test/{bin,lib64,lib} $ cd $T
而后,把 bash 命令拷贝到 test 目录对应的 bin 路径下:
$ cp -v /bin/{bash,ls} $HOME/test/bin
接下来,把 bash 命令须要的全部 so 文件,也拷贝到 test 目录对应的 lib 路径下。找到 so 文件能够用 ldd 命令:
$ T=$HOME/test $ list="$(ldd /bin/ls | egrep -o '/lib.*\.[0-9]')" $ for i in $list; do cp -v "$i" "${T}${i}"; done
最后,执行+chroot+命令,告诉操做系统,咱们将使用 /HOME/test 目录做为 /bin/bash 进程的根目录:
$ chroot $HOME/test /bin/bash
这时,你若是执行 "ls /",就会看到,它返回的都是 /HOME/test 目录下面的内容,而不是宿主机的内容。
更重要的是,对于被 chroot 的进程来讲,它并不会感觉到本身的根目录已经被“修改”成 /HOME/Ftest 了。
这种视图被修改的原理,是否是跟我以前介绍的 Linux Namespace 很相似呢?
没错! 实际上,Mount Namespace 正是基于对 chroot 的不断改良才被发明出来的,它也是 Linux 操做系统里的第一个 Namespace。
固然,为了可以让容器的这个根目录看起来更“真实”,咱们通常会在这个容器的根目录下挂载一个完整操做系统的文件系统,好比 Ubuntu16.04 的 ISO。这样,在容器启动以后,咱们在容器里经过执行 "ls /" 查看根目录下的内容,就是 Ubuntu 16.04 的全部目录和文件。
而这个挂载在容器根目录上、用来为容器进程提供隔离后执行环境的文件系统,就是所谓的“容器镜像”。它还有一个更为专业的名字,叫做:rootfs(根文件系统)。
因此,一个最多见的 rootfs,或者说容器镜像,会包括以下所示的一些目录和文件,好比 /bin,/etc,/proc 等等:
$ ls / bin dev etc home lib lib64 mnt opt proc root run sbin sys tmp usr var
而你进入容器以后执行的 /bin/bash,就是/bin 目录下的可执行文件,与宿主机的 /bin/bash 彻底不一样。
如今,你应该能够理解,对 Docker 项目来讲,它最核心的原理实际上就是为待建立的用户进程: 启用 Linux Namespace 配置;
设置指定的 Cgroups 参数; 切换进程的根目录(Change+Root)。 这样,一个完整的容器就诞生了。不过,Docker 项目在最后一步的切换上会优先使用 pivot_root 系统调用,若是系统不支持,才会使用 chroot。这两个系统调用虽然功能相似,可是也有细微的区别,这一部分小知识就交给你课后去探索了。
另外,须要明确的是,rootfs 只是一个操做系统所包含的文件、配置和目录,并不包括操做系统内核。在 Linux 操做系统中,这两部分是分开存放的,操做系统只有在开机启动时才会加载指定版本的内核镜像。
因此说,rootfs 只包括了操做系统的“躯壳”,并无包括操做系统的“灵魂”。 那么,对于容器来讲,这个操做系统的“灵魂”又在哪里呢?
实际上,同一台机器上的全部容器,都共享宿主机操做系统的内核。+这就意味着,若是你的应用程序须要配置内核参数、加载额外的内核模块,以及跟内核进行直接的交互,你就须要注意了:这些操做和依赖的对象,都是宿主机操做系统的内核,它对于该机器上的全部容器来讲是一个“全局变量”,牵一发而动全身。
这也是容器相比于虚拟机的主要缺陷之一:毕竟后者不只有模拟出来的硬件机器充当沙盒,并且每一个沙盒里还运行着一个完整的+Guest+OS+给应用随便折腾。+不过,正是因为+rootfs+的存在,容器才有了一个被反复宣传至今的重要特性:一致性。 但有了容器以后,更准确地说,有了容器镜像(即 rootfs)以后,这个问题被很是优雅地解决了。
因为 rootfs 里打包的不仅是应用,而是整个操做系统的文件和目录,也就意味着,应用以及它运行所须要的全部依赖,都被封装在了一块儿。 事实上,对于大多数开发者而言,他们对应用依赖的理解,一直局限在编程语言层面。好比 Golang 的 Godeps.json。但实际上,一个一直以来很容易被忽视的事实是,对一个应用来讲,操做系统自己才是它运行所须要的最完整的“依赖库”。
有了容器镜像“打包操做系统”的能力,这个最基础的依赖环境也终于变成了应用沙盒的一部分。这就赋予了容器所谓的一致性:不管在本地、云端,仍是在一台任何地方的机器上,用户只须要解压打包好的容器镜像,那么这个应用运行所须要的完整的执行环境就被重现出来了。+这种深刻到操做系统级别的运行环境一致性,打通了应用在本地开发和远端执行环境之间难以逾越的鸿沟。
不过,这时你可能已经发现了另外一个很是棘手的问题:难道我每开发一个应用,或者升级一下现有的应用,都要重复制做一次 rootfs 吗? 好比,我如今用 Ubuntu 操做系统的 ISO 作了一个 rootfs,而后又在里面安装了 Java 环境,用来部署个人 Java 应用。那么,个人另外一个同事在发布他的 Java 应用时,显然但愿可以直接使用我安装过 Java 环境的 rootfs,而不是重复这个流程。
一种比较直观的解决办法是,我在制做 rootfs 的时候,每作一步“有意义”的操做,就保存一个 rootfs 出来,这样其余同事就能够按需求去用他须要的 rootfs 了。
可是,这个解决办法并不具有推广性。缘由在于,一旦你的同事们修改了这个 rootfs,新旧两个 rootfs 之间就没有任何关系了。这样作的结果就是极度的碎片化。
那么,既然这些修改都基于一个旧的 rootfs,咱们能不能以增量的方式去作这些修改呢?这样作的好处是,全部人都只须要维护相对于 base rootfs 修改的增量内容,而不是每次修改都制造一个“fork”。
答案固然是确定的。 这也正是为什么,Docker 公司在实现 Docker 镜像时并无沿用之前制做 rootfs 的标准流程,而是作了一个小小的创新: Docker+在镜像的设计中,引入了层(layer)的概念。也就是说,用户制做镜像的每一步操做,都会生成一个层,也就是一个增量+rootfs。+固然,这个想法不是凭空臆造出来的,而是用到了一种叫做联合文件系统(Union+File+System)的能力。 Union File System 也叫 UnionFS,最主要的功能是将多个不一样位置的目录联合挂载(union+mount)到同一个目录下。好比,我如今有两个目录 A 和 B,它们分别有两个文件:
$ tree . ├── A │ ├── a │ └── x └── B ├── b └── x
而后,我使用联合挂载的方式,将这两个目录挂载到一个公共的目录+C+上:
$ mkdir C $ mount -t aufs -o dirs=./A:./B none ./C
这时,我再查看目录 C 的内容,就能看到目录 A 和 B 下的文件被合并到了一块儿:
$ tree ./C ./C ├── a ├── b └── x
能够看到,在这个合并后的目录 C 里,有 a、b、x 三个文件,而且 x 文件只有一份。这,就是“合并”的含义。此外,若是你在目录 C 里对 a、b、x 文件作修改,这些修改也会在对应的目录 A、B 中生效。 那么,在 Docker 项目中,又是如何使用这种 Union File System 的呢?
个人环境是 Ubuntu 16.04 和 Docker CE 18.05,这对组合默认使用的是 AuFS 这个联合文件系统的实现。你能够经过 docker info 命令,查看到这个信息。
AuFS 的全称是 Another UnionFS,后更名为 Alternative UnionFS,再后来干脆更名叫做 Advance UnionFS,从这些名字中你应该能看出这样两个事实: 它是对 Linux 原生 UnionFS 的重写和改进;
它的做者怨气好像很大。我猜是 Linus Torvalds(Linux 之父)一直不让 AuFS 进入 Linux 内核主干的缘故,因此咱们只能在 Ubuntu 和 Debian 这些发行版上使用它。 对于 AuFS 来讲,它最关键的目录结构在 /var/lib/Fdocker 路径下的 diff 目录:
/var/lib/docker/aufs/diff/<layer_id>
而这个目录的做用,咱们不妨经过一个具体例子来看一下。 如今,咱们启动一个容器,好比:
$ docker run -d ubuntu:latest sleep 3600
这时候,Docker 就会从 Docker Hub 上拉取一个 Ubuntu 镜像到本地。
这个所谓的“镜像”,实际上就是一个 Ubuntu 操做系统的 rootfs,它的内容是 Ubuntu 操做系统的全部文件和目录。不过,与以前咱们讲述的 rootfs 稍微不一样的是,Docker 镜像使用的 rootfs,每每由多个“层”组成:
$ docker image inspect ubuntu:latest ... "RootFS": { "Type": "layers", "Layers": [ "sha256:f49017d4d5ce9c0f544c...", "sha256:8f2b771487e9d6354080...", "sha256:ccd4d61916aaa2159429...", "sha256:c01d74f99de40e097c73...", "sha256:268a067217b5fe78e000..." ] }
能够看到,这个 Ubuntu 镜像,实际上由五个层组成。这五个层就是五个增量 rootfs,每一层都是 Ubuntu 操做系统文件与目录的一部分;而在使用镜像时,Docker 会把这些增量联合挂载在一个统一的挂载点上(等价于前面例子里的“/”目录)。 这个挂载点就是/var/lib/docker/aufs/Fmnt/,好比:
/var/lib/docker/aufs/mnt/6e3be5d2ecccae7cc0fcfa2a2f5c89dc21ee30e166be823ceaeba15dce645b3e
不出意外的,这个目录里面正是一个完整的+Ubuntu+操做系统:
$ ls /var/lib/docker/aufs/mnt/6e3be5d2ecccae7cc0fcfa2a2f5c89dc21ee30e166be823ceaeba15dce645b3e bin boot dev etc home lib lib64 media mnt opt proc root run sbin srv sys tmp usr var
那么,前面提到的五个镜像层,又是如何被联合挂载成这样一个完整的 Ubuntu 文件系统的呢? 这个信息记录在 AuFS 的系统目录 /sys/fs/aufs 下面。 首先,经过查看 AuFS 的挂载信息,咱们能够找到这个目录对应的 AuFS 的内部 ID(也叫:si):
$ cat /proc/mounts| grep aufs none /var/lib/docker/aufs/mnt/6e3be5d2ecccae7cc0fc... aufs rw,relatime,si=972c6d361e6b32ba,dio,dirperm1 0 0
即,si=972c6d361e6b32ba。 而后使用这个 ID,你就能够在 /sys/fs/aufs 下查看被联合挂载在一块儿的各个层的信息:
$ cat /sys/fs/aufs/si_972c6d361e6b32ba/br[0-9]* /var/lib/docker/aufs/diff/6e3be5d2ecccae7cc...=rw /var/lib/docker/aufs/diff/6e3be5d2ecccae7cc...-init=ro+wh /var/lib/docker/aufs/diff/32e8e20064858c0f2...=ro+wh /var/lib/docker/aufs/diff/2b8858809bce62e62...=ro+wh /var/lib/docker/aufs/diff/20707dce8efc0d267...=ro+wh /var/lib/docker/aufs/diff/72b0744e06247c7d0...=ro+wh /var/lib/docker/aufs/diff/a524a729adadedb90...=ro+wh
从这些信息里,咱们能够看到,镜像的层都放置在 /var/lib/docker/Faufs/diff 目录下,而后被联合挂载在 /Fvar/lib/docker/aufs/mnt 里面。 并且,从这个结构能够看出来,这个容器的 rootfs 由以下图所示的三部分组成:
第一部分,只读层。 它是这个容器的 rootfs 最下面的五层,对应的正是 ubuntu:latest 镜像的五层。能够看到,它们的挂载方式都是只读的(ro%+wh,即 readonly+whiteout,至于什么是 whiteout,我下面立刻会讲到)。
这时,咱们能够分别查看一下这些层的内容:
$ ls /var/lib/docker/aufs/diff/72b0744e06247c7d0... etc sbin usr var $ ls /var/lib/docker/aufs/diff/32e8e20064858c0f2... run $ ls /var/lib/docker/aufs/diff/a524a729adadedb900... bin boot dev etc home lib lib64 media mnt opt proc root run sbin srv sys tmp usr var
能够看到,这些层,都以增量的方式分别包含了 Ubuntu 操做系统的一部分。
第二部分,可读写层。 它是这个容器的+rootfs+最上面的一层(6e3be5d2ecccae7cc),它的挂载方式为:rw,即+read write。在没有写入文件以前,这个目录是空的。而一旦在容器里作了写操做,你修改产生的内容就会以增量的方式出如今这个层中。
但是,你有没有想到这样一个问题:若是我如今要作的,是删除只读层里的一个文件呢?
为了实现这样的删除操做,AuFS 会在可读写层建立一个 whiteout 文件,把只读层里的文件“遮挡”起来。
好比,你要删除只读层里一个名叫 foo 的文件,那么这个删除操做其实是在可读写层建立了一个名叫.wh.foo 的文件。这样,当这两个层被联合挂载以后,foo 文件就会被.wh.foo 文件“遮挡”起来,“消失”了。这个功能,就是“ro+wh”的挂载方式,即只读+whiteout 的含义。我喜欢把 whiteout 形象地翻译为:“白障”。
因此,最上面这个可读写层的做用,就是专门用来存放你修改+rootfs+后产生的增量,不管是增、删、改,都发生在这里。而当咱们使用完了这个被修改过的容器以后,还可使用 docker commit 和 push 指令,保存这个被修改过的可读写层,并上传到 Docker Hub 上,供其余人使用;而与此同时,原先的只读层里的内容则不会有任何变化。这,就是增量 rootfs 的好处。
第三部分,Init 层。 它是一个以“-init”结尾的层,夹在只读层和读写层之间。Init 层是 Docker 项目单独生成的一个内部层,专门用来存放 /etc/hosts、/etc/resolv.conf 等信息。
须要这样一层的缘由是,这些文件原本属于只读的 Ubuntu 镜像的一部分,可是用户每每须要在启动容器时写入一些指定的值好比 hostname,因此就须要在可读写层对它们进行修改。
但是,这些修改每每只对当前的容器有效,咱们并不但愿执行 docker commit 时,把这些信息连同可读写层一块儿提交掉。
因此,Docke 作法是,在修改了这些文件以后,以一个单独的层挂载了出来。而用户执行 docker commit 只会提交可读写层,因此是不包含这些内容的。
最终,这 7 个层都被联合挂载到/var/lib/docker/aufs/mnt 目录下,表现为一个完整的 Ubuntu 操做系统供容器使用。