
上个月帮一家创业公司做容器化改造看到他们生产环境的后端服务镜像 1.2GB当时我就愣住了。正常情况下一个基于 Ubuntu 的服务镜像清理干净后也就 100 多 MB这中间差了整整一个数量级。等我把 Dockerfile 打开原因一眼就看明白了——apt-get update和apt-get install被拆成了好几行独立的 RUN后面也没有接任何清理动作。就是这个在无数中小项目里反复出现的写法让镜像体积膨胀了 10 倍CI/CD 流水线也跟着一起遭殃。不夸张地说我接触过的中小型团队里十个项目有九个存在类似的 Dockerfile 写法。很多人写镜像构建脚本的时候脑子里只有能跑这个目标装完包不收拾现场打包出来的镜像自然又大又脏。但这不只是占点磁盘空间的小事它直接拖慢构建、推送、部署的整个链路还增加存储和带宽成本。这篇文章我就用这个 1.2GB 到 120MB 的真实案例把根因、解法、验证过程和常见坑一次讲清楚希望能帮你少走弯路。1. 镜像膨胀不是小事从构建到部署的连锁反应1.1 一个看似正常的 Dockerfile藏着什么问题先说这台创业公司项目的原始 Dockerfile。它大概是这样的FROM ubuntu:20.04 RUN apt-get update RUN apt-get install -y python3 python3-pip nginx RUN apt-get install -y build-essential RUN pip install flask gunicorn每一行看起来都挺合理先更新索引再装 python 和 nginx接着装编译工具最后用 pip 装应用依赖。但如果你了解 Docker 镜像的分层机制就会知道问题有多严重每一行 RUN 指令都会生成一个新的镜像层这个层里不仅包含你想装的东西还包含这条命令产生的所有副作用文件。apt-get update会把软件包索引下载到/var/lib/apt/lists/apt-get install会把下载的.deb安装包缓存在/var/cache/apt/archives/。这些文件全部被原封不动地打进了镜像层里。于是最终镜像的体积从三层叠加基础镜像本身、apt 索引文件、deb 安装包缓存。如果中间再混入build-essential这类几百 MB 的编译工具包体积很容易就飙到 1GB 以上。这个问题最坑的地方在于它不像代码报错那样会立刻崩溃而是悄悄潜伏在每一次构建里直到某天你发现推送镜像要等好几分钟、生产节点拉个镜像像在下载电影才会意识到不对劲。1.2 体积膨胀如何拖垮整套 CI/CD 流程镜像变大影响的绝对不是磁盘多占一点这么简单。我后来给这家公司做了个简单的时间统计数据非常直观。环节优化前1.2GB 镜像优化后120MB 镜像变化构建完成后推送镜像到私有仓库约 30~50 秒约 3~5 秒缩短约 90%生产节点拉取镜像并启动容器约 20~40 秒取决于内网带宽约 2~5 秒缩短约 85%每次 CI 触发后的全量流程约 12 分钟约 5 分钟总耗时缩短 60% 左右关键在于解决的不只是推送和拉取这两个环节它同时释放了构建机的磁盘压力、网络带宽、私有仓库的存储成本以及多个节点同时拉取镜像时占用的 IO。尤其是很多中小公司只有一台自建的 GitLab Runner机器配置也就 4 核 8G 左右一个 1.2GB 的镜像连续构建几次磁盘就直接告警。优化镜像体积是性价比极高的一次改造。2. 根因拆解为什么未合并会膨胀到 10 倍2.1 镜像分层的机制与看不见的文件要彻底理解这个问题先得搞清楚 Docker 镜像的分层机制。你可以把每个镜像层想象成一张快照底片底层是ubuntu:20.04往上每执行一条 RUN 指令Docker 就在当前状态下记录一次文件系统的变更生成一个全新的层。多个层叠加起来才是你最终看到的文件系统。这里有个很容易被忽略的细节每个层都是只读的只记录增量文件而不是记录完整目录。也就是说/var/lib/apt/lists/这个目录在第二层可能只有 30 个文件、20MB 大小但到了第四层时它依然存在于镜像中没有被覆盖也没有被删除。除非你在某条指令里显式地把它删掉否则它永远都在镜像里。打个比方你每次 RUN 就像在厨房做一顿饭。apt-get update是去超市拿了一大叠菜品目录回来apt-get install是买了几大袋食材。做完这顿饭你既没扔菜单也没清食材包装而是整层打包封存。下一顿饭再做厨房只会越来越满。清理缓存这件事本质上就是在每一层结束前把超市菜单和包装袋全部扔出去。2.2 拆开看apt update 和 install 到底在层里留下了什么我用apt-get update和apt-get install分开写时实际上会留下两类垃圾第一类是软件包索引文件位于/var/lib/apt/lists/目录下。执行一次 update 后这个目录下会缓存所有软件源的索引信息可能包含几十 MB 的数据。第二类是apt下载的安装包缓存位于/var/cache/apt/archives/。每次 installapt都会先把.deb文件下载到这个目录安装完成后不会自动删除。如果执行了apt-get install build-essential它背后的依赖链非常庞大build-essential自己加上gcc、g、make、libc6-dev等依赖包累积的.deb缓存动辄 300~500MB。再加上 Python 开发头文件、各种库的编译依赖体积翻倍毫不奇怪。而且这些缓存文件对运行时完全没有用进了容器以后你根本不会再去读那些.deb包。这里有个容易出错的细节很多人以为用了apt-get clean就万事大吉。apt-get clean确实能清理/var/cache/apt/archives下的 deb 包缓存但它不会清理/var/lib/apt/lists下的索引文件。真正完整的清理动作是rm -rf /var/lib/apt/lists/*这条命令很多新手在优化镜像时漏了这一步。2.3 为什么照文档写也会掉进这个坑网上大量教程、甚至官方文档的部分示例写的都是先 update 再 install这种两行甚至多行的 Dockerfile。单独执行时没问题但组合到镜像构建里就会留下缓存。很多应届生、半路转行的运维从第一眼接触 Docker 看到的就是这种写法默认它就是对的。没人告诉他们因为镜像层的特性每一条 RUN 都是独立的你在当前层留下的文件会永久保存在这个层里除非你在同一条 RUN 里清理。这个问题在中小公司尤其普遍因为往往没人专门 review Dockerfile写的人不关心体积用的人也不关注构建细节。直到某天流水线慢到无法忍受才有人发现问题。所以我一直觉得镜像体积的优化应该前置到写 Dockerfile 的那一刻而不是等出问题再排查。前端写代码要规范后端写接口要规范构建脚本同样需要规范。3. 解法落地合并 RUN 指令并清理 apt 缓存3.1 最小改动方案从 10 倍膨胀回到 120MB先说最直接、改动成本最低的解法合并 RUN 指令并在同一条指令里完成清理。核心原则就一句话凡是会产生缓存文件的包管理命令都必须和清理动作放在同一条 RUN 里用串联保证它们在同一层完成。改造后的 Dockerfile 是这样的FROM ubuntu:20.04 RUN apt-get update apt-get install -y --no-install-recommends \ python3 \ python3-pip \ python3-venv \ nginx \ rm -rf /var/lib/apt/lists/*逐行解释一下关键点apt-get update apt-get install必须用串联保证 update 和 install 在同一个镜像层中执行。如果分开写update 生成的索引文件会被单独保存在一层里后续安装时即使清掉索引上一层中仍然保留着这份数据。--no-install-recommends告诉 apt 不要安装推荐的附加包。这个参数能显著减少不必要的依赖在 Ubuntu 上经常能省下几十 MB 甚至上百 MB 的无效文件。rm -rf /var/lib/apt/lists/*这是清理动作的关键。apt-get clean只能清掉/var/cache/apt/archives下的 deb 包缓存但apt-get update产生的/var/lib/apt/lists索引文件还要手动删除。两条清理习惯都要保留。这样改完镜像体积几乎是断崖式下降原本 1.2GB 的镜像删掉索引和缓存后只剩 120MB 左右。这里的 120MB 还包括基础镜像占的 60 多 MB 和实际安装的软件体积基本就是该有的东西了。3.2 进阶方案多阶段构建和更小的基础镜像如果你觉得光合并 RUN 还不够还可以再往前推一步用多阶段构建。比如你的一堆编译工具只是为了编译某个 C 扩展运行时根本用不到那就别把编译产物和编译工具放在同一个最终镜像里。# 第一阶段编译 FROM ubuntu:20.04 AS builder RUN apt-get update apt-get install -y --no-install-recommends \ build-essential gcc python3-dev \ rm -rf /var/lib/apt/lists/* WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt # 第二阶段运行时 FROM ubuntu:20.04 RUN apt-get update apt-get install -y --no-install-recommends \ python3 python3-venv \ rm -rf /var/lib/apt/lists/* WORKDIR /app COPY --frombuilder /app /app COPY app.py . CMD [python3, app.py]多阶段构建的核心思路是阶段一里装好所有编译工具和依赖把需要的东西复制到阶段二阶段二只保留运行时必要的内容。这样最终镜像里不会出现gcc、build-essential这类动辄几百 MB 的开发工具。对于中小项目来说这是成本最低、收益最高的体积优化手段之一。另外如果你的业务对 glibc 没有强依赖可以把基础镜像从ubuntu:20.04换成python:3.9-slim或alpine。Alpine 的包管理工具apk本身就支持--no-cache参数一条命令搞定FROM alpine:3.18 RUN apk add --no-cache nginx--no-cache的意思是安装时不写本地缓存镜像里自然就不会留下多余的包管理器缓存文件。Alpine 基础镜像本身只有几 MB加上 nginx 也就 20 多 MB体积优势非常明显。不过要注意Alpine 使用的 musl libc 与 Ubuntu 的 glibc 有差异某些预编译二进制比如部分国产 SDK、机器学习库可能跑不起来换之前最好先做一轮兼容性验证。3.3 关于缓存命中率的一点权衡说了这么多有读者可能会问把原来三行 RUN 合并成一行以后如果我只想改其中一个软件包版本是不是整层缓存都会失效导致每次构建都要重新安装所有东西这个担心有一定道理。Docker 构建缓存是按层置信的只要某层的上下文发生变化这一层和它之后的所有层都会重新执行。合并后如果你改了apt-get install后面的包列表整条 RUN 会从头执行包括 update 和之前的软件安装。但在实际情况中基础依赖的变更频率非常低通常只有加新包或升级版本时才动一次。相比之下镜像体积膨胀带来的每一分钟推送、拉取开销是每次 CI/CD 都在承受的。我还见过一些团队为了缓存故意把包管理命令拆开结果每次构建都要多花几十秒去处理那些缓存文件因小失大。我的建议是体积优先缓存其次。大多数中小项目的构建频率一天十几二十次而依赖变更一个月可能才一两次合并 RUN 的收益远远大于损失。如果确实有多个频繁变动的软件包要装可以单独拉一条 RUN 来装它们让稳定不变的依赖保持在更底层的镜像层中但记住每条 RUN 都要带清理动作。4. 效果验证与 CI/CD 链路优化4.1 用 docker history 和 dive 验证清理结果改完 Dockerfile 以后不能只看构建成功了就算完必须验证每个镜像层的体积是否合理。最基础的方法是用docker history查看镜像每一层的大小docker history myapp:optimized如果输出里哪一层突然出现几百 MB 的apt-get install说明那层里藏着大体积缓存如果每条 RUN 后面的体积都只有几十 KB 到几 MB说明清理是到位的。想看得更细推荐用dive这个工具。它会以交互界面的方式显示镜像每一层新增、修改和删除的文件还能直接给出每个文件的大小。用dive myapp:optimized打开镜像按 Tab 切到文件树视图重点看/var/lib/apt/lists/和/var/cache/apt/archives/这两个目录是否为空。如果在最终镜像里还能看到这两个目录下有文件那就说明清理动作没生效得回头检查是不是rm指令没接到同一条 RUN 里。我始终有个习惯镜像优化完必须跑一次docker history和dive双重检查肉眼确认没有异常的大文件残留。很多问题只靠看当前目录大小是发现不了的因为镜像层里的历史垃圾并不会体现在运行中的容器里。4.2 流水线总耗时和带宽的真实变化回到这家创业公司的案例。优化之前他们的 CI/CD 流程大概是这样的流程代码提交后GitLab Runner 触发构建构建完成后把 1.2GB 镜像推送到内网镜像仓库然后测试环境服务器从仓库拉取镜像并启动容器。整个构建流程跑下来大概 12 分钟左右。优化后镜像变成 120MB构建机的磁盘占用明显下降推送时间和拉取时间都大幅缩短。整体流水线耗时从 12 分钟降到了 5 分钟左右算下来提速约 60%。这个提速还不包括因为磁盘空间不够导致的任务排队、构建中断等间接问题。如果他们用的是公网镜像仓库或者云厂商的容器服务网络带宽和存储费用的节省会更加夸张。对我来说最直观的感受是优化前的流水线光推拉镜像的时间加起来超过 1 分钟像是背负着一个大包裹跑马拉松优化后推拉加起来不到 10 秒整个链路轻盈了很多。这也是我为什么一直强调镜像体积不只是一个存储问题它是整个 CI/CD 体系的隐形瓶颈。4.3 顺手把其他包管理器的缓存也一起治理了apt 只是最典型的一个但其他语言生态的包管理器在 Docker 里同样会制造缓存垃圾。我建议在写 Dockerfile 时一视同仁地处理以下几类命令包管理器典型命令问题正确写法aptapt-get install缓存 deb 包和索引文件加rm -rf /var/lib/apt/lists/*配合--no-install-recommendsyumyum install缓存 rpm 包执行后接yum clean allapkapk add默认写缓存直接加--no-cachepippip install缓存下载的 wheel 包加--no-cache-dir或者设置环境变量PIP_NO_CACHE_DIR1npmnpm install缓存 npm 包到 node_modules/.cache安装后删除缓存目录或使用--no-audit --no-fund等参数注意一点pip install --no-cache-dir很常用但很多人会在requirements.txt里通过 pip.conf 配置索引源这种情况下命令行参数依然是有效的。如果项目里有多个 Python 依赖要装记得把--no-cache-dir写进pip install命令里而不是依赖全局配置。治理完这些缓存后你往往还会发现一个惊喜不只体积变小了构建速度也变快了因为包管理器不再需要花大量时间处理缓存元数据。特别是在内网环境中磁盘 IO 也很宝贵。5. 常见问题与排查技巧实录5.1 进了容器还能正常 apt install 吗有同事问过我你清掉了/var/lib/apt/lists容器启动以后还能用apt-get install直接装软件吗答案是可以但需要先在容器里手动执行一次apt-get update重新拉取索引。因为/var/lib/apt/lists里的是索引缓存不是软件本身删掉它只会让 apt 暂时失忆重新 update 就能恢复。不过我更推荐的做法是不要在运行中的容器里临时装软件。容器应该是不可变的应用实例任何依赖都应该在构建期固化进镜像。如果真的需要临时排查问题可以用apt-get update apt-get install -y vim curl rm -rf /var/lib/apt/lists/*一条命令装完就走。离开容器后重新创建一个干净镜像跑业务别留着那个变了样子的容器当祖传环境。5.2 合并了 RUN 镜像还是很大问题出在哪这是排查时最常遇到的挫败场景。Dockerfile 已经按规范写了rm -rf /var/lib/apt/lists/*也加了镜像却依然有 600MB 或 1GB。这时候我的排查路径是固定的第一检查有没有用COPY . .把不必要的文件拷进镜像。很多项目的工作目录里躺着node_modules、target、dist、__pycache__等目录这些目录跟着上下文一起被塞进镜像层。解决办法是写.dockerignore文件把编译产物、日志、版本控制目录全部排除在外。第二检查有没有多阶段构建中漏加--frombuilder。有些新手会把第一阶段的整个目录直接复制过去结果把编译工具缓存也一起带过去了。要只复制真正需要的产物文件。第三用dive看每一层到底增加了什么文件。只靠猜是低效的把文件树展开找出体积最大的目录通常一眼就能看出是哪个环节出了问题。我遇到过最夸张的一次是项目里一个无用的model.pth文件300MB被 COPY 进了镜像而所有人都在盯着 apt 缓存排查。5.3 别用 latest 标签小心基础镜像静默变大还有一个和镜像体积相关的隐藏问题就是FROM ubuntu:latest这种写法。latest标签是漂浮的它会跟随官方镜像更新而改变。今天你基于它构建出一个 120MB 的镜像半年后你再重新构建基础镜像可能已经变了体积变成 200MB而且没人知道为什么。我的习惯是固定到具体版本号比如FROM ubuntu:20.04或FROM python:3.9.18-slim。除非你有特殊理由否则不要在生产环境使用latest。固定版本也方便回滚当某个基础镜像版本出现 CVE 或兼容性问题时你能精确知道当前使用的到底是哪一个版本而不是笼统地说是最新版。另外补充一个安全层面的视角镜像体积越小潜在的攻击面通常也越小。一个干净镜像里只有运行所需的最小文件集合即使被攻破能利用的工具和组件也相对有限。优化体积不仅是性能问题也是容器安全的基本功之一。5.4 把体积检查写进 CI防止问题回潮优化完成后很重要的一件事是防止镜像体积回潮。团队后续加依赖、改 Dockerfile很可能又产生新的缓存垃圾。我建议把镜像体积的检查直接集成到 CI 流水线中比如用docker history解析每一层大小如果某一层超过阈值就报警或者用dive的 CI 模式配合--ci参数让超大镜像直接构建失败。在 GitLab Runner 的 Job 里可以这样简单实现docker build -t myapp:check . docker history myapp:check --no-trunc --format {{.Size}} | awk NR1 $1 ~ /[0-9]/ { sum $1 } END { if (sum 500000000) { print 镜像体积超限; exit 1 } }这个脚本会把每一层的大小加起来判断是否超过 500MB单位是字节不同 docker 版本格式可能不同需要按实际情况调整超过就返回非零退出码让流水线失败。虽然用 shell 解析docker history的方式有点粗暴但对于中小团队来说已经能解决 80% 的镜像悄悄变大问题。如果想要更精细的分析可以把dive --ci接入流水线它支持通过配置文件强制执行镜像体积的阈值。写在最后站在我自己的角度镜像体积优化这件事看起来只是 Dockerfile 里几行命令的调整背后却反映了构建脚本的规范化程度。很多团队愿意花大把时间优化业务代码的性能却对一把抓的 Dockerfile 视而不见直到 CI/CD 慢到影响发布频率才想着补救。而实际上一个整洁的 Dockerfile 能带来的回报是持续性的每次构建、每次部署都在享受收益。最后再分享一个小习惯我现在每接手一个新项目第一件事就是打开 Dockerfile 扫一眼看包管理命令是否合并、清理动作是否到位。如果发现apt-get update和apt-get install分开写我基本就能猜到镜像体积大概率会有问题。把包管理命令必须清理缓存写进团队规范远比每次靠某个人排查问题更可靠。希望这篇文章能帮你把那些藏在镜像层里的垃圾一次清理干净。