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。
每次比较两者时,都要注意以下两个选项。
-x让du只停留在一个文件系统中。不使用该选项时,du /会进入挂载在/下的所有文件系统,并生成一个df /从未统计的总数。-s为每个参数输出一行汇总信息,而不是为每个目录输出一行。
因此,在目标文件系统上,可以并排运行以下两个命令。
df -h /
sudo du -xhs / 2>/dev/nulldf 会立即返回结果。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 会打开文件并将文件描述符交给 sleep,由它保持文件打开。$! 保存该后台作业的进程 ID。随后,rm 会在文件描述符仍处于打开状态时删除文件名。
查看输出。ls 找不到该文件,因为文件名已不存在。du 已恢复到接近初始值,因为它会遍历文件名。df 没有变化,因为数据块仍已分配。此时,文件系统与目录树的状态不一致,两者之间的差值就是你刚刚删除的文件。
查找占用已删除文件的进程
每个打开的文件描述符都会出现在 /proc/<pid>/fd/ 下,并作为指向其所引用文件的符号链接。文件解除链接后,内核会将该链接的目标标记为 deleted。因此,查找占用者就是查找目标带有该标记的链接。
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 | headstat -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 的大小和已分配块数。二者结合即可回答关键问题:哪个服务仍在保持该文件处于打开状态。
如果系统已经安装 lsof,sudo lsof +L1会列出链接计数已降为 0 的打开文件,并在同一张表中显示其大小。最小化 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该命令会将信号发送给 systemd 记录为该单元主进程的进程。因此,如果单元为守护进程的实际启动方式声明了错误的 Type=,信号可能会发送给从未持有该已删除文件的进程,空间也就不会被释放。
第四,重启该单元。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 时已经固定。要提高该数量,必须重新创建文件系统,然后从备份恢复。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 显示的是一个空目录。复制操作并没有把文件复制到其他位置:文件仍位于根文件系统中,卸载后就会再次出现。现在设想某个服务在该路径下写入日志一个月,之后才有人在该路径上挂载卷。
要在运行中的服务器上查找实际内容,可以将根文件系统再次挂载到其他位置。bind mount 会显示其中一个文件系统,但不会显示挂载在该文件系统内部的其他文件系统。
sudo mkdir -p /mnt/rootcheck
sudo mount --bind / /mnt/rootcheck
sudo du -xhs /mnt/rootcheck/* 2>/dev/null | sort -h
sudo umount /mnt/rootcheck列表中出现、但在常规路径下看不到的内容,都隐藏在某个挂载点下。完成后卸载 bind mount,否则之后不带 -x 的 du 会将同一批文件计算两次。
为 root 保留的块
ext4 会为 root 用户保留一部分块,这样磁盘写满时,root 仍可登录并修复系统。以普通用户身份运行的进程会先遇到这个限制,而 df 仍会显示少量剩余空间。请在实际使用的文件系统上读取该设置,不要直接假定使用默认值。
dev=$(df --output=source / | tail -n 1)
sudo tune2fs -l "$dev" | grep -i 'block count'该命令会以相同单位打印块总数和保留块数,因此可以直接计算两者的比例。df 将 available 列显示为普通用户仍可使用的空间,所以已用空间加上可用空间会小于文件系统大小。两者之间的差值就是保留空间。
使用 sudo tune2fs -m <percent> "$dev" 修改该设置。修改会立即生效,无需重新挂载。在独立的数据文件系统上减少保留空间通常是合理的。对于根文件系统,应保留足够空间,以便 root 仍可写入数据,因为完全没有可用空间的根文件系统更难修复。保留空间也能防止您被锁在系统之外:如果文件系统已没有剩余空间,追加到 authorized_keys 的密钥可能只写入一部分,或者完全无法写入;下一次登录就会返回 公钥认证被拒绝,原因与密钥本身无关。tune2fs 适用于 ext2、ext3 和 ext4。XFS 没有等效设置。
du 单独使用时会误导你的情况
du 的 4 个行为会产生看似错误的总量。
- 硬链接:即使多个名称指向同一个 inode,
du也只会统计一次。因此,充满硬链接的目录树显示的大小会小于其文件大小之和。 - 稀疏文件:
du报告实际分配的块,而ls -l报告表观大小。添加--apparent-size即可查看另一个数值。 - 权限:以普通用户身份运行时,
du会跳过无法读取的内容,导致统计值偏小。它输出的错误通常会被重定向到/dev/null,之后不再查看。 - 文件系统边界:不使用
-x时,du /会统计挂载在/下方的所有文件系统,因此其总量可能超过df /报告的数值。
df 也有一个值得了解的行为。它会分别报告每个文件系统,因此应针对发生写入失败的确切路径运行它。单独的 /boot 会按照自身的节奏逐渐占满,例如内核软件包不断累积;在 Ubuntu 上删除旧内核 与清理 / 空间是两项不同的工作。
实际事故的处理顺序
- 在发生写入失败的目标文件系统上运行
df -h <path>和df -i <path>,不要习惯性地在/上运行。 - 运行
sudo du -xh -d 1 <mountpoint> 2>/dev/null | sort -h,然后逐级进入占用空间最大的目录。 - 如果
du无法解释df报告的已用空间,请在/proc中查找仍处于打开状态的已删除文件。 - 如果两者结果一致,请将该文件系统绑定挂载到其他位置,然后查找挂载点下的文件。
- 如果达到上限的是 inode 使用量,请统计文件数量,而不是字节数。
上述每一步都有命令可执行,并且可以直接读取其输出。这是解决问题与凭猜测排查之间的区别。
FAQ
为什么 df 显示磁盘已满,而 du 统计的空间少得多?
通常是因为某个文件已被删除,但某个进程仍保持该文件处于打开状态。删除文件会移除其目录项,因此 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 指定相同路径,确保两个命令描述的是同一对象。