Docker 多阶段构建实战:把 Java 应用镜像从 1.13GB 压到 78MB
*## 为什么一个 Java 应用的镜像动不动就 1GB
很多人第一次给 Spring Boot 应用写 Dockerfile,写出来大概是这样的:
FROM maven:3.9-eclipse-temurin-17
WORKDIR /app
COPY . .
RUN mvn clean package -DskipTests
CMD ["java", "-jar", "target/app.jar"]
能跑,但镜像体积通常在 1GB 以上。原因很直接:这个镜像把 JDK、Maven、Maven 本地仓库缓存、项目源码、target 目录 全部打进去了。而线上运行时真正需要的只有两样东西:一个 JRE,和一个 jar 包。
镜像大带来的问题不只是磁盘:
- 每次发布推送镜像,网络传输时间成倍增加,回滚变慢;
- 基础镜像里的编译工具链在生产环境中是多余攻击面,
gcc、git、maven都会出现在漏扫报告里; - 容器启动时解压层数多,冷启动更慢。
多阶段构建:编译环境和运行环境分离
Docker 17.05 引入的多阶段构建(multi-stage build)就是为解决这件事设计的。一个 Dockerfile 里可以写多个 FROM,后面的阶段可以把前面阶段的产物复制过来,而前面阶段的整个文件系统会被丢弃。
改造后的写法:
# ---------- 构建阶段 ----------
FROM maven:3.9-eclipse-temurin-17 AS builder
WORKDIR /build
# 先只复制 pom,让依赖层能够缓存
COPY pom.xml .
RUN mvn -B dependency:go-offline
COPY src ./src
RUN mvn -B clean package -DskipTests
# ---------- 运行阶段 ----------
FROM eclipse-temurin:17-jre-jammy
WORKDIR /app
COPY --from=builder /build/target/app.jar /app/app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75", "-jar", "/app/app.jar"]
最终镜像里只剩下 JRE 层和 jar 本身的层,实测从 1.13GB 降到 78MB 左右,体积下降超过 90%。
四个容易被忽略的细节
第一,COPY pom.xml 要单独一层。 如果直接 COPY . . 再 mvn package,任何一次源码改动都会让依赖下载层失效,构建时间从 20 秒退化到几分钟。先复制 pom 并执行 dependency:go-offline,只要 pom 不变,这一层永远命中缓存。
第二,基础镜像选 -jre 而不是 -jdk。 编译只需要在构建阶段发生,运行阶段用 JRE 能省掉 200MB 以上。如果追求极致,可以用 distroless/java17 或 alpine 系列,但要注意 glibc/musl 差异,某些依赖 JNI 的库在 alpine 上会报 No such file or directory。
第三,.dockerignore 必须写。 没有它,COPY . . 会把 .git、node_modules、IDE 配置、本地日志一起送进构建上下文。构建上下文越大,docker build 第一步的传输就越慢,而且容易因为误把 .env 复制进去导致密钥泄漏。
第四,别用 root 跑应用。 在运行阶段加一句:
RUN useradd -r -u 1001 appuser
USER 1001
K8s 的 runAsNonRoot 策略和很多安全基线都要求这一点,提前做掉比事后整改省事。
怎么验证优化真的生效
看层大小最直接:
docker history --no-trunc --format "{{.Size}}\t{{.CreatedBy}}" app:latest | head -20
哪一层异常大,一眼就能看出来。想更细粒度地分析,可以用 dive 工具,它会给出每一层的文件增量和浪费空间评分:
dive app:latest
另外建议在 CI 里加一条体积门禁,比如超过 200MB 就让流水线失败。镜像体积和代码一样会随着时间悄悄腐化,没有门禁就很难守住。
一个补充:分层复制 jar 可以进一步提速
Spring Boot 3 支持把 jar 拆成分层结构,让不常变的依赖和经常变的业务代码分开:
RUN java -Djarmode=layertools -jar app.jar extract
然后把 dependencies、spring-boot-loader、snapshot-dependencies、application 依次 COPY 进镜像。这样每次只改业务代码时,推送的只有最后一层几十 KB,对频繁发布的场景收益很明显。
小结
多阶段构建的本质是把「构建依赖」和「运行依赖」彻底分开,编译器不应该出现在生产镜像里。配合依赖层缓存、JRE 基础镜像、.dockerignore 和非 root 用户这四件事,一个典型 Java 服务的镜像可以稳定控制在 100MB 以内,同时构建速度、安全性和发布效率都会一起改善。
*
- 点赞
- 收藏
- 关注作者
评论(0)