镜像为什么越做越大:分层、缓存与多阶段构建

举报
yd_238022924 发表于 2026/11/01 02:14:16 2026/11/01
【摘要】 从 Docker 的分层与缓存两条机制切入,解释「删除不减小体积」的常见误区,给出多阶段构建、基础镜像取舍和一套按收益排序的瘦身清单。

镜像为什么越做越大:分层、缓存与多阶段构建

「这个服务镜像两个多 G,能不能瘦一下?」几乎是每个团队都会遇到的需求。有意思的是,绝大多数体积问题并不是因为装了什么奇怪的东西,而是对 Docker 的两条基本机制理解不到位:分层和构建缓存。

一、删除不会让镜像变小

镜像由一层层只读层叠加而成。每条 RUN、COPY、ADD 都产生一层。关键在于:在后面的层里删掉前面的层里写过的文件,只是在上层记一个「已删除」的标记,下层的数据依然在镜像里。

所以这种写法毫无意义:

RUN apt-get update && apt-get install -y build-essential
RUN rm -rf /var/lib/apt/lists/* /tmp/*

第二条命令只让最终挂载出来的文件系统里看不到那些文件,镜像体积一点没少。正确做法是在同一条 RUN 里装完就清:

RUN apt-get update \
 && apt-get install -y --no-install-recommends build-essential \
 && apt-get purge -y --auto-remove build-essential \
 && rm -rf /var/lib/apt/lists/*

顺带一提,--no-install-recommends 通常能省掉几十到几百兆的推荐包;构建工具类依赖装完就 purge,比留在镜像里强得多。

二、COPY 进来的东西最容易被忽略

「怎么镜像里有一份 .git?」这类问题非常常见。COPY . . 会把构建上下文里的一切都塞进去——包括 .git、node_modules、本地日志、测试数据、IDE 配置。

解决办法是 .dockerignore。它和 .gitignore 语法相近,但完全独立,别指望 Docker 会读 .gitignore。一份靠谱的 .dockerignore 至少应该包含:

.git
node_modules
dist
*.log
.env*
.DS_Store

以 Node 项目为例,只把 package.json 和锁文件拷进去、装完依赖再拷源码,收益往往比换基础镜像还大:

COPY package*.json ./
RUN npm ci --omit=dev
COPY . .

三、缓存顺序决定了构建快慢

Docker 的构建缓存是按层逐条比对的:只要某一层失效,它之后的所有层都要重建。所以把变化频繁的内容放在后面,是提速的关键。

上面那段写法就是标准做法:锁文件很少变,先复制它并安装依赖;源码天天变,放在最后复制。反过来写——先 COPY . . 再 RUN npm ci——每次改一行代码就要重装一遍依赖。

同理,系统包安装、依赖拉取这类耗时的步骤要尽量靠前,且版本要固定。用 latest 标签会让缓存判断完全失效,还会让不同时间的构建产物不一致。

四、多阶段构建才是真正的瘦身手段

如果构建阶段需要编译器、开发头文件、测试框架,而运行阶段只需要产物,那就应该分成两个阶段:

FROM node:20 AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:20-slim
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
CMD ["node", "dist/main.js"]

最终镜像只包含 runtime 阶段的内容,构建工具链全部留在中间层,既不进产物,也不会被推送。对于 Go、Rust 这类静态编译语言,收益更夸张:builder 阶段可以是几百兆的基础镜像,最终产物就是一个几十兆的镜像。

五、基础镜像怎么选

alpine 体积小(几十兆),但用的是 musl libc 而不是 glibc。遇到预编译二进制、某些 Python 包、或者依赖 glibc 特性的库时会踩坑,表现为莫名其妙的段错误或找不到符号。

slim / distroless 折中一些,保留 glibc,去掉编译器和文档。生产环境用 distroless 还有个额外好处:没有 shell,攻击面小。

别为了小而无脑换。为了省两百兆换来三天排查 musl 兼容问题,不划算。判断标准是:产物里到底需不需要 glibc。

六、先测量,再优化

优化前先看清楚体积到底在哪。docker history <image> 能按层列出大小,一眼就能找到那个异常大的层。更细的分析可以用 dive,它能把每一层新增和删除的文件都列出来,也能算出「浪费空间」——也就是被上层删掉、但依然占体积的那些文件。

一个常见误区是凭感觉换基础镜像,结果发现基础镜像只占 60MB,真正的大头是自己拷进去的数据库备份。先看层大小,再动手,能省掉大部分无效优化。

一份可执行的清单

按收益从高到低排:

  1. 加 .dockerignore,别把无关文件带进构建上下文
  2. 同一条 RUN 里「装 + 用 + 清」,不要跨层删除
  3. 用多阶段构建,把构建工具链留在 builder 阶段
  4. 依赖安装放前面、源码复制放后面,利用缓存分层
  5. 固定版本标签,不用 latest
  6. 基础镜像按「是否需要 glibc」选,别只看体积
  7. 每次改动后用 docker history 复核,别凭感觉

这七条做完,大多数服务的镜像都能压到原来的三分之一以内,而且构建时间通常会一起变快——因为缓存命中的部分变多了。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。