侧边栏壁纸
  • 累计撰写 149 篇文章
  • 累计收到 2 条评论

线上服务突发 Too many open files 崩溃?排查 Linux 文件句柄泄露与 ulimit 调优我踩过的几个坑

2026-10-2 / 0 评论 / 15 阅读
AI 摘要由 AI 生成

文章深入分析了Linux系统中线上服务因文件句柄泄露导致的崩溃问题,揭示了通过`limits.conf`调优`ulimit`时的常见错误和解决方案。作者详细描述了系统限制不适用于systemd管理服务的原因,并提供了通过修改unit文件和全局配置文件解决此问题的方法,避免了因误操作而引发的其他问题。

周五下午刚过四点,业务监控突然报警,网关层开始大面积返回 502。登上服务器看后端日志,控制台密密麻麻全是 java.io.IOException: Too many open files,伴随着下游 Go 服务报出来的 accept tcp [::]:8080: accept4: too many open files in system。

这时候所有的 TCP 新建连接全部被操作系统直接拒绝,curl 本地端口也握不上手。

遇到这个报错,很多人第一反应往往是在网上搜个命令:打开 /etc/security/limits.conf,加上 * soft nofile 65535,然后觉得完事了。

但真正在生产环境排查过几次就会发现,事情远没这么简单。改完配置服务依然起不来、重启后依然显示限制 1024、盲目把数值改到几百万导致机器无法登录、或者是代码里连接没关把 65535 活活耗光……这些坑我都踩过一遍。

把排查过程里的关键节点和容易混淆的地方理清楚,线上再遇到就能直接定位。


坑一:改了 limits.conf,systemd 服务依然限制 1024

这是排查 Too many open files 最常见的翻车现场。

很多人跑到 /etc/security/limits.conf 里加上两行:

* soft nofile 65535
* hard nofile 65535

重开 SSH 终端,敲一下命令验证:

ulimit -n
# 输出:65535

看着很正常,结果重启应用服务,业务跑了一阵子又挂了,日志依然报句柄不足。

为什么会这样?

因为 /etc/security/limits.conf 是 PAM 认证机制(pam_limits.so)的配置文件。只有通过 PAM 登录的会话(比如 SSH 登录、控制台登录、su - username 切换用户),才会加载这套限制。

而现在的 Linux 发行版(CentOS 7/8/9、Ubuntu 18.04+、Debian 等),后台常驻服务几乎都是交给 systemd 统一管理的。systemd 启动的守护进程是直接由 PID 1 fork 出来的,压根不走 PAM 认证链,自然完全无视 limits.conf 里的设置。

验证某个正在运行的进程实际句柄上限,绝不能只看当前终端的 ulimit -n,而是要直接查内核 proc 文件系统:

# 找到出问题的服务进程 PID
pidof my-service

# 查看该进程真实生效的资源限制
cat /proc/<PID>/limits | grep "Max open files"

输出大概长这样:

Max open files            1024                 524288               files

第一列是软限制(Soft Limit),第二列是硬限制(Hard Limit)。你会发现软限制死死卡在 1024,只要进程打开的 socket 和文件总数超过 1024,立马抛错。

正确解法

给 systemd 管理的服务增加句柄上限,有两个位置:

1. 针对单个服务(推荐)

修改服务的 unit 文件,或者通过 systemctl edit <service_name> 增加覆盖配置:

[Unit]
Description=My Backend Service

[Service]
Type=simple
ExecStart=/opt/app/bin/server
# 显式指定该进程的文件句柄限制
LimitNOFILE=65535

[Install]
WantedBy=multi-user.target

保存后执行:

systemctl daemon-reload
systemctl restart <service_name>

再去查 /proc/<PID>/limits,这时候 Max open files 就会变成 65535。

2. 全局 systemd 默认值

如果这台服务器上跑了大量容器或自建服务,想让 systemd 默认给所有 unit 比较大的限制,可以修改 /etc/systemd/system.conf 和 /etc/systemd/user.conf:

[Manager]
DefaultLimitNOFILE=65535:524288

改完后执行 systemctl daemon-reexec 让 systemd 重新加载主配置,后续新启动的服务就会继承这个默认值。


坑二:搞混三个层级的限制,修改数值直接超纲

Linux 对文件句柄的限制其实分了三层,从上到下互相约束。很多运维同学只知道 ulimit -n,遇到报错就瞎填一个 1000000,结果直接被内核拦住或者导致异常。

这三层关系如下:

1. 系统全局限制:fs.file-max

这是整个操作系统内核允许所有进程打开的文件句柄总数上限。就算单个进程只占了几千个,如果整台机器所有服务加起来把 fs.file-max 吃完了,系统也会报 Too many open files in system。

查看当前系统上限:

cat /proc/sys/fs/file-max
# 或者
sysctl fs.file-max

查看全系统当前使用了多少句柄:

cat /proc/sys/fs/file-nr
# 输出三个数字,例如:
# 3200    0    2097152

这三个数字分别代表:

  • 第 1 个:系统当前已分配并使用的文件句柄数。
  • 第 2 个:已分配但目前处于空闲的句柄数(Linux 2.6 以后通常保持为 0)。
  • 第 3 个:系统允许的最大文件句柄数(对应 fs.file-max)。

2. 单进程上限天花板:fs.nr_open

这是内核针对单个进程允许设置的 nofile 最大上限,默认通常是 1048576(1024 * 1024)。

你在 /etc/security/limits.conf 或 systemd 的 LimitNOFILE 里填的值,绝对不能超过 fs.nr_open。如果强行把 nofile 设成 2000000,用户再次通过 SSH 登录时,PAM 模块解析配置出错,会直接把你拒之门外,SSH 连都连不上。

3. 用户及进程级限制:nofile(ulimit -n)

这是特定用户或特定进程能打开的句柄数。软限制可以由普通用户在运行时调大(只要不超过硬限制),硬限制只能由 root 用户调整。

生产推荐配置组合

在高并发或者网关服务器上,建议在 /etc/sysctl.conf 里统一调优内核参数:

# 全局句柄总数上限(按机器内存来,16G/32G 机器给 200 万足够)
fs.file-max = 2097152

# 单进程能够设置的最大打开句柄数
fs.nr_open = 1048576

修改后执行 sysctl -p 生效,然后再去调整应用的 LimitNOFILE=65535 或者 131072,层层递进才不会冲突。


坑三:盲目调大限制,掩盖真正的句柄泄露

很多线上事故里,服务原本只需要几百个长连接,某天突然把 65535 甚至更高限制撑爆。

这时候如果只是机械地把 LimitNOFILE 改成 200000 重新拉起,往往跑两三天又爆了。因为这不是真实的业务量增长,而是代码发生了文件句柄泄露。

在 Linux 世界里,一切皆文件。打开一个本地日志文件是 fd,建立一个 TCP 连接是 fd,一个 UNIX Domain Socket 是 fd,管道 pipe 也是 fd。

当句柄数异常暴涨时,第一步不是调参数,而是抓出现场证据。

1. 确认当前进程实时占用了多少个 fd

ls -l /proc/<PID>/fd | wc -l

如果这个数字逼近了 cat /proc/<PID>/limits 里的限制值,说明这个进程离崩溃只有一步之遥。

2. 分类统计到底是什么资源占满了句柄

用 lsof 按照文件类型快速分组统计:

lsof -p <PID> | awk '{print $5}' | sort | uniq -c | sort -rn

看输出里的主导类型是哪一个:

  • 如果排在前面的是 IPv4 / IPv6 / sock:说明大量 TCP 连接没有释放。
  • 如果排在前面的是 REG:说明普通磁盘文件被打开后没有调用 close。
  • 如果排在前面的是 FIFO 或 pipe:说明进程间通信的管道未回收。

3. 常见泄露根因排查

场景 A:网络连接死在 CLOSE_WAIT 状态

用 ss 或 netstat 统计该进程的网络状态:

ss -antp | grep "pid=<PID>," | awk '{print $1}' | sort | uniq -c

如果看到成千上万个 CLOSE_WAIT,问题基本就坐实了。

CLOSE_WAIT 表示远端客户端或下游服务已经主动发起断开(发了 FIN 包),Linux 内核回复了 ACK,正在等待本地应用程序调用 socket.close()。

如果应用代码拿到了连接断开的通知,却因为异常处理逻辑疏漏(比如解析 JSON 抛了 RuntimeException,导致后续的 client.close() 或 response.close() 被跳过),这个连接就会永久卡在 CLOSE_WAIT。每个卡住的 socket 都会长期占着一个文件句柄,直到堆满。

场景 B:读写临时文件忘记关闭流

在做 Excel 导出、图片转码、日志写入或者临时文件解压的业务里,经常有人写出这样的代码:

// 错误示范:抛异常时无法保证流被关闭
FileInputStream fis = new FileInputStream(file);
processData(fis); // 如果这里抛错,下面的 close 永远走不到
fis.close();

即使把磁盘上的文件 rm 删掉了,如果打开文件的进程还没有执行 close,Linux 也只是把文件从目录树里 unlink,inode 和数据块依然挂在进程的 fd 表里。

这时候运行 lsof -p <PID> | grep deleted,就能抓到一长串标记着 (deleted) 的僵尸文件,每一个都在白白消耗句柄配额。

排查出泄露原因后,在代码里老老实实用 try-with-resources 或保证 finally 块显式关闭,这才是治本的做法。


线上应急排查命令速查

遇到线上报警 Too many open files 时,按顺序执行这组命令定位问题:

# 1. 确认哪个进程的句柄耗尽
for pid in $(pgrep -d ' ' -f "java|nginx|node|python|go"); do
    echo "PID: $pid, FDs: $(ls -1 /proc/$pid/fd 2>/dev/null | wc -l)"
done | sort -k4 -rn | head -n 5

# 2. 查看目标进程实际生效的句柄限制
cat /proc/<PID>/limits | grep "Max open files"

# 3. 检查系统整体句柄使用水位
cat /proc/sys/fs/file-nr

# 4. 分析目标进程句柄泄露的具体类型
lsof -p <PID> | awk '{print $5}' | sort | uniq -c | sort -rn | head -n 10

# 5. 如果是网络连接泄露,查看具体处于什么状态、连向哪个 IP
ss -antp | grep "pid=<PID>," | head -n 20

先确认生效限制是否偏小,再确认是否存在未释放的连接与已删除文件句柄。分清 systemd 和 limits.conf 的边界,把系统级参数和代码泄露理顺,以后再碰到类似的句柄打满报警,五分钟就能完成定界。

评论一下?

OωO
取消