☰
多阶段构建不算成功?Docker镜像从1.3G压到280M的完整复盘
2026/10/20 14:49:50 网站建设 项目流程

打包完成的那一刻,我盯着docker images的输出愣了好几秒。一个内部数据接口服务,Python 写的,依赖也就二三十个,最后镜像体积整整 1.3G。当时的反应是:算了,能跑就行。直到团队里负责部署的同学抱怨每次拉镜像都要等几分钟,磁盘越吃越满,我才决定认真做一次镜像瘦身。搜了一圈方案,人人都说多阶段构建,我也照着写了,效果确实有,从 1.3G 降到了四百多兆,但离心理预期还是差很远,所以我给这个标题加了四个字:不算成功。这篇文章不打算只讲成功的套路,我想把这个"半失败"的完整过程拆开,说清楚为什么多阶段构建没能把镜像压到理想状态,以及后来我又做了哪些补救。

1. 打包完成那一刻,我盯着 1.3G 沉默了

先说背景。这个服务是一个典型的业务型 API,基于 FastAPI,主要做数据聚合和简单计算,依赖里有几个比较重的 Python 库,比如 SciPy、pandas 这类带 C 扩展的包。一开始的 Dockerfile 写得很草率,属于"写完能跑就再也不看"的状态。

FROM python:3.11 WORKDIR /app COPY requirements.txt . RUN pip install -r requirements.txt COPY . . CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]

当时为什么没在意体积?说白了就是没有触发条件。镜像大,本地开发不受影响,单元测试也在宿主机上跑,只有推送私有镜像仓库和部署时才暴露问题。后来 CI 里加了镜像推送步骤,每次构建完上传要花几分钟;生产环境扩容时,新节点拉镜像慢得让人心焦。这才意识到,镜像体积不是审美问题,是实打实的运维成本。

我先记录了一下原始状态,方便后面对比。

指标初始值
镜像体积1.3G
构建产物上传耗时3 分钟以上
新节点拉取镜像耗时2 分多钟
主要依赖类型FastAPI、pandas、SciPy 等

拿到这组数据,我做的第一件事并不是改 Dockerfile,而是先想明白一个问题:这 1.3G 到底是哪来的?如果不清楚体积分布,后面的优化都是盲改。

2. 多阶段构建初上手:先搞清楚它到底解决什么问题

在动手之前,我重新确认了一下多阶段构建的核心逻辑。它解决的其实是"构建环境"和"运行环境"分离的问题:编译源码、安装依赖需要一整套工具链,但运行服务只需要最终产物。把这两种环境塞进同一个镜像,体积自然就失控了。

2.1 镜像是怎么被撑大的

Dockerfile 里每条RUN、COPY指令都会生成一个层,层是叠加的,删除文件并不会让镜像变小,它只是标记为已删除,底层数据还在。这就像装修队住进你家,完工后不搬走,你发现房子变小了,再把他们的行李扔出去也没用,因为行李占过的空间已经被记在了"历史账本"里。

具体到这个项目,FROM python:3.11用的是完整版 Debian 基础镜像,里面自带 gcc、make、各种头文件,本身就非常重。紧接着RUN pip install -r requirements.txt会把 pip 下载的源码包、编译产生的临时文件、安装缓存全部留在镜像里。最后COPY . .更是直接把整个项目目录塞进去,包括.git、测试数据、本地虚拟环境、临时日志,一样都没落下。三次操作叠加,1.3G 就这么来了。

2.2 标准的多阶段拆法

多阶段构建的思路很直接:第一个阶段负责编译和打包依赖,第二个阶段只把编译好的产物复制过来。依赖的编译工具链、源码缓存、临时文件全部留在第一阶段,最终镜像只保留能跑起来的最小集合。

我当时的做法是在第一个阶段用pip wheel把依赖打成 wheel 包,第二个阶段只安装这些预编译好的包,不再重复编译。

# 构建阶段:负责安装编译依赖、生成 wheel 包 FROM python:3.11 AS builder WORKDIR /build COPY requirements.txt . RUN apt-get update && apt-get install -y --no-install-recommends gcc build-essential \ && pip wheel --no-cache-dir -w /wheels -r requirements.txt # 运行阶段:只安装 wheel 包,不保留编译器 FROM python:3.11 WORKDIR /app COPY --from=builder /wheels /wheels RUN pip install --no-cache-dir /wheels/*.whl && rm -rf /wheels COPY app/ /app/app/ CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]

构建完成后看体积,从 1.3G 降到了 480M 左右。这个结果让我有点尴尬:降幅很明显,说明多阶段构建确实有效;但绝对体积依然很大,离"瘦身成功"的标准还差着十万八千里。

问题出在哪?这时候我才意识到,我对镜像是"盲"的。我根本不知道这剩下的 480M 里装的是什么东西。

3. 体积到底去哪儿了:把镜像拆开看

优化不能靠猜。要想知道剩余体积的构成,得一层一层看镜像,再把运行中的容器目录扫一遍。

3.1 先查每一层的体积

docker history是最快的切入点。它能展示镜像每一层的大小和对应的 Dockerfile 指令。

docker history --no-trunc --format "table {{.ID}}\t{{.Size}}\t{{.CreatedBy}}" my-service:latest

输出里能看到几个体积特别突兀的层。运行阶段执行pip install安装那些 wheel 包时,每一层都有几十到上百兆的增量。这说明什么?说明这 480M 里,依赖本身占了绝对大头,基础镜像反而是小头。我猜对了多阶段构建能省下的部分,却低估了运行时依赖的体量。

3.2 进容器里查实际占用

为了确认到底谁在吃空间,我把镜像跑起来,直接进容器统计目录大小。

docker run -d --name size-check my-service:latest docker exec size-check du -sh /usr/local/lib/python3.11/site-packages/*

结果基本验证了猜测,几个大包把空间吃得干干净净:

目录 / 包体积说明
pandas 及依赖约 120M带 C 扩展,功能齐全
SciPy 相关约 180M数值计算库,体积本来就大
FastAPI 及 Web 相关约 60M纯 Python 包为主
其他依赖约 80M零零散散
基础镜像 + Python 运行时约 40M反而成了小头

看完这个表,我基本死心了:多阶段构建再怎么做,这些 C 扩展库的 wheel 包也必须装进运行镜像,否则服务根本启动不了。多阶段构建解决的是"别把编译工具带进镜像"的问题,它没法解决"你的业务依赖本身就这么大"的问题。

3.3 还发现了一个被忽视的问题:构建上下文

排查过程中我还发现,最初的COPY . .把构建上下文里所有文件都复制进去了。当时没写.dockerignore,.git目录、测试用例、本地训练数据、venv 目录全部被docker build通过上下文发送给了守护进程。也就是说,我在单阶段镜像里看到的体积,有一部分根本不是程序运行需要的,纯粹是"手滑带进去的垃圾"。

后来我补上了.dockerignore,把.git、__pycache__、*.pyc、.venv、测试数据全部排除。这一步单独做,也能砍掉几十上百兆。很多文章讲镜像瘦身只提多阶段构建,很少提.dockerignore,实际项目中它带来的收益一点都不小。

4. 瘦不下来的真正原因:多阶段构建的边界在哪里

做了多阶段构建之后镜像还有 480M,这件事让我重新审视了多阶段构建的定位。它不是一个万能开关,它有明确的能力边界。

4.1 多阶段处理的是"构建依赖",不是"运行依赖"

如果依赖都是纯 Python 包,多阶段构建的效果会非常明显,镜像可以压到一两百兆以内。但这个项目里有 pandas、SciPy 这种带 C 扩展的重型依赖,它们需要运行时的动态链接库,也需要完整的 Python 运行时环境,这部分体积无论如何都省不掉。

用一句话概括:多阶段构建只能帮你扔掉"造东西的工具",扔不掉"东西本身"。如果你的业务依赖体积就有三百兆,那镜像体积的下限就是三百兆,再多阶段构建也无济于事。

4.2 基础镜像的选择同样关键

我最初用的是python:3.11完整版,它基于完整的 Debian 系统,带了大量运行服务不需要的系统工具和库。换成python:3.11-slim之后,基础镜像和系统层的体积立刻降了一截。这一步其实应该在多阶段构建之前就做,因为基础镜像选错了,后面的优化都是事倍功半。

我也试过python:3.11-alpine。Alpine 用 musl 替代 glibc,体积小到了极致,但部分依赖没有提供适用于 musl 的预编译 wheel,pip 只能现场编译,反而把构建时间拉长了。更麻烦的是,编译出来的包在 Alpine 上能跑,不代表在 glibc 环境下也能跑,这给后续部署埋了雷。折腾了一圈,还是换回了 slim 版本。这个取舍后面细说。

4.3 层缓存的假象

另一个容易踩的坑是层缓存。很多 Dockerfile 把COPY requirements.txt .和RUN pip install分成两条指令,就是为了利用缓存:只要 requirements.txt 没变,pip 安装层就不会重新执行。这个思路本身没错,但有个隐含问题,它只对"源代码变动频繁、依赖变动少"的场景有效。

我们当时为了图省事,把pip install和rm -rf /var/lib/apt/lists/*写在了同一条 RUN 里,结果 apt 的清理虽然做了,但每次运行都因为代码变动触发重建,缓存基本没起到作用。后来我重新整理了指令顺序,把依赖安装和源码复制彻底分开,构建速度才恢复正常。

4.4 为什么我坚持用"不算成功"来描述这次尝试

因为多阶段构建本身没有骗人,它确实把镜像从 1.3G 压到了 480M,这个成果是实打实的。我说它"不算成功",是因为我一开始对它有超出合理范围的期待,以为它是镜像瘦身的终点。事实证明它只是起点,真正要把体积压到理想范围,还需要组合更多的策略。一个"半成功"的结果,往往比"完全成功"更能让人认清问题的本质。

5. 后续的组合拳:从 480M 继续往下压

在确认多阶段构建不是终点之后,我把优化目标定在了 250M 以内。这个目标不算激进,但需要同时处理基础镜像、依赖缓存、层数量、文件系统布局几件事。

5.1 换掉过重的基础镜像

第一步是把python:3.11换成python:3.11-slim。这一步在运行阶段和构建阶段同时生效,基础系统层至少少了几十兆。注意,构建阶段依然需要 gcc 和 build-essential,所以我在 builder 阶段显式安装它们,再配合--no-install-recommends避免装进一堆用不上的推荐包。

FROM python:3.11-slim AS builder RUN apt-get update && apt-get install -y --no-install-recommends build-essential gcc

5.2 让 pip 不留下任何缓存和临时文件

很多镜像体积爆炸,pip 缓存是重要来源。pip install默认会把下载的包缓存在~/.cache/pip下,每次构建都残留一批。加上--no-cache-dir能从源头掐掉缓存。同样,pip wheel生成临时构建目录后,/wheels用完了就删,别留在运行阶段。

RUN pip install --no-cache-dir /wheels/*.whl && rm -rf /wheels

这个rm -rf /wheels表面上看是删文件,但它的意义不在"省那一会儿空间",而在于不让运行镜像里白白多一层。Docker 里删除文件只会在该层额外记录一条删除标记,底层的数据可能还在,但对于最终暴露给运行时的文件系统来说,它确实是干净的。

5.3 用 Alpine 踩了一次实实在在的坑

我在这个阶段做过一次尝试:把运行阶段换成 Alpine,想着能再砍一刀。结果 SciPy 在 Alpine 上没有现成的 musl 版 wheel,pip 直接从源码编译,构建时间从几分钟暴涨到二十多分钟,最后还是编译失败。原因也不复杂:Alpine 的 musl libc 和主流 Linux 发行版用的 glibc 在二进制兼容性上有差异,很多带 C 扩展的包,官方不提供 musl 的预编译产物。

从那以后,我对 Alpine 的态度变成了"能用但必须先验证":如果项目依赖全是纯 Python 或用纯静态链接的 C 库,Alpine 可能是最优解;如果依赖里有一堆科学计算库、图像处理库,老老实实用 slim 更稳妥。镜像小是目标,但可维护性和可复现性更值钱。

5.4 清理 apt 残留和只需要运行时的系统库

运行阶段如果还需要某些系统库,比如 SciPy 在运行时依赖的 libgfortran、libopenblas 之类,可以用apt-get install装,但一定要在同一层里清理/var/lib/apt/lists/*。这个操作必须和安装放同一条 RUN,否则清理本身会成为一个新层,之前的列表文件还是会被保留到镜像里。

FROM python:3.11-slim AS runtime RUN apt-get update && apt-get install -y --no-install-recommends \ libgfortran5 libopenblas0 \ && rm -rf /var/lib/apt/lists/*

5.5 最终结果

这一套组合拳打完,镜像体积从 480M 压到了 280M 左右。虽然没有实现最初"压到 150M 以内"的激进目标,但拉取和部署的速度已经明显改观。我把这个过程重新理了一遍,发现 480M 到 280M 的收益,主要来自基础镜像替换和依赖安装方式的调整,多阶段构建的贡献其实早在 1.3G 降 480M 就已经兑现了。真正有效的优化,从来不是某一个技巧单独发力,而是把不合理的地方一个个找出来改掉。

阶段镜像体积主要操作
初始1.3G无优化
多阶段构建480M拆分构建/运行阶段,引入 wheel 安装方式
替换基础镜像 + 清理缓存360Mpython:3.11-slim、pip --no-cache-dir、apt 清理
运行时依赖精简280M只安装必要系统库,删掉多余包

6. 回头复盘:我会给后来者说的几句大实话

这次"不算成功"的镜像瘦身,给我留下的经验比一次顺利的优化多得多。多阶段构建在几乎所有场景下都值得做,因为它能让镜像结构更干净、更安全,但它不是体积问题的终极答案。真正靠谱的流程应该是先量化再优化:用docker history看层,进容器用du扫目录,搞清楚体积痛点在哪,再针对性处理。盲目的基础镜像替换、无脑上 Alpine,大概率会像我在 musl 上栽的跟头一样,费力不讨好。

有几件事我现在一定会提前做:第一,写.dockerignore,从源头控制构建上下文;第二,基础镜像优先选 slim 系列,只有在验证过全部依赖兼容性之后,才考虑更激进的选择;第三,把依赖安装和源码复制分开,这一步对构建速度的影响长期被低估;第四,在 CI 里对镜像体积做检查,超过阈值直接告警,防止体积悄悄长回去。体积膨胀的速度永远比瘦身的速度快,不加约束,过几个月它会自己回到 1G 以上。

最后说点个人体会。做技术优化的心态很重要,一上来就追求"完美结果"反而容易让人走偏。多阶段构建让镜像从 1.3G 降到 480M,这一半的成功已经解决了部署慢的燃眉之急;剩下的一半,是给下一次优化留的功课。镜像瘦身本质上是做减法,减掉每一层多余的东西之前,先搞明白哪些能减、哪些不能减,这个判断力比任何技巧都值钱。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询