书接上文
各位同学,通过上一章的内容我们知道了 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 显然又无法承担起这个责任,应该怎么办呢?
对了,利用挂载!要持久化容器中的内容,用挂载的方式不就解决了吗?对于 npm 来说,我们把 node_modles
挂载到宿主机中不就解决这个问题了吗?
挂载
挂载的确是个好办法!但是我不得不给你浇一盆冷水。因为通常我们所说的挂载是一个运行时行为。……好吧,什么是运行时行为?
你可能很熟悉下面这样的挂载方式:把容器的 /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 这个默认的缓存位置。将这个缓存位置挂载到宿主机中后,这个持久化的缓存结果会在不同的构建过程中持续存在,因此即使最终重建了层,也只会下载新的或已更改的软件包。
综上所述
如果我们联合这两个命令,就可以达成这样一种效果:
- 在编译时,利用
bind mounts
命令将当前源代码目录临时挂载到容器编译期(这避免了使用 COPY 来创建多余的层) - 紧接着使用
cache mounts
配合构建命令如:npm install
(这由你使用的构建工具来决定)来执行构建并自动把指定的工具使用的缓存目录挂载到宿主机中; - 然后再利用
bind mounts
用完即抛的特性,把构建时临时挂载到镜像中的源代码等内容从镜像中清楚掉。 - 最终我们得到了一个干净的只包含构建产物的镜像。并且,这一次的构建过程已经成功被缓存了起来,下一次构建时 Docker 会自动利用这个缓存,执行构建工具本身具备的重要功能:增量构建。
那么重要的问题来了,哪里能找到这两个命令联合使用的 Java 代码示例呢?
在这个Github 仓库 中你便可找到它。我已经为你写好了基于 Java 的构建示例的 Dockerfile,你只需要在今后的项目中照抄即可。
对了,如果你没有忘记给仓库一个 Star,便可得好运相随。
性能对比
使用增量构建到底能节约多少时间?在我的项目的测试中,如果不使用增量构建,在完善的网络环境条件下,每次构建需要 6-7 分钟时间。如果使用增量构建,第一次构建需要 3-5 分钟;而后续构建只需要 1 分到 1 分 30 秒,这是非常巨大的性能差距。
写在最后
- 我是 Chuck1sn,一个长期致力于现代 Jvm 生态推广的开发者。
- 您的回帖、点赞、收藏、就是我持续更新的动力。
- 举手之劳的一键三连,对我来说是莫大的支持,非常感谢!
- 关注我的账号,第一时间收到文章推送。