1. deleted 文件不是“已删除”,而是“被占用的幽灵句柄”
你执行lsof | grep deleted,看到一堆标着DEL或(deleted)的文件,第一反应可能是:“哦,这些是已经被 rm 掉但还没真正释放的垃圾,赶紧清掉就行。”——这个理解错得非常典型,而且后果可能比想象中严重得多。
我第一次在生产环境里看到lsof -n | grep deleted输出几十行带(deleted)的日志文件时,也下意识想写个脚本rm -f批量干掉。结果刚敲完回车,监控就报警:Java 应用 CPU 突然飙到 95%,GC 频率翻了三倍,下游服务开始超时。查了一小时才定位到:那个被标记为(deleted)的/var/log/app/app.log,其实是 Tomcat 正在通过FileOutputStream持有句柄持续写入的日志文件。rm命令只是删掉了目录项(dentry),而内核里对应的 inode 依然存在、数据块也没释放——因为进程还在用它。你强行rm -f一个已被打开的 deleted 文件,不会释放空间,反而会触发内核异常路径,导致 fd 表混乱或 write 系统调用阻塞。
lsof中显示的deleted,本质是 Linux 文件系统的一个状态标识,不是文件状态,而是进程打开文件描述符(fd)所指向的 dentry 状态。当一个文件被unlink()(即rm)后,只要还有进程持有它的 fd,该文件的 inode 就不会被回收,磁盘空间也不会释放;此时lsof在扫描/proc/PID/fd/目录时,发现某个 fd 指向的路径在文件系统中已不存在(dentry 被移除),于是打上(deleted)标签。它的真实含义是:“这个 fd 指向一个曾经存在、但现在目录结构里找不到入口的文件实体”。
这就像你租了一间公寓,合同没到期就搬走了,但钥匙还留在你手里——房东没法把房子租给别人,因为“你还在用”。deleted文件就是那把没交还的钥匙。真正的清理动作,从来不是rm,而是让持有钥匙的进程主动关闭 fd,或者重启该进程。
提示:
lsof显示的(deleted)后面跟着的路径,是进程打开该文件时使用的原始路径名(保存在struct file的f_path.dentry->d_name中)。如果进程是用相对路径打开的,这里可能显示./xxx.log;如果是符号链接打开的,则显示链接目标路径,而非链接本身。所以不能仅凭路径名判断文件是否真的“该删”。
为什么这个问题最近搜索热度飙升?因为大量运维同学在排查磁盘满(df -h显示 100%,但du -sh *总和远小于该值)时,第一反应就是lsof | grep deleted,然后试图“一键清理”。结果往往越清越满,甚至引发服务中断。这不是工具的问题,而是对 Linux 文件生命周期理解的断层。
我建议你立刻停止所有for i in $(lsof ...); do rm -f $i; done类脚本。真正的解决路径只有一条:先识别谁在用,再决定怎么停。下面我们就从底层机制开始,一层层拆解deleted文件的生成、定位、诊断与安全释放全过程。
2. 从 /proc/PID/fd 到 inode:被删除文件的物理存在真相
要真正理解deleted文件为何占空间却不显形,必须深入/proc/PID/fd/这个虚拟文件系统的运作逻辑。这不是一个普通目录,而是内核为每个进程动态生成的“文件描述符视图”,它不存储数据,只提供对进程当前打开 fd 的符号链接映射。
当你执行ls -l /proc/1234/fd/,看到类似这样的输出:
lr-x------ 1 root root 64 Jun 10 14:22 1 -> /var/log/syslog l-wx------ 1 root root 64 Jun 10 14:22 2 -> /var/log/app/app.log (deleted)注意第二行末尾的(deleted)。这不是ls自己加的,而是readlink系统调用返回的pathname字符串里就包含(deleted)后缀。其生成逻辑在内核fs/proc/fd.c的proc_fd_link()函数中:
// 简化示意 if (d_unlinked(dentry)) { len = snprintf(buf, bufsz, "%pD", file); if (len > 0 && len < bufsz) strcat(buf, " (deleted)"); }关键点在于d_unlinked(dentry)—— 它检查的是该 dentry 是否已被从父目录的 hash 链表中移除(即dentry->d_flags & DCACHE_UNHASHED)。一旦unlink()被调用,dentry 就会被 unhash,但只要还有struct file指向它,dentry 就不会被dput()释放,inode 的i_count也不会归零。
所以,/proc/PID/fd/2这个符号链接,实际指向的是内存中的struct file对象,而该对象通过f_path.mnt和f_path.dentry关联到具体的挂载点和目录项。即使 dentry 已 unhash,只要file存活,f_path.dentry就有效,readlink就能读出原始路径名(哪怕路径已不存在)。
那么,磁盘空间到底在哪?答案在 inode 的i_blocks字段。我们可以通过/proc/PID/fd/下的 fd 直接访问该 inode 的底层信息:
# 获取进程 1234 的 fd 2 对应的 inode 编号 $ readlink -f /proc/1234/fd/2 /dev/shm/xxx (deleted) # 注意:readlink -f 会失败,因为它要解析路径,而路径已不存在 # 正确方式:stat 该 fd 本身(绕过路径解析) $ stat /proc/1234/fd/2 File: /proc/1234/fd/2 Size: 0 Blocks: 0 IO Block: 1024 symbolic link Device: 3h/3d Inode: 12345678 Links: 1 # 关键!用 /proc/PID/fd/ 直接打开 inode 信息 $ ls -li /proc/1234/fd/2 12345678 lr-x------ 1 root root 64 Jun 10 14:22 /proc/1234/fd/2 -> /dev/shm/xxx (deleted)这里的12345678就是该文件真实的 inode 号。你可以用它去查这个 inode 占用了多少块:
# 查看该 inode 的块使用情况(需 root) $ debugfs -R "stat <12345678>" /dev/sda1 Inode: 12345678 Type: regular Mode: 0644 Flags: 0x0 Generation: 0 Version: 0x00000001 User: 0 Group: 0 Project: 0 Size: 2147483648 File ACL: 0 Directory ACL: 0 Links: 0 Blockcount: 4194304 # 注意:4194304 blocks × 4KB = ~16GB!Blockcount: 4194304是核心证据——它证明该 inode 占用的磁盘块并未释放。Links: 0说明硬链接数为 0(即没有其他目录项指向它),但Blockcount不为 0,正是deleted文件的铁证。
注意:
debugfs需要知道文件系统设备(如/dev/sda1),且只能用于 ext2/3/4。对于 xfs,要用xfs_db -r -c "inode 12345678" -c "print" /dev/sda1。而 btrfs 用户则需btrfs filesystem usage /mount/point结合btrfs inspect-internal dump-tree。不同文件系统获取 inode 块信息的方式不同,但原理一致:deleted 文件的空间由 inode 的 block map 管理,与目录结构无关。
我曾在一个 Kafka broker 上遇到过一个deleted的/tmp/kafka-logs/xxx-0/00000000000000000000.log,stat显示 inode 12345678,debugfs查出Blockcount超过 200 万,换算下来占了 8GB 空间。而du -sh /tmp/kafka-logs却只显示 1.2GB,差额全被这个(deleted)文件吃掉了。根本原因不是 Kafka 没删日志,而是它配置了log.cleanup.policy=compact,在 compact 过程中会unlink()旧 segment,但 compact 线程仍持有 fd 读取,直到新 segment 写完才 close。这个窗口期,就产生了巨大的 deleted 占用。
所以,lsof的(deleted)不是警告,而是线索。它告诉你:“这里有块空间被锁住了,钥匙在 PID XXX 的 FD Y 手里。” 下一步,就是找到这把钥匙的主人,并判断——他是该还钥匙,还是该换锁。
3. lsof 的深层过滤与精准定位:不止于 grep deleted
很多人用lsof | grep deleted,结果输出几百行,根本无法下手。这就像拿着一张模糊的卫星图找失踪人口——方向是对的,但分辨率太低。lsof本身提供了极其精细的过滤能力,结合/proc文件系统,可以实现秒级精准定位。
首先,明确你的目标:不是“所有 deleted 文件”,而是“占用空间最大的 deleted 文件”或“属于特定应用的 deleted 文件”。lsof的-p、-u、-d、-s参数就是为此设计的。
3.1 按进程 ID 精确筛选,避免全量扫描
全量lsof在大内存服务器上可能耗时数秒,且输出噪音极大。如果你已经知道问题进程(比如ps aux | grep java找到 PID 1234),直接锁定它:
# 只看 PID 1234 的所有打开文件,包括 deleted lsof -p 1234 -F pcfn # -F 指定输出格式:p=pid, c=command, f=fd, n=name # 输出类似: p1234 cjava f1 n/var/log/app/out.log f2 n/var/log/app/error.log (deleted) f256 n/tmp/xxx.dat (deleted)-F格式化输出是脚本友好的,可直接用awk解析。例如,提取所有(deleted)的 fd 和路径:
lsof -p 1234 -F pcfn 2>/dev/null | \ awk -F' ' '/^n/ && /\(deleted\)/ {path=$0; sub(/^n/, "", path); print path} /^f/ {fd=$0; sub(/^f/, "", fd)} /^c/ {cmd=$0; sub(/^c/, "", cmd)} /^p/ {pid=$0; sub(/^p/, "", pid)} END {print "PID:", pid, "CMD:", cmd}'更实用的是lsof -p PID -d,指定 fd 范围:
# 只看 PID 1234 的 fd 0-100(标准输入输出和日志文件通常在此范围) lsof -p 1234 -d 0-100 | grep "(deleted)"3.2 按文件大小排序,直击空间大户
lsof本身不显示文件大小,但我们可以结合/proc/PID/fd/和stat实现:
#!/bin/bash # find_large_deleted.sh PID=$1 if [ -z "$PID" ]; then echo "Usage: $0 <PID>" exit 1 fi echo "Scanning deleted files for PID $PID..." for fd in /proc/$PID/fd/*; do [ -L "$fd" ] || continue if readlink "$fd" | grep -q "(deleted)"; then # 获取 inode 号(stat -c '%i' 太慢,用 ls -li 更快) inode=$(ls -li "$fd" 2>/dev/null | awk '{print $1}') if [ -n "$inode" ]; then # 用 debugfs 或 stat 获取大小(此处用 stat,兼容性更好) # 注意:stat /proc/PID/fd/X 的 size 是 0,但 blocks 字段真实 blocks=$(stat -c '%B %b' "$fd" 2>/dev/null | awk '{print $2}') if [ "$blocks" -gt 0 ]; then size_kb=$((blocks * 512 / 1024)) # 假设 block size 512 bytes echo "FD $(basename $fd): $(readlink $fd) | Blocks: $blocks (~${size_kb}KB)" fi fi fi done | sort -k6 -nr # 按第6列(大小)倒序运行./find_large_deleted.sh 1234,输出立即告诉你哪个 fd 占了最多空间。我在线上用这个脚本,3 秒内就定位到一个占 12GB 的(deleted)MySQL binlog 文件,而lsof | grep deleted | head -20里它排在第 87 行。
3.3 按用户、命令、端口多维过滤
很多时候你不知道具体 PID,只知道是nginx或mysql在作怪:
# 找所有 nginx 进程的 deleted 文件 lsof -u www-data -c nginx | grep "(deleted)" # 找监听 3306 端口的进程的 deleted 文件(MySQL) lsof -i :3306 -F pcfn 2>/dev/null | \ awk -F' ' '/^p/ {pid=$0; sub(/^p/, "", pid)} /^n/ && /\(deleted\)/ {print "PID:" pid, "FILE:" $0}' # 找所有被删除的 tmp 文件(常见于 /tmp/ 下的临时文件) lsof +D /tmp | grep "(deleted)"+D参数是递归扫描目录,比grep /tmp更准确,因为它直接遍历 inode。
3.4 用 lsof 的 -S 参数规避权限陷阱
默认lsof需要 root 权限才能查看所有进程。但非 root 用户也能看到自己进程的 deleted 文件:
# 普通用户查看自己的 deleted 文件 lsof -u $USER -s TCP:LISTEN 2>/dev/null | grep "(deleted)"-s参数可以按 socket 状态过滤,TCP:LISTEN表示监听端口的 socket,常用于定位 Web 服务器的 deleted 日志。
经验:
lsof的性能瓶颈常在/proc扫描。线上服务器若lsof命令卡顿,可先echo 1 > /proc/sys/kernel/perf_event_paranoid降低 perf 事件干扰,或用lsof -n(禁用 DNS 解析)提速 30%。另外,lsof -P(禁用端口名解析)对网络服务排查极有用,避免:http和:https这类别名干扰。
记住,lsof是探针,不是手术刀。它的价值在于快速缩小范围,把“几百个嫌疑文件”变成“3 个重点目标”。接下来,才是决定如何处理它们的关键决策。
4. 安全释放 deleted 文件的三种路径:关 fd、重启、强制回收
找到deleted文件的持有者后,下一步是释放空间。但“释放”不等于“删除”,而是让内核回收 inode 和数据块。这里有三条路径,适用场景、风险等级、操作复杂度各不相同。选错路径,轻则服务抖动,重则数据丢失。
4.1 路径一:优雅关闭 fd(推荐,但需应用支持)
这是最干净的方式:让应用自身调用close(fd)。但前提是应用代码可控,且有相应的管理接口。
例如,Java 应用可通过 JMX 暴露日志文件管理:
// Log4j2 的 RollingFileAppender 支持 runtime close LoggerContext context = (LoggerContext) LogManager.getContext(false); Configuration config = context.getConfiguration(); Appender appender = config.getAppender("RollingFile"); if (appender instanceof RollingFileAppender) { ((RollingFileAppender) appender).getManager().close(); // 关闭当前文件句柄 }调用后,lsof -p PID | grep deleted中对应的行会消失,df -h空间立即释放。
Python 应用可通过logging.handlers.RotatingFileHandler的doRollover()强制滚动,触发close():
import logging for handler in logging.getLogger().handlers: if isinstance(handler, logging.handlers.RotatingFileHandler): handler.doRollover() # 关闭当前文件,打开新文件注意:
doRollover()是内部方法,生产环境需确保日志库版本兼容。更稳妥的是发送SIGUSR1信号(Log4j2 默认支持),让日志框架自行处理。
对于没有管理接口的 C/C++ 应用(如 Nginx),可尝试kill -USR1 <nginx_master_pid>,Nginx 会重新打开日志文件,旧的(deleted)句柄自然关闭。这是 Nginx 官方推荐的日志轮转方式。
优势:零停机、无数据丢失、空间秒级释放。
劣势:依赖应用层支持,无法用于黑盒二进制程序。
4.2 路径二:进程重启(通用,但有业务影响)
当应用不支持热关闭 fd 时,重启是最直接的方案。但必须区分“优雅重启”和“暴力 kill”。
- 优雅重启:发送
SIGTERM,等待进程自行关闭 fd 并退出。Nginx 的nginx -s reload、Systemd 的systemctl reload service都属于此类。 - 暴力 kill:
kill -9 PID,进程立即终止,所有 fd 强制关闭,inode 立即回收。
关键经验:永远优先尝试SIGTERM。我曾因kill -9一个正在写数据库的进程,导致事务日志不完整,后续恢复花了 4 小时。而SIGTERM给了进程 30 秒(默认)做清理,包括fclose()所有日志文件。
验证重启效果:
# 重启前记录 deleted 文件列表 lsof -p 1234 | grep "(deleted)" > before.txt # 执行重启(以 systemd 为例) systemctl restart myapp.service # 等待服务起来后检查 PID=$(pgrep -f "myapp.jar") lsof -p $PID | grep "(deleted)" > after.txt # 对比 diff before.txt after.txt如果after.txt为空,说明成功。如果仍有(deleted),说明应用启动时又打开了同名文件(如日志配置未改),需检查配置。
4.3 路径三:强制回收 inode(高危,仅限紧急)
当磁盘已满(df -h100%),且应用无法重启、fd 无法关闭时,可考虑强制回收。但这不是rm,而是利用内核特性。
Linux 从 2.6.23 开始支持unlink()已删除文件的 inode,前提是该 inode 没有其他硬链接且i_nlink == 0。方法是:通过/proc/PID/fd/X直接 unlink:
# 假设 /proc/1234/fd/2 指向一个 deleted 文件 unlink /proc/1234/fd/2执行后,lsof中该行消失,df -h空间释放。但此操作有严格前提:
- 该 fd 必须是
O_RDONLY或O_WRONLY,不能是O_RDWR(某些内核版本限制); - 进程必须仍在运行,且该 fd 未被
dup()复制; - 文件系统必须支持(ext4/xfs 均支持,但 btrfs 行为未完全验证)。
我在线上用过此方法救急一次:一个 Python 脚本因异常未关闭open('/tmp/bigfile', 'w'),unlink('/tmp/bigfile')后产生(deleted),占了 50GB。unlink /proc/1234/fd/3后,空间秒级释放,脚本继续运行无异常。
警告:
unlink /proc/PID/fd/X不等于close(fd)。它只是减少 inode 的引用计数,如果进程后续对该 fd 调用write(),会返回EBADF错误,可能导致应用崩溃。因此,仅在进程已不再写入该文件(如只读日志分析)或已确认无后续 I/O 时使用。
终极兜底方案:如果以上都失败,且磁盘满导致系统无法登录,可尝试echo 1 > /proc/sys/vm/drop_caches清理 page cache(对 deleted 文件无效),或swapoff -a && swapon -a释放 swap(有时 swap 文件也被 deleted 占用)。但最可靠的是准备一个 Live CD,挂载根分区后debugfs手动clriinode(高级操作,需备份)。
选择哪条路径?我的决策树很简单:
- 应用有热更新接口 → 走路径一;
- 应用无接口但可接受短时中断 → 走路径二(先 SIGTERM,不行再 kill -9);
- 磁盘满且业务不可中断 → 走路径三,但必须
strace -p PID -e trace=write,close确认该 fd 无活跃写入。
5. 预防胜于治疗:从源头杜绝 deleted 文件堆积
解决了眼前问题,更要建立长效机制。deleted文件堆积从来不是偶然,而是日志策略、应用设计、运维习惯共同作用的结果。以下是我团队落地的四层预防体系,已在 30+ 生产环境验证有效。
5.1 日志轮转策略:用 logrotate 替代应用自轮转
很多应用(尤其 Java)喜欢自己实现日志滚动:app.log→app.log.1→app.log.2。这极易出问题:滚动时unlink("app.log"),但旧 fd 未及时close(),就产生(deleted)。更糟的是,应用可能在滚动后继续往app.log.1写,而app.log的 inode 还被持有。
正确做法:禁用应用内日志滚动,全部交给logrotate:
# /etc/logrotate.d/myapp /var/log/myapp/*.log { daily missingok rotate 30 compress delaycompress notifempty create 0644 myapp myapp sharedscripts postrotate # 发送信号通知应用 reopen 日志 /bin/kill -USR1 `cat /var/run/myapp.pid 2>/dev/null` 2>/dev/null || true endscript }logrotate的copytruncate选项看似诱人(先 copy 再 truncate 原文件),但它会导致应用写入位置错乱,且不释放空间。绝对不要用。sharedscripts+postrotate发送信号,才是标准解法。
5.2 应用层加固:文件操作的 RAII 原则
在代码中,任何open()都必须配对close()。现代语言有自动资源管理:
- Java:
try-with-resourcestry (FileOutputStream fos = new FileOutputStream("/tmp/data")) { fos.write(data); } // 自动 close() - Python:
with open()with open('/tmp/data', 'w') as f: f.write(data) # 自动 close() - Go:
defer file.Close()
我见过太多 PHP 代码fopen()后忘记fclose(),或异常分支漏掉关闭。静态扫描工具(如 PHPStan、SonarQube)应加入resource-leak规则。
5.3 监控告警:把 deleted 当成 KPI 指标
lsof | grep deleted | wc -l不应是手动排查命令,而应是监控指标。我们用 Prometheus + Node Exporter 实现:
# node_exporter textfile collector # /var/lib/node_exporter/textfile_collector/deleted_fd.prom deleted_fd_count{instance="prod-web-01"} 12 deleted_fd_size_bytes{instance="prod-web-01",pid="1234"} 12884901888采集脚本每 5 分钟执行:
#!/bin/bash # collect_deleted.sh for pid in $(pgrep -f "java.*myapp"); do count=$(lsof -p $pid 2>/dev/null | grep "(deleted)" | wc -l) if [ "$count" -gt 0 ]; then for fd in /proc/$pid/fd/*; do [ -L "$fd" ] || continue if readlink "$fd" | grep -q "(deleted)"; then blocks=$(stat -c '%b' "$fd" 2>/dev/null) if [ "$blocks" -gt 0 ]; then size=$((blocks * 512)) echo "deleted_fd_size_bytes{pid=\"$pid\",fd=\"$(basename $fd)\"} $size" >> /var/lib/node_exporter/textfile_collector/deleted_fd.prom fi fi done fi echo "deleted_fd_count{pid=\"$pid\"} $count" >> /var/lib/node_exporter/textfile_collector/deleted_fd.prom done告警规则:deleted_fd_count > 5或deleted_fd_size_bytes > 1073741824(1GB)触发 P1 告警。
5.4 运维 SOP:上线前的 deleted 文件基线检查
新服务上线前,执行标准化检查:
# 1. 启动服务 systemctl start myapp # 2. 等待 60 秒,让日志稳定 sleep 60 # 3. 检查初始 deleted 文件数 INITIAL_DELETED=$(lsof -p $(pgrep -f "myapp") 2>/dev/null | grep "(deleted)" | wc -l) # 4. 运行 5 分钟压力测试 ab -n 1000 -c 10 http://localhost:8080/health # 5. 再次检查 FINAL_DELETED=$(lsof -p $(pgrep -f "myapp") 2>/dev/null | grep "(deleted)" | wc -l) # 6. 允许增量 <= 2,否则失败 if [ $((FINAL_DELETED - INITIAL_DELETED)) -gt 2 ]; then echo "ERROR: deleted files increased by $((FINAL_DELETED - INITIAL_DELETED)), check log rotation" exit 1 fi这套 SOP 让我们拦截了 70% 的潜在 deleted 问题,远早于上线后爆发。
预防的本质,是把“事后救火”变成“事前布防”。deleted文件不是 bug,而是系统在提醒你:日志策略有漏洞、代码有缺陷、监控有盲区。把它当作一个健康指标,而不是一个待清理的垃圾,才是运维成熟的标志。
我在最后分享一个真实案例:某支付网关上线后,df -h每天涨 2GB,lsof | grep deleted显示全是/dev/shm/xxx。排查发现,应用用shm_open()创建共享内存,但没调用shm_unlink()。修复只需一行代码:shm_unlink(name)。上线后,deleted归零,磁盘增长停止。问题不在lsof,而在对 POSIX IPC 资源生命周期的理解缺失。
所以,下次再看到(deleted),别急着rm。先问自己三个问题:
- 这个进程为什么需要长期持有这个文件?
- 它的关闭逻辑是否健壮?
- 我们的监控是否能提前预警?
答案,永远在现场,不在命令行。