Docker 基础体系 · 第 43/70 篇。示例以现代 Docker Engine、BuildKit 与 Compose 规范为基础,并明确 Linux 容器边界。

Docker Storage Driver:overlay2、Copy-on-Write、Inode 和磁盘诊断

在 Linux 上,Docker 容器看到的根文件系统通常不是一个单独目录,而是由镜像只读层、容器可写层以及 OverlayFS 联合挂载形成的视图。overlay2 是 Docker Engine 在现代 Linux 主机上最常见的存储驱动,它决定了镜像层如何组合、容器文件如何写入,以及删除和修改文件时磁盘空间如何变化。

理解 overlay2 需要同时区分四个对象:

  1. 镜像层(image layer):不可变、只读,通常由镜像构建产生。
  2. 容器可写层(container writable layer):某个容器独有,容器删除后通常随之删除。
  3. 卷和绑定挂载(volume / bind mount):绕过容器可写层,独立管理数据。
  4. 主机文件系统资源:块空间、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 通常遵循“上层优先”的可见性规则:

  1. 如果 upperdir 中存在该路径,使用 upperdir 的对象;
  2. 否则从最上面的有效 lowerdir 查找;
  3. 如果任何层都没有该路径,则返回不存在。

假设镜像层结构如下:

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

可以分解为以下步骤:

  1. 进程通过容器的 merged 视图打开 /etc/app.conf
  2. OverlayFS 发现目标文件原本位于只读 lower 层;
  3. 为了允许写入,OverlayFS 将 lower 层文件复制到 upper 层;
  4. upper 层中的副本保留原文件内容;
  5. printf 将新内容写入 upper 层副本;
  6. 之后从容器视图读取 /etc/app.conf 时,命中 upper 层;
  7. lower 层中的原文件仍未改变,其他容器也不会看到该修改。

可以用一个简化的空间模型表示:

设:

  • S_l:lower 层中的原始文件大小;
  • S_u:copy-up 后 upper 层副本的大小;
  • Δ:应用实际追加的数据大小。

在普通完整 copy-up 语义下,第一次修改产生的额外空间近似为:

ΔSfirst writeSuSl+Δ\Delta S_{\text{first write}} \approx S_u \approx S_l + \Delta

而不是仅仅增加 Δ

例如:

镜像中的 /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 层原始文件仍然保留。这样做有两个重要后果:

  1. 删除镜像中的大文件,不会自动释放该镜像层占用的空间;
  2. 如果把容器文件系统导出或提交为新镜像,删除动作需要通过 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 层中的 abc,就需要表达该目录对 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 会先于容量耗尽

磁盘空间通常至少有两种独立资源:

  1. 数据块空间(blocks):保存文件内容;
  2. 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 为:

Ifree=ItotalIused=10,000,0009,900,000=100,000I_{\text{free}} = I_{\text{total}} - I_{\text{used}} = 10{,}000{,}000 - 9{,}900{,}000 = 100{,}000

如果某个构建步骤产生 200,000 个小文件,即使这些文件总大小只有 1 GiB,也可能因为 inode 不足失败。

典型来源包括:

node_modules/
Python 虚拟环境
包管理器缓存
编译中间文件
测试产生的临时文件
容器日志轮转文件
大量短生命周期容器的可写层元数据

因此,磁盘诊断必须同时执行 df -hdf -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 dfdu 可能对不上

不同工具统计的对象不同:

  • 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

表示目录链接已删除,但进程仍打开文件。处理步骤通常是:

  1. 确认哪个进程持有文件;
  2. 判断是否为日志、临时文件或数据库文件;
  3. 让应用重新打开文件,通常通过优雅重启或日志 reopen;
  4. 观察 df -h 是否释放空间。

不要盲目终止数据库或核心服务。对数据库文件执行强制重启可能造成恢复过程、额外 I/O 甚至数据损坏。


14. dfdu 和容器内 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 文件执行了 chownchmod
  • 应用对镜像中的数据库文件进行原地更新;
  • 重命名或替换了包含大量内容的目录。

验证方式:

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、OCI 与 CNCF 官方文档重新梳理;正文与生产检查清单由 WR BLOG 编写。