6月20日 11:24

Docker 镜像分层是什么?如何优化构建体积和缓存?

Docker 镜像分层到底是什么?

Docker 镜像不是一个单独的大文件,而是一组只读层叠加出来的文件系统。Dockerfile 里的多数指令,例如 FROMRUNCOPYADD,都会生成新的镜像层。容器启动时,Docker 会在这些只读层上再加一层可写层,应用运行时产生的文件修改就落在这一层里。

这些层通常基于内容寻址保存。简单说,Docker 会根据层内容计算摘要,内容一样的层可以被复用、共享和缓存。两个镜像如果都基于同一个 node:20-alpine,底层基础镜像层通常只需要在机器上保存一份。

在 Linux 上,Docker 常见的存储驱动是 overlay2。它会把多个只读层通过 OverlayFS 叠在一起,对容器表现成一个完整目录。当上层修改下层已有文件时,并不是直接改原文件,而是把文件复制到可写层再修改,这就是常说的 copy-on-write。

分层带来了哪些好处?

分层最直接的价值是构建缓存。Docker 构建镜像时会从上到下执行 Dockerfile,如果某一层的指令和上下文没有变化,就可以直接复用缓存,不必重新执行。

它也能减少存储和传输成本。相同的基础层可以被多个镜像共享,拉取镜像时已经存在的层不会重复下载。镜像仓库推送和拉取时,也可以按层并行处理,所以一个设计合理的镜像通常构建更快、传输更省。

但分层也有副作用:每一层都会记录文件系统变化。你在一层里创建了大文件,下一层再删除它,最终镜像里仍可能保留前一层的大文件内容。很多“明明删了缓存,镜像还是很大”的问题,都和这个机制有关。

构建缓存为什么会失效?

Dockerfile 的缓存是顺序命中的。某一层缓存失效后,它后面的层通常也要重新构建。

最常见的坑是过早执行大范围 COPY

dockerfile
COPY . . RUN npm install

只要项目里任意文件变化,COPY . . 这一层就会变,后面的依赖安装也会重新执行。前端或 Node.js 项目更推荐先复制依赖清单,再安装依赖,最后复制源码:

dockerfile
COPY package.json package-lock.json ./ RUN npm ci COPY . .

这样只改业务代码时,依赖安装层仍能命中缓存。Python、Go、Java 项目也有类似思路:先复制依赖描述文件,再下载依赖,最后复制源码。

如何减少镜像体积?

选择合适的基础镜像

基础镜像决定了镜像体积的起点。能用运行时镜像就不要用完整构建环境,能用官方 slim 版本就不要默认拉 full 版本。

dockerfile
FROM node:20-slim

alpine 很小,但不是所有场景都适合。它使用 musl libc,部分依赖 glibc 的原生库可能需要额外处理,排查成本反而更高。对 Node.js、Python 原生扩展较多的项目,slim 有时比 alpine 更稳。

使用多阶段构建

多阶段构建适合把“编译环境”和“运行环境”分开。第一阶段安装编译工具并产出构建结果,第二阶段只复制运行所需文件。

dockerfile
FROM node:20-slim AS build WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci COPY . . RUN npm run build FROM nginx:alpine COPY --from=build /app/dist /usr/share/nginx/html

这样最终镜像里不会带上 node_modules、源码、构建工具和临时缓存,只保留真正运行需要的产物。

清理必须发生在同一层

如果安装包和清理缓存拆成两条 RUN,上一层里的缓存仍然会留在镜像历史中:

dockerfile
RUN apt-get update && apt-get install -y curl RUN rm -rf /var/lib/apt/lists/*

更好的写法是放在同一个 RUN 里:

dockerfile
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*

包管理器缓存、临时文件、编译中间产物,都应该遵守这个原则:创建和删除放在同一层。

合并 RUN 指令,但别过度合并

合并 RUN 可以减少层数,也能避免临时文件残留。但不要为了少一层把完全无关的命令揉成一大坨,否则可读性和缓存命中都会变差。

比较好的做法是按变化频率拆分:系统依赖一层,应用依赖一层,业务代码一层。这样既便于缓存,也便于排查问题。

.dockerignore 为什么很重要?

.dockerignore 用来排除不应该进入构建上下文的文件。没有它时,Docker 可能会把 .git、日志、测试产物、本地依赖、临时文件一起发送给 Docker daemon。

常见配置如下:

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

它不只是减少镜像体积,还会影响缓存。构建上下文越干净,COPY . . 越不容易因为无关文件变化而失效。

能不能用 squash 压成一层?

镜像 squashing 可以把多层压成更少的层,看起来能减少历史包袱。但它不是默认首选。

原因有两个:第一,压扁后层复用能力会变差,多个镜像之间不容易共享中间层;第二,它可能掩盖 Dockerfile 本身的问题,比如缓存清理位置不对、构建产物没有隔离。多数情况下,优化 Dockerfile 比依赖 squash 更可靠。

怎么检查镜像哪里变大了?

先用 docker history 看每一层的大小和对应指令:

bash
docker history your-image:tag

它能快速定位是哪条 Dockerfile 指令引入了大体积文件。需要更细的分析时,可以用 dive 查看每一层新增、修改、删除了哪些文件:

bash
dive your-image:tag

如果发现某一层新增了大量缓存,下一层又删除,说明清理时机不对;如果发现源码、测试文件、.git 目录被打进镜像,通常是 .dockerignore 没写好。

一个更合理的 Dockerfile 思路

以 Node.js 应用为例,比较稳的结构通常是这样:

dockerfile
FROM node:20-slim AS deps WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci FROM node:20-slim AS build WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . RUN npm run build FROM node:20-slim AS runtime WORKDIR /app ENV NODE_ENV=production COPY package.json package-lock.json ./ RUN npm ci --omit=dev && npm cache clean --force COPY --from=build /app/dist ./dist CMD ["node", "dist/index.js"]

这个写法的重点不是模板本身,而是顺序:依赖文件先复制,依赖安装尽量缓存;源码后复制,避免频繁改代码导致依赖层失效;最终阶段只保留运行时需要的内容。

小结

Docker 镜像优化的关键不是单纯减少层数,而是理解每一层留下了什么、哪些层能复用、哪些改动会让缓存失效。实际项目里优先做好几件事:基础镜像选小但别盲目选,依赖文件先复制,清理和安装放同一层,多阶段构建隔离编译产物,用 .dockerignore 控制构建上下文,再用 docker historydive 找出真正的大层。

标签:Docker