学习完 《Dockerfile最佳实践》,就以一个基于 maven 的 Java 项目来实践下如何优化 Dockerfile。
:::info 注意:
要为一个项目制作镜像,首先我们需要明白如果不适用镜像应该怎么来部署这样的一个应用,否则是不可能编写出 Dockerfile;
所以 Dockerfile 的编写,由熟悉项目的开发人员来编写是最高效; :::

构建命令 docker build 的简单介绍

在构建镜像之前有一些概念是一定要理解的。

当我们编写好了 Dockerfile,我们就可以使用构建命令来制作镜像:

  1. $ docker build -t java-web:v1 .

我们可以看到 docker build 命令最后有一个 .. 表示当前目录,而 Dockerfile 就在当前目录,因此不少初学者认为这个路径就是在指定 Dockerfile 所在路径,这么理解其实是不准确的。

docker build 的工作原理

Docker 在运行时分为 Docker Daemon 和 客户端工具。Docker 的引擎提供了一组 REST 风格的 Docker Remote API,而 Docker 客户端工具就是通过这组 API 与 Docker 引擎交互。
因此我们表面上好像是在本地机器执行各种 docker 功能,但实际上,一切都是以远程调用的形式在 Docker 引擎完成。

当我们进行镜像构建的时候,经常会需要将一些本地文件复制进镜像,比如通过 COPYADD 等。而 docker build 命令构建镜像,其实并非在本地构建,而是在服务端。
当构建的时候,用户会指定构建镜像 上下文 的路径,docker build 命令得知这个路径后,会将路径下的所有内容打包,然后上传给 Docker 引擎。这样 Docker 引擎受到这个上下文包后,展开就会获得构建镜像所需的一切文件。

构建上下文

如果在 Dockerfile 中这么写:

  1. COPY ./package.json /app/

这并不是要复制执行 docker build 命令所在的目录下的 package.json,也不是复制 Dockerfile 所在目录下的 package.json,而是复制 上下文(contex) 目录下的 package.json。

因此, COPY 这类指令中的源文件的路径都是 相对路径 。这也是初学者遇到 COPY ../package.json /app 或者 COPY /opt/xxx /app 无法工作的原因,因为这些路径已经超出了上下文的范围,Docker 引擎无法获得这些位置的文件。

在默认情况下,如果不额外指定 Dockerfile 的话,会将上下文目录下的名为 Dockerfile 的文件作为 Dockerfile。这只是默认行为,实际上 Dockerfile 的文件名并不要求必须为 Dockerfile,而且并不要求必须位于上下文目录中,比如可以用-f ../Dockerfile.Dev参数指定某个文件作为 Dockerfile。

.dockerignore

一般来说,应该会将 Dockerfile 置于一个空目录下,或者项目根目录下。如果该目录下没有所需文件,那么应该把所需文件复制一份过来。如果目录下有些东西确实不希望构建时传给 Docker 引擎,那么可以用 .gitignore 一样的语法写一个 .dockerignore,该文件是用于剔除不需要作为上下文传递给 Docker 引擎的。

:::danger 理解 构建上下文 对于镜像构建是很重要的,可以避免犯一些不应该的错误。比如有些初学者在发现 COPY /opt/xxxx /app 不工作后,于是干脆将 Dockerfile 放到了硬盘根目录去构建,结果发现 docker build 执行时发送一个几十 GB 的东西,极为缓慢而且很容易构建失败。那是因为这种做法是在让 docker build 打包整个硬盘,这显然是使用错误。 :::

利用构建缓存,减少构建时间

一个开发周期包括构建 Docker 镜像,更改代码,然后重新构建 Docker 镜像。在构建镜像的过程中,如果能够利用缓存,可以减少不必要的重复构建步骤。

在执行每条指令之前,Docker 都会在缓存中查找是否已经存在可重用的镜像,如果有就使用现存的镜像,不再重复创建。当然如果你不想在构建过程中使用缓存,你可以在 docker build 命令中使用 --no-cache=true 选项。Docker 中构建缓存遵循的基本规则如下:

  • 从一个基础镜像开始(FROM 指令指定),下一条指令将和该基础镜像的所有子镜像进行匹配,检查这些子镜像被创建时使用的指令是否和被检查的指令完全一样。如果不是,则缓存失效。
  • 对于 ADD 和 COPY 指令,镜像中对应文件的内容也会被检查,每个文件都会计算出一个校验值。在缓存的查找过程中,会将这些校验和已存在镜像中的文件校验值进行对比。如果文件有任何改变,则缓存失效。
  • 除了 ADD 和 COPY 指令,缓存匹配过程不会查看临时容器中的文件来决定缓存是否匹配。例如,当执行完 RUN apt-get -y update 指令后,容器中一些文件被更新,但 Docker 不会检查这些文件。这种情况下,只有指令字符串本身被用来匹配缓存。
  • 一旦缓存失效,所有后续的 Dockerfile 指令都将产生新的镜像,缓存不会被使用。


构建顺序影响缓存的利用率

Docker 镜像优化:基于 maven 的 java 项目为例 - 图1
镜像的构建顺序很重要,当你向 Dockerfile 中添加文件,或者修改其中的某一行时,那一部分的缓存就会失效,该缓存的后续步骤都会中断,需要重新构建。
所以优化缓存的最佳方法是把不需要经常更改的行放到最前面,更改最频繁的行放到最后面:

  • 安装构建应用程序所需的依赖工具
  • 安装或更新依赖项
  • 构建你的应用

**

只拷贝需要的文件,防止缓存溢出

Docker 镜像优化:基于 maven 的 java 项目为例 - 图2
当拷贝文件到镜像中时,尽量只拷贝需要的文件,切忌使用 COPY . 指令拷贝整个目录。如果被拷贝的文件内容发生了更改,缓存就会被破坏。在上面的示例中,镜像中只需要构建好的 jar 包,因此只需要拷贝这个文件就行了,这样即使其他不相关的文件发生了更改也不会影响缓存。

最小化可缓存的执行层

Docker 镜像优化:基于 maven 的 java 项目为例 - 图3
每一个 RUN 指令都会被看作是可缓存的执行单元。太多的 RUN 指令会增加镜像的层数,增大镜像体积,而将所有的命令都放到同一个 RUN 指令中又会破坏缓存,从而延缓开发周期。当使用包管理器安装软件时,一般都会先更新软件索引信息,然后再安装软件。推荐将 RUN apt-get updateapt-get install -y 组合成一条 RUN 声明,这样可以形成一个可缓存的执行单元,否则你可能会安装旧的软件包。

减小镜像体积

镜像的体积很重要,因为镜像越小,部署的速度更快,攻击范围越小。

删除不必要依赖

Docker 镜像优化:基于 maven 的 java 项目为例 - 图4
删除不必要的依赖,不要安装调试工具。如果实在需要调试工具,可以在容器运行之后再安装。某些包管理工具(如 apt)除了安装用户指定的包之外,还会安装推荐的包,这会无缘无故增加镜像的体积。apt 可以通过添加参数 -–no-install-recommends 来确保不会安装不需要的依赖项。如果确实需要某些依赖项,请在后面手动添加。

删除包管理工具的缓存

Docker 镜像优化:基于 maven 的 java 项目为例 - 图5
包管理工具会维护自己的缓存,这些缓存会保留在镜像文件中,推荐的处理方法是在每一个 RUN 指令的末尾删除缓存。如果你在下一条指令中删除缓存,不会减小镜像的体积。

当然了,还有其他更高级的方法可以用来减小镜像体积,如下文将会介绍的多阶段构建。接下来我们将探讨如何优化 Dockerfile 的可维护性、安全性和可重复性。

可维护性

尽量使用官方镜像

Docker 镜像优化:基于 maven 的 java 项目为例 - 图6
使用官方镜像可以节省大量的维护时间,因为官方镜像的所有安装步骤都使用了最佳实践。如果你有多个项目,可以共享这些镜像层,因为他们都可以使用相同的基础镜像。

使用更具体的标签

Docker 镜像优化:基于 maven 的 java 项目为例 - 图7
基础镜像尽量不要使用 latest 标签。虽然这很方便,但随着时间的推移,latest 镜像可能会发生重大变化。因此在 Dockerfile 中最好指定基础镜像的具体标签。我们使用 openjdk 作为示例,指定标签为 8。

使用体积最小的基础镜像

Docker 镜像优化:基于 maven 的 java 项目为例 - 图8
基础镜像的标签风格不同,镜像体积就会不同。slim 风格的镜像是基于 Debian 发行版制作的,而 alpine 风格的镜像是基于体积更小的 Alpine Linux 发行版制作的。其中一个明显的区别是:Debian 使用的是 GNU 项目所实现的 C 语言标准库,而 Alpine 使用的是 Musl C 标准库,它被设计用来替代 GNU C 标准库(glibc)的替代品,用于嵌入式操作系统和移动设备。因此使用 Alpine 在某些情况下会遇到兼容性问题。 以 openjdk 为例,jre 风格的镜像只包含 Java 运行时,不包含 SDK,这么做也可以大大减少镜像体积。

重复利用

到目前为止,我们一直都在假设你的 jar 包是在主机上构建的(项目已经生成好 jar 包,直接拷贝到镜像中),这还不是理想方案,因为没有充分利用容器提供的一致性环境。例如,如果你的 Java 应用依赖于某一个特定的操作系统的库,就可能会出现问题,因为环境不一致(具体取决于构建 jar 包的机器)。

在一致的环境中从源代码构建

源代码是你构建 Docker 镜像的最终来源,Dockerfile 里面只提供了构建步骤。
Docker 镜像优化:基于 maven 的 java 项目为例 - 图9
首先应该确定构建应用所需的所有依赖,本文的示例 Java 应用很简单,只需要 MavenJDK,所以基础镜像应该选择官方的体积最小的 maven 镜像,该镜像也包含了 JDK。如果你需要安装更多依赖,可以在 RUN 指令中添加。pom.xml 文件和 src 文件夹需要被复制到镜像中,因为最后执行 mvn package 命令(-e 参数用来显示错误,-B 参数表示以非交互式的“批处理”模式运行)打包的时候会用到这些依赖文件。
虽然现在我们解决了环境不一致的问题,但还有另外一个问题:每次代码更改之后,都要重新获取一遍 pom.xml 中描述的所有依赖项。

下面我们来解决这个问题。

在单独的步骤中获取依赖项

Docker 镜像优化:基于 maven 的 java 项目为例 - 图10
结合前面提到的缓存机制,我们可以让获取依赖项这一步变成可缓存单元,只要 pom.xml 文件的内容没有变化,无论代码如何更改,都不会破坏这一层的缓存。上图中两个 COPY 指令中间的 RUN 指令用来告诉 Maven 只获取依赖项。

现在又遇到了一个新问题:跟之前直接拷贝 jar 包相比,镜像体积变得更大了,因为它包含了很多运行应用时不需要的构建依赖项。

使用多阶段构建来删除构建时的依赖项

Docker 镜像优化:基于 maven 的 java 项目为例 - 图11
多阶段构建可以由多个 FROM 指令识别,每一个 FROM 语句表示一个新的构建阶段,阶段名称可以用 AS 参数指定。本例中指定第一阶段的名称为 builder,它可以被第二阶段直接引用。两个阶段环境一致,并且第一阶段包含所有构建依赖项。

第二阶段是构建最终镜像的最后阶段,它将包括应用运行时的所有必要条件,本例是基于 Alpine 的最小 JRE 镜像。上一个构建阶段虽然会有大量的缓存,但不会出现在第二阶段中。为了将构建好的 jar 包添加到最终的镜像中,可以使用 COPY --from=STAGE_NAME 指令,其中 STAGE_NAME 是上一构建阶段的名称。

Docker 镜像优化:基于 maven 的 java 项目为例 - 图12

多阶段构建是删除构建依赖的首选方案。

本文从在非一致性环境中构建体积较大的镜像开始优化,一直优化到在一致性环境中构建最小镜像,同时充分利用了缓存机制。下一篇文章将会介绍多阶段构建的更多其他用途。

相关引用链接:你确定你会写 Dockerfile 吗?