UnionFS / OverlayFS

联合文件系统将多个目录"叠加"挂载,实现镜像的分层存储和写时复制——Docker 镜像的底层魔法

阶段一 · 04 · 返回阶段大纲

前置回顾

第 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 原理

OverlayFS 是 Linux 内核内置的联合文件系统(Union File System),也是 Docker 当前默认的存储驱动(overlay2)。

三层结构

┌─────────────────────────────────┐
│       merged(合并视图)         │  ← 容器进程看到的完整文件系统
├─────────────────────────────────┤
│       upperdir(可写层)         │  ← 容器的所有修改写在这里
├─────────────────────────────────┤
│       lowerdir(只读层)         │  ← 镜像的各层(可以有多个,按顺序叠加)
└─────────────────────────────────┘
说明读写权限对应概念
lowerdir镜像层,可多个,按顺序叠加(后面的优先级高)只读Docker 镜像的各层
upperdir容器层,记录所有修改读写容器的可写层
workdirOverlayFS 内部使用的临时工作目录内部
merged最终合并后的统一视图合并容器看到的 rootfs

写时复制(Copy-on-Write)

容器运行时需要修改文件怎么办?镜像层是只读的。OverlayFS 的策略是写时复制——只在真正修改时才产生拷贝:

操作OverlayFS 行为
读取文件 直接从 lowerdir 读取,不做任何拷贝(零开销)
修改文件 先将文件从 lowerdir 复制到 upperdir,然后在 upperdir 中修改副本
删除文件 在 upperdir 中创建一个 whiteout 文件(特殊标记),遮挡 lowerdir 中的原文件
新建文件 直接在 upperdir 中创建,不涉及 lowerdir

类比图书馆的参考书规则:参考书(lowerdir)不能带走、不能涂改。你需要修改时,先去复印一份(copy-up),然后在你的复印件(upperdir)上随便写。删除一本书?在目录卡上贴个"已撤架"标签(whiteout),让人查不到它。

实操:手动体验 OverlayFS

挂载 OverlayFS

# 创建目录结构
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:最上面的镜像层优先级最高。

修改与删除:观察 upperdir 的变化

# 修改一个文件(触发 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 命令背后的原理。

Docker 镜像分层

Dockerfile 与层的关系

Dockerfile 中每条会修改文件系统的指令(RUNCOPYADD)都会产生一个新的层:

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 四个目录

动手练习

  1. 按照实操部分手动挂载 OverlayFS,修改文件后观察 upperdir 的变化
  2. 删除 merged 中的文件,用 ls -la upper/ 找到 whiteout 文件,确认它是字符设备
  3. 运行一个 Docker 容器,用 docker inspect 找到其 LowerDir/UpperDir/MergedDir 路径,进去看看真实的文件
  4. 对比 docker diff <container> 输出与 upperdir 中的文件变化,验证一致性
  5. 思考题:为什么在 Dockerfile 中 RUN rm -rf /big-file 并不能让最终镜像变小?(即使文件"删了")