📖 前言
如果你写过 Dockerfile,通常会习惯这样理解构建:
FROM一下,RUN一下,COPY一下,最后得到一个镜像。🐳
但当你开始遇到这些问题时,背后的机制就必须真正弄清楚了:
-
❓ 为什么 BuildKit 构建更快?
-
❓ 为什么改一行 Dockerfile,会导致后面一串缓存失效?
-
❓ 为什么 overlayfs 说的是"叠层",但 BuildKit 又像在跑 DAG?
-
❓ 为什么
squash能让镜像变小,却可能让后续构建变慢? -
❓
upperdir、snapshot、layer、merged这些词到底是什么关系?
这篇文章把这些概念串起来,尽量用概念分层 + 图示 + 工程视角讲透。🧠
🗺️ 一张总图先建立直觉
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 只是一个挂载入口,它本身通常不保存完整文件内容。
真正的数据仍然散落在 lowerdir 和 upperdir 里。
4️⃣ upperdir 到底是什么
upperdir 是 overlayfs 中最核心的"写入层"。✍️
所有变化都会落在这里:
-
➕ 新文件:直接写入
upperdir -
✏️ 修改文件:先 copy-up,再写入
upperdir -
🗑️ 删除文件:写 whiteout 标记到
upperdir
📝 示例
假设 lowerdir 里有:
/a.txt = old
你在 merged 里执行:
echo "new" > /a.txt
overlayfs 的行为是:
-
把
/a.txt从 lowerdir 复制到 upperdir 📋 -
修改 upperdir 中的
/a.txt✏️ -
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: 用户看到的文件系统
读取时会按优先级查找:
-
先查
upperdir👆 -
再查更上层的
lowerdir👇 -
找到就返回 ✅
所以 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 优化,往往不是"写几个命令"这么简单,而是先把这套底层关系想清楚。🧠✨