镜像是一个很古老的词,从最开始人们把镜子里的内容叫做镜像,后来微软把操作系统刻到光盘上售卖,把这个叫镜像,再到互联网时代,Docker镜像,网站镜像等等,可以说无处不在。在一定意义上,镜像的意思就是拷贝,即同一份数据的不同副本。那么现在容器语义下的镜像又是什么呢,本文将探讨一下容器的镜像

手动创造一个容器

在上一篇中,已经知道容器本质是经过隔离和限制的进程。理想情况下,容器的文件系统也是独有的,随便删改都不会影响宿主机,这个又是怎么实现的呢?我们再来探究一下。这里参考一下左耳朵耗子的小程序:

#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;
}

核心代码就是通过clone系统调用创建了新的进程,并且通过CLONE_NEWNS启用Mount Namespace,而新进程执行了/bin/bash。这时候看一下目录信息会发现和宿主机一样:

[root@10-23-234-84 tmp]# vi test.c
[root@10-23-234-84 tmp]# gcc -o test test.c 
[root@10-23-234-84 tmp]# ./test 
Parent - start a container!
Container - inside the container!
[root@10-23-234-84 tmp]# ls /tmp
systemd-private-999247ff7f3f4d07a1daf0aa34363c8b-chronyd.service-ooizTe
systemd-private-999247ff7f3f4d07a1daf0aa34363c8b-earlyoom.service-uTG9lJ
test
test.c

这里就要提到Mount Namespace和其他Namespace的差别了。它是Linux系统的第一个Namespace,当系统启动时,只有一个“ 初始命名空间”。新的mount命名空间通过设置clone()系统调用中的CLONE_NEWNS来创建,或者通过unshare()系统调用将调用者迁移到一个新的命名空间。当一个新的命名空间被创建时,它将继承调用clone()和unshare()的进程的命名空间的全部挂载点列表(mount point list)。
调用clone()或者unshare()之后,通过mount()或者unmount()函数,每个namespace中的挂载点可以被动态的删除或者增加。默认情况下,对挂载点列表的修改只对处于该命名空间的进程是可见的,对于其他命名空间中的进程则是无效的。现在我们在代码中执行一下mount():

int container_main(void* arg)
{  
  printf("Container - inside the container!\n");
  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目录。再重新编译执行看一下效果:

[root@10-23-234-84 tmp]# ls /tmp
[root@10-23-234-84 tmp]# 

可以看到已经不是宿主机的/tmp了,里面是个空目录。用mount -l看一下:

[root@10-23-234-84 tmp]# mount -l |grep tmpfs
tmpfs on /sys/fs/cgroup type tmpfs (ro,nosuid,nodev,noexec,mode=755)
devtmpfs on /dev type devtmpfs (rw,nosuid,size=929076k,nr_inodes=232269,mode=755)
tmpfs on /dev/shm type tmpfs (rw,nosuid,nodev)
tmpfs on /run type tmpfs (rw,nosuid,nodev,mode=755)
tmpfs on /run/user/0 type tmpfs (rw,nosuid,nodev,relatime,size=189320k,mode=700)
none on /tmp type tmpfs (rw,relatime)

也可以看到/tmp是以tmpfs方式单独挂载的,再到宿主机里执行相同的命令:

[root@10-23-234-84 ~]# mount -l | grep tmpfs
[root@10-23-234-84 ~]#

可以看到是不存在的。再结合chroot命令,就可以改变进程的根目录。换句话说,就可以指定任何一个目录为进程“以为的”根目录/。进程再次被欺骗了一次。
为了让进程感觉更真实些,通常会在根目录下挂载一个真实的文件系统,比如常见的:

[root@10-23-234-84 ~]# ls /
bin   data  etc   lib    media  opt   root  sbin  sys  usr
boot  dev   home  lib64  mnt    proc  run   srv   tmp  var

而这个挂载在进程根目录,用来为容器进程提供隔离后执行环境的文件系统,就是“容器镜像”,即rootfs。回想这个命令:docker run -it busybox /bin/bash,现在就知道了是执行进程以为的/bin/bash的文件,与宿主机没有关系。
其实到这里,已经创建了一个完整的容器,这些步骤也是Docker主要干的事情。但是rootfs并不难挂载操作系统内核,对于同一台机器上的此类进程或者“容器”来说,都是共享宿主机的操作系统内核的。所以当容器需要使用内核参数,加载内核模块时,需要额外注意。

深入容器镜像

有了rootfs的存在,解决了paas最痛苦的应用“打包”过程。因为rootfs打包的不仅是应用,还有操作系统的文件和目录。这样一来,镜像就可以“编译一次,到处运行”了。不过这和现在的Docker镜像打包还是不一样,因为我们并没有每一次打包应用时都打包一次rootfs。
Docker的镜像设计中,引入了layer的概念,用户制作镜像的每一步操作,都会生成一个layer,也就是一个增量rootfs。把各层联合在一起就用到了联合文件系统,在centOS中默认是overlay2,最多支持128层。我们也动手实践一下。首先创建三个目录d1,d2,d3,其中d2有和d1相同的file1文件,如下:

[root@10-23-234-84 shentu]# mkdir d1 d2 d3 
[root@10-23-234-84 shentu]# echo "file1" > d1/file1
[root@10-23-234-84 shentu]# echo "file1" > d2/file1
[root@10-23-234-84 shentu]# echo "file2" > d2/file2
[root@10-23-234-84 shentu]# tree
.
├── d1
│   └── file1
├── d2
│   ├── file1
│   └── file2
└── d3

3 directories, 3 files

然后创建一个文件夹d4,并用overlay2的方式把d1和d2挂载到d3目录下:

[root@10-23-234-84 shentu]# mount -t overlay overlay -o lowerdir=d1,upperdir=d2,workdir=d3 d4
[root@10-23-234-84 shentu]# tree ./d3
./d3
├── file1
└── file2

0 directories, 2 files

可以看到d3已经把d1,d2的文件合并到了一起,且file1只有一份。进一步探究可以发现,对d3中的修改也会对d1,d2中的生效。可以用docker info | grep Storage查看当前使用联合文件系统。在这里有必要解释一下overlay的一些基本概念:它包括lowerdir,upperdir和merged三个层次,其中:

  • lowerdir:表示较为底层的目录,修改联合挂载点不会影响到lowerdir。
  • upperdir:表示较为上层的目录,修改联合挂载点会在upperdir同步修改。
  • merged:是lowerdir和upperdir合并后的联合挂载点。
  • workdir:用来存放挂载后的临时文件与间接文件。

现在我们来看一下Docker镜像的实质。首先启动后台启动一个容器:docker run -d nginx,然后执行docker inspect nginx可以看到

 "Data": {
                "LowerDir": "/var/lib/docker/overlay2/373dc50e8a4b4aa5f2a5b3e23b655e68077c4705a4db1b48082588e7fc401ad1/diff:/var/lib/docker/overlay2/caa4e6c0c30146d66b8aa0373c3abb1c4b8669ca8bf150f27459b445804481ae/diff:/var/lib/docker/overlay2/ed603fa65d9aff84c35a94cbe53f1fdc038167a359cf41e9f204579247f3605c/diff:/var/lib/docker/overlay2/cd91a02733b81e05ce4a50406f9b2ae800e2dfd57e0def7c266b2535456f7f38/diff:/var/lib/docker/overlay2/35fe0999119afeafe976e6bb1a0464ae1ad49fe0967961b7d2ef8168bc1286f0/diff",
                "MergedDir": "/var/lib/docker/overlay2/e563026190a1bdcfcd2743f723cd376112f7b9476865cd5bfcc0cbe944ecb3ae/merged",
                "UpperDir": "/var/lib/docker/overlay2/e563026190a1bdcfcd2743f723cd376112f7b9476865cd5bfcc0cbe944ecb3ae/diff",
                "WorkDir": "/var/lib/docker/overlay2/e563026190a1bdcfcd2743f723cd376112f7b9476865cd5bfcc0cbe944ecb3ae/work"
            },
            "Name": "overlay2"
        },
        "RootFS": {
            "Type": "layers",
            "Layers": [
                "sha256:e81bff2725dbc0bf2003db10272fef362e882eb96353055778a66cda430cf81b",
                "sha256:43f4e41372e42dd32309f6a7bdce03cf2d65b3ca34b1036be946d53c35b503ab",
                "sha256:788e89a4d186f3614bfa74254524bc2e2c6de103698aeb1cb044f8e8339a90bd",
                "sha256:f8e880dfc4ef19e78853c3f132166a4760a220c5ad15b9ee03b22da9c490ae3b",
                "sha256:f7e00b807643e512b85ef8c9f5244667c337c314fa29572206c1b0f3ae7bf122",
                "sha256:9959a332cf6e41253a9cd0c715fa74b01db1621b4d16f98f4155a2ed5365da4a"
            ]
        },

nginx镜像由六层组成,这六层就是六个增量rootfs,使用时docker会把这些层挂载到一个统一的挂载点上。在Data字段中,很清楚写明了overlay的几层位置。
/var/lib/docker/overlay2/e563026190a1bdcfcd2743f723cd376112f7b9476865cd5bfcc0cbe944ecb3ae/merged这里就是nginx容器所“以为”的根文件路径了。
还可以进一步在/proc/mounts里看看是如何挂载成一个的:

[root@10-23-234-84 diff]# cat /proc/mounts | grep over
overlay /root/shentu/d4 overlay rw,relatime,lowerdir=d1,upperdir=d2,workdir=d3 0 0
overlay /var/lib/docker/overlay2/ac9da5b7dba16b17ae8dcce1ec9a85ecb3a752b92cdde1161835d521e332ad07/merged overlay rw,relatime,lowerdir=/var/lib/docker/overlay2/l/AVUQ5BVNUVTXNKQ6V6JCZ7QLGG:/var/lib/docker/overlay2/l/SOOBNWNM5VE4CSLBD464V7WAZQ:/var/lib/docker/overlay2/l/OAPUEVQ5ORUAC7TAOQEJGHK5BH:/var/lib/docker/overlay2/l/SK3SUWJ3OUKVGP2B2DO6HSRRBW:/var/lib/docker/overlay2/l/O2MCKJVW6RYNXSEZOTRZNZKFZ3:/var/lib/docker/overlay2/l/6TPZVGZJIUKPUFHEGFFABALNWY:/var/lib/docker/overlay2/l/WH4NI7JWVATVNV2UUXCEA5AER4,upperdir=/var/lib/docker/overlay2/ac9da5b7dba16b17ae8dcce1ec9a85ecb3a752b92cdde1161835d521e332ad07/diff,workdir=/var/lib/dockeroverlay2/ac9da5b7dba16b17ae8dcce1ec9a85ecb3a752b92cdde1161835d521e332ad07/work 0 0

这里的信息是和docker inspect中显示的workdir,lowdir,upperdir一样的.

  • lowerdir=/var/lib/docker/overlay2/l/AVUQ5BVNUVTXNKQ6V6JCZ7QLGG:/var/lib/docker/overlay2/l/SOOBNWNM5VE4CSLBD464V7WAZQ:/var/lib/docker/overlay2/l/OAPUEVQ5ORUAC7TAOQEJGHK5BH:/var/lib/docker/overlay2/l/SK3SUWJ3OUKVGP2B2DO6HSRRBW:/var/lib/docker/overlay2/l/O2MCKJVW6RYNXSEZOTRZNZKFZ3:/var/lib/docker/overlay2/l/6TPZVGZJIUKPUFHEGFFABALNWY:/var/lib/docker/overlay2/l/WH4NI7JWVATVNV2UUXCEA5AER4,冒号分隔多个lowerdir,从左到右层次越低
  • upperdir=/var/lib/docker/overlay2/ac9da5b7dba16b17ae8dcce1ec9a85ecb3a752b92cdde1161835d521e332ad07/diff
  • workdir=/var/lib/dockeroverlay2/ac9da5b7dba16b17ae8dcce1ec9a85ecb3a752b92cdde1161835d521e332ad07/work 0 0
    只有三种场景,容器会通过overlay只读访问文件。
  • 容器层不存在的文件。如果容器只读打开一个文件,但该容器不在容器层(upperdir),就要从镜像层(lowerdir)中读取。这会引起很小的性能损耗。
  • 只存在于容器层的文件。如果容器只读权限打开一个文件,并且容器只存在于容器层(upperdir)而不是镜像层(lowerdir),那么直接从镜像层读取文件,无额外性能损耗。
  • 文件同时存在于容器层和镜像层。那么会读取容器层的文件,因为容器层(upperdir)隐藏了镜像层(lowerdir)的同名文件。因此,也没有额外的性能损耗。
    而以下场景容器修改文件。
  • 第一次写一个文件。容器第一次写一个已经存在的文件,容器层不存在这个文件。overlay/overlay2驱动执行copy-up操作,将文件从镜像层拷贝到容器层。然后容器修改容器层新拷贝的文件。

总结

以上就是镜像的奥秘了,可能很多次听说过分层,但真正的实践之后才知道为什么要分层,怎么做的分层。另外联合文件系统有许多不同的实现,上面的overlay也只是在我环境的实现,不同的实现有不同的挂载,但是原理都是相同的。
容器镜像即:rootfs。它只是一个操作系统的所有文件和目录,并不包含内核,最多也就几百兆。而相比之下,传统虚拟机的镜像大多是一个磁盘的“快照”,磁盘有多大,镜像就至少有多大。通过结合使用 Mount Namespace 和 rootfs,容器就能够为进程构建出一个完善的文件系统隔离环境。当然,这个功能的实现还必须感谢 chroot 欺骗了进程的根目录。而在 rootfs 的基础上,Docker 公司创新性地提出了使用多个增量 rootfs 联合挂载一个完整 rootfs 的方案,这就是容器镜像中“层”的概念。通过“分层镜像”的设计,以 Docker 镜像为核心,来自不同公司、不同团队的技术人员被紧密地联系在了一起。
并且,由于容器镜像的操作是增量式的,这样每次镜像拉取、推送的内容,比原本多个完整的操作系统的大小要小得多;而共享层的存在,可以使得所有这些容器镜像需要的总空间,也比每个镜像的总和要小。这样就使得基于容器镜像的团队协作,要比基于动则几个 GB 的虚拟机磁盘镜像的协作要敏捷得多。
更重要的是,这改变了应用的分发方式,只要制作了一个镜像,那么任何地方下载了该镜像就能包含所有的依赖,完美填补了开发-测试-部署之间巨大的鸿沟。
至此,容器和镜像正式登上了历史的舞台,也为后面的容器战争埋下了伏笔。