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)/limitsPHP-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 也用完,只是晚一点而已。