苏打

docker--容器镜像

2018-03-04 · 10 min read
docker

容器镜像将会成为未来软件的主流发布方式。

Linux 容器最基础的两种技术:Namespace 和 Cgroups。Namespace 的作用是“隔离”,它让应用进程只能看到该 Namespace 内的“世界”;而 Cgroups 的作用是“限制”,它给这个“世界”围上了一圈看不见的墙。可是,还有一个问题不知道你有没有仔细思考过:这个房间四周虽然有了墙,但是如果容器进程低头一看地面,又是怎样一副景象呢?

容器的文件系统

首先我们想到的是这和 Mount Namespace 有关,当我们容器进程启动时指定了 Mount Namespace 那么容器应用就会看到一份完全独立于宿主机的文件系统。

接下来我们做一个测试:
首先我们写一个小程序,该程序启动时会创建一个进程,并指定一个新的 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;
}

这段代码在 main 函数里调用 clone 函数创建一个子进程 container_main,并指定新的 Namespace(CLONE_NEWNS),在子进程内会执行 /bin/bash 命令。

编译执行:

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

执行后会进入这个进程容器中,在容器内执行 ls 命令发现文件和宿主机的内容是一样的。可知,即使开启了 Mount Namespace,容器进程看到的文件系统也跟宿主机完全一样。

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

因此除了开启 Mount Namespace,还需要让容器进程重新挂载文件系统。比如我们重新挂载 /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 目录。

重新编译执行:

$ gcc -o ns ns.c
$ ./ns
Parent - start a container!
Container - inside the container!
$ ls /tmp

发现/tmp目录为空,和宿主机的内容不一致,说明重新挂载生效。

由于重新挂载/tmp的操作是在新的 Namespace 发生的,所以在宿主机看不到这个挂载的信息,可以使用mount -l检查挂载信息,发现挂载不存在。

# 在宿主机上
$ mount -l | grep tmpfs

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

容器镜像

我们实际执行docker容器时看到的是完全独立的文件系统,而不是继承宿主机的文件系统。其实只需要在容器启动时重新挂载整个根文件系统。在linux系统中可以使用chroot命令来实现改变进程的根文件系统到指定目录。

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

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

到目前为止可知,创建一个docker容器最核心的原理就是:

  1. 启用 Linux Namespace 配置
  2. 设置指定的 Cgroups 参数
  3. 切换进程的根目录(Change Root)
    这样,一个完整的容器就诞生了。

需要明确的是,rootfs 只是一个操作系统所包含的文件、配置和目录,并不包括操作系统内核。在 Linux 操作系统中,这两部分是分开存放的,操作系统只有在开机启动时才会加载指定版本的内核镜像。
由于 rootfs 里打包的不只是应用,而是整个操作系统的文件和目录,也就意味着,应用以及它运行所需要的所有依赖,都被封装在了一起。正是由于 rootfs 的存在,容器才有了一个被反复宣传至今的重要特性:一致性。 这种深入到操作系统级别的运行环境一致性,打通了应用在本地开发和远端执行环境之间难以逾越的鸿沟。

镜像分层

我们制作容器镜像时,需要每次都要制作整个 rootfs 吗?显然不需要,而是在别人做好的基础镜像之上来增加我们自定义的内容。这种处理方式其实用到了镜像分层的思想。Docker 在镜像的设计中,引入了层(layer)的概念。也就是说,用户制作镜像的每一步操作,都会生成一个层,也就是一个增量 rootfs。

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

$ tree
.
├── A
│  ├── a
│  └── x
└── B
  ├── b
  └── x

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

$ tree ./C
./C
├── a
├── b
└── x

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

$ tree ./C
./C
├── a
├── b
└── x

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

那么在Docker 项目中,又是如何使用这种 Union File System 的呢?
首先我们启动 dockerd 服务并配置使用 aufs 驱动,然后启动容器

docker run -d ubuntu:latest sleep 3600

这时候,Docker 就会从 Docker Hub 上拉取一个 Ubuntu 镜像到本地。它的内容是 Ubuntu 操作系统的所有文件和目录,由多个“层”组成:

$ docker image inspect ubuntu:latest
...
     "RootFS": {
      "Type": "layers",
      "Layers": [
        "sha256:f49017d4d5ce9c0f544c...",
        "sha256:8f2b771487e9d6354080...",
        "sha256:ccd4d61916aaa2159429...",
        "sha256:c01d74f99de40e097c73...",
        "sha256:268a067217b5fe78e000..."
      ]
    }

可以看到,这个 Ubuntu 镜像,实际上由五个层组成。这五个层就是五个增量 rootfs,每一层都是 Ubuntu 操作系统文件与目录的一部分;而镜像的每一层都放在 /var/lib/docker/aufs/diff 目录下。然后被联合挂载在 /var/lib/docker/aufs/mnt 里面。

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

这个目录下放的就是完整的根文件系统。

从这个结构可以看出来,这个容器的 rootfs 由如下图所示的三部分组成:

  1. 读写层
    可读写层是rootfs最上面一层,可读写,在没有对容器做修改前,这个目录是空的,一旦做了修改,修改部分的内容就会以增量的形式保存在这一层。如果删除只读层的内容,则会在可读写层创建一个whiteout文件,把只读层的文件遮挡起来。
    容器退出时,在可读写层做的任何修改将会消失,除非使用 docker commitdocker push命令保存可读写层的修改并上传到镜像仓库。
  2. 只读层
    它是这个容器的 rootfs 最下面的五层,对应的正是 ubuntu:latest 镜像的五层。可以看到,它们的挂载方式都是只读的(ro+wh,即 readonly+whiteout)
  3. Init层
    它是一个以“-init”结尾的层,夹在只读层和读写层之间。Init 层是 Docker 项目单独生成的一个内部层,专门用来存放 /etc/hosts、/etc/resolv.conf 等信息。
    该层是docker启动容器时为了写入 /etc/hosts、/etc/resolv.conf 等配置信息单独创建的一个镜像层,且执行 docker commit 时不会保存这一层的修改。

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

总结

容器镜像其实就是一个操作系统完整的rootfs,并不包含系统内核,相较于虚拟机镜像来说更加轻量。结合 Mount Namespacerootfs,容器能够为进程构建出一个独立于宿主机环境的完整的文件系统。而在 rootfs的基础上,Docker 公司提出了使用多个增量 rootfs 联合挂载为一个完整 rootfs的方案,这就是镜像中的概念。