将 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"]

每个部分的解释:

  • build stage:先 COPY pom.xml,再运行 dependency:go-offline,然后才 copy src。当你只修改代码时,Docker 会重用已下载依赖的 layer。你不必每次都从 Maven Central 重新下载整个世界。
  • extract stage:把 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 再 copy dependencies,代码的任何改变都会破坏 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 身份运行。

spring-bootdockerjavadevopscontainer