联合文件系统将多个目录"叠加"挂载,实现镜像的分层存储和写时复制——Docker 镜像的底层魔法
前置回顾
第 01 篇讲过 mount 挂载机制——文件系统必须 mount 到某个目录才能使用。OverlayFS 就是一种特殊的文件系统类型,通过 mount -t overlay 将多个目录叠加成一个统一视图。第 02 篇的 Mount Namespace 则保证每个容器看到自己独立的挂载——两者配合,就是容器文件系统隔离的完整实现。
假设你有 100 个容器都基于 Ubuntu 镜像运行。如果每个容器都完整复制一份 Ubuntu 文件系统(约 70MB),光镜像层就要占 7GB。
分层存储的解决方案:把镜像拆成多个只读层,多个容器共享相同的层,每个容器只额外维护自己的可写层:
镜像 A (Ubuntu + Python):
Layer 3: Python 安装 ← 独有
Layer 2: apt-get update ← 可共享
Layer 1: Ubuntu base ← 可共享
镜像 B (Ubuntu + Node.js):
Layer 3: Node.js 安装 ← 独有
Layer 2: apt-get update ← 与镜像 A 共享(磁盘上只存一份)
Layer 1: Ubuntu base ← 与镜像 A 共享(磁盘上只存一份)
→ Layer 1 和 Layer 2 在磁盘上只存一份,100 个容器共享也只占一份空间
类比透明胶片投影:每一层是一张透明片,叠在一起看就是完整的画面。底层(基础镜像)是背景,上层(应用代码)画在新的透明片上。修改时只需要换掉某一张片,不用重绘整幅画。
OverlayFS 是 Linux 内核内置的联合文件系统(Union File System),也是 Docker 当前默认的存储驱动(overlay2)。
┌─────────────────────────────────┐
│ merged(合并视图) │ ← 容器进程看到的完整文件系统
├─────────────────────────────────┤
│ upperdir(可写层) │ ← 容器的所有修改写在这里
├─────────────────────────────────┤
│ lowerdir(只读层) │ ← 镜像的各层(可以有多个,按顺序叠加)
└─────────────────────────────────┘
| 层 | 说明 | 读写权限 | 对应概念 |
|---|---|---|---|
| lowerdir | 镜像层,可多个,按顺序叠加(后面的优先级高) | 只读 | Docker 镜像的各层 |
| upperdir | 容器层,记录所有修改 | 读写 | 容器的可写层 |
| workdir | OverlayFS 内部使用的临时工作目录 | 内部 | — |
| merged | 最终合并后的统一视图 | 合并 | 容器看到的 rootfs |
容器运行时需要修改文件怎么办?镜像层是只读的。OverlayFS 的策略是写时复制——只在真正修改时才产生拷贝:
| 操作 | OverlayFS 行为 |
|---|---|
| 读取文件 | 直接从 lowerdir 读取,不做任何拷贝(零开销) |
| 修改文件 | 先将文件从 lowerdir 复制到 upperdir,然后在 upperdir 中修改副本 |
| 删除文件 | 在 upperdir 中创建一个 whiteout 文件(特殊标记),遮挡 lowerdir 中的原文件 |
| 新建文件 | 直接在 upperdir 中创建,不涉及 lowerdir |
类比图书馆的参考书规则:参考书(lowerdir)不能带走、不能涂改。你需要修改时,先去复印一份(copy-up),然后在你的复印件(upperdir)上随便写。删除一本书?在目录卡上贴个"已撤架"标签(whiteout),让人查不到它。
# 创建目录结构
mkdir -p /tmp/overlay/{lower1,lower2,upper,work,merged}
# 在 lower 层创建文件(模拟镜像层)
echo "from lower1" > /tmp/overlay/lower1/file1.txt
echo "shared content v1" > /tmp/overlay/lower1/shared.txt
echo "from lower2" > /tmp/overlay/lower2/file2.txt
echo "shared content v2" > /tmp/overlay/lower2/shared.txt
# 挂载 OverlayFS(lower2 优先级高于 lower1)
sudo mount -t overlay overlay \
-o lowerdir=/tmp/overlay/lower2:/tmp/overlay/lower1,\
upperdir=/tmp/overlay/upper,\
workdir=/tmp/overlay/work \
/tmp/overlay/merged
# 查看合并后的视图
ls /tmp/overlay/merged/
# file1.txt file2.txt shared.txt
# shared.txt 来自 lower2(优先级高)
cat /tmp/overlay/merged/shared.txt
# "shared content v2"
lowerdir 的顺序
lowerdir=A:B:C 中,A 优先级最高、C 最低。同名文件存在于多层时,取优先级最高的那个。对应 Docker:最上面的镜像层优先级最高。
# 修改一个文件(触发 Copy-on-Write)
echo "modified by container" > /tmp/overlay/merged/shared.txt
# 查看 upperdir —— 修改后的副本在这里
cat /tmp/overlay/upper/shared.txt
# "modified by container"
# lower 层完全不受影响(只读)
cat /tmp/overlay/lower2/shared.txt
# "shared content v2" ← 原封未动
# 删除一个文件
rm /tmp/overlay/merged/file1.txt
# 查看 upperdir —— 出现了 whiteout 文件
ls -la /tmp/overlay/upper/
# c--------- 1 root root 0, 0 ... file1.txt ← 字符设备(whiteout 标记)
# merged 中已看不到 file1.txt
ls /tmp/overlay/merged/
# file2.txt shared.txt
# 新建文件
echo "new file" > /tmp/overlay/merged/created.txt
# 直接出现在 upperdir
cat /tmp/overlay/upper/created.txt
# "new file"
# 清理
sudo umount /tmp/overlay/merged
rm -rf /tmp/overlay
动手验证
上面的命令可以直接在任何 Linux 机器上运行(内核 3.18+ 内置 OverlayFS)。通过观察 upper/ 目录的变化,你可以直观看到写时复制和 whiteout 的行为——这就是 docker diff 命令背后的原理。
Dockerfile 中每条会修改文件系统的指令(RUN、COPY、ADD)都会产生一个新的层:
FROM ubuntu:22.04 # Layer 1: 基础镜像(来自 Docker Hub)
RUN apt-get update # Layer 2: 包索引更新
RUN apt-get install -y curl # Layer 3: 安装 curl
COPY app /app # Layer 4: 复制应用文件
CMD ["/app"] # 不产生新层(只是元数据)
运行容器时,Docker 在这些只读层之上加一个可写层(upperdir),形成完整的 OverlayFS:
容器运行时的文件系统:
merged(容器看到的根目录 /)
│
├── upperdir = 容器可写层(docker diff 看到的变更)
│
└── lowerdir = Layer4 : Layer3 : Layer2 : Layer1
(自上而下叠加,上层优先)
最佳实践:合并 RUN 指令
每个 RUN 产生一层,层越多镜像越大(删除文件只是加 whiteout,下层的数据仍占空间)。应合并相关操作并清理缓存:
# 不好:3 层,且 apt 缓存留在 Layer 2
RUN apt-get update
RUN apt-get install -y curl wget
RUN rm -rf /var/lib/apt/lists/*
# 好:1 层,且缓存在同一层内被清理,不占镜像空间
RUN apt-get update && \
apt-get install -y curl wget && \
rm -rf /var/lib/apt/lists/*
# 查看镜像的分层(每个 sha256 是一层)
docker image inspect nginx --format '{{json .RootFS.Layers}}' | python3 -m json.tool
# 查看 Docker 使用的存储驱动
docker info | grep "Storage Driver"
# Storage Driver: overlay2
# 查看镜像层在磁盘上的存储位置
ls /var/lib/docker/overlay2/
# 运行容器后查看其 OverlayFS 挂载信息
docker run -d --name test nginx
docker inspect test --format '{{json .GraphDriver.Data}}' | python3 -m json.tool
# {
# "LowerDir": "/var/lib/docker/overlay2/.../diff:...", ← 镜像只读层
# "MergedDir": "/var/lib/docker/overlay2/.../merged", ← 合并视图
# "UpperDir": "/var/lib/docker/overlay2/.../diff", ← 容器可写层
# "WorkDir": "/var/lib/docker/overlay2/.../work"
# }
# 查看容器对文件系统做了哪些修改
docker diff test
# C /var ← Changed
# A /var/log ← Added
# D /tmp/x ← Deleted
容器三件套完成
Namespace(第 02 篇)= 看到什么(视图隔离)
Cgroups(第 03 篇)= 用多少(资源限制)
OverlayFS(本篇)= 文件从哪来(分层文件系统 + 写时复制)
至此你已经理解了容器的全部底层原理。从第 05 篇开始,我们进入 Docker 工程实践——看它如何把这三件套封装成好用的产品。
| 要点 | 一句话概括 |
|---|---|
| 分层存储的意义 | 多个容器共享相同的只读镜像层,节省磁盘空间(100 个容器只存一份基础镜像) |
| OverlayFS 结构 | lowerdir(只读镜像层)+ upperdir(容器可写层)→ merged(统一视图) |
| 写时复制 | 读取零开销;修改时才复制到 upper;删除用 whiteout 遮挡 |
| Dockerfile 与层 | RUN / COPY / ADD 每条产生一层;应合并指令减少层数 |
| Docker 验证 | docker inspect 看 GraphDriver.Data 即可见 OverlayFS 四个目录 |
ls -la upper/ 找到 whiteout 文件,确认它是字符设备docker inspect 找到其 LowerDir/UpperDir/MergedDir 路径,进去看看真实的文件docker diff <container> 输出与 upperdir 中的文件变化,验证一致性RUN rm -rf /big-file 并不能让最终镜像变小?(即使文件"删了")