返回首页

幽灵文件排查 — 删了 200GB 日志磁盘却不释放

📅 创建于 2026-09-02 🔄 更新于 2026-09-05 📝 2000 字

幽灵文件排查 — 删了 200GB 日志磁盘却不释放

一句话结论: rm 解除的是名字,FD 保留的是对象。只要还有进程持有这个 inode,数据块就不回收。这个没有路径名、仍被进程占着的文件,就是「幽灵文件」(deleted but open)。可靠处理依赖完整证据链:dfdu 建立差异 → lsof//proc 找到持有者 → 设备号 + inode 锁定对象 → 优先 reopen / 优雅重启 → 万不得已才截断 → 最后从轮转、容量限制、监控上消除复发条件。

值班时最让人困惑的磁盘告警之一:du 看到目录已经变小,df 却仍显示文件系统接近 100%。

在 Linux 中,目录项只是文件名到 inode 的引用。进程通过文件描述符(FD)打开文件后,内核维护的打开文件对象仍指向 inode。rm 删除的是目录项并减少链接计数;只要还有进程持有这个 inode,数据块就不能回收。

这不是文件系统出错,而是 Unix 文件语义保证进程已有的 FD 不会因别人删文件而失效

但反过来说,dfdu 不一致不只有这一种原因:挂载点覆盖下层目录、ext4 保留块、容器 overlay、快照、稀疏文件、硬链接、文件系统元数据,都可能造成类似表象。排障必须先确认对象,再释放空间——不能一看到 (deleted) 就重启整机。

先止住增长,保留现场

磁盘接近写满时,最危险的动作是继续运行会产生大量输出的扫描、压缩或复制命令——它们可能成为压垮剩余空间的最后一根稻草。先确认告警针对哪个挂载点、剩余多少块和 inode,以及日志写入速度是否仍在上升。

date --iso-8601=seconds
df -hT
df -ih
findmnt -o TARGET,SOURCE,FSTYPE,OPTIONS
sudo du -xhd1 /var 2>/dev/null | sort -h
sudo du -xhd1 /var/log 2>/dev/null | sort -h
sudo find /var/log -xdev -type f -size +1G -printf '%s %p\n' 2>/dev/null |
  sort -nr | numfmt --field=1 --to=iec | head -n 30
  • df -hT数据块df -iinode。inode 满时即使还有容量也不能创建文件,两类故障处理方向完全不同。
  • findmnt 用来确认 /var/log 是独立挂载、LVM、网络盘还是容器存储,避免在错误设备上操作。
  • 后三条只在目标文件系统内统计,-x 防止跨越其他挂载点;若根分区已满,不要把错误输出再落到本地文件

此时不要再次删除未知文件。先降低非关键日志源的级别、临时摘除故障节点流量,或把产生洪水的批任务暂停。暂停动作会影响业务,必须由服务负责人确认;不能为了保磁盘而停止数据库、审计或安全日志。

对 systemd journal,先只读查看占用与时间范围。

journalctl --disk-usage
journalctl --list-boots
journalctl --since '-30 min' -p warning --no-pager | tail -n 100
journalctl --since '-15 min' -o json --no-pager |
  jq -r '._SYSTEMD_UNIT // .SYSLOG_IDENTIFIER // "unknown"' |
  sort | uniq -c | sort -nr | head -n 20

如果是日志洪水,先定位源单元,而不是先 vacuum。最后一条按最近 15 分钟的 journal 行数粗略排序;一行并不等于一个字节,但能快速发现异常服务。journalctl 的日常用法见 journalctl-log-tracking-guide

用 df 与 du 的差值建立假设

df 向文件系统查询已用块;du 遍历可见目录项并累计文件占用。若同一挂载点df 明显大于 du,说明部分已用块不再能通过当前目录树正常遍历——已删除但仍打开的文件是首要嫌疑。

下面的脚本计算同一挂载点的近似差值。执行 du 可能给繁忙存储带来读取压力,生产高峰应限制优先级并只扫目标挂载。

#!/usr/bin/env bash
set -euo pipefail

mountpoint=${1:-/var}
df_used=$(df -B1 --output=used "$mountpoint" | awk 'NR==2{print $1}')
du_used=$(nice -n 19 ionice -c3 du -x -s -B1 "$mountpoint" 2>/dev/null | awk '{print $1}')
gap=$((df_used-du_used))
printf 'mount=%s df_used=%s du_visible=%s gap=%s\n' \
  "$mountpoint" "$df_used" "$du_used" "$gap"
numfmt --to=iec "$df_used" "$du_used" "$gap"

差值不是严格等式df 会计入文件系统元数据和保留空间,du 对硬链接、稀疏文件、权限失败及并发变化的表现不同。只有差值达到数十、数百 GB 且存在对应的 deleted FD,证据链才完整。

同时确认告警读的是宿主机还是容器内部文件系统。可用 readlink /proc/1/ns/mnt 获取宿主视图标识,再对目标进程执行 readlink /proc/PID/ns/mnt;链接目标不同说明两者处于不同挂载命名空间。最后用 findmnt -T /var/log 确认路径落在哪个挂载上。

直接找出已删除但仍打开的文件

有 lsof 时:+L1 是最快的入口

+L1 表示链接数小于 1,即目录项已删除但仍有进程打开。-nP 避免 DNS 和服务名解析,让事故现场查询更快、更确定。

sudo lsof -nP +L1
sudo lsof -nP +L1 -F pcfnst 2>/dev/null |
awk '
  /^p/{pid=substr($0,2)} /^c/{cmd=substr($0,2)} /^f/{fd=substr($0,2)}
  /^s/{size=substr($0,2)} /^n/{name=substr($0,2); if(size+0>0) print size,pid,cmd,fd,name}
' | sort -nr | head -n 30 | numfmt --field=1 --to=iec
  • 第一条适合快速浏览,输出中的 COMMANDPIDFDTYPESIZE/OFFNAME 最关键。FD 可能显示 4w,表示以写方式打开;NAME 末尾通常有 (deleted)
  • 第二条使用字段模式 -F 按大小排序,避免靠肉眼找最大的对象。
  • lsof 的字段在不同版本和文件类型上可能缺失,脚本把 size 当作近似筛选。对日志这种普通文件通常足够;不要把 socket、管道或内存映射的 SIZE/OFF 都解释为可释放磁盘空间

没有 lsof 时:从 /proc 找符号链接

这个操作遍历进程 FD,容器多、进程多的节点上也有成本,但不会修改系统。

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

要获得实际块占用,不能只信文件逻辑大小。stat%s 是逻辑字节,%b × 512 是分配块字节;稀疏文件两者可能差很多。

pid=1234
fd=7
sudo stat -Lc 'path=%n size=%s blocks=%b block_unit=%B inode=%i links=%h' "/proc/$pid/fd/$fd"
sudo readlink "/proc/$pid/fd/$fd"

stat -L 跟随 FD 链接,即使原路径已删除也能访问 inode。示例中的 PID/FD 必须替换为现场值;进程退出或 FD 被复用后路径会失效,所以操作前要再次核对 inode、进程启动时间和服务归属。

pid=1234
fd=7
sudo stat -Lc 'device=%d inode=%i links=%h bytes=%s blocks=%b' "/proc/$pid/fd/$fd"
sudo ls -l "/proc/$pid/fd/$fd"
ps -p "$pid" -o pid,ppid,lstart,etime,user,cmd

完整巡检脚本(不读文件内容)

下面的脚本用 /proc 输出普通已删除 FD 的 PID、FD、分配字节、逻辑字节、命令和链接目标。它不读取文件内容,可安全用于事故现场。

#!/usr/bin/env bash
set -u

printf '%-8s %-8s %-14s %-14s %-20s %s\n' PID FD ALLOCATED LOGICAL COMMAND TARGET
for link in /proc/[0-9]*/fd/*; do
  target=$(readlink "$link" 2>/dev/null) || continue
  [[ $target == *' (deleted)' ]] || continue
  pid=${link#/proc/}; pid=${pid%%/*}; fd=${link##*/}
  type=$(stat -Lc '%F' "$link" 2>/dev/null) || continue
  [[ $type == 'regular file' ]] || continue
  read -r logical blocks block_size < <(stat -Lc '%s %b %B' "$link")
  allocated=$((blocks*block_size))
  command=$(tr -d '\n' < "/proc/$pid/comm" 2>/dev/null)
  printf '%-8s %-8s %-14s %-14s %-20s %s\n' \
    "$pid" "$fd" "$allocated" "$logical" "$command" "$target"
done | sort -k3,3nr

三个必须知道的边界:

  1. 若脚本未以 root 运行,受 hidepid、Yama 或容器权限限制会漏报漏报不能证明不存在幽灵文件。
  2. 同一个打开文件可能被多个 FD 或 fork 后的多个进程共享,简单相加会重复计算;最终以 inode、设备号和文件系统 df 变化验证。
  3. 输出只覆盖 regular file,mmap 场景见下节。

为什么删除日志后进程还在写

典型链路是:

  1. 应用启动时 open("app.log", O_APPEND),获得 FD 7;
  2. 轮转脚本直接 mvrm
  3. 应用没有收到 reopen 信号,也没有按 inode 变化重新打开;
  4. 之后每次 write(7, ...) 都继续写旧 inode

此时目录里新建的同名 app.log 与 FD 7 已经是两个文件

对比路径和 FD 的 inode 可以证实这一点。

pid=1234
fd=7
sudo stat -Lc 'fd inode=%i device=%d size=%s links=%h' "/proc/$pid/fd/$fd"
sudo stat -Lc 'path inode=%i device=%d size=%s links=%h' /var/log/myapp/app.log

判定标准:

观察 结论
FD 的 links=0 且 inode 不同 确认是删除后继续写
inode 相同 可能只是 lsof 展示时发生竞态,或当前路径仍是硬链接之一

strace 可以确认进程是否仍向该 FD 写入,但 attach 会有性能与合规影响。一次受控采样:

sudo timeout 10 strace -f -p 1234 -e trace=write,writev,openat,close -s 80 -tt -T -o /var/tmp/myapp-io.strace

PID 必须来自前述核对,10 秒超时用来限制 attach 时间;不要在敏感服务上无审批执行。采样文件可能包含日志片段,应按生产数据处理并及时清理。eBPF 工具也可降低某些场景开销,但仍需验证内核支持和访问控制。

选择释放方式:优先让应用重新打开日志

释放手段有优先级。最安全的修复是使用应用支持的 reopen/reload 机制,让进程关闭旧 FD 并打开新文件。

具体信号不能猜:Nginx 的 USR1 是 reopen,某些守护进程用 HUP,Java 应用往往由 Logback/Log4j2 自己轮转。先查本机安装版本的文档和 unit 配置。

一级:信号 reopen(以 Nginx 为例)

执行前验证配置和 PID,发送 USR1 后检查新旧 inode 与错误日志。该操作不应中断连接,但错误的 PID 或权限可能导致 reopen 失败。

sudo nginx -t
pid=$(cat /run/nginx.pid)
ps -p "$pid" -o pid,lstart,cmd
sudo kill -USR1 "$pid"
sleep 2
sudo lsof -nP +L1 -a -p "$pid"
sudo tail -n 50 /var/log/nginx/error.log

二级:systemd reload

由 systemd 管理且支持 reload 的服务,先查看 unit 实际定义,确认 ExecReload 到底做什么。

systemctl show myapp.service -p MainPID -p ExecReload -p CanReload
systemctl cat myapp.service
sudo systemctl reload myapp.service
systemctl is-active myapp.service

三级:优雅重启(影响最大,最后选)

如果应用只在启动时打开日志,可能需要优雅重启。重启会影响连接和内存态,应先摘流、确认有其他健康副本、保存配置与运行参数,并设置失败回滚。以下流程示意单节点从负载均衡摘除后的检查,实际摘流命令由平台决定。

service=myapp.service
systemctl is-active "$service"
systemctl show "$service" -p MainPID -p ActiveEnterTimestamp
sudo systemctl restart "$service"
systemctl is-active "$service"
journalctl -u "$service" --since '-2 min' --no-pager | tail -n 100
curl -fsS --max-time 3 http://127.0.0.1:8080/healthz

若启动失败,先保持节点摘流,使用已验证的上一版配置/二进制回滚并再次启动。不要因为磁盘压力连续重启所有副本。 释放后还要确认 df,不能只看服务 active。

df -hT /var/log
sudo lsof -nP +L1 | head -n 50
ss -lntp | grep ':8080 '

紧急方案:通过 /proc/PID/fd/N 截断

当空间只剩几分钟、服务不能立即重启且应用没有 reopen 机制,可以把已删除 FD 截断为 0。

这个动作会永久丢失日志内容,并可能与应用的当前偏移产生交互,只能作为经批准的应急措施

执行前必须重新验证: 目标是普通文件;链接仍带 (deleted);PID 属于正确服务;设备与 inode 与前一步一致;数据是否需要先取证;剩余空间是否足够复制。不要对数据库数据文件、WAL、容器 writable layer 中未知文件或 mmap 文件照做。

先取证(如有另一个文件系统)

cp --sparse=always 尽量保留稀疏性;目标不能仍位于写满的文件系统。

pid=1234
fd=7
dest=/mnt/recovery/myapp.deleted.$(date +%Y%m%d-%H%M%S).log
sudo stat -Lc '%F device=%d inode=%i links=%h size=%s blocks=%b' "/proc/$pid/fd/$fd"
sudo cp --sparse=always --reflink=auto "/proc/$pid/fd/$fd" "$dest"
sudo sync -f "$dest"
sudo sha256sum "$dest"

复制的是变化中的文件,哈希只能标识副本,不能证明与某一时刻完全一致。如果空间紧张,优先保存业务要求的时间窗口,或让安全/审计团队决定取证方式。

再截断(不可回滚)

先记录 df、inode 和大小,使用 truncate 明确目标;禁止使用通配符

pid=1234
fd=7
sudo readlink "/proc/$pid/fd/$fd"
sudo stat -Lc 'device=%d inode=%i links=%h size=%s blocks=%b' "/proc/$pid/fd/$fd"
df -B1 /var/log
sudo truncate -s 0 "/proc/$pid/fd/$fd"
df -B1 /var/log
sudo stat -Lc 'device=%d inode=%i links=%h size=%s blocks=%b' "/proc/$pid/fd/$fd"

三个必须记住的坑:

  1. 截断不可回滚。保存的副本只能用于审计或分析,无法让原进程恢复已丢内容。
  2. 空间可能「诡异」地回来:某些非 O_APPEND 写入者保留原文件偏移,截断后下一次写可能在高偏移处制造稀疏文件,逻辑大小突然回来。可用下面的命令短时观察 FD 是否重新膨胀: bash watch -n 2 "stat -Lc 'size=%s blocks=%b inode=%i' /proc/1234/fd/7; df -h /var/log" 截断只是止血,根治仍是 reopen 或重启。

  3. 通过 shell 重定向 : > /proc/.../fd/... 也能截断,但 truncate 意图更明确、容易审计。绝不能 rm /proc/PID/fd/N——它不是普通目录项,删除行为也不是关闭目标进程的 FD。

别漏掉 mmap、继承 FD 和容器进程

「空间没释放」最常见的三个漏网点。

1. mmap 映射的文件

文件可能被内存映射而不在常规 FD 列表中,或 FD 已关闭但映射仍存在。lsof +L1 通常能展示 DEL/mem 项,/proc/PID/maps 也能确认。

sudo grep -H ' (deleted)$' /proc/[0-9]*/maps 2>/dev/null | head -n 50
sudo lsof -nP +L1 | awk '$4=="DEL" || $4=="mem" {print}' | head -n 50

不要截断未知 mmap 文件。 数据库、JVM、共享库和临时文件可能依赖映射内容;优先使用应用支持的退出/重载流程。

2. fork/exec 继承的 FD

日志 FD 还可能在 fork/exec 时被子进程继承。多个 PID 指向相同设备号和 inode,必须让最后一个引用关闭,空间才会释放。

target_inode=123456
for link in /proc/[0-9]*/fd/*; do
  inode=$(stat -Lc '%i' "$link" 2>/dev/null) || continue
  [ "$inode" = "$target_inode" ] || continue
  printf '%s %s\n' "$link" "$(readlink "$link" 2>/dev/null)"
done

inode 只在同一设备内唯一,自动化时应同时比较 st_dev

服务重启后空间仍未释放,常见原因是: 旧 worker 正在优雅退出、孤儿子进程仍持有 FD,或重启的只是容器内某个进程。

3. 容器与 K8s 场景

容器场景应从宿主机确认容器 PID 和 overlay 挂载。命令因运行时而异,以 containerd/CRI 为例先用 crictl 定位,不要直接进入未知 namespace 删除文件

sudo crictl ps --name myapp
container_id=$(sudo crictl ps --name myapp -q | head -n1)
sudo crictl inspect "$container_id" | jq '.info.pid, .status.metadata.name'
host_pid=$(sudo crictl inspect "$container_id" | jq -r '.info.pid')
sudo nsenter -t "$host_pid" -m -- findmnt -T /var/log/myapp/app.log

容器日志常由 kubelet 或运行时在宿主机写入,应用容器删除自己看到的路径不一定影响宿主机 JSON 日志。应分别检查 /var/log/pods/var/log/containers 与运行时存储;不要手工删除 overlay2/containerd 元数据目录

sudo du -xhd2 /var/log/pods 2>/dev/null | sort -h | tail -n 30
sudo find /var/log/pods -xdev -type f -name '*.log' -size +500M \
  -printf '%s %p\n' 2>/dev/null | sort -nr | numfmt --field=1 --to=iec
sudo lsof -nP +L1 /var/lib/containerd 2>/dev/null | head -n 50

节点级容器日志失控会触发 kubelet 驱逐,见 k8s-pod-evicted-troubleshooting

如果没有 deleted FD,再查四类差异

排除幽灵文件后,dfdu 不一致还有四类成因。逐条排查,不要跳到「重启」。

① 挂载点覆盖下层文件

某目录写入大量数据后又在其上挂载新文件系统,du 从当前 namespace 只能看见上层内容,而下层文件仍占根分区

不要贸然卸载生产挂载。可以在同一文件系统创建 bind mount 视图,再检查下层路径:

sudo mkdir -p /mnt/root-view
root_source=$(findmnt -n -o SOURCE /)
echo "root source: $root_source"
sudo mount --bind / /mnt/root-view
sudo du -xhd2 /mnt/root-view/var 2>/dev/null | sort -h | tail -n 30
sudo umount /mnt/root-view

普通 bind mount 不是递归 bind,通常不会把 / 下的子挂载一并复制,因此可从新路径看到被子挂载遮住的底层目录;仍要用 findmnt -R /mnt/root-view 验证实际视图。复杂的共享挂载传播场景应使用独立 mount namespace 或从救援环境挂载底层设备。高风险的卸载、只读挂载和底层清理要在维护窗口执行,并先确认没有进程使用。

② ext4 保留块

ext4 默认可为特权进程保留一部分块(默认 5%),普通用户看到不可用。大数据分区可以评估调低比例,但根分区保留空间有助于管理员登录和服务写关键文件

先确认设备,再读取保留块;仅当 findmnt 表明它确为 ext4 块设备时解释该输出:

device=$(findmnt -n -o SOURCE /var)
sudo tune2fs -l "$device" | grep -E 'Block count|Reserved block count|Block size|Reserved block percentage'

修改保留比例是文件系统级变更。确认设备确为 ext4、已有备份,并根据容量计算绝对保留量。下面示例将独立数据卷调整为 1%,不适用于根分区,也不要对 XFS 执行

device=/dev/mapper/vg_logs-lv_logs
sudo tune2fs -m 1 "$device"
sudo tune2fs -l "$device" | grep -E 'Reserved block count|Reserved block percentage'
df -hT /var/log

回滚可用 tune2fs -m <原比例>,但如果新增空间已经被业务写满,恢复更高保留比例会使普通用户可用空间进一步下降

③ 硬链接与稀疏文件

删除一个硬链接不会释放 inode,只要另一个路径仍存在。按 inode 查找所有链接,必须限制在同一文件系统

file=/var/log/archive/app.log
inode=$(stat -c '%i' "$file")
device=$(stat -c '%d' "$file")
printf 'device=%s inode=%s links=%s\n' "$device" "$inode" "$(stat -c '%h' "$file")"
sudo find /var/log -xdev -inum "$inode" -ls
ls -lh /var/log/myapp/large.log
du -h /var/log/myapp/large.log
stat -c 'logical=%s blocks=%b block_unit=512' /var/log/myapp/large.log

前五条按 inode 查找硬链接;inode 只在设备内唯一,所以先记录设备号并限制同一文件系统。后三条识别稀疏文件:逻辑大小很大、实际块占用较小,ls -lhdu -h 不同是正常现象

④ 快照、配额与文件系统特性

LVM thin snapshot、云盘快照通常不直接计入挂载内的 df,但存储池可能因 COW 增长而耗尽。XFS project quota、btrfs 子卷/qgroup、Ceph 等也有自己的计量。先识别技术再用对应工具。

lsblk -f
sudo lvs -a -o lv_name,vg_name,lv_size,segtype,data_percent,metadata_percent,pool_lv,origin
sudo xfs_quota -x -c 'report -h' /var/log 2>/dev/null || true
sudo btrfs filesystem usage /var/log 2>/dev/null || true

thin pool 的 data_percentmetadata_percent 接近 100% 是独立的高危事件,不能靠删除挂载内一个文件保证恢复。扩容、合并或删除快照具有数据风险,必须核对依赖并按存储变更流程执行。

修正日志轮转,避免下次再出现

正确的 logrotate 策略取决于应用是否支持 reopen。

策略 机制 适用 代价
create + postrotate 信号 rename 后通知进程 reopen 应用支持 reopen(Nginx USR1、syslog HUP) 信号错了就不 reopen,正是幽灵文件的成因
copytruncate 先复制再截断原文件,inode 不变 应用不支持 reopen 复制窗口内可能丢失或重复日志;复制 200GB 会产生巨大 I/O
应用内轮转 Logback / Log4j2 自己管 Java 应用 需按框架配置,不依赖外部信号

关键判断: copytruncate 的卖点是「应用无需 reopen」,但它先复制再截断——对 200GB 级日志意味着一次全量复制,I/O 翻倍,且复制窗口内可能丢日志。因此它不适合高写入速率的大日志,只应作为「应用确实无法 reopen」时的兜底,而不是首选。这与「create 是幽灵文件根因」并不矛盾:根治顺序是 reopen 机制 > copytruncate > 什么都不做

Nginx 轮转示例(rename + USR1)

先用 logrotate -d dry-run 检查,不实际修改:

/var/log/nginx/*.log {
    daily
    rotate 14
    missingok
    notifempty
    compress
    delaycompress
    sharedscripts
    postrotate
        test ! -s /run/nginx.pid || kill -USR1 "$(cat /run/nginx.pid)"
    endscript
}
sudo logrotate -d /etc/logrotate.conf
sudo nginx -t
sudo logrotate -v -f /etc/logrotate.d/nginx
sudo lsof -nP /var/log/nginx/*.log

-f强制轮转,有 I/O 和日志切换影响,只应在测试或维护窗口执行。验证点是新日志 inode 被进程打开、旧文件不再增长、压缩在预期周期发生,而不是仅看轮转文件出现。

systemd journal 容量上限

让 journal 在容量阈值内自行回收。修改前保留原配置,并验证有效配置。

# /etc/systemd/journald.conf.d/limits.conf
[Journal]
SystemMaxUse=4G
SystemKeepFree=2G
RuntimeMaxUse=512M
MaxFileSec=1day
sudo systemd-analyze cat-config systemd/journald.conf
sudo systemctl restart systemd-journald
journalctl --disk-usage
journalctl --verify
sudo journalctl --rotate
sudo journalctl --vacuum-size=4G
journalctl --disk-usage

前四条验证配置和 journal 完整性;后三条只在确认审计保留要求后立即回收历史数据。重启 journald 通常不会重启业务,但可能造成短暂日志转发影响。回滚删除 drop-in 或恢复备份,再重启 journald;不要直接 rm journal 文件

K8s 节点:kubelet 日志轮转

容器平台还应设置 kubelet 日志轮转,并确认当前发行版/托管平台支持字段。配置修改会影响节点级容器日志保留,需要灰度节点验证

# kubelet 配置片段
containerLogMaxSize: 50Mi
containerLogMaxFiles: 5

验证、复盘与自动巡检

释放成功的六条判据

空间释放完成的判断至少包含:

  1. df 使用量下降;
  2. deleted FD 不再持有大文件;
  3. 目标服务健康;
  4. 新日志继续写入正确 inode
  5. 日志轮转下一周期可执行;
  6. 告警恢复且增长速率正常。
df -hT /var/log
df -ih /var/log
sudo lsof -nP +L1 | head -n 100
systemctl --failed
find /var/log/myapp -maxdepth 1 -type f -printf '%TY-%Tm-%Td %TH:%TM:%TS %s %i %p\n' | sort

自动巡检脚本(只告警,不自动操作)

可以定期采集 deleted 普通文件的分配块并在达到阈值时告警,但巡检脚本不要自动截断或重启。下面脚本按设备/inode 去重,超过 1 GiB 返回非零(可直接接监控退出码)。

#!/usr/bin/env bash
set -u

threshold=${DELETED_FILE_THRESHOLD_BYTES:-1073741824}
declare -A seen
total=0

for link in /proc/[0-9]*/fd/*; do
  target=$(readlink "$link" 2>/dev/null) || continue
  [[ $target == *' (deleted)' ]] || continue
  IFS='|' read -r type dev inode blocks unit < <(stat -Lc '%F|%d|%i|%b|%B' "$link" 2>/dev/null) || continue
  [[ $type == 'regular file' ]] || continue
  key="$dev:$inode"
  [[ -v seen[$key] ]] && continue
  seen[$key]=1
  bytes=$((blocks*unit)); total=$((total+bytes))
  printf 'bytes=%s inode=%s target=%q holder=%s\n' "$bytes" "$key" "$target" "$link"
done

printf 'deleted_allocated_bytes=%s\n' "$total"
(( total < threshold ))

/proc 挂载为 hidepid,应让受控的 root 采集器运行,而不是放宽所有用户权限。报警信息要保留设备/inode、持有 PID、服务、首次发现时间和增长速度,避免只报「存在 deleted 文件」

复盘八问

  1. 是谁删除或轮转的?
  2. 应用为什么没有 reopen?
  3. 日志为何能增长到 200GB?
  4. 容量告警是否给足处理时间?
  5. 日志保留和审计要求是什么?
  6. 是否有多个 namespace 或子进程持有?
  7. 紧急操作丢失了什么?
  8. 怎样验证下一次轮转?

一页速查(值班直接照抄)

步骤 命令 判定
1. 止住增长 df -hT / df -ih / findmnt -o TARGET,SOURCE,FSTYPE,OPTIONS 确认挂载点与块/inode 两类余量
2. 建立差异 df-du 差值脚本 差值达数十 GB 且持续 → 锁定 deleted
3. 找持有者 sudo lsof -nP +L1/proc 巡检脚本 拿到 PID / FD / 分配块
4. 锁对象 stat -Lc 'device=%d inode=%i links=%h blocks=%b' /proc/PID/fd/N links=0 且与路径 inode 不同 = 幽灵文件
5. 释放(优先) 信号 reopen → systemctl reload → 优雅重启 释放后必须复查 df
6. 释放(应急) 取证 cp --sparse=alwaystruncate -s 0 不可回滚,事后仍需 reopen
7. 查漏网 maps / 继承 FD / 容器 namespace 重启后未释放多半是这三类
8. 防复发 logrotate + journald 上限 + kubelet 轮转 + 巡检告警 六条判据全绿才算结束

关联页面

页面关联点
linux-disk-space-troubleshooting磁盘空间排查通盘流程(8 命令 / 四种场景 / 生产清理),本页是其中「已删未释放」场景的深度展开
linux-disk-io-troubleshoot磁盘 IO 排查实战(%util 陷阱与 await 真相),区分「空间满」与「IO 慢」
linux-disk-io-tuning生产级磁盘 IO 调优(调度器 / 文件系统 / 脏页),保留块与稀疏文件的上层背景
journalctl-log-tracking-guidejournalctl 命令全指南,journald 容量上限与 vacuum 的日常操作
k8s-pod-evicted-troubleshooting节点磁盘/inode 压力触发 Pod Evicted,容器日志失控的下游后果
linux-server-load-case-study日志 IO 打满导致 load 飙高的完整案例(logrotate create 引发双写)
linux-rm-rf-recovery-guide/proc/PID/fd 抢救已删文件的恢复手法,与本页取证步骤同源
online-troubleshooting-checklist线上故障四维速查清单,磁盘维度的入口页
linux-kernel-tuning-production生产内核参数调优(文件句柄、fs 相关)
server-performance-four-dimensions服务器性能五维排查总纲,文件系统是容易被忽视的第五维
nginx-log-spike-crawler-troubleshootingNginx 访问日志暴涨排查手册:14 步定位异常 URI 与爬虫流量