Linux 文件描述符泄漏排查:从 Too many open files 说起
服务运行几天后,日志里开始出现:
1OSError: [Errno 24] Too many open files很多人第一反应是“磁盘满了”或“文件太多”,但在 Linux 中,文件描述符(FD)不只代表普通文件。socket、pipe、epoll、eventfd、inotify、signalfd 都会占用 FD。因此这句话的真实含义往往是:进程打开的连接、管道或事件对象,已经达到上限。
先看清三层限制
排查前要先区分限制发生在哪一层,否则容易改错地方。
| 层级 | 查看方式 | 说明 |
|---|---|---|
| 进程 soft/hard limit | ulimit -Sn、ulimit -Hn | 只对当前 shell 及其子进程有效 |
| 指定进程 | cat /proc/$PID/limits | 查看目标进程实际生效的限制 |
| systemd 服务 | systemctl show -p LimitNOFILE myapp.service | 服务不一定继承登录 shell 的 ulimit |
| 系统级 | cat /proc/sys/fs/file-nr | 分别是已分配、未使用、系统上限 |
| 内核硬上限 | sysctl fs.nr_open | 单个进程 hard limit 不能超过它 |
ulimit -n 经常具有迷惑性:它显示的是当前 shell 的 soft limit,不代表 systemd 启动的服务也使用这个值。真正要以 /proc/$PID/limits 为准。
输出类似:
1Max open files 1024 4096 files第一列是 soft limit,第二列是 hard limit。进程运行中只能把 soft limit 提高到 hard limit 以内;要突破 hard limit,通常需要 root 或 CAP_SYS_RESOURCE。
用 /proc 定位现场
先确认当前 FD 数量:
这个数字比 lsof -p $PID | wc -l 更接近真实 FD 数,因为 lsof 还会列出内存映射、可执行文件等条目。
接着观察它是否持续增长:
1PID=$(pgrep -n myapp)
2while :; do
3 printf '%s %s\n' "$(date +%T)" "$(ls /proc/$PID/fd 2>/dev/null | wc -l)"
4 sleep 5
5done如果停止业务流量后,FD 数量仍然只升不降,基本可以判断为泄漏,而不是单纯配置太小。
再按 FD 指向的对象归类:
1PID=$(pgrep -n myapp)
2sudo ls -l /proc/$PID/fd 2>/dev/null \
3 | awk 'NR > 1 {print $NF}' \
4 | sort | uniq -c | sort -nr | head -20常见输出包括:
1 812 socket:[1234567]
2 64 /var/log/myapp/app.log
3 18 anon_inode:[eventpoll]
4 6 pipe:[7654321]如果 socket 数量异常多,继续看连接状态:
大量 CLOSE_WAIT 通常意味着对端已经关闭连接,但本进程没有调用 close()。这不是内核的问题,而是应用没有正确释放 socket。
还要检查“已删除但仍被占用”的文件:
1sudo lsof +L1 -p "$PID"这类文件会继续占用磁盘空间,但和 FD 泄漏是两个问题,需要分别处理。
判断是泄漏还是上限太低
可以按下面的现象快速判断:
| 现象 | 更可能的原因 |
|---|---|
| FD 数稳定在限制附近,业务量也没有明显增长 | soft/hard limit 配得太低 |
| 每次请求后 FD 数上升,停止流量后不下降 | 应用泄漏 |
| FD 数呈锯齿状,GC 后明显回落 | 连接池或临时对象,需看峰值 |
大量 CLOSE_WAIT | 收到连接后未关闭 |
大量 anon_inode:[eventpoll] | 反复创建 event loop / selector 未释放 |
大量 inotify | 文件监听器泄漏 |
一个实用方法是:停掉流量,等待 30 秒,再数一次 FD。如果数量几乎不变,就不要继续怀疑“请求太慢”,而应去找未关闭的资源。
常见泄漏模式
Python 中忘记关闭文件是最基础的一种:
1# 不推荐
2f = open("app.log")
3data = f.read()应使用上下文管理器:
1with open("app.log") as f:
2 data = f.read()HTTP 客户端也容易泄漏。每次请求都新建 Session 且不关闭,会积累连接和 socket:
1import requests
2
3with requests.Session() as s:
4 with s.get("https://example.com", timeout=5) as r:
5 print(r.status_code)启动子进程时,如果使用了 PIPE,要确保读取并回收:
1import subprocess
2
3p = subprocess.Popen(
4 ["ls", "-l"],
5 stdout=subprocess.PIPE,
6 text=True,
7)
8out, _ = p.communicate(timeout=10)
9print(out)Go 的典型问题是忘记关闭响应体:
1resp, err := http.Get(url)
2if err != nil {
3 return err
4}
5defer resp.Body.Close()
6_, _ = io.Copy(io.Discard, resp.Body)另外,asyncio.new_event_loop() 创建后不 close()、select.epoll() 未释放、inotify watcher 只增不减,也都会表现为 FD 持续上涨。
应急处理与根治
如果服务已经不可用,可以先临时提高限制,让进程恢复:
这只对当前进程有效,重启后失效,而且治标不治本。
对 systemd 服务,应通过 drop-in 修改:
1sudo systemctl edit myapp.service写入:
1[Service]
2LimitNOFILE=65535然后重新加载并重启:
验证是否生效:
1systemctl show -p LimitNOFILE --value myapp.service
2cat /proc/$(pgrep -n myapp)/limits | grep 'Max open files'如果是登录会话或传统 init 脚本,可以在 /etc/security/limits.conf 或 /etc/security/limits.d/ 中配置:
1myapp soft nofile 65535
2myapp hard nofile 65535但要注意:limits.conf 依赖 PAM,通常需要重新登录;systemd 服务不要指望它生效。
系统级上限可以这样查看和调整:
若要持久化:
不过,fs.file-max 是系统总上限,调大它不会修复应用泄漏,只适合在确认总量确实不够时使用。
最短排查路径
- 从日志确认是
EMFILE还是ENFILE。 - 用
/proc/$PID/limits查看目标进程的真实限制。 - 用
ls /proc/$PID/fd | wc -l统计 FD,并观察趋势。 - 按 FD 指向对象分类,重点看 socket、pipe、eventpoll、inotify。
- 结合
ss的CLOSE_WAIT状态和代码中的资源释放逻辑定位泄漏点。 - 修复代码,把提高 limit 只当作缓冲,而不是解决方案。
FD 泄漏的危险之处在于它通常不会立刻让服务崩溃,而是先表现为偶发超时、连接失败,最后才集中报错。越早把 FD 数量纳入监控,越容易在故障前找到那条没有关闭的连接。