将 Spring Boot 打包成轻量级 Docker 镜像
1. 概述
将 Spring Boot 打包进 Docker 很容易。打包成体积小、构建快、运行安全的镜像则需要额外步骤。用常见的“三行” Dockerfile,镜像通常有几百 MB,每次修改一行代码就要重新推送整个 JAR 文件,而且进程以 root 身份运行。
本文从一个幼稚的 Dockerfile 讲起,逐步演变为一个多阶段 Dockerfile,使用分层 jar 和最小化 JRE。之后你会看到如何用 Buildpacks 作为替代方案,以及如何用 Testcontainers 编写测试来验证构建的镜像。
2. 示例应用
我们使用一个名为 order-service 的小型服务,它有一个 REST 端点和 Actuator 用于健康检查。
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
</dependencies>
@SpringBootApplication
public class OrderServiceApplication {
public static void main(String[] args) {
SpringApplication.run(OrderServiceApplication.class, args);
}
}
@RestController
@RequestMapping("/orders")
class OrderController {
record Order(long id, String customer, BigDecimal total) {}
@GetMapping("/{id}")
Order find(@PathVariable long id) {
return new Order(id, "Nguyen Van A", new BigDecimal("250000"));
}
}
执行 ./mvnw package 会生成 target/order-service-1.0.0.jar。这是一个“fat jar”,包含你的代码和所有依赖库。
3. 幼稚的 Dockerfile 及其问题
许多人写的第一版看起来是这样的:
FROM eclipse-temurin:21
COPY target/order-service-1.0.0.jar app.jar
ENTRYPOINT ["java", "-jar", "/app.jar"]
这个 Dockerfile 能运行,但有四个问题:
- 基础镜像太大。
eclipse-temurin:21包含整个 JDK:编译器、jshell、javadoc等。运行时你不需要这些。 - 一个 layer 装所有。 Fat jar 大约 20-30 MB,其中你的代码只占几百 KB。修改一行代码,这 30 MB 的 layer 就改变了,必须重新构建和推送。
- 依赖于构建机器。 镜像需要预先构建好的 JAR 文件。每台机器上的 JDK 和 Maven 版本可能不同。
- 以 root 身份运行。 如果应用被攻破,攻击者在容器中有 root 权限。
检查大小:
docker build -t order-service:naive .
docker images order-service
REPOSITORY TAG IMAGE ID SIZE
order-service naive 3f1c9a7e2b10 ~480MB
具体数字取决于基础镜像的版本,但一个“hello world”服务用掉接近半 GB 显然太多了。
4. 使用分层 jar 的多阶段构建
4.1. 什么是分层 jar
从 Spring Boot 2.3 开始,fat jar 被预先分成几个layer,按修改频率划分:
| Layer | 内容 | 修改频率 |
|---|---|---|
dependencies |
版本号为正式版的库 | 很少 |
spring-boot-loader |
Spring Boot 的类加载器 | 极少 |
snapshot-dependencies |
-SNAPSHOT 版本的库 |
偶尔 |
application |
你的代码和资源 | 频繁 |
你用单独的 COPY 命令把每部分复制到镜像中,按从少变化到多变化的顺序。Docker 会缓存上面的 layer。当你只修改代码时,只有 application layer 会重新构建和推送。
从 Spring Boot 3.3 开始,你可以用 jarmode=tools 提取 layer:
java -Djarmode=tools -jar target/order-service-1.0.0.jar extract --layers --destination extracted
ls extracted
application dependencies snapshot-dependencies spring-boot-loader
application 目录包含被“瘦身”过的 order-service-1.0.0.jar 文件。这个文件引用位于 dependencies layer 内的 lib/ 目录。合并各 layer 后,你可以像往常一样运行 java -jar。
注意: 对于 Spring Boot 3.2 及更早版本,语法是
java -Djarmode=layertools -jar app.jar extract。此时 entrypoint 是org.springframework.boot.loader.launch.JarLauncher而非-jar。
4.2. 完整的 Dockerfile
# ---------- Stage 1: 用 Maven 构建 ----------
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /workspace
COPY pom.xml .
RUN mvn -B dependency:go-offline
COPY src ./src
RUN mvn -B package -DskipTests
# ---------- Stage 2: 提取 layer ----------
FROM eclipse-temurin:21-jre-alpine AS extract
WORKDIR /extract
COPY --from=build /workspace/target/*.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --destination extracted
# ---------- Stage 3: 运行时 ----------
FROM eclipse-temurin:21-jre-alpine
RUN addgroup -S spring && adduser -S spring -G spring
WORKDIR /app
COPY --from=extract /extract/extracted/dependencies/ ./
COPY --from=extract /extract/extracted/spring-boot-loader/ ./
COPY --from=extract /extract/extracted/snapshot-dependencies/ ./
COPY --from=extract /extract/extracted/application/ ./
USER spring:spring
EXPOSE 8080
ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75.0 -XX:+ExitOnOutOfMemoryError"
HEALTHCHECK --interval=30s --timeout=3s --start-period=30s \
CMD wget -qO- http://localhost:8080/actuator/health || exit 1
ENTRYPOINT ["java", "-jar", "app.jar"]
每个部分的解释:
buildstage:先COPY pom.xml,再运行dependency:go-offline,然后才 copysrc。当你只修改代码时,Docker 会重用已下载依赖的 layer。你不必每次都从 Maven Central 重新下载整个世界。extractstage:把 JAR 提取成四个 layer 目录。这个 stage 只是中间步骤,不会出现在最终镜像中。- 运行时 stage:使用
21-jre-alpine,只有 Alpine Linux 上的 JRE。 - 四个
COPY命令:每个命令创建一个 Docker layer,按从少变到多变的顺序。因为“瘦身” JAR 文件(app.jar)在application中,lib/目录在dependencies中,合并后/app的结构就是app.jar+lib/。 USER spring:spring:Java 进程不再以 root 身份运行。JAVA_TOOL_OPTIONS:JVM 自动读取这个变量。MaxRAMPercentage=75.0使堆最多使用容器内存限制的 75%。ExitOnOutOfMemoryError在 JVM 内存耗尽时强制退出,让编排器重启容器而不是让它处于僵尸状态。HEALTHCHECK:Alpine 内置 BusyBox 的wget,所以你不需要安装curl。- exec 形式
ENTRYPOINT(JSON 数组):java是 PID 1,会收到容器停止时的SIGTERM信号。这样 Spring Boot 就能优雅关闭。
4.3. 别忘了 .dockerignore
构建命令会把当前目录(构建上下文)整个发送给 Docker daemon。排除不需要的文件:
target/
.git/
.idea/
*.iml
.mvn/wrapper/maven-wrapper.jar
Dockerfile
没有这个文件,target/ 或 .git/ 目录可能使上下文膨胀到几百 MB。每次构建都会明显变慢。
4.4. 结果
docker build -t order-service:1.0.0 .
docker images order-service
docker history order-service:1.0.0 --format "{{.Size}}\t{{.CreatedBy}}" | head -8
REPOSITORY TAG IMAGE ID SIZE
order-service 1.0.0 9b2e44d1c0af ~200MB
order-service naive 3f1c9a7e2b10 ~480MB
0B ENTRYPOINT ["java" "-jar" "app.jar"]
...
45kB COPY /extract/extracted/application/ ./
0B COPY /extract/extracted/snapshot-dependencies/ ./
290kB COPY /extract/extracted/spring-boot-loader/ ./
22MB COPY /extract/extracted/dependencies/ ./
镜像体积减少了一半多。更重要的是,application layer 只有几十 KB。修改 OrderController 后重新构建,只有这个 layer 改变。下次推送到仓库只需几秒。
5. 替代方案:Cloud Native Buildpacks
如果你不想自己维护 Dockerfile,Spring Boot 的 Maven 插件可以直接用 Buildpacks 构建镜像:
./mvnw spring-boot:build-image \
-Dspring-boot.build-image.imageName=shop/order-service:1.0.0
你也可以在 pom.xml 中永久配置:
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<image>
<name>shop/order-service:${project.version}</name>
<env>
<BP_JVM_VERSION>21</BP_JVM_VERSION>
</env>
</image>
</configuration>
</plugin>
Buildpacks 自动做我们上面手工做的一切:分层 jar、选择 JRE、以非 root 用户运行、为 JVM 计算内存。你不需要 Dockerfile,只需要 Docker daemon 在运行。
代价是控制力较弱。基础镜像由 buildpack 决定,首次构建相当慢(需要下载 builder 镜像),深度定制比编辑 Dockerfile 难。想快速标准化的团队应该用 Buildpacks;需要细粒度控制的团队(比如要用经过安全审批的内部基础镜像),应该用 Dockerfile。
6. 用 Testcontainers 验证镜像
一个“瘦身”的镜像如果启动不了就没意义。你可以写一个测试,从 Dockerfile 构建镜像,运行容器,验证它正常工作。
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
Spring Boot BOM 已经管理这两个库的版本,所以你不需要声明 <version>。
@Testcontainers
class OrderServiceImageIT {
static final ImageFromDockerfile IMAGE =
new ImageFromDockerfile("order-service-it", false)
.withDockerfile(Path.of("./Dockerfile"));
@Container
static final GenericContainer<?> APP = new GenericContainer<>(IMAGE)
.withExposedPorts(8080)
.waitingFor(Wait.forHttp("/actuator/health").forStatusCode(200))
.withStartupTimeout(Duration.ofMinutes(2));
@Test
void shouldServeOrdersAndReportHealthy() throws Exception {
String baseUrl = "http://" + APP.getHost() + ":" + APP.getMappedPort(8080);
HttpClient client = HttpClient.newHttpClient();
HttpResponse<String> response = client.send(
HttpRequest.newBuilder(URI.create(baseUrl + "/orders/42")).build(),
HttpResponse.BodyHandlers.ofString());
assertThat(response.statusCode()).isEqualTo(200);
assertThat(response.body()).contains("\"id\":42", "Nguyen Van A");
}
@Test
void shouldNotRunAsRoot() throws Exception {
var result = APP.execInContainer("id", "-u");
assertThat(result.getStdout().trim()).isNotEqualTo("0");
}
}
这个测试做了以下事情:
ImageFromDockerfile从项目根目录的Dockerfile构建镜像。构建上下文是包含 Dockerfile 的目录。参数false保留测试后的镜像,下次构建时可以利用缓存更快。Wait.forHttp("/actuator/health")等待应用真正就绪,不仅仅是容器启动。- 第一个测试 通过宿主机上随机映射的端口调用端点,验证返回的 JSON。
- 第二个测试 在容器内运行
id -u。如果有人不小心删除了USER spring:spring这一行,结果会是0,测试立即失败。
用 IT 后缀命名类,用 maven-failsafe-plugin 运行。这个测试构建整个镜像所以需要几十秒,不适合在常规单元测试中频繁运行。
7. 常见错误与何时不应采用
- 用 shell 形式的
ENTRYPOINT java -jar app.jar。此时/bin/sh是 PID 1,不会把SIGTERM转发给 JVM。容器超时后被强行杀死,失去优雅关闭。总是用 JSON 数组形式。 - 设置固定的
-Xmx。如果你设-Xmx2g但容器限制是 1 GB,容器会被 OOM-killed,没有堆栈跟踪。用MaxRAMPercentage让堆根据限制自动伸缩。 - Alpine 和本地库。Alpine 用
musl代替glibc。某些带本地代码的库(Netty native transport、某些驱动、图像处理库)可能出错或很慢。改用eclipse-temurin:21-jre-jammy或-noble。镜像大几十 MB 但更稳定。 - layer 顺序错误。如果先 copy
application再 copydependencies,代码的任何改变都会破坏 dependency layer 的缓存。你失去分层 jar 的全部好处。 - 基础镜像用
latest标签。今天的构建和下周的构建可能产生不同的镜像。固定具体的标签,如21.0.5_11-jre-alpine,或者固定 digest 确保完全可复现的构建。
注意: 要得到更小的镜像,你可以用
jdeps和jlink创建只包含应用所需模块的 JRE,或者用 GraalVM 编译成本地代码。两种方法都能显著缩小体积,但让构建变得复杂得多,在有反射时容易崩溃。只在镜像体积或启动时间确实成为瓶颈时才用,比如无服务器或 scale-to-zero 场景。
8. 总结
在这篇文章中,你把一个三行 Dockerfile 变成了多阶段 Dockerfile,步骤如下:
- 在容器内构建,不依赖构建机器;
- 提取分层 jar,使只有包含代码的 layer 在每次修改后重新构建;
- 用 Alpine JRE 减少镜像体积;
- 以非 root 用户运行,提供健康检查,根据容器内存限制配置 JVM。
你还了解了 Buildpacks 作为无需 Dockerfile 的替代方案。最后,Testcontainers 测试帮助确保镜像能启动,且在每次改动后不以 root 身份运行。