定位 Too many open files:一次生产服务文件描述符耗尽的排查与根治

一个跑了几天的 Java 服务突然大量报 Too many open files,新连接被拒、健康检查失败,最终定位到 systemd nofile 太小叠加句柄泄漏,用 lsof/proc 定位并根治。

问题背景

我们有一个对外提供接口的 Java 服务(Spring Boot,跑在一台 CentOS 7 的物理机上,直接用 systemd 拉起,没有走容器),日常 QPS 不高,长期稳定运行。某次发版后,服务运行三四天一切正常,但到了第五天早上,监控开始告警:接口 5xx 陡增、健康检查探测失败、部分请求直接连不上。

奇怪的是,服务进程还活着,CPU、内存、磁盘都不满,网络也通。运维同事第一反应是"重启一下试试",重启后确实恢复了,但过了几天同样的现象又准时出现——这种"跑一段时间就崩、重启就好、周期性复发"的症状,几乎可以肯定是某种资源在缓慢泄漏。这篇就记录一下把它从现象定位到根因、再到根治的完整过程。

故障现象

从应用日志里翻,很快找到了一批扎眼的报错,几乎刷屏:

1
2
3
4
5
java.net.SocketException: Too many open files
  at java.base/sun.nio.ch.Net.accept(Native Method)
  ...
java.io.IOException: Too many open files
  at java.base/java.io.FileInputStream.open0(Native Method)

同时系统日志 /var/log/messages 里也有内核层面的记录:

1
kernel: VFS: file-max limit reached

现象可以归纳为几点:

  • 报错关键词是 Too many open files,对应系统调用返回 EMFILE(进程级文件描述符耗尽)或 ENFILE(系统级)。
  • 现象随运行时间推移逐渐恶化:新建 socket、打开文件、accept 新连接全部失败,但已有连接大多还能用。
  • 重启进程立刻恢复,然后 fd 数量又开始从低位缓慢往上爬。
  • 业务侧表现为间歇性 5xx 和健康检查失败,容易被误判成"网络抖动"或"下游超时"。

这类报错的本质很明确:进程持有的文件描述符(file descriptor,简称 fd)数量触到了上限。Linux 里"一切皆文件",socket、普通文件、pipe、epoll 实例都占 fd,所以"文件描述符耗尽"往往不是真的打开了太多文件,而是连接或句柄泄漏。

排查过程

第一步,确认到底占了多少 fd。 先拿到进程 PID,然后数它当前打开的 fd 数量:

1
2
3
4
5
6
# 找到进程
pid=$(pgrep -f my-app.jar)

# 统计当前打开的 fd 数量
ls -l /proc/$pid/fd | wc -l
# 输出:4102

第二步,看进程自己的限制是多少。 不要只看 ulimit -n(那是当前 shell 的),一定要看目标进程实际生效的 limits:

1
2
cat /proc/$pid/limits | grep "open files"
# Max open files            4096                 4096                 files

真相浮现了一半:这个进程的软硬限制都只有 4096,而它当前已经占到 4102(数字略有出入是统计时刻差异),已经顶到天花板。很多人以为改了 /etc/security/limits.conf 就万事大吉,但 systemd 拉起的服务根本不读 limits.conf,它只认 unit 文件里的 LimitNOFILE,不写就用一个偏小的默认值。这是第一层坑。

第三步,判断是"用量合理但上限太小"还是"句柄泄漏"。 上限小只是诱因,如果 fd 会无限增长,那一定还有泄漏。我按类型统计一下这 4000 多个 fd 都是什么:

1
2
3
4
5
6
7
ls -l /proc/$pid/fd | awk '{print $NF}' | \
  sed -E 's/[0-9]+$//' | sort | uniq -c | sort -rn | head
# 输出(节选):
#   3600 socket:[
#    210 /data/app/tmp/
#     80 anon_inode:[eventpoll]
#     ...

3600 个 socket,这个比例极不正常——服务并发没那么高。再用 lsof 看这些 socket 连到哪里、处于什么状态:

1
2
3
4
lsof -p $pid | grep -i tcp | awk '{print $NF}' | sort | uniq -c | sort -rn
#   1900 (CLOSE_WAIT)
#    900 (ESTABLISHED)
#    ...

大量 CLOSE_WAIT 是决定性证据。CLOSE_WAIT 意味着对端已经发了 FIN 关闭连接,但本端应用一直没有调用 close(),连接卡在半关闭状态,对应的 fd 也就一直不释放。这几乎百分百是代码里连接/流没有正确关闭。

第四步,定位泄漏点。 结合 lsof 看到这些 CLOSE_WAIT 大多连向同一个下游 HTTP 服务,去翻调用那个下游的代码,果然发现问题:有一处用 HttpURLConnection 拿响应流,但在异常分支里没有关闭连接,也没有 try-with-resources。平时下游正常返回时走的是正常分支会关闭,但下游偶尔超时/报错时就漏关一个连接,日积月累就攒出了几千个 CLOSE_WAIT。

至此链条闭环:下游异常 → 连接漏关 → CLOSE_WAIT 堆积 → fd 缓慢增长 → 触到 4096 上限 → Too many open files → 新连接全废

解决方案

分两步走,先止血再根治。

止血:调大 fd 上限并让 systemd 生效。 编辑 unit 文件(不要图省事去改 limits.conf,对 systemd 无效):

1
2
3
4
5
6
7
# /etc/systemd/system/my-app.service 的 [Service] 段加上
LimitNOFILE=65535

systemctl daemon-reload
systemctl restart my-app
# 重启后复核,确认真的生效
cat /proc/$(pgrep -f my-app.jar)/limits | grep "open files"

如果系统级 file-max 也报限制(VFS: file-max limit reached),再调内核层:

1
2
sysctl -w fs.file-max=2000000
echo "fs.file-max = 2000000" >> /etc/sysctl.d/99-fd.conf

根治:修复句柄泄漏。 把漏关连接的代码改成自动关闭,确保任何分支都会释放:

1
2
3
4
5
6
// 用 try-with-resources,异常也会自动关闭
try (var in = conn.getInputStream()) {
    // 读取响应
} finally {
    conn.disconnect();
}

更彻底的做法是引入连接池化的 HTTP 客户端(如 Apache HttpClient / OkHttp)并复用,从根上避免这种手工管理连接漏关的问题。上线后持续观察 fd 曲线,确认稳定在低位、不再单调上涨。

根因分析

这次故障是两层原因叠加才爆发的:

  • 诱因(上限太小):服务用 systemd 拉起,没显式配置 LimitNOFILE,实际生效值只有 4096,天花板本就很低。
  • 根因(句柄泄漏):应用代码在下游异常分支漏调 close(),连接卡在 CLOSE_WAIT 不释放,fd 单调增长只是时间问题。

只有上限小、没有泄漏,一般不会崩;只有泄漏、上限很大,也只是把爆发时间往后推。两者叠加,才形成了这种"稳定运行几天后周期性崩溃"的迷惑现象。CLOSE_WAIT 堆积是这类泄漏最典型的指纹——它明确指向"应用层没关连接",而不是网络或内核的问题。

预防措施

  • 服务上线前就设置合理的 LimitNOFILE(systemd 用 unit 文件,容器用 --ulimit nofile),别用默认值裸奔。
  • 给 fd 使用量加监控:采集 /proc/<pid>/fd 数量与 /proc/<pid>/limits 的比值,超过 70%~80% 就告警,能在崩溃前几天就发现泄漏趋势。
  • 代码规范:所有 socket、流、连接一律用 try-with-resources / defer / with 等语言级机制自动关闭;HTTP 调用统一走连接池化客户端并复用。
  • 定期巡检 CLOSE_WAITss -slsof 看长期存在的 CLOSE_WAIT,是句柄泄漏的早期信号。
  • 压测覆盖异常路径:泄漏常藏在下游超时、异常分支里,压测时要故意让下游报错,才能暴露漏关连接的问题。

总结

Too many open files 看着吓人,排查路径其实很固定:先 cat /proc/<pid>/limits 看上限、ls /proc/<pid>/fd | wc -l 看用量、再用 lsof 按类型和状态拆解占用。如果看到大量 CLOSE_WAIT,基本可以锁定是应用层连接漏关,而不是上限的锅——这时候光调大 LimitNOFILE 只是把爆发时间推后,真正要修的是代码里的句柄泄漏。记住一个判断口诀:上限决定何时崩,泄漏决定必定崩。把监控加在 fd 使用率上,让问题在告警里暴露,而不是在凌晨的 5xx 里。

使用 Hugo 构建
主题 StackJimmy 设计