Too many open files:句柄泄漏排查

Too many open files:不是 ulimit 太小

「Too many open files」这个报错几乎每个运维都见过,但大部分人的第一反应都是去改 ulimit -n,把它从 1024 改成 65535,然后重启服务。这种做法有时候能撑几天,有时候连一分钟都撑不过去。问题在于:如果报错的原因是「进程泄漏了文件描述符」,那把上限提高 60 倍,只是把崩溃时间从 3 天推迟到 180 天,泄漏量本身一点没变。

更麻烦的是,文件描述符耗尽在多进程架构下表现得非常奇怪。PHP-FPM 主进程的信令管道、Nginx 的 listen socket、MySQL 的连接都会受影响,但报错信息会分散在三四个不同的日志文件里,看起来像是三件互不相关的事:nginx 报 accept4() failed (24: Too many open files),PHP 报 fopen(): failed to open stream: Too many open files,而 MySQL 那边可能只表现为连接建立超时。等你查清楚它们是同一个根因,已经过去两个小时了。

这篇要讲的是:怎么在 5 分钟内判断这到底是「配置上限真的不够」还是「有东西在漏」,以及漏的时候怎么精确找出是谁在漏。

先分清三个不同的上限

「文件描述符上限」其实不是一层,是三层,而且很多时候你以为改了,其实没生效。

第一层是系统级:/proc/sys/fs/file-max,这是整个系统所有进程能打开的文件总数上限。现代内核一般自动算到内存的 10% 左右,除非你用的是极小内存的容器,否则不太可能撞到这个。

第二层是用户级:/etc/security/limits.conf 或 /etc/security/limits.d/*.conf 里配的 nofile。这里有 soft 和 hard 两个值,进程可以自己把 soft 提到 hard,但不能超过 hard。关键坑是:如果你只改了 soft 没改 hard,进程调 setrlimit() 提额会失败;如果你改的是 root 的配置,而服务实际以 www-data 运行,那配置改了个寂寞。

第三层是进程级:这是真正生效的值,只能看 /proc/PID/limits。三层里只有这一层说了算。

所以排查的第一步永远不是改配置,而是看进程实际拿到多少:

# 看某个进程实际的 fd 上限
grep -i 'open files' /proc/$(pidof nginx | cut -d' ' -f1)/limits

# 看所有 nginx 进程各自的占用情况
for p in $(pidof nginx); do
  echo "PID $p  fd=$(ls /proc/$p/fd 2>/dev/null | wc -l)"
done

如果 limits 里显示 1024 而你明明改了 limits.conf,问题基本就定位了:要么服务不是通过 PAM 启动的(systemd 服务默认不走 limits.conf,见下文),要么改错了用户。

systemd 服务不读 limits.conf(最容易白改的地方)

这是 Debian 10 以后最常见的坑。你改了 /etc/security/limits.conf 里的 * soft nofile 65535,然后 systemctl restart nginx,结果 /proc/PID/limits 里还是 1024。原因是 limits.conf 由 PAM 的 pam_limits.so 在登录时读取,而 systemd 启动的服务根本不经过 PAM 登录流程。

正确的改法是给服务加 drop-in:

mkdir -p /etc/systemd/system/nginx.service.d
cat > /etc/systemd/system/nginx.service.d/limits.conf <<'UNIT'
[Service]
LimitNOFILE=65535
LimitNPROC=65535
UNIT

systemctl daemon-reload
systemctl restart nginx
grep -i 'open files' /proc/$(pidof nginx | cut -d' ' -f1)/limits

PHP-FPM 如果用的是官方 unit,同理写 /etc/systemd/system/php-fpm.service.d/limits.conf,改完必须 daemon-reload 再重启。daemon-reload 不能省,只 restart 的话 systemd 用的还是旧 unit 缓存。

另外还有一个更隐蔽的:如果你在 nginx.conf 里写 worker_rlimit_nofile 65535;,它只调 nginx worker 进程自己的 soft 上限,前提是 systemd 给的 hard 上限足够。如果 hard 还是 1024,这一行会被内核静默忽略,nginx 不会报错,也不会有任何日志。所以 worker_rlimit_nofile 和 LimitNOFILE 是配套的,只写一个等于没写。

判断「不够」还是「漏了」:三个数

改完上限之后别急着收工,先回答一个问题:现在的占用是稳定在一个高位,还是在持续往上爬?

数一,fd 总量的时间曲线。相隔 10 分钟采两次:

for i in 1 2; do
  echo "$(date +%T) total=$(ls /proc/*/fd 2>/dev/null | wc -l)"
  sleep 600
done

如果两次差几百个,那就是在漏,跟上限没关系。正常服务的 fd 占用会有小幅波动(连接建立和关闭),但基线应该稳定。

数二,单个进程的 fd 构成。找出是 socket 多还是文件多:

PID=$(pidof php-fpm8.2 | cut -d' ' -f1)
ls -l /proc/$PID/fd 2>/dev/null | awk '{print $NF}' | sed 's/[0-9]//g' | sort | uniq -c | sort -rn | head

输出里 socket: 占绝对多数 → 是连接没关;/var/log/... 或 /path/to/uploads/... 反复出现 → 是文件没关;出现大量 (deleted) → 是日志轮转后进程还攥着旧句柄不放。

数三,有没有「已删除但仍被打开」的文件。这一条经常能直接抓到元凶:

lsof +L1 2>/dev/null | awk 'NR==1 || $5+0 > 0' | head -20

如果看到某个进程攥着几十上百个 (deleted) 文件,那基本就是它了。最常见场景是日志轮转配置错了:logrotate 把文件 rename 走了,但进程没有收到 SIGHUP,于是继续往那个已经不存在的 inode 写,每个轮转周期泄漏一批。

六个真实的泄漏源

日志轮转没让进程重开句柄。logrotate 配置里必须带 postrotate 里的 reload 动作。Nginx 用 nginx -s reopen 或 kill -USR1,PHP-FPM 用 kill -USR2。少了这一句,每周漏一批,一个月就能看到占用曲线明显抬升。

PHP 的 curl 句柄没关。这是 PHP 应用里最高频的泄漏。curl_init() 之后如果异常路径提前 return,curl_close() 就执行不到。虽然 PHP 8 起 CurlHandle 是对象、GC 会回收,但回收时机不确定,在请求密集时会短期堆积。用 try/finally 保证释放,或者干脆用 file_get_contents 处理简单请求。

数据库连接池没有上限。PHP-FPM 的 pm.max_children 是 100,每个子进程持有一个到 MySQL 的连接,MySQL 的 max_connections 是 151,当 FPM 打满时连接数就撞顶了。这里的「文件描述符」其实是 MySQL 那侧的。查法:SHOW STATUS LIKE 'Threads_connected'; 对照 pm.max_children × pool 数量。

fopen 打开了文件但忘了 fclose。常见于自己写的日志类、缓存类。表现是某个目录下的文件被反复打开,用上面「数二」的方法看路径就能定位到具体代码位置。

epoll 里注册的 fd 没注销。用 epoll_ctl(EPOLL_CTL_ADD) 注册后,关闭 fd 时内核会自动从 epoll 集合里移除,但如果你在事件循环里只把连接标记为「已关闭」而没有真的 close(),那 fd 就一直在。这类泄漏在自研的长连接服务里很常见,普通 PHP 站不太会遇到。

inotify watch 泄漏。如果用了 inotifywait 或者 fsnotify 类库监听目录,每次 inotify_add_watch 都会占一个 fd。监听目录被重建(比如 rm -rf 后重新创建)后旧 watch 失效但不释放,会持续累积。查法:cat /proc/PID/fdinfo/* 2>/dev/null | grep -c inotify。

用 strace 抓现行:盯住 open 和 close 的差值

如果上面的方法还定位不到,就直接上 strace 数系统调用。思路是:一个健康的进程,openat 和 close 的次数应该大致相等;差值持续扩大就是在漏。

PID=$(pidof php-fpm8.2 | cut -d' ' -f1)
timeout 30 strace -f -p $PID -e trace=openat,close,accept4 -c 2>&1 | tail -20

-c 会给出汇总表,直接看 openat 和 close 两行的 calls 数。如果 30 秒里 openat 比 close 多出几百次,那就是有路径在漏;接着去掉 -c,加上 -o /tmp/trace.txt 落盘,然后 grep 'openat' /tmp/trace.txt | grep -oE '"[^"]+"' | sort | uniq -c | sort -rn | head -20,就能看到是哪个文件被反复打开。

注意 -f 会跟随子进程,输出量可能很大,务必加 timeout 并落盘而不是直接打到终端。-p 附加到正在运行的进程时,被附加的进程会短暂暂停,如果是在线业务,最好挑一个 worker 而不是主进程,并且避开高峰。

关于 lsof 的两个使用误区

误区一:以为 lsof | wc -l 等于实际占用。lsof 会列出所有能看到的打开文件,包括内存映射的库文件(/lib/x86_64-linux-gnu/libc.so.6 之类),这些每个进程都有几十个。真正该看的是网络连接和业务文件,用 lsof -i 或者指定进程 lsof -p PID。

误区二:在 fd 快耗尽时跑 lsof。lsof 本身也要打开文件才能工作,在一个 fd 已经见底的系统上,lsof 自己可能就报 Too many open files 然后给你一半数据。这种时候用 ls /proc/PID/fd 更可靠,因为读 /proc 用的是 procfs 的虚拟文件,开销小得多。

一个真实案例:从 12000 个 fd 降到 340 个

某台跑 Typecho 的机器,每两周左右就会在凌晨出现一次 502,重启 PHP-FPM 后恢复。运维的处置方式是把它加进 crontab:0 4 * * * systemctl restart php-fpm,每天凌晨主动重启一次,把「两周一次」变成「永远不会发生」。这个方案看起来很有效,直到有一天白天流量高峰时它挂了。

登进去看,FPM 主进程的 fd 占用是 12000 多,上限是 65535,远远没到。所以问题不是 fd 耗尽。但 ls -l /proc/PID/fd | awk '{print $NF}' | sed 's/[0-9]//g' | sort | uniq -c | sort -rn | head 的输出里,/www/wwwlogs/xxx.log 出现了 4100 次。

这说明有 4100 个句柄同时指向一个日志文件。正常情况应该只有 1 个(所有子进程共享同一个 fd)。继续查 lsof +L1,发现大量 (deleted) 状态的日志文件——logrotate 每天都把这天的日志 rename 成 .1 然后新建一个,但 PHP 的子进程没有收到重开信号,于是每个子进程都还攥着旧 inode。FPM 的 pm.max_requests 没设,子进程长期不退出,句柄就一路累积。

根因是 logrotate 配置里缺了 postrotate:

# 错误:只轮转,不通知进程
/www/wwwlogs/*.log {
    daily
    rotate 14
    compress
    missingok
}

# 正确:轮转后让 FPM 重开句柄
/www/wwwlogs/*.log {
    daily
    rotate 14
    compress
    missingok
    notifempty
    sharedscripts
    postrotate
        [ -S /run/php/php8.2-fpm.sock ] && kill -USR2 $(cat /run/php/php8.2-fpm.pid) || true
    endscript
}

补上 postrotate 之后,句柄数从 12000 降到 340,稳定不涨了。同时把「每天凌晨重启 FPM」的 crontab 删掉了——那条 crontab 之所以看起来有效,正是因为它顺手清掉了累积的句柄,掩盖了真正的病因。这个案例的教训很直接:用重启掩盖症状,等于把「可观测的慢性病」变成「不可观测的定时炸弹」。

一套可落地的监控与收口

配置层面收口三件事:systemd drop-in 里把 LimitNOFILE 设为 65535;nginx.conf 里 worker_rlimit_nofile 与之一致;logrotate 的每一段都补上 postrotate 的 reopen 动作。这三件事做完,90% 的「配置性 fd 不足」就没了。

监控层面加一条定时检查,比重启管用得多:

#!/bin/bash
# /usr/local/bin/fdcheck.sh   每 5 分钟跑一次(cron 或 systemd timer)
LIMIT=65535
for p in $(pgrep -f 'php-fpm: pool' | head -5); do
  n=$(ls /proc/$p/fd 2>/dev/null | wc -l)
  pct=$((n * 100 / LIMIT))
  if [ "$pct" -gt 70 ]; then
    echo "fd high: pid=$p used=$n limit=$LIMIT pct=${pct}%"
    ls -l /proc/$p/fd 2>/dev/null | awk '{print $NF}' | sed 's/[0-9]//g' | sort | uniq -c | sort -rn | head -5
  fi
done

把输出接到已有的告警通道上。关键是阈值设在 70% 而不是 95%——到了 95% 再去查,进程已经在疯狂打日志,取样困难;70% 的时候系统还很安静,随手 ls -l /proc/PID/fd 就能看清构成。

最后一句实话:把 ulimit 从 1024 改到 65535 本身没错,现代服务该拿到这个上限。但请把它当成「消除误报」,而不是「修好了问题」。改完之后一定要做一次间隔 10 分钟的双采样,确认占用曲线是平的。如果还在爬,那漏的那一头迟早会把 65535 也用完,只是晚一点而已。

Last modification:October 22nd, 2026 at 01:27 pm

Leave a Comment