Docker 镜像体积优化实战——从 1.2GB 到 87MB 的完整压缩历程
Docker 镜像体积优化实战——从 1.2GB 到 87MB 的完整压缩历程
适读人群:有 Docker 使用经验的后端工程师、运维工程师 | 阅读时长:约 14 分钟 | 核心价值:系统掌握镜像瘦身的所有实用手段,避开常见陷阱
去年双十一前夕,我们的镜像仓库突然报警:存储配额快撑不住了。我登上 Harbor 管理台一看,一个 Java 服务的镜像居然有 1.2GB,而且每个分支都有独立 tag,加一起占了 180GB 出头。运维的同事找到我,眼神里透着一股子无奈——"老张,这镜像能不能搞小一点?"
我当时只说了一句话:"今晚搞定。"
事后证明,这句话差点让我食言——真正做完优化是凌晨 1 点 17 分,最终把这个服务的镜像从 1.2GB 压到了 87MB。这篇文章把整个过程完整复盘一遍,希望能帮到面对同样问题的人。
先诊断:镜像为什么会这么大?
很多人的第一反应是"我们用的 JDK 镜像就这么大",然后就放弃优化了。这是典型的错误归因。镜像体积大,通常是多个问题叠加的结果。
我的排查工具是 dive,一个可以逐层分析镜像内容的命令行工具:
# 安装 dive(macOS)
brew install dive
# 分析镜像每一层
dive your-service:latestdive 会列出每一层的大小和新增/删除文件,有时候你会发现令人震惊的事情——某一层新增了 400MB,紧接着下一层删除了 350MB,但因为 Union FS 的层叠机制,这 350MB 其实并没有消失,依然占用镜像体积。
我们这个 Java 服务的具体情况:
| 层 | 大小 | 内容 |
|---|---|---|
| ubuntu:20.04 基础层 | 72MB | 系统基础文件 |
| JDK 安装层 | 380MB | 完整 JDK 17,含编译工具 |
| Maven 依赖下载层 | 420MB | 全量 Maven 依赖 |
| 源码编译层 | 210MB | 编译中间文件 + jar |
| 配置文件层 | 3MB | application.yml 等 |
| 清理层 | -280MB | rm -rf ~/.m2,但无效 |
| 启动脚本层 | 1MB | entrypoint.sh |
你看到问题了吗?"清理层"删除了 280MB 的 Maven 缓存,但这层删除在 Union FS 的叠加机制下只是"遮盖",实际上两层都在。这是我见过最经典的 Docker 镜像体积踩坑。
踩坑实录一:RUN 命令分层导致删除无效
现象:Dockerfile 里明明有 RUN rm -rf /root/.m2,但镜像还是那么大。
原因:Docker 每条 RUN 指令都会创建一个新层。在上一层"创建"的文件,在下一层"删除"只是在新层上做了标记(whiteout 文件),两层实际上都存在于最终镜像里。
原来的写法(错误):
FROM ubuntu:20.04
RUN apt-get install -y maven
RUN mvn package
RUN rm -rf /root/.m2 # 无效!这是新的一层正确做法:把下载、构建、清理合并成一条 RUN 命令:
FROM ubuntu:20.04
RUN apt-get install -y maven \
&& mvn package \
&& rm -rf /root/.m2 \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*合并这一步之后,体积从 1.2GB 降到了 890MB。进步明显,但还不够。
核心手段:多阶段构建
这才是真正的核武器。思路很简单:编译环境和运行环境是两回事,运行时根本不需要编译工具链。
# ===== 第一阶段:构建 =====
FROM maven:3.9-eclipse-temurin-17 AS builder
WORKDIR /build
# 先只复制 pom.xml,利用缓存层机制
COPY pom.xml .
RUN mvn dependency:go-offline -B
# 再复制源码,这层变化频繁,不缓存也没关系
COPY src ./src
RUN mvn package -DskipTests -B
# ===== 第二阶段:运行 =====
FROM eclipse-temurin:17-jre-alpine
# 非 root 用户运行(安全最佳实践)
RUN addgroup -g 1001 appgroup && \
adduser -u 1001 -G appgroup -s /bin/sh -D appuser
WORKDIR /app
# 只从第一阶段复制编译产物
COPY --from=builder /build/target/*.jar app.jar
USER appuser
EXPOSE 8080
ENTRYPOINT ["java", \
"-XX:+UseContainerSupport", \
"-XX:MaxRAMPercentage=75.0", \
"-jar", "app.jar"]这个 Dockerfile 做了几件事:
- 构建阶段用完整的 Maven + JDK 17 环境
- 运行阶段只用 JRE(没有编译器)+ Alpine(最小 Linux)
- 运行时镜像里根本没有 Maven 依赖、源码、编译工具
应用这个方案后:镜像从 890MB 降到了 147MB。
踩坑实录二:基础镜像选型错误
当时我第一版多阶段构建用的是 openjdk:17,没仔细看,结果运行镜像还有 350MB。
现象:换了多阶段构建,镜像还是很大。
原因:openjdk:17 这个镜像是基于 Debian 的,带了大量的系统工具,体积本身就接近 460MB。
解法:换基础镜像。我对比了几个选项:
| 基础镜像 | 体积 | 适用场景 |
|---|---|---|
openjdk:17 | ~460MB | 不推荐,已废弃维护 |
eclipse-temurin:17-jre | ~226MB | 标准选择 |
eclipse-temurin:17-jre-alpine | ~96MB | 推荐,体积小 |
eclipse-temurin:17-jre-jammy | ~199MB | Ubuntu 22.04 兼容性好 |
| GraalVM native image | ~30MB+ | 需要 native 编译 |
切到 eclipse-temurin:17-jre-alpine 之后体积到了 147MB,再加上后面的 jar 分层优化,最终到了 87MB。
有一点要注意:Alpine 用的是 musl libc,不是 glibc,极少数情况下会有兼容性问题,比如某些用到 JNI 的库。遇到这种情况换 jammy 就好。
踩坑实录三:jar 包没有分层导致缓存失效
现象:每次改一行代码,CI 重新构建镜像,Docker 层缓存完全失效,每次都重新下载依赖,构建慢得要命。
原因:把整个 fat jar 复制进镜像,但 fat jar 里包含了依赖和业务代码,任何一行改动都导致这个大文件变化,缓存失效。
解法:Spring Boot 2.3+ 支持 jar 分层(layered jar),可以把 fat jar 拆成依赖层、快照依赖层、业务代码层等:
FROM eclipse-temurin:17-jre-alpine AS layers-extractor
WORKDIR /app
COPY --from=builder /build/target/*.jar app.jar
# 提取 jar 分层内容
RUN java -Djarmode=layertools -jar app.jar extract
FROM eclipse-temurin:17-jre-alpine
RUN addgroup -g 1001 appgroup && \
adduser -u 1001 -G appgroup -s /bin/sh -D appuser
WORKDIR /app
# 按照变化频率从低到高依次复制,确保缓存最大化利用
COPY --from=layers-extractor /app/dependencies/ ./
COPY --from=layers-extractor /app/spring-boot-loader/ ./
COPY --from=layers-extractor /app/snapshot-dependencies/ ./
COPY --from=layers-extractor /app/application/ ./
USER appuser
EXPOSE 8080
ENTRYPOINT ["java", \
"-XX:+UseContainerSupport", \
"-XX:MaxRAMPercentage=75.0", \
"org.springframework.boot.loader.JarLauncher"]这样,依赖没变的情况下,只有 application/ 这层需要重建,构建时间从 4 分 37 秒降到了 43 秒。
记得在 pom.xml 里开启分层支持:
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<layers>
<enabled>true</enabled>
</layers>
</configuration>
</plugin>其他实用优化手段
1. .dockerignore 文件别忘了
很多人知道 .gitignore,但经常忘记 .dockerignore。没有这个文件,COPY . . 会把你的 node_modules、.git 目录、IDE 配置都复制进 build context,既浪费时间又可能把敏感信息带进镜像。
# .dockerignore
.git
.gitignore
.idea
*.iml
target/
node_modules/
*.log
.env
.env.local
README.md
docs/2. 选择更小的基础镜像系列
除了 Alpine,还可以考虑 Distroless 镜像(Google 维护),这类镜像连 shell 都没有,只有运行时必要的文件:
FROM gcr.io/distroless/java17-debian11
COPY --from=builder /build/target/*.jar /app.jar
ENTRYPOINT ["/app.jar"]Distroless 比 Alpine JRE 还小 20MB 左右,而且攻击面更小。缺点是没有 shell,调试会麻烦些。
3. 清理 apt 缓存
如果基础镜像不是 Alpine,在安装任何 apt 包后都要清理:
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
curl \
netcat-traditional \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*--no-install-recommends 这个参数很重要,apt 默认会安装推荐包,加这个参数可以少装很多东西。
完整优化效果对比
| 阶段 | 镜像体积 | 主要优化手段 |
|---|---|---|
| 初始状态 | 1,200MB | 无优化 |
| 合并 RUN 命令 | 890MB | 清理操作放在同一层 |
| 切换基础镜像 | 680MB | 从 ubuntu 换到 eclipse-temurin |
| 引入多阶段构建 | 147MB | 构建与运行分离 |
| 切换 Alpine JRE | 93MB | 最小化运行时 |
| jar 分层 + 其他优化 | 87MB | 分层复制 + .dockerignore |
最终 87MB,比初始缩小了 92.8%。
整个优化下来,我的体会是:镜像体积大很少是"单一原因",通常是多个坏习惯叠加的结果。要系统地用 dive 分析每一层,找到最大的问题先解决。
另外,多阶段构建是投入产出比最高的优化手段,只要用了,至少砍掉 60% 的体积。这个不用,其他手段再折腾收益也有限。
最后一个提醒:体积优化要测试功能完整性。Alpine 的环境和 Debian/Ubuntu 有差异,切换基础镜像后一定要跑完整的集成测试。我们之前有个服务用到了 glibc 的某个特性,切 Alpine 之后线上报错,查了两个小时才定位到。
