Docker 文件系统深度解析:从镜像分层到 OverlayFS
> 发布日期:2026-03-31 | 作者:Tony @ Jay's Lab
>
> 完整解析 Docker 的文件系统架构:镜像如何分层存储、容器如何读写文件、OverlayFS 的工作原理、数据持久化方案,以及性能调优实践。
1. 核心概念:一切皆分层
Docker 的文件系统建立在一个核心设计上:联合文件系统(Union Filesystem)——把多个目录"叠"在一起,呈现为一个统一的文件系统。
用户看到的容器文件系统(合并视图)
┌──────────────────────────┐
│ /app/server.js (修改过) │ ← 容器可写层
│ /var/log/app.log (新文件) │
├──────────────────────────┤
│ /app/server.js (原始) │ ← 镜像层 3(COPY . /app)
│ /app/package.json │
├──────────────────────────┤
│ /usr/bin/node │ ← 镜像层 2(RUN apt install nodejs)
│ /usr/lib/libnode.so │
├──────────────────────────┤
│ /bin/bash │ ← 镜像层 1(基础镜像 ubuntu:22.04)
│ /lib/x86_64-linux-gnu/ │
│ /etc/apt/sources.list │
└──────────────────────────┘
关键规则:
- 镜像层(Image Layers)= 只读
- 容器层(Container Layer)= 可读写
- 多个容器可以共享同一组镜像层
- 文件修改只发生在最上面的容器层
2. 镜像分层:Dockerfile 的每一行都是一层
2.1 层是怎么产生的
FROM ubuntu:22.04 # 层 1:基础镜像(~77MB)
RUN apt update && \
apt install -y nodejs # 层 2:安装软件(~120MB)
COPY . /app # 层 3:复制代码(~5MB)
CMD ["node", "/app/server.js"] # 不产生新层(元数据指令)
产生新层的指令:RUN、COPY、ADD
不产生新层的指令:CMD、ENV、EXPOSE、WORKDIR、ENTRYPOINT(只修改元数据)
2.2 查看镜像分层
# 查看层信息
docker history my-app:latest
IMAGE CREATED SIZE COMMENT
a1b2c3d4e5f6 2 min ago 5.2MB COPY . /app
f6e5d4c3b2a1 5 min ago 120MB RUN apt update && apt install -y nodejs
ubuntu:22.04 3 weeks ago 77.8MB base image
# 详细层信息(含 diff_id)
docker inspect my-app:latest | jq '.[0].RootFS'
{
"Type": "layers",
"Layers": [
"sha256:aaa...", # 层 1
"sha256:bbb...", # 层 2
"sha256:ccc..." # 层 3
]
}
2.3 层共享节省空间
镜像 A (node-app) 镜像 B (python-app)
┌──────────────┐ ┌──────────────┐
│ COPY . /app │ 5MB │ COPY . /app │ 8MB
├──────────────┤ ├──────────────┤
│ RUN npm i │ 80MB │ RUN pip i │ 150MB
├──────────────┤ ├──────────────┤
│ ubuntu:22.04 │ ← 共享!只存一份
│ 77MB(磁盘上只占一次) │
└──────────────────────────────────────┘
# 查看实际磁盘占用(含共享层信息)
docker system df -v
TYPE TOTAL ACTIVE SIZE RECLAIMABLE
Images 5 3 1.2GB 450MB (37%)
Containers 3 2 125MB 50MB (40%)
Volumes 4 3 890MB 200MB (22%)
3. OverlayFS:Docker 的默认存储驱动
3.1 什么是 OverlayFS
OverlayFS 是 Linux 内核(3.18+)内置的联合文件系统,也是 Docker 的默认存储驱动(overlay2)。
它把多个目录合并为一个:
merged/ ← 容器看到的统一视图(mount point)
│
├── upperdir/ ← 容器可写层(所有修改写这里)
├── lowerdir/ ← 镜像只读层(可以有多层,用 : 分隔)
└── workdir/ ← OverlayFS 内部临时目录(原子操作用)
3.2 手动体验 OverlayFS
不用 Docker,直接用 mount 感受:
# 创建目录
mkdir -p /tmp/overlay/{lower,upper,work,merged}
# 在 lower(只读层)放文件
echo "original content" > /tmp/overlay/lower/file.txt
echo "read-only file" > /tmp/overlay/lower/readonly.txt
# 挂载 OverlayFS
sudo mount -t overlay overlay \
-o lowerdir=/tmp/overlay/lower,\
upperdir=/tmp/overlay/upper,\
workdir=/tmp/overlay/work \
/tmp/overlay/merged
# 在 merged 视图里可以看到 lower 的文件
cat /tmp/overlay/merged/file.txt
# → "original content"
# 修改文件
echo "modified!" > /tmp/overlay/merged/file.txt
# lower 没变!修改写到了 upper
cat /tmp/overlay/lower/file.txt
# → "original content"(不变)
cat /tmp/overlay/upper/file.txt
# → "modified!"(Copy-on-Write)
# 删除文件
rm /tmp/overlay/merged/readonly.txt
# lower 里文件还在,upper 里产生了一个"白障"文件
ls -la /tmp/overlay/upper/
# → readonly.txt 变成了 character device (0,0) — whiteout 标记
# 清理
sudo umount /tmp/overlay/merged
3.3 OverlayFS 的三个核心操作
读取(Read)
读 /app/server.js
│
▼ 先查 upperdir(容器层)
│ 找到了?→ 返回
│ 没找到?↓
▼ 再查 lowerdir(镜像层,从上到下)
找到了?→ 返回
没找到?→ 文件不存在
性能:读未修改的文件直接从 lowerdir 读,零拷贝,和宿主机直接读文件一样快。
写入 / 修改(Copy-on-Write)
写 /app/server.js(已存在于 lowerdir)
│
▼ 第一次修改?
│ 是 → 整个文件从 lowerdir 复制到 upperdir(Copy-Up)
│ 然后在 upperdir 的副本上修改
│ 否 → 直接修改 upperdir 的副本
Copy-on-Write 的代价:
- 第一次修改大文件时有性能开销(需要完整拷贝)
- 修改 1 字节也要拷贝整个文件
- 后续修改不再有额外开销
# 查看容器层的实际写入量
docker diff <container>
A /var/log/app.log # Added(新增)
C /app/server.js # Changed(修改,触发了 copy-up)
D /tmp/cache.tmp # Deleted(删除,产生 whiteout)
删除(Whiteout)
删除 /app/old-file.txt(存在于 lowerdir)
│
▼ 不能真删 lowerdir 的文件(只读)
│
▼ 在 upperdir 创建 "whiteout" 标记文件
→ character device (0, 0)
→ OverlayFS 看到标记后,在 merged 视图中隐藏该文件
删除目录用 opaque whiteout(.wh..wh..opq 文件),表示"忽略 lowerdir 中该目录的所有内容"。
3.4 Docker 中的 OverlayFS 实际结构
# 查看 Docker 使用的存储驱动
docker info | grep "Storage Driver"
# → Storage Driver: overlay2
# 查看容器的 overlay 挂载
docker inspect <container> | jq '.[0].GraphDriver'
{
"Data": {
"LowerDir": "/var/lib/docker/overlay2/abc123/diff:
/var/lib/docker/overlay2/def456/diff:
/var/lib/docker/overlay2/ghi789/diff",
"MergedDir": "/var/lib/docker/overlay2/xyz000/merged",
"UpperDir": "/var/lib/docker/overlay2/xyz000/diff",
"WorkDir": "/var/lib/docker/overlay2/xyz000/work"
},
"Name": "overlay2"
}
# 直接查看容器的可写层
ls /var/lib/docker/overlay2/xyz000/diff/
4. 存储驱动对比
4.1 主流存储驱动
| 驱动 | 内核要求 | 后端 | 状态 | 适用场景 |
|---|---|---|---|---|
| **overlay2** | 4.0+ | OverlayFS | ✅ 默认推荐 | 所有场景 |
| **btrfs** | — | Btrfs 文件系统 | ✅ | Btrfs 用户 |
| **zfs** | — | ZFS 文件系统 | ✅ | ZFS 用户 |
| **fuse-overlayfs** | — | FUSE | ✅ | rootless Docker |
| ~~aufs~~ | — | AuFS | ❌ 已弃用 | — |
| ~~devicemapper~~ | — | Device Mapper | ❌ 已弃用 | — |
4.2 overlay2 vs 其他
# 检查当前驱动
docker info --format '{{.Driver}}'
# 切换存储驱动(需要重建所有镜像!)
# /etc/docker/daemon.json
{
"storage-driver": "overlay2"
}
overlay2 的优势:
- Linux 内核原生支持,不需要额外模块
- 性能最好(尤其是读操作,直接走内核 VFS)
- 支持 128 层(足够了)
- 内存开销最小
5. 数据持久化:容器层之外的存储
容器层的数据在容器删除后就没了。持久化方案:
5.1 三种挂载方式
Docker Host
┌─────────────────────────────────────────┐
│ │
│ /var/lib/docker/volumes/mydata/_data │ ← Volume
│ /home/jay/project │ ← Bind Mount
│ (tmpfs in memory) │ ← tmpfs
│ │
└────────────┬──────────┬──────────┬──────┘
│ │ │
▼ ▼ ▼
Container: /data /app /tmp
5.2 Volume(推荐)
# 创建
docker volume create mydata
# 使用
docker run -v mydata:/data my-app
# 查看
docker volume inspect mydata
{
"Mountpoint": "/var/lib/docker/volumes/mydata/_data",
"Driver": "local"
}
# Volume 存在于 Docker 管理的目录,容器删了 volume 还在
docker rm my-container # 容器没了
docker volume ls # volume 还在
5.3 Bind Mount(开发用)
# 把宿主机目录挂进容器
docker run -v /home/jay/project:/app my-app
# 新语法(更明确)
docker run --mount type=bind,source=/home/jay/project,target=/app my-app
# 只读挂载
docker run -v /home/jay/config:/etc/myapp:ro my-app
5.4 tmpfs(内存文件系统)
# 敏感数据(密码、密钥)不落盘
docker run --tmpfs /tmp:size=100m my-app
# 高性能临时目录
docker run --mount type=tmpfs,destination=/cache,tmpfs-size=256m my-app
5.5 对比
| Volume | Bind Mount | tmpfs | |
|---|---|---|---|
| 管理方 | Docker | 用户 | 内核 |
| 位置 | /var/lib/docker/volumes/ | 任意路径 | 内存 |
| 容器删除后 | **保留** | **保留** | **丢失** |
| 多容器共享 | ✅ | ✅ | ❌ |
| 适用场景 | 数据库、持久数据 | 开发(代码热更新) | 临时/敏感数据 |
| 备份 | docker volume 命令 | 直接 cp | 不需要 |
| 性能 | 接近原生 | **原生** | **最快**(内存) |
6. 容器文件系统的大小控制
6.1 查看容器写了多少数据
# 查看每个容器的文件系统使用
docker ps -s
CONTAINER ID IMAGE SIZE
a1b2c3d4e5f6 my-app 125MB (virtual 330MB)
# ↑ 容器层 ↑ 含镜像层总大小
# 详细 diff
docker diff <container>
6.2 限制容器写入量
# overlay2 不直接支持容器存储配额
# 但可以用 --storage-opt(需要特定文件系统支持)
docker run --storage-opt size=10G my-app
# 更常见的做法:用 tmpfs 限制临时目录
docker run --tmpfs /tmp:size=100m my-app
6.3 减小镜像体积的技巧
# ❌ 坏:每层都有残留
RUN apt update
RUN apt install -y nodejs
RUN apt clean
# ✅ 好:单层完成,清理不占空间
RUN apt update && \
apt install -y nodejs && \
apt clean && \
rm -rf /var/lib/apt/lists/*
# ✅ 更好:多阶段构建
FROM node:20 AS builder
COPY . /app
RUN npm ci && npm run build
FROM node:20-slim # 更小的基础镜像
COPY --from=builder /app/dist /app
CMD ["node", "/app/server.js"]
# builder 阶段的 node_modules 不会出现在最终镜像
7. /var/lib/docker 目录结构
/var/lib/docker/
├── overlay2/ # 存储驱动数据
│ ├── abc123.../ # 每个层一个目录
│ │ ├── diff/ # 该层的文件内容
│ │ ├── link # 短标识符(用于构建 mount 参数)
│ │ ├── lower # 指向下层的链接
│ │ ├── merged/ # 合并视图(容器运行时才有)
│ │ └── work/ # OverlayFS 工作目录
│ └── l/ # 短链接目录(避免 mount 参数过长)
│ ├── ABC123 -> ../abc123/diff
│ └── DEF456 -> ../def456/diff
├── image/ # 镜像元数据
│ └── overlay2/
│ ├── imagedb/ # 镜像配置
│ ├── layerdb/ # 层元数据
│ └── repositories.json # 镜像名 → ID 映射
├── containers/ # 容器元数据
│ └── <container-id>/
│ ├── config.v2.json # 容器配置
│ ├── hostname # 主机名
│ ├── resolv.conf # DNS
│ └── hosts # hosts 文件
├── volumes/ # Volume 数据
│ └── <volume-name>/
│ └── _data/ # 实际数据
├── network/ # 网络配置
├── plugins/ # 插件
└── tmp/ # 临时文件
# 查看总占用
du -sh /var/lib/docker/
# → 15G
# 清理未使用的资源
docker system prune -a --volumes
# ⚠️ 会删除所有未运行容器的镜像、所有停止的容器、所有未使用的 volume
8. 高级话题
8.1 层的内容寻址(Content-Addressable)
Docker 用 SHA256 哈希标识每一层:
Layer = sha256(该层所有文件的 tar 包)
好处:
- 相同内容只存一份(去重)
- 篡改检测(哈希不匹配 = 数据损坏)
- pull 镜像时只下载缺失的层
# 查看镜像的层 hash
docker inspect ubuntu:22.04 | jq '.[0].RootFS.Layers'
[
"sha256:a8b5423f...", # 每个 hash 对应一个层
"sha256:c4d6e7f8..."
]
# 在 registry 里的存储
# registry.example.com/v2/<repo>/blobs/sha256:a8b5423f...
8.2 Lazy Pulling(按需拉取)
传统 pull 要下载所有层。新方案:
# eStargz / Nydus / OverlayBD
# 只下载实际访问的文件,容器秒启动
# containerd + stargz-snapshotter
ctr images rpull --stargz ghcr.io/my-app:latest
# 只下载元数据(几 MB),文件按需从 registry 拉取
8.3 只读容器
# 整个容器文件系统只读
docker run --read-only my-app
# 只允许写 /tmp
docker run --read-only --tmpfs /tmp:size=50m my-app
# 安全加固:防止恶意程序写入文件系统
8.4 Docker 内的文件系统隔离
容器内的 mount namespace:
/ ← overlay mount(merged view)
/proc ← procfs(进程信息,部分屏蔽)
/sys ← sysfs(部分只读)
/dev ← devtmpfs(受限设备访问)
/dev/shm ← tmpfs(共享内存,默认 64MB)
/etc/resolv.conf ← bind mount from host
/etc/hostname ← bind mount from host
/etc/hosts ← bind mount from host
9. 性能调优
9.1 I/O 性能基准
| 操作 | overlay2 | bind mount | tmpfs | 原生 ext4 |
|---|---|---|---|---|
| 顺序读 | ~95% | **100%** | N/A | 100% |
| 顺序写 | ~90% | **100%** | 最快 | 100% |
| 随机读(首次) | ~85% | **100%** | N/A | 100% |
| 小文件大量创建 | ~70% | **100%** | 最快 | 100% |
| 大文件首次修改 | **~50%** | 100% | N/A | 100% |
最大性能瓶颈:Copy-on-Write 首次修改大文件。
9.2 优化建议
# 1. 数据库文件用 Volume,不要放在容器层
docker run -v pgdata:/var/lib/postgresql/data postgres
# 2. 频繁写入的日志用 tmpfs 或 Volume
docker run --tmpfs /var/log:size=200m my-app
# 3. 减少层数(减少 lowerdir 查找深度)
# 合并 RUN 命令
# 4. 使用 .dockerignore 减少 COPY 上下文
echo "node_modules\n.git\n*.log" > .dockerignore
# 5. 监控容器层增长
docker ps -s --format "table {{.Names}}\t{{.Size}}"
10. 总结
Docker 文件系统 = 镜像层(只读)+ 容器层(可写)
镜像层:Dockerfile 每条 RUN/COPY/ADD 产生一层
内容寻址(SHA256),跨镜像共享
存储在 /var/lib/docker/overlay2/
容器层:OverlayFS upperdir
Copy-on-Write:修改时拷贝,删除用 whiteout
容器删除后丢失
持久化:Volume > Bind Mount > tmpfs
数据库、重要数据必须用 Volume
性能: 读操作接近原生
写操作有 CoW 开销(首次修改大文件最慢)
高 I/O 场景用 Volume 或 tmpfs