Docker 镜像分层是什么?如何优化构建体积和缓存?
Docker 镜像分层到底是什么?
Docker 镜像不是一个单独的大文件,而是一组只读层叠加出来的文件系统。Dockerfile 里的多数指令,例如 FROM、RUN、COPY、ADD,都会生成新的镜像层。容器启动时,Docker 会在这些只读层上再加一层可写层,应用运行时产生的文件修改就落在这一层里。
这些层通常基于内容寻址保存。简单说,Docker 会根据层内容计算摘要,内容一样的层可以被复用、共享和缓存。两个镜像如果都基于同一个 node:20-alpine,底层基础镜像层通常只需要在机器上保存一份。
在 Linux 上,Docker 常见的存储驱动是 overlay2。它会把多个只读层通过 OverlayFS 叠在一起,对容器表现成一个完整目录。当上层修改下层已有文件时,并不是直接改原文件,而是把文件复制到可写层再修改,这就是常说的 copy-on-write。
分层带来了哪些好处?
分层最直接的价值是构建缓存。Docker 构建镜像时会从上到下执行 Dockerfile,如果某一层的指令和上下文没有变化,就可以直接复用缓存,不必重新执行。
它也能减少存储和传输成本。相同的基础层可以被多个镜像共享,拉取镜像时已经存在的层不会重复下载。镜像仓库推送和拉取时,也可以按层并行处理,所以一个设计合理的镜像通常构建更快、传输更省。
但分层也有副作用:每一层都会记录文件系统变化。你在一层里创建了大文件,下一层再删除它,最终镜像里仍可能保留前一层的大文件内容。很多“明明删了缓存,镜像还是很大”的问题,都和这个机制有关。
构建缓存为什么会失效?
Dockerfile 的缓存是顺序命中的。某一层缓存失效后,它后面的层通常也要重新构建。
最常见的坑是过早执行大范围 COPY:
dockerfileCOPY . . RUN npm install
只要项目里任意文件变化,COPY . . 这一层就会变,后面的依赖安装也会重新执行。前端或 Node.js 项目更推荐先复制依赖清单,再安装依赖,最后复制源码:
dockerfileCOPY package.json package-lock.json ./ RUN npm ci COPY . .
这样只改业务代码时,依赖安装层仍能命中缓存。Python、Go、Java 项目也有类似思路:先复制依赖描述文件,再下载依赖,最后复制源码。
如何减少镜像体积?
选择合适的基础镜像
基础镜像决定了镜像体积的起点。能用运行时镜像就不要用完整构建环境,能用官方 slim 版本就不要默认拉 full 版本。
dockerfileFROM node:20-slim
alpine 很小,但不是所有场景都适合。它使用 musl libc,部分依赖 glibc 的原生库可能需要额外处理,排查成本反而更高。对 Node.js、Python 原生扩展较多的项目,slim 有时比 alpine 更稳。
使用多阶段构建
多阶段构建适合把“编译环境”和“运行环境”分开。第一阶段安装编译工具并产出构建结果,第二阶段只复制运行所需文件。
dockerfileFROM 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 /app/dist /usr/share/nginx/html
这样最终镜像里不会带上 node_modules、源码、构建工具和临时缓存,只保留真正运行需要的产物。
清理必须发生在同一层
如果安装包和清理缓存拆成两条 RUN,上一层里的缓存仍然会留在镜像历史中:
dockerfileRUN apt-get update && apt-get install -y curl RUN rm -rf /var/lib/apt/lists/*
更好的写法是放在同一个 RUN 里:
dockerfileRUN 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 看每一层的大小和对应指令:
bashdocker history your-image:tag
它能快速定位是哪条 Dockerfile 指令引入了大体积文件。需要更细的分析时,可以用 dive 查看每一层新增、修改、删除了哪些文件:
bashdive your-image:tag
如果发现某一层新增了大量缓存,下一层又删除,说明清理时机不对;如果发现源码、测试文件、.git 目录被打进镜像,通常是 .dockerignore 没写好。
一个更合理的 Dockerfile 思路
以 Node.js 应用为例,比较稳的结构通常是这样:
dockerfileFROM 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 /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 /app/dist ./dist CMD ["node", "dist/index.js"]
这个写法的重点不是模板本身,而是顺序:依赖文件先复制,依赖安装尽量缓存;源码后复制,避免频繁改代码导致依赖层失效;最终阶段只保留运行时需要的内容。
小结
Docker 镜像优化的关键不是单纯减少层数,而是理解每一层留下了什么、哪些层能复用、哪些改动会让缓存失效。实际项目里优先做好几件事:基础镜像选小但别盲目选,依赖文件先复制,清理和安装放同一层,多阶段构建隔离编译产物,用 .dockerignore 控制构建上下文,再用 docker history 或 dive 找出真正的大层。