镜像为什么越做越大:分层、缓存与多阶段构建
镜像为什么越做越大:分层、缓存与多阶段构建
「这个服务镜像两个多 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,真正的大头是自己拷进去的数据库备份。先看层大小,再动手,能省掉大部分无效优化。
一份可执行的清单
按收益从高到低排:
- 加
.dockerignore,别把无关文件带进构建上下文 - 同一条
RUN里「装 + 用 + 清」,不要跨层删除 - 用多阶段构建,把构建工具链留在 builder 阶段
- 依赖安装放前面、源码复制放后面,利用缓存分层
- 固定版本标签,不用
latest - 基础镜像按「是否需要 glibc」选,别只看体积
- 每次改动后用
docker history复核,别凭感觉
这七条做完,大多数服务的镜像都能压到原来的三分之一以内,而且构建时间通常会一起变快——因为缓存命中的部分变多了。
- 点赞
- 收藏
- 关注作者
评论(0)