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  # ← 调试工具

四个问题一目了然:

  1. 装了 JDK 而不是 JRE(多出 200 多 MB 的编译器和源码)
  2. 构建工具链(Maven)进了运行镜像
  3. COPY . /app 把 .git(198 MB 里的大头)一起拷进去了
  4. 装了一堆运行时根本不需要的调试工具

二、第 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 分钟。

查看「容器与编排」全部文章 →

同分类文章

查看 0 评论 0

评论

正在加载评论…

留言提交后自动审核:正常内容立即显示,含敏感信息的会转人工复核。