深入理解容器镜像

在上一篇文章容器基础之障眼法中讲到,docker容器进程,实际上就是在创建容器进程时,指定了这个进程需要启用的一组Namespace参数,然后为进程设置指定的Cgroups参数。实际上,在创建一个容器的时候,还有一个步骤,也就是本文的重点切换进程的根目录,修改容器进程对文件系统“挂载点”的认知。

在一个容器进程启动后,如果不执行挂载操作,那么容器进程看到的,就是当前宿主机的文件系统。linux提供了一个用于改变进程进程根目录的命令:chroot.
例如:

1
chroot $HOME/tmp /bin/bash

它的作用就是将$HOME/tmp作为 /bin/bash 的根目录。

容器镜像的定义-rootfs根文件系统

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

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

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

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

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

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

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

那么,对于容器来说,这个操作系统的“灵魂”又在哪里呢?

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

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

不过,正是由于 rootfs 的存在,容器才有了一个被反复宣传至今的重要特性:一致性。无论在本地、云端,还是在一台任何地方的机器上,用户只需要解压打包好的容器镜像,那么这个应用运行所需要的完整的执行环境就被重现出来了。

容器镜像的分层

不过,这时你可能已经发现了另一个非常棘手的问题:容器镜像的更新问题。

比如,我现在用 Ubuntu 操作系统的 ISO 做了一个 rootfs,然后又在里面安装了 Java 环境,用来部署我的 Java 应用。那么,我的另一个同事在发布他的 Java 应用时,显然希望能够直接使用我安装过 Java 环境的 rootfs,而不是重复这个流程。

Docker 在镜像的设计中,引入了层(layer)的概念。也就是说,用户制作镜像的每一步操作,都会生成一个层,也就是一个增量 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..."
]
}

事实上,镜像的分层可以分为三个部分:

第一部分:可读写层
最上面这个可读写层的作用,就是专门用来存放你修改 rootfs 后产生的增量,无论是增、删、改,都发生在这里。而当我们使用完了这个被修改过的容器之后,还可以使用 docker commit 和 push 指令,保存这个被修改过的可读写层,并上传到 Docker Hub 上,供其他人使用;

第二部分:Init层
需要这样一层的原因是,这些文件本来属于只读的 Ubuntu 镜像的一部分,但是用户往往需要在启动容器时写入一些指定的值比如 hostname,所以就需要在可读写层对它们进行修改。

可是,这些修改往往只对当前的容器有效,我们并不希望执行 docker commit 时,把这些信息连同可读写层一起提交掉。

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

通过“分层镜像”的设计,以 Docker 镜像为核心,来自不同公司、不同团队的技术人员被紧密地联系在了一起。而且,由于容器镜像的操作是增量式的,这样每次镜像拉取、推送的内容,比原本多个完整的操作系统的大小要小得多;而共享层的存在,可以使得所有这些容器镜像需要的总空间,也比每个镜像的总和要小。这样就使得基于容器镜像的团队协作,要比基于动则几个 GB 的虚拟机磁盘镜像的协作要敏捷得多。

第三部分:只读层
不允许用户修改。用户可以继承自这些公共的分层来打包自己的镜像。

最终,这些所有的分层目录,被联合挂载在一起,形成一个完整的操作系统文件以供容器使用。


总结:
在启动一个容器的时候,会执行以下几个步骤:

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

容器镜像更多的是指rootfs,也就是容器进程运行所需要的运行环境-根文件系统。