我们已经知道了Linux容器中两个基础的技术Namespace和Cgroups,而且说明了,容器本质上是一种特殊的进程
Namespace的作用是隔离,让应用只能看到Namespace内的世界,Cgroups的作用是限制,装了一道道的栅栏,但是容器的立足之地,是什么样的呢?
容器能够看到的文件系统是什么样的呢?
这就需要说道我们之前说的Mount Namespace的问题,容器的应用进程,应该看到的是自己的独立的文件系统,不会受到宿主机或者影响到宿主机
但是,可以直接说的是,即使开启了Mount Namespace,看到的文件系统和宿主机也完全一样
因为,Mount Namespace修改的,是容器进程对文件系统挂载点的认识-只有挂载这个操作发生后,进程的试图才会被改变,在此之前,新创建的容器都会直接继承宿主机的各个挂载点
创建新的进程的时候,出了声明需要启动 Mount Namespace之外,还需要告诉容器进程,有哪些目录需要重新加载,比如/tmp目录
我们在进程执行前添加一步重新挂载/tmp目录的操作
我们让容器以tmpfs内存盘的格式,重新挂载了/tmp目录
那么再次查看/tmp,会变成了一个空目录,这样重新挂载生效了,在容器中用mount -l 检查一下,可以查看挂载
宿主机会发现看不见挂载的信息,我们创建的新进程启动了Mount Namespace,重新挂载的操作,只在容器进程的Mount Namespace中有效.如果在宿主机用mount -l来检查挂载,是发现不存在的
这就是Mount Namespace和其他的Namespace的使用略有不同的地方,对容器进程视图的改变,一定是伴随着挂载操作mount才能生效
但我们希望容器进程看到的文件系统就是一个单独的隔离环境,不是继承自宿主机的文件系统,如何能做到呢?
不难想到,我们可以再容器进程启动之前重新挂载这个整个根目录 “/”,由于Mount Namespace的存在,整个挂载对宿主机是不可见的,容器进程可以在里面随便的折腾了
在Linux之中,有一个名为chroot的命令可以帮助你完成这个命令,就是改变进程的根目录到指定的位置
我们拿一个$HOME/test的目录,作为/bin/bash进程的根目录
将所需要运行的进程文件和对应的环境文件,拷贝到这个$HOME/test
最后,执行chroot命令,告诉操作系统,使用$HOME/test目录进行/bin/bash进程的根目录
$ chroot $HOME/test /bin/bash
这样,看到的ls 看到的就是$HOME/test目录下的内容,不是宿主机的内容,
而且,对于被chroot来说,不会感受到自己的根目录已经被修改为了$HOME/test
而我们所说的Mount Namespace,就是基于对chroot的不断改进发明出来的,也是Linux第一个Namespace
为了能让容器这个根目录看起来更加真实,我们一般会挂载一个完整操作系统的文件系统,比如Ubuntu16.04的ISO,这样,我们能够直接ls看的是原生的文件系统
这个挂载到容器根目录上,用来给容器进程提供隔离后的执行环境的文件系统,就是所谓的容器镜像,还会被称为rootfs 根文件系统
一般会包括 一下
bin dev etc home lib lib64 mnt opt proc root run sbin sys tmp usr var
进入容器之后执行/bin/bash,就是这个/bin目录下的执行文件,和宿主机的/bin/bash 完全不同
那么创建Dokcer容器的流程如下
启动 Linux Namespace配置
设置指定的Cgroups参数
切换进程的根目录
这样,一个完整的容器就诞生了,不过Docker项目在最后的一步的执行,会先使用pivot_root的功能,如果不支持,再使用chroot
但是,即时模拟了地板,还是没有模拟出地基,rootfs只是在一个文件操作系统,并不包含整体的核心,操作系统的内核, 所有的容器,是共用宿主机上的操作系统的内核的
这就导致,应用程序如果需要配置内核参数,加载额外的内核模块,以及内核进行直接交互,还是会对所有的容器进行影响的
这就是虚拟机和沙盒的不同,虽然性能消耗高,但是毕竟里面还有着一个完整的GuestOS
但抛开内核的层面不谈,容器解决了除了内核上,其他的一致性
主要利用的,就是rootfs,
将整个开端的环境进行了一个打包,这个打的包中,不仅仅有应用,还有整个操作系统的文件和目录,也就是应用和其运行需要的依赖,都放在了一起
这种将操作系统的打包的能力,让其赋予了容器的一致性,保证了无论在什么机器上,只要解压了镜像,就可以展现一个完整的执行环境
这种深入到操作系统级别的运行环境一致性,打通了应用在本地开发和远端执行环境难以逾越的鸿沟
接下来,我们考虑,如何去在容器不断迭代中,利用现有的镜像,来避免多次制作rootfs的问题
最好是不要每一步的操作,都要去保存一个rootfs的镜像,而是利用DIFF,进行差异化的保存,方便不同时期的修改和删除
我们基于一种增量的方式去做修改,而不是,所有人都维护一个相对于base rootfs的增量内容
Docker就是在镜像的设计中,引入了层layer的概念,用户制作镜像的每一步操作,都会生成一个层,也就是一个增量rootfs
这里用到了一个叫做联合文件系统 Union File System的能力
Union FIle System叫做 UnionFS,将不同的位置的目录联合挂载到同一个目录下,比如 有两个目录 A B
分别有两个文件
$ tree
.
├── A
│ ├── a
│ └── x
└── B
├── b
└── x
这样,我们使用联合挂载,将两个目录挂载到一个公共的目录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系统,很可能使用的AuFS这个联合文件系统的实战,可以通过docker info来看
对于AuFS,最关键的目录结构 /var/lib/docker路径下的diff目录
/var/lib/docker/aufs/diff/<layter_id>
我们拿一个例子来看下容器的分层
我们启动了一个容器
docker run -d ubuntu:latest sleep 3600
Docker 会从Docker hub上拉取一个Ubuntu镜像到本地
这个所谓的镜像,就是一个Ubuntu的rootfs,就是对应操作系统的文件和目录,不过,与我们讲述的rootfs稍微不同的,Docke镜像使用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/mnt
这个目录下就是一个完整的Ubuntu操作系统
这五个镜像层,如何联合挂载成一个完整的Ubuntu文件系统呢?
我们,可以再aufs系统目录下看见这些挂载信息,可以找到对应的AuFs的内部ID
$ cat /proc/mounts| grep aufs
none /var/lib/docker/aufs/mnt/6e3be5d2ecccae7cc0fc… aufs rw,relatime,si=972c6d361e6b32ba,dio,dirperm1 0 0
然后仔细看各个层的信息
整体的目录结构如上,分为三部分
分别分为了只读层,init层,可读写层
首先是只读层,就是rootfs的最下层,对应的是ubuntu:latest镜像的五层,挂载的方式都是只读的,readonly+whiteout
这些层,对应的是Ubuntu操作系统的一些部分
然后第二部分,可读写层
是这个容器的rootfs最上面的一层,挂载方式是rw,没有写入文件之前,这个目录是空的,一旦在容器中做了写操作,就会增量的方式出现在这层
那么假设,我们需要删除一个只读层的叫做foo的文件,那么删除操作会在这个可读写层创建一个名为 wh.foo的文件,然后foo文件会被wh.foo文件遮挡起来,消失了,这就是whiteout的含义,白色障目
最上面的可读写层,就是存放修改后的rootfs后产生的增量,无论是增删改查,都发生在这里,我们使用完这个修改后的容器,还能使用docker commit 和push命令,将这个可读写层,传入Docker Hub,让其他人使用,原来的只读层不会有任何的改变,这就是增量rootfs
第三层 init层
这是一个夹在只读层和读写层之间的事情,init层是Docker项目单独生成的一个内部层,存放 /etc/hosts
/etc/resolv.conf等信息
这样一层的主要存在目的是,用户需要在启动容器的时候写入一些指定的值,比如hostname,就需要在可读写层进行修改
然而,这些修改往往只对当前的容器有效,并不希望执行docker commit时候提交,所以有一个单独的层,让用户在执行docker commit的时候,只会提交可读写层,不包含这些内容
最后,形成了一个完整的Ubuntu操作系统
今天,我们说了容器中文件系统的实现方式,而这种机制,就是我们的容器镜像的地基,rootfs,包含了一个操作系统的所有文件和目录,但不包含内核,最多就几百兆
结合Mount Namespace和rootfs,容器能够进程构建出一个完善的文件系统隔离环境,并配合chroot来进行相对应的挂载操作
在rootfs的基础上,Docker公司提出了多个增量rootfs联合挂载为一个完整的rootfs的方案,就是层的概念
通过分层镜像的设计,以Docker镜像为核心,来做到了增量式的操作,这样,每次镜像拉取,推送的内容,比原本多个完整系统大小小的多,而且分层中共享层的概念,可以使得所有容器镜像需要的总空间,也比每个镜像的总和要小,这使得基于容器镜像的团队协作更加敏捷
既然容器的rootfs,是以只读的方式挂载的,那么如何在容器内修改Ubuntu镜像内容呢?
除了AuFS,还有什么UnionFS呢?
1.上面的读写层也是叫做容器层,下面的只读层叫做镜像层,所有的增删改查的操作都会在读写层 容器层上执行,而由于wh的存在,导致相同的文件,上层会覆盖掉下层,这就是镜像文件的修改,我们先对容器层的文件进行了修改,其实是拷贝了一份镜像进行修改的,修改完成后,会返回去覆盖下层镜像层的数据,这种方式就是copy-on-wirte
copy on wirte按照字面的理解,是在修改的时候,先拷贝出一份拷贝文件,在拷贝文件上进行修改,这样的话,是不是可以理解为,修改镜像内容就是先拷贝一份,在可修改的地方修改好之后,进行只读层的替换呢?