容器化进程中的文件系统视图
容器内运行的进程所看到的文件系统是什么样的?人们可能会立刻想到这与 Mount Namespace(挂载命名空间)有关——容器内的进程应该看到一个完全独立的文件系统。这样一来,就可以在容器目录(如 /tmp)中执行操作,而不受宿主机或其他容器的影响。实际情况是这样吗?
Mount Namespace 的作用
Mount Namespace 在功能上与其他命名空间略有不同,它对容器进程文件系统视图的影响只有在执行挂载操作时才会生效。然而,作为普通用户,我们更期望一个更友好的场景:每次创建新容器时,我们都希望容器进程看到的文件系统是一个隔离的环境,而不是从宿主机继承的。如何实现这一点呢?
不难想象,我们可以在启动容器进程之前重新挂载整个根目录“/”。得益于 Mount Namespace,这个挂载对宿主机不可见,从而让容器进程可以自由操作。在 Linux 操作系统中,有一个名为 chroot 的命令,可以方便地在 shell 中完成此任务。顾名思义,它可以帮助你“更改根文件系统”,将进程的根目录重定向到指定位置。
容器镜像与 rootfs
事实上,Mount Namespace 是在对 chroot 不断改进的基础上发明的,并且是 Linux 中的第一个命名空间。为了让这个根目录对容器来说更“真实”,我们通常会在该根目录下挂载一个完整的操作系统文件系统,例如 Ubuntu 16.04 的 ISO 文件。因此,容器启动后,在容器内执行 ls / 就会看到 Ubuntu 16.04 的所有目录和文件。这个挂载在容器根目录上、为容器进程提供隔离执行环境的文件系统,被称为“容器镜像”。它还有一个更技术性的术语:rootfs(根文件系统)。
进入容器后执行的 /bin/bash,就是 /bin 目录下的可执行文件,与宿主机的 /bin/bash 完全不同。现在你应该明白,Docker 项目的核心原理基本就是:为要创建的用户进程配置 Linux Namespace、设置特定的 Cgroups 参数,并更改进程的根目录(Change Root)。这样,一个完整的容器就诞生了。
容器中的共享内核与应用依赖
不过,Docker 在切换的最后一步更倾向于使用 pivot_root 系统调用,如果系统不支持 pivot_root 则回退到 chroot。虽然这两个系统调用的功能类似,但存在细微差别。此外,需要明确的是,rootfs 只包含一个操作系统的文件、配置和目录,但不包含内核。在 Linux 中,这两部分是分开存储的,特定版本的内核镜像仅在启动时加载。因此,rootfs 只包含操作系统的“外壳”,而不包含其“灵魂”。
那么,容器的操作系统“灵魂”在哪里?实际上,同一台机器上的所有容器共享宿主操作系统的内核。这意味着,如果你的应用需要配置内核参数、加载额外的内核模块,或直接与内核交互,你应该注意这些操作和依赖都指向宿主机的操作系统内核,而该内核对于该机器上的所有容器来说是一个“全局变量”。这是容器相比虚拟机的主要缺点之一:虚拟机不仅有模拟硬件作为沙箱,而且每个沙箱内还运行一个完整的客户操作系统(Guest OS)供应用使用。尽管如此,由于 rootfs 的存在,容器拥有一个被广泛宣传的重要特性:一致性。
容器镜像中的层概念
容器的“一致性”是什么?由于云环境和本地服务器环境的差异,应用打包过程一直是使用 PaaS 时最“痛苦”的步骤之一。然而,借助容器,更准确地说是容器镜像(即 rootfs),这个问题得到了优雅的解决。
由于 rootfs 打包的不仅是应用,还包括整个操作系统的文件和目录,因此它封装了应用运行所需的所有依赖。事实上,对于大多数开发者来说,他们对应用依赖的理解一直局限于编程语言层面,例如 Golang 的 Godeps.json。然而,一个长期被忽视的事实是:对于一个应用来说,操作系统本身就是它运行时所需的最完整的“依赖库”。
借助容器镜像“打包操作系统”的能力,这个基础的依赖环境终于成为了应用沙箱的一部分。这赋予了容器被广为称道的一致性:无论是在本地机器、云端还是其他地方,用户只需解压容器镜像,就能重现应用运行所需的完整执行环境。
容器镜像的增量层设计
这种操作系统级别的一致性弥合了应用在本地开发和远程执行环境之间的差距。不过,你可能已经注意到另一个棘手的问题:每次开发新应用或更新现有应用时,我们都需要重新创建 rootfs 吗?一个直观的解决方案可能是,在创建 rootfs 的过程中,每次执行“有意义的”操作后都保存一个 rootfs,这样同事就可以使用他们需要的 rootfs。
然而,这个方案并不具备可扩展性。原因是,一旦同事修改了这个 rootfs,新旧 rootfs 之间就没有了关系,导致极度碎片化。既然这些修改都基于一个旧的 rootfs,我们能否让这些更改变得增量?这种方法的好处是,每个人只需要维护相对于基础 rootfs 的增量内容,而不是每次修改都创建一个“分支”。
答案是肯定的。这正是 Docker 在实现 Docker 镜像时没有遵循创建 rootfs 的标准流程,而是进行了一项小创新的原因:Docker 在其镜像设计中引入了层的概念。用户创建镜像的每个操作都会生成一个层,这是一个增量的 rootfs。这个想法并非凭空而来,而是利用了联合文件系统(Union File System,UnionFS)的能力,其主要功能是将来自不同位置的多个目录联合挂载(union mount)到同一个目录中。
容器中的分层

第一部分:只读层
这是该容器 rootfs 的底部五层,对应 ubuntu:latest 镜像的五层。它们以只读方式挂载(ro+wh,即 readonly+whiteout)。每一层增量地包含 Ubuntu 操作系统的一部分。
第二部分:读写层
这是该容器 rootfs 的最顶层(6e3be5d2ecccae7cc),以 rw(读写)方式挂载。在写入任何文件之前,此目录为空。一旦在容器内执行写操作,修改就会增量地出现在这一层。但你想过如果要从只读层删除一个文件会发生什么吗?为了实现删除,AuFS 会在读写层创建一个 whiteout 文件来“遮蔽”只读层中的文件。例如,从只读层删除名为 foo 的文件,实际上是在读写层创建一个名为 .wh.foo 的文件。这样,当这些层联合挂载时,foo 文件会被 .wh.foo 文件遮蔽而“消失”。这就是“ro+wh”挂载方式所代表的功能,即只读加 whiteout。
第三部分:Init 层
这是 Docker 项目生成的一个内部层,以“-init”结尾,位于只读层和读写层之间。Init 层专门用于存储 /etc/hosts 和 /etc/resolv.conf 等信息。这种层的存在是因为这些文件原本属于只读的 Ubuntu 镜像,但通常需要在容器启动时写入特定的值,例如 hostname。因此,需要在读写层中进行修改。然而,这些修改通常只适用于当前容器,并不希望在执行 docker commit 时与读写层一起提交。因此,Docker 的做法是将这些修改后的文件挂载到一个单独的层中。当用户执行 docker commit 时,只提交读写层,而不包含此内容。
容器镜像分层设计的优势
通过“分层镜像”设计,以 Docker 镜像为核心,来自不同公司和团队的技术人员紧密地联系在一起。此外,由于对容器镜像的操作是增量的,每次拉取或推送的内容远小于多个完整的操作系统;共享层意味着所有这些容器镜像所需的总空间小于每个镜像单独占用空间的总和。这种基于容器镜像的协作敏捷性远远超过可能达数 GB 的虚拟机磁盘镜像。
更重要的是,一旦镜像发布,在全球任何地方下载都会得到完全相同的内容,完全重现镜像创建者制作的原始环境。
容器镜像对软件开发生命周期的影响
容器镜像的发明不仅衔接了“开发-测试-部署”流程的每一步,还标志着容器镜像将成为未来软件分发的主流方式。这种分发方式具有轻量级、高度一致、便于协作等优势,使软件开发和部署更加高效可靠。凭借其轻量、一致和高效的特性,容器技术正日益成为软件开发和运维中不可或缺的工具。随着技术的不断发展和创新,我们有理由相信容器技术将在未来发挥更重要的作用。
Novita AI 是一站式平台,为您提供无限创意,可访问超过 100 个 API。从图像生成到语言处理,从音频增强到视频编辑,按使用量付费,让您在构建自己的产品时无需为 GPU 维护烦恼。免费试用。
