容器基础3:理解容器镜像

todo

一、理解Mount Namespace

如下,是一个小程序,它的作用是在创建子进程时开启指定的Namespace。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#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标志)。而子进程container_main是一个“/bin/bash”程序,也就是一个shell。所以,这个shell就运行在Mount Namespace的隔离环境中。下面,我们编译一下这个程序:

1
2
3
4
$ gcc -o ns ns.c
$ ./ns
Parent - start a container!
Container - inside the container!

这样,我们就进入到了这个“容器”当中。此时,如果我们执行ls命令的话,会发现这个容器中的内容跟宿主机是一样的。也就是说,即时开启了Mount Namespace,容器进程看到的文件系统也跟宿主机完全一样。而这又是为什么呢?

其实,Mount Namespace修改的是容器进程对文件系统“挂载点”的认知。这也就意味着,只有在“挂载”这个操作发生之后,进程的视图才会被改变。而在此之前,新创建的容器会直接继承宿主机的各个挂载点。

因此,在创建新进程时,除了声明要启用Mount Namespace之外,我们还可以告诉容器进程,有哪些目录需要重新挂载,比如/tmp目录。于是,我们可以在容器进程执行前添加一步重新挂载/tmp目录的操作:

1
2
3
4
5
6
7
8
9
10
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目录。再次编译后程序的执行结果如下:

1
2
3
4
5
$ gcc -o ns ns.c
$ ./ns
Parent - start a container!
Container - inside the container!
$ ls /tmp

可以看到,这次/tmp变成了一个空目录,这就意味着重新挂载生效了。我们可以用mount -l检查一下:

1
2
$ mount -l | grep tmpfs
none on /tmp type tmpfs (rw,relatime)

这就是Mount Namespace跟其他Namespace在使用时的不同:它对容器进程视图的改变,一定要伴随着挂载操作(mount)才能生效。

但是,作为一个普通用户,我们希望的是一个更友好的情况:每当创建一个新容器时,容器进程看到的文件系统就是一个独立的隔离环境,而不是继承自宿主机的文件系统。可是要怎样才能做到这一点呢?

其实,我们可以在容器进程启动之前重新挂载它的整个根目录“/”。而由于Mount Namespace的存在,这个挂载对宿主机是不可见的,所以容器进程就可以在里面随便折腾了。

二、理解chroot机制

在Linux操作系统中,有一个名为chroot的命令可以帮助我们完成对整个根目录的挂载。顾名思义,它的作用就是帮助我们“change root file system”,即改变进程的根目录。现假设,有一个$HOME/test目录,我们想把它作为一个/bin/bash进程的根目录。操作如下:

首先,创建test目录和几个lib目录,

1
2
mkdir -p $HOME/test
mkdir -p $HOME/test/{bin,lib64,lib}

然后,把bash命令拷贝到test目录对应的bin目录下,

1
cp -v /bin/{bash,ls} $HOME/test/bin

接下来,把运行bash和ls所需的so文件也拷贝到test目录对应的lib目录下,

1
2
3
4
5
6
T=$HOME/test
mkdir -p $T/lib/x86_64-linux-gnu
list="$(ldd /bin/bash | egrep -o '/lib.*\.[0-9]')"
for i in $list; do cp -v "$i" "${T}${i}"; done
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进程的根目录,

1
chroot $HOME/test /bin/bash

这时,如果执行ls /,就会看到,它返回的都是HOME/test目录下的内容,而不是宿主机的内容。更重要的是,对于被chroot的进程来说,它并不会感受到自己的根目录已经被“修改”成了HOME/test目录下的内容,而不是宿主机的内容。更重要的是,对于被chroot的进程来说,它并不会感受到自己的根目录已经被“修改”成了HOME/test。

可以看到,这种视图被修改的原理和Linux Namespace很类似。实际上,Mount Namespace正是基于对chroot的不断改良才被发明出来的,它也是Linux操作系统中的第一个Namespace。

三、容器镜像

3.1、什么是容器镜像

为了能够让容器的根目录看起来更“真实”,我们一般会在这个容器的根目录下挂载一个操作系统完整的文件系统,比如,Ubuntu 16.04的ISO。这样,在容器启动之后,我们在容器中通过执行ls /查看根目录的下的内容,就是Ubuntu 16.04的所有目录和文件。

这个挂载在容器根目录上、用来为容器进程提供隔离后的执行环境的文件系统,就是所谓的“容器镜像”。它还有一个更为专业的名字,叫作rootfs(即根文件系统)。

通常,一个最常见的rootfs,或者说容器镜像,会包括如下所示的一些目录和文件,比如/bin,/etc,/proc等:

1
2
$ ls /
bin dev etc home lib lib64 mnt opt proc root run sbin sys tmp usr var

而当我们进入容器之后执行的/bin/bash,就是容器进项的/bin目录下的可执行文件,它与宿主机的/bin/bash完全不同。

现在,我们应该可以理解,对Docker项目来说,它最核心的原理实际上就是为待创建的用户进程:

  • 启用Linux Namespace配置;
  • 设置指定的Cgroups参数;
  • 切换进程的根目录。

这样,一个完成的容器就诞生了。不过,Docker项目在最后一步的切换上会优先使用pivot_root系统调用,如果系统不支持,才会使用chroot。这两个系统调用虽然功能类似,但是也有细微的区别。

此外,我们需要知道的是,rootfs只是一个操作系统所包含的文件、目录和配置,并不包括操作系统内核。在Linux操作系统中,这两部分是分开存放的,操作系统只有在开机启动时才会加载指定版本的内核镜像。所以说,rootfs只包括了操作系统的”躯壳“,并没有包括操作系统的”灵魂“。那么,对于容器来说,这个操作系统的”灵魂“又在哪里呢?

实际上,同一台机器上的所有容器,都共享宿主机操作系统的内核。这就意味着,如果我们的应用程序需要配置内核参数、加载额外的内核模块以及跟内核进行直接的交互,那么此时就需要特别注意了:这些操作和依赖的对象,就是宿主机操作系统的内核,它对于该机器上的所有容器来说是一个“全局变量”,牵一发而动全身。

这也是容器相比于虚拟机的主要缺陷之一:毕竟后者不仅有模拟出来的硬件机器充当沙盒,而且每个沙盒里还运行着一个完整的 Guest OS 给应用随便折腾。

3.2、理解容器镜像的一致性

我们知道,容器有一个被反复宣传的重要特性:一致性。由于rootfs里打包的不仅仅是应用,而且包括整个操作系统的文件和目录,也就意味着,应用以及它运行所需要的所有依赖,都被封装在了一起。

事实上,对于大多数开发者而言,他们对应用依赖的理解,一直局限在编程语言层面。比如Golang的Godeps.jsonm。但实际上,一个一直以来很容易被忽视的事实是,对一个应用来说,操作系统本身才是它运行所需要的最完整的“依赖库”。

有了容器镜像“打包操作系统”的能力,这个最基础的依赖环境也终于变成了应用沙盒的一部分。这就赋予了容器所谓的一致性:无论在本地、云端,还是在一台任何地方的机器上,用户只需要解压打包好的容器镜像,那么这个应用运行所需要的完整的执行环境就会被重现出现。

而正是这种深入到操作系统级别的运行环境一致性,才打通了应用在本地开发和远端执行环境之间难以逾越的鸿沟。

四、联合文件系统

4.1、早期rootfs存在的问题

这时我们可能已经发现了另一个棘手的问题:难道每次开发一个应用,或升级一下现用的应用,都要重复制作一次rootfs吗?比如,我们现在用Ubuntu操作系统的ISO做了一个rootfs,然后又在里面安装了Java环境,用来部署我的Java应用。那么,其他人在发布他的Java应用时,显然是可以直接使用我们已经安装过Java环境的rootfs,而不用重复这个流程。

一种比较直观地解决办法就是,我们在制作rootfs的时候,每做一步“有意义”的操作,就保存一个rootfs出来,这样其他人就可以按需求去用他需要的rootfs了。但是,这个解决方法并不具备推广性。原因在于,一旦其他人修改了这个rootfs,新旧两个rootfs之间就没有任何关系了。这样做的结果就是极度的碎片化。

那么,既然这些修改都是基于一个旧的rootfs,我们能不能以增量的方式去做这些修改呢?这样做的好处就是,所有人都只需要维护相对于base rootfs修改的增量内容,而不是每次修改都制造一个“fork”。

答案当然是肯定的。Docker公司在实现Docker镜像时并没有沿用以前制作rootfs的流程,而是做了一个小小的创新:Docker在镜像的设计中,引入了层(layer)的概念。也就是说,用户制作镜像的每一步操作,都会生成一个层,也就是一个增量rootfs。而这种创新的想法用到了一种叫作联合文件系统(Union File System)的能力。

4.2、什么是联合文件系统

Union File System也叫UnionFS,它最主要的功能就是将多个不同位置的目录联合挂载(union mount)到同一个目录下。比如,我们现在有两个目录A和B,它们分别有两个文件:

1
2
3
4
5
6
mkdir {A,B}
touch A/a
touch B/b
echo a > A/x
echo b > B/x
tree

输出如下:

1
2
3
4
5
6
7
.
├── A
│ ├── a
│ └── x
└── B
├── b
└── x

然后,我们使用联合挂载的方式,将这两个目录挂载到一个公共的目录C上:

1
2
mkdir C
mount -t aufs -o dirs=./A:./B none ./C

这时,我们再查看目录C的内容,就能看到目录A和B下的文件被合并到了一起:

1
2
3
4
5
$ tree ./C
./C
├── a
├── b
└── x

可以看到,在这个合并后的目录C中,有a、b、x三个文件,并且x文件只有一份。这就是“合并”的含义。此外,如果我们在目录C里对a、b、x文件做修改,这些修改也会在对应的目录A、B中生效。

4.3、AuFS联合文件系统

那么,在Docker项目中,又是如何使用这种Union File System的呢?

在Ubuntu 16.04和Docker CE 18.05中,默认使用的是AuFS这个联合文件系统的实现。我们可以通过docker info命令,查看到这个信息。

1
2
$ docker info | grep Storage
Storage Driver: aufs

AuFS 的全称是 Another UnionFS,后改名为 Alternative UnionFS,再后来干脆改名叫作 Advance UnionFS。对于 AuFS 来说,它最关键的目录结构在 /var/lib/docker 路径下的 diff 目录中:

1
/var/lib/docker/aufs/diff/<layer_id>

下面,我们通过一个具体的例子来理解这个目录的作用。

首先,我们启动一个容器,比如:

1
docker run -d ubuntu:latest sleep 3600

这时,Docker就会从Docker Hub上拉取一个Ubuntu镜像到本地。而这个所谓的“镜像”,实际上就是一个Ubuntu操作系统的rootfs,它的内容是Ubuntu操作系统的所有文件和目录。不过,与之前我们讨论的rootfs不同的是,Docker镜像使用的rootfs,往往也是多个“层”组成:

1
2
3
4
5
6
7
8
9
10
11
12
$ 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/,比如:

1
/var/lib/docker/aufs/mnt/6e3be5d2ecccae7cc0fcfa2a2f5c89dc21ee30e166be823ceaeba15dce645b3e

不出意外,这个目录里面就是一个完成的Ubuntu操作系统:

1
2
$ 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):

1
2
$ 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下查看被联合挂载在一起的各个层的信息:

1
2
3
4
5
6
7
8
$ 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下。

4.4、理解镜像分层

从上面的结构中,我们可以看出,这个容器的rootfs由如下所示的三部分组成:

4.4.1、只读层

第一部分,只读层。它是这个容器的rootfs最下面的五层,对应的正是ubuntu:latest镜像的五层。可以看到,它们的挂载方式都是只读的(ro+wh,即readonly+witeout)。这时,我们可以分别查看一下这些层的内容:

1
2
3
4
5
6
$ 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操作系统的一部分。

4.4.2、可读写层

第二部分,可读写层。它是这个容器的roots最上面的一层,它的挂载方式为读写(rw,即read write)。在没有写入文件之前,这个目录是空的。而一旦在容器里做了写操作,修改产生的内容就会以增量的方式出现在这个层中。

如果我们要做的是,删除只读层中的一个文件,这时会发生什么呢?

为了实现删除操作,AuFS会在可读写层创建一个whieout文件,把只读层里的文件“遮挡”起来。比如,我们要删除只读层里一个名为foo的文件,那么这个删除操作实际上是在可读写层创建了一个名叫.wh.foo的文件。这样,当着两个层被联合挂载只有,foo文件就会被.wh.foo文件“遮挡”起来,从而就“消失”了。这个功能,就是“ro+wh”的挂载方式,即read only+whiteout的含义。

所以,最上面的这个可读写层的作用,就是专门用来存放我们修改rootfs后产生的增量,增、删、改操作都发生在这里。而当我们使用完了这个被修改过的容器之后,还可以使用docker commit和push指令,保存这个被修改过的可读写层,并上传到Docker Hub上,供其他人使用;与此同时,原先的只读层里的内容则不会有任何变化。而这就是增量rootfs的好处。

4.4.3、init层

第三部分,init层。它是一个以“-init”结尾的层,夹在只读层和读写层之间。init层是Docker项目单独生成的一个内部层,专门用来存放/etc/hosts、/etc/resolv.conf等信息。

之所以需要这样的一层,是因为这些文件本属于只读的ubuntu镜像的一部分,但是用户往往需要在启动容器时,写入一些指定的值比如hostname,所以就需要在可读写层对它们进行修改。可是,这些修改往往只对当前的容器有效,我们并不希望执行docker commit时,把这些信息连同可读写层一起提交。

所以,Docker的做法是,在修改了这些文件之后,以一个单独的层挂载出来。而用户执行docker commit只会提交可读写层,所以是不包含这些内容的。

最终,这7个层都被联合挂载到/var/lib/docker/aufs/mnt目录下,表现为一个完成的Ubuntu操作系统供容器使用。

五、小结

在这篇文章中,我们主要讨论了如下内容:

  • mount namespace不同与其它namespace,它只有在挂载操作发生后才会生效。
  • 现行的Mount Namespace是对chroot的改良。
  • 容器镜像就是挂载在容器根目录上、用来为容器进程提供隔离后的执行环境的文件系统。
  • 容器镜像的一致性是指容器不仅打包了应用运行所需的依赖程序,而且打包了整个文件系统。这样就实现了“一处构建,到处运行”。
  • 联合文件系统就是将多个不同位置的目录联合挂载到同一个目录下。而容器镜像的分层特性就是借助联合文件系统实现的。

容器基础3:理解容器镜像
https://kuberxy.github.io/2020/10/01/容器基础3:理解容器镜像/
作者
Mr.x
发布于
2020年10月1日
许可协议