分类:DevOps 与部署 · 作者:管理员 · 发布于 2026-10-07 · 阅读 0 · 标签:Docker / 镜像瘦身 / 多阶段构建 / Spring Boot / DevOps

一、痛点:一个 Spring Boot 镜像凭什么 700MB

一个典型的电商中台服务,打出来的镜像动辄 600MB~1GB。部署时要等拉取,磁盘几周就满,而且镜像里塞满了开发期才需要的工具,攻击面被放大。镜像变胖通常有三个原因:

  • 基础镜像选错:直接基于 openjdk:8-jdk 这类全量 JDK 镜像,里面带着编译器、调试符号、一堆用不到的模块;
  • 构建产物冗余:把 target 目录、源码、本地仓库依赖一股脑 COPY 进镜像;
  • 没有利用分层缓存:每次改一行代码都要重新下载全部 Maven 依赖、重新 COPY 整个 jar,构建慢、层也大。

本文以一个典型的 Spring Boot 3 + Maven 服务(下文统一叫 app-backend)为例,从 700MB 瘦到 150MB 左右,并解决构建速度和运行安全两个连带问题。

二、方案设计:三条主线同时做

  1. 多阶段构建:构建阶段用带 Maven + JDK 的镜像编译打包,运行阶段只保留 JRE + 产物,两者互不干扰;
  2. 分层缓存:让「依赖层」和「应用代码层」分离,依赖没变就不重新构建,并用 Spring Boot 官方分层 jar 进一步拆分;
  3. 安全加固:非 root 用户运行、最小化基础镜像、显式健康检查。

三、第一步:换基础镜像,立省一半

不要再用 openjdk:8-jdk-alpine(早已停止更新,且是 JDK 不是 JRE)。推荐 eclipse-temurin 系列:官方维护、有 JRE 变体、提供 alpine 与基于 debian 的 slim 版本。

# 构建阶段:需要完整 JDK + Maven
FROM maven:3.9-eclipse-temurin-17 AS builder
WORKDIR /build
COPY pom.xml .
RUN mvn -q -DskipTests dependency:go-offline
COPY src ./src
RUN mvn -q -DskipTests package

# 运行阶段:只要 JRE
FROM eclipse-temurin:17-jre-jammy
WORKDIR /srv/app
COPY --from=builder /build/target/app-backend.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

光是把运行阶段从 JDK 换成 JRE 镜像,就能省掉 150MB 以上。再配合 .dockerignore,避免把 target、.git、本地 ~/.m2 拷进构建上下文:

target/
.git
.idea/
*.iml
logs/
Dockerfile

四、第二步:分层缓存,让构建从 3 分钟变 10 秒

Docker 的层是按 COPY / RUN 指令切分的,某一层内容没变,后面所有层都会命中缓存。所以必须把「依赖」和「代码」分开 COPY:先只拷 pom.xml 触发依赖下载(dependency:go-offline 把依赖拉到本地仓库),再拷源码编译。这样改业务代码时,依赖层直接复用。

更进一步,Spring Boot 2.3+ 默认把 jar 拆成 dependencies、spring-boot-loader、snapshot-dependencies、application 四层,可以用官方 layertools(Spring Boot 3.2+ 推荐 jarmode=tools)在 Dockerfile 里把层拆开、分步 COPY:

# 构建阶段
FROM maven:3.9-eclipse-temurin-17 AS builder
WORKDIR /build
COPY pom.xml .
RUN mvn -q -DskipTests dependency:go-offline
COPY src ./src
RUN mvn -q -DskipTests package
# 提取分层
RUN java -Djarmode=tools -jar target/app-backend.jar extract --layers --destination extracted

# 运行阶段
FROM eclipse-temurin:17-jre-jammy
WORKDIR /srv/app
# 依赖层基本不变,稳定命中缓存
COPY --from=builder /build/extracted/dependencies/ ./
COPY --from=builder /build/extracted/spring-boot-loader/ ./
COPY --from=builder /build/extracted/snapshot-dependencies/ ./
COPY --from=builder /build/extracted/application/ ./
ENTRYPOINT ["java", "-jar", "app.jar"]

效果:依赖层(通常占 80% 体积)几乎永不重下,日常迭代只重建 application 一层,CI 构建从几分钟降到十几秒。

五、踩坑记录:镜像小了,坑也来了

  • Alpine 的 musl DNS 问题:alpine 用 musl libc,DNS 解析逻辑与 glibc 不同,容器编排环境里偶发解析超时、UnknownHostException。如果服务要访问数据库/其他微服务,优先选 debian slim 变体(17-jre-jammy 等),别为了省 30MB 给自己埋雷;
  • 时区错乱:alpine 镜像默认没有 tzdata,日志时间全是 UTC。要么 RUN apk add --no-cache tzdata 并设置 TZ,要么直接用带时区的 JRE 镜像并在启动参数里加 -Duser.timezone=Asia/Shanghai;
  • SecureRandom 启动慢:老版本 JDK 在容器里读 /dev/random 可能阻塞,启动卡几十秒。生产环境建议显式加 -Djava.security.egd=file:/dev/./urandom,新版本 JDK 虽已默认,但显式声明不亏;
  • 分层缓存不生效:检查两点——pom.xml 是否真的在源码之前 COPY;dependency:go-offline 与 package 是否同层(同层会导致代码变更时依赖也一起重跑,必须分开两个 RUN)。

六、安全加固:非 root + 只读根文件系统

镜像瘦身和加固是同一件事的两面:东西越少,能被打的点越少。运行阶段可以再加三步:

FROM eclipse-temurin:17-jre-jammy
WORKDIR /srv/app

# 1. 非 root 运行
RUN groupadd -r app && useradd -r -g app -u 10001 app
COPY --from=builder --chown=app:app /build/extracted/ ./
USER app

# 2. 显式健康检查
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
  CMD curl -f http://localhost:8080/actuator/health || exit 1

# 3. 时区与随机数
ENV TZ=Asia/Shanghai JAVA_OPTS="-Djava.security.egd=file:/dev/./urandom"
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]

进阶选项:追求极致体积可以上 distroless 或 gcr.io/distroless/java17-debian11(无 shell、无包管理器),但排查问题不方便,建议成熟团队再用;另外在 CI 里加一步 trivy image 扫描,把高危 CVE 挡在上线前。

七、最佳实践小结

  • 运行镜像只用 JRE,构建和运行环境分离(多阶段构建);
  • 依赖层与代码层分开 COPY,并用 Spring Boot 分层 jar 拆分,稳定命中 Docker 缓存;
  • 基础镜像优先 debian slim(eclipse-temurin:17-jre-jammy),避免 musl DNS 坑;
  • 镜像内一律非 root 用户 + 显式健康检查;
  • 带宽充足时用 docker pull --platform 固定平台,避免架构混用导致镜像双倍体积。

照这套流程走完,app-backend 的镜像从 700MB 降到 150MB 左右,构建提速一个量级,运行时的安全隐患也顺手清了一轮。