Docker 镜像之所以能够做到快速分发和增量更新,核心原因在于它从设计之初就不是把整个文件系统当成一个整体来传输,而是拆分成一层一层的只读文件系统块。每层只记录与上一层相比发生变化的内容,拉取镜像时本地已有的层直接复用,只下载缺失的部分。理解这套机制,对于优化镜像体积、加快 CI/CD 流水线、减少镜像仓库带宽消耗都有直接的帮助。

镜像分层的本质:内容寻址与只读层堆叠
一个 Docker 镜像在内部由一个 manifest(清单)和若干 layer(层)组成。每一层对应 Dockerfile 中的一条指令(如 RUN、COPY、ADD),每个层实际上是一个 tar 包,里面记录了这一步操作新增或修改的文件。Docker 会根据每层的内容计算 SHA256 摘要作为该层的唯一 ID,这就是所谓的内容寻址(content-addressable)存储。只要内容完全一致,摘要就一致,两个摘要相同的层在字节层面完全等价。
正因为层 ID 由内容决定,Docker 在拉取镜像时可以逐层比对本地的层缓存:摘要已经存在的层直接跳过下载,只有缺失的层才需要从仓库获取。这就是增量更新的第一层含义——镜像层面按层增量下载。可以通过以下命令直观观察一个镜像的分层结构:
docker image inspect nginx:latest --format '{{json .RootFS.Layers}}'
docker history nginx:latest前者输出该镜像所有层的摘要列表,后者展示每条构建指令产生的层以及各自占用的空间。你会看到 COPY 一个小文件的层只有几 KB,而 RUN apt-get install 的层可能有上百 MB,层的大小完全取决于该指令实际改动的文件量。
联合文件系统:多层如何叠加成一个根文件系统
分层的 tar 包本身只是静态数据,要让容器真正跑起来,Docker 需要借助联合文件系统把镜像层和可写层堆叠成一个统一的目录视图。以目前 Linux 上最主流的 OverlayFS 为例,它涉及三个关键目录:lowerdir(只读的下层,对应镜像的各个层,从上往下依次叠加)、upperdir(可写层,容器运行后的所有修改都发生在这里)、merged(合并后呈现给容器进程的最终视图)。
OverlayFS 的工作规则可以概括为两条:读取文件时从上层往下找,第一个命中的生效;写入文件时如果目标只存在于 lower 层,就把文件复制到 upper 层再修改,这就是写入时复制。而删除操作则通过在 upper 层创建一个字符设备文件作为白屏标记,屏蔽 lower 层的同名文件。可以手动构造一个 OverlayFS 挂载来验证:
mkdir -p /tmp/{lower1,lower2,upper,work,merged}
echo "from lower1" > /tmp/lower1/a.txt
echo "from lower2" > /tmp/lower2/b.txt
mount -t overlay overlay \
-o lowerdir=/tmp/lower1:/tmp/lower2,upperdir=/tmp/upper,workdir=/tmp/work \
/tmp/merged
ls /tmp/merged # 同时能看到 a.txt 和 b.txt
echo "modified" > /tmp/merged/a.txt
ls /tmp/upper # a.txt 被复制到了 upper 层这段实验清楚地展示了只读层叠加与写入时复制的全过程。容器删除镜像里的某个大文件并不会减小镜像体积,只是在容器自己的可写层打了个删除标记,这一点和很多人直觉相反。可写层在容器删除后随之消失,因此需要持久化的数据必须放到 volume 或 bind mount 中,而不是依赖容器内的写入。
构建缓存与指令顺序:增量更新的另一半
增量下载解决的是分发端的问题,构建端的增量则由 Docker 的构建缓存机制负责。Dockerfile 构建时,Docker 会从第一条指令开始逐条比对缓存:如果某条指令本身以及它所基于的父层都没变,就直接复用缓存层;一旦某条指令失效,它之后的所有指令缓存全部作废,必须重新执行。这正是很多团队镜像构建忽快忽慢的根本原因。
由此可以推导出几条编写 Dockerfile 的实践原则。第一,把变化频率低的指令放在前面,变化频繁的放在后面。例如先安装系统依赖、再复制依赖清单、最后复制源代码:
FROM node:20-alpine WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci --omit=dev COPY . . RUN npm run build
只要 package-lock.json 不变,即使业务代码每天改几十次,耗时的 npm ci 层也能命中缓存,构建时间从几分钟缩短到几十秒。第二,COPY . . 之前务必配置 .dockerignore,把 node_modules、.git 等排除掉,否则这些目录的任何变动都会导致该层缓存失效。第三,多条 RUN 命令如果逻辑上总是同生共死,可以合并成一条并清理缓存,避免留下中间垃圾文件占据一个独立层:
RUN apt-get update \ && apt-get install -y --no-install-recommends curl \ && rm -rf /var/lib/apt/lists/*
进一步优化:多阶段构建与层复用的取舍
分层机制也有代价:层数过多会让 manifest 变大、挂载开销增加,而且曾经写入过又删除的文件依然残留在历史层里,无法凭空消失。对于编译型语言,多阶段构建是当前最有效的手段——在第一阶段用完整的工具链编译,第二阶段只把产物复制到精简的运行时镜像中,编译器、源码、中间产物统统不会进入最终镜像:
FROM golang:1.22 AS builder WORKDIR /src COPY . . RUN CGO_ENABLED=0 go build -o /out/app . FROM alpine:3.20 COPY --from=builder /out/app /usr/local/bin/app ENTRYPOINT ["app"]
需要注意的是,多阶段构建的 COPY --from 跨阶段复制会绕过普通 COPY 的缓存细粒度匹配,优化点应放在第一阶段内部的指令编排上。另外,如果团队希望最大化层复用率(比如所有服务共享同一个基础镜像层),就要保持基础镜像、依赖安装指令的写法完全一致,哪怕是无关紧要的空格差异也会改变层摘要,导致缓存与增量下载双双失效。
总结来看,Docker 的增量更新是一套贯穿始终的机制:内容寻址让相同的层在构建、存储、传输三个环节都能被识别和复用;联合文件系统让多个只读层在运行时按需叠加;构建缓存的匹配规则则决定了增量能否真正命中。把这三层机制吃透,再配合合理的 Dockerfile 编排和多阶段构建,镜像体积和分发耗时往往能下降一个数量级。