一、凌晨三点的"too many open files"
这个名字很唬人的报错,实际上是一句非常朴素的话:这个进程想把文件句柄数量加到超过允许的上限,被内核拒绝了。它出现在错误日志里的时候往往已经是结果,真正的问题可能埋了几个小时甚至几天。
我在一个日 PV 二十万左右的站点上遇到过:每天下午两点左右,PHP-FPM 日志开始零星出现 Too many open files,前端表现为一部分用户 502,十分钟后又自己好了。重启一下 PHP-FPM 就恢复,于是运维形成了一种"每天重启一次"的伪习惯——这不是解决问题,只是把问题的周期重置了。
本文把这件事故拆开讲:句柄从哪来、怎么定位是谁在漏、以及怎么用 ulimit 和系统参数把上限和回收都管住。
二、先理解"文件"在 Linux 里的含义
Linux 里"文件描述符"(fd)不只指磁盘文件。下面这些都占一个 fd:
- 普通文件:日志文件、配置文件、上传的临时文件;
- 网络连接:每一条 TCP 连接(含 TIME_WAIT 之外的活动连接)就是一个 fd;
- 管道与 socketpair:进程间通信;
- epoll 实例、inotify 监听、eventfd;
- 动态库加载时的 mmap 也会短暂占用。
所以"打开文件数暴涨"有两大类原因:一是连接数涨了(流量增长、慢客户端堆积、健康检查过密),二是代码真的漏了(打开没关)。前者调参数,后者改代码,两者处置完全不同。搞混方向是这类故障最常见的误判。
三、三个不同层级的上限,别只改一个
这是最容易踩的坑:Linux 里 fd 上限不是一处配置,而是三处叠加,生效值取最小值。
| 层级 | 查看方式 | 说明 |
|---|---|---|
| 系统级 | cat /proc/sys/fs/file-max | 全系统所有进程可用的 fd 总数,通常几十万,改动少 |
| 进程级软限制 | ulimit -n | 当前 shell 的软上限,子进程继承 |
| 进程级硬限制 | ulimit -Hn | 非 root 只能把软限制调到硬限制以内 |
另外还有 systemd 服务自己的 LimitNOFILE,以及容器里的 ulimits 配置。如果服务由 systemd 拉起,而 /etc/security/limits.conf 里写了 * soft nofile 65535,在多数发行版上这个配置对 systemd 服务不生效,因为 systemd 的 PAM 会话不读 limits.conf。很多"我明明改了 ulimit 怎么还是 1024"的困惑就来自这里。
正确的改法分两处,缺一不可:
# /etc/systemd/system/php-fpm.service.d/limits.conf
[Service]
LimitNOFILE=65535
LimitNPROC=65535systemctl daemon-reload
systemctl restart php-fpm
# 验证(PID 换成实际主进程)
cat /proc/$(pidof php-fpm8.2 | awk '{print $1}')/limits | grep -i 'open files'如果是 Nginx,同理改 nginx.service 的 drop-in,而不是只改 nginx.conf 顶部的 worker_rlimit_nofile。两者都改也不冲突,但只改后者时如果 systemd 的硬限制是 1024,Nginx 主进程启动时就会失败或被子进程继承到小值。
四、定位是谁在漏:四步走
知道上限怎么配之后,下一个问题是"到底是谁把句柄用光了"。下面这套流程我在线上用过很多次,不需要装任何额外组件。
第一步,看系统总览。
cat /proc/sys/fs/file-nr
# 输出三列:已分配 / 已释放待回收 / 上限
# 第一列持续上涨并接近第三列,说明系统级快满了第二步,按进程排序。
for p in $(pgrep -d' ' -u www-data); do
n=$(ls /proc/$p/fd 2>/dev/null | wc -l)
echo "$n $p $(cat /proc/$p/comm 2>/dev/null)"
done | sort -rn | head -10正常情况下 PHP-FPM 每个 worker 20~50 个 fd 是健康的;如果某一个 worker 有几千个,说明它卡在某个循环里没关句柄。
第三步,看它到底开了什么。
ls -l /proc/12345/fd | awk '{print $NF}' | sort | uniq -c | sort -rn | head -20这一步的洞察力很强。如果大量指向同一个日志文件,是日志轮转后没重新打开(经典问题:logrotate 用 mv 而不是 copytruncate,进程还持有旧 inode);如果大量是 socket:[...],就是连接没关,去看是到数据库、到 Redis 还是到 HTTP 上游。
第四步,判断是"活着"还是"卡住"。
cat /proc/12345/status | grep -E 'State|Threads'
ls -l /proc/12345/fd | awk '{print $NF}' | grep -c socket
ss -tanp | grep 12345 | awk '{print $1}' | sort | uniq -c如果 socket 大量处于 CLOSE_WAIT,那是对端已关、本进程没关,典型是代码里忘了 fclose 或 close,或者用了连接池但归还逻辑在异常分支被跳过。如果大量是 ESTABLISHED 且时间很长,那是慢客户端或上游没有超时设置,连接一直挂着。
补充一个只看不装的工具:lsof -p 12345 | wc -l 和 lsof -p 12345 | grep -c socket 也能达到同样效果,只是大并发时会慢一点。
五、最常见的三种泄漏形态
形态一:日志轮转后没重开。Nginx、PHP-FPM、自研的常驻进程都会遇到。logrotate 默认用 create 模式,把 access.log 改名后新建,但进程仍往旧 inode 写。表面上看是"日志不更新",深层是旧文件被句柄钉住、磁盘空间不释放。df 显示已满、du 却找不到大文件,就是它。
修法是 logrotate 里加 postrotate 发 USR1,或者干脆用 copytruncate(有短暂时序风险,但兼容性最好):
/var/log/nginx/*.log {
daily rotate 14
missingok notifempty compress delaycompress
sharedscripts
postrotate
[ -f /run/nginx.pid ] && kill -USR1 $(cat /run/nginx.pid)
endscript
}形态二:数据库连接池没归还。PHP 里用长连接(pdo 的 ATTR_PERSISTENT)时,如果代码在异常分支里 return 而没有显式释放事务,连接会越积越多。MySQL 侧 show processlist 能看到大量 Sleep 状态的连接,从数据库侧数一下就知道合不合理。
形态三:HTTP 客户端没有超时。这点在 PHP 里非常常见:file_get_contents 默认超时是 default_socket_timeout(60 秒),curl 不设置 CURLOPT_TIMEOUT 更是可以挂到天荒地老。一旦外部接口出问题,所有 worker 都被一条连接卡住,新请求进来只能排队。这类"泄漏"其实不是泄漏,而是卡死,但表现完全一样。
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_TIMEOUT => 8,
CURLOPT_FORBID_REUSE => false,
]);
$out = curl_exec($ch);
curl_close($ch);连接超时和整体超时要分开设。CONNECTTIMEOUT 管的是"连不上",TIMEOUT 管的是"连上了但没返回"。只设后者的常见后果是:DNS 坏了的时候,请求会卡在解析阶段很久。
六、把上限调到什么值才合适
不是越大越好。fd 上限是一个"理论上限",调大不会立即占用内存,但会掩盖问题:漏句柄的程序会在几小时里慢慢吃掉整个系统的可用 fd,等到影响别人时更难查。
我的经验值:
- 静态站 Nginx:
worker_rlimit_nofile=worker_connections× worker 数 + 余量,通常 65535 足够; - PHP-FPM:
pm.max_children× 每个子进程平均 fd 数 × 2 的余量,一个 50 子进程的池,65535 也是宽裕的; - 数据库:MySQL 每连接约占 1 到 2 个 fd,连接数上千时需要
open_files_limit相应调大。
真正需要盯的不是上限值,而是使用率。我把下面这条放进监控,每五分钟采样一次,超过 60% 就告警:
usage=$(awk '{printf "%d", $1*100/$3}' /proc/sys/fs/file-nr)
[ "$usage" -gt 60 ] && echo "fd usage ${usage}%" | mail -s "fd warning" ops@example.com有了这条,就再也不会出现"突然全站 502 才发现句柄满了"的情况。
七、容器与 systemd 下的两个隐形坑
第一个坑是 Docker 的 nofile 限制。容器默认继承 dockerd 的限制,早期版本默认是 1024,容器里的进程 ulimit -n 也是 1024,宿主机调多大都没用。需要显式传:
docker run --ulimit nofile=65535:65535 ...或者在 compose 里写 ulimits: nofile: {soft: 65535, hard: 65535}。改完进容器 ulimit -n 验证,不要在外层看。
第二个坑是 systemd 的 TasksMax / LimitNOFILE 与 Restart= 的关系。服务自动重启时,新的进程会继承新的配置,但已经打开的 socket 可能仍在 TIME_WAIT 里,短时间内的 fd 压力会更高。频率敏感的服务可以配 RestartSec=5 给一点缓冲,避免"重启风暴"把系统 fd 打满。
八、一次真实故障的完整时间线
回到开头那个"每天下午两点 PHP-FPM 开始报 Too many open files"的案例,把六天的排查压缩成一条时间线,能看清方法论到底省掉了什么。
第一天,只做了两件事:确认 /proc/sys/fs/file-nr 的使用率在两点前后从 12% 冲到 90%,再按进程排序找到是 www-data 下七个 worker 各自持有两千多个 fd。当时的第一反应是"池子开太大",其实方向就错了——pm.max_children 只是五十,五十个进程绝不会天然需要一万四千个句柄。
第二天,ls -l /proc/PID/fd 显示这七个 worker 打开的几乎全是 socket:[...],指向同一个上游地址。用 ss -tanp 按状态分组,发现 CLOSE_WAIT 占了九成。CLOSE_WAIT 的含义非常明确:对端已经发了 FIN,本进程没有回应关闭。这就把责任从"连接多"直接推到了"代码没有关连接"。
第三天,顺着上游地址追到业务代码,找到的是一个抓取外部接口的定时任务。它在遍历一批 URL 时创建了 curl 句柄,正常分支里有 curl_close,但请求超时的分支里直接 continue 跳过了关闭。定时任务每五分钟跑一次,每次漏几十个句柄,到下午两点正好累积到上限。
第四天加了 CURLOPT_TIMEOUT 也没马上好——因为超时后走的是同一个分支,仍然漏。超时只是让失败变快,不修关闭逻辑就等于把漏水口开得更大。这一点很多排查会漏:看到超时参数没有立刻生效,就以为超时设错了,又去调参数,其实是另一个问题。
第五天,在 finally 语义的位置统一关闭句柄,并给定时任务加上"处理完即退出、下次重新拉起"的模式,让进程天生不可能带着句柄活太久。第六天复查,全天 fd 峰值回落到 9%,CLOSE_WAIT 归零。
整件事的教训只有一句:把"卷进事故的进程"、"每个进程开了什么"、"这些连接处于什么状态"三件事分开看,比盯着 ulimit 数字有用得多。前三天如果有人先去把上限从 65535 调到 200000,日志确实会安静几天,然后漏得更隐蔽——直到某个深夜把系统的 fd 一次性吃空,连 sshd 都连不上。
九、小结
ulimit 与文件描述符这条线,真正的难点从来不是"调多大",而是分清三件事:是系统上限不够、是连接太多、还是程序在漏。
判断的路径很固定:先看 /proc/sys/fs/file-nr 的使用率,再按进程排序找异常大户,然后 ls -l /proc/PID/fd 看它开了什么,最后用 CLOSE_WAIT 与 ESTABLISHED 的数量区分"漏"和"卡"。四步下来,九成的情况都能定性。
调参的优先级永远是:先修泄漏,再调上限。反过来做,就是把一个会在白天爆炸的定时炸弹,改成在深夜爆炸而已。