Docker多阶段构建:Spring Boot镜像从800MB瘦身到80MB的完整方案
Docker多阶段构建:Spring Boot镜像从800MB瘦身到80MB的完整方案
适读人群:有Docker基础的Java后端工程师 | 阅读时长:约20分钟 | 适用版本:Docker 20.10+、Spring Boot 2.7+/3.x
开篇故事
那是2022年底,我们团队刚刚开始把核心交易服务往K8s上迁移。当时负责这块的小王跑来找我,说镜像仓库快撑不住了,Harbor的存储告警天天响。我一看,好家伙,一个Spring Boot服务的镜像足足820MB,我们一共有37个微服务,每个服务平均保留10个版本,算下来光镜像存储就超过了300GB。
更要命的是CI/CD流水线跑得贼慢。从代码提交到镜像推送完毕,整个构建阶段要花将近8分钟,其中光是docker push就占了将近5分钟。研发同学改一行配置,得等8分钟才能看到效果,怨声载道。
我把那个Dockerfile翻出来一看,顿时明白问题所在了:
FROM openjdk:11
WORKDIR /app
COPY . .
RUN mvn clean package -DskipTests
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "target/app.jar"]就这十行,把整个Maven工具链、所有源代码、所有编译中间产物全打进了镜像里。这种写法在开发环境图个方便无所谓,但在生产环境就是灾难。
我花了一个下午,用多阶段构建把这个镜像从820MB压缩到了78MB,构建推送时间从8分钟降到了2分30秒,整个镜像仓库存储降了将近90%。今天把这套方案完整写下来,让大家少走弯路。
一、核心问题分析
为什么传统Dockerfile会产生胖镜像
Spring Boot应用镜像体积膨胀的根本原因,是把构建时依赖和运行时依赖混在了一个镜像层里。
构建一个Spring Boot应用需要:Maven或Gradle、JDK(完整版)、项目源代码、所有编译期依赖的源码和文档(如果开了下载)、各种编译插件和工具。
而真正运行Spring Boot应用只需要:JRE(不需要完整JDK)、打好的fat jar或者解压后的应用文件、必要的系统运行库。
用docker history看一个传统构建的镜像,你会发现前三层加起来就有600多MB,全是构建工具和源码的残留。
镜像层的叠加机制
理解Docker镜像层的叠加机制对于瘦身至关重要。Docker镜像是只读的分层文件系统,每一条RUN、COPY、ADD指令都会新增一层。关键点在于:即使你在后续层删除了文件,前面层里的文件依然存在于镜像中,只是在当前视图里被遮盖了而已。所以RUN rm -rf /tmp/bigfile这种操作完全没用,镜像该多大还是多大。
这就是为什么很多人写出这种反模式:
RUN apt-get update && apt-get install -y build-tool
RUN do-some-build
RUN apt-get remove build-tool # 完全没用!多阶段构建从根本上解决了这个问题:不同阶段之间只传递最终需要的文件,构建工具所在的那一层根本不会出现在最终镜像里。
二、原理深度解析
多阶段构建的工作原理
每个FROM指令开启一个新的构建阶段。最终镜像只包含最后一个FROM指令所在阶段的内容,以及从前面阶段COPY --from=过来的文件。构建阶段的镜像层在构建完成后可以完全丢弃,不会进入最终镜像。
Spring Boot的分层Jar机制
Spring Boot 2.3开始引入了分层Jar(Layered Jar)功能,这是实现高效缓存的关键。传统fat jar把所有东西打在一起,Docker每次推送都要重新推整个jar包。分层Jar把内容拆分成四层:
dependencies:第三方依赖,几乎不变,可以长期缓存spring-boot-loader:Spring Boot加载器,基本不变snapshot-dependencies:快照版依赖,偶尔更新application:应用代码,每次发布都变
这样一来,99%的情况下只有application层需要重新推送,镜像推送时间从分钟级降到了秒级。
JRE与JDK的尺寸差异
很多人不知道JDK和JRE的体积差距有多大。以Eclipse Temurin 17为例:
eclipse-temurin:17(完整JDK):约380MBeclipse-temurin:17-jre:约220MBeclipse-temurin:17-jre-alpine:约65MB- 用
jlink自定义JRE:最小可到18MB
Alpine版本使用musl libc而不是glibc,体积小了很多,但需要注意兼容性问题,特别是依赖了本地库的情况。
三、完整配置实现
方案一:标准多阶段构建(推荐生产使用)
# =============================================
# 构建阶段:使用完整Maven+JDK镜像
# =============================================
FROM maven:3.9.6-eclipse-temurin-17-alpine AS builder
WORKDIR /build
# 先只复制pom.xml,利用Docker层缓存机制
# 只要pom.xml不变,依赖下载层就会被缓存
COPY pom.xml .
COPY .mvn .mvn
COPY mvnw .
# 下载依赖(独立一层,便于缓存)
RUN mvn dependency:go-offline -B
# 再复制源码并构建
COPY src ./src
# 构建时启用分层jar
RUN mvn clean package -DskipTests \
-Dspring-boot.build-image.skip=true
# =============================================
# 分层展开阶段:提取jar各层
# =============================================
FROM eclipse-temurin:17-jre-alpine AS layertools
WORKDIR /extracted
COPY --from=builder /build/target/*.jar app.jar
# 使用Spring Boot提供的layertools展开jar
RUN java -Djarmode=layertools -jar app.jar extract
# =============================================
# 最终运行阶段:只包含运行时必需内容
# =============================================
FROM eclipse-temurin:17-jre-alpine AS runtime
# 安全加固:不以root运行
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
# 按层复制,确保缓存效率最大化
# 变化频率从低到高排列,充分利用层缓存
COPY --from=layertools /extracted/dependencies/ ./
COPY --from=layertools /extracted/spring-boot-loader/ ./
COPY --from=layertools /extracted/snapshot-dependencies/ ./
COPY --from=layertools /extracted/application/ ./
# 切换到非root用户
USER appuser
EXPOSE 8080
# JVM参数优化:容器感知内存设置
ENV JAVA_OPTS="-XX:+UseContainerSupport \
-XX:MaxRAMPercentage=75.0 \
-XX:InitialRAMPercentage=50.0 \
-XX:+ExitOnOutOfMemoryError \
-Djava.security.egd=file:/dev/./urandom"
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS org.springframework.boot.loader.JarLauncher"]对应的pom.xml需要开启分层构建支持:
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<!-- 开启分层jar -->
<layers>
<enabled>true</enabled>
</layers>
<!-- 排除开发工具 -->
<excludeDevtools>true</excludeDevtools>
</configuration>
</plugin>
</plugins>
</build>方案二:使用jlink定制最小JRE(极致瘦身)
FROM maven:3.9.6-eclipse-temurin-17-alpine AS builder
WORKDIR /build
COPY pom.xml .
RUN mvn dependency:go-offline -B
COPY src ./src
RUN mvn clean package -DskipTests
# =============================================
# jlink阶段:分析依赖,定制最小JRE
# =============================================
FROM eclipse-temurin:17-alpine AS jlink-builder
WORKDIR /jlink
COPY --from=builder /build/target/*.jar app.jar
# 分析jar需要哪些JDK模块
RUN jar xf app.jar && \
jdeps \
--ignore-missing-deps \
--print-module-deps \
--multi-release 17 \
--recursive \
--class-path 'BOOT-INF/lib/*' \
app.jar > /tmp/modules.txt && \
cat /tmp/modules.txt
# 用jlink构建定制JRE,只包含需要的模块
RUN MODULES=$(cat /tmp/modules.txt) && \
jlink \
--add-modules ${MODULES},java.instrument,jdk.unsupported \
--strip-debug \
--no-man-pages \
--no-header-files \
--compress=2 \
--output /custom-jre
# =============================================
# 最终镜像:Alpine + 定制JRE
# =============================================
FROM alpine:3.19 AS runtime
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
# 从jlink阶段复制定制JRE
COPY --from=jlink-builder /custom-jre /opt/jre
# 从分层提取阶段复制应用
COPY --from=builder /build/target/*.jar /app/app.jar
ENV PATH="/opt/jre/bin:$PATH"
ENV JAVA_OPTS="-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0"
USER appuser
WORKDIR /app
EXPOSE 8080
ENTRYPOINT ["sh", "-c", "/opt/jre/bin/java $JAVA_OPTS -jar app.jar"]方案三:Docker BuildKit缓存挂载(CI/CD加速神器)
# syntax=docker/dockerfile:1.4
FROM maven:3.9.6-eclipse-temurin-17-alpine AS builder
WORKDIR /build
COPY pom.xml .
# 使用BuildKit的缓存挂载,Maven本地仓库持久化
# --mount=type=cache 在多次构建间共享缓存目录
RUN --mount=type=cache,target=/root/.m2,sharing=locked \
mvn dependency:go-offline -B
COPY src ./src
RUN --mount=type=cache,target=/root/.m2,sharing=locked \
mvn clean package -DskipTests
FROM eclipse-temurin:17-jre-alpine AS layertools
WORKDIR /extracted
COPY --from=builder /build/target/*.jar app.jar
RUN java -Djarmode=layertools -jar app.jar extract
FROM eclipse-temurin:17-jre-alpine AS runtime
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
COPY --from=layertools /extracted/dependencies/ ./
COPY --from=layertools /extracted/spring-boot-loader/ ./
COPY --from=layertools /extracted/snapshot-dependencies/ ./
COPY --from=layertools /extracted/application/ ./
USER appuser
EXPOSE 8080
ENV JAVA_OPTS="-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0 -XX:+ExitOnOutOfMemoryError"
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS org.springframework.boot.loader.JarLauncher"]构建命令:
# 启用BuildKit
export DOCKER_BUILDKIT=1
# 构建镜像
docker build \
--build-arg BUILDKIT_INLINE_CACHE=1 \
--cache-from myregistry.com/myapp:latest \
-t myregistry.com/myapp:$(git rev-parse --short HEAD) \
.
# 查看镜像大小
docker images myregistry.com/myapp
# 查看镜像分层详情
docker history myregistry.com/myapp:latest.dockerignore文件(必须配置)
# 版本控制
.git
.gitignore
# IDE文件
.idea
*.iml
.vscode
*.code-workspace
# 构建产物(让Maven在容器内重新构建)
target/
*.class
# 本地配置(绝对不能进镜像)
.env
*.local.yml
application-local.yml
application-dev.yml
# 文档和测试报告
docs/
*.md
src/test/
# 日志文件
*.log
logs/
# Docker相关(避免循环引用)
Dockerfile*
docker-compose*
.dockerignore四、生产最佳实践
镜像大小对比数据
经过实测,各方案在同一个20万行代码的Spring Boot微服务上的效果:
| 方案 | 镜像大小 | 首次构建时间 | 增量构建时间 | 备注 |
|---|---|---|---|---|
| 传统单阶段 | 820MB | 7分30秒 | 7分30秒 | 无缓存优化 |
| 多阶段基础版 | 195MB | 5分钟 | 4分钟 | JDK换JRE |
| 多阶段+分层Jar | 78MB | 5分钟 | 45秒 | 推荐方案 |
| 多阶段+BuildKit缓存 | 78MB | 5分钟 | 28秒 | CI/CD首选 |
| jlink定制JRE | 42MB | 8分钟 | 40秒 | 极致场景 |
注意:镜像大小不含等,只含应用本身。
容器内JVM参数调优
容器环境下JVM参数设置是一个大坑,下面这些参数在生产上必须设置:
# 容器感知(JDK 10+默认开启,但明确写出更保险)
-XX:+UseContainerSupport
# 内存比例设置,推荐75%给堆,留25%给堆外内存
-XX:MaxRAMPercentage=75.0
-XX:InitialRAMPercentage=50.0
# OOM时直接退出,让K8s重启,不要让进程挂死
-XX:+ExitOnOutOfMemoryError
# GC选择:容器环境推荐G1GC或ZGC
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
# 加快随机数生成,避免启动慢
-Djava.security.egd=file:/dev/./urandom
# DNS缓存时间,容器IP会变,不能缓存太久
-Dnetworkaddress.cache.ttl=10多环境镜像管理策略
# 构建时注入环境标识
docker build \
--build-arg APP_ENV=prod \
--build-arg APP_VERSION=$(git rev-parse --short HEAD) \
--build-arg BUILD_TIME=$(date -u +"%Y-%m-%dT%H:%M:%SZ") \
-t registry.company.com/order-service:${VERSION} \
-t registry.company.com/order-service:latest \
.在Dockerfile里接收这些参数并写入LABEL,方便后续追踪:
ARG APP_VERSION=unknown
ARG BUILD_TIME=unknown
ARG APP_ENV=prod
LABEL maintainer="老张" \
app.version="${APP_VERSION}" \
app.build-time="${BUILD_TIME}" \
app.environment="${APP_ENV}"五、踩坑实录
坑一:Alpine镜像的glibc兼容性问题
第一次把某个服务改成Alpine镜像后,在本机测试完全正常,一上线就报错:
java.lang.UnsatisfiedLinkError: /opt/jdk/lib/server/libjvm.so:
Error loading shared library libstdc++.so.6: No such file or directory排查了半天才发现,这个服务依赖了一个本地库,编译时链接的是glibc,而Alpine用的是musl libc,两者不兼容。
解决方案有两个:一是用eclipse-temurin:17-jre(Debian base)而不是Alpine版本,大约220MB,虽然比Alpine大但不会有兼容性问题;二是在Alpine镜像里安装glibc兼容层:
FROM eclipse-temurin:17-jre-alpine AS runtime
# 安装glibc兼容层(解决部分native库兼容性问题)
RUN apk add --no-cache libc6-compat gcompat现在我的原则是:如果服务只用纯Java,用Alpine没问题;如果依赖了任何JNI或native库,老老实实用Debian base。
坑二:Maven依赖缓存层失效
按照网上的教程,先COPY pom.xml .再RUN mvn dependency:go-offline,理论上pom.xml不变就能命中缓存。但我发现有时候缓存莫名其妙失效,重新下载了几百MB的依赖。
后来找到原因:有个同事在pom.xml里加了${maven.build.timestamp}这类属性,导致pom.xml的内容每次构建都不一样,哈希变了,缓存自然失效。
另一个原因是多模块项目只复制了根pom.xml,子模块的pom.xml没复制,导致依赖解析不完整。正确做法:
# 多模块项目,需要复制所有pom.xml
COPY pom.xml .
COPY module-a/pom.xml module-a/
COPY module-b/pom.xml module-b/
COPY module-c/pom.xml module-c/
RUN mvn dependency:go-offline -B -pl . -am坑三:分层Jar的layertools命令在某些版本下抽风
我们有个服务用的是Spring Boot 2.4.3,用java -Djarmode=layertools -jar app.jar extract解压时报了个奇怪错误:
Unable to open nested entry 'BOOT-INF/lib/somelib-1.0.jar'.
It has been compressed and nested jar files must be stored without compression.查了一圈发现,这个jar包是用老版本Maven打的,里面某个依赖被压缩了,而Spring Boot的LayerTools不支持嵌套压缩。
解决方法是在spring-boot-maven-plugin里强制指定不压缩:
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<layers>
<enabled>true</enabled>
</layers>
<!-- 强制嵌套jar不压缩 -->
<embeddedLaunchScript></embeddedLaunchScript>
</configuration>
</plugin>如果上面不管用,还可以退而求其次,不用分层展开,直接把fat jar复制进去:
# 降级方案:不用分层,但至少保留多阶段构建的其他收益
FROM eclipse-temurin:17-jre-alpine AS runtime
COPY --from=builder /build/target/*.jar /app/app.jar
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar /app/app.jar"]坑四:非root用户无法访问某些目录
切换到非root用户运行后,有个服务启动时报错找不到日志目录。原来应用在/app/logs下创建日志文件,而这个目录的owner是root,普通用户没有写权限。
# 在切换用户前,提前创建需要的目录并赋予权限
RUN mkdir -p /app/logs /app/tmp && \
chown -R appuser:appgroup /app
USER appuser另外,某些JVM参数需要写临时文件的情况(比如dump heap),也需要提前确认目录权限。
六、总结
多阶段构建对Spring Boot项目的意义远不止是节省存储空间。更大的价值在于:
安全性提升:最终镜像里没有Maven、没有JDK、没有源代码,攻击面大幅减小。即使容器被攻破,攻击者也无法直接拿到源代码或利用构建工具链做进一步攻击。
推送速度飞跃:配合分层Jar,日常迭代只需要推几十KB的应用层,推送时间从分钟级降到秒级,研发效率显著提升。
可预期的构建环境:把构建工具链固化在镜像里,告别"在我机器上能跑"的问题。每次构建都是干净的、可重复的。
从今天开始,把你们公司的所有Dockerfile翻出来,凡是用了FROM openjdk或FROM maven作为最终基础镜像的,统统改成多阶段构建。这件事投入少、收益大,值得优先做。
