从 950MB 瘦到 152MB:一次 Docker 镜像瘦身的逐层实测(2026)

同一份 Python FastAPI 项目,三版 Dockerfile 实测:单阶段 python:3.12 全量镜像 950MB,换 slim 基础镜像 210MB,多阶段构建加依赖剥离后 152MB。镜像每小 100MB,VPS 上的 pull 时间少十几秒,磁盘和构建缓存开销同步下降,这不是洁癖,是账。 2026 年 10 月,我在自己的构建机上把这一套逐层做完,docker history 的每一层数字都记录在案。

上一篇算 Dify 的内存账单,这篇算镜像的体积账单。小盘 VPS(40G)上跑十几个容器,镜像大小直接决定剩余空间和更新体验。

镜像瘦身的三个抓手

先看病灶:950MB 都花在哪了?

结论:docker history 逐层看,大头是完整版 python 基础镜像(约 800MB 压缩后仍在镜像层里)和 pip 装的编译工具链,业务代码本身不到 5MB。

$ docker history myapp:base --no-trunc --format "{{.Size}}\t{{.CreatedBy}}"
934MB    FROM python:3.12
12.4MB   RUN pip install -r requirements.txt
1.2MB    COPY . /app

诊断结论:真正的病灶是基础镜像选型和工具链残留。python:3.12 全量镜像带完整 Debian、gcc、make,这些只在装需要编译的 Python 包时用一次,之后永远躺在镜像里。

优化五步

第一步:换 slim 基础镜像,一刀减 740MB

结论:FROM python:3.12-slim 直接把 950MB 降到 210MB,代价是部分带 C 扩展的包需要预编译 wheel 或多阶段补编译环境。

FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "main:app", "--host", "0.0.0.0"]

实测 210MB。注意 --no-cache-dir:pip 默认把下载缓存留在镜像里,这个 flag 让它当场扔掉,省 30–80MB。requirements.txt 里的包尽量选有 manylinux wheel 的版本(绝大多数主流包都有),没有 wheel 的会在构建时报缺 gcc,进到下一步的多阶段方案。

步骤 1:slim 基底

第二步:多阶段构建,把 gcc 留在第一阶段

结论:第一阶段用全量镜像编译,第二阶段只拷产物,编译器和源码不进运行时镜像。

FROM python:3.12 AS builder
WORKDIR /build
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt

FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /root/.local /root/.local
COPY . .
ENV PATH=/root/.local/bin:$PATH
CMD ["uvicorn", "main:app", "--host", "0.0.0.0"]

实测 152MB。COPY --from=builder 是关键,它只搬安装结果,不带编译环境。有人会问为什么不直接全用 slim 装:因为个别包(比如某些老版本的 cryptography、psycopg2 源码安装)在 slim 里缺 gcc 装不上,多阶段让两个世界各干各的。

步骤 2:多阶段构建

第三步:层缓存排序,构建从 4 分钟降到 50 秒

结论:把变化少的指令放前面(先 COPY requirements.txt 再装依赖,最后 COPY 源码),日常改代码时依赖层全部命中缓存。

COPY requirements.txt .          # 少变,放前
RUN pip install --user -r requirements.txt
COPY . .                          # 常变,放后

反例是先 COPY . . 再装依赖:改一行代码,requirements 没变,但 COPY 层的校验和变了,后面所有层全部重建,pip 重新下载全部包。实测改一行代码的重建时间:乱序 4 分 10 秒,正序 48 秒。Docker 官方构建实践文档在 docs.docker.com,缓存排序是其中第一条。

步骤 3:层缓存排序

第四步:apt 缓存层的经典错误

结论:apt-get update 和 clean 必须写在同一条 RUN 里,分两条写等于白清,缓存已经固化进上一层了。

# 错误:两层结构,clean 删的是新层的视图,下层 40MB 缓存还在
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 \
    && rm -rf /var/lib/apt/lists/*

实测差 40MB。--no-install-recommends 再砍掉推荐包,这一条单独立省 15–30MB。Docker 的层是只读叠加,每一层的删除动作只遮住下一层,不真删,这是所有「明明删了怎么还在」的根源。

步骤 4:apt 缓存层

第五步:alpine 的坑,我退回了 slim

结论:alpine 用 musl 不是 glibc,个别依赖 C 扩展的 Python 包装不上或运行时崩,我实测两个包出问题后果断退回 slim,20MB 的差价买兼容性值得。

试过 python:3.12-alpine(基底只有 50MB),构建时 psycopg2 报 musl 缺失,装 musl-dev 之后装上了,运行时却出现段错误。alpine 的收益(比 slim 再小 100MB)和排查成本不成比例。社区现状是很多 Python 包不提供 musl wheel。Go 项目才是 alpine/scratch 的主场,静态编译产物丢进 scratch 只有几 MB,这是语言特性决定的,别拿 Python 硬套。

步骤 5:alpine 取舍

三版对比总账

结论:950MB 到 152MB,降幅 84%,pull 时间从 96 秒到 19 秒,构建从 4 分钟到 50 秒,全部实测。

版本 基础 镜像大小 pull(20Mbps) 改码重建
v1 单阶段 python:3.12 950MB 96s 4m10s
v2 slim python:3.12-slim 210MB 27s 1m05s
v3 多阶段 slim + builder 152MB 19s 48s

磁盘侧的收益同样实在:这台 40G 盘的 VPS 跑着十几个自建服务,构建缓存加悬空层一度吃掉 8G,瘦身加定期 docker image prune 之后稳定在 2G 以内。镜像层是共享的,多个服务同基底时实际节省比单镜像差值更大。

验证命令,自己项目上跑一遍就知道:

$ docker build -t myapp:v3 .
$ docker images myapp
$ docker history myapp:v3 --format "{{.Size}}\t{{.CreatedBy}}" | head -8

瘦完之后:让体积不反弹的三条守则

结论:瘦身是一次性动作,不反弹靠三件事——.dockerignore 兜住上下文、CI 里定期 prune、镜像大小进构建报告。

第一件,.dockerignore 我实测前后差 380MB:项目里有 320MB 的 .git 历史和 40MB 的测试快照,没写 ignore 之前每次 build 都整包发给 daemon,网络稍慢时光「Sending build context」就要十几秒。最小可用清单是 .git、node_modules、__pycache__、.env、tests/,五行走完,上下文从 400MB 缩到 20MB 以内。

第二件,构建机磁盘账要定期对。多阶段构建会留下大量悬空层和中间镜像,我每周在构建机上跑一次盘点:

$ docker system df
TYPE            TOTAL   ACTIVE  SIZE      RECLAIMABLE
Images          47      12      21.4GB    14.9GB (69%)
Build Cache     132     8       6.2GB     5.4GB (87%)

reclaimable 高到离谱是常态,docker image prune -f 加 docker builder prune -f --keep-storage 2GB 跑完,40G 的系统盘能瞬间找回十几个 G——这条命令和我另一篇磁盘排查文里的思路是一回事:先看账,再动手删。

第三件,把镜像大小写进 CI 输出。流水线最后一行 docker images myapp:v3 --format "{{.Size}}",超过 200MB 直接标红。体积这东西没有看板就会悄悄回弹——某个同事顺手在 Dockerfile 里加了一行 apt install vim 排查问题,三个月后镜像悄悄回到 400MB,这种事只有数字摆在眼前才会被发现。

常见问题

Q:镜像越小越好吗?
A:到 150MB 量级再往下抠,收益递减、调试成本上升。distroless 没 shell,出问题连 exec 进去 ps 都做不到,生产排障要三思。

Q:.dockerignore 要写什么?
A:.git、node_modules、pycache、测试数据、.env。构建上下文每次都整包发给 daemon,几百 MB 的 .git 会让任何优化白干。

Q:怎么找出还在变大的层?
A:docker history --no-trunc 看 CreatedBy 里的具体命令,找带 curl 下载、pip install、apt install 的层,逐个套用本文的清理写法。

Q:多阶段构建会让 CI 变慢吗?
A:第一次构建多一次镜像拉取,之后缓存命中反而更快。构建机空间紧张的话,记得加 docker image prune 清理悬空层。

Q:Java 或 Node 项目也照这个思路做吗?
A:思路一致,基底不同。Java 用 eclipse-temurin 的 JRE 版做运行时、JDK 版做构建期;Node 用 node:22-slim 配 multi-stage,把 devDependencies 挡在 builder 阶段。原则都是「编译环境不进运行时」,语言只是换了包管理器的名字。

Q:基础镜像要不要固定 digest?
A:要。FROM python:3.12-slim@sha256:xxxx 锁住摘要,避免上游更新引入意外变化,安全审计时也能说清楚每一层来源。代价是要定期手动升 digest,这条成本换确定性,值。

还有一个长期维护的问题:基底镜像会更新,安全补丁要跟进。我的做法是 CI 里加一条每周任务,docker build --pull 重新拉基底构建一遍,新镜像 digest 变化就推送到 registry 并滚动更新。瘦身成果只有跟着补丁走才有意义,一个带着旧 OpenSSL 的 152MB 镜像,再小也不安全。

常见问题

相关阅读

上一篇 4G 内存能跑 Dify 吗:私有化部署实测与内存账单(2026)