SSD Nodes Learn 🎉 VPS $5.50/月起
指南 Matt Connor作者: Matt Connor

df显示磁盘已满但du找不到空间怎么办

df显示磁盘已满但du找不到占用空间?常见原因是进程仍打开已删除文件。本文教您用lsof定位并释放空间,也检查inode、挂载点和root保留块。

df 显示磁盘已满,而 du 显示的结果不同

df 报告磁盘已满,但 du 找不到占用的空间,因为某个进程仍持有一个已删除的文件。删除文件时,只会从目录中移除文件名。只有指向该 inode 的最后一个打开文件描述符关闭后,数据块才会释放。du 遍历文件名,因此不会统计这个文件。df 会向文件系统查询已分配的块数量,因此仍会统计这个已经没有文件名的文件。

本指南将在一台安装了现有工具的普通 Ubuntu VPS 上复现这一现象,通过 /proc 找到持有文件的进程,并在不重启的情况下释放空间。随后还会介绍导致相同现象的其他原因:inode 表没有可用条目、文件隐藏在挂载点下,以及为 root 保留的块。

请逐条运行命令,并查看您自己的输出。具体数值取决于您的磁盘,因此请在自己的机器上比较操作前后的结果,不要与指南中打印的数值比较。

df 统计什么,du 统计什么

df(disk free)向每个已挂载文件系统查询其自身的统计信息:块总数、已分配块数和空闲块数。它不会打开目录。统计结果包括所有已分配的块,包括属于某个文件但没有任何目录项指向的块。

du(disk usage)则相反。它从指定路径开始读取目录,获取找到的每个目录项的状态信息,然后累加这些块。没有文件名的文件对它不可见。它也无法统计不允许读取的目录,因此普通用户得到的总数会小于 root 得到的总数。在根据比较结果下结论前,请在 sudo 下运行 du

每次比较两者时,都要注意以下两个选项。

  • -xdu 限制在同一个文件系统中。如果不使用该选项,du / 会进入挂载在 / 下的每个文件系统,并生成一个 df / 从未统计过的总数。
  • -s 为每个参数输出一行汇总信息,而不是为每个目录输出一行。

因此,在需要检查的文件系统上,可以并排运行以下命令。

df -h /
sudo du -xhs / 2>/dev/null

df 会立即返回结果。du 在大型文件系统上可能需要数分钟,因为它必须沿途获取每个文件的状态信息。当两个总数相差很大,且 du 以 root 身份并使用 -x 运行时,缺失的空间就分配给了某个没有名称的对象。

有意重现不一致

请在测试 VPS 上执行。以下命令只使用 bash 和 coreutils,因此不会安装任何内容。

记录保存 /var/tmp 的文件系统的初始状态。

cd /var/tmp
df -h .
df --output=used -B1 .

第二条命令会输出未经四舍五入的已用字节数,因此最后的检查结果是精确的。

现在创建一个文件。文件大小取自计算机报告的可用空间,因此无论磁盘容量是多少,都可以完成此演示。

free=$(df --output=avail -B1 . | tail -n 1)
fallocate -l $((free / 10)) ghost.bin
ls -l ghost.bin
df -h .

$(...) 是命令替换:shell 会运行其中的命令,并将输出作为 free 的值。如果您不熟悉这种语法,请参阅 bash 中的命令替换fallocate 会预留实际数据块,但不写入数据,因此可以立即完成。在不支持该功能的文件系统上,此命令会失败;head -c $((free / 10)) /dev/zero > ghost.bin 则通过写入字节执行相同操作。

将此 df -h . 与刚才记录的结果比较。已用列增加了,可用列减少了。

现在让另一个进程保持文件打开,然后删除该文件。

sleep infinity < ghost.bin &
holder=$!
rm ghost.bin
ls -l ghost.bin
df -h .
sudo du -xhs . 2>/dev/null

重定向是关键。sleep infinity < ghost.bin & 会启动一个后台进程,并将该文件作为其标准输入,因此 shell 会打开文件,并将文件描述符传递给 sleepsleep 会保持该文件处于打开状态。$! 保存该后台任务的进程 ID。随后,rm 会在文件描述符仍处于打开状态时删除文件名。

读取输出。ls 找不到该文件,因为文件名已经不存在。du 已回到接近初始值的位置,因为它会遍历文件名。df 没有变化,因为数据块仍处于分配状态。此时,文件系统与目录树的记录不一致,两者之间的差异就是刚才删除的文件。

查找仍占用已删除文件的进程

每个打开的文件描述符都会显示在 /proc/<pid>/fd/ 下,并作为指向其引用文件的符号链接。文件解除链接后,内核会将该链接的目标标记为已删除。因此,查找占用者就是查找目标带有该标记的链接。

sudo find /proc/[0-9]*/fd -lname '*(deleted)' -printf '%p -> %l\n' 2>/dev/null

-lname 匹配符号链接的目标,而不是链接名称;%p 输出描述符路径,%l 输出链接指向的内容。进程 ID 是该路径输出中的第二个元素。使用 sudo 运行,否则只能读取自己进程的 /proc/<pid>/fd。stderr 重定向会丢弃 find 遍历期间因进程退出而产生的错误信息。

繁忙的服务器随时可能持有多个已删除文件,其中大多数文件很小且不会造成影响。按大小排序后,较大的文件会排在最前面,便于筛选出需要关注的文件。

sudo bash -c 'for fd in /proc/[0-9]*/fd/*; do
  target=$(readlink "$fd" 2>/dev/null) || continue
  case "$target" in
    *"(deleted)") echo "$(stat -Lc %s "$fd" 2>/dev/null) $fd $target" ;;
  esac
done' | sort -rn | head

stat -L 会跟随链接并访问 inode 本身,因此 %s 报告的是已不再具有文件名的文件大小。按该数字排序后,最大的文件会排在第一位。

接下来确定占用该描述符的进程。列表顶部的路径包含所需的两个数字,因此先将它们保存到变量中,将 PID 和 N 替换为列表中实际输出的值。

pid=PID
n=N
ps -o pid,user,etime,args -p "$pid"
sudo stat -L "/proc/$pid/fd/$n"

ps 显示程序名称及其已运行时间。stat -L 显示已删除 inode 的大小和已分配块数。两者结合即可回答关键问题:哪个服务仍在保持该文件处于打开状态。

如果系统已经安装 lsofsudo lsof +L1 会列出链接计数已降为零的打开文件,并在同一张表中显示文件大小。精简版 Ubuntu 映像通常未预装该工具,而在没有可用空间的文件系统上安装软件包本身也可能失败,因此 /proc 遍历方式始终可用。

无需重启即可释放空间

重启确实可以解决问题,但不应作为首选操作:它会使服务中断,还会破坏证据。更稳妥的方案有4种,应按以下顺序尝试。

如果仍然需要这些数据,先将其复制出来。读取描述符路径就会读取实时 inode。

sudo cp /proc/<pid>/fd/<n> /root/recovered.log

这是少数可以轻松恢复已删除文件的情况。因此,恢复使用 rm -rf 删除的文件时,首先要确认某个进程是否仍然保持文件打开。最后一个描述符关闭后,这条恢复路径就不存在了。

第二种方法是通过描述符清空文件。/proc路径指向同一个 inode,因此截断该文件可以释放数据块,同时保持进程继续运行。

sudo truncate -s 0 "/proc/$pid/fd/$n"
df -h /

如果写入进程以追加模式打开文件,这种方法可以正常工作,因为之后的每次写入都会写入文件当前末尾。如果不是追加模式,进程会保留原来的写入偏移量。这样,下一次写入会落在文件很靠后的位置,并在文件开头形成空洞。空洞不会分配磁盘空间,因此这些数据块仍保持空闲状态,df会继续显示刚刚释放的空间。变化的只是文件大小:进程写入后再次运行sudo stat -L "/proc/$pid/fd/$n",它会报告原来的文件大小,但对应的数据块数量已不再匹配。若还希望文件大小从0开始,请重启进程。

第三种方法是让服务重新打开日志。日志文件被删除后,守护进程仍保持文件打开,这是该问题最常见的实际情况。许多守护进程会在收到信号后重新打开日志文件:nginx 使用 SIGUSR1,rsyslog 使用 SIGHUP。请查阅当前守护进程的文档,不要凭猜测发送信号,因为向错误的守护进程发送错误的信号可能会使其停止。

sudo systemctl kill -s USR1 nginx

第四种方法是重启该单元。sudo systemctl restart <unit>会关闭旧进程持有的所有描述符,因此可以确定数据块会被释放。对于上面的演示,持有描述符的是您自行启动的sleep,结束它即可。

kill $holder
df -h .
df --output=used -B1 .
sudo find /proc/[0-9]*/fd -lname '*(deleted)' -printf '%p -> %l\n' 2>/dev/null

将已用字节数与创建文件前记录的数值进行比较。两者再次一致,find也不再报告您的描述符。使用发现问题的同一条命令进行验证,是值得保留的习惯。

反复手动运行df不如观察该数值的变化方便。watch 按固定间隔重复运行命令并在原位置重新输出结果,因此watch df -h /会显示已用空间列的变化,直到空间重新释放。

当总量一致但磁盘仍然已满

如果 df 与 root du -x 的结果一致,则没有涉及已删除文件。剩余原因各不相同,分别需要进行相应检查。

inode 耗尽,而不是块耗尽

inode 保存一个文件的元数据。创建 ext4 文件系统时,系统会为其创建固定数量的 inode。因此,即使文件系统仍有空闲块,也可能耗尽 inode。此时新文件会创建失败,即使 df -h 显示仍有空间。

df -h /
df -i /

第一个命令统计块,第二个命令统计 inode。比较两者的 use 列。如果块使用率较低,而 inode 使用率已达到上限,说明问题是存在数量极多的小文件。

df 在同一次调用中拒绝 -i--output。因此,如果需要读取原始计数,或将其传递给其他命令,请按名称选择 inode 字段,并省略 -i

df --output=itotal,iused,iavail,ipcent /

这些列包含 df -i 输出的相同计数信息,但格式便于进一步拆分处理。

通过统计条目数量而不是字节数来查找文件。

sudo du --inodes -x -d 1 / 2>/dev/null | sort -rn | head

在排名最高的目录上向下递归一层,重复运行同一命令,直到找到创建这些文件的目录树。如果您的 du 不支持 --inodes,则使用 sudo find /var -xdev -type f | wc -l 以较慢的方式统计子树。

解决方法是删除或移动这些文件。无法向现有 ext4 文件系统添加 inode,因为 inode 数量在 mkfs 时已经固定。要提高 inode 数量,必须重新创建文件系统,然后从备份恢复。XFS 会根据需要分配 inode,因此不会以相同方式受到固定上限的限制。运行容器的机器通常会更早达到这两种上限,因为镜像层包含大量小文件。在该机器上,清理 VPS 上 Docker 的磁盘占用是针对性解决方法,其释放的空间远多于对整个文件系统执行常规清理。

挂载点下隐藏的空间

目录在挂载任何内容之前就可以存放文件。将文件系统挂载到该目录后,下面的文件会原样保留:仍然占用空间,仍会被 df 统计,但不再能通过名称访问。du 看不到这些文件,因为挂载点将它们遮住了。

使用 tmpfs 可以演示这一点,因为它不需要额外磁盘空间。本部分需要具备挂载权限的机器,因此可在 KVM VPS 上运行。

sudo mkdir -p /srv/covered
sudo cp /etc/services /srv/covered/
ls /srv/covered
sudo mount -t tmpfs tmpfs /srv/covered
ls /srv/covered
sudo umount /srv/covered
ls /srv/covered

中间的 ls 显示为空目录。复制操作并没有将文件复制到其他位置:文件仍在根文件系统中,卸载后就会重新出现。设想某个服务在有人将卷挂载到该路径之前,已经持续一个月向该路径写入日志。

要在运行中的服务器上找到实际内容,可以将根文件系统再次挂载到其他位置。绑定挂载可以显示某个文件系统本身,但不会显示其中挂载的文件系统。

sudo mkdir -p /mnt/rootcheck
sudo mount --bind / /mnt/rootcheck
sudo du -xhs /mnt/rootcheck/* 2>/dev/null | sort -h
sudo umount /mnt/rootcheck

列表中出现、但在正常路径下看不到的内容,都隐藏在某个挂载点下面。完成后卸载绑定挂载,否则之后不带 -x 执行 du 时,会将同一批文件统计两次。

为 root 预留的块

ext4 会为 root 用户预留一部分块,避免磁盘写满后 root 无法登录并修复系统。以普通用户身份运行的进程会先遇到这个限制,而 df 仍会显示少量可用空间。请在您自己的文件系统上读取该设置,不要直接假定使用默认值。

dev=$(df --output=source / | tail -n 1)
sudo tune2fs -l "$dev" | grep -i 'block count'

该命令会以相同单位输出块总数和预留块数,因此可以直接计算两者的比例。df 将 available 列报告为普通用户仍可使用的空间,所以 used 加 available 会小于总容量。两者之间的差值就是预留空间。

使用 sudo tune2fs -m <percent> "$dev" 修改该设置。修改会立即生效,无需重新挂载。在独立的数据文件系统上降低预留比例通常是合理的。对于根文件系统,应保留足够的空间供 root 写入,因为根文件系统完全没有可用空间时会更难修复。tune2fs 适用于 ext2、ext3 和 ext4。XFS 没有对应的设置。

du 单独使用时容易误导的情况

du 的以下四种行为会产生看似错误的总计。

  • 硬链接:即使多个名称指向同一个 inode,du 也只计算一次。因此,充满硬链接的目录树报告的大小会小于其中各文件大小之和。
  • 稀疏文件:du 报告实际分配的块,而 ls -l 报告表观大小。添加 --apparent-size 可查看另一个数值。
  • 权限:以普通用户身份运行时,du 会跳过无法读取的内容,因此报告的大小偏小。它输出的错误通常会被重定向到 /dev/null,然后被忽略。
  • 文件系统边界:不使用 -x 时,du / 会计算挂载在 / 下的所有文件系统,因此其总计可能超过 df / 报告的结果。

df 也有一个值得了解的行为。它会分别报告每个文件系统,因此应针对发生写入失败的确切路径运行它。单独的 /boot 会按照自己的计划逐渐填满,因为内核软件包会不断累积;在 Ubuntu 中删除旧内核 与清理 / 上的空间是两项不同的工作。

真实事故的处理顺序

  1. 在失败写入所针对的文件系统上运行 df -h <path>df -i <path>,不要习惯性地在 / 上运行。
  2. 运行 sudo du -xh -d 1 <mountpoint> 2>/dev/null | sort -h,然后逐级进入占用空间最大的目录。
  3. 如果 du 无法解释 df 报告的已用空间,请在 /proc 中搜索仍处于打开状态的已删除文件。
  4. 如果两者结果一致,请将该文件系统绑定挂载到其他位置,然后查找挂载点下的文件。
  5. 如果达到限制的是 inode 使用量,请统计文件数量,而不是字节数。

其中每一步都会运行一个命令,并且可以读取其输出。这正是解决问题与凭猜测处理之间的区别。

FAQ

当 du 统计的空间少得多时,为什么 df 显示磁盘已满?

通常是因为某个文件已被删除,但仍有进程保持该文件打开。删除文件会移除其目录项,因此 du 找不到可遍历的文件名,也就不再统计它。该文件的 inode 及其数据块仍会保持分配状态,直到最后一个文件描述符关闭;而 df 统计的是已分配的数据块。在 /proc/<pid>/fd 中搜索目标标记为 deleted 的符号链接,即可同时找到该文件和持有它的进程。在相信两者的统计差异之前,确认您以 root 身份运行了 du,并使用了 -x,因为普通用户会静默跳过无权读取的目录。

不使用 lsof,如何查找仍处于打开状态的已删除文件?

使用内核维护的打开文件描述符记录。sudo find /proc/[0-9]*/fd -lname '*(deleted)' -printf '%p -> %l\n' 2>/dev/null 会列出指向无名文件的所有文件描述符,进程 ID 位于它输出的路径中。对其中一个描述符路径运行 sudo stat -Lc %s 可显示其大小,您可以据此排序并找出需要处理的文件。此方法完全不需要安装软件包。这一点很重要,因为在没有可用空间的文件系统上安装软件包可能会失败。

不终止进程,能否释放空间?

有时可以。sudo truncate -s 0 /proc/<pid>/fd/<n> 会通过文件描述符访问同一个 inode,并在进程继续运行的同时释放其数据块。如果进程以追加模式打开文件,这种方式最为安全,因为写入始终发生在当前文件末尾。如果进程不是以追加模式打开文件,写入偏移量会保留在原位置。下一次写入会在文件开头形成空洞并重新创建文件,因此报告的文件大小会恢复,而空洞对应的数据块仍保持空闲。重启该单元,或根据其文档指定的信号通知它重新打开日志,是不会留下稀疏文件的解决方法。

df 显示有可用空间,但写入仍然失败。还可能是什么原因?

在相同路径上使用 df -i 检查 inode,因为有可用数据块但没有可用 inode 的文件系统会拒绝创建新文件。检查写入是否由非 root 用户执行,并且目标是 ext4 文件系统,同时仅剩预留数据块;在设备上运行 sudo tune2fs -l 即可查看这一情况。确认您检查的是写入实际指向的文件系统,因为独立的 /boot/var 会分别耗尽空间,与 / 无关。

为什么 du 报告的总空间比 df 大?

不使用 -x 时,du 会进入指定路径下挂载的所有文件系统,因此它会合计多个文件系统,而 df 只描述其中一个文件系统。绑定挂载会使问题更加明显,因为同一批文件会按其出现的每个路径分别统计。添加 -x,让 du 只停留在单个文件系统中;同时为 df 指定相同路径,确保两个命令描述的是同一对象。