资讯详情

Docker镜像瘦身实战:从1.3G到728M的多阶段构建优化

📅 2026/10/19 11:08:07 | 华诺云谱 👁 阅读
Docker镜像瘦身实战:从1.3G到728M的多阶段构建优化
先说结论我用多阶段构建把那个 1.3G 的 Docker 镜像压到了 728M省了差不多一半但这场仗我真不敢说打赢了——因为最初的目标是压到 300M 以内。折腾了整整一天之后我反而想明白了一件更关键的事镜像体积优化光靠“构建技巧”是有极限的你得先搞清楚那些体积到底花在了哪里。这个镜像是一个 Python 数据处理服务跑在容器里做定时批处理任务依赖了一堆科学计算相关的库。最初打包时完全没想过体积问题build 完之后看了一眼 1.3G当场就有点绷不住。部署倒是能跑但每次推镜像、拉镜像都要等半天内网带宽再差一点简直折磨。于是我开始了一次不完美的瘦身实践过程记录在这里希望对同样被镜像体积困扰的朋友有点参考价值。1. 从 1.3G 说起这个镜像是怎么变成大胖子的1.1 最初的 Dockerfile 长什么样先看看最初的 Dockerfile可以说非常“朴素”FROM python:3.9-slim WORKDIR /app COPY requirements.txt . RUN apt-get update apt-get install -y build-essential RUN pip install -r requirements.txt COPY . . CMD [python, app.py]用这个构建出来的镜像就是 1.3G。表面上看起来每行都没什么问题但你把它们叠在一起问题就大了。当时我的依赖里主要有numpy、pandas、scikit-learn这些都是重量级选手。最要命的是我一开始在python:3.9-slim上直接pip install结果发现有几个包没有对应的预编译 wheelpip 当场报错说要编译于是我就补了一行apt-get install build-essential。这一行装进去的东西包括 gcc、g、make、各种库头文件加起来一个多 G 的下载和安装量全留在镜像里了。也就是说这个 1.3G 里有很大一部分是为了“编译”而存在的工具链而工具链在服务运行的时候根本用不到。1.2 镜像为什么这么大分层机制是罪魁祸首很多人不理解一个问题我明明在 Dockerfile 里把编译工具装了又卸了为什么镜像还是那么大这就要说到 Docker 镜像的分层机制。简单打个比方Docker 的每一层就像一个透明的贴纸每执行一条RUN、COPY、ADD指令就往这个贴纸堆上叠一层。最终你的镜像由所有这些贴纸的叠加成果组成。问题在于如果你在第一张贴纸上写满了内容第二张贴纸把内容擦掉了但第一张贴纸的原始内容还是完整存在——因为贴纸的设计是不可变的数据一旦写进去就不会因为你后面的操作而消失。对应到 DockerfileRUN apt-get install -y build-essential # 这一层的体积已经实打实落进去了 RUN apt-get remove -y build-essential # 这一层即使删除了前面的层还在删除动作只会在这个新层里增加一个“删除标记”对最终容器来说文件可能看不到了但对镜像整体体积来说被覆盖的那一层数据依然占用空间。这就是为什么“在 Dockerfile 里先装后卸”这种操作几乎不省任何体积。同理pip install下载的 wheel 包缓存、生成的__pycache__、apt 的索引文件都会成为镜像里的垃圾。但真正的大头不是这些缓存而是编译工具链和依赖包本体。还有一个小插曲我的项目目录里本来就有一个本地 venv 虚拟环境COPY . .直接把它也复制进镜像了。本地 venv 里那几百 MB 的东西虽然不是最终运行的必需项但因为它被复制进来了就永远留在这一层里。后来我用.dockerignore把它挡在门外才意识到这个坑有多蠢。2. 多阶段构建思路和第一个版本2.1 多阶段构建的原理多阶段构建是 Dockerfile 里从 17.05 开始支持的特性核心思路很简单同一个 Dockerfile 里可以写多个FROM每个FROM都是一个独立的构建阶段而最终的镜像只以最后一个阶段为准。你可以把第一个阶段当成“生产车间”把最后一个阶段当成“成品包装台”。车间的工具、原料、半成品都会留在车间里不会进入最后的包装盒。只有你明确用COPY --from某个阶段拷出来的东西才会进入最终镜像。这正好解决了我面临的困境编译工具链需要但只需要在构建阶段存在依赖包本体需要那就在构建阶段装好然后只把装好的依赖目录拷到最终阶段不要带着那套 gcc 一起走。2.2 第一个多阶段 Dockerfile我很快就写出了第一个多阶段版本FROM python:3.9-slim AS builder WORKDIR /build COPY requirements.txt . RUN apt-get update apt-get install -y --no-install-recommends build-essential \ python -m venv /opt/venv \ /opt/venv/bin/pip install --no-cache-dir -r requirements.txt FROM python:3.9-slim AS runtime WORKDIR /app COPY --frombuilder /opt/venv /opt/venv ENV PATH/opt/venv/bin:$PATH COPY app.py . CMD [python, app.py]这里的关键点有几个我逐个说下当时的思路。构建阶段里我把编译工具链和依赖安装都放在同一个RUN指令里并且用连接。这样做的意义是最后只是在这一层里“结束”不会额外多出几层没意义的中间状态。但更核心的原因是我根本没打算让构建阶段进入最终镜像所以这一层再怎么大也无所谓。--no-install-recommends是为了避免 apt 拉进来一堆用不到的推荐包。pip install --no-cache-dir是避免 pip 把下载的 wheel 缓存留到镜像层里。这些是当时就知道的基础操作。我创建了 venv并且把它固定安装在/opt/venv目录。后面COPY --frombuilder /opt/venv /opt/venv把这整个虚拟环境原封不动搬到运行阶段。为什么要用 venv因为如果直接在构建阶段用系统 Python 装依赖那系统 Python 的 site-packages 里会混入一堆包文件用 venv 的话所有第三方依赖都在一个独立目录里拷走它等于拷走了完整的依赖树干净且可控。第一次 build 完成我看了一眼大小842M。比 1.3G 是降了不少但我本来预期能压到 300M 左右顿时觉得这个结果有点“尴尬”——省了三分之一但离理想还差一大截。2.3 结果从 1.3G 降到了 842M为什么还是很大我进容器里跑了一遍du结论很快出来了。真正占空间的并不是系统库和缓存而是site-packages里的那些库本体。docker run --rm -it 镜像名 sh -c du -sh /opt/venv/lib/python3.9/site-packages/* | sort -rh | head -20结果一眼望去scikit-learn、pandas、numpy这些加起来就 400 多 M。这是它们的 wheel 包解压后的真实体积包括.so动态链接库文件、Python 源码、数据文件。这部分属于“运行必需的重量”不是靠优化技巧能抹掉的。所以 842M 的构成大概是基础镜像本身约 230Mvenv 依赖约 400M运行时需要的几个系统库约 150M其余零碎 60M。这个结构里最让我意外的是基础镜像占了 230M——这让我开始琢磨要不要换 alpine。3. 继续压身换 alpine 的尝试与失败3.1 为什么想换 alpinealpine 的基础镜像只有几 MB 到几十 MB这和python:3.9-slim动辄几百 MB 比起来极具诱惑力。很多教程都在吹 alpine 是“瘦身利器”所以我想当然地觉得如果基础镜像能从 230M 缩到 50M那整体体积至少还能再省 180M 左右听起来很划算。但这里藏着一个我一开始没重视的差异alpine 基于 musl libc而绝大多数 Linux 镜像包括 python 官方镜像的 slim 版本基于 glibc。Python 的很多科学计算扩展包是用 C/C 写的它们编译的时候通常链接的是 glibc。在网络上发布的预编译 wheel 包大多数是manylinux标准兼容 glibc而 musl 体系要的是musllinux标准的 wheel。3.2 alpine 带来的连环坑我很快就体验到了 alpine 的“亲切”。pip install一开始就报错说找不到匹配的 wheel然后自动切换成“从源码构建”。接下来系统开始编译pandas、scikit-learn那个过程我只能说当时我盯着屏幕怀疑人生——CPU 消耗极高、编译进度条爬了几十分钟中途还因为编译器内存不足被打断过一次。就算编译完还会遇到新的问题。有些二进制模块在 musl 环境下运行时会崩或者行为异常。我试着把gcc、musl-dev、g都装到构建阶段然后发现 alpine 加编译工具链这一套下来构建阶段的体积也没比 slim 版本省太多而且构建耗时翻了好几倍。更重要的是alpine 的“小”是建立在牺牲兼容性基础上的。如果项目纯用 Python 标准库或者只用纯 Python 的包那 alpine 很香一旦涉及科学计算栈它就是噩梦。我在踩了几个坑之后果断放弃了 alpine回到 slim 路线上继续想办法。这个经历让我意识到一件事基础镜像的选择不是越小越好而是要综合考虑依赖包的生态兼容性和构建时间。为了省 100M 而花两个小时调兼容性对于大多数业务来说都不划算。4. 用 dive 和 docker history 复盘体积到底卡在哪4.1 先说结论先诊断再优化在继续下一步优化之前我做了一件应该更早做的事把镜像的每一层拆开看搞清楚体积到底分布在哪里。这一步非常重要因为如果不先诊断后面所有的优化动作都像蒙着眼睛修车。常用的命令是docker history可以看每一层的大小docker history 镜像名 --no-trunc输出里会列出每一层对应的指令Dockerfile 里的原始命令和该层在镜像里增加的体积大小。配合--no-trunc可以看到完整指令内容而不是被截断的摘要。我当时的输出大致是这个感觉IMAGE CREATED CREATED BY SIZE abc123def456 1 minute ago CMD [python, app.py] 0B ... RUN apt-get update ... 38MB RUN python -m venv /opt/venv ... 536MB COPY requirements.txt . 22kB这里的 536MB 那一层基本就是创建虚拟环境 安装依赖这一整条操作留下的。看到这层数字我进一步确认了依赖包本体才是真正的大头。4.2 用 dive 做更细的体检如果只好奇每层加了多少体积docker history就够了。但如果想知道“这一层里具体装了哪些大文件”我用的是另一个工具——dive。它能把镜像的文件系统按层展开列出每一层新增的文件并且直接显示文件大小排行。dive 打开镜像之后我能直观看到哪些文件占了大头。比如site-packages/scikit-learn/lib/libsvm-skl.so这种几十 MB 的动态库pandas里的_lib目录下各种扩展模块.so文件numpy 的core动态库和include目录看到这些之后我心里基本有数了这些是库的功能本体删不掉。真正能在不破坏功能的前提下抠掉的主要是这几类Python 编译缓存__pycache__和.pyc文件pip 残留的缓存目录即使用了--no-cache-dir有时候也会在环境中留下 build 临时文件项目目录里被复制进去的不必要文件比如测试数据、原始文档、日志4.3 体积瓶颈的最终结论这一轮复盘之后我的结论其实很清晰这个 1.3G 的镜像真正常态的体积消耗就是依赖 基础系统 少量系统库。多阶段构建已经帮我剔除了编译工具链这一坨“一次性体积”剩下的是“刚需体积”。所谓“刚需体积”就是这些库和数据一旦缺少服务就跑不起来或达不到功能效果。这部分不是靠构建魔法能随便抹掉的。认清这个现实之后我不再死磕“压到 300M”而是改为接受“压到 700M 左右已经算合理”的目标。这一步心态变化我认为比任何一条技术命令都重要。5. 最终采用的 Dockerfile把能抠的细节都抠一遍5.1 完整版 Dockerfile 与关键配置经过前面的尝试和复盘我最终回退到 slim 路线并在此基础上把能做的细节优化全部做了。这是最终采用的 DockerfileFROM python:3.9-slim AS builder WORKDIR /build ENV PIP_NO_CACHE_DIR1 \ PIP_DISABLE_PIP_VERSION_CHECK1 \ PYTHONDONTWRITEBYTECODE1 COPY requirements.txt . RUN apt-get update \ apt-get install -y --no-install-recommends build-essential \ python -m venv --copies /opt/venv \ /opt/venv/bin/pip install -r requirements.txt FROM python:3.9-slim AS runtime WORKDIR /app RUN apt-get update \ apt-get install -y --no-install-recommends libgomp1 \ rm -rf /var/lib/apt/lists/* COPY --frombuilder /opt/venv /opt/venv COPY app.py . ENV PATH/opt/venv/bin:$PATH \ PYTHONDONTWRITEBYTECODE1 CMD [python, app.py]同时项目根目录里加了.dockerignore.venv/ __pycache__/ *.pyc .git/ logs/ data/这个版本最终体积是 728M。逐个说一下这几个改动的作用和背后的原因。python -m venv --copies这个参数很关键。默认情况下venv 里的bin/python是一个指向系统 Python 的软链接而不是真实的二进制文件。如果你把 venv 从一个镜像目录拷到另一个镜像软链接指向的路径一旦不存在或者版本不一致就会出很多莫名其妙的问题。--copies让 venv 里的所有可执行文件都以复制副本的形式存在彻底避免软链接失效的坑。PYTHONDONTWRITEBYTECODE1这个环境变量禁止 Python 在导入模块时生成__pycache__目录和.pyc字节码文件。看似不起眼但几百个依赖包加在一起能省下几十 MB 的垃圾文件。PIP_NO_CACHE_DIR1和PIP_DISABLE_PIP_VERSION_CHECK1前者和--no-cache-dir作用一样后者省掉 pip 启动时的版本检查请求构建时少点网络等待不直接减少镜像体积但在构建体验上有帮助。apt-get install libgomp1这是我在第一次运行新镜像时踩到的坑——程序一启动就报缺少libgomp.so.1。原因是 scikit-learn 和 numpy 内部使用的 OpenBLAS 在运行时需要这个动态库。这个库很小但没它就起不来。这次我吸取了教训先跑起来再根据实际报错补库而不是凭感觉一次装一堆没用的东西。rm -rf /var/lib/apt/lists/*这是在同一个RUN指令里完成的清理动作。之所以必须和apt-get install写在同一条RUN里是因为如果分成两行前一层留下的索引文件依然会占据最终镜像的空间这些都是只有诊断时才需要的东西运行时完全用不到。.dockerignore它控制了构建上下文的大小同时避免本地 venv、日志、缓存等目录被打进镜像。这个文件虽然不直接影响 Dockerfile 分层的最终体积但能防止“大箱子混入”的低级错误。5.2 优化效果对比整个折腾过程的体积变化大致如下方案镜像体积说明最初版本单个阶段 build-essential1.3G编译工具链 项目本地 venv 被带进去第一次多阶段构建842M剔除了编译工具链多阶段 清理 venv 副本化 .dockerignore728M压住了缓存和多余文件试图换 alpine尝试失败编译成本高、兼容性差最终回退从 1.3G 到 728M相当于减了接近 45%对部署体验来说已经是实打实的改善。但回到最初的 300M 目标确实“不算成功”。5.3 还能再试的方向在这轮优化之后如果再往下压就不能靠 Dockerfile 层面的一般技巧了我做了一点调研和思考使用 distroless 基础镜像比如gcr.io/distroless/python3这个镜像不包含 shell、包管理器整体更小理论上比 slim 还能再省几十 MB。但调试困难容器里没有 bash、没有 ls遇到问题只能靠日志对开发阶段不友好。精简依赖我的项目里有些包其实只用到了其中一小部分功能。如果能把scikit-learn替换成纯 Python 实现的逻辑、或者只 import 需要的模块可以砍掉相当大一块。这是“功能性瘦身”而不是“构建技巧瘦身”效果可能比任何方案都大但需要改动代码。把重量级计算拆到独立服务如果业务允许把涉及 pandas、scikit-learn 的部分单独部署成一个服务其他轻量任务走轻量镜像从全局来看体积会更合理。这已经属于架构层面了。按需裁剪 numpy/scipy 的模块numpy 里有很多模块与当前业务无关但因为是打包好的二进制发行版没办法轻易剥离。如果非要极致压缩可以考虑自己从源码构建裁剪版本工程量和维护成本都不是一般的大。这些方向我并没有全部实践因为对一个在跑的存量项目来说性价比和稳定性才是优先考虑的事。但如果你是从零开始设计一个 Docket 化服务从一开始就按“蒸馏产物”的思路选择依赖和基础镜像你的起点会比我高得多。6. 常见问题与排查技巧实录6.1 venv 拷进新镜像后python 命令失效现象COPY --frombuilder /opt/venv /opt/venv之后运行阶段里执行python报找不到可执行文件或者/usr/bin/python被调用却根本不是 venv 里的 Python。原因默认python -m venv /opt/venv创建的是符号链接环境/opt/venv/bin/python本身是一个指向系统 Python 解释器的软链接。这个软链接在构建阶段指向/usr/local/bin/python但把 venv 原样移到 runtime 之后如果 runtime 镜像里对应路径的 Python 版本不同或者根本没有这个文件就会出问题。解决办法创建 venv 时加--copies参数让所有可执行文件都变成实打实的副本然后拷贝。这是最稳的做法。另外一条思路是 runtime 阶段直接用系统的 Python PYTHONPATH指向 venv 的 site-packages但这样会把系统 Python 环境也挂进来不推荐。6.2 运行时报缺少 libgomp.so.1现象镜像构建成功启动容器时候直接报ImportError: libgomp.so.1: cannot open shared object file。原因numpy/pandas 底层依赖 OpenBLASOpenBLAS 运行时需要libgompGNU 的 OpenMP 运行时库。python 官方 slim 镜像默认没有装这个库。这类“动态链接库缺失”问题在多阶段构建里尤其容易踩到因为构建阶段的环境和运行阶段的环境不一样。排查方法别急着猜直接在容器里跑命令看缺什么docker run --rm -it 镜像名 sh -c ldd /opt/venv/lib/python3.9/site-packages/numpy/core/*.so 2/dev/null | grep not foundldd的输出会直接告诉你缺哪个.so。然后apt-get install libgomp1解决。注意装完要把apt-get list的索引文件清理掉也就是说清理命令和安装命令必须写在同一个RUN里。6.3 明明用了多阶段构建镜像还是很大现象文件确实是从 builder 阶段拷贝出来的但最终镜像体积比预期大很多。可能的原因你没有把运行阶段需要但不必要的东西挡在 final 层之外。比如在 runtime 阶段又重新pip install了某些包。在 runtime 阶段执行了apt-get install但没在同一层里清理apt lists或者装了一堆--no-install-recommends也挡不住的推荐包。.dockerignore没配置构建上下文中的大文件被COPY . .带进来了。拷贝时复制了超出必要范围的内容比如整个/build目录而不是只拷贝/opt/venv。排查用docker history和 dive 逐层看体积增量。如果某一层明显比预期大就看看那层指令是什么针对性改。大多数时候都是因为“运行阶段”里又偷偷装了一堆东西。6.4 基础镜像版本不统一导致的兼容性问题现象builder 阶段用python:3.9-slimruntime 阶段也用的python:3.9-slim但系统库版本不一致偶尔也会有差异。比如某次因为 Ubuntu 发行版差异runtime 里缺了某个系统头文件相关的运行库。解决思路尽量让 builder 和 runtime 阶段使用相同基础镜像和版本标签避免两边系统环境不一致。多阶段构建的原则应该是“构建和运行环境分离但系统基础对齐”而不是“构建环境一个系统运行环境另一个系统”。7. 写在最后的个人体会这次优化折腾下来我最深的体会是镜像体积优化的第一步不是打开编辑器改 Dockerfile而是先搞清楚镜像里到底装了什么、为什么要装、哪些是真正转瞬即逝的构建垃圾哪些是运行时会一直陪着你的重量级依赖。很多人被各种“优化技巧清单”带着跑结果垃圾没清掉反而把必要的依赖拆出了问题。我也认识到“不算成功”往往才是技术优化里的常态。你能从 1.3G 降到 728M这已经是能感知到的提升但从 728M 到 300M可能意味着换技术栈、拆服务、或者牺牲某些功能的便利性。这些代价和收益是否匹配只有你对自己的业务场景有足够清楚的认识才能做出合适的取舍。最后分享一个这次用得最顺手的排查技巧如果你想快速看一个容器里到底哪些目录占了空间不用装任何工具一条命令就能搞定docker run --rm -it 镜像名 sh -c du -sh /*/*/* 2/dev/null | sort -rh | head -20它能在一秒钟之内让你看到镜像内部最大的几个目录比任何花哨的工具都来得直观。下次再有人跟你说某个优化技巧能把镜像“大幅瘦身”你就先用这条命令看一下你的体积到底花在哪再决定要不要听他的。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑