《你不知道的 Java》🔥 寻找与 Docker 集成的方法(第二章)

书接上文

各位同学,通过上一章的内容我们知道了 Docker 的运行机制和缓存的作用原理。这一章节我们书接上文,告诉大家 Docker 的原生缓存无法应对的情况以及处理办法。

过激的 Layer 缓存

上一章节中,我们提到过利用「 指令的分割与排序」的手段来加速构建过程的方法,如下所示:

FROM node
WORKDIR /app
COPY package.json yarn.lock .    # 先拷贝依赖定义文件
RUN npm install                  # 安装依赖文件
COPY . .                         # 再拷贝项目文件
RUN npm build                    # 构建镜像

这种方法看似很完美,实际却有一个重要缺陷:当你的 package.json 等依赖定义文件发生变化时,当前的 Layer 极其下属都会失效。也就是说,无论你是增加还是删除任何一个小依赖,那所有的依赖项都需要重新安装。
在一个大项目中,任何细微的改动都触发成千上万的依赖重新解析与安装的成本实在太大了;好在任何成熟的构建工具,都提供一个基本功能叫「增量构建」。

增量构建

增量构建包括一系列增量行为,其中就有依赖库的分析与增量更新。我们还是以 npm 举例:当你的 package.json 中新增了 axios 这个依赖时,npm 不会重新解析和下载 loadash 和 express 这两个库,只会重新下载 axios 这个库。

package.json:
- lodash@^4.17.21
- express@^4.18.2
- axios@^1.5.0  # 新增

node_modules:
- [email protected]
- [email protected]
- [email protected]   # 增量下载

不用去深究这些工具的执行原理,任何与增量相关的行为都逃不开保存上一次执行结果作为缓存的方法;但上一期所讲的 Layer 显然又无法承担起这个责任,应该怎么办呢:thinking:

对了,利用挂载!要持久化容器中的内容,用挂载的方式不就解决了吗?对于 npm 来说,我们把 node_modles 挂载到宿主机中不就解决这个问题了吗?

挂载

挂载的确是个好办法!但是我不得不给你浇一盆冷水。因为通常我们所说的挂载是一个运行时行为。……好吧,什么是运行时行为:face_with_raised_eyebrow:

你可能很熟悉下面这样的挂载方式:把容器的 /path/in/container 路径挂载到宿主机的 pwd 命令即当前目录中。

# bind mount using the -v flag
docker run -v $(pwd):/path/in/container image-name

但请注意,这里的挂载指的是容器运行时所产生的任何文件的挂载。我们要解决的问题,是在 Dockerfile 编译时将构建的中间产物挂载到宿主机中持久化保存。而 Docker 的编译期是一个临时性的一次性行为,而这时候容器还没有开始运行呢。

编译时挂载

如何在编译时挂载文件呢?其实这个问题困扰了社区很多年,为此社区发明了各种各样千奇百怪的方案来解决这个问题。不过好消息是混乱的纪元已经过去,现在我们已经已经有了官方的解决方案!

接下来我们将要讲到两个命令,通过这两个命令的联合使用便可解决这个令人头疼的难题。

Bind Mounts

首先一个叫做 bind mounts 的命令可以提供在镜像编译时,临时将宿主机的某个目录挂载到容器中的功能。这意味着你不再需要 COPY 指令了,而这也意味着这样的命令不会形成 Layer 也就不存在 Layer 失效的问题。

FROM golang:latest
WORKDIR /app
RUN --mount=type=bind,target=. go build -o /app/hello

在上面这个示例中,当前目录在 go 构建命令执行前被挂载到构建容器中。在 RUN 指令执行期间,源代码可在构建容器中使用。指令执行完毕后,加载的文件不会在最终镜像或缓存中保留。只有 go 构建命令的输出会保留下来。使用这个命令完成的构建是干净的:在镜像中不需要源代码,你只需要编译结果。

Cache Mounts

紧接着的一个命令叫做 cache mounts,它提供了一种编译时的全自动的挂载行为,自动将容器中的指定目录挂载到宿主机的磁盘中的行为。具体是哪个盘符的目录呢?你不需要关心,Docker 会自动处理好宿主机的保存路径,你只需要告诉他你想要持久化保存的容器目录就行了。

FROM node:latest
WORKDIR /app
RUN --mount=type=cache,target=/root/.npm npm install

在本例中,npm install 命令使用了 root/.npm 这个默认的缓存位置。将这个缓存位置挂载到宿主机中后,这个持久化的缓存结果会在不同的构建过程中持续存在,因此即使最终重建了层,也只会下载新的或已更改的软件包。

综上所述

如果我们联合这两个命令,就可以达成这样一种效果:

  1. 在编译时,利用 bind mounts 命令将当前源代码目录临时挂载到容器编译期(这避免了使用 COPY 来创建多余的层)
  2. 紧接着使用 cache mounts 配合构建命令如:npm install(这由你使用的构建工具来决定)来执行构建并自动把指定的工具使用的缓存目录挂载到宿主机中;
  3. 然后再利用 bind mounts 用完即抛的特性,把构建时临时挂载到镜像中的源代码等内容从镜像中清楚掉。
  4. 最终我们得到了一个干净的只包含构建产物的镜像。并且,这一次的构建过程已经成功被缓存了起来,下一次构建时 Docker 会自动利用这个缓存,执行构建工具本身具备的重要功能:增量构建。

那么重要的问题来了,哪里能找到这两个命令联合使用的 Java 代码示例呢?

在这个Github 仓库 中你便可找到它。我已经为你写好了基于 Java 的构建示例的 Dockerfile,你只需要在今后的项目中照抄即可。

对了,如果你没有忘记给仓库一个 Star,便可得好运相随。:wink:

性能对比

使用增量构建到底能节约多少时间?在我的项目的测试中,如果不使用增量构建,在完善的网络环境条件下,每次构建需要 6-7 分钟时间。如果使用增量构建,第一次构建需要 3-5 分钟;而后续构建只需要 1 分到 1 分 30 秒,这是非常巨大的性能差距。

写在最后

  • 我是 Chuck1sn,一个长期致力于现代 Jvm 生态推广的开发者。
  • 您的回帖、点赞、收藏、就是我持续更新的动力。
  • 举手之劳的一键三连,对我来说是莫大的支持,非常感谢!
  • 关注我的账号,第一时间收到文章推送。
30 个赞

太强了,感谢教程

2 个赞

太强了,感谢教程!!!

1 个赞

大佬,太强了,感谢教程

1 个赞

谢谢大帅哥。

现在 不是应该推荐用 multi-stage build 来干这活么…

谢谢佬友的支持

1 个赞

期待后续的技术文章,加油。

1 个赞

进我的书签吧!我晚点看!

1 个赞

有个问题,为什么一定要在一个dockerfile中完成编译+部署呢?不应该更推荐使用多阶段构建或者在打包镜像之前先编译好,在打包阶段直接拿最后的产物

multi-stage 的确可以在这个的基础上,更加进一步优化编译过程。我也喜欢用 multi-stage,还特地在签名的产品里增加了这个功能作为选配项,供用户下载使用。

不过这篇文章主要是介绍 docker 和相关的缓存技术为主,暂时未提及 multi-stage 的相关内容。

后面我可以再开一个帖子讲一下 multi-stage 作为第三章的内容。佬友可以关注我的账号哈。

厉害厉害,学习一下,chapter1

2 个赞

佬友可以看这里的回复。

可以关注我的账号,接收后面的文章更新。

谢谢佬友的资瓷。

1 个赞

谢谢佬友的支持

感谢老友支持!

可问题是打包是在打包机器上发生的…如果是增量的依赖打包机上并不存在…
我也比较喜欢多阶段构建…

1 个赞

所以就要看我这篇文章介绍的内容,要仔细看哈。

感谢佬的分享,很有用,学习了

谢谢佬友支持。也可以搭配签名中的项目使用,这样体会会更加深入。