02-容器镜像

0.1. 概念

Linux容器最基础的两种技术:

  • Namespace:作用“隔离”,让应用进程只能看到该Namespace内的世界
  • Cgroups:作用“限制”,给上面的世界围上一圈看不见的墙

容器的本质是一种特殊的进程。 被以上两种技术装进了一个被隔离的空间中。这个空间就是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;
}
  1. 在main函数里,通过clone()系统调用创建了一个新的子进程container_main,并且声明要为它启用Mount Namespace(即:CLONE_NEWNS标志)
  2. 在子进程中执行的是/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”,即改变进程的根目录到指定的位置

0.2. 典型示例

现在有一个$home/test目录,想要把它作为一个/bin/bash进程的根目录。

  • 创建test目录和相关的lib文件夹:
mkdir -p $HOME/test
mkdir -p $HOME/test/{bin,lib64,lib}
cd $T
  • 把bash命令拷贝到test目录对应的bin路径下:
cp -v /bin/{bash,ls} $HOME/test/bin
  • 把 bash 命令需要的所有 so 文件,也拷贝到 test目录对应的lib路径下。(找不到so文件可用ldd命令):
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进程的根目录:
chroot $HOME/test /bin/bash

此时执行 ls / 返回的都是$HOME/test目录下的内容,而不是宿主机的内容。

对于被chroot的进程来说,它不会感受到自己的根目录被修改了。

0.3. 容器镜像

为了让容器中的根目录看起来更加的真实,一般会在容器的根目录下挂载一个完整操作系统的文件系统,比如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项目来说,最核心的原理实际上就是为待创建的用户进程

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

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

注意:rootfs只是一个操作系统包含的文件、配置和目录,并不包括操作系统的内核。

在Linux系统中,操作系统内核和操作系统包含的文件、配置和目录是分开存放的,只有在开机启动的时候,操作系统才会加载指定版本的内核镜像。

rootfs只包含操作系统的躯壳,不包括操作系统的灵魂。同一台机器上的所有容器都共享宿主机操作系统的内核。

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

这是容器相比虚拟机的主要缺陷之一

由于云端和本地服务器环境不同,应用的打包过程,一直是使用PaaS时最痛苦的一个步骤。但有了容器镜像(即rootfs)之后,这个问题就被优雅的解决了。rootfs的存在,保证了容器的一致性

  • rootfs里打包的不只是应用,而是整个操作系统的文件和目录,即:应用以及它运行所需的依赖,都被封装在了一起
  • 容器镜像“打包操作系统”的能力,使得最基础的依赖环境也变成了应用沙盒的一部分,这就赋予了容器所谓的一致性

对应用依赖的理解,不能局限于编程语言层面,对于一个应用来说,操作系统本身才是它所需要的最完整的“依赖库”。

无论是在本地还是云端,只要解压打包好的容器镜像,那么这个应用运行所需的完整的执行环境就被重现出来了。

这种深入到操作系统级别的运行环境一致性,打通了应用在本地开发和云端执行之间的鸿沟

0.4. 联合文件系统

思考另一个问题,是否在每次开发或者升级应用的时候,都要重复制作一次rootfs?

  • 既然这些修改都是基于一个旧的rootfs,以增量的方式去做修改
  • 所有人都只维护相对于base rootfs修改的增量内容,而不是每次修改都制造一个“fork”

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

实现这个想法,使用了联合文件系统(Union File System)的能力。

0.4.1. 示例

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

  • 两个目录A和B:
tree
.
├── A
│  ├── a
│  └── x
└── B
  ├── b
  └── x
  • 使用联合挂在的方式,将这两个目录挂载到一个公共的目录C:
mkdir C
mount -t aufs -o dirs=./A:./B none ./C
  • 查看目录C的内容:
tree ./C
./C
├── a
├── b
└── x

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

0.4.2. Docker中使用Union File System

系统版本:

  • Ubuntu 16.04
  • Docker CE 18.05

这对组合默认使用AuFS,可以使用docker info命令查看到这些信息。

0.4.2.1. AuFS

AuFS 名字的进化过程:

AnotherUnionFS-->AlternativeUnionFS
AlternativeUnionFS-->AdvanceUnionFS

从名字可以发现:

  1. AuFS是对Linux原生UnionFS的重写和改进
  2. AuFS没有进入Linux内核的主干,只在Ubuntu和Debian这些发行版中使用

对于AuFS来说,最关键的目录结构在 /var/lib/docker 路径下的 diff 目录:

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

通过下面的例子来学习该目录的作用。

0.4.2.2. 样例

启动容器:

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..."
      ]
    }
  • 该Ubuntu镜像实际上由5层组成,这5层就是5个增量rootfs,每一层都是Ubuntu操作系统文件和目录的一部分。
  • 在使用镜像时,Docker会把这些增量联合挂载在一个统一的挂载点上(等价于上面例子中的目录c)

这个挂载点就是 /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由下图所示的三部分组成:

image

0.4.2.2.1. 第一部分 只读层

它是这个容器的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操作系统的一部分。

0.4.2.2.2. 第二部分 可读可写层

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

为了实现删除文件的操作,AuFS在可读写层创建一个whiteout文件,把只读层里的文件“遮挡”起来。

比如要删除只读层中的foo文件,那么这个删除操作实际上是在可读写层创建了一个名为.wh.foo的文件。这样,当这两个层被联合挂载之后,foo文件就会被.wh.foo文件“遮挡”起来。这个功能就是“ro+wh”的挂载方式,即只读+witheout。

所以:

  • 最上面的可读写层的作用就是专门用来存放修改rootfs后产生的增量,无论是增删改都发生在这里。
  • 当使用完了这个修改过的容器之后,可以使用docker commit 和docker push指令,保存这个被修改过的可读写层,并上传到Docker Hub上。
  • 与此同时,原先的只读层里的内容不会有任何变化。

这就是增量rootfs的好处

0.4.2.2.3. 第三部分 Init层

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

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

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

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

0.5. 总结

  1. 通过使用Mount Namespace和rootfs,容器就能够为进程构建出一个完善的文件系统隔离环境。这个功能的实现必须感谢 chrootpivot_root 这两个系统调用切换进程根目录的能力。
  2. 在rootfs的基础上,Docker公司创新性地提出了使用多个增量rootfs联合挂载一个完整rootfs的方案,这就是容器镜像中 的概念。
  3. 容器镜像的发明,不仅打通了“开发--测试--部署”流程的每一个环节,更重要的是:容器镜像将会成为未来软件的主流发布方式。

0.5.1. copy-on-write

  • 上面的读写层通常也称为容器层
  • 下面的只读层称为镜像层

所有的增删查改操作都只会作用在容器层,相同的文件上层会覆盖掉下层。镜像文件的修改,比如修改一个文件的时候,首先会从上到下查找有没有这个文件,找到,就复制到容器层中,修改,修改的结果就会作用到下层的文件,这种方式也被称为copy-on-write

0.5.2. Docker 支持的UnionFS

包括但不限于以下这几种:aufs, device mapper, btrfs, overlayfs, vfs, zfs。

  • aufs是ubuntu 常用的
  • device mapper 是 centos
  • btrfs 是 SUSE
  • overlayfs ubuntu 和 centos 都会使用,现在最新的 docker 版本中默认两个系统都是使用的 overlayfs
  • vfs 和 zfs 常用在 solaris 系统
上次修改: 14 April 2020