第一次把 Spring Boot 项目打成镜像,我看到 1.2GB 这个数字时以为是命令敲错了。推到内网仓库要三分钟,节点拉取又要三分钟,一个简单的滚动发布能拖到十分钟。
排查下来,镜像里塞了 JDK、Maven、源码、测试代码,以及 .m2 里几百兆的依赖缓存——全都不是运行时需要的东西。
问题从哪来
先看一个"朴素"的 Dockerfile,问题很典型:
dockerfile
FROM maven:3.9-eclipse-temurin-21
WORKDIR /app
COPY . .
RUN mvn clean package
CMD ["java", "-jar", "target/app.jar"]它做了什么?把构建环境原封不动地留到了运行镜像里:
| 内容 | 运行时需要吗 | 体积 |
|---|---|---|
| 完整 JDK(含编译器) | 不需要,JRE 就够 | ~300MB |
| Maven + ~/.m2 依赖 | 不需要 | ~400MB |
| 源码、测试代码 | 不需要 | ~10MB |
| 目标 jar | ✅ 需要 | ~80MB |
结论:真正需要的只有最后一行。
多阶段构建
核心思路:用多个 FROM,前面的阶段只负责产出产物,最后一个阶段只装运行时。
dockerfile
# ---------- 阶段 1:构建 ----------
FROM maven:3.9-eclipse-temurin-21 AS builder
WORKDIR /build
# 先只复制 pom,利用 Docker 层缓存
COPY pom.xml .
RUN mvn -B dependency:go-offline
COPY src ./src
RUN mvn -B clean package -DskipTests
# ---------- 阶段 2:运行 ----------
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
# 从构建阶段只取产物
COPY --from=builder /build/target/app.jar app.jar
# 非 root 运行
RUN addgroup -S app && adduser -S app -G app
USER app
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/app.jar"]三个关键点:
AS builder+COPY --from=builder:跨阶段只搬运需要的文件,其余全部丢弃dependency:go-offline单独成层:依赖没变时,这一层直接命中缓存,构建从 3 分钟降到 20 秒- 运行阶段用
-jre-alpine:只要运行时,不要编译器
顺序很重要
COPY pom.xml 必须在 COPY src 之前。改一行代码不会让依赖层失效,这是缓存命中的前提。
瘦身清单
做完多阶段构建通常是 200MB 左右。再往下压,按收益排序:
1. 别用 fat jar 启动(收益最大)
Spring Boot fat jar 启动时解压全部依赖,慢且占用高。拆成分层目录后,可以只复制变动的层:
dockerfile
FROM eclipse-temurin:21-jre-alpine AS extractor
WORKDIR /build
COPY --from=builder /build/target/app.jar app.jar
RUN java -Djarmode=layertools -jar app.jar extract
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=extractor /build/dependencies/ ./
COPY --from=extractor /build/spring-boot-loader/ ./
COPY --from=extractor /build/snapshot-dependencies/ ./
COPY --from=extractor /build/application/ ./
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]业务代码改动时,只有最后一层(通常几 MB)需要重新推送和拉取。
2. 加 .dockerignore
text
target/
.git/
.idea/
*.log
node_modules/少了它,COPY . . 会把 target/ 里的旧产物也塞进构建上下文,既慢又容易出错。
3. 清掉包管理器缓存
dockerfile
RUN apk add --no-cache tzdata curl \
&& rm -rf /var/cache/apk/*注意 --no-cache 和 rm -rf 必须和 apk add 在同一个 RUN 里,否则删除层不会减少镜像体积。
4. 用 distroless(可选,追求极致安全)
dockerfile
FROM gcr.io/distroless/java21-debian12
COPY --from=builder /build/target/app.jar /app.jar
ENTRYPOINT ["java", "-jar", "/app.jar"]没有 shell、没有包管理器,攻击面最小。代价是没法 docker exec 进去调试,出问题只能靠日志和监控。
别用 latest 标签
FROM maven:latest 会在某天悄悄换掉 JDK 版本。生产镜像必须锁死基础镜像版本,例如 maven:3.9-eclipse-temurin-21。
验证与对比
bash
# 看体积
docker images myapp --format "{{.Repository}}:{{.Tag}} {{.Size}}"
# 看每一层占了多少,定位大块头
docker history myapp:1.0 --human --format "{{.Size}}\t{{.CreatedBy}}"
# 看镜像里到底有什么文件
dive myapp:1.0我这轮优化的实际数据:
| 阶段 | 体积 | 构建耗时 | 推送耗时 |
|---|---|---|---|
| 优化前 | 1.2 GB | 3m10s | 2m50s |
| 多阶段 + JRE | 210 MB | 1m05s | 35s |
| 分层 + .dockerignore | 122 MB | 22s(命中缓存) | 8s |
最后一个数字才是关键:日常迭代时镜像推送从近 3 分钟降到 8 秒,发布体验完全不同。
小结
- 多阶段构建解决"构建环境混进运行镜像",是收益最大的一步
pom先复制,锁死基础镜像版本,配好.dockerignore- 分层启动 + 清理缓存同层执行,能再压掉一半
- 先用
docker history定位谁在占空间,再动手,别凭感觉优化