k8s 深入篇———— docker 镜像是什么[二]

前言

简单介绍一下docker的镜像。

正文

前面讲到了容器的工作原理了(namespace 限制了时间, cgroup限制了资源),知道docker 历史的也知道,docker 之所以能够称为容器大佬,是因为其只做了容器。

也就是做到了一次打包,到处运行的这种思想得到了实现。

那么容器的镜像涉及思路是怎么样的呢?

tee test.c <<- 'EOF'
> #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;
> }
> EOF

写入这一段代码。

然后编译:

gcc -o test test.h

然后运行一下:

./test

这个时候容器就启动了。

这个时候就已经进入了,容器里面了。

但是你这个时候发现一个问题,那就是你所在的容器和外面一模一样。

即使开启了 Mount Namespace,容器进程看到的文件系统也跟宿主机完全一样。

那么是不是挂载点的问题呢?

还是说代码根本就没有成功。

第一,验证代码是否开启了mount namespace:

kill 之后,然后就推出了,这时候就知道了,其实我们一直是在容器里面。

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

那么更换一下挂载点:

[root@iZ8vb42623daibnzbt0j16Z ~]# tee test.c <<- 'EOF'
> #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");
>   // 如果你的机器的根目录的挂载类型是 shared,那必须先重新挂载根目录
>   // mount("", "/", NULL, MS_PRIVATE, "");
>   mount("none", "/tmp", "tmpfs", 0, "");
>   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;
> }
> EOF

mount("none", "/tmp", "tmpfs", 0, ""); 这一行。

那么看下效果,编译,然后运行。

现在这个进程来说,tmp 就完全看不到了。

然后看一下tmpfs 的挂载情况:

mount -l | grep tmpfs

tmpfs 这个表示内存盘的意思,就是往这个盘上写东西,其实是写入内存中。

这个时候看下宿主机的情况。

宿主机也能看到。

也就是说容器影响了宿主机,这很危险。

改下。

更重要的是,因为我们创建的新进程启用了 Mount Namespace,所以这次重新挂载的操
作,只在容器进程的 Mount Namespace 中有效。如果在宿主机上用 mount -l 来检查一
下这个挂载,你会发现它是不存在的:

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

可是,作为一个普通用户,我们希望的是一个更友好的情况:每当创建一个新容器时,我希
望容器进程看到的文件系统就是一个独立的隔离环境,而不是继承自宿主机的文件系统。怎
么才能做到这一点呢?
不难想到,我们可以在容器进程启动之前重新挂载它的整个根目录“/”。而由于 Mount
Namespace 的存在,这个挂载对宿主机不可见,所以容器进程就可以在里面随便折腾了。

那么如果希望看到一个独立的环境呢? 那么就得重新挂载整个根目录了。

在 Linux 操作系统里,有一个名为 chroot 的命令可以帮助你在 shell 中方便地完成这个工
作。顾名思义,它的作用就是帮你“change root file system”,即改变进程的根目录到
你指定的位置。它的用法也非常简单。

上面创建一些目录。

然后把文件拷贝进去。

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

拷贝依赖:

T=$HOME/testnamespace
list="$(ldd /bin/ls | egrep -o '/lib.*\.[0-9]')"
for i in $list; do cp -v "$i" "${T}${i}"; done 

如果是64位,运行这个:

T=$HOME/testnamespace
list="$(ldd /bin/ls | egrep -o '/lib.*\.[0-9]')"
for i in $list; do cp -v "$i" "${T}${i}"; done 

然后运行:

chroot $HOME/testnamespace /bin/bash

这个时候这个进程,运行的就是在$HOME/testnamepsace空间下。

看一下ls -al,看到的就是$HOME/testnamepsace的内容。

实际上,Mount Namespace 正是基于对 chroot 的不断改良才被发明出来的,它也是
Linux 操作系统里的第一个 Namespace。

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

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

  1. 启用 Linux Namespace 配置;
  2. 设置指定的 Cgroups 参数;
  3. 切换进程的根目录(Change Root)。

这样,一个完整的容器就诞生了。不过,Docker 项目在最后一步的切换上会优先使用
pivot_root 系统调用,如果系统不支持,才会使用 chroot。

另外,需要明确的是,rootfs 只是一个操作系统所包含的文件、配置和目录,并不包括操
作系统内核。在 Linux 操作系统中,这两部分是分开存放的,操作系统只有在开机启动时
才会加载指定版本的内核镜像

所以说,rootfs 只包括了操作系统的“躯壳”,并没有包括操作系统的“灵魂”。

实际上,同一台机器上的所有容器,都共享宿主机操作系统的内核。

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

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

不过,正是由于 rootfs 的存在,容器才有了一个被反复宣传至今的重要特性:一致性。

由于 rootfs 里打包的不只是应用,而是整个操作系统的文件和目录,也就意味着,应用以
及它运行所需要的所有依赖,都被封装在了一起。

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

Docker 在镜像的设计中,引入了层(layer)的概念。也就是说,用户制作
镜像的每一步操作,都会生成一个层,也就是一个增量 rootfs。

当然,这个想法不是凭空臆造出来的,而是用到了一种叫作联合文件系统(Union File
System)的能力。

最主要的功能是将多个不同位置的目录联合挂载(union mount)到同一个目录下。

做个实验

有A B C三个目录

A下有a x两个文件

B下有b x两个文件

用联合挂载的方式,将这两个目录挂载到一个公共的目录 C 上

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

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

形成这样一个覆盖的功能。

查看docker info:

使用的是overlay2

来查看一下这个东西吧。

我们来随便查看一个镜像。

docker image inspect busybox

可以看到这一层的id 是sha256:01fd6df81c8ec7dd24bbbd72342671f41813f992999a3471b9d9cbc44ad88374

然后也可也看到镜像的位置在:

/var/lib/docker/overlay2/62f55dcb71d6096a2f9a874ae51397fac69c689122aa7fcecb20bfa3ee087d5e

进入/var/lib/docker/overlay2/62f55dcb71d6096a2f9a874ae51397fac69c689122aa7fcecb20bfa3ee087d5e/diff

就可以看到完整的镜像目录。

当我们启动一个容器的时候,我们发现了两个东西:

这两层是什么呢?

这两层分别是可读性层 和 init 层。

第一部分,只读层。
它是这个容器的 rootfs 最下面的五层,对应的正是 ubuntu:latest 镜像的五层。可以看
到,它们的挂载方式都是只读的(ro+wh,即 readonly+whiteout,至于什么是
whiteout,我下面马上会讲到)。

第二部分,可读写层。
它是这个容器的 rootfs 最上面的一层(6e3be5d2ecccae7cc),它的挂载方式为:rw,
即 read write。在没有写入文件之前,这个目录是空的。而一旦在容器里做了写操作,你
修改产生的内容就会以增量的方式出现在这个层中。
可是,你有没有想到这样一个问题:如果我现在要做的,是删除只读层里的一个文件呢?
为了实现这样的删除操作,AuFS 会在可读写层创建一个 whiteout 文件,把只读层里的文
件“遮挡”起来。
比如,你要删除只读层里一个名叫 foo 的文件,那么这个删除操作实际上是在可读写层创
建了一个名叫.wh.foo 的文件。这样,当这两个层被联合挂载之后,foo 文件就会
被.wh.foo 文件“遮挡”起来,“消失”了。这个功能,就是“ro+wh”的挂载方式,即只
读 +whiteout 的含义。我喜欢把 whiteout 形象地翻译为:“白障”。
所以,最上面这个可读写层的作用,就是专门用来存放你修改 rootfs 后产生的增量,无论
是增、删、改,都发生在这里。而当我们使用完了这个被修改过的容器之后,还可以使用
docker commit 和 push 指令,保存这个被修改过的可读写层,并上传到 Docker Hub
上,供其他人使用;而与此同时,原先的只读层里的内容则不会有任何变化。这,就是增量
rootfs 的好处

第三部分,Init 层。
它是一个以“-init”结尾的层,夹在只读层和读写层之间。Init 层是 Docker 项目单独生成
的一个内部层,专门用来存放 /etc/hosts、/etc/resolv.conf 等信息。
需要这样一层的原因是,这些文件本来属于只读的 Ubuntu 镜像的一部分,但是用户往往
需要在启动容器时写入一些指定的值比如 hostname,所以就需要在可读写层对它们进行
修改。
可是,这些修改往往只对当前的容器有效,我们并不希望执行 docker commit 时,把这些
信息连同可读写层一起提交掉。
所以,Docker 做法是,在修改了这些文件之后,以一个单独的层挂载了出来。而用户执行
docker commit 只会提交可读写层,所以是不包含这些内容的。

通过:

docker inspect 7ebe 可查看.

那么前面提及到了layer,也就是层sha256:01fd6df81c8ec7dd24bbbd72342671f41813f992999a3471b9d9cbc44ad88374,到底在哪呢?

然后查看cacheid 这个,发现:

这个就是我们前面看到的一层哈。

正是我们经常提到的容器镜像,也叫作:rootfs。它只是一个操作系统的所有文件和目录,并不包含
内核,最多也就几百兆。而相比之下,传统虚拟机的镜像大多是一个磁盘的“快照”,磁盘
有多大,镜像就至少有多大。
通过结合使用 Mount Namespace 和 rootfs,容器就能够为进程构建出一个完善的文件系
统隔离环境。当然,这个功能的实现还必须感谢 chroot 和 pivot_root 这两个系统调用切
换进程根目录的能力。
而在 rootfs 的基础上,Docker 公司创新性地提出了使用多个增量 rootfs 联合挂载一个完
整 rootfs 的方案,这就是容器镜像中“层”的概念。

下一节容器的基本知识。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章