从1.2GB到120MB:Docker镜像体积优化实战
2026/9/24 22:27:18 网站建设 项目流程

上个月帮一家创业公司做容器化改造,看到他们生产环境的后端服务镜像 1.2GB,当时我就愣住了。正常情况下一个基于 Ubuntu 的服务镜像,清理干净后也就 100 多 MB,这中间差了整整一个数量级。等我把 Dockerfile 打开,原因一眼就看明白了——apt-get updateapt-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 updateapt-get install分开写时,实际上会留下两类"垃圾":

第一类是软件包索引文件,位于/var/lib/apt/lists/目录下。执行一次 update 后,这个目录下会缓存所有软件源的索引信息,可能包含几十 MB 的数据。第二类是apt下载的安装包缓存,位于/var/cache/apt/archives/。每次 install,apt都会先把.deb文件下载到这个目录,安装完成后不会自动删除。

如果执行了apt-get install build-essential,它背后的依赖链非常庞大,build-essential自己加上gccg++makelibc6-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 --from=builder /app /app COPY app.py . CMD ["python3", "app.py"]

多阶段构建的核心思路是:阶段一里装好所有编译工具和依赖,把需要的东西复制到阶段二;阶段二只保留运行时必要的内容。这样最终镜像里不会出现gccbuild-essential这类动辄几百 MB 的开发工具。对于中小项目来说,这是成本最低、收益最高的体积优化手段之一。

另外,如果你的业务对 glibc 没有强依赖,可以把基础镜像从ubuntu:20.04换成python:3.9-slimalpine。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 historydive双重检查,肉眼确认没有异常的大文件残留。很多问题只靠看当前目录大小是发现不了的,因为镜像层里的历史垃圾并不会体现在运行中的容器里。

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-recommends
yumyum install缓存 rpm 包执行后接yum clean all
apkapk add默认写缓存直接加--no-cache
pippip install缓存下载的 wheel 包--no-cache-dir,或者设置环境变量PIP_NO_CACHE_DIR=1
npmnpm 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_modulestargetdist__pycache__等目录,这些目录跟着上下文一起被塞进镜像层。解决办法是写.dockerignore文件,把编译产物、日志、版本控制目录全部排除在外。

第二,检查有没有多阶段构建中漏加--from=builder。有些新手会把第一阶段的整个目录直接复制过去,结果把编译工具缓存也一起带过去了。要只复制真正需要的产物文件。

第三,用dive看每一层到底增加了什么文件。只靠猜是低效的,把文件树展开,找出体积最大的目录,通常一眼就能看出是哪个环节出了问题。我遇到过最夸张的一次,是项目里一个无用的model.pth文件(300MB)被 COPY 进了镜像,而所有人都在盯着 apt 缓存排查。

5.3 别用 latest 标签,小心基础镜像静默变大

还有一个和镜像体积相关的隐藏问题,就是FROM ubuntu:latest这种写法。latest标签是漂浮的,它会跟随官方镜像更新而改变。今天你基于它构建出一个 120MB 的镜像,半年后你再重新构建,基础镜像可能已经变了,体积变成 200MB,而且没人知道为什么。

我的习惯是固定到具体版本号,比如FROM ubuntu:20.04FROM 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 'NR>1 && $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 updateapt-get install分开写,我基本就能猜到镜像体积大概率会有问题。把"包管理命令必须清理缓存"写进团队规范,远比每次靠某个人排查问题更可靠。希望这篇文章能帮你把那些藏在镜像层里的"垃圾"一次清理干净。

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

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

立即咨询