Docker镜像优化

多阶段构建

要想大幅度减少镜像的体积,多阶段构建是必不可少的。多阶段构建的想法很简单:“我不想在最终的镜像中包含一堆 C 或 Go 编译器和整个编译工具链,我只要一个编译好的可执行文件!”

多阶段构建可以由多个 FROM 指令识别,每一个 FROM 语句表示一个新的构建阶段,阶段名称可以用 AS 参数指定,例如:

1
2
3
4
5
6
7
FROM gcc AS mybuildstage
COPY hello.c .
RUN gcc -o hello hello.c

FROM ubuntu
COPY --from=mybuildstage hello .
CMD ["./hello"]

本例使用基础镜像 gcc 来编译程序 hello.c,然后启动一个新的构建阶段,它以 ubuntu 作为基础镜像,将可执行文件 hello 从上一阶段拷贝到最终的镜像中。最终的镜像大小是 64 MB,比之前的 1.1 GB 减少了 95%

1
2
3
4
$ docker images minimage
REPOSITORY TAG ... SIZE
minimage hello-c.gcc ... 1.14GB
minimage hello-c.gcc.ubuntu ... 64.2MB

FROM scratch 的魔力

回到我们的 hello world,C 语言版本的程序大小为 16 kB,Go 语言版本的程序大小为 2 MB,那么我们到底能不能将镜像缩减到这么小?能否构建一个只包含我需要的程序,没有任何多余文件的镜像?

答案是肯定的,你只需要将多阶段构建的第二阶段的基础镜像改为 scratch 就好了。scratch 是一个虚拟镜像,不能被 pull,也不能运行,因为它表示空、nothing!这就意味着新镜像的构建是从零开始,不存在其他的镜像层。例如:

1
2
3
4
5
6
7
FROM golang
COPY hello.go .
RUN go build hello.go

FROM scratch
COPY --from=0 /go/hello .
CMD ["./hello"]

这一次构建的镜像大小正好就是 2 MB,堪称完美!

然而,但是,使用 scratch 作为基础镜像时会带来很多的不便,且听我一一道来。

缺少 shell

scratch 镜像的第一个不便是没有 shell,这就意味着 CMD/RUN 语句中不能使用字符串,例如:

1
2
3
4
...
FROM scratch
COPY --from=0 /go/hello .
CMD ./hello

如果你使用构建好的镜像创建并运行容器,就会遇到下面的报错:

1
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 中执行,也就是说,下面这两条语句是等效的:

1
2
CMD ./hello
CMD /bin/sh -c "./hello"

解决办法其实也很简单 : 使用 JSON 语法取代字符串语法。 例如,将 CMD ./hello 替换为 CMD ["./hello"],这样 Docker 就会直接运行程序,不会把它放到 shell 中运行。

缺少调试工具

scratch 镜像不包含任何调试工具,lspsping 这些统统没有,当然了,shell 也没有(上文提过了),你无法使用 docker exec 进入容器,也无法查看网络堆栈信息等等。

如果想查看容器中的文件,可以使用 docker cp;如果想查看或调试网络堆栈,可以使用 docker run --net container:,或者使用 nsenter;为了更好地调试容器,Kubernetes 也引入了一个新概念叫 Ephemeral Containers,但现在还是 Alpha 特性。

虽然有这么多杂七杂八的方法可以帮助我们调试容器,但它们会将事情变得更加复杂,我们追求的是简单,越简单越好。

折中一下可以选择 busyboxalpine 镜像来替代 scratch,虽然它们多了那么几 MB,但从整体来看,这只是牺牲了少量的空间来换取调试的便利性,还是很值得的。

缺少 libc

这是最难解决的问题。使用 scratch 作为基础镜像时,Go 语言版本的 hello world 跑得很欢快,C 语言版本就不行了,或者换个更复杂的 Go 程序也是跑不起来的(例如用到了网络相关的工具包),你会遇到类似于下面的错误:

1
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 中的 cossin 函数,该程序运行时就会根据索引找到并加载 libtrigonometry.so,然后程序就可以调用这个库文件中的函数。

使用动态链接的好处显而易见:

  • 节省磁盘空间,不同的程序可以共享常见的库。
  • 节省内存,共享的库只需从磁盘中加载到内存一次,然后在不同的程序之间共享。
  • 更便于维护,库文件更新后,不需要重新编译使用该库的所有程序。

严格来说,动态库与共享库(shared libraries)相结合才能达到节省内存的功效。Linux 中动态库的扩展名是 .soshared object),而 Windows 中动态库的扩展名是 .DLLDynamic-link library)。

回到最初的问题,默认情况下,C 程序使用的是动态链接,Go 程序也是。上面的 hello world 程序使用了标准库文件 libc.so.6,所以只有镜像中包含该文件,程序才能正常运行。使用 scratch 作为基础镜像肯定是不行的,使用 busyboxalpine 也不行,因为 busybox 不包含标准库,而 alpine 使用的标准库是 musl libc,与大家常用的标准库 glibc 不兼容,后续的文章会详细解读,这里就不赘述了。

那么该如何解决标准库的问题呢?有三种方案。

1. 使用静态库

我们可以让编译器使用静态库编译程序,办法有很多,如果使用 gcc 作为编译器,只需加上一个参数 -static

1
$ gcc -o hello hello.c -static

编译完的可执行文件大小为 760 kB,相比于之前的 16kB 是大了好多,这是因为可执行文件中包含了其运行所需要的库文件。编译完的程序就可以跑在 scratch 镜像中了。

如果使用 alpine 镜像作为基础镜像来编译,得到的可执行文件会更小(< 100kB),下篇文章会详述。

2. 拷贝库文件到镜像中

为了找出程序运行需要哪些库文件,可以使用 ldd 工具:

1
2
3
4
$ 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)

从输出结果可知,该程序只需要 libc.so.6 这一个库文件。linux-vdso.so.1 与一种叫做 VDSO 的机制有关,用来加速某些系统调用,可有可无。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,例如:

1
2
3
4
5
6
7
8
FROM golang
COPY whatsmyip.go .
ENV CGO_ENABLED=0
RUN go build whatsmyip.go

FROM scratch
COPY --from=0 /go/whatsmyip .
CMD ["./whatsmyip"]

由于编译生成的是静态二进制文件,因此可以直接跑在 scratch 镜像中。

当然,也可以不用完全禁用 cgo,可以通过 -tags 参数指定需要使用的内建库,例如 -tags netgo 就表示使用内建的 net 包,不依赖系统库:

1
$ 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 打包方式很熟悉,那你应该知道 JDKJRE

  • 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,这是 Amazon fork OpenJDK 后打了补丁的版本,号称企业级。

开始构建

说了那么多,到底该用哪个镜像呢?这里给出几个参考:

  • openjdk:8-jre-alpine(85MB)
  • openjdk:11-jre(267MB)或者 openjdk:11-jre-slim(204MB)
  • openjdk:14-alpine(338MB)

如果你想要更直观的数据,可以看我的例子,还是搬出屡试不爽的 『hello world』,只不过这次是 Java 版本:

1
2
3
4
5
class hello {
public static void main(String [] args) {
System.out.println("Hello, world!");
}
}

不同构建方法得到的镜像大小:

  • 使用基础镜像 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 库,可以正常运行于 UbuntuDebianFedora 之类的镜像中,但不能运行于 busybox:glibc 中。因为 Rust 二进制需要调用 libdl 库,busybox:glibc 中不包含该库。

还有一个 rust:alpine 镜像,Rust 编译的二进制也可以正常运行其中。

如果考虑编译成静态链接,可以参考 Rust 官方文档。在 Linux 上需要构建一个特殊版本的 Rust 编译器,构建的依赖库就是 musl libc,你没有看错,就是 Alpine 中的那个 musl libc。如果你想获得更小的镜像,请按照文档中的说明进行操作,最后将生成的二进制文件扔进 scratch 镜像中就好了。

Docker build优化

构建命令 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 引擎的。

理解 构建上下文 对于镜像构建是很重要的,可以避免犯一些不应该的错误。比如有些初学者在发现 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 updateapt-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 应用很简单,只需要 MavenJDK,所以基础镜像应该选择官方的体积最小的 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 是上一构建阶段的名称。

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

有用就打赏一下作者吧!