上周有台跑了两年的独立服务器,Nginx 突然从能撑住两千 QPS 掉到三百就 502,但 CPU 只用了 20%,内存还剩一半,MySQL 的慢查询日志干净得不像话。把 ss -s 打出来一看,TIME-WAIT 四千多、ESTAB 只有一百多,再 watch -n1 'ss -s' 盯了五分钟,发现 ESTAB 慢慢往上爬、CLOSE-WAIT 只增不减,而 Nginx 的 worker_connections 早就打满了。
这不是负载变大了,是连接没有被正常关闭。也就是说,服务器手里攒了一堆「对方已经走了、我们这边还攥着」的 socket,新请求进来只能排队或者被直接拒绝。这类问题统称 fd / 连接泄漏,它和「文件描述符不够」是两件不同的事——前者是漏出来的,调大上限只会延缓,不会治好。
第一步:分清是「不够」还是「漏了」
很多人一看到 Too many open files 就直接改 limits.conf、改 worker_rlimit_nofile,改完能多跑两天,然后复发。判断到底是容量问题还是泄漏问题,只需要三个数字:
第一个是双采样曲线。对同一个进程每隔十分钟取一次 fd 数量,如果它是一条斜线上涨的曲线,就是泄漏;如果它涨到某个高度就平了(白天高、夜里回落),那是并发量决定的水位,属于正常。
# 十分钟一次,采六次,看进程 fd 数
for i in $(seq 1 6); do
date +%H:%M
PID=$(pgrep -o -f 'nginx: master')
ls /proc/$PID/fd | wc -l
sleep 600
done注意这里取的是 master 的 fd,但真正干活的是 worker,所以要真正定位还得遍历所有 worker:
for p in $(pgrep -f 'nginx: worker'); do
n=$(ls /proc/$p/fd 2>/dev/null | wc -l)
echo "$p $n"
done | sort -k2 -rn | head第二个是 fd 的构成。拿一个 worker 的 fd 全部列出来,按目标路径归并,一眼就能看出泄漏的是什么:
PID=$(pgrep -o -f 'nginx: worker')
ls -l /proc/$PID/fd 2>/dev/null \
| awk '{print $11}' \
| sed 's/[0-9]\+/N/g' \
| sort | uniq -c | sort -rn | head -20输出里如果是 socket:[...] 占绝大多数,那是连接泄漏;如果是某个日志文件出现几百次,那是 logrotate 之后没有 reopen;如果是某个已删除的文件((deleted) 后缀),那是 lsof +L1 里常见的「磁盘没释放但空间没回」的元凶。
第三个是三个上限的实际生效值。Linux 上 fd 上限有三个层次,最容易出错的是 systemd 服务不读 limits.conf:
cat /proc/sys/fs/file-max # 系统级总量
grep -E 'nofile' /etc/security/limits.conf # 只对 PAM 登录的会话生效
cat /proc/$PID/limits | grep 'open files' # 进程真正拿到的值如果第三行显示 1024,那你在 limits.conf 里写 65535 也没用——Nginx 是由 systemd 启动的,它的限制由 unit 文件决定。正确写法是给 unit 加 drop-in:
mkdir -p /etc/systemd/system/nginx.service.d
cat > /etc/systemd/system/nginx.service.d/limit.conf <<'EOF'
[Service]
LimitNOFILE=65535
systemctl daemon-reload
systemctl restart nginx另外 nginx.conf 里还有 worker_rlimit_nofile 和 worker_connections 两个参数,它们的关系是:worker_connections 是「每个 worker 最多多少连接」,worker_rlimit_nofile 是「把进程上限提到多少」。当 hard limit 不够时,Nginx 的 worker_rlimit_nofile 会静默失败,只在 error.log 里留一行 setrlimit(RLIMIT_NOFILE, 65535) failed (1: Operation not permitted)。所以排查顺序永远是:先看 /proc/PID/limits,再看 nginx.conf。
第二步:六个最常见的泄漏源,按发生频率排
一是 logrotate 少了 postrotate。日志被 mv 走之后,进程手里的 fd 还指着旧 inode,新日志写不进去,旧文件永远删不掉。Nginx 的正确做法是发 USR1 信号(reopen),不是 reload:
/var/log/nginx/*.log {
daily
rotate 14
missingok
notifempty
compress
delaycompress
sharedscripts
postrotate
[ -f /run/nginx.pid ] && kill -USR1 $(cat /run/nginx.pid)
endscript
}sharedscripts 很关键,它保证所有日志轮转完之后只发一次信号。少了它会发很多次,虽然不致命但会浪费。
二是 curl 句柄没释放。PHP 里 curl_init() 之后忘了 curl_close(),在 pm.max_requests=0(永不复用)的配置下,一个 worker 会累积到几千个 fd 然后死掉。这类问题的特征是:重启 PHP-FPM 立刻恢复,跑几小时又不行。把 pm.max_requests 设成 500 或 1000 是个便宜的兜底,但它只是掩盖症状。
三是连接池上限比数据库的 max_connections 还大。应用侧配了 200,MySQL 只允许 151,超出的连接在握手阶段被拒,应用如果没 close 就变成僵尸连接。表现为 Aborted_connects 一直涨、Threads_connected 贴近上限不动。
四是 fopen 之后没有 fclose。尤其在「循环里打开文件追加一行」的定时脚本里,跑一夜能漏几千。用 strace 数一下就非常直观:
strace -f -c -e trace=openat,close php /path/to/job.php 2>&1 | tail -8如果 openat 的次数比 close 多出几十上百,那基本可以确定是这个脚本。这是比读代码快十倍的定位方法。
五是 epoll 的 fd 忘记 close。自己写 epoll 服务(或者用某些老版本扩展)的时候,客户端断开只 epoll_ctl DEL 而不 close,fd 会一直占着。这个坑在自研长连接服务里非常常见。
六是 inotify watch。监听了目录但没 inotify_rm_watch,每个 watch 占一个 fd 且不会被回收。cat /proc/$PID/fdinfo/* 里如果反复看到一个 anon_inode:inotify,就是这个。
第三步:CLOSE-WAIT 堆积是「应用没关」,不是「内核没关」
这一步是本文最想强调的地方,因为它被误判得最多。
TCP 连接关闭分四步,主动关闭方发 FIN,进入 FIN-WAIT-1;被动关闭方收到 FIN 回 ACK,进入 CLOSE-WAIT;被动方应用调用 close() 后发自己的 FIN,进入 LAST-ACK;主动方回 ACK,连接彻底消失或进入 TIME-WAIT。
关键在于:一个连接停在 CLOSE-WAIT,说明对方已经发了 FIN,内核也回了 ACK,但本机应用一直没有调用 close()。这是应用层的 bug,不是网络问题,也不是内核参数问题。你去调 tcp_fin_timeout、tcp_tw_reuse 对 CLOSE-WAIT 一点用都没有——那两个参数管的是 TIME-WAIT。
# 按状态统计
ss -tan | awk 'NR>1 {print $1}' | sort | uniq -c | sort -rn
# 只看 CLOSE-WAIT 对应的进程
ss -tanp state close-wait | head -20拿到 pid 之后问题就简单了:是 PHP-FPM 就把这个 worker 的栈打出来,是 Nginx 就查 proxy_ignore_client_abort 之类的配置,是自研程序就查 socket 释放路径。经验上,CLOSE-WAIT 单机稳定在个位数是正常的(有短暂的关闭窗口),持续增长到几百上千就一定是有个连接没被 close。
而 TIME-WAIT 是另一回事。ss -tan 里几千个 TIME-WAIT 是健康的标志,说明连接被正常关闭了。只有在主动发起连接的一方出现 Cannot assign requested address,才是本地端口耗尽(默认 ip_local_port_range 是 32768~60999,约 2.8 万个),这时候的解法是给 upstream 加 keepalive 复用连接,而不是调内核参数硬扛。
upstream php_backend {
server 127.0.0.1:9000;
keepalive 32;
}
server {
location ~ \.php$ {
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_pass http://php_backend;
}
}注意 proxy_set_header Connection "" 这一行不能省,否则 Nginx 会传 Connection: close,keepalive 形同虚设。这一对配置在很多「照抄教程」的站上都是缺的。
第四步:一个真实案例的数字
回到开头那台机器。定位过程是这样的:
ss -s显示CLOSE-WAIT 1182,而且每分钟还在涨;ss -tanp state close-wait全部指向php-fpm: pool www的十几个 worker;- 对其中一个 worker 数 fd,得到
11987,而/proc/PID/limits显示上限 65535,所以还没撞上限但已经严重异常; - 按目标路径归并 fd,
socket:[...]占 97%,剩下的是正常的 log 与 socketpair; - 抓
strace -f -p PID -e trace=connect,close,十秒内看到 30 次connect只见到 2 次close; - 顺着
connect的目标地址找到代码:一个「同步回调第三方统计接口」的函数,fsockopen打开就写数据,超时分支里return false了,没fclose。
改法就是把超时分支也补上 fclose,另外把 fsockopen 换成 curl 并设 CURLOPT_TIMEOUT 秒级超时。改完之后 worker 的 fd 数从 11987 降到 340,CLOSE-WAIT 从 1182 降到 4,pm.max_children 从「明显不够」变成「够用还剩一半」。
顺带还有两个变化:内存占用降了 400MB(每个连接都有缓冲区),以及 PHP-FPM 的 slowlog 干净了——原来是这些僵尸连接的写操作阻塞了 worker。所以泄漏问题往往不是「多占一点资源」,而是把整条请求链路的容量都吃掉了。
第五步:把它变成一条可监控的线
这类问题不会自己消失,但可以从「客户投诉」提前到「自己发现」。我给自己加了一个很轻的检查,每五分钟跑一次,超过阈值就往手机推一条:
#!/bin/bash
# fdwatch.sh —— 不要 set -e,grep 无匹配会返回 1
LIMIT=${1:-3000}
for p in $(pgrep -f 'nginx: worker|php-fpm: pool'); do
n=$(ls /proc/$p/fd 2>/dev/null | wc -l)
if [ "$n" -gt "$LIMIT" ]; then
name=$(cat /proc/$p/comm 2>/dev/null)
echo "$(date '+%F %T') pid=$p comm=$name fd=$n limit=$LIMIT"
fi
done
cw=$(ss -tan | grep -c 'CLOSE-WAIT')
[ "$cw" -gt 200 ] && echo "$(date '+%F %T') close-wait=$cw"三个细节值得说一下。ls /proc/$p/fd 对已退出的进程会报错,所以 2>/dev/null 必须加,否则每次重启都会刷一堆 No such file or directory。grep -c 在没有匹配时返回 1,如果脚本开头写了 set -e,整个脚本会在第一行没超阈值的情况下静默退出——这是 shell 脚本里最经典的假死。pgrep -f 的模式里 | 是正则的或,别写成 pgrep nginx php-fpm,那个只认单个进程名。
阈值不要拍脑袋。先跑一周记录基线,取 P95 的两倍作为告警线。以我这台机器为例,正常水位是 nginx worker 300~400、php-fpm worker 200~350,告警线设在 1200,就是因为不想被每天的备份任务误扰。
第六步:关于上限的几句实话
很多人把 fs.file-max 调到 100 万就觉得解决了问题。这个值是系统级的总量,调整它几乎不花成本,但它不能解决泄漏——只是让机器在挂掉之前攒更久。反而有两个副作用:一是 fd 表变大之后 lsof 之类工具变慢(排查时更痛苦),二是有些老程序会对大的 fd 值做位运算,超过 1024 之后出奇怪的 bug。
真正有效的顺序是:先用双采样确认是漏还是不够,再按目标路径定位漏的是什么,改代码或改配置,最后才是把上限设到一个「有余量但不夸张」的值。对我这台 2 核 4G 的机器,LimitNOFILE=65535、worker_rlimit_nofile 65535、worker_connections 4096 已经很宽松了——注意 worker_connections 乘上 worker 数才是总连接能力,而每个连接在 Nginx 里大约占几 KB 内存,4096 x 4 worker 折算下来也有十几 MB,对 4G 内存的机器完全够用。
最后一条经验:任何「重启就好了」的问题都值得当天记一笔。fd 泄漏、内存泄漏、连接池耗尽,这三个的表现都是「跑几天变慢、重启立刻正常」,它们的共性是把状态攒在了进程里。攒的过程一定在某个计数器上留了痕迹,找那个计数器比猜代码快得多。