前两文中,讲了Linux容器最基础的两种技术docker
做用是“隔离”,它让应用进程只能看到该Namespace内的“世界”shell
做用是“限制”,它给这个“世界”围上了一圈看不见的墙编程
这么一搞,进程就真的被“装”在了一个与世隔绝的房间里,而这些房间就是PaaS项目赖以生存的应用“沙盒”。json
还有一个问题是:墙外的咱们知道他的处境了,墙内的他呢?ubuntu
也许你会认为这是一个关于Mount Namespace的问题
容器里的应用进程,理应看到一份彻底独立的文件系统。这样,它就能够在本身的容器目录(好比/tmp)下进行操做,而彻底不会受宿主机以及其余容器的影响。小程序
那么,真实状况是这样吗?segmentfault
“左耳朵耗子”叔在多年前写的一篇关于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的隔离环境中。
咱们来一块儿编译一下这个程序:
这样,咱们就进入了这个“容器”当中。但是,若是在“容器”里执行一下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目录。
这段修改后的代码,编译执行后的结果又如何呢?咱们能够试验一下:
能够看到,此次/tmp变成了一个空目录,这意味着从新挂载生效了。咱们能够用mount -l检查一下:
能够看到,容器里的/tmp目录是以tmpfs方式单独挂载的。
更重要的是,由于咱们建立的新进程启用了Mount Namespace,因此此次从新挂载的操做,只在容器进程的Mount Namespace中有效。若是在宿主机上用mount -l来检查一下这个挂载,你会发现它是不存在的:
这就是Mount Namespace跟其余Namespace的使用略有不一样的地方:
它对容器进程视图的改变,必定是伴随着挂载操做(mount)才能生效。
可做为用户,但愿每当建立一个新容器,容器进程看到的文件系统就是一个独立的隔离环境,而不是继承自宿主机的文件系统。怎么才能作到这一点呢?
能够在容器进程启动以前从新挂载它的整个根目录“/”。
而因为Mount Namespace的存在,这个挂载对宿主机不可见,因此容器进程就能够在里面随便折腾了。
在Linux操做系统里,有一个名为
的命令, 改变进程的根目录到指定的位置
假设,咱们如今有一个$HOME/test
目录,想要把它做为一个/bin/bash
进程的根目录。
$ mkdir -p $HOME/test $ mkdir -p $HOME/test/{bin,lib64,lib} $ cd $T
$ 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/test了。
这种视图被修改的原理,是否是跟我以前介绍的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项目来讲,它最核心的原理实际上就是为待建立的用户进程:
Docker项目在最后一步的切换上会优先使用pivot_root
系统调用,若是系统不支持,才会使用chroot
这两个系统调用虽然功能相似,可是也有细微的区别
rootfs只是一个操做系统所包含的文件、配置和目录,并不包括操做系统内核。只包括了操做系统的“躯壳”,并无包括操做系统的“灵魂”。
在Linux操做系统中,这两部分是分开存放的,操做系统只有在开机启动时才会加载指定版本的内核镜像。
那么,对于容器来讲,这个
同一台机器上的全部容器,都共享宿主机操做系统的内核。
若是你的应用程序须要配置内核参数、加载额外的内核模块,以及跟内核进行直接的交互
这些操做和依赖的对象,都是宿主机操做系统的内核,它对于该机器上的全部容器来讲是一个“全局变量”,牵一发动全身。
这也是容器相比于虚拟机的主要缺陷之一
毕竟后者不只有模拟出来的硬件机器充当沙盒,并且每一个沙盒里还运行着一个完整的Guest OS给应用随便折腾。
不过,正是因为rootfs的存在,容器才有了一个被反复宣传至今的重要特性:
什么是容器的“一致性”呢?
因为云端与本地服务器环境不一样,应用的打包过程,一直是使用PaaS时最“痛苦”的一个步骤。
但有了容器镜像(即rootfs)以后,这个问题被很是优雅地解决了。
因为rootfs里打包的不仅是应用,而是整个操做系统的文件和目录,也就意味着,应用以及它运行所须要的全部依赖,都被封装在了一块儿。
事实上,对于大多数开发者而言,他们对应用依赖的理解,一直局限在编程语言层面。好比Golang的Godeps.json。
但实际上,一个一直以来很容易被忽视的事实是,对一个应用来讲,操做系统自己才是它运行所须要的最完整的“依赖库”。
有了容器镜像“打包操做系统”的能力,这个最基础的依赖环境也终于变成了应用沙盒的一部分。这就赋予了容器所谓的一致性:
不管在本地、云端,仍是在一台任何地方的机器上,用户只须要解压打包好的容器镜像,那么这个应用运行所须要的完整的执行环境就被重现出来了。
这种深刻到操做系统级别的运行环境一致性,打通了应用在本地开发和远端执行环境之间难以逾越的鸿沟。
不过,这时你可能已经发现了另外一个很是棘手的问题:难道我每开发一个应用,或者升级一下现有的应用,都要重复制做一次rootfs吗?
好比,我如今用Ubuntu操做系统的ISO作了一个rootfs,而后又在里面安装了Java环境,用来部署应用。那么,个人另外一个同事在发布他的Java应用时,显然但愿可以直接使用我安装过Java环境的rootfs,而不是重复这个流程。
一种比较直观的解决办法是,我在制做rootfs的时候,每作一步“有意义”的操做,就保存一个rootfs出来,这样其余同事就能够按需求去用他须要的rootfs了。
可是,这个解决办法并不具有推广性。缘由在于,一旦你的同事们修改了这个rootfs,新旧两个rootfs之间就没有任何关系了。这样作的结果就是极度的碎片化。
那么,既然这些修改都基于一个旧的rootfs,咱们能不能以增量的方式去作这些修改
呢?
这样作的好处是,全部人都只须要维护相对于base rootfs修改的增量内容,而不是每次修改都制造一个“fork”。
答案固然是确定的。
这也正是为什么,Docker公司在实现Docker镜像时并无沿用之前制做rootfs的标准流程,而是作了一个小小的创新:
Docker在镜像的设计中,引入了层(layer)的概念。也就是说,用户制做镜像的每一步操做,都会生成一个层,也就是一个增量rootfs。
固然,这个想法不是凭空臆造出来的,而是用到
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中生效。
个人环境是Ubuntu 16.04和Docker CE 18.05,这对组合默认使用的是AuFS这个联合文件系统的实现。
能够经过docker info命令,查看到这个信息。
AuFS的全称是Another UnionFS,后更名为Alternative UnionFS,再后来干脆更名叫做Advance UnionFS,从这些名字中你应该能看出这样两个事实:
对于AuFS来讲,它最关键的目录结构在/var/lib/docker路径下的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会把这些增量联合挂载在一个统一的挂载点上(等价于前面例子里的“/C”目录)。
这个挂载点就是/var/lib/docker/aufs/mnt/,好比:
/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/aufs/diff目录下,而后被联合挂载在/var/lib/docker/aufs/mnt里面。
并且,从这个结构能够看出来,这个容器的rootfs由以下图所示的三部分组成:
容器的rootfs最下面的五层,对应的正是ubuntu:latest镜像的五层。
它们的挂载方式都是只读的(ro+wh,即readonly+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
在没有写入文件以前,这个目录是空的。而一旦在容器里作了写操做,你修改产生的内容就会以增量的方式出如今这个层中。
若是我如今要作的,是删除只读层里的一个文件呢?
为了实现这样的删除操做,AuFS会在可读写层建立一个whiteout文件,把只读层里的文件“遮挡”起来。
好比,你要删除只读层里一个名叫foo的文件,那么这个删除操做其实是在可读写层建立了一个名叫.wh.foo的文件。这样,当这两个层被联合挂载以后,foo文件就会被.wh.foo文件“遮挡”起来,“消失”了。这个功能,就是“ro+wh”的挂载方式,即只读+whiteout的含义。我喜欢把whiteout形象地翻译为:“白障”。
因此,最上面这个可读写层的做用,就是专门用来存放你修改rootfs后产生的增量,不管是增、删、改,都发生在这里。而当咱们使用完了这个被修改过的容器以后,还可使用docker commit和push指令,保存这个被修改过的可读写层,并上传到Docker Hub上,供其余人使用;而与此同时,原先的只读层里的内容则不会有任何变化。这,就是增量rootfs的好处。
它是一个以“-init”结尾的层,夹在只读层和读写层之间
Init层是Docker项目单独生成的一个内部层,专门用来存放/etc/hosts、/etc/resolv.conf等信息。
须要这样一层的缘由是,这些文件原本属于只读的Ubuntu镜像的一部分,可是用户每每须要在启动容器时写入一些指定的值好比hostname,因此就须要在可读写层对它们进行修改。
但是,这些修改每每只对当前的容器有效,咱们并不但愿执行docker commit时,把这些信息连同可读写层一块儿提交掉。
因此,Docker作法是,在修改了这些文件以后,以一个单独的层挂载了出来。而用户执行docker commit只会提交可读写层,因此是不包含这些内容的。
最终,这7个层都被联合挂载到/var/lib/docker/aufs/mnt目录下,表现为一个完整的Ubuntu操做系统供容器使用。
本文介绍了Linux容器文件系统的实现方式。即容器镜像,也叫做:rootfs。
它只是一个操做系统的全部文件和目录,并不包含内核,最多也就几百兆。而相比之下,传统虚拟机的镜像大可能是一个磁盘的“快照”,磁盘有多大,镜像就至少有多大。
经过结合使用Mount Namespace和rootfs,容器就可以为进程构建出一个完善的文件系统隔离环境。固然,这个功能的实现还必须感谢chroot和pivot_root这两个系统调用切换进程根目录的能力。
而在rootfs的基础上,Docker公司创新性地提出了使用多个增量rootfs联合挂载一个完整rootfs的方案,这就是容器镜像中“层”的概念。
经过“分层镜像”的设计,以Docker镜像为核心,来自不一样公司、不一样团队的技术人员被紧密地联系在了一块儿。并且,因为容器镜像的操做是增量式的,这样每次镜像拉取、推送的内容,比本来多个完整的操做系统的大小要小得多;
而共享层的存在,可使得全部这些容器镜像须要的总空间,也比每一个镜像的总和要小。
这样就使得基于容器镜像的团队协做,要比基于动则几个GB的虚拟机磁盘镜像的协做要敏捷得多。
更重要的是,一旦这个镜像被发布,那么你在全世界的任何一个地方下载这个镜像,获得的内容都彻底一致,能够彻底复现这个镜像制做者当初的完整环境。这,就是容器技术“强一致性”的重要体现。
而这种价值正是支撑Docker公司在2014~2016年间迅猛发展的核心动力。容器镜像的发明,不只打通了“开发-测试-部署”流程的每个环节,更重要的是:
容器镜像将会成为将来软件的主流发布方式。
深刻剖析Kubernetes
本文由博客一文多发平台 OpenWrite 发布!