Docker 基础体系 · 第 43/70 篇。示例以现代 Docker Engine、BuildKit 与 Compose 规范为基础,并明确 Linux 容器边界。
Docker Storage Driver:overlay2、Copy-on-Write、Inode 和磁盘诊断
在 Linux 上,Docker 容器看到的根文件系统通常不是一个单独目录,而是由镜像只读层、容器可写层以及 OverlayFS 联合挂载形成的视图。overlay2 是 Docker Engine 在现代 Linux 主机上最常见的存储驱动,它决定了镜像层如何组合、容器文件如何写入,以及删除和修改文件时磁盘空间如何变化。
理解 overlay2 需要同时区分四个对象:
- 镜像层(image layer):不可变、只读,通常由镜像构建产生。
- 容器可写层(container writable layer):某个容器独有,容器删除后通常随之删除。
- 卷和绑定挂载(volume / bind mount):绕过容器可写层,独立管理数据。
- 主机文件系统资源:块空间、inode、目录项、页缓存和文件系统特性。
这几个对象都可能导致“磁盘满”,但原因、命令结果和清理方式并不相同。
1. Storage Driver 是什么
Docker 的 Storage Driver 是 Docker Engine 管理镜像层和容器根文件系统的机制。它至少需要解决以下问题:
- 如何保存镜像的多个只读层;
- 如何把多个镜像层组合成容器看到的单一目录树;
- 容器修改文件时,修改结果保存在哪里;
- 容器删除文件时,如何隐藏下层仍然存在的文件;
- 多个容器如何共享相同的镜像层;
- 如何在主机崩溃或容器退出后恢复文件系统状态。
overlay2 并不是 Docker 自己实现的完整文件系统,而是 Docker 使用 Linux 内核 OverlayFS 的一种存储驱动实现。
可以用以下命令查看当前 Docker 使用的驱动:
docker info --format \
'Storage Driver={{.Driver}}
Docker Root Dir={{.DockerRootDir}}
Backing Filesystem={{.BackingFilesystem}}'
典型输出类似:
Storage Driver=overlay2
Docker Root Dir=/var/lib/docker
Backing Filesystem=ext4
其中:
Storage Driver是 Docker 选择的驱动;Docker Root Dir是 Docker 管理镜像、容器元数据和存储驱动数据的根目录;Backing Filesystem是该目录所在的主机文件系统。
Docker 的实际目录布局属于实现细节,不应直接修改 /var/lib/docker 下的文件。即使能够看懂某些目录名称,也不能通过手工删除层目录来“清理 Docker”,因为 Docker 的元数据、层引用和实际目录可能因此不一致。
2. OverlayFS 的基本模型:lower、upper、merged 和 work
OverlayFS 将多个目录组合为一个统一的目录树。对 Docker 的一个容器而言,可以抽象为:
lowerdir:一个或多个只读镜像层;upperdir:容器独有的可写层;workdir:OverlayFS 执行某些原子操作所需的工作目录;merged:容器进程实际看到的联合视图。
其关系可以表示为:
flowchart LR
L1[镜像层 1:只读]
L2[镜像层 2:只读]
L3[镜像层 3:只读]
U[容器可写层:upperdir]
W[OverlayFS 工作目录:workdir]
M[merged:容器看到的根文件系统]
P[容器进程]
L1 --> M
L2 --> M
L3 --> M
U --> M
W -. 内核内部操作 .-> M
M --> P
对于同一路径,OverlayFS 通常遵循“上层优先”的可见性规则:
- 如果
upperdir中存在该路径,使用upperdir的对象; - 否则从最上面的有效
lowerdir查找; - 如果任何层都没有该路径,则返回不存在。
假设镜像层结构如下:
lower-1:
/etc/base.conf
/app/main.py
lower-2:
/etc/base.conf
/app/config.yaml
如果 lower-2 位于 lower-1 之上,容器看到的是:
/etc/base.conf -> lower-2/etc/base.conf
/app/main.py -> lower-1/app/main.py
/app/config.yaml -> lower-2/app/config.yaml
因此,镜像层并不是简单地把同名文件并排暴露出来,而是按照层的顺序形成一个覆盖关系。
2.1 镜像层与容器层的区别
镜像层是构建结果的一部分,通常由 Dockerfile 指令产生。例如:
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y ca-certificates
COPY app /opt/app
一个具体镜像可能由多个层组成:
层 A:debian:bookworm-slim 的基础文件
层 B:安装 ca-certificates 后新增或修改的文件
层 C:COPY app 产生的文件
启动容器时,Docker 不会把这些层完整复制成一个新的目录。它会把它们作为只读下层,并为该容器增加一个独立的可写层:
镜像只读层 A
镜像只读层 B
镜像只读层 C
容器可写层 W
----------------
容器 merged 视图
多个容器可以共享 A、B、C,但每个容器分别拥有自己的 W。这是镜像层复用和容器隔离的基础。
3. Copy-on-Write:写时复制究竟复制了什么
Copy-on-Write,CoW,写时复制 的核心语义是:多个对象先共享同一份只读内容,只有某一方要修改时,才为该修改创建可写副本。
在 overlay2 中,CoW 主要体现为 OverlayFS 的 copy-up:
- 文件只在 lower 层存在时,读取直接从 lower 层完成;
- 容器第一次修改这个文件时,OverlayFS 将文件或相关元数据复制到 upper 层;
- 后续读写针对 upper 层副本;
- 容器视图中 upper 层对象遮蔽 lower 层对象。
3.1 修改一个文件的完整过程
假设镜像中有:
lower:
/etc/app.conf 1 MiB
容器启动后,执行:
printf '\nmode=prod\n' >> /etc/app.conf
可以分解为以下步骤:
- 进程通过容器的
merged视图打开/etc/app.conf; - OverlayFS 发现目标文件原本位于只读 lower 层;
- 为了允许写入,OverlayFS 将 lower 层文件复制到 upper 层;
- upper 层中的副本保留原文件内容;
printf将新内容写入 upper 层副本;- 之后从容器视图读取
/etc/app.conf时,命中 upper 层; - lower 层中的原文件仍未改变,其他容器也不会看到该修改。
可以用一个简化的空间模型表示:
设:
S_l:lower 层中的原始文件大小;S_u:copy-up 后 upper 层副本的大小;Δ:应用实际追加的数据大小。
在普通完整 copy-up 语义下,第一次修改产生的额外空间近似为:
而不是仅仅增加 Δ。
例如:
镜像中的 /var/log/app.log:2 GiB
容器只在文件末尾追加 1 KiB
第一次追加可能需要在容器可写层中创建接近 2 GiB 的副本,而不是只占用 1 KiB。之后继续写入同一个 upper 层副本,新增空间才大致接近实际写入量。
这就是“容器只改了一小段文件,却突然占用大量空间”的典型原因。
具体 copy-up 行为受 Linux 内核和 OverlayFS 特性的影响。Docker 应用层不应把它当成“按块精确复制”的保证。对于工程估算,按完整文件可能被复制来考虑更安全。
3.2 读取不会自动增加容器可写层空间
以下操作通常不会因为读取而把普通文件复制到 upper 层:
cat /etc/app.conf
sha256sum /usr/bin/python3
grep -R "keyword" /usr/share
但“打开”不等于“永远不会触发 copy-up”。某些改变文件元数据的操作也可能要求 upper 层对象,例如:
chmod 600 /some/lower/file
chown app:app /some/lower/file
touch /some/lower/file
这些操作改变了权限、所有者或时间戳,不能继续只使用不可变的 lower 对象,因此可能触发复制或元数据上移。
3.3 CoW 不是数据库事务
OverlayFS 的 CoW 只描述文件系统层面的可写化过程,不提供应用事务语义。以下操作不应被推断为“整个目录树原子提交”:
修改 A
修改 B
更新索引
进程崩溃
OverlayFS 不会把这几个系统调用自动包装成一个事务。应用仍然需要使用:
- 临时文件加
rename; fsync;- 数据库自身的 WAL 或日志;
- 应用级事务和恢复逻辑。
因此,把容器可写层当作数据库持久化方案是错误的。即使容器重启后可写层还在,文件系统层也不等于数据库层。
4. 删除文件:whiteout 如何隐藏 lower 层对象
由于镜像层是只读的,容器不能真的从 lower 层删除文件。例如镜像里存在:
lower:
/usr/share/doc/example.txt
容器执行:
rm /usr/share/doc/example.txt
OverlayFS 不能修改 lower 层,于是会在 upper 层记录一个“这里有一个删除标记”。这个标记通常称为 whiteout。
对容器的 merged 视图而言:
lower:
/usr/share/doc/example.txt
upper:
/usr/share/doc/.wh.example.txt
结果是:
容器内:
/usr/share/doc/example.txt 不存在
但 lower 层原始文件仍然保留。这样做有两个重要后果:
- 删除镜像中的大文件,不会自动释放该镜像层占用的空间;
- 如果把容器文件系统导出或提交为新镜像,删除动作需要通过 whiteout 传递给后续层。
4.1 删除不等于回收镜像空间
假设某镜像层中有一个 500 MiB 文件:
layer-1:
/opt/data.bin 500 MiB
容器启动后删除它:
rm /opt/data.bin
容器可写层可能只增加一个删除标记,占用很小空间,但 layer-1 仍然占用约 500 MiB,并且可能仍被镜像引用。
因此,以下 Dockerfile 写法不能真正减少最终镜像层体积:
FROM debian:bookworm
RUN apt-get update
RUN apt-get install -y build-essential
RUN rm -rf /var/lib/apt/lists/*
如果目标是让安装过程中产生的临时文件不进入最终镜像,应在同一个 RUN 指令中创建和删除它们:
FROM debian:bookworm
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential \
&& rm -rf /var/lib/apt/lists/*
原因不是 shell 命令是否连续,而是每条 Dockerfile 指令通常会形成新的镜像层:
错误方式:
层 A:apt update 产生缓存
层 B:apt install
层 C:删除缓存
层 C 只能在视图中隐藏层 A 或层 B 中的文件,不能让已写入的历史层缩小。
正确方式把临时文件的创建和删除放在同一层:
层 A:创建缓存、安装软件、删除缓存后的最终状态
这里还要注意:具体镜像层数量和构建缓存行为由 BuildKit、Dockerfile 前端和构建流程共同决定,不能仅凭 Dockerfile 行数推断所有内部细节;但“后续层删除不了前一层已经写入的字节”这一层模型仍然成立。
5. 目录操作与 opaque 目录
文件删除可以使用单个 whiteout 表示,目录覆盖则更复杂。
假设 lower 层有:
lower:
/var/cache/
├── a
├── b
└── c
如果 upper 层需要表示一个“新的 /var/cache 目录”,并且不希望自动显示 lower 层中的 a、b、c,就需要表达该目录对 lower 层是不透明的,通常称为 opaque directory。
这与普通 upper 目录不同:
普通 upper 目录:
upper 中的文件优先,但可能仍可看到 lower 中未被遮蔽的文件
opaque upper 目录:
lower 中的同名目录内容整体不再向上合并
这也是为什么递归删除或替换目录,可能产生比“删除几个文件”更复杂的 OverlayFS 元数据。
对于 Docker 用户,重要结论是:不要根据 /var/lib/docker/overlay2 中看到的特殊文件名称,手工推断或修改容器语义。whiteout 和 opaque 标记由文件系统和 Docker 的导出、提交逻辑共同解释。
6. Copy-up 的关键边界:文件、目录、重命名和链接
6.1 修改大文件的风险
最容易被忽略的场景是:镜像内已有大文件,应用在容器启动后对其进行原地修改。
例如:
镜像层:
/var/lib/app/index.db 8 GiB
应用执行:
打开 index.db
写入少量索引
即使实际逻辑修改只有几 KB,容器可写层也可能因为 copy-up 而显著增长。更稳妥的设计是:
- 把数据库目录挂载到 volume;
- 让应用数据从一开始就位于独立可写存储;
- 避免在镜像内放置运行时会原地修改的大文件。
6.2 重命名不应被理解为廉价元数据操作
在普通单一文件系统中,rename 通常只需更新目录项。但 OverlayFS 中,如果源对象来自 lower 层,重命名可能需要将对象或相关目录结构上移到 upper 层,以保证 lower 层仍不可变。
因此:
mv /image-data/file /image-data/file.old
不一定只产生一个很小的目录项变更;其成本取决于源对象所在层、目录结构和内核实现。
6.3 硬链接和跨层限制
OverlayFS 对硬链接、跨层 rename、特殊文件、xattr 和权限有自己的限制。应用如果严重依赖以下行为,应在目标内核和 Docker 版本上进行验证:
- 在不同层之间创建硬链接;
- 频繁对大量 lower 文件执行
chown; - 依赖特定扩展属性;
- 使用需要稳定 inode 语义的工具;
- 把 OverlayFS 当作普通 ext4 目录使用。
“容器内看起来像一个普通 Linux 根目录”不意味着它具备所有底层文件系统组合后的原始语义。
7. Inode 是什么,为什么 inode 会先于容量耗尽
磁盘空间通常至少有两种独立资源:
- 数据块空间(blocks):保存文件内容;
- inode:描述文件的元数据和对象身份。
inode 通常记录:
- 文件类型;
- 权限;
- UID/GID;
- 文件大小;
- 时间戳;
- 数据块位置;
- 链接计数;
- 其他文件系统元数据。
目录项(directory entry,dentry)则把“文件名”映射到 inode。一个目录中有大量小文件时,数据块消耗可能不大,但 inode、目录块和 OverlayFS 元数据都可能快速增加。
查看块空间和 inode 使用情况:
df -h /var/lib/docker
df -i /var/lib/docker
典型结果可能是:
Filesystem Size Used Avail Use% Mounted on
/dev/sda1 100G 70G 30G 70% /
Filesystem Inodes IUsed IFree IUse% Mounted on
/dev/sda1 6553600 6550000 3600 100% /
这里第一条显示容量仍有 30 GiB,但第二条显示 inode 已接近耗尽。此时创建一个只有几字节的新文件,也可能失败:
No space left on device
所以:
No space left on device
不一定表示字节容量为 100%,也可能表示:
- inode 用尽;
- 项目配额用尽;
- 用户配额用尽;
- overlay upper 所在文件系统无法分配元数据;
- 临时目录或工作目录所在分区已满。
7.1 inode 的简单估算
假设某文件系统有:
总 inode:10,000,000
已使用 inode:9,900,000
则可用 inode 为:
如果某个构建步骤产生 200,000 个小文件,即使这些文件总大小只有 1 GiB,也可能因为 inode 不足失败。
典型来源包括:
node_modules/
Python 虚拟环境
包管理器缓存
编译中间文件
测试产生的临时文件
容器日志轮转文件
大量短生命周期容器的可写层元数据
因此,磁盘诊断必须同时执行 df -h 和 df -i,不能只看容量百分比。
8. overlay2 与 inode 的关系
每个 lower 层、upper 层和相关目录都需要在 backing filesystem 上创建目录项和 inode。下面几类操作尤其可能增加 inode 消耗:
- 安装包含大量小文件的软件包;
- 构建前端依赖树;
- 创建新的容器可写层;
- 对 lower 文件执行大量 copy-up;
- 产生大量 whiteout 或目录元数据;
- 生成大量构建缓存对象;
- 写入大量小型日志文件。
一个常见误判是:
“镜像层是共享的,所以创建很多容器不会增加存储压力。”
更准确的说法是:
- 相同的镜像 lower 层可以共享,避免重复保存相同内容;
- 每个容器仍会有独立的可写层目录和元数据;
- 只读镜像共享不意味着容器运行时修改可以共享;
- 大量容器、频繁 copy-up 和大量小文件仍会消耗 inode 和目录元数据。
9. Volume、bind mount 与容器可写层
容器内看到的路径可能来自不同存储对象。以以下启动命令为例:
docker run -d \
--name app \
-v app-data:/var/lib/app \
-v "$PWD/config:/etc/app:ro" \
myapp:1.0
容器中的路径来源如下:
/var/lib/app -> Docker volume
/etc/app -> 主机绑定挂载,且只读
其他路径 -> 镜像 lower + 容器 upper
因此,应用写入:
/var/lib/app/data.db
通常不会进入该容器的 overlay 可写层,而是写入 Docker volume 的实际存储位置。
应用写入:
/tmp/output
如果 /tmp 没有单独挂载,则会进入容器可写层。
9.1 为什么 docker system df 和 du 可能对不上
不同工具统计的对象不同:
docker system df按 Docker 对象和引用关系统计;du按目录遍历统计可见文件或实际目录;df按整个底层文件系统统计已分配块和 inode;docker volume数据通常不属于镜像层统计;- 容器日志可能位于 Docker 数据目录,但不是 overlay layer;
- BuildKit 缓存也可能由 Docker 管理,但不等同于某个镜像层。
因此,不能用单个命令回答“Docker 到底用了多少磁盘”。必须先确定要问的是:
镜像层占用?
容器可写层?
Build Cache?
Volume?
容器日志?
整个 /var/lib/docker 所在文件系统?
10. 镜像层、内容寻址和 overlay2 的关系
现代 Docker 镜像通常使用内容寻址标识对象。一个镜像不是简单的“某个目录打包文件”,而是由配置对象、清单(manifest)和层对象组成。
可以抽象为:
镜像标签
-> manifest
-> config
-> layer digest 1
-> layer digest 2
-> layer digest 3
例如层可以由摘要标识:
sha256:...
相同摘要表示相同的内容对象。Docker 可以让多个镜像或容器引用同一个层,从而避免重复保存。
但需要区分:
- 内容寻址:通过内容摘要识别并复用不可变对象;
- OverlayFS 叠加:把这些层组合成运行时目录树;
- Copy-up:把 lower 中需要修改的对象复制到容器 upper;
- Build Cache:保存构建步骤及其输入输出关系;
- Volume:独立于镜像层保存运行时数据。
“镜像复用层”并不意味着“容器写入也复用同一个可写文件”。如果两个容器同时修改同一个镜像文件,必须分别在各自 upper 中得到可写副本,否则容器之间无法隔离。
11. BuildKit 缓存为什么会占磁盘
现代 Docker 构建通常使用 BuildKit。构建时除了最终镜像层,还可能产生:
- 中间构建层;
- 未被当前镜像引用的缓存记录;
- 包管理器缓存;
- 构建器内部的 snapshot;
- 依赖下载缓存;
- 多平台构建产生的额外对象。
查看 Docker 对象占用:
docker system df
docker system df -v
典型输出会区分:
Images
Containers
Local Volumes
Build Cache
清理未使用的构建缓存:
docker builder prune
更激进的清理:
docker builder prune -a
两者区别是:
- 不带
-a通常只清理未被当前构建使用的部分缓存; - 带
-a会进一步清理未被现有镜像引用的缓存,下一次构建可能重新下载依赖并重新执行步骤。
在 CI 机器上,缓存有价值;在磁盘即将耗尽的临时构建机上,缓存可能需要定期回收。清理之前应确认该主机是否承担构建加速职责,以及回收后是否会增加构建时间和外部依赖压力。
12. 磁盘诊断:先定位文件系统,再定位 Docker 对象
遇到磁盘告警时,建议沿着“文件系统 → Docker 子对象 → 进程打开文件”的顺序诊断。
12.1 第一步:确认 Docker 数据目录所在挂载点
docker info --format '{{.DockerRootDir}}'
假设结果为:
/var/lib/docker
检查它所在的文件系统:
findmnt -T /var/lib/docker
df -h /var/lib/docker
df -i /var/lib/docker
findmnt -T 用于确认路径最终落在哪个挂载点。不要直接假设 /var/lib/docker 一定属于根分区,因为管理员可能通过独立磁盘、LVM、XFS 或其他挂载方式配置 Docker data-root。
12.2 第二步:查看 Docker 级别统计
docker system df
docker system df -v
该命令适合回答:
哪些镜像占用较多?
哪些容器可写层较大?
哪些 volume 被使用或未使用?
Build Cache 是否占用明显空间?
查看容器的可写层大小:
docker ps -s
输出中的含义需要谨慎理解。常见字段包括:
SIZE
VIRTUAL SIZE
通常:
SIZE更接近该容器可写层新增的大小;VIRTUAL SIZE可能包含其依赖的镜像层;- 多个容器共享同一镜像层时,不能把每个容器的 virtual size 相加当作真实磁盘占用。
具体展示格式可能随 Docker 版本变化,生产诊断应以当前 Engine 输出和对象关系为准。
12.3 第三步:查看容器发生了哪些文件变化
docker diff app
输出中的标记通常表示:
A /path Added,新增
C /path Changed,修改
D /path Deleted,删除或隐藏
例如:
A /tmp/report
C /etc/app.conf
D /usr/share/doc/example.txt
这可以帮助判断容器可写层为什么增长,但不能把它当作精确的字节占用报告:
docker diff展示路径变化,不展示每个路径占用多少空间;- 一个
C文件可能因 copy-up 产生远大于逻辑修改量的空间; - volume 和 bind mount 中的变化不一定属于容器 upper;
- 已删除但仍被进程打开的文件,路径可能已经不再出现在目录树中。
12.4 第四步:在主机上按挂载点统计
对 Docker 数据目录使用 du 时,至少限制在同一文件系统:
sudo du -xhd1 /var/lib/docker | sort -h
参数含义:
-x:不跨文件系统;-h:人类可读;-d1:只统计一层目录;sort -h:按人类可读大小排序。
继续向下查看:
sudo du -xhd1 /var/lib/docker/containers | sort -h
sudo du -xhd1 /var/lib/docker/overlay2 | sort -h
但不要把 overlay2 内部目录直接映射为“某个镜像大小”。原因包括:
- 层之间存在共享;
- 目录结构是驱动实现细节;
du可能统计目录项和硬链接的方式与 Docker 对象统计不同;- 某些数据可能属于缓存、容器 upper 或驱动元数据;
- 直接遍历 lower/upper 目录不等于统计 merged 视图。
12.5 第五步:检查容器日志
Docker 的 json-file 日志驱动可能把标准输出和标准错误保存到 Docker 数据目录中。查看日志配置:
docker inspect -f '{{.HostConfig.LogConfig.Type}}' app
查看容器日志路径:
docker inspect -f '{{.LogPath}}' app
检查该文件:
sudo ls -lh "$(docker inspect -f '{{.LogPath}}' app)"
如果应用持续向 stdout 输出大量日志,而没有限制轮转,日志可能成为主要磁盘消耗者。Docker Compose 可以在服务级别配置日志选项,例如:
services:
app:
image: myapp:1.0
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
该配置的含义是:
- 单个日志文件达到约 10 MB 后轮转;
- 最多保留 3 个文件;
- 轮转不等于无限期保留;
- 具体日志驱动和选项支持情况取决于 Docker Engine 配置。
不要直接使用 rm 删除正在由 Docker 管理的日志文件。删除后,Docker 的文件描述符、日志状态和后续轮转可能产生不一致。应通过日志驱动配置、日志采集系统或受控的 Docker 配置变更来治理。
12.6 第六步:检查 volume
列出 volume:
docker volume ls
查看某个 volume 的实际挂载点:
docker volume inspect app-data
典型结果包含:
[
{
"Name": "app-data",
"Mountpoint": "/var/lib/docker/volumes/app-data/_data"
}
]
统计 volume 使用量时要先确认它所在文件系统:
sudo du -xhd1 /var/lib/docker/volumes | sort -h
不要因为某个 volume 当前没有被容器使用,就直接判断其中数据无价值。未使用 volume 可能包含数据库、备份或人工保留的数据。
13. 文件已删除但磁盘没有释放:打开文件描述符
Linux 中,一个文件名被删除后,只要进程仍然持有打开的文件描述符,文件内容仍然占据磁盘空间。目录树看不到它,但 df 仍会显示空间已使用。
检查这类文件:
sudo lsof +L1
典型输出可能包含:
process pid user fd type size/off NLINK name
app 123 root 5w REG 5G 0 /var/log/app.log (deleted)
这里:
NLINK 0
表示目录链接已删除,但进程仍打开文件。处理步骤通常是:
- 确认哪个进程持有文件;
- 判断是否为日志、临时文件或数据库文件;
- 让应用重新打开文件,通常通过优雅重启或日志 reopen;
- 观察
df -h是否释放空间。
不要盲目终止数据库或核心服务。对数据库文件执行强制重启可能造成恢复过程、额外 I/O 甚至数据损坏。
14. df、du 和容器内 df 为什么可能不同
这三个命令回答的问题不同。
df
df -h /var/lib/docker
回答的是:该挂载点所在文件系统已经分配了多少块和 inode。
它通常能看到:
- 所有用户的文件;
- 已删除但仍被打开的文件占用;
- 文件系统元数据;
- 配额或保留块带来的差异。
du
sudo du -xsh /var/lib/docker
回答的是:从目录树遍历能够看到的文件,累计有多大。
它可能看不到:
- 已删除但仍被打开的文件;
- 无权限访问的目录;
- 某些挂载点之外的内容;
- 文件系统内部元数据。
容器内 df
docker exec app df -h /
回答的是:容器进程看到的根挂载点对应的文件系统视图。
在没有独立配额的常见配置中,容器内 df 可能显示与主机底层文件系统相近的总容量,而不是“这个容器还剩多少专属空间”。这不代表容器拥有整个磁盘,也不代表 Docker 为它提供了精确隔离的剩余容量。
因此,下面的推断是不可靠的:
容器内 df 显示还有 20 GiB
=> 该容器一定还能写入 20 GiB
实际写入还可能受以下因素限制:
- upper 所在文件系统已经接近满;
- inode 耗尽;
- 项目或用户配额;
- 容器运行时限制;
- 应用写入的是 volume,而不是根文件系统;
- overlayfs 在 copy-up 时需要额外空间。
15. 常见失败表现与对应原因
15.1 只有少量写入,却 upper 层增长很多
常见原因:
- 修改了来自 lower 层的大文件;
- 对大量 lower 文件执行了
chown、chmod; - 应用对镜像中的数据库文件进行原地更新;
- 重命名或替换了包含大量内容的目录。
验证方式:
docker ps -s
docker diff <container>
sudo du -xhd1 /var/lib/docker
修复方向:
- 将运行时数据放入 volume;
- 将编译和临时目录放在适合的独立存储;
- 构建时避免把大文件放进随后会被原地修改的镜像层;
- 减少容器启动时递归修改整个应用目录。
15.2 删除容器后磁盘仍然很满
可能原因:
- 镜像层仍被其他镜像或容器引用;
- volume 独立保留;
- Build Cache 仍存在;
- 日志文件占用空间;
- 进程持有已删除文件;
- 清理的不是实际 Docker data-root 所在分区。
诊断:
docker system df -v
docker volume ls
df -h /var/lib/docker
sudo lsof +L1
15.3 df -h 未满,但创建文件失败
优先检查:
df -i /var/lib/docker
如果 inode 使用率接近 100%,应寻找小文件密集目录:
sudo find /var/lib/docker -xdev -type f | wc -l
sudo find /var/lib/docker -xdev -type d | wc -l
进一步按目录统计数量:
for d in /var/lib/docker/*; do
[ -e "$d" ] || continue
printf '%s\t' "$d"
sudo find "$d" -xdev -type f 2>/dev/null | wc -l
done
这类命令遍历大量文件时可能产生 I/O 压力,不宜在繁忙生产节点上无条件执行。
15.4 容器内应用报权限错误
OverlayFS 不会消除 Linux 的 UID/GID 和权限规则。常见原因包括:
- upper 层文件由 root 创建;
- volume 由主机用户创建,容器内 UID 不匹配;
- 应用启动脚本对 lower 文件执行
chown,触发 copy-up; - rootless 容器受到用户命名空间映射限制;
- SELinux 或其他 LSM 拒绝访问。
诊断基础信息:
docker inspect app --format \
'User={{.Config.User}}
ReadonlyRootfs={{.HostConfig.ReadonlyRootfs}}
Mounts={{json .Mounts}}'
在 Linux 容器边界内,还应检查主机安全策略、挂载选项和用户命名空间,而不能只看容器内的 ls -l。
16. overlay2 的主机文件系统前提
overlay2 是 Linux 专用边界内的实现。Docker Desktop 在 macOS 或 Windows 上通常还隔着一个 Linux VM,主机上看到的 Docker 文件不等同于原生 Linux Engine 的直接布局;本文的 overlay2 诊断命令主要针对 Linux Docker Engine。
使用 overlay2 时,需要关注:
16.1 内核支持
OverlayFS 依赖 Linux 内核支持。较新的内核通常提供更完整的功能,但具体行为仍与内核版本、发行版补丁和挂载参数有关。
查看内核:
uname -r
查看驱动:
docker info
16.2 backing filesystem 特性
Docker 官方文档通常要求支持 overlay2 的 backing filesystem 具备适当的目录项类型信息。例如 XFS 通常要求 ftype=1,也就是 d_type 可用。
检查 XFS:
xfs_info /var/lib/docker
寻找类似:
ftype=1
如果 backing filesystem 不满足 OverlayFS 要求,可能出现 Docker 无法启动、挂载失败、构建失败或行为受限。
16.3 NFS 和远程文件系统边界
不要把 Docker data-root 随意放到 NFS 等远程文件系统上。OverlayFS 对远程文件系统、锁、xattr、inode 和一致性有额外限制,Docker 官方也对支持范围和配置条件有明确说明。
一个看似可行但高风险的配置是:
/var/lib/docker -> NFS
即使目录可以访问,也不代表 overlay2 在该环境下获得可靠支持。生产环境应依据当前 Docker Engine 和内核文档验证,而不是只依据“能挂载”判断可用。
17. 只读根文件系统与 writable layer 的关系
可以通过 Docker 运行参数使容器根文件系统只读:
docker run --read-only \
--tmpfs /tmp \
--tmpfs /run \
myapp:1.0
此时:
- 镜像层仍然是只读;
- 容器的根视图也禁止普通写入;
/tmp和/run通过 tmpfs 提供独立可写空间;- 持久化数据仍应使用 volume 或外部存储。
如果应用写入未挂载的路径,可能得到:
Read-only file system
这与以下错误不同:
No space left on device
前者表示写入权限或挂载状态不允许,后者表示空间、inode、配额或文件系统分配失败。
Compose 中可以表达类似配置:
services:
app:
image: myapp:1.0
read_only: true
tmpfs:
- /tmp
- /run
volumes:
- app-data:/var/lib/app
volumes:
app-data:
这类配置把“临时可写数据”和“需要持久化的数据”从容器根层中分离出来,减少因运行时写入导致 upper 层膨胀的风险。
18. 生产环境中的清理:对象引用比目录删除更重要
Docker 的清理命令会根据对象引用关系工作,通常比手工删除 /var/lib/docker 安全。
查看未使用对象:
docker system df -v
清理已停止容器:
docker container prune
清理未使用网络:
docker network prune
清理未使用镜像:
docker image prune
清理所有未被容器使用的镜像:
docker image prune -a
清理未使用 volume:
docker volume prune
清理全部未使用对象:
docker system prune
更激进的版本:
docker system prune -a --volumes
最后一个命令风险很高,因为它可能删除:
- 未被容器使用的镜像;
- 已停止容器;
- 未使用网络;
- 未使用 volume;
- 部分构建缓存。
清理前应先查看:
docker ps -a
docker image ls
docker volume ls
docker system df -v
尤其不要把下面的做法当作 Docker 清理:
sudo rm -rf /var/lib/docker/overlay2/*
这会绕过 Docker 的引用计数和元数据管理,极易导致已有镜像、容器和存储状态损坏。若必须迁移或重建 Docker data-root,应使用受控停机、备份、配置变更和验证流程,而不是直接删除内部目录。
19. 一个完整的磁盘故障诊断流程
假设应用报告:
write /tmp/result: no space left on device
可以按以下路径判断。
第一步:确定错误发生在哪个文件系统
docker exec app df -h /tmp
docker exec app df -i /tmp
findmnt -T /var/lib/docker
df -h /var/lib/docker
df -i /var/lib/docker
如果 /tmp 是 tmpfs,则错误可能与容器根层无关;如果 /tmp 未单独挂载,则它通常位于容器可写层对应的 overlay 视图中。
第二步:判断是容量还是 inode
df -h /var/lib/docker
df -i /var/lib/docker
两种典型结果:
容量 100%,inode 正常
=> 大文件、日志、镜像层、volume 或缓存占用
容量正常,inode 100%
=> 大量小文件、目录项、层元数据或缓存对象
两者都高
=> 同时存在大文件和小文件压力
第三步:按 Docker 对象分类
docker system df -v
docker ps -s
docker volume ls
确认是否主要来自:
容器 writable layer
镜像
Build Cache
volume
第四步:检查日志和删除但仍打开的文件
docker inspect -f '{{.LogPath}}' app
sudo lsof +L1
第五步:验证清理效果
清理前记录:
df -h /var/lib/docker
df -i /var/lib/docker
docker system df
执行经过确认的清理后,再次记录同样的指标:
df -h /var/lib/docker
df -i /var/lib/docker
docker system df
如果 Docker 对象统计下降,但 df 没有明显变化,继续检查:
- volume 是否占用空间;
- 日志文件是否仍在;
- 是否存在 deleted-but-open 文件;
- 是否统计了错误的挂载点;
- 文件系统是否有保留块、配额或快照。
20. 哪些数据不应放在容器 writable layer
容器可写层适合保存:
- 短生命周期的临时状态;
- 容器运行时少量配置变化;
- 不需要在容器删除后保留的中间结果。
不适合保存:
- 数据库数据文件;
- 用户上传文件;
- 大型索引;
- 大型构建产物;
- 需要独立备份和扩容的数据;
- 高频追加的日志;
- 会被原地修改的大文件。
原因不仅是持久化生命周期,还包括 OverlayFS 的 copy-up、inode 消耗和诊断复杂度。
例如,下面的容器把数据库目录放在 writable layer:
docker run -d --name db postgres:16
数据库启动后,所有数据写入容器可写层。容器重建、迁移、清理或误删除时,数据管理都与容器生命周期耦合。
更明确的方式是使用 volume:
docker volume create pgdata
docker run -d \
--name db \
-v pgdata:/var/lib/postgresql/data \
postgres:16
此时 PostgreSQL 数据目录位于 volume,而不是容器 upper。volume 仍然需要备份、容量监控和权限管理,但其生命周期与容器解耦。
21. 对 overlay2 的正确性能预期
OverlayFS 的价值主要来自:
- 镜像层共享;
- 容器启动时不需要完整复制镜像;
- 只在需要修改时进行 copy-up;
- 多个容器可以复用同一只读内容。
但这不等于所有 I/O 都与直接使用 ext4 目录完全相同。性能和语义可能受以下因素影响:
- lower 层数量;
- copy-up 文件大小;
- 目录遍历规模;
- 元数据修改频率;
- 文件系统 xattr 和 dentry 行为;
- 内核版本;
- backing filesystem;
- 应用是否频繁执行
chown、rename、fsync; - 数据是否实际位于 volume 或 bind mount。
对于数据库、搜索索引、消息队列等高 I/O 状态系统,通常应把数据目录放在经过验证的 volume 或主机存储上,并进行实际负载测试,而不是仅根据“容器能启动”判断存储方案合适。
22. 最容易混淆的几个结论
“镜像层不可变,所以容器中不能修改文件”
不准确。容器可以修改 merged 视图中的文件;修改结果保存到 upper 层,lower 层仍保持不变。
“删除大文件会释放空间”
只有删除 upper 中独有的文件,或者删除不再被任何对象引用的镜像层,才可能真正释放对应空间。删除 lower 中的文件通常只产生 whiteout。
“容器只写了 1 KB,所以只增加 1 KB”
不一定。第一次修改 lower 中的大文件可能触发完整 copy-up。
“df -h 还有空间,所以不会出现 no space left”
不一定。inode、配额或文件系统元数据可能已经耗尽。
“容器删除后,所有容器数据都会消失”
容器 writable layer 通常随容器删除,但 volume、bind mount、主机日志和外部存储不会因此自动删除。
“du /var/lib/docker/overlay2 就是 Docker 镜像占用”
不准确。该目录可能包含不同层、容器可写层和驱动内部数据;共享层、引用关系和其他 Docker 对象会使目录大小与 Docker 逻辑统计不同。
“OverlayFS 提供了快照事务”
不准确。它提供的是层叠和 copy-up 机制,不提供应用级事务、一致性提交或数据库恢复语义。
overlay2 的核心可以归纳为一个状态变化:
镜像 lower 层中的对象
│
│ 容器读取
▼
仍然共享 lower
│
│ 容器首次修改、改元数据或执行相关目录操作
▼
copy-up 到 upper
│
│ 容器删除 lower 对象
▼
upper 中写入 whiteout,隐藏 lower 对象
而磁盘诊断必须同时沿着两条轴进行:
对象轴:
镜像层 / 容器 writable layer / Build Cache / Volume / 日志
资源轴:
数据块 / inode / 配额 / 打开文件 / 文件系统元数据
只有把 OverlayFS 的可见性规则、Copy-on-Write 的 copy-up、whiteout 删除语义,以及 Linux 的块空间和 inode 机制放在一起,才能解释“镜像不大但容器变大”“删除后空间不回收”和“df 还有空间却无法创建文件”等看似矛盾的现象。
系列导航与关联阅读
- 系列入口:Docker 完整学习路线:从镜像与容器到安全、可观测和生产交付
- 上一篇:Docker 运行时隔离:namespaces、cgroups、Mount、PID 和网络
- 下一篇:Docker Volume 权限:UID/GID、umask、Rootless、SELinux 和共享
- 延伸:Docker 镜像与分层:内容寻址、OverlayFS、缓存和镜像体积
- 延伸:Docker 磁盘治理:Layer、Build Cache、Volume、日志和安全清理
官方资料
本文依据 Docker、OCI 与 CNCF 官方文档重新梳理;正文与生产检查清单由 WR BLOG 编写。

评论
0 条讨论