Linux容器最基础的两种技术:
容器的本质是一种特殊的进程。 被以上两种技术装进了一个被隔离的空间中。这个空间就是PaaS项目赖以生存的应用“沙盒”。
在这个空间中,虽然四周有墙,但是如果容器进程低头一看地面,会是什么样的景象?换句话说,容器里的进程看到的文件系统又是什么样子的?
这是一个关于Mount Namespace的问题:容器里的应用进程,理应看到一份完全独立的文件系统。这样,它就可以在自己的容器目录(比如/tmp
)下进行操作,而完全不会受到宿主机以及其他容器的影响。
以下程序作用,在创建子进程时开启指定的Namespace:
#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;
}
/bin/bash
程序,也就是一个shell,这个shell就运行在了Mount Namespace的隔离环境中在子进程中执行ls命令,查看到的还是宿主机的文件,即:即使开启了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 Namespace,所以这次挂载操作,只在容器进程的Mount Namespace中有效,在宿主机的挂载中查看不到上述挂载点。
Mount Namespace和其他Namespace的使用略有不同的地方:它对容器进程视图的改变,一定是伴随着挂载(mount)操作才能生效。
在创建新的容器的时候,重新挂载根目录“/”,即可实现,容器进程看到的文件系统就是一个独立的隔离环境,而不是继承自宿主机的文件系统。
在Linux系统中,chroot命令可以方便的完成上述工作,“change root file system”,即改变进程的根目录到指定的位置。
现在有一个$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
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
此时执行 ls /
返回的都是$HOME/test
目录下的内容,而不是宿主机的内容。
对于被chroot的进程来说,它不会感受到自己的根目录被修改了。
为了让容器中的根目录看起来更加的真实,一般会在容器的根目录下挂载一个完整操作系统的文件系统,比如Ubuntu 16.04的ISO。这样在容器启动后,我们在容器里通过执行ls /
查看根目录下的内容,就是ubuntu 16.04的所有目录和文件。
这个挂载在容器根目录上、用来为容器进程提供隔离后执行环境的文件系统,就是所谓的“容器镜像”,它有个更专业的名字,叫rootfs(根文件系统)
一个最常见的rootfs会包含以下目录:
ls /
bin dev etc home lib lib64 mnt opt proc root run sbin sys tmp usr var
进入容器之后,执行的/bin/bash
就是rootfs的/bin
目录下的可执行文件,与宿主机的/bin/bash
完全不同。
对于Docker项目来说,最核心的原理实际上就是为待创建的用户进程:
这样,一个完整的容器就诞生了,不过Docker项目在最后一步会优先使用pivot_root系统调用,如果系统不支持,才使用chroot
注意:rootfs只是一个操作系统包含的文件、配置和目录,并不包括操作系统的内核。
在Linux系统中,操作系统内核和操作系统包含的文件、配置和目录是分开存放的,只有在开机启动的时候,操作系统才会加载指定版本的内核镜像。
rootfs只包含操作系统的躯壳,不包括操作系统的灵魂。同一台机器上的所有容器都共享宿主机操作系统的内核。
这就意味着,如果应用程序需要配置内核参数,加载额外的内核模块,以及跟内核进行直接的交互,这些操作和依赖的对象都是宿主机操作系统的内核,它对于该机器上的所有容器来说是一个“全局变量”,牵一发而动全身。
这是容器相比虚拟机的主要缺陷之一。
由于云端和本地服务器环境不同,应用的打包过程,一直是使用PaaS时最痛苦的一个步骤。但有了容器镜像(即rootfs)之后,这个问题就被优雅的解决了。rootfs的存在,保证了容器的一致性。
对应用依赖的理解,不能局限于编程语言层面,对于一个应用来说,操作系统本身才是它所需要的最完整的“依赖库”。
无论是在本地还是云端,只要解压打包好的容器镜像,那么这个应用运行所需的完整的执行环境就被重现出来了。
这种深入到操作系统级别的运行环境一致性,打通了应用在本地开发和云端执行之间的鸿沟。
思考另一个问题,是否在每次开发或者升级应用的时候,都要重复制作一次rootfs?
Docker在镜像的设计中,引入了层(layer)的概念,用户在制作镜像的每一步操作,都会生成一个层,也就是增量的rootfs。
实现这个想法,使用了联合文件系统(Union File System)的能力。
UnionFS最主要的功能是将多个不同位置的目录联合挂载到同一个目录下。
tree
.
├── A
│ ├── a
│ └── x
└── B
├── b
└── x
mkdir C
mount -t aufs -o dirs=./A:./B none ./C
tree ./C
./C
├── a
├── b
└── x
在这个合并后的目录里,有a,b,x三个文件,并且x文件只有一份。这就是合并的含义,并且如果在目录C里对a,b,x文件做修改,这些修改也会在对应的目录A,B中生效。
系统版本:
这对组合默认使用AuFS,可以使用docker info命令查看到这些信息。
AuFS 名字的进化过程:
AnotherUnionFS-->AlternativeUnionFS
AlternativeUnionFS-->AdvanceUnionFS
从名字可以发现:
对于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..."
]
}
这个挂载点就是 /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
这5个镜像层,是如何被联合挂载成这样一个完整的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,即read write。在没有写入文件之前,这个目录是空的,而一旦在容器里做了写操作,修改产生的内容就会以增量的方式出现在这个层中。
为了实现删除文件的操作,AuFS在可读写层创建一个whiteout文件,把只读层里的文件“遮挡”起来。
比如要删除只读层中的foo文件,那么这个删除操作实际上是在可读写层创建了一个名为.wh.foo的文件。这样,当这两个层被联合挂载之后,foo文件就会被.wh.foo文件“遮挡”起来。这个功能就是“ro+wh”的挂载方式,即只读+witheout。
所以:
这就是增量rootfs的好处。
它是一个以“-init”结尾的层,夹在只读层和读写层之间。Init层是Docker项目单独生成的一个内部层,专门用来存放 /etc/hosts
, /etc/resolv.conf
等信息。
需要这样一层的原因是,这些文件本来属于只读的Ubuntu镜像的一部分,但是用户往往需要在启动容器时写入一些指定的值,比如hostname,所以需要在可读写层对它们进行修改。但是这些修改往往只能对当前容器生效,并不希望执行docker commit时,把这些信息连同可读写层一起提交。
所以Docker做法是,在修改了这些文件之后,以一个单独的层挂载出来,在用户执行docker commit时只会提交可读写层,所以是不包含这些信息的。
最终,这7层被联合挂载到/var/lib/docker/aufs/mnt
目录下,表现为一个完整的Ubuntu操作系统供容器使用。
所有的增删查改操作都只会作用在容器层,相同的文件上层会覆盖掉下层。镜像文件的修改,比如修改一个文件的时候,首先会从上到下查找有没有这个文件,找到,就复制到容器层中,修改,修改的结果就会作用到下层的文件,这种方式也被称为copy-on-write。
包括但不限于以下这几种:aufs, device mapper, btrfs, overlayfs, vfs, zfs。