容器技术是云原生的核心技术之一,利用容器化技术,可以将微服务以及它所需要的配置、依赖关系、环境变了等都可以便捷地部署到新的服务器节点上,而不用再次重新配置,这就使得微服务具备了强大的可移植性。
二、Docker 的分层设计
分层设计是 Docker 的一个非常核心的实现思路,只有了解分层设计的原理,才能更好地理解 Docker 镜像、容器等运行过程。
1、分层设计与写时拷贝
应用的部署通常需要依赖许多外部环境,如操作系统、应用服务器、虚拟机、库文件、应用程序包和配置文件等。为了实现一次构建,到处运行的目标,必须将这些依赖项打包到一起,以屏蔽环境的差异性。然而,操作系统本身就有数 G 字节的大小,如果将所有依赖项都打包进来,将导致部署包过大,难以快速分发和部署。
为解决部署包过大使分发下载慢的问题,Docker引入了分层设计的概念。将应用分解为多个层次,如操作系统层、库和第三方软件层、应用软件包和配置文件层。如果两个应用共享相同的底层,它们可以共享这些层次。例如,如果应用 A 和应用 B 使用相同的操作系统版本,则它们可以共享操作系统层。当安装应用 A 时,只需下载操作系统层;而安装应用 B 时,则无需再次下载操作系统层,只需下载其依赖包和应用软件包。这种设计大大减少了安装部署时的下载量。
(图片来自于网络)
然而,共享层次也会带来共性和个性的冲突。例如,应用 A 可能需要修改操作系统的某些配置,而应用 B 则不需要。为解决这一冲突,Docker 参考了 Java 的子类继承机制,设计了有优先级的层次。当上层和下层存在相同的文件或配置时,上层将覆盖下层,并以上层的数据为准。每个应用被赋予一个优先级最高的空白层,如果需要修改下层文件,则将该文件复制到优先级最高的空白层进行修改,这种策略称为写时拷贝(Copy-on-Write,COW)。这样,对应用 A 而言,文件已经被成功修改;而对应用 B 而言,文件保持不变。
(图片来自于网络)
Docker 的分层设计和写时拷贝策略有效解决了包含操作系统的应用程序过大的问题,但并未解决虚拟机启动慢的瓶颈。主流虚拟机(如KVM、Xen、VMWare、VirtualBox等)通常较为笨重,因为它们需要通过软件模拟硬件指令来启动,导致启动一个虚拟机通常需要数分钟时间。这显然无法满足云环境下对于快速弹性伸缩的需求。那么,如何实现虚拟机的轻量化呢?
容器类虚拟机,以 LXC 和 OpenVZ 为代表,采用了一种内核虚拟化技术。这些容器与宿主机共享相同的 Linux 内核,无需进行指令级模拟,因此性能消耗非常小,几乎与普通进程相当。Docker 就是利用 LXC(后来发展出Libcontainer)实现了虚拟机的轻量化。
Libcontainer 是 Docker 中用于容器管理的核心组件。它使用 Go 语言编写,通过管理 namespaces、cgroups、capabilities 以及文件系统来控制容器。最初,Docker 采用了基于 LXC 的开源容器管理引擎,但随着发展,Docker 开始设定更广泛的目标,即反向定义容器的实现标准,并将底层实现抽象化为 Libcontainer 的接口。这意味着,底层容器的实现方式变得灵活多样,只要满足 Libcontainer 定义的一组接口,Docker 就能够运行。这也为 Docker 实现全面跨平台带来了可能。
在 Docker 的官方仓库中,只需包含完整的文件系统和程序包,无需动态生成新文件。当将镜像下载到宿主机以提供服务时,可能需要修改文件(如应用启动参数、日志输出),此时就需要空白层进行写时拷贝。因此,Docker 将镜像和容器这两种不同状态进行了区分,如图所示:
(图片来自于网络)
仓库中的应用以镜像的形式存储。将镜像从 Docker 镜像仓库下载到本地宿主机,然后以此镜像为模板启动应用,这便形成了容器。镜像为只读状态,而容器则可读写。它们之间的关系如下图:
(图片来自于网络)
2、镜像分层管理
通过 Docker 的分层设计,我们已经了解了镜像的分层原理。现在,我们将通过具体的例子来看一个镜像层次结构,加深对 Docker 镜像的理解。
Docker 镜像分为基础镜像和扩展镜像。基础镜像提供操作系统内核,通常是各种Linux发行版的镜像,比如 Ubuntu、Debian、CentOS 等。基础镜像有两个含义:
- 从空白镜像构建,不依赖其他镜像
- 其他镜像以基础镜像为基准进行扩展
Linux 系统包含内核空间 kernel 和用户空间 rootfs 两部分。容器使用各自的用户空间 rootfs,但共享宿主机的内核空间 kernel,形成基础镜像的最底层结构。
基础镜像
不同 Linux 发行版的区别主要在用户空间 rootfs,比如 Ubuntu 14.04 使用 upstart 管理服务,apt 管理软件包,而 CentOS 7 使用 systemd 和 yum。这些区别并不大,因为 Linux 内核几乎相同。因此,Docker 能够同时支持多种 Linux 镜像,模拟出多种操作系统环境。
不同 Linux 发行版本的主要区别是 rootfs,kernel 都一样
一台宿主机上的所有容器都共用宿主机的 kernel,在容器中无法对 kernel 升级。大部分镜像是基于基础镜像构建的,所以通常使用的是官方发布的基础镜像,可以在 Docker Hub 里找到。
ADD centos-8-container.tar.xz / 命令将本地的 CentOS 8 的 tar 包添加到镜像,并解压到根目录下,生成 /dev、/proc、/bin、/usr、/var等。我们既可以自己构建基础镜像,也可以直接使用Docker Hub 上已有的基础镜像。最新的 CentOS 镜像只有 220 MB,比较小。这是因为 Docker 镜像在运行时直接使用 Docker 宿主机的 kernel,因此 CentOS 镜像中只包含用户空间 rootfs 部分的程序包。
新镜像类似于 CentOS 镜像,是通过在基础镜像上逐层叠加生成的。每安装一个软件,就在现有镜像的基础上增加一层。如果多个镜像都是从相同的基础镜像构建而来,那么 Docker 宿主机只需在磁盘上保存一份基础镜像,并且在内存中也只需加载一份基础镜像,就可以为所有容器提供服务。此外,镜像的每一层都可以被共享。在创建镜像时,分层设计让 Docker 只保存我们添加和修改的部分内容,其他内容基于基础镜像,无需额外存储,只需读取基础镜像即可。因此,当我们创建多个镜像时,它们都共享基础镜像,节省了磁盘空间。
如果多个容器共享一份基础镜像,那么当某个容器修改了基础镜像的内容(例如 /etc 下的文件),其他容器的 /etc 是否也会被修改呢?当一个容器启动时,会在镜像的顶部加载一个新的可写层,通常称为“容器层”,而位于其下方的部分称为“镜像层”。容器层是可读写的,所有文件变更都发生在这一层,而镜像层则只允许读取。
(容器和镜像分层结构)
镜像的分层结构即镜像制作过程中的操作,通过 docker history <镜像id / 镜像名称> 命令即可查看。
通过 docker image inspect <镜像id / 镜像名称>命令查看镜像的元数据信息,其中包括镜像的分层信息。
3、镜像版本变更管理
在软件开发中,版本迭代和回退是常见的需求。Docker 在版本变更管理方面也有着独特之处。考虑一个应用的 Docker 镜像,比如1.0版本,它包含三层:第一层 1 GB,第二层 300 MB,第三层 10 MB。现在需要做以下修改:
- 在第一层修改文件 A
- 删除第二层的文件 B
- 添加一个新文件 C
对于这些修改需求,Docker 会新增一个第四层,使版本变更为 1.1。具体处理方法如下:
- 将文件A复制到第四层,并修改内容为 A'
- 在第四层中将文件B标记为不存在
- 在第四层创建新文件C
通过增加第四层,完成版本变更为 1.1。上传 1.1 版本到 Docker 镜像仓库时,由于前三层已存在于仓库中,只需上传第四层(大小仅为 3 MB),大大减小了上传程序包的大小。同样,应用方只需下载第四层即可从 1.0 版本快速升级到 1.1 版本,因为它已经拥有了前三层的镜像,使得版本升级变得非常迅速轻量。
总之,Docker不仅能够控制版本变更,还能利用分层特性实现增量更新。这种分层设计对于大规模分布式环境下的版本分发部署至关重要,为云原生环境下的持续集成/持续部署提供了可靠的基础。