我们已经知道了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按照字面的理解,是在修改的时候,先拷贝出一份拷贝文件,在拷贝文件上进行修改,这样的话,是不是可以理解为,修改镜像内容就是先拷贝一份,在可修改的地方修改好之后,进行只读层的替换呢?

发表评论

邮箱地址不会被公开。 必填项已用*标注