编写优秀的 Dockerfile:Spring Boot 的 multi-stage build

1. 概述

一个匆忙写出的 Dockerfile 通常会产生数百 MB 的镜像。该镜像包含 JDK、Maven、源代码和 .m2 目录。每次修改一行代码,Docker 都会重新加载所有依赖。

本文将指导你使用 multi-stage build 来分离构建步骤和运行步骤。学完本文后,你将能够为 Spring Boot 3.x (Java 21) 应用编写 Dockerfile,得到一个体积小、利用缓存快速重建、在独立 stage 中运行测试、以非 root 用户运行进程的镜像。

2. 单阶段 Dockerfile 的问题

我们以一个返回订单状态的简单应用为例:

package com.nonla.orders;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;

@SpringBootApplication
public class OrderApplication {
    public static void main(String[] args) {
        SpringApplication.run(OrderApplication.class, args);
    }
}

@RestController
class OrderController {

    @GetMapping("/orders/{id}/status")
    String status(@PathVariable long id) {
        return id > 0 ? "CONFIRMED" : "UNKNOWN";
    }
}

在 pom.xml 中只需要 web 和 test 依赖:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

以下是一个“能跑就行”的 Dockerfile 写法:

FROM maven:3.9-eclipse-temurin-21
WORKDIR /app
COPY . .
RUN mvn package -DskipTests
CMD ["java", "-jar", "target/orders-0.0.1-SNAPSHOT.jar"]

这个 Dockerfile 有三个问题:

  • 镜像体积大:最终镜像包含 Maven、完整的 JDK、源代码和整个 .m2 缓存。
  • 缓存利用率低:COPY . . 在 mvn package 之前。只要修改一个 .java 文件,这个层就会失效,导致需要重新加载所有依赖。
  • 攻击面大:构建工具和源代码都存在于生产容器中。

3. Multi-stage build 基础

Multi-stage build 允许一个 Dockerfile 包含多个 FROM 指令。每个 FROM 开启一个新的阶段。后续阶段可以通过 COPY --from=<stage name> 从前面的阶段复制文件。只有最后一个阶段会成为最终镜像,中间阶段会被丢弃。

3.1. 构建阶段与运行时阶段

# Stage 1: build
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /workspace
COPY pom.xml .
COPY src ./src
RUN mvn -B package -DskipTests

# Stage 2: runtime
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=build /workspace/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

详细说明:

  • AS build 为第一个阶段命名。你可以在 COPY --from=build 中引用这个名称。
  • 构建阶段使用包含 Maven 和 JDK 的镜像进行编译和打包 jar。
  • 运行时阶段使用 eclipse-temurin:21-jre。该镜像只包含 JRE,不包含 javac 或 Maven。
  • COPY --from=build 只将 jar 文件复制到最终镜像中。
  • ENTRYPOINT 采用 exec 形式(JSON 数组)。这样 java 以 PID 1 运行,并在容器停止时接收 SIGTERM 信号。

3.2. 结果对比

构建并查看大小:

docker build -t orders:single -f Dockerfile.single .
docker build -t orders:multi .
docker images orders

示例结果(实际数字取决于镜像版本和依赖):

REPOSITORY   TAG      SIZE
orders       single   ~560MB
orders       multi    ~290MB

大部分剩余体积来自 JRE 和基础操作系统。你可以使用更精简的基础镜像(如 eclipse-temurin:21-jre-alpine)进一步减少体积。但是,如果应用使用本地库,需要仔细测试。

4. 充分利用层级缓存

每条 RUN 和 COPY 指令都会创建一个层。如果命令和输入未变,Docker 会复用该层。当一个层发生变化时,其后的所有层都必须重新构建。因此,一般原则是 将不常变的内容放在前面,经常变的内容放在后面。

4.1. 分离依赖下载步骤

pom.xml 远不如源代码经常变化。我们先复制它,然后在单独的层中下载依赖:

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

现在当你只修改 OrderController.java 时,Docker 会复用 dependency:go-offline 层。构建日志会显示:

 => CACHED [build 3/5] COPY pom.xml .
 => CACHED [build 4/5] RUN mvn -B dependency:go-offline
 => [build 5/5] COPY src ./src

注意: dependency:go-offline 并不总是下载 Maven 在 package 步骤所需的全部插件。某些插件可能仍在后续步骤中下载。但结果仍比不分离要好得多。

4.2. 使用 BuildKit 的 cache mount

BuildKit(当今 Docker 的默认方案)支持 --mount=type=cache。被挂载的目录会在构建间保留,但 不会 包含在镜像中:

# syntax=docker/dockerfile:1
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /workspace
COPY pom.xml .
COPY src ./src
RUN --mount=type=cache,target=/root/.m2 mvn -B package -DskipTests
  • 行 # syntax=docker/dockerfile:1 启用完整的新 Dockerfile 语法。
  • /root/.m2 在构建器端被缓存。即使 pom.xml 变化,Maven 也只下载新增的依赖,而不是重新下载全部。

你可以结合两种技术。在 CI 中,cache mount 只有在构建器在运行之间被保留时才有效。如果不是,第 4.1 节的分层方法结合镜像仓库缓存仍是更可靠的选择。

5. 分离 Spring Boot jar 的层

Spring Boot 的 fat jar 包含你的代码和数十 MB 的库。如果把整个 jar 复制到镜像中,每次修改代码都会创建一个新的数十 MB 的层。镜像仓库需要存储这个额外的层,而服务器必须重新拉取整个镜像。

Spring Boot 支持根据变化频率将 jar 解压为多个层。从 Spring Boot 3.3 开始,你可以使用 jarmode=tools:

# syntax=docker/dockerfile:1
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /workspace
COPY pom.xml .
RUN --mount=type=cache,target=/root/.m2 mvn -B dependency:go-offline
COPY src ./src
RUN --mount=type=cache,target=/root/.m2 mvn -B package -DskipTests

FROM eclipse-temurin:21-jre AS extract
WORKDIR /extract
COPY --from=build /workspace/target/*.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --launcher --destination layers

FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=extract /extract/layers/dependencies/ ./
COPY --from=extract /extract/layers/spring-boot-loader/ ./
COPY --from=extract /extract/layers/snapshot-dependencies/ ./
COPY --from=extract /extract/layers/application/ ./
EXPOSE 8080
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]

要点:

  • extract 阶段将 jar 解压为四个目录:dependencies、spring-boot-loader、snapshot-dependencies、application。
  • 四条 COPY 指令创建四个层,按从最少变化到最常变化排列。
  • 当你修改代码时,只有 application 层变化。该层通常只有几百 KB。
  • --launcher 选项保留启动器。因此 ENTRYPOINT 调用 JarLauncher 而不是 -jar。

注意: 对于 Spring Boot 3.0–3.2,使用旧语法 java -Djarmode=layertools -jar app.jar extract。启动器的包名在不同版本中也不同:3.2 之后是 org.springframework.boot.loader.launch.JarLauncher,之前是 org.springframework.boot.loader.JarLauncher。使用错误的类名会在容器启动时导致 ClassNotFoundException 错误。

6. 在独立阶段运行测试

在构建阶段设置 -DskipTests 可以加快构建速度,但你不希望将未经测试的镜像推送到镜像仓库。Multi-stage build 允许将测试分离到独立阶段,并通过 --target 选择要构建的阶段。

控制器测试使用 JUnit 5 和 AssertJ(已包含在 spring-boot-starter-test 中):

package com.nonla.orders;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.test.web.servlet.MockMvc;

import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;

@WebMvcTest(OrderController.class)
class OrderControllerTest {

    @Autowired
    MockMvc mockMvc;

    @Test
    void givenPositiveId_whenGetStatus_thenConfirmed() throws Exception {
        String body = mockMvc.perform(get("/orders/42/status"))
                .andReturn().getResponse().getContentAsString();

        assertThat(body).isEqualTo("CONFIRMED");
    }

    @Test
    void givenZeroId_whenGetStatus_thenUnknown() throws Exception {
        String body = mockMvc.perform(get("/orders/0/status"))
                .andReturn().getResponse().getContentAsString();

        assertThat(body).isEqualTo("UNKNOWN");
    }
}

在构建阶段基础上添加 test 阶段:

FROM build AS test
RUN --mount=type=cache,target=/root/.m2 mvn -B test

在 CI 中,先运行测试,然后再构建运行时镜像:

docker build --target test -t orders:test .
docker build -t orders:1.0.0 .

第一个命令只构建到 test 阶段。如果测试失败,RUN 命令返回非零退出码,构建停止:

[ERROR] Tests run: 2, Failures: 1, Errors: 0, Skipped: 0
ERROR: failed to solve: process "/bin/sh -c mvn -B test" did not complete successfully: exit code: 1

第二个命令构建最后一个阶段。对于 BuildKit,test 阶段由于最终阶段没有引用它,会被跳过。生产镜像不会再次运行测试。通过缓存,共享的阶段也不会重新构建。

注意: 不要在 docker build 中运行需要 Docker 的集成测试(例如 Testcontainers)。构建环境没有 Docker daemon。应在 CI 的外部步骤中运行此类测试。

7. 安全性:非 root 用户和 .dockerignore

7.1. 不以 root 运行

默认情况下,容器中的进程以 root 身份运行。如果应用被利用,攻击者将拥有容器内的 root 权限。应在运行时阶段创建专用用户:

FROM eclipse-temurin:21-jre
RUN groupadd --system spring && useradd --system --gid spring spring
WORKDIR /app
COPY --from=extract --chown=spring:spring /extract/layers/dependencies/ ./
COPY --from=extract --chown=spring:spring /extract/layers/spring-boot-loader/ ./
COPY --from=extract --chown=spring:spring /extract/layers/snapshot-dependencies/ ./
COPY --from=extract --chown=spring:spring /extract/layers/application/ ./
USER spring:spring
EXPOSE 8080
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]

快速检查:

docker run --rm --entrypoint id orders:1.0.0
uid=999(spring) gid=999(spring) groups=999(spring)

具体的 uid/gid 可能不同,只要不是 0 即可。对于 Alpine 镜像,使用 addgroup -S spring && adduser -S spring -G spring 替代 groupadd 和 useradd。

7.2. 添加 .dockerignore

构建上下文是传递给 docker build 的整个目录。如果不进行过滤,Docker 会将 target/、.git/ 和 IDE 配置文件发送给构建器。这会使构建变慢,破坏缓存,并可能泄露敏感信息。

target/
.git/
.idea/
*.iml
.vscode/
.env
Dockerfile*

将此文件放在项目根目录中,与 Dockerfile 相邻。

8. 常见错误

  • COPY . . 过早:破坏依赖缓存。应先复制 pom.xml,再复制 src。
  • 在运行时阶段使用 JDK:能运行,但多出数十到数百 MB。生产环境只需 JRE,除非你需要如 jcmd 等诊断工具。
  • 通配符匹配多个 jar:如果 target/ 中有额外的 *-plain.jar 或 *-sources.jar,COPY --from=build /workspace/target/*.jar app.jar 会出错。应在 pom.xml 中设置固定的 <finalName>,或禁用额外的 artifact。
  • ENTRYPOINT 使用 shell 形式:ENTRYPOINT java -jar app.jar 通过 /bin/sh -c 运行。此时 java 不会接收 SIGTERM,Spring 无法优雅关闭,容器会在超时后被强制终止。
  • 为基础镜像使用 latest 标签:今天的构建和下个月的构建可能不同。应锁定版本(如 eclipse-temurin:21-jre)或锁定 digest 以实现完全可重复的构建。
  • 在 ARG/ENV 中放置 secret:值可能通过 docker history 泄露。当需要 token 来下载私有依赖时,应使用 RUN --mount=type=secret。

何时 不需要 如此复杂?对于小脚本或仅用于个人开发环境的镜像,一个阶段就足够了。此外,如果你的团队已在使用 mvn spring-boot:build-image(Cloud Native Buildpacks)或 Jib,这些工具已自动处理层分离和非 root 用户。你不再需要手动编写 Dockerfile。

9. 总结

Multi-stage build 清晰地分离了“构建环境”和“运行环境”。最终镜像仅包含应用实际需要的内容。结合合理的层顺序、cache mount、Spring Boot 层分离、独立的测试阶段和非 root 用户,你将拥有一个构建快速、镜像精简、安全性显著提高的 Dockerfile。

你可以从第 3 节的 Dockerfile 开始。然后逐步应用每项改进,并在每个步骤后重新测量构建时间和镜像大小,以清楚地看到效果。

dockerdockerfilemulti-stage buildspring bootjava