Docker镜像优化
# 多阶段构建
要想大幅度减少镜像的体积,多阶段构建是必不可少的。多阶段构建的想法很简单:“我不想在最终的镜像中包含一堆 C 或 Go 编译器和整个编译工具链,我只要一个编译好的可执行文件!”
多阶段构建可以由多个 FROM
指令识别,每一个 FROM
语句表示一个新的构建阶段,阶段名称可以用 AS
参数指定,例如:
FROM gcc AS mybuildstage
COPY hello.c .
RUN gcc -o hello hello.c
FROM ubuntu
COPY hello .
CMD ["./hello"]
2
3
4
5
6
7
本例使用基础镜像 gcc
来编译程序 hello.c
,然后启动一个新的构建阶段,它以 ubuntu
作为基础镜像,将可执行文件 hello
从上一阶段拷贝到最终的镜像中。最终的镜像大小是 64 MB
,比之前的 1.1 GB
减少了 95%
:
$ docker images minimage
REPOSITORY TAG ... SIZE
minimage hello-c.gcc ... 1.14GB
minimage hello-c.gcc.ubuntu ... 64.2MB
2
3
4
# FROM scratch 的魔力
回到我们的 hello world
,C 语言版本的程序大小为 16 kB
,Go 语言版本的程序大小为 2 MB
,那么我们到底能不能将镜像缩减到这么小?能否构建一个只包含我需要的程序,没有任何多余文件的镜像?
答案是肯定的,你只需要将多阶段构建的第二阶段的基础镜像改为 scratch
就好了。scratch
是一个虚拟镜像,不能被 pull,也不能运行,因为它表示空、nothing!这就意味着新镜像的构建是从零开始,不存在其他的镜像层。例如:
FROM golang
COPY hello.go .
RUN go build hello.go
FROM scratch
COPY /go/hello .
CMD ["./hello"]
2
3
4
5
6
7
这一次构建的镜像大小正好就是 2 MB
,堪称完美!
然而,但是,使用 scratch
作为基础镜像时会带来很多的不便,且听我一一道来。
# 缺少 shell
scratch
镜像的第一个不便是没有 shell
,这就意味着 CMD/RUN
语句中不能使用字符串,例如:
...
FROM scratch
COPY /go/hello .
CMD ./hello
2
3
4
如果你使用构建好的镜像创建并运行容器,就会遇到下面的报错:
docker: Error response from daemon: OCI runtime create failed: container_linux.go:345: starting container process caused "exec: \"/bin/sh\": stat /bin/sh: no such file or directory": unknown.
从报错信息可以看出,镜像中并不包含 /bin/sh
,所以无法运行程序。这是因为当你在 CMD/RUN
语句中使用字符串作为参数时,这些参数会被放到 /bin/sh
中执行,也就是说,下面这两条语句是等效的:
CMD ./hello
CMD /bin/sh -c "./hello"
2
解决办法其实也很简单 : 使用 JSON 语法取代字符串语法。 例如,将 CMD ./hello
替换为 CMD ["./hello"]
,这样 Docker 就会直接运行程序,不会把它放到 shell 中运行。
# 缺少调试工具
scratch
镜像不包含任何调试工具,ls
、ps
、ping
这些统统没有,当然了,shell 也没有(上文提过了),你无法使用 docker exec
进入容器,也无法查看网络堆栈信息等等。
如果想查看容器中的文件,可以使用 docker cp
;如果想查看或调试网络堆栈,可以使用 docker run --net container:
,或者使用 nsenter
;为了更好地调试容器,Kubernetes 也引入了一个新概念叫 Ephemeral Containers (opens new window),但现在还是 Alpha 特性。
虽然有这么多杂七杂八的方法可以帮助我们调试容器,但它们会将事情变得更加复杂,我们追求的是简单,越简单越好。
折中一下可以选择 busybox
或 alpine
镜像来替代 scratch
,虽然它们多了那么几 MB,但从整体来看,这只是牺牲了少量的空间来换取调试的便利性,还是很值得的。
# 缺少 libc
这是最难解决的问题。使用 scratch
作为基础镜像时,Go 语言版本的 hello world
跑得很欢快,C 语言版本就不行了,或者换个更复杂的 Go 程序也是跑不起来的(例如用到了网络相关的工具包),你会遇到类似于下面的错误:
standard_init_linux.go:211: exec user process caused "no such file or directory"
从报错信息可以看出缺少文件,但没有告诉我们到底缺少哪些文件,其实这些文件就是程序运行所必需的动态库(dynamic library)。
那么,什么是动态库?为什么需要动态库?
所谓动态库、静态库,指的是程序编译的链接阶段,链接成可执行文件的方式。静态库指的是在链接阶段将汇编生成的目标文件.o 与引用到的库一起链接打包到可执行文件中,因此对应的链接方式称为静态链接(static linking)。而动态库在程序编译时并不会被连接到目标代码中,而是在程序运行时才被载入,因此对应的链接方式称为动态链接(dynamic linking)。
90 年代的程序大多使用的是静态链接,因为当时的程序大多数都运行在软盘或者盒式磁带上,而且当时根本不存在标准库。这样程序在运行时与函数库再无瓜葛,移植方便。但对于 Linux 这样的分时系统,会在同一块硬盘上并发运行多个程序,这些程序基本上都会用到标准的 C 库,这时使用动态链接的优点就体现出来了。使用动态链接时,可执行文件不包含标准库文件,只包含到这些库文件的索引。例如,某程序依赖于库文件 libtrigonometry.so
中的 cos
和 sin
函数,该程序运行时就会根据索引找到并加载 libtrigonometry.so
,然后程序就可以调用这个库文件中的函数。
使用动态链接的好处显而易见:
- 节省磁盘空间,不同的程序可以共享常见的库。
- 节省内存,共享的库只需从磁盘中加载到内存一次,然后在不同的程序之间共享。
- 更便于维护,库文件更新后,不需要重新编译使用该库的所有程序。
严格来说,动态库与共享库(shared libraries)相结合才能达到节省内存的功效。Linux 中动态库的扩展名是 .so
( shared object
),而 Windows 中动态库的扩展名是 .DLL
(Dynamic-link library (opens new window))。
回到最初的问题,默认情况下,C 程序使用的是动态链接,Go 程序也是。上面的 hello world
程序使用了标准库文件 libc.so.6
,所以只有镜像中包含该文件,程序才能正常运行。使用 scratch
作为基础镜像肯定是不行的,使用 busybox
和 alpine
也不行,因为 busybox
不包含标准库,而 alpine 使用的标准库是 musl libc
,与大家常用的标准库 glibc
不兼容,后续的文章会详细解读,这里就不赘述了。
那么该如何解决标准库的问题呢?有三种方案。
# 1. 使用静态库
我们可以让编译器使用静态库编译程序,办法有很多,如果使用 gcc 作为编译器,只需加上一个参数 -static
:
$ gcc -o hello hello.c -static
编译完的可执行文件大小为 760 kB
,相比于之前的 16kB
是大了好多,这是因为可执行文件中包含了其运行所需要的库文件。编译完的程序就可以跑在 scratch
镜像中了。
如果使用 alpine 镜像作为基础镜像来编译,得到的可执行文件会更小(< 100kB),下篇文章会详述。
# 2. 拷贝库文件到镜像中
为了找出程序运行需要哪些库文件,可以使用 ldd
工具:
$ ldd hello
linux-vdso.so.1 (0x00007ffdf8acb000)
libc.so.6 => /usr/lib/libc.so.6 (0x00007ff897ef6000)
/lib64/ld-linux-x86-64.so.2 => /usr/lib64/ld-linux-x86-64.so.2 (0x00007ff8980f7000)
2
3
4
从输出结果可知,该程序只需要 libc.so.6
这一个库文件。linux-vdso.so.1
与一种叫做 VDSO (opens new window) 的机制有关,用来加速某些系统调用,可有可无。ld-linux-x86-64.so.2
表示动态链接器本身,包含了所有依赖的库文件的信息。
你可以选择将 ldd
列出的所有库文件拷贝到镜像中,但这会很难维护,特别是当程序有大量依赖库时。对于 hello world
程序来说,拷贝库文件完全没有问题,但对于更复杂的程序(例如使用到 DNS 的程序),就会遇到令人费解的问题:glibc
(GNU C library)通过一种相当复杂的机制来实现 DNS,这种机制叫 NSS
(Name Service Switch, 名称服务开关)。它需要一个配置文件 /etc/nsswitch.conf
和额外的函数库,但使用 ldd
时不会显示这些函数库,因为这些库在程序运行后才会加载。如果想让 DNS 解析正确工作,必须要拷贝这些额外的库文件(/lib64/libnss_*
)。
不建议直接拷贝库文件,因为它非常难以维护,后期需要不断地更改,而且还有很多未知的隐患。
# 3. 使用 busybox:glibc
作为基础镜像
有一个镜像可以完美解决所有的这些问题,那就是 busybox:glibc
。它只有 5 MB
大小,并且包含了 glibc
和各种调试工具。如果你想选择一个合适的镜像来运行使用动态链接的程序,busybox:glibc
是最好的选择。
注意:如果你的程序使用到了除标准库之外的库,仍然需要将这些库文件拷贝到镜像中。
最后来对比一下不同构建方法构建的镜像大小:
- 原始的构建方法:1.14 GB
- 使用
ubuntu
镜像的多阶段构建:64.2 MB - 使用
alpine
镜像和静态glibc
:6.5 MB - 使用
alpine
镜像和动态库:5.6 MB - 使用
scratch
镜像和静态glibc
:940 kB - 使用
scratch
镜像和静态musl libc
:94 kB
最终我们将镜像的体积减少了 99.99%
。
但我不建议使用 sratch 作为基础镜像,因为调试起来非常麻烦。
# 针对不同语言的精简策略
# Go 语言镜像精简
Go
语言程序编译时会将所有必须的依赖编译到二进制文件中,但也不能完全肯定它使用的是静态链接,因为 Go
的某些包是依赖系统标准库的,例如使用到 DNS 解析的包。只要代码中引入了这些包,Go 就需要生成一个调用系统库的二进制文件。为了这个需求,Go 实现了一种机制叫 cgo
,它允许 Go 调用 C 代码,并生成一个动态可执行文件,引用它需要调用的系统库。
也就是说,如果 Go 程序使用了 net
包,就会生成一个动态的二进制文件,如果想让镜像能够正常工作,必须将需要的库文件复制到镜像中,或者直接使用 busybox:glibc
镜像。
当然,你也可以禁止 cgo
,这样 Go 就不会使用系统库,使用内置的实现来替代系统库(例如使用内置的 DNS 解析器),这种情况下生成的二进制文件就是静态的。可以通过设置环境变量 CGO_ENABLED=0
来禁用 cgo,例如:
FROM golang
COPY whatsmyip.go .
ENV CGO_ENABLED=0
RUN go build whatsmyip.go
FROM scratch
COPY /go/whatsmyip .
CMD ["./whatsmyip"]
2
3
4
5
6
7
8
由于编译生成的是静态二进制文件,因此可以直接跑在 scratch
镜像中。
当然,也可以不用完全禁用 cgo
,可以通过 -tags
参数指定需要使用的内建库,例如 -tags netgo
就表示使用内建的 net 包,不依赖系统库:
$ go build -tags netgo whatsmyip.go
这样指定之后,如果导入的其他包都没有用到系统库,那么编译得到的就是静态二进制文件。也就是说,只要还有一个包用到了系统库,都会开启 cgo
,最后得到的就是动态二进制文件。要想一劳永逸,还是设置环境变量 CGO_ENABLED=0
吧。
# Java 语言镜像精简
Java
属于编译型语言,但运行时还是要跑在 JVM
中。那么对于 Java 语言来说,该如何使用多阶段构建呢?
# 静态还是动态?
从概念上来看,Java 使用的是动态链接,因为 Java 代码需要调用 JVM
提供的 Java API
,这些 API 的代码都在可执行文件之外,通常是 JAR
文件或 WAR
文件。
然而这些 Java 库并不是完全独立于系统库的,某些 Java 函数最终还是会调用系统库,例如打开文件时需要调用 open()
, fopen()
或它们的变体,因此 JVM
本身可能会与系统库动态链接。
这就意味着理论上可以使用任意的 JVM
来运行 Java 程序,系统标准库是 musl libc
还是 glibc
都无所谓。因此,也就可以使用任意带有 JVM
的基础镜像来构建 Java 程序,也可以使用任意带有 JVM
的镜像作为运行 Java 程序的基础镜像。
# 类文件格式
Java 类文件(Java 编译器生成的字节码)的格式会随着版本而变化,且大部分变化都是 Java API
的变化。还有一部分更改与 Java 语言本身有关,例如 Java 5
中添加了泛型,这种变化就可能会导致类文件格式的变化,从而破坏与旧版本的兼容性。
所以默认情况下,使用给定版本的 Java 编译器编译的类不能与更早版本的 JVM 兼容,但可以指定编译器的 -target
(Java 8 及其以下版本)参数或者 --release
(Java 9 及其以上版本)参数来使用较旧的类文件格式。--release
参数还可以指定类文件的路径,以确保程序运行在指定的 JVM 版本中(例如 Java 11),不会意外调用 Java 12 的 API。
# JDK vs JRE
如果你对大多数平台上的 Java 打包方式很熟悉,那你应该知道 JDK
和 JRE
。
JRE
即 Java 运行时环境(Java Runtime Environment
),包含了运行 Java 程序所需要的环境,即JVM
。JDK
即 Java 开发工具包(Java Development Kit
),既包含了 JRE,也包含了开发 Java 程序所需的工具,即 Java 编译器。
大多数 Java 镜像都提供了 JDK 和 JRE 两种标签,因此可以在多阶段构建的 build
阶段使用 JDK
作为基础镜像,run
阶段使用 JRE
作为基础镜像。
# Java vs OpenJDK
推荐使用 openjdk
,因为开源啊,更新勤快啊~~
也可以使用 amazoncorretto (opens new window),这是 Amazon
fork OpenJDK 后打了补丁的版本,号称企业级。
# 开始构建
说了那么多,到底该用哪个镜像呢?这里给出几个参考:
- openjdk:8-jre-alpine(85MB)
- openjdk:11-jre(267MB)或者 openjdk:11-jre-slim(204MB)
- openjdk:14-alpine(338MB)
如果你想要更直观的数据,可以看我的例子,还是搬出屡试不爽的 『hello world』,只不过这次是 Java 版本:
class hello {
public static void main(String [] args) {
System.out.println("Hello, world!");
}
}
2
3
4
5
不同构建方法得到的镜像大小:
- 使用基础镜像
java
构建:643MB - 使用基础镜像
openjdk
构建:490MB - 多阶段构建,build 阶段使用基础镜像
openjdk
,run 阶段使用基础镜像openjdk:jre
:479MB - 使用基础镜像
amazoncorretto
构建:390MB - 多阶段构建,build 阶段使用基础镜像
openjdk:11
,run 阶段使用基础镜像openjdk:11-jre
:267MB - 多阶段构建,build 阶段使用基础镜像
openjdk:8
,run 阶段使用基础镜像openjdk:8-jre-alpine
:85MB
# Rust 语言镜像精简
Rust
是最初由 Mozilla
设计的现代编程语言,并且在 Web
和基础架构领域中越来越受欢迎。Rust 编译的二进制文件动态链接到 C 库,可以正常运行于 Ubuntu
、Debian
和 Fedora
之类的镜像中,但不能运行于 busybox:glibc
中。因为 Rust 二进制需要调用 libdl
库,busybox:glibc
中不包含该库。
还有一个 rust:alpine
镜像,Rust 编译的二进制也可以正常运行其中。
如果考虑编译成静态链接,可以参考 Rust 官方文档 (opens new window)。在 Linux 上需要构建一个特殊版本的 Rust 编译器,构建的依赖库就是 musl libc
,你没有看错,就是 Alpine 中的那个 musl libc
。如果你想获得更小的镜像,请按照文档中的说明进行操作,最后将生成的二进制文件扔进 scratch
镜像中就好了。
# Docker build优化
# 构建命令 docker build 的简单介绍
在构建镜像之前有一些概念是一定要理解的。
当我们编写好了 Dockerfile,我们就可以使用构建命令来制作镜像:
$ 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 引擎完成。
当我们进行镜像构建的时候,经常会需要将一些本地文件复制进镜像,比如通过 COPY
、 ADD
等。而 docker build
命令构建镜像,其实并非在本地构建,而是在服务端。
当构建的时候,用户会指定构建镜像 上下文
的路径,docker build 命令得知这个路径后,会将路径下的所有内容打包,然后上传给 Docker 引擎。这样 Docker 引擎受到这个上下文包后,展开就会获得构建镜像所需的一切文件。
# 构建上下文
如果在 Dockerfile 中这么写:
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 引擎的。
理解
构建上下文
对于镜像构建是很重要的,可以避免犯一些不应该的错误。比如有些初学者在发现 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 指令都将产生新的镜像,缓存不会被使用。
# 构建顺序影响缓存的利用率
镜像的构建顺序很重要,当你向 Dockerfile 中添加文件,或者修改其中的某一行时,那一部分的缓存就会失效,该缓存的后续步骤都会中断,需要重新构建。 所以优化缓存的最佳方法是把不需要经常更改的行放到最前面,更改最频繁的行放到最后面:
- 安装构建应用程序所需的依赖工具
- 安装或更新依赖项
- 构建你的应用
# 只拷贝需要的文件,防止缓存溢出
当拷贝文件到镜像中时,尽量只拷贝需要的文件,切忌使用 COPY .
指令拷贝整个目录。如果被拷贝的文件内容发生了更改,缓存就会被破坏。在上面的示例中,镜像中只需要构建好的 jar 包,因此只需要拷贝这个文件就行了,这样即使其他不相关的文件发生了更改也不会影响缓存。
# 最小化可缓存的执行层
每一个 RUN
指令都会被看作是可缓存的执行单元。太多的 RUN 指令会增加镜像的层数,增大镜像体积,而将所有的命令都放到同一个 RUN 指令中又会破坏缓存,从而延缓开发周期。当使用包管理器安装软件时,一般都会先更新软件索引信息,然后再安装软件。推荐将 RUN apt-get update
和 apt-get install -y
组合成一条 RUN 声明,这样可以形成一个可缓存的执行单元,否则你可能会安装旧的软件包。
# 减小镜像体积
镜像的体积很重要,因为镜像越小,部署的速度更快,攻击范围越小。
# 删除不必要依赖
删除不必要的依赖,不要安装调试工具。如果实在需要调试工具,可以在容器运行之后再安装。某些包管理工具(如 apt
)除了安装用户指定的包之外,还会安装推荐的包,这会无缘无故增加镜像的体积。apt 可以通过添加参数 -–no-install-recommends
来确保不会安装不需要的依赖项。如果确实需要某些依赖项,请在后面手动添加。
# 删除包管理工具的缓存
包管理工具会维护自己的缓存,这些缓存会保留在镜像文件中,推荐的处理方法是在每一个 RUN 指令的末尾删除缓存。如果你在下一条指令中删除缓存,不会减小镜像的体积。
当然了,还有其他更高级的方法可以用来减小镜像体积,如下文将会介绍的多阶段构建。接下来我们将探讨如何优化 Dockerfile 的可维护性、安全性和可重复性。
# 可维护性
# 尽量使用官方镜像
使用官方镜像可以节省大量的维护时间,因为官方镜像的所有安装步骤都使用了最佳实践。如果你有多个项目,可以共享这些镜像层,因为他们都可以使用相同的基础镜像。
# 使用更具体的标签
基础镜像尽量不要使用 latest
标签。虽然这很方便,但随着时间的推移,latest 镜像可能会发生重大变化。因此在 Dockerfile 中最好指定基础镜像的具体标签。我们使用 openjdk
作为示例,指定标签为 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 里面只提供了构建步骤。
首先应该确定构建应用所需的所有依赖,本文的示例 Java 应用很简单,只需要 Maven
和 JDK
,所以基础镜像应该选择官方的体积最小的 maven 镜像,该镜像也包含了 JDK。如果你需要安装更多依赖,可以在 RUN 指令中添加。pom.xml
文件和 src
文件夹需要被复制到镜像中,因为最后执行 mvn package
命令(-e 参数用来显示错误,-B 参数表示以非交互式的“批处理”模式运行)打包的时候会用到这些依赖文件。
虽然现在我们解决了环境不一致的问题,但还有另外一个问题:每次代码更改之后,都要重新获取一遍 pom.xml 中描述的所有依赖项。
下面我们来解决这个问题。
# 在单独的步骤中获取依赖项
结合前面提到的缓存机制,我们可以让获取依赖项这一步变成可缓存单元,只要 pom.xml 文件的内容没有变化,无论代码如何更改,都不会破坏这一层的缓存。上图中两个 COPY 指令中间的 RUN 指令用来告诉 Maven 只获取依赖项。
现在又遇到了一个新问题:跟之前直接拷贝 jar 包相比,镜像体积变得更大了,因为它包含了很多运行应用时不需要的构建依赖项。
# 使用多阶段构建来删除构建时的依赖项
多阶段构建可以由多个 FROM 指令识别,每一个 FROM 语句表示一个新的构建阶段,阶段名称可以用 AS
参数指定。本例中指定第一阶段的名称为 builder
,它可以被第二阶段直接引用。两个阶段环境一致,并且第一阶段包含所有构建依赖项。
第二阶段是构建最终镜像的最后阶段,它将包括应用运行时的所有必要条件,本例是基于 Alpine 的最小 JRE 镜像。上一个构建阶段虽然会有大量的缓存,但不会出现在第二阶段中。为了将构建好的 jar 包添加到最终的镜像中,可以使用 COPY --from=STAGE_NAME
指令,其中 STAGE_NAME 是上一构建阶段的名称。
多阶段构建是删除构建依赖的首选方案。