文件描述符耗尽:泄漏、限额与全局撞顶

一、从一条「Too many open files」说出三种完全不同的病

「Too many open files」这句报错在运维里出现频率很高,但它背后至少对应三种完全不同的病因,处理方式互不通用。把这三种分清楚,比背参数重要得多。

第一种:某个进程把 fd 泄漏了。代码里打开了文件、socket 或数据库连接,异常分支没有关闭。表现是这个进程的 fd 数随时间单调上涨,最后撞到自己进程的软限制上。系统其他一切都正常。这一类的关键是找到那个进程,然后修代码。

第二种:进程的 limit 设得太低。进程没有任何泄漏,只是它被允许打开的 fd 数量不够用。比如 Nginx 作为反代要同时撑住几千条上游连接,每条连接占一个 fd,再加上本地文件和日志,wd 一旦超过 1024 就直接拒绝新连接。这一类不是病,是配置没跟上业务。

第三种:系统级上限被打穿。进程自己的 limit 给得很足,但系统整体的 file-nr 逼近了内核上限,内核直接拒绝任何新的 open。这一类的症状最吓人——不只是某个进程,而是整机所有服务开始连锁报错,SSH 都可能登不上。而这个上限经常被一篇文章的「优化建议」调成了一个更小的值。

大多数教程只讲第三种,于是新手遇到第一种也会去调 fs.file-max,调完舒服两天,然后泄漏继续把 fd 数推高,只是撞墙时间晚了几个小时。下面按这三种分开讲。

二、先读数:三个数字必须同时看

排查 fd 问题的第一动作是读三个数,缺一个都容易误判。

# 1) 系统级:已分配 / 上限
cat /proc/sys/fs/file-nr

# 2) 系统级上限(单独看)
cat /proc/sys/fs/file-max

# 3) 某个进程当前打开了多少个 fd
ls /proc/<pid>/fd | wc -l
ls /proc/<pid>/fd | wc -l

# 4) 该进程的 limit(软限制是实际生效值)
grep -E 'open files|Max open files' /proc/<pid>/limits

/proc/sys/fs/file-nr 是三个空格分隔的数字:已分配、已分配但未使用(这个值在新内核里恒为 0,不用管)、上限。判断系统是否危险,看第一个除第三个的比例,超过 80% 就要处理。

/proc/<pid>/limits 里的 soft limit 才是进程实际受约束的那个值,hard limit 是它能自己往上调的天花板。这两个值如果不相等,说明有程序在运行时自己调了软限制,这也是一个线索。

接下来最重要的一步:按 fd 类型分类。只看总数无法判断是「大量 socket」还是「大量普通文件」,而这两种的处置方式完全相反。分类靠 lsof 或直接读 fd 链接:

# 按类型统计(socket / REG / DIR / FIFO / CHR)
ls -l /proc/<pid>/fd 2>/dev/null | awk '{print $NF}' | grep -oE '^[a-z]+' | sort | uniq -c | sort -rn

# 找出最常被打开的文件,泄漏的元凶往往就在前几名里
ls -l /proc/<pid>/fd 2>/dev/null | sed 's/.* -> //' | sort | uniq -c | sort -rn | head -20

# 看 socket 到底连到哪(排查连接池泄漏)
lsof -p <pid> 2>/dev/null | grep -c socket
lsof -p <pid> 2>/dev/null | grep TCP | awk '{print $9}' | sed 's/.*->//' | sort | uniq -c | sort -rn | head

如果排行榜第一是某个日志文件被打开了上万次,那是典型的标准泄漏;如果第一名是 socket:[12345] 这种匿名 socket,那就是连接没关;如果前三名每条计数都是 1,说明 fd 是分散的,问题更可能在 limit 而不是泄漏。

三、fd 泄漏的四种常见形态

PHP、Python、Go 的泄漏形态不一样,值得分开说。

PHP 里的经典形态是「handler 没关」。fopen 之后 fclose 只写在 happy path 上,异常抛出时被跳过;curl_init 之后忘了 curl_close(PHP 8 之后由 GC 回收,但连接池会长时间持有);PDO 连接在长驻进程里越开越多。最隐蔽的是 file_get_contents 之后又 fopen 同一个文件没关,在 FPM 的单进程里累积,几十万个请求后就撑不住了。

Python 里的经典形态是「with 用了一半」。open() 之后靠 GC 回收,在 CPython 里引用计数会及时关,但一旦被闭包、异常 traceback 或循环引用挂住,垃圾回收就延迟到不可预期的时间。日志 handler 在 RotatingFileHandler 做轮转时如果不重开,fd 会指向已被 rename 的旧 inode,既泄漏又丢日志——这就是第 1997 篇讲的「删了文件空间不释放」的同源问题。

Go 里的经典形态是 defer 放错位置。defer resp.Body.Close() 写在循环内部是对的,写在循环外就是灾难:一轮循环开几百个连接,defer 全部堆到函数结束时才执行。http.Client 如果不复用(每次 new 一个),连接池各自独立,fd 会成倍增长。

所有语言的共同形态:连接池的「借出未归还」。数据库连接池、Redis 连接池、HTTP 客户端池,如果取连接之后在异常路径上没有归还,池会不断新建到 max 然后拒绝。这时 fd 数看起来正常(被 max 卡住了),但业务开始超时。判断方法是看池的「活跃连接数」与「空闲连接数」,而不是只看 fd 总数。

定位泄漏源有一个很实用的技巧:用 fd 编号的增长速率判断泄漏速度,用 fd 指向的目标判断泄漏对象,用「重新打开同一个路径的 fd 数」判断是否同一个对象被反复打开。比如 ls -l /proc/<pid>/fd | grep '/var/log/app.log' | wc -l 返回 3000,那基本可以确定是日志句柄没关。

四、limit 的四个生效层级,改错一个都白改

这是最容易翻车的地方。同一个「open files = 65535」要生效,需要同时满足四个层级,任何一层没改到,进程拿到的仍然是小值。

# 层级一:内核全局硬上限
cat /proc/sys/fs/nr_open        # 单个进程能设的上限,通常 1048576
cat /proc/sys/fs/file-max       # 整机所有进程的总量

# 层级二:systemd 服务(现代发行版的实际控制点)
systemctl show nginx -p LimitNOFILE
systemctl show php8.1-fpm -p LimitNOFILE

# 层级三:PAM,影响登录 shell 启动的进程
grep -E 'nofile' /etc/security/limits.conf /etc/security/limits.d/*.conf

# 层级四:进程自己的运行时设置
cat /proc/<pid>/limits | grep 'open files'

最常见的坑是改了 limits.conf 但服务没重启,或者服务由 systemd 启动而 systemd 不读 limits.conf。systemd 管理的服务有自己的一套 DefaultLimitNOFILE 和 unit 级 LimitNOFILE=,PAM 的配置对它不起作用。所以你改了 /etc/security/limits.conf 里的 * soft nofile 65535,重启 Nginx 一看还是 1024——不是没生效,是根本没被读。

# 正确的 systemd 写法:/etc/systemd/system/nginx.service.d/limits.conf
[Service]
LimitNOFILE=65535

改完必须 systemctl daemon-reload 再 restart(注意不是 reload,reload 只重新读配置,不重建进程,limit 不会变)。验证方式只有一个:systemctl show nginx -p LimitNOFILE 和 cat /proc/$(pidof nginx | awk '{print $1}')/limits 两边都要看到新值。注意 nginx 的 worker 进程是 master fork 出来的,要看 worker 的 pid 才算数。

另一个坑是 file-max 被调到比 nr_open 还大。有人看到「fd 不够」就 echo 9999999 > /proc/sys/fs/file-max,但如果 nr_open 只有 1048576,单进程依然上不去,而且还可能出现「系统级数字看着很大但进程撞墙」的矛盾现象。这两个值的关系是:nr_open 管单进程,file-max 管总和,后者应当显著大于前者乘以预期的并发进程数。

五、把上限一下调小造成的整机故障

这一节是本文最想强调的。有大量「Linux 优化」文章会建议:

# 危险建议,不要不加思考地执行
echo "fs.file-max = 65535" >> /etc/sysctl.conf

看起来是在「设置合理的上限」,实际是把系统上限从百万级降到六万五。这个改动在低负载时完全看不出问题,等到流量上来——或者某个进程开始泄漏——整机所有服务会同时撞墙。因为 file-max 是全局的,Nginx 用掉四万、MySQL 用掉一万五、日志和监控再吃点,剩下的就不够开一个 SSH 会话了。这时候你会发现修复手段都进不去:SSH 拒绝连接、rescue 需要控制台、而运维可能根本不知道几个小时前有人改过这个值。

判据很简单:file-max 只能往大调,永远不该往小调。如果确实要控制资源,用 LimitNOFILE 按服务分别限,不要动全局的 file-max。这两者的语义完全不同:前者是「给这个服务多少」,后者是「这台机器总共能给多少」。

真正安全的加固方式是反向的——给每个服务设一个合理的上限,防止单个服务失控吃光全局配额:

# 给可能泄漏的服务设一个「不会拖垮全机」的天花板
[Service]
LimitNOFILE=65535
# 而不是去动 /proc/sys/fs/file-max

配合一个监控:file-nr 的占用率超过 70% 告警。这个值上升通常是渐进的,有足够时间反应;一旦它到顶,你连排查的窗口都没有。

六、fd 数正常但服务报错:三个误判点

误判一:把 socket 的 TIME_WAIT 当成 fd 泄漏。短连接服务(比如每秒几千次的 HTTP 调用)会让 socket fd 大量处于 TIME_WAIT,看起来像个持续泄漏,但实际上是正常的连接回收阶段。区别在于:泄漏的 fd 数单调上涨不回落,TIME_WAIT 的 fd 数会在 60 秒后周期性下降。看两分钟的曲线就能区分,不用改任何参数。

误判二:容器里的 fd 与宿主机的 fd 混着看。容器有自己的 namespace,/proc/sys/fs/file-nr 在容器里看到的是宿主机视角(这个文件不是 namespace 隔离的),而进程的 limit 是容器自己的。所以会出现「容器里看全局用了 60%、进程 limit 还有余量,但就是报 Too many open files」——那多半是 cgroup 层面的限制,或者 pids.max 撞了而不是 fd 撞了。后者报错信息很像,要分清。

误判三:只看 ulimit -n。ulimit -n 显示的是当前 shell 的值,不是目标服务的值。在 SSH 会话里跑一次觉得「已经调到 65535 了」,实际那个服务由 systemd 启动、根本不受当前 shell 影响。这是新手在 fd 问题上最容易原地打转的地方。

七、一份可以照做的处置顺序

  1. 读三个数:file-nr、进程 fd 总数、进程 limit。先判断是三种病里的哪一种。
  2. 如果是第三种(全局撞顶),立刻找占用最大的三个进程,先给其中一个降载或重启,确保拿回操作空间,再慢慢分析。这时候不要重启全机。
  3. 如果是第二种(limit 不够),改 systemd 的 LimitNOFILE,daemon-reload + restart,两边验证。
  4. 如果是第一种(泄漏),分类 fd 类型 → 找排行榜第一的路径或 socket → 定位到代码或组件的具体位置 → 修。临时措施是给服务加一个「重启阈值」,或者用 prlimit --pid <pid> --nofile=65535:65535 在线抬高上限争取时间(这个操作对运行中的进程有效,但重启后失效,别把它当修复)。
  5. 无论哪种,最后都补上 file-nr 与进程 fd 数的监控。fd 问题几乎都有前兆,只是没人看前兆。

八、小结

fd 问题的核心不是「把数调大」,而是先分清楚是泄漏、是限额不足、还是全局撞顶。三种病的处理完全不同,用错药的结果是把急性病拖成慢性病。另外记住两条硬规则:file-max 只许调大不许调小,LimitNOFILE 才是按服务限流的正确工具。

最后一条经验:fd 数量是一条曲线,不是一次读数。图上如果有缓慢上升的斜率,那就是泄漏,早点处理;如果是阶梯式上升后平台期,那是并发量增长,属于正常。学会看曲线的形状,比记住任何一个参数值都有用。

Last modification:November 7th, 2026 at 12:34 pm

Leave a Comment