Docker 镜像瘦身:从 1.24 GB 压到 87 MB
多阶段构建、分层 jar、合理清理缓存,六步把镜像从 1.24 GB 压到 87 MB,CI 推送从 3 分钟降到 8 秒,以及四种看着能省实则翻车的做法。
一个 Spring Boot 服务的镜像,最初打出来 1.24 GB。这个数字意味着:每台机器拉一次要 4 分钟,CI 每次推送到镜像仓库要 3 分钟,节点的磁盘水位每周涨一截。折腾两天压到 87 MB 之后,同样的发布流程从 7 分钟降到 50 秒。
这篇文章把瘦身过程完整拆开,每一步给出前后体积和收益,最后是几个看起来能省、实际会翻车的做法。
一、先看清 1.24 GB 都被谁吃了
瘦身第一步不是改 Dockerfile,而是搞清楚现有的层里有什么。两个命令:
# 看层大小(从大到小)
docker history --no-trunc --format '{{.Size}}\t{{.CreatedBy}}' api:1.4.2 \
| sort -rh | head -15
# 交互式分析每一层新增的文件(dive 工具)
docker run --rm -it -v /var/run/docker.sock:/var/run/docker.sock \
wagoodman/dive:latest api:1.4.2
第一次跑出来的结果很典型:
512 MB RUN /bin/sh -c apt-get update && apt-get install -y openjdk-17-jdk ...
286 MB RUN /bin/sh -c mvn clean package -DskipTests # ← 编译工具链
198 MB COPY . /app # ← 含 .git 和 node_modules
94 MB RUN /bin/sh -c apt-get install -y build-essential gcc # ← 编译 native 依赖用
63 MB ADD target/app.jar /app.jar
31 MB RUN /bin/sh -c apt-get install -y curl vim git net-tools # ← 调试工具
四个问题一目了然:
- 装了 JDK 而不是 JRE(多出 200 多 MB 的编译器和源码)
- 构建工具链(Maven)进了运行镜像
COPY . /app把.git(198 MB 里的大头)一起拷进去了- 装了一堆运行时根本不需要的调试工具
二、第 1 步:JRE 替代 JDK,换基础镜像
最容易拿到的大块收益。
# 之前
FROM openjdk:17
# 之后
FROM eclipse-temurin:17-jre-alpine
openjdk:17 ≈ 470 MB,eclipse-temurin:17-jre-alpine ≈ 165 MB。光这一步省 300 MB。
再往下还能用 distroless:
FROM gcr.io/distroless/java17-debian12
约 130 MB,且不含 shell、包管理器、curl。代价是调试变难——kubectl exec 进去没有 shell 可用。这在生产环境反而是一个安全收益(攻击者拿到 RCE 也没有 shell 用),但要求你的探针不能用 exec、preStop 不能用 sh -c。这一点在上一篇讲探针时踩过。
选择建议:出问题的概率高、需要现场排查的服务用 alpine;基础稳定、以安全合规为先的服务用 distroless。
三、第 2 步:多阶段构建,把构建工具链隔离出去
这是收益最大也最容易被忽略的一步。核心思路:编译发生在 builder 阶段,产物 COPY 到运行阶段,builder 整个丢弃。
# ---------- 阶段 1:构建 ----------
FROM maven:3.9-eclipse-temurin-17 AS builder
WORKDIR /build
# 先只拷 pom,让依赖层可以被缓存
COPY pom.xml .
RUN mvn -B dependency:go-offline
# 再拷源码
COPY src ./src
RUN mvn -B clean package -DskipTests
# 顺手把 fat jar 拆成依赖 + 业务代码(第 4 步会用)
RUN java -Djarmode=layertools -jar target/*.jar extract --destination /build/extracted
# ---------- 阶段 2:运行 ----------
FROM eclipse-temurin:17-jre-alpine
WORKDIR /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", "org.springframework.boot.loader.launch.JarLauncher"]
Maven 镜像约 400 MB、~/.m2 依赖约 300 MB——全部留在 builder 阶段,不进最终镜像。
mvn dependency:go-offline 单独一层是有意为之:只要 pom.xml 没变,这一层就一直命中缓存,改业务代码时不用重新下载依赖。这个技巧能把 CI 的构建时间砍掉一半以上。
四、第 3 步:.dockerignore,别把 .git 拷进去
COPY . . 是最常见的脏数据来源。一个中等规模项目的 .git 目录轻松上百 MB。
# .dockerignore
.git
.gitignore
.idea
.vscode
node_modules
target
build
dist
*.log
*.md
docs
docker-compose*.yml
.env*
关键点:.dockerignore 必须在构建上下文根目录,不是 Dockerfile 所在目录。有次 Dockerfile 放在 docker/ 子目录,.dockerignore 也放那儿了,结果完全不生效——构建上下文是项目根,规则文件也得在根。
COPY . /app 换成精确拷贝也是好习惯:
COPY src ./src
COPY pom.xml .
五、第 4 步:分层 jar,让依赖层复用
Spring Boot 的 fat jar 有一个缓存问题:整个 jar 是一个 90 MB 的单一文件层,改一行代码就要重推 90 MB。
用 layertools 拆成四层后:
| 层 | 内容 | 变更频率 | 大小 |
|---|---|---|---|
dependencies |
所有第三方 jar | 极低 | ~78 MB |
spring-boot-loader |
启动器 | 几乎不变 | ~1 MB |
snapshot-dependencies |
内部 SNAPSHOT 包 | 偶尔 | ~5 MB |
application |
自己的 class | 每次 | ~2 MB |
结果是:日常发布只需推送 2 MB 的 application 层,前三层直接复用节点上的缓存。推送时间从 3 分钟降到 8 秒。
注意 Spring Boot 3.2 之后启动类的包名变了:org.springframework.boot.loader.JarLauncher → org.springframework.boot.loader.launch.JarLauncher。升级时忘了改会启动报 ClassNotFoundException。
六、第 5 步:合并 RUN,清理缓存
Docker 每一层都是增量叠加,apt-get install 之后再 apt-get clean 放在另一个 RUN 里是无效的——被删的文件仍在下面那层里占体积。
# 错误:清理无效,镜像还是那么大
RUN apt-get update && apt-get install -y curl
RUN apt-get clean && rm -rf /var/lib/apt/lists/*
# 正确:同一层内完成安装和清理
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
--no-install-recommends 也值得加。它会跳过「推荐包」——比如 curl 装完顺手带上 30 MB 的文档和证书包。
七、第 6 步:非 root 用户与时间同步
瘦身之外,这两个是顺手必须做的。
RUN addgroup -g 1000 app && adduser -u 1000 -G app -D -s /sbin/nologin app
USER 1000:1000
以非 root 运行后,EXPOSE 80 这种低端口就绑不上了(Linux 限制 1024 以下端口需要特权)。这也逼着你把端口改成 8080,是好事。
时区问题几乎每个容器化项目都会撞一次:
ENV TZ=Asia/Shanghai
RUN apk add --no-cache tzdata && \
cp /usr/share/zoneinfo/$TZ /etc/localtime && \
echo $TZ > /etc/timezone
Alpine 默认没有时区数据,容器里 date 显示 UTC。日志时间、LocalDateTime.now()、定时任务触发时间全都会偏 8 小时。这个坑不会报错,只会让日志对不上,排查起来很浪费生命。
八、看起来能省、实际坑人的四种做法
1. 把 node_modules 从宿主机拷进镜像
COPY . . # 含 node_modules
RUN npm ci # 又把依赖装一遍
node_modules 里可能包含宿主机编译的 native 模块(.node 文件),在 Linux 容器里直接 cannot open shared object。而且镜像会膨胀两倍。正确做法是 .dockerignore 排除,容器内 npm ci。
2. 用 alpine 跑 glibc 编译的二进制
Alpine 用的是 musl libc。netty 的 tcnative、某些 JDBC 驱动、依赖 glibc 的 native 库在 Alpine 上会报 Error loading shared library: No such file or directory。遇到这类报错不要硬调,直接换成 debian-slim 基础镜像。
3. 删掉 /var/cache 就以为省了空间
如果删除发生在另一层,体积不会减少。docker history 会诚实地告诉你每一层实际占多少。
4. 盲目压 --squash
把所有层压成一层确实能减小体积,但会彻底失去层缓存——每次构建都要重装所有依赖,CI 时间翻三倍。得不偿失。
九、最终结果
完整跑完六步的对比:
| 阶段 | 体积 | CI 推送耗时 | 拉取耗时 |
|---|---|---|---|
初始 openjdk:17 |
1.24 GB | 3m 10s | 4m 05s |
换 jre-alpine |
940 MB | 2m 20s | 3m 10s |
| 多阶段构建 | 210 MB | 40s | 55s |
加 .dockerignore |
178 MB | 32s | 45s |
| 分层 jar | 178 MB | 8s | 12s |
清理 + --no-install-recommends |
87 MB | 9s | 14s |
注意最后两行:体积只降了 90 MB,但推送速度提升了 20 倍。这说明一个道理——真正影响发布效率的不是镜像总大小,而是每次发布需要传输的增量大小。分层 jar 就是为这件事设计的。
十、配套的构建检查清单
把这些步骤固化进 CI,作为流水线的失败条件:
# 1. 体积上限
SIZE=$(docker image inspect api:$TAG --format '{{.Size}}')
if [ "$SIZE" -gt 150000000 ]; then
echo "镜像超过 150MB:$((SIZE/1024/1024))MB"; exit 1
fi
# 2. 不允许以 root 运行
USER_NAME=$(docker image inspect api:$TAG --format '{{.Config.User}}')
[ -z "$USER_NAME" ] && { echo "镜像未指定非 root 用户"; exit 1; }
# 3. 关键层是否命中缓存(推送量是否合理)
docker push api:$TAG 2>&1 | grep -E 'Pushed|Layer already exists'
# 4. 漏洞扫描(阻断高危)
trivy image --exit-code 1 --severity HIGH,CRITICAL --no-progress api:$TAG
第 1 条阈值 150 MB 是按「Spring Boot + 常见依赖」估的。如果你的服务确实需要更多(比如带了 native 库或模型文件),按基线定阈值,重点是防止体积静默膨胀——没有闸门的话,半年后一定会回到 800 MB。
小结
瘦身的收益排序和难度排序是反的:
- 最容易拿、收益最大:换 JRE 基础镜像 + 多阶段构建(省 700 MB,改 10 行)
- 收益中等、必须做:
.dockerignore+ 合并 RUN 清理(省 60 MB,防脏数据) - 体积收益小、效率收益极大:分层 jar(只省 90 MB,但推送快 20 倍)
- 不为体积、为安全:非 root + distroless
如果只能做一件事,做多阶段构建。如果只能做两件,加上分层 jar。这两件事加起来,一次发布从 7 分钟变成 1 分钟。
正在加载评论…