Docker BuildKit、DAG、Snapshot 与 OverlayFS:从构建原理到缓存机制的深度理解

2 阅读10分钟

📖 前言

如果你写过 Dockerfile,通常会习惯这样理解构建:

FROM 一下,RUN 一下,COPY 一下,最后得到一个镜像。🐳

但当你开始遇到这些问题时,背后的机制就必须真正弄清楚了:

  • ❓ 为什么 BuildKit 构建更快?

  • ❓ 为什么改一行 Dockerfile,会导致后面一串缓存失效?

  • ❓ 为什么 overlayfs 说的是"叠层",但 BuildKit 又像在跑 DAG?

  • ❓ 为什么 squash 能让镜像变小,却可能让后续构建变慢?

  • upperdirsnapshotlayermerged 这些词到底是什么关系?

这篇文章把这些概念串起来,尽量用概念分层 + 图示 + 工程视角讲透。🧠


🗺️ 一张总图先建立直觉

Dockerfile
   ↓
Frontend(解析 Dockerfile)
   ↓
LLB(Low-Level Build)
   ↓
BuildKit Solver(调度 / 缓存 / 并行)
   ↓
Snapshotter(比如 overlayfs)
   ↓
文件系统视图(merged)
   ↓
导出为 OCI image layer

你可以先把它记成一句话:

Dockerfile 是输入,LLB 是中间表示,BuildKit 是调度器,snapshotter 是文件系统实现,layer 是最终打包结果。 🎯


1️⃣ 先把三个最容易混淆的概念分开

1.1 Layer:数据块 / 增量包 📦

layer 可以理解为"文件变化集合",也就是某一步构建相对前一步的差异。

例如:

RUN echo "hello" > /a.txt

这一层对应的变化可能只有:

+ /a.txt

它更像一个 diff 包,通常会被打成 tar,再压缩,作为镜像层存储与分发。


1.2 Snapshot:某一时刻的文件系统状态 📸

snapshot 不是"文件副本",而是某个时刻看到的完整文件系统视图。

你可以把它理解成:

S0 = base filesystem
S1 = S0 + 变化1
S2 = S1 + 变化2

Snapshot 关心的是"现在这个文件系统长什么样",而不是"这些文件是怎么来的"。


1.3 OverlayFS:拼接这些状态的底层机制 🧩

overlayfs 是 Linux 内核提供的一种联合文件系统机制,负责把多个只读层和一个可写层拼成一个可见目录。

它解决的是:

"如何在不复制整个文件系统的前提下,看到一个统一视图?" 🔍


2️⃣ Layer、Snapshot、OverlayFS 的关系

先看最简化的关系:

layer  = 数据(变化)📦
snapshot = 视图(状态)📸
overlayfs = 实现视图的方式 🧩

再看更工程化的版本:

多个 layer
   ↓
通过 overlayfs 叠加
   ↓
得到一个 snapshot(merged view)

🏗️ 一个类比

  • layer 像砖块 🧱

  • snapshot 像房子当前长什么样 🏠

  • overlayfs 像施工方式 🔧


3️⃣ overlayfs 里的几个关键目录

一个典型挂载看起来像这样:

mount -t overlay overlay \
  -o lowerdir=layer1:layer2,upperdir=upper,workdir=work \
  merged/

这里的三个核心目录:

名称作用
lowerdir 📂只读层,历史内容
upperdir ✏️当前可写层,新增/修改/删除都落这里
workdir ⚙️overlayfs 内部工作目录
merged 👁️挂载点,最终看到的视图

⚠️ 重点:merged 不是"真实存储层"

merged 只是一个挂载入口,它本身通常不保存完整文件内容。
真正的数据仍然散落在 lowerdirupperdir 里。


4️⃣ upperdir 到底是什么

upperdir 是 overlayfs 中最核心的"写入层"。✍️

所有变化都会落在这里:

  • ➕ 新文件:直接写入 upperdir

  • ✏️ 修改文件:先 copy-up,再写入 upperdir

  • 🗑️ 删除文件:写 whiteout 标记到 upperdir

📝 示例

假设 lowerdir 里有:

/a.txt = old

你在 merged 里执行:

echo "new" > /a.txt

overlayfs 的行为是:

  1. /a.txt 从 lowerdir 复制到 upperdir 📋

  2. 修改 upperdir 中的 /a.txt ✏️

  3. merged 里看到的新值来自 upperdir ✅

所以,upperdir 本质上是:

当前层的修改区 🛠️


5️⃣ BuildKit 为什么会有 DAG

BuildKit 并不是"顺序执行 Dockerfile",而是把构建过程变成一个 有向无环图(DAG)。🕸️

5.1 节点是什么 🔵

每个节点代表一个构建操作:

  • FROM 🏁

  • COPY 📋

  • RUN

  • COPY --from=... 🔗

5.2 边是什么 ➡️

边代表依赖关系:

  • 这个节点依赖哪个输入 snapshot

  • 这个节点依赖哪个 stage

  • 这个节点依赖哪个文件上下文

5.3 为什么要做成 DAG 🤔

因为 DAG 能带来三件事:

  • 🚀 并行执行:无依赖节点可以同时跑

  • 🎯 精确缓存:只重建受影响的子图

  • ⏭️ 跳过无关步骤:不必机械地从头执行


6️⃣ BuildKit 的 DAG 不是"猜出来"的,而是"声明出来"的

一个很关键的理解是:

BuildKit 不会分析你的 RUN 命令到底访问了哪些文件。🚫

它依赖的是"输入声明"和"状态传递"。

也就是说,BuildKit 更像是:

  • 🗣️ 你告诉我这个节点输入是什么

  • 🧩 我把这个输入和当前状态组合起来

  • 💡 我据此决定能不能缓存、能不能并行

它不是编译器级别的运行时 IO 追踪器。


7️⃣ 为什么有些操作能并行,有些不能

7.1 ✅ 可以并行的情况

如果两个节点的输入完全独立,就可以并行。

例如:

COPY a.txt /app/a.txt
COPY b.txt /app/b.txt

如果它们不共享同一个输出路径,也不互相依赖,就有并行空间。

7.2 ❌ 不能并行的情况

例如:

COPY a.txt /app/x
COPY b.txt /app/x

这两个操作都写同一个路径 /app/x,那结果就依赖顺序:

  • 🤔 谁先写

  • 🔄 谁后覆盖

  • ❓ 最终谁生效

为了保证结果可重复,BuildKit 通常会把它们串起来。

💡 重点不是"overlayfs 检测到了冲突",而是"构建语义需要确定性顺序"。


8️⃣ overlayfs 能不能真正表达 DAG

严格说:单个 overlayfs mount 只支持线性叠层,不支持真正的多父合并图结构。 🚫

它可以表达:

lowerdir = A:B:C

但这本质是有顺序的层链,不是"任意 DAG"。

所以 BuildKit 的 DAG 并不是靠"一个 overlayfs 直接画出来"的,而是:

  • 🏗️ 构建层面:先形成 DAG

  • 🖥️ 执行层面:再映射到多个 snapshot / mount

  • 🔀 合成层面:按确定顺序使用 overlayfs 视图

换句话说:

DAG 是 BuildKit 的调度模型,不是 overlayfs 的原生数据结构。 🎯


9️⃣ overlayfs 的 merge 到底在做什么

overlayfs 的"merge"更准确地说是:

把多个只读层叠加成一个可见视图。🪞

它做的不是"内容融合",而是"视图叠加"。

👁️ 可视化

lowerdir:  L1 + L2 + L3
upperdir:  U
merged:    用户看到的文件系统

读取时会按优先级查找:

  1. 先查 upperdir 👆

  2. 再查更上层的 lowerdir 👇

  3. 找到就返回 ✅

所以 merge 的本质是:

读取时动态拼接,不是提前复制成一个新目录。


🔟 merge 遇到冲突时会发生什么

如果多个层里有同一路径,比如:

S1: /a.txt = A
S2: /a.txt = B

overlayfs 不会帮你做"语义上的智能合并"。它只遵循层顺序:

  • ⬆️ 上层覆盖下层

  • 🥇 更靠近 upper 的版本优先

🔑 关键结论

  • overlayfs 不会自动生成一个新的 lower layer 来表达冲突结果 🚫

  • 它只会在 upperdir 中记录写入结果 📝

  • 是否把这个结果导出成"新镜像 layer",是 BuildKit / Docker 的事情,不是 overlayfs 的事情 🔧


1️⃣1️⃣ "upperdir + diff 打包成新 layer"到底是什么意思

这句话容易误解,所以单独讲清楚。🧐

🖥️ overlayfs 运行时阶段

lowerdir + upperdir → merged view

这时还没有"打包 layer"这件事。

📤 BuildKit / Docker 导出阶段

当构建结束,或者需要导出镜像时,BuildKit 会把某个 snapshot 对应的变更导出成 OCI layer:

snapshot diff → tar archive → compressed blob → OCI layer

这里被"打包"的不是整个 upperdir 目录,而是:

  • 📊 当前 snapshot 相对于父 snapshot 的变化

  • 📁 这些变化被整理成 tar 流

  • 📦 再压缩成可分发的 layer blob

所以更准确的表达应该是:

BuildKit 会把 snapshot 的 diff 导出为镜像 layer。


1️⃣2️⃣ 一个 stage 等于一个 layer 吗

答案是:不等于。

一个 Dockerfile stage 只是一个逻辑构建阶段,里面可能包含很多步:

  • 多个 RUN

  • 多个 COPY 📋

  • 多个 ADD

  • 多个中间 snapshot 📸

📝 举例

FROM ubuntu:22.04
RUN apt-get update
RUN apt-get install -y curl
COPY a.txt /app/

这个 stage 里至少会经历多个状态变化,而不是一个 layer 就完事。

🤔 什么时候看起来像"一个 stage 一个 layer"

  • 你把所有逻辑压成一个操作

  • 你使用了 squash

  • 你最终导出时把多个 diff 合成了一个层

但这不是 stage 的天然属性。


1️⃣3️⃣ squash 的本质:把多个 diff 压平

squash 的目标是把多个 snapshot 的变化合成一个更少层的结果。🧹

✅ 好处

  • 镜像层数变少 📉

  • 运行时可能更简洁 ⚡

  • 清理中间构建痕迹 🧹

❌ 代价

  • 后续构建的缓存粒度变粗 📏

  • 中间步骤的复用能力下降 📉

  • 改一小步,可能要重算一大段 🔄

👁️ 直观理解

非 squash:

S0 → S1 → S2 → S3

squash 后:

S0 → Sfinal

你失去了中间状态,cache 自然也就没那么细了。


1️⃣4️⃣ 为什么 squash 会影响后续缓存复用

BuildKit 的缓存非常依赖"中间状态"。🎯

如果你保留了中间 snapshot:

  • ✅ 某一步没变,就可以直接命中

  • 🚀 只重建受影响节点

  • ⚡ 效率高

但如果 squash 把历史压扁:

  • ❌ 中间节点没了

  • 📉 细粒度 cache 点没了

  • 🐢 后续只能用更粗粒度的整体缓存

所以 squash 的本质是:

用镜像体积/层数优化,换缓存粒度。 ⚖️


1️⃣5️⃣ 重新整理这几个词的准确关系

layer 📦

  • 存储层面的增量数据

  • 通常是 tar/diff

  • 可被 content store 复用

snapshot 📸

  • 文件系统视图

  • BuildKit 调度与缓存的核心对象之一

  • 表示"当前状态"

overlayfs 🧩

  • Linux 内核级文件系统机制

  • 负责把 lowerdir + upperdir 合成 merged view

  • 是 snapshot 视图落地的一种实现方式

upperdir ✏️

  • 当前可写层

  • 新增/修改/删除都在这里落地

  • 可以被后续导出成 diff layer

merged 👁️

  • overlayfs 的挂载点

  • 用户看到的"合并后的目录视图"

  • 不是一个真实存储层


1️⃣6️⃣ 一张完整的脑内图

Dockerfile
   ↓
Frontend 解析
   ↓
LLB DAG
   ↓
BuildKit Solver
   ├── 缓存命中?是 → 直接复用 snapshot ✅
   └── 缓存未命中 → 交给 snapshotter 🔄
                          ↓
                      overlayfs 🧩
                      /        \
                lowerdir      upperdir
                      \        /
                       merged view 👁️
                          ↓
                 snapshot diff / export 📤
                          ↓
                    OCI image layer 📦

1️⃣7️⃣ 对你最实用的几个结论

💡 结论 1:不要把 stage 当 layer

一个 stage 里可以有多个 snapshot、多个 diff、多个 layer。

💡 结论 2:不要把 merged 当存储目录

merged 只是挂载出来的视图,不是实际打包层。

💡 结论 3:不要把 overlayfs 当 DAG 引擎

DAG 是 BuildKit 的调度模型,overlayfs 只是实现文件系统视图的底层工具。

💡 结论 4:squash 有代价

它会减少层数,但也会伤害后续缓存复用。


1️⃣8️⃣ 适合落地的 Dockerfile 优化思路

如果你的项目里有:

  • uv sync 🐍

  • pip install 📦

  • 模型文件下载 🤖

  • 前端依赖构建 🖥️

  • 后端依赖构建 ⚙️

可以考虑:

18.1 ⬆️ 把高复用内容拆前面

COPY pyproject.toml uv.lock ./
RUN uv sync

18.2 ⬇️ 把不稳定内容放后面

COPY . .
RUN python build.py

18.3 🏗️ 多 stage 隔离构建环境

FROM python:3.11 AS backend
RUN uv sync

FROM node:20 AS frontend
RUN npm install && npm run build

FROM nginx:alpine
COPY --from=frontend /dist /usr/share/nginx/html
COPY --from=backend /app /app

这样更容易让 BuildKit 的 DAG 发挥作用,也更容易控制最终镜像体积。🚀


🏁 结语

如果把 Docker 构建看成一条流水线,那么:

  • layer 是原料 📦

  • snapshot 是中间状态 📸

  • overlayfs 是拼装方式 🧩

  • BuildKit DAG 是调度逻辑 🕸️

  • squash 是"压平历史"的优化手段 🧹

理解了它们之间的边界,你就不再只是"会写 Dockerfile",而是能够开始设计 Dockerfile 的结构。🏗️

这在下面这些场景尤其重要:

  • 🐢 构建慢

  • 🎯 缓存命中率低

  • 📏 镜像体积大

  • 🔄 CI/CD 反复重建

  • 🌀 依赖层、模型层、编译层混杂

真正高效的 Docker 优化,往往不是"写几个命令"这么简单,而是先把这套底层关系想清楚。🧠✨