Too many open files 排查实战:ulimit 软硬限制、/proc 计数定位与 systemd LimitNOFILE 配置

一个被忽视的故障类型:进程"打开不了文件"了

凌晨两点,监控告警:网站 502。你登上服务器,Nginx 进程活着,PHP-FPM 进程活着,负载不高,内存充足,磁盘还有空间。你翻了半天日志,终于在 PHP 的错误日志里看到一行不起眼的东西:

PHP Warning:  fopen(/www/wwwroot/site/cache/data.php): failed to open stream: Too many open files

再去翻 Nginx 的错误日志,也有类似的东西:

2026/09/25 02:11:07 [crit] 1234#1234: *998877 open() "/www/wwwroot/site/logo.png" failed (24: Too many open files)

这两个错误是同一个病根:文件描述符(File Descriptor,简称 fd)用完了。它不是内存不够、不是磁盘不够,而是内核给这个进程发放的"文件句柄配额"被耗尽了。这个故障最迷惑人的地方在于:服务器本身一切正常,只有某个特定进程卡住。你用 top 看不到异常,用 free -h 看到内存一大半空闲,最后很容易怀疑成"服务器不行了、要升级配置",换一台机器,过几天同样的问题再犯一次。

这篇文章把这个问题拆开讲清楚:fd 到底是什么、谁在限制它、怎么一眼看出是它、以及系统级和进程级两个层面究竟该改哪里。

先搞懂 fd 到底是什么:它不是"文件数量",而是"打开的动作"

很多人第一次听到"文件描述符耗尽",会以为是自己网站文件太多——几万个静态文件把配额用光了。这是个典型的误判。

文件描述符是 Linux 内核用来追踪"一个进程正在使用哪些资源"的编号。它不代表磁盘上有多少文件,而代表这个进程此刻打开了多少个东西。而"东西"远不止普通文件:

  • 普通文件,比如 logo.png、data.php
  • 目录,是的,opendir() 也会占一个 fd
  • 网络连接,每一个 TCP socket 就是一个 fd,包括每个来自浏览器的 HTTP 连接
  • 管道(pipe)和 socketpair
  • 共享内存、epoll 句柄、inotify 监听

这就解释了一个反直觉的现象:一个网站流量突然变大,最先被吃掉的资源可能不是 CPU、不是内存、不是带宽,而是 fd。因为每个并发 HTTP 连接都要占一个 socket fd,每个 PHP 进程处理的每个请求可能还要再打开几个文件。500 个并发连接,每个连接背后再打开 3 个文件,就是 2000 个 fd 的瞬时消耗。

而 fd 的特点恰恰是"用完不还"——只要你没调用 close(),或者代码里出现了"打开文件后忘记关闭"的逻辑漏洞,fd 就会像内存泄漏一样只增不减,直到撞上配额上限。

两层限制:系统级和进程级,改错一层等于没改

这是最容易踩坑的地方。Linux 对 fd 的限制是两层叠加的,任何一层不够,业务都会失败:

第一层:系统级上限 fs.file-max

这是整个操作系统层面允许打开的最大 fd 总数,所有进程共享。查看命令:

cat /proc/sys/fs/file-max
# 9223372036854775807   (现代 Debian/Ubuntu 上这个值极大,通常不是瓶颈)

# 更实用的是看"当前用了多少"
cat /proc/sys/fs/file-nr
# 3840    0    9223372036854775807
#  ^已分配   ^未使用(老内核)   ^上限

在绝大多数现代发行版上,fs.file-max 大到几乎不可能成为瓶颈,所以如果 fd 耗尽,你几乎不需要动这一层。看到这里就急着改 fs.file-max 的人,通常是把问题找错了地方。

第二层:进程级上限 nofile(真正的瓶颈)

每个进程有自己独立的 fd 配额,由 软限制(soft limit) 和 硬限制(hard limit) 组成。软限制是进程实际能用的数量,硬限制是软限制能提升到的天花板。查看方式:

# 看当前 shell 的限制
ulimit -n
# 1024

# 看某个具体进程的限制(最准确的排查入口)
cat /proc/$(pgrep -f php-fpm | head -1)/limits | grep -i "open files"
# Max open files            1024                 4096                 files
#                          ^软限制             ^硬限制

1024 是传统默认值,也是无数线上故障的根源。一个 PHP-FPM 子进程如果同时要处理若干请求、每个请求又要访问缓存文件和数据库连接,1024 很容易被顶穿。Nginx 做反向代理时更夸张:一个 worker 要同时持有"客户端连接 fd + 上游连接 fd",500 并发连接就是 1000 个 fd,默认配置下必然爆。

三步定位:确认到底是不是 fd 问题

不要凭感觉改配置。先做三个确认,每一个都有明确的输出判据。

第一步:数一个进程当前用了多少 fd

# 统计 PHP-FPM master 进程及其所有子进程
ls /proc/$(pgrep -f 'php-fpm: master' | head -1)/fd | wc -l

# 或者针对指定 PID
ls -l /proc/12345/fd | wc -l

判据:如果这个数字已经贴近 /proc/PID/limits 里的软限制,就基本锁定了。比如软限制 1024,实测 1010,那就是它。

第二步:看这些 fd 到底是什么

数量接近上限只是第一步,真正有价值的是知道它们"卡在哪"。这条命令能按类型归类:

ls -l /proc/12345/fd 2>/dev/null | awk '{print $NF}' | sed 's/[0-9]\+$//' | sort | uniq -c | sort -rn | head -20

典型输出会像这样:

    812 socket:[
     45 /www/wwwroot/site/cache/
    12 /var/log/php-fpm.log
      8 /dev/null
      3 pipe:[

812 个 socket 意味着问题不是"文件泄漏",而是"连接没释放"——大概率是上游数据库连接池没管好、或者有慢请求把连接挂住了。反之如果大量 fd 指向同一个日志文件或同一个缓存目录,那就是代码层面的泄漏,得回去查业务逻辑。

这一步是分水岭:socket 多,改配置(加大配额)就能缓解;普通文件多,改配置只是让你晚一点再崩,必须改代码。

第三步:确认系统级总量还有余量

cat /proc/sys/fs/file-nr | awk '{print "已分配:",$1, " 上限:",$3}'
ss -s | head -3

如果"已分配"远小于上限,且 ss -s 显示的 total socket 数量正常,那就不用动系统参数,问题纯粹在进程级。

修复:systemd 时代必须改的三个地方

难点在于,ulimit -n 65535 这种写法只在当前 shell 有效,重启即失效,而且对 systemd 管理的服务完全无效。这是很多人改了没用的根本原因:Nginx、PHP-FPM、MySQL 在新版系统上都是 systemd 单元,它们不读 /etc/security/limits.conf 的某些字段,也不继承你 shell 的 ulimit。

改法一:service 单元的 LimitNOFILE(正统、推荐)

用 systemctl edit 创建 override 文件,不要直接改 /lib/systemd/system/ 下的原始文件(升级会被覆盖):

systemctl edit nginx

在打开的编辑器里写入:

[Service]
LimitNOFILE=65535

PHP-FPM 同理:

systemctl edit php-fpm   # 或者 php8.2-fpm,取决于你的单元名
# [Service]
# LimitNOFILE=65535

关键点:LimitNOFILE 同时设置软限制和硬限制,一个值搞定,比 ulimit 干净得多。改完必须 systemctl daemon-reload 再 systemctl restart nginx,否则不生效。

改法二:Nginx 自己的 worker_rlimit_nofile

Nginx 有一层额外的自我限制,写在主配置 nginx.conf 的顶层(必须在 events 块之外):

worker_rlimit_nofile 65535;

events {
    worker_connections 10240;
    use epoll;
}

这里的数量关系必须成立,否则 Nginx 会在启动日志里报 warning:

worker_rlimit_nofile  >=  worker_processes * worker_connections * 2

为什么乘 2?因为一个反向代理连接要同时占用"下游客户端 fd"和"上游后端 fd"两个描述符。只调 worker_connections 而不调 worker_rlimit_nofile,会发现连接数上不去——这是最常见的漏配。

改法三:limits.conf(兜底,针对非 systemd 进程)

对于自己用脚本、crontab 或者超级用户手工拉起的进程,配置文件仍然有效:

# /etc/security/limits.conf
*    soft    nofile    65535
*    hard    nofile    65535
root soft    nofile    65535
root hard    nofile    65535

注意几点:* 通配符不包含 root,所以 root 要单独写一行;修改后需要重新登录才生效;systemd 托管的服务不受这里影响(这也是为什么改法一和改法二都要做)。

验证:三个数字必须全部对上

重启服务后,逐条核对:

# 1. 进程级限制是否真的变了
systemctl show nginx -p LimitNOFILE
# LimitNOFILE=65535

cat /proc/$(pgrep -f 'nginx: master' | head -1)/limits | grep -i "open files"
# Max open files            65535                65535                 files

# 2. Nginx 配置是否通过校验并生效
nginx -t
# nginx: configuration file /etc/nginx/nginx.conf test is successful

# 3. 压力下的实际表现
watch -n1 'ls /proc/$(pgrep -f "nginx: worker" | head -1)/fd | wc -l'

只有 systemctl show 和 /proc/PID/limits 两个地方都显示 65535,才算真正改成功。很多人只看了 systemctl show,但进程没重启,实际 limits 还是 1024。

必须提醒的坑:加配额不解决泄漏

把 1024 改成 65535,相当于把堤坝从 1 米加高到 65 米。如果 fd 是缓慢泄漏(比如每秒漏一个),你只是把崩溃时间从几小时推迟到几十小时。所以正确的心态是:

  • 加大配额是止血,排查泄漏才是治病。先在改配额的同时开启长期监控,看 fd 数量是"随流量涨落"还是"单调递增"。
  • 随流量涨落是正常的,说明只是配额太小;单调递增的曲线必有泄漏,要回去查代码里的 fopen/curl_init/数据库连接是否成对释放。
  • 别忘了 MySQL 和 Redis 也有自己的 fd 需求。MySQL 的 open_files_limit、Redis 的 maxclients 都会互相牵扯,特别是把 MySQL 和 Web 服务放在同一台低配服务器上时,两个进程会抢系统级总量。

一个务实的做法是给关键进程设一个观测告警,不要等它爆了才发现:

# 每 5 分钟采集一次,超过软限制 80% 就告警
PID=$(pgrep -f 'php-fpm: master' | head -1)
USED=$(ls /proc/$PID/fd 2>/dev/null | wc -l)
LIMIT=$(awk '/open files/{print $4}' /proc/$PID/limits)
[ "$USED" -gt $((LIMIT * 8 / 10)) ] && echo "WARN: php-fpm fd $USED/$LIMIT"

小结

"Too many open files" 这个错误看起来指向系统资源不足,实际上绝大多数情况下指向的是一个进程的配额配置没调。排查顺序永远是:先数当前 fd、再归类 fd 类型、再确认系统级余量、最后才动配置。修复时记住 systemd 服务要改 LimitNOFILE,Nginx 还要额外配对 worker_rlimit_nofile 和 worker_connections。改完一定要用 /proc/PID/limits 复核,而不是只看配置文件。

把这两层参数理清楚,你会发现这个困扰很多站长半夜起床的故障,其实是一次配置补齐就能永久解决的小问题。真正需要警惕的是那条单调上升的 fd 曲线——配额只是止痛药,找到那个忘记 close() 的地方,才是根治。

Last modification:September 25th, 2026 at 07:23 pm

Leave a Comment