容器与容器间的文件系统必须相互隔离。如果一个容器能不受限制地访问到宿主机或另一个容器里的文件,那必定会引起严重的安全风险。所以对于docker中的进程,必须限制其能够访问的文件系统。
我们先来看一种简易版的实现方式:chroot。
chroot-监狱
chroot,即change root的缩写。它是一个 UNIX 操作系统上的系统调用,用于将一个进程及其子进程的根目录改变到文件系统中的一个新位置。
我们知道root根目录(/)是Linux的顶层目录。这里的顶层,换句话说就是没有办法访问比根目录更高一层级的目录。但对每个进程,可以通过chroot来“欺骗”,将指定的目录骗他们认定为根目录。
chroot经常和一个单词结合在一起说:jail(监狱)。可以很形象地理解为chroot就是给每个docker容器划了一个监狱房间。
Docker在每个监狱里配套放置了一套文件系统。
chroot的基本语法如下:
1 | 将某个进程 |
chroot看起来挺不错,但也存在两个问题:
- 监狱里需要备齐所有需要的文件,有几个容器就需要备几份。
- 有办法可以越狱。方式之一就是在chroot里运行chroot。
事实上Docker存储引擎之一的VFS就是每个容器的存储完全独立(有时会在排查问题的时候使用),所以空间占用最大。
但我们总希望能对空间利用能进一步优化。
Docker镜像原理
写时复制
Linux刚启动的时候会加载bootfs。当boot成功,kernel被加载到内存之后,bootfs就被umount了。我们平时能看到的/bin,/lib等目录是rootfs,处于bootfs上一层。
假设我们有两个centos容器。对于这两个容器来说,像/bin,/lib等rootfs目录里的内容完全一致,同时保存两份会浪费存储空间。那么我们是否可以让两个容器共享这些相同的文件?
我们知道Linux里有软链接的概念。可以在不同的目录里创建软链接,指向同一个实际文件/目录。但软链接方案有一个很显而易见的问题:改动会互相影响。我们无法接受在一个容器里修改了文件之后影响到另一个容器。
很自然,我们就会考虑做一个只读的基准版本。每个容器对这个只读版本的修改分别独立保存。
这个只读的基准版本就是镜像,可修改的就是容器。
Docker存储引擎对文件的修改使用到了写时复制(COW,Copy on Write)技术。即当要对镜像中某个文件进行写操作时,将文件复制到文件系统中,对副本进行修改,而不会对image里的源文件进行修改。多个容器操作同一个文件的时候会创建多个副本。
写时复制使得同一台机器上可以部署的Docker容器数量远超过可以部署的虚拟机数量:
假设一个虚拟机的大小是10GB,那么创建并启动10个虚拟机需要多少空间?
视使用的虚拟技术而定,可能是100GB,可能小于100GB。但假设操作系统占了2GB,容量不会小于20GB。
假设一个容器镜像的大小是10GB,那么创建并启动10个容器需要多少空间?
依然只需要10GB。
Union Mount
假设原始镜像中有一个文件a.txt。当要修改该文件的内容时,通过“写时复制”,创建出来了一份a.txt的副本。我们希望最终在容器中查看文件系统时,除了a.txt之外的所有文件都读取镜像,只有a.txt这个文件读取副本。这依赖的是Union Mount(联合挂载)技术。
我们已Docker存储引擎之一:OverlayFS为例,看一下联合挂载的特征。
如上图所示,在OverlayFS中存在Lower和Upper两个层次。Lower层就是镜像层,Upper层就是容器层。最终的效果类似从正上方俯视,Upper中的文件会覆盖Lower层中的文件。
我们以下四种情况,看一下不同的文件系统操作的实际原理:
- 创建文件/目录:在Upper层中直接创建
- 修改文件/目录:从Lower层复制到Upper层,然后在Upper层中修改
- 删除文件/目录:在Upper层创建一个Whiteout文件/目录。Whiteout文件/目录在Merge后不显示。
- 删除目录后创建同名目录:Upper层新目录为opaque目录,屏蔽Lower层目录和里面的文件
Layer
假设我们现在有一批基于centos镜像的容器,这些容器里都需要装JDK。
当然我们可以在每个容器的Upper层里都包含一份JDK文件。但既然每个容器里的JDK文件都一样,那么是不是可以考虑把JDK也做成共通的?
有一种方案是把centos和JDK放在一起打包成一个独立的镜像。但接下来你就会面对组合的极速膨胀,比如:
- centos + JDK
- centos + python 2
- centos + python 3
- centos + JDK + tomcat
- centos + apache
- …
光是镜像的大小就会变得很可观。
我们上一节提到过overlay层 = 容器层 + 镜像层,那么自然可以联想到,这个模式也可以扩展成:overlay层 = 容器层 + JDK镜像层 + centos镜像层
推而广之:
- centos + JDK + tomcat = 容器层 + tomcat镜像层 + JDK镜像层 + centos镜像层
- centos + python 3 = 容器层 + python 3层 + centos镜像层
- …
这样就只需要维护一份centos的镜像文件,以及基于centos镜像的增量改动即可。
容器层的改动也可以通过docker commit固化成镜像。于是只要把应用打包成镜像,分发到任何服务器上docker run,就达到了“一次打包,到处运行”的效果。
我认为这个设计还有一个顺带的好处:下载镜像的时候可以并发下载多个层,加快镜像的下载速度。每一层下载完后自行解压缩。下面是下载Gitlab镜像的实时状态:
1 | latest: Pulling from gitlab/gitlab-ce |
Docker存储引擎的选择
Docker的存储引擎有多种实现方式,并还在不断改进中。除了上文中提到的VFS和OverlayFS外,还有Device Mapper/AUFS/Overlay2等。
当前最新版本推荐使用Overlay2。
对于Overlay2引擎,镜像保存在/var/lib/docker/overlay2路径下,镜像和层的元数据保存在/var/lib/docker/image/overlay2/路径下。
参考资料
一个便于深入理解chroot效果的动手实验
技术|Linux / Unix:chroot 命令实例讲解
容器Layer相关的图来自于这个博客
Docker Getting Start: Related Knowledge
OverlayFS的一些动手试验
OverlayFS | Programster’s Blog
Docker引擎原理的官方文档介绍
About storage drivers | Docker Documentation
对Overlay/Overlay2具体实现的分析
Docker存储驱动—Overlay/Overlay2「译」 | Arking