Docker多阶段构建优化:Java镜像从800MB到80MB的完整过程
Docker多阶段构建优化:Java镜像从800MB到80MB的完整过程
适读人群:部署Java微服务到容器环境、关注镜像体积和构建效率的开发者 | 阅读时长:约17分钟
开篇故事
刚开始用 Docker 部署 Java 服务时,Dockerfile 写得很随意:
FROM openjdk:17
COPY target/app.jar /app.jar
ENTRYPOINT ["java", "-jar", "/app.jar"]docker images 一看,镜像 800MB。上传到镜像仓库要好几分钟,CI/CD 流水线每次部署都龟速。
后来一步一步优化下来,同样的应用镜像做到了 80MB 左右,推送时间从 3 分钟降到了 15 秒。
这不是玄学,有一套系统的方法论。今天把完整的优化过程分享出来。
一、镜像大的根本原因
1.1 各基础镜像大小对比
| 基础镜像 | 大小 | 说明 |
|---|---|---|
openjdk:17 | ~470MB | 完整JDK,包含编译工具 |
openjdk:17-jdk-slim | ~230MB | JDK精简版,去掉部分工具 |
openjdk:17-jre-slim | ~130MB | 只有JRE,无法编译 |
eclipse-temurin:17-jre-alpine | ~80MB | Alpine Linux + JRE |
gcr.io/distroless/java17 | ~65MB | Distroless,极小无Shell |
1.2 多阶段构建解决了什么
没有多阶段构建时:所有构建工具(Maven、JDK编译工具)都会留在最终镜像里。
多阶段构建的核心思想:只把运行时需要的东西复制到最终镜像。
二、完整优化步骤
步骤1:基础版 Dockerfile(800MB)
# ❌ 最差实践:所有内容都在一个镜像里
FROM openjdk:17
WORKDIR /app
COPY . .
RUN apt-get update && apt-get install -y maven
RUN mvn clean package -DskipTests
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "target/app.jar"]问题:包含完整JDK、Maven、源代码、中间产物
步骤2:分离构建和运行(从800MB到250MB)
# 阶段1:构建(使用Maven镜像)
FROM maven:3.9.5-eclipse-temurin-17 AS builder
WORKDIR /build
# 先复制 pom.xml,利用 Docker 层缓存(只有pom变了才重新下载依赖)
COPY pom.xml .
RUN mvn dependency:go-offline -B
# 再复制源码
COPY src ./src
RUN mvn clean package -DskipTests -B
# 阶段2:运行(只使用JRE)
FROM eclipse-temurin:17-jre-alpine AS runner
WORKDIR /app
# 只复制 JAR 文件,不复制源码和Maven工具
COPY --from=builder /build/target/app.jar .
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]优化点:
- 运行阶段用 JRE 而不是 JDK,小了很多
- 构建工具不进入最终镜像
- 分离
pom.xml和源码,最大化层缓存利用
步骤3:Spring Boot 分层 JAR(从250MB到80MB)
Spring Boot 2.3+ 支持分层 JAR,把依赖和应用代码分成不同层:
pom.xml 开启分层:
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<!-- 开启分层 JAR -->
<layers>
<enabled>true</enabled>
</layers>
</configuration>
</plugin>
</plugins>
</build>完整优化 Dockerfile:
# ============================================================
# 阶段1:构建阶段
# ============================================================
FROM maven:3.9.5-eclipse-temurin-17-alpine AS builder
WORKDIR /build
# 利用层缓存优化:先复制 pom.xml,依赖变了才重新下载
COPY pom.xml .
RUN mvn dependency:go-offline -B --quiet
# 复制源码并构建
COPY src ./src
RUN mvn clean package -DskipTests -B --quiet
# ============================================================
# 阶段2:提取分层内容
# ============================================================
FROM eclipse-temurin:17-jre-alpine AS extractor
WORKDIR /app
# 复制构建产物
COPY --from=builder /build/target/app.jar ./app.jar
# 使用 Spring Boot 的 layertools 提取分层内容
RUN java -Djarmode=layertools -jar app.jar extract
# 提取后会生成:
# dependencies/ ← 第三方依赖(变化最少)
# snapshot-dependencies/ ← SNAPSHOT依赖
# spring-boot-loader/ ← Spring Boot 启动加载器
# application/ ← 你的业务代码(变化最频繁)
# ============================================================
# 阶段3:最终运行镜像
# ============================================================
FROM eclipse-temurin:17-jre-alpine AS runner
# 安全:不使用root用户运行
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
# 按照变化频率从低到高复制(利用Docker层缓存)
# 变化最少的放最前面(层缓存最稳定)
COPY --from=extractor /app/dependencies/ ./
COPY --from=extractor /app/snapshot-dependencies/ ./
COPY --from=extractor /app/spring-boot-loader/ ./
# 变化最频繁的放最后(这一层改了不影响前面的缓存)
COPY --from=extractor /app/application/ ./
# 切换到非root用户
USER appuser
EXPOSE 8080
# JVM 优化参数
ENTRYPOINT ["java", \
"-XX:+UseContainerSupport", \
"-XX:MaxRAMPercentage=75.0", \
"-XX:+UseG1GC", \
"-XX:+ExitOnOutOfMemoryError", \
"-Djava.security.egd=file:/dev/./urandom", \
"org.springframework.boot.loader.launch.JarLauncher"]关键JVM参数说明:
| 参数 | 作用 |
|---|---|
-XX:+UseContainerSupport | 让JVM感知容器限制(不用宿主机内存),JDK 8u191+支持 |
-XX:MaxRAMPercentage=75.0 | 使用容器内存的75%作为堆内存 |
-XX:+UseG1GC | 使用G1垃圾收集器 |
-XX:+ExitOnOutOfMemoryError | OOM时退出,让K8s重启(而不是僵死) |
-Djava.security.egd=file:/dev/./urandom | 加速随机数生成(容器里/dev/random熵不足) |
步骤4:自定义 JRE(从80MB到50MB)
使用 jlink 工具创建只包含应用需要模块的最小 JRE:
# 阶段1:构建
FROM maven:3.9.5-eclipse-temurin-17-alpine AS builder
WORKDIR /build
COPY pom.xml .
RUN mvn dependency:go-offline -B --quiet
COPY src ./src
RUN mvn clean package -DskipTests -B --quiet
# 阶段2:分析依赖模块 + 创建自定义JRE
FROM eclipse-temurin:17-alpine AS jre-builder
WORKDIR /app
COPY --from=builder /build/target/app.jar ./app.jar
# 分析 JAR 需要哪些 Java 模块
RUN jar xf app.jar && \
jdeps \
--ignore-missing-deps \
--print-module-deps \
--multi-release 17 \
--recursive \
--class-path 'BOOT-INF/lib/*' \
app.jar > modules.txt && \
cat modules.txt
# 用 jlink 创建最小 JRE(只包含需要的模块)
RUN jlink \
--add-modules $(cat modules.txt) \
--strip-debug \
--no-man-pages \
--no-header-files \
--compress=2 \
--output /custom-jre
# 阶段3:提取分层
FROM eclipse-temurin:17-alpine AS extractor
WORKDIR /app
COPY --from=builder /build/target/app.jar ./app.jar
RUN java -Djarmode=layertools -jar app.jar extract
# 阶段4:最终镜像(Alpine + 自定义JRE)
FROM alpine:3.19 AS runner
# 复制自定义 JRE
COPY --from=jre-builder /custom-jre /opt/jre
ENV PATH="/opt/jre/bin:${PATH}"
ENV JAVA_HOME="/opt/jre"
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
WORKDIR /app
COPY --from=extractor /app/dependencies/ ./
COPY --from=extractor /app/snapshot-dependencies/ ./
COPY --from=extractor /app/spring-boot-loader/ ./
COPY --from=extractor /app/application/ ./
USER appuser
EXPOSE 8080
ENTRYPOINT ["java", \
"-XX:+UseContainerSupport", \
"-XX:MaxRAMPercentage=75.0", \
"org.springframework.boot.loader.launch.JarLauncher"]三、踩坑实录
坑1:.dockerignore 没有配置,导致缓存失效
症状:明明没改代码,docker build 还是重新下载依赖。
根因:COPY . . 把 .git、target、node_modules 等目录也复制进去了,这些目录经常变化,导致缓存层失效。
解决:配置 .dockerignore:
# .dockerignore
.git
.gitignore
target/
*.md
.idea/
*.iml
Dockerfile
.dockerignore坑2:Alpine 镜像里 glibc 兼容性问题
症状:用 Alpine + JDK 的应用正常运行,但某些依赖了 glibc 的第三方库(如 RocksDB、某些加密库)报错。
根因:Alpine 用的是 musl libc,不是 glibc,部分 native library 不兼容。
解决方案:改用 Debian slim 版本:
FROM eclipse-temurin:17-jre as runner
# Debian slim,有glibc,比Alpine大约20MB,但兼容性好坑3:容器内 JVM 不感知内存限制,OOM 被 K8s Kill
症状:给容器设置了 512MB 内存限制,但 Pod 总是被 OOMKill。
根因:JDK 8u191 之前,JVM 不感知 cgroup 限制,按宿主机内存(比如 16GB)计算堆大小,远超容器限制。
解决:升级 JDK 到 8u191+ 或 JDK 11+,并加上 -XX:+UseContainerSupport(JDK 11+ 默认开启)。
坑4:分层 JAR 提取失败
症状:运行 java -Djarmode=layertools -jar app.jar extract 报错。
根因:Spring Boot Maven 插件没有开启 <layers><enabled>true</enabled></layers>,默认不会生成分层 JAR。
确认方式:解压 JAR,看 BOOT-INF/ 目录下是否有 layers.idx 文件。
四、镜像大小对比
| Dockerfile 方案 | 镜像大小 | 构建时间 | 推送时间 |
|---|---|---|---|
| 基础版 | ~800MB | 3分钟 | 5分钟 |
| 分离构建运行 | ~250MB | 2分钟 | 90秒 |
| 分层 JAR | ~80MB | 90秒 | 15秒 |
| 自定义 JRE | ~50MB | 3分钟 | 10秒 |
推荐:对于大多数项目,"分层 JAR"方案的性价比最高,实现复杂度适中,镜像体积已经够小。"自定义 JRE"适合对体积极度敏感的场景(如 IoT)。
