Linux 文件句柄耗尽排查:从 lsof 到 systemd LimitNOFILE
一、先分清是进程上限还是系统上限
服务突然报 Too many open files、accept failed,或者日志写不进去,通常不是磁盘问题,而是文件描述符(fd)耗尽。排查的第一步不是急着调大参数,而是分清两层限制:
- 单进程限制:
ulimit -n、/proc/<pid>/limits里的Max open files; - 系统总限制:
fs.file-max,以及当前已分配数量fs.file-nr。
如果只是某个进程到顶,其他进程正常,优先查这个进程有没有泄漏;如果系统级 file-nr 接近 file-max,才考虑整体扩容。把两者混为一谈,很容易出现“调了 ulimit 却没效果”的情况。
二、定位是谁占着 fd
最直接的办法是看进程自己的 fd 表:
ls /proc/<pid>/fd | wc -l
lsof -p <pid> | wc -l
两者数量接近时,说明统计可信。接着按类型归类,找出增长最快的那一类:
lsof -p <pid> -Fn | grep '^n' | sed 's/^n//' | \
awk -F/ '{print $1"/"$2}' | sort | uniq -c | sort -rn | head
常见的高占用来源有:
- socket 未关闭:HTTP 客户端、数据库连接池、gRPC 流式调用忘记
close; - 日志轮转未 reopen:
logrotate用copytruncate时旧 fd 仍指向被截断的文件; - 子进程管道泄漏:
subprocess.Popen的 stdout/stderr 管道没有读取或关闭; - inotify watch:文件监控工具对目录递归监听,watch 数量随文件增长;
- epoll/eventfd:高并发网络框架每个连接持有多个 fd,连接回收不及时就会堆积。
三、临时与永久提高上限
临时验证可以在当前 shell 里执行:
ulimit -n 65535
但这只影响当前 shell 及其子进程,重启或换个终端就失效。要让服务稳定生效,需要写到 systemd unit:
[Service]
LimitNOFILE=65535
改完后 systemctl daemon-reload && systemctl restart <service>,再用 /proc/<pid>/limits 确认实际值。注意 LimitNOFILE 同时设置 soft 和 hard,若只改 /etc/security/limits.conf,对 systemd 托管的服务往往不生效。
系统级可以检查:
sysctl fs.file-nr
sysctl fs.file-max
fs.file-nr 的三个数字分别是“已分配、未使用但已分配、上限”。只有在已分配持续贴近上限时,才需要调整 fs.file-max。
四、代码层的几个高频坑
Python 忘记关闭文件:
# 不推荐
f = open(path)
data = f.read()
# 推荐
with open(path) as f:
data = f.read()
HTTP 连接池没有上限:使用 requests.Session 或自定义连接池时,要设置 pool_maxsize,并在异常路径确保连接释放。流式响应必须显式 close(),否则连接会一直占着 fd。
子进程管道:Popen 带 stdout=PIPE 却不读取,管道缓冲区满后子进程会阻塞,fd 也不会释放。可以用 communicate(),或者把输出重定向到文件。
日志轮转:如果使用 copytruncate,进程仍持有旧 fd,写入位置会错乱;更稳妥的是让进程支持 SIGHUP 重新打开日志文件,或在轮转后重启 worker。
五、监控与告警
不要等报错才查。至少采集三个指标:
- 进程级
process_open_fds; - 系统级
filefd.allocated; - 每秒新建连接数与关闭数是否平衡。
给 fd 使用率设置阈值(例如 80% 预警、90% 告警),并保留 lsof 快照。这样出现泄漏时能直接对比“哪个文件/连接在增长”,而不是只看一个总数。
六、小结
文件句柄耗尽的排查顺序应该是:先确认是进程级还是系统级,再按 fd 类型找增长源,最后才调整 ulimit/LimitNOFILE。参数调大只能争取时间,真正解决问题的是关闭未释放的连接、修正日志轮转方式,以及给连接池和子进程加上明确的回收路径。
- 点赞
- 收藏
- 关注作者
评论(0)