凌晨1点47分,502报警
那台服务器是台4核8G的京东云主机,跑着Nginx 1.24.0 + PHP-FPM 8.2 + Laravel 11。平时每秒三四百请求,稳得很。
结果周一凌晨,线上突然开始大量502。打开Nginx错误日志:
tail -f /var/log/nginx/error.log
刷过去的全是同一句话:
[error] 12345#0: *678910 connect() failed (111: Connection refused) while connecting to upstream, client: 1.2.3.4, server: api.example.com, request: "POST /api/order/create HTTP/1.1", upstream: "fastcgi://unix:/var/run/php-fpm.sock:"
`111: Connection refused`——PHP-FPM已经把连接拒了。我第一反应是重启FPM:
systemctl restart php-fpm
sleep 2
curl -I https://api.example.com/health
好了,恢复了。但过了不到10分钟,又502了。这才意识到不是偶发故障,是资源被吃满了。
那晚折腾到3点半,换了三种排查工具,最后定位到根因:PHP-FPM的进程数配置严重不合理,导致进程池被占满。这篇文章把完整的排查链路、方案对比和代码实现全部写出来。
502 vs 504,先分清是哪一端的问题
很多人把502和504混在一起排查,其实这两个错误码指向完全不同的故障点:
tail -f /var/log/nginx/error.log[error] 12345#0: *678910 connect() failed (111: Connection refused) while connecting to upstream, client: 1.2.3.4, server: api.example.com, request: "POST /api/order/create HTTP/1.1", upstream: "fastcgi://unix:/var/run/php-fpm.sock:"systemctl restart php-fpm
sleep 2
curl -I https://api.example.com/health| 错误码 | 含义 | 发生在哪一层 | 典型原因 |
|---|---|---|---|
| 502 Bad Gateway | Nginx无法连接上游(PHP-FPM/Java/Node) | TCP连接建立阶段 | FPM进程池耗尽、FPM崩溃、socket文件权限错误、FPM未启动 |
| 504 Gateway Timeout | Nginx成功连接上游,但等待响应超时 | HTTP请求处理阶段 | 慢SQL、死锁、外部API调用卡死、fastcgi_read超时设置过短 |
问题定位:三条命令 + 一个压测工具
第一步:看FPM进程池实时状态
PHP-FPM自带状态接口,先打开它。编辑`/etc/php-fpm.d/www.conf`(PHP 8.2.5):
; 开启状态页
pm.status_path = /php-fpm-status
然后在Nginx配置里放行这个路径:
location ~ ^/php-fpm-status {
fastcgi_pass unix:/var/run/php-fpm.sock;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
# 内网监控用,外网必须加访问控制
allow 127.0.0.1;
allow 10.0.0.0/8;
deny all;
}
重载配置后,直接curl状态页:
curl http://127.0.0.1/php-fpm-status
输出关键行:
pool: www
process manager: dynamic
start time: 28/Aug/2024:00:12:33 +0800
start since: 5312
accepted conn: 45239
listen queue: 0
max listen queue: 0
listen queue len: 128
idle processes: 0
active processes: 30
total processes: 30
max active processes: 30
max children reached: 42
注意最后一行:`max children reached: 42`。这是在说:**进程数已经被顶到上限42次了**。总进程数30,说明`pm.max_children`配置的就是30,但这台4核8G的机器,FPM进程平均吃掉150MB内存,30个进程就是4.5GB,加上Nginx、MySQL、Laravel框架本身的内存开销,机器直接进入swap。
第二步:同时看系统内存
free -h
当时输出:
total used free shared buff/cache available
Mem: 7.6Gi 7.1Gi 156Mi 12Mi 452Mi 213Mi
Swap: 2.0Gi 1.9Gi 92Mi
内存只剩156MB,swap都用了1.9GB。这就是根因一。
第三步:用wrk压测复现
重启FPM后,用wrk 4.2.0压测接口,20秒内就能复现:
wrk -t4 -c200 -d30s --latency https://api.example.com/health
结果:
Running 30s test @ https://api.example.com/health
4 threads and 200 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 1.92s 3.15s 11.53s 87.32%
Req/Sec 87.14 69.42 339.00 66.24%
Latency Distribution
50% 1.23s
75% 2.87s
90% 5.32s
99% 10.11s
4576 requests in 30.01s, 1.02MB read
Socket errors: connect 0, read 0, write 0, timeout 1308
Non-2xx or 3xx responses: 1308
1308个请求失败,错误率28.6%。而且注意延迟:p50是1.23秒,p99超过10秒——这不是正常的健康检查接口该有的表现。
第四步:看FPM日志确认崩溃规律
grep -E "WARNING|ERROR|max_children" /var/log/php-fpm.log | tail -20
日志里的规律是:
[28-Aug-2024 01:23:44] WARNING: [pool www] server reached pm.max_children setting (30), consider raising it
[28-Aug-2024 01:26:12] WARNING: [pool www] server reached pm.max_children setting (30), consider raising it
每2-3分钟就顶到上限一次。
三种方案对比
方案一:无脑调大max_children
这种方案最直接,也是很多人第一反应。
pm = dynamic
pm.max_children = 80
pm.start_servers = 20
pm.min_spare_servers = 10
pm.max_spare_servers = 30
后果:80个进程 × 150MB = 12GB内存,8G的机器直接OOM。机器直接卡死。
**结论:不可行**,除非加内存到16GB以上。但加内存还要考虑CPU——FPM进程多起来后,CPU跑满照样503。这台机器CPU核数只有4,80个FPM进程同时跑,上下文切换开销会拖垮整个系统。
方案二:调高Nginx超时时间
proxy_connect_timeout 75s;
proxy_read_timeout 300s;
fastcgi_connect_timeout 75s;
fastcgi_read_timeout 300s;
fastcgi_send_timeout 300s;
这个方案解决的是504,不是502。502是连接都建立不了,超时设置再长也没用。而且调高`fastcgi_read_timeout`会让用户等待时间更长,体验更差。
**结论:治标不治本**,只能掩盖问题。
方案三:动态调优 + 健康检查自动摘除(最终方案)
分两步:
1. **合理设置PHP-FPM进程模型**:调整为`static` + 设置进程回收,充分利用现有8G内存,同时避免进程无限积压
2. **健康检查 + 自动摘除**:增加一个轻量的health endpoint,定时检测FPM状态,连续N次失败就自动重启FPM并将节点从Nginx upstream摘除
**结论:这是根因解法。**
最终方案:完整代码实现
第一步:调整PHP-FPM配置
以PHP 8.2 + 4核8G + Laravel 11为例,最终配置如下:
; /etc/php-fpm.d/www.conf (PHP 8.2.5)
; 使用static模式,因为流量曲线是稳定的
; static模式FPM启动时就创建固定数量的worker,避免了dynamic模式频繁创建/销毁进程的开销
pm = static
; 关键计算:8G内存,系统预留2G,MySQL预留2G,Nginx+其他预留0.5G
; 剩余3.5G给PHP-FPM,每个worker约150MB
; 3500MB / 150MB ≈ 23,留一点buffer,取20
pm.max_children = 20
; 每个worker处理500个请求后自动回收,防止内存泄漏累积
pm.max_requests = 500
; 慢日志阈值——方便定位哪些请求拖慢了FPM
slowlog = /var/log/php-fpm-slow.log
request_slowlog_timeout = 5s
注意:`pm.max_children = 20`比原来的30还小。这反直觉对吧?后面有数据对比。
第二步:配置Nginx超时和健康检查端点
Nginx 1.24.0配置:
# /etc/nginx/conf.d/api.conf
upstream php_backend {
# 用unix socket而不是TCP,减少TCP握手开销
server unix:/var/run/php-fpm.sock;
}
server {
listen 80;
server_name api.example.com;
# 健康检查端点:轻量,不依赖数据库
location = /health {
access_log off;
fastcgi_pass php_backend;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /var/www/html/api/public/health.php;
# 健康检查必须快速失败,不能拖死FPM
fastcgi_connect_timeout 2s;
fastcgi_read_timeout 2s;
}
location / {
try_files $uri $uri/ /index.php?$query_string;
fastcgi_pass php_backend;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
# 业务接口超时:连接5s,读取30s
# 502是连接失败,调超时没用;504才是等待超时
fastcgi_connect_timeout 5s;
fastcgi_read_timeout 30s;
fastcgi_send_timeout 30s;
# 开启keepalive连接复用,减少FPM握手压力
keepalive 32;
}
}
第三步:健康检查脚本
#!/bin/bash
# /usr/local/bin/fpm-healthcheck.sh
# 功能:检测FPM健康状态,连续3次失败自动重启FPM
# 配合cron每30秒执行一次
STATUS_URL="http://127.0.0.1/health"
FAIL_COUNT_FILE="/tmp/fpm_health_fail_count"
MAX_FAILURES=3
LOG_FILE="/var/log/fpm-healthcheck.log"
log() {
echo "$(date '+%Y-%m-%d %H:%M:%S') $1" >> "$LOG_FILE"
}
# 1. 请求health端点,要求1秒内返回200
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 1 --max-time 2 \
-H "Host: api.example.com" "$STATUS_URL" 2>/dev/null)
if [ "$HTTP_CODE" = "200" ]; then
# 成功:重置失败计数
rm -f "$FAIL_COUNT_FILE"
exit 0
fi
# 2. 失败:累加计数
FAIL_COUNT=0
if [ -f "$FAIL_COUNT_FILE" ]; then
FAIL_COUNT=$(cat "$FAIL_COUNT_FILE")
fi
FAIL_COUNT=$((FAIL_COUNT + 1))
echo "$FAIL_COUNT" > "$FAIL_COUNT_FILE"
log "health check failed, HTTP_CODE=$HTTP_CODE, fail_count=$FAIL_COUNT"
# 3. 连续失败达到阈值,重启FPM
if [ "$FAIL_COUNT" -ge "$MAX_FAILURES" ]; then
log "ALERT: FPM unhealthy, restarting php-fpm..."
systemctl restart php-fpm
if [ $? -eq 0 ]; then
log "php-fpm restarted successfully"
# 冷却30秒再恢复计数
sleep 30
rm -f "$FAIL_COUNT_FILE"
else
log "ERROR: php-fpm restart failed"
# 重启失败可以接告警通知,如钉钉/企业微信
curl -s -X POST "https://oapi.dingtalk.com/robot/send?access_token=YOUR_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"msgtype":"text","text":{"content":"[P1] PHP-FPM重启失败,请立即处理"}}' > /dev/null
fi
fi
exit 0
注册到cron,每30秒执行一次:
crontab -e
*/1 * * * * /usr/local/bin/fpm-healthcheck.sh
* * * * * sleep 30; /usr/local/bin/fpm-healthcheck.sh
第四步:健康检查的PHP文件
这个文件必须够轻,不能加载整个Laravel框架,否则健康检查本身会加重FPM负担:
'ok',
'time' => date('c'),
'memory_bytes' => $memory,
'uptime' => file_get_contents('/proc/uptime') ?? null,
];
// 检查磁盘空间
$diskFree = disk_free_space('/');
if ($diskFree !== false && $diskFree < 500 * 1024 * 1024) {
http_response_code(500);
$status['status'] = 'disk_low';
$status['disk_free_bytes'] = $diskFree;
echo json_encode($status);
exit;
}
http_response_code(200);
header('Content-Type: application/json');
echo json_encode($status);
第五步:压测验证新配置
调整配置并重启FPM+nginx:
systemctl restart php-fpm nginx
sleep 3
# 确认进程数
ps aux | grep php-fpm | grep -v grep | wc -l
# 输出 20
# 确认内存占用
ps aux | grep php-fpm | grep -v grep | awk '{sum+=$6} END {print sum/1024, "MB"}'
然后重新用wrk压测:
wrk -t4 -c200 -d30s --latency https://api.example.com/health
效果数据:调整前后对比
压测结果对比(wrk -t4 -c200 -d30s,同一台机器,同一接口)
; 开启状态页
pm.status_path = /php-fpm-status
然后在Nginx配置里放行这个路径:
location ~ ^/php-fpm-status {
fastcgi_pass unix:/var/run/php-fpm.sock;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
# 内网监控用,外网必须加访问控制
allow 127.0.0.1;
allow 10.0.0.0/8;
deny all;
}
重载配置后,直接curl状态页:
curl http://127.0.0.1/php-fpm-status
输出关键行:
pool: www
process manager: dynamic
start time: 28/Aug/2024:00:12:33 +0800
start since: 5312
accepted conn: 45239
listen queue: 0
max listen queue: 0
listen queue len: 128
idle processes: 0
active processes: 30
total processes: 30
max active processes: 30
max children reached: 42
注意最后一行:`max children reached: 42`。这是在说:**进程数已经被顶到上限42次了**。总进程数30,说明`pm.max_children`配置的就是30,但这台4核8G的机器,FPM进程平均吃掉150MB内存,30个进程就是4.5GB,加上Nginx、MySQL、Laravel框架本身的内存开销,机器直接进入swap。
第二步:同时看系统内存
free -h
当时输出:
total used free shared buff/cache available
Mem: 7.6Gi 7.1Gi 156Mi 12Mi 452Mi 213Mi
Swap: 2.0Gi 1.9Gi 92Mi
内存只剩156MB,swap都用了1.9GB。这就是根因一。
第三步:用wrk压测复现
重启FPM后,用wrk 4.2.0压测接口,20秒内就能复现:
wrk -t4 -c200 -d30s --latency https://api.example.com/health
结果:
Running 30s test @ https://api.example.com/health
4 threads and 200 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 1.92s 3.15s 11.53s 87.32%
Req/Sec 87.14 69.42 339.00 66.24%
Latency Distribution
50% 1.23s
75% 2.87s
90% 5.32s
99% 10.11s
4576 requests in 30.01s, 1.02MB read
Socket errors: connect 0, read 0, write 0, timeout 1308
Non-2xx or 3xx responses: 1308
1308个请求失败,错误率28.6%。而且注意延迟:p50是1.23秒,p99超过10秒——这不是正常的健康检查接口该有的表现。
第四步:看FPM日志确认崩溃规律
grep -E "WARNING|ERROR|max_children" /var/log/php-fpm.log | tail -20
日志里的规律是:
[28-Aug-2024 01:23:44] WARNING: [pool www] server reached pm.max_children setting (30), consider raising it
[28-Aug-2024 01:26:12] WARNING: [pool www] server reached pm.max_children setting (30), consider raising it
每2-3分钟就顶到上限一次。
三种方案对比
方案一:无脑调大max_children
这种方案最直接,也是很多人第一反应。
pm = dynamic
pm.max_children = 80
pm.start_servers = 20
pm.min_spare_servers = 10
pm.max_spare_servers = 30
后果:80个进程 × 150MB = 12GB内存,8G的机器直接OOM。机器直接卡死。
**结论:不可行**,除非加内存到16GB以上。但加内存还要考虑CPU——FPM进程多起来后,CPU跑满照样503。这台机器CPU核数只有4,80个FPM进程同时跑,上下文切换开销会拖垮整个系统。
方案二:调高Nginx超时时间
proxy_connect_timeout 75s;
proxy_read_timeout 300s;
fastcgi_connect_timeout 75s;
fastcgi_read_timeout 300s;
fastcgi_send_timeout 300s;
这个方案解决的是504,不是502。502是连接都建立不了,超时设置再长也没用。而且调高`fastcgi_read_timeout`会让用户等待时间更长,体验更差。
**结论:治标不治本**,只能掩盖问题。
方案三:动态调优 + 健康检查自动摘除(最终方案)
分两步:
1. **合理设置PHP-FPM进程模型**:调整为`static` + 设置进程回收,充分利用现有8G内存,同时避免进程无限积压
2. **健康检查 + 自动摘除**:增加一个轻量的health endpoint,定时检测FPM状态,连续N次失败就自动重启FPM并将节点从Nginx upstream摘除
**结论:这是根因解法。**
最终方案:完整代码实现
第一步:调整PHP-FPM配置
以PHP 8.2 + 4核8G + Laravel 11为例,最终配置如下:
; /etc/php-fpm.d/www.conf (PHP 8.2.5)
; 使用static模式,因为流量曲线是稳定的
; static模式FPM启动时就创建固定数量的worker,避免了dynamic模式频繁创建/销毁进程的开销
pm = static
; 关键计算:8G内存,系统预留2G,MySQL预留2G,Nginx+其他预留0.5G
; 剩余3.5G给PHP-FPM,每个worker约150MB
; 3500MB / 150MB ≈ 23,留一点buffer,取20
pm.max_children = 20
; 每个worker处理500个请求后自动回收,防止内存泄漏累积
pm.max_requests = 500
; 慢日志阈值——方便定位哪些请求拖慢了FPM
slowlog = /var/log/php-fpm-slow.log
request_slowlog_timeout = 5s
注意:`pm.max_children = 20`比原来的30还小。这反直觉对吧?后面有数据对比。
第二步:配置Nginx超时和健康检查端点
Nginx 1.24.0配置:
# /etc/nginx/conf.d/api.conf
upstream php_backend {
# 用unix socket而不是TCP,减少TCP握手开销
server unix:/var/run/php-fpm.sock;
}
server {
listen 80;
server_name api.example.com;
# 健康检查端点:轻量,不依赖数据库
location = /health {
access_log off;
fastcgi_pass php_backend;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /var/www/html/api/public/health.php;
# 健康检查必须快速失败,不能拖死FPM
fastcgi_connect_timeout 2s;
fastcgi_read_timeout 2s;
}
location / {
try_files $uri $uri/ /index.php?$query_string;
fastcgi_pass php_backend;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
# 业务接口超时:连接5s,读取30s
# 502是连接失败,调超时没用;504才是等待超时
fastcgi_connect_timeout 5s;
fastcgi_read_timeout 30s;
fastcgi_send_timeout 30s;
# 开启keepalive连接复用,减少FPM握手压力
keepalive 32;
}
}
第三步:健康检查脚本
#!/bin/bash
# /usr/local/bin/fpm-healthcheck.sh
# 功能:检测FPM健康状态,连续3次失败自动重启FPM
# 配合cron每30秒执行一次
STATUS_URL="http://127.0.0.1/health"
FAIL_COUNT_FILE="/tmp/fpm_health_fail_count"
MAX_FAILURES=3
LOG_FILE="/var/log/fpm-healthcheck.log"
log() {
echo "$(date '+%Y-%m-%d %H:%M:%S') $1" >> "$LOG_FILE"
}
# 1. 请求health端点,要求1秒内返回200
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 1 --max-time 2 \
-H "Host: api.example.com" "$STATUS_URL" 2>/dev/null)
if [ "$HTTP_CODE" = "200" ]; then
# 成功:重置失败计数
rm -f "$FAIL_COUNT_FILE"
exit 0
fi
# 2. 失败:累加计数
FAIL_COUNT=0
if [ -f "$FAIL_COUNT_FILE" ]; then
FAIL_COUNT=$(cat "$FAIL_COUNT_FILE")
fi
FAIL_COUNT=$((FAIL_COUNT + 1))
echo "$FAIL_COUNT" > "$FAIL_COUNT_FILE"
log "health check failed, HTTP_CODE=$HTTP_CODE, fail_count=$FAIL_COUNT"
# 3. 连续失败达到阈值,重启FPM
if [ "$FAIL_COUNT" -ge "$MAX_FAILURES" ]; then
log "ALERT: FPM unhealthy, restarting php-fpm..."
systemctl restart php-fpm
if [ $? -eq 0 ]; then
log "php-fpm restarted successfully"
# 冷却30秒再恢复计数
sleep 30
rm -f "$FAIL_COUNT_FILE"
else
log "ERROR: php-fpm restart failed"
# 重启失败可以接告警通知,如钉钉/企业微信
curl -s -X POST "https://oapi.dingtalk.com/robot/send?access_token=YOUR_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"msgtype":"text","text":{"content":"[P1] PHP-FPM重启失败,请立即处理"}}' > /dev/null
fi
fi
exit 0
注册到cron,每30秒执行一次:
crontab -e
*/1 * * * * /usr/local/bin/fpm-healthcheck.sh
* * * * * sleep 30; /usr/local/bin/fpm-healthcheck.sh
第四步:健康检查的PHP文件
这个文件必须够轻,不能加载整个Laravel框架,否则健康检查本身会加重FPM负担:
'ok',
'time' => date('c'),
'memory_bytes' => $memory,
'uptime' => file_get_contents('/proc/uptime') ?? null,
];
// 检查磁盘空间
$diskFree = disk_free_space('/');
if ($diskFree !== false && $diskFree < 500 * 1024 * 1024) {
http_response_code(500);
$status['status'] = 'disk_low';
$status['disk_free_bytes'] = $diskFree;
echo json_encode($status);
exit;
}
http_response_code(200);
header('Content-Type: application/json');
echo json_encode($status);
第五步:压测验证新配置
调整配置并重启FPM+nginx:
systemctl restart php-fpm nginx
sleep 3
# 确认进程数
ps aux | grep php-fpm | grep -v grep | wc -l
# 输出 20
# 确认内存占用
ps aux | grep php-fpm | grep -v grep | awk '{sum+=$6} END {print sum/1024, "MB"}'
然后重新用wrk压测:
wrk -t4 -c200 -d30s --latency https://api.example.com/health
效果数据:调整前后对比
压测结果对比(wrk -t4 -c200 -d30s,同一台机器,同一接口)
free -h total used free shared buff/cache available
Mem: 7.6Gi 7.1Gi 156Mi 12Mi 452Mi 213Mi
Swap: 2.0Gi 1.9Gi 92Miwrk -t4 -c200 -d30s --latency https://api.example.com/health
结果:
Running 30s test @ https://api.example.com/health
4 threads and 200 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 1.92s 3.15s 11.53s 87.32%
Req/Sec 87.14 69.42 339.00 66.24%
Latency Distribution
50% 1.23s
75% 2.87s
90% 5.32s
99% 10.11s
4576 requests in 30.01s, 1.02MB read
Socket errors: connect 0, read 0, write 0, timeout 1308
Non-2xx or 3xx responses: 1308
1308个请求失败,错误率28.6%。而且注意延迟:p50是1.23秒,p99超过10秒——这不是正常的健康检查接口该有的表现。
第四步:看FPM日志确认崩溃规律
grep -E "WARNING|ERROR|max_children" /var/log/php-fpm.log | tail -20
日志里的规律是:
[28-Aug-2024 01:23:44] WARNING: [pool www] server reached pm.max_children setting (30), consider raising it
[28-Aug-2024 01:26:12] WARNING: [pool www] server reached pm.max_children setting (30), consider raising it
每2-3分钟就顶到上限一次。
三种方案对比
方案一:无脑调大max_children
这种方案最直接,也是很多人第一反应。
pm = dynamic
pm.max_children = 80
pm.start_servers = 20
pm.min_spare_servers = 10
pm.max_spare_servers = 30
后果:80个进程 × 150MB = 12GB内存,8G的机器直接OOM。机器直接卡死。
**结论:不可行**,除非加内存到16GB以上。但加内存还要考虑CPU——FPM进程多起来后,CPU跑满照样503。这台机器CPU核数只有4,80个FPM进程同时跑,上下文切换开销会拖垮整个系统。
方案二:调高Nginx超时时间
proxy_connect_timeout 75s;
proxy_read_timeout 300s;
fastcgi_connect_timeout 75s;
fastcgi_read_timeout 300s;
fastcgi_send_timeout 300s;
这个方案解决的是504,不是502。502是连接都建立不了,超时设置再长也没用。而且调高`fastcgi_read_timeout`会让用户等待时间更长,体验更差。
**结论:治标不治本**,只能掩盖问题。
方案三:动态调优 + 健康检查自动摘除(最终方案)
分两步:
1. **合理设置PHP-FPM进程模型**:调整为`static` + 设置进程回收,充分利用现有8G内存,同时避免进程无限积压
2. **健康检查 + 自动摘除**:增加一个轻量的health endpoint,定时检测FPM状态,连续N次失败就自动重启FPM并将节点从Nginx upstream摘除
**结论:这是根因解法。**
最终方案:完整代码实现
第一步:调整PHP-FPM配置
以PHP 8.2 + 4核8G + Laravel 11为例,最终配置如下:
; /etc/php-fpm.d/www.conf (PHP 8.2.5)
; 使用static模式,因为流量曲线是稳定的
; static模式FPM启动时就创建固定数量的worker,避免了dynamic模式频繁创建/销毁进程的开销
pm = static
; 关键计算:8G内存,系统预留2G,MySQL预留2G,Nginx+其他预留0.5G
; 剩余3.5G给PHP-FPM,每个worker约150MB
; 3500MB / 150MB ≈ 23,留一点buffer,取20
pm.max_children = 20
; 每个worker处理500个请求后自动回收,防止内存泄漏累积
pm.max_requests = 500
; 慢日志阈值——方便定位哪些请求拖慢了FPM
slowlog = /var/log/php-fpm-slow.log
request_slowlog_timeout = 5s
注意:`pm.max_children = 20`比原来的30还小。这反直觉对吧?后面有数据对比。
第二步:配置Nginx超时和健康检查端点
Nginx 1.24.0配置:
# /etc/nginx/conf.d/api.conf
upstream php_backend {
# 用unix socket而不是TCP,减少TCP握手开销
server unix:/var/run/php-fpm.sock;
}
server {
listen 80;
server_name api.example.com;
# 健康检查端点:轻量,不依赖数据库
location = /health {
access_log off;
fastcgi_pass php_backend;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /var/www/html/api/public/health.php;
# 健康检查必须快速失败,不能拖死FPM
fastcgi_connect_timeout 2s;
fastcgi_read_timeout 2s;
}
location / {
try_files $uri $uri/ /index.php?$query_string;
fastcgi_pass php_backend;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
# 业务接口超时:连接5s,读取30s
# 502是连接失败,调超时没用;504才是等待超时
fastcgi_connect_timeout 5s;
fastcgi_read_timeout 30s;
fastcgi_send_timeout 30s;
# 开启keepalive连接复用,减少FPM握手压力
keepalive 32;
}
}
第三步:健康检查脚本
#!/bin/bash
# /usr/local/bin/fpm-healthcheck.sh
# 功能:检测FPM健康状态,连续3次失败自动重启FPM
# 配合cron每30秒执行一次
STATUS_URL="http://127.0.0.1/health"
FAIL_COUNT_FILE="/tmp/fpm_health_fail_count"
MAX_FAILURES=3
LOG_FILE="/var/log/fpm-healthcheck.log"
log() {
echo "$(date '+%Y-%m-%d %H:%M:%S') $1" >> "$LOG_FILE"
}
# 1. 请求health端点,要求1秒内返回200
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 1 --max-time 2 \
-H "Host: api.example.com" "$STATUS_URL" 2>/dev/null)
if [ "$HTTP_CODE" = "200" ]; then
# 成功:重置失败计数
rm -f "$FAIL_COUNT_FILE"
exit 0
fi
# 2. 失败:累加计数
FAIL_COUNT=0
if [ -f "$FAIL_COUNT_FILE" ]; then
FAIL_COUNT=$(cat "$FAIL_COUNT_FILE")
fi
FAIL_COUNT=$((FAIL_COUNT + 1))
echo "$FAIL_COUNT" > "$FAIL_COUNT_FILE"
log "health check failed, HTTP_CODE=$HTTP_CODE, fail_count=$FAIL_COUNT"
# 3. 连续失败达到阈值,重启FPM
if [ "$FAIL_COUNT" -ge "$MAX_FAILURES" ]; then
log "ALERT: FPM unhealthy, restarting php-fpm..."
systemctl restart php-fpm
if [ $? -eq 0 ]; then
log "php-fpm restarted successfully"
# 冷却30秒再恢复计数
sleep 30
rm -f "$FAIL_COUNT_FILE"
else
log "ERROR: php-fpm restart failed"
# 重启失败可以接告警通知,如钉钉/企业微信
curl -s -X POST "https://oapi.dingtalk.com/robot/send?access_token=YOUR_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"msgtype":"text","text":{"content":"[P1] PHP-FPM重启失败,请立即处理"}}' > /dev/null
fi
fi
exit 0
注册到cron,每30秒执行一次:
crontab -e
*/1 * * * * /usr/local/bin/fpm-healthcheck.sh
* * * * * sleep 30; /usr/local/bin/fpm-healthcheck.sh
第四步:健康检查的PHP文件
这个文件必须够轻,不能加载整个Laravel框架,否则健康检查本身会加重FPM负担:
'ok',
'time' => date('c'),
'memory_bytes' => $memory,
'uptime' => file_get_contents('/proc/uptime') ?? null,
];
// 检查磁盘空间
$diskFree = disk_free_space('/');
if ($diskFree !== false && $diskFree < 500 * 1024 * 1024) {
http_response_code(500);
$status['status'] = 'disk_low';
$status['disk_free_bytes'] = $diskFree;
echo json_encode($status);
exit;
}
http_response_code(200);
header('Content-Type: application/json');
echo json_encode($status);
第五步:压测验证新配置
调整配置并重启FPM+nginx:
systemctl restart php-fpm nginx
sleep 3
# 确认进程数
ps aux | grep php-fpm | grep -v grep | wc -l
# 输出 20
# 确认内存占用
ps aux | grep php-fpm | grep -v grep | awk '{sum+=$6} END {print sum/1024, "MB"}'
然后重新用wrk压测:
wrk -t4 -c200 -d30s --latency https://api.example.com/health
效果数据:调整前后对比
压测结果对比(wrk -t4 -c200 -d30s,同一台机器,同一接口)
grep -E "WARNING|ERROR|max_children" /var/log/php-fpm.log | tail -20[28-Aug-2024 01:23:44] WARNING: [pool www] server reached pm.max_children setting (30), consider raising it
[28-Aug-2024 01:26:12] WARNING: [pool www] server reached pm.max_children setting (30), consider raising itpm = dynamic
pm.max_children = 80
pm.start_servers = 20
pm.min_spare_servers = 10
pm.max_spare_servers = 30proxy_connect_timeout 75s;
proxy_read_timeout 300s;
fastcgi_connect_timeout 75s;
fastcgi_read_timeout 300s;
fastcgi_send_timeout 300s;; /etc/php-fpm.d/www.conf (PHP 8.2.5)
; 使用static模式,因为流量曲线是稳定的
; static模式FPM启动时就创建固定数量的worker,避免了dynamic模式频繁创建/销毁进程的开销
pm = static
; 关键计算:8G内存,系统预留2G,MySQL预留2G,Nginx+其他预留0.5G
; 剩余3.5G给PHP-FPM,每个worker约150MB
; 3500MB / 150MB ≈ 23,留一点buffer,取20
pm.max_children = 20
; 每个worker处理500个请求后自动回收,防止内存泄漏累积
pm.max_requests = 500
; 慢日志阈值——方便定位哪些请求拖慢了FPM
slowlog = /var/log/php-fpm-slow.log
request_slowlog_timeout = 5s# /etc/nginx/conf.d/api.conf
upstream php_backend {
# 用unix socket而不是TCP,减少TCP握手开销
server unix:/var/run/php-fpm.sock;
}
server {
listen 80;
server_name api.example.com;
# 健康检查端点:轻量,不依赖数据库
location = /health {
access_log off;
fastcgi_pass php_backend;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /var/www/html/api/public/health.php;
# 健康检查必须快速失败,不能拖死FPM
fastcgi_connect_timeout 2s;
fastcgi_read_timeout 2s;
}
location / {
try_files $uri $uri/ /index.php?$query_string;
fastcgi_pass php_backend;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
# 业务接口超时:连接5s,读取30s
# 502是连接失败,调超时没用;504才是等待超时
fastcgi_connect_timeout 5s;
fastcgi_read_timeout 30s;
fastcgi_send_timeout 30s;
# 开启keepalive连接复用,减少FPM握手压力
keepalive 32;
}
}#!/bin/bash
# /usr/local/bin/fpm-healthcheck.sh
# 功能:检测FPM健康状态,连续3次失败自动重启FPM
# 配合cron每30秒执行一次
STATUS_URL="http://127.0.0.1/health"
FAIL_COUNT_FILE="/tmp/fpm_health_fail_count"
MAX_FAILURES=3
LOG_FILE="/var/log/fpm-healthcheck.log"
log() {
echo "$(date '+%Y-%m-%d %H:%M:%S') $1" >> "$LOG_FILE"
}
# 1. 请求health端点,要求1秒内返回200
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 1 --max-time 2 \
-H "Host: api.example.com" "$STATUS_URL" 2>/dev/null)
if [ "$HTTP_CODE" = "200" ]; then
# 成功:重置失败计数
rm -f "$FAIL_COUNT_FILE"
exit 0
fi
# 2. 失败:累加计数
FAIL_COUNT=0
if [ -f "$FAIL_COUNT_FILE" ]; then
FAIL_COUNT=$(cat "$FAIL_COUNT_FILE")
fi
FAIL_COUNT=$((FAIL_COUNT + 1))
echo "$FAIL_COUNT" > "$FAIL_COUNT_FILE"
log "health check failed, HTTP_CODE=$HTTP_CODE, fail_count=$FAIL_COUNT"
# 3. 连续失败达到阈值,重启FPM
if [ "$FAIL_COUNT" -ge "$MAX_FAILURES" ]; then
log "ALERT: FPM unhealthy, restarting php-fpm..."
systemctl restart php-fpm
if [ $? -eq 0 ]; then
log "php-fpm restarted successfully"
# 冷却30秒再恢复计数
sleep 30
rm -f "$FAIL_COUNT_FILE"
else
log "ERROR: php-fpm restart failed"
# 重启失败可以接告警通知,如钉钉/企业微信
curl -s -X POST "https://oapi.dingtalk.com/robot/send?access_token=YOUR_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"msgtype":"text","text":{"content":"[P1] PHP-FPM重启失败,请立即处理"}}' > /dev/null
fi
fi
exit 0crontab -e
*/1 * * * * /usr/local/bin/fpm-healthcheck.sh
* * * * * sleep 30; /usr/local/bin/fpm-healthcheck.sh 'ok',
'time' => date('c'),
'memory_bytes' => $memory,
'uptime' => file_get_contents('/proc/uptime') ?? null,
];
// 检查磁盘空间
$diskFree = disk_free_space('/');
if ($diskFree !== false && $diskFree < 500 * 1024 * 1024) {
http_response_code(500);
$status['status'] = 'disk_low';
$status['disk_free_bytes'] = $diskFree;
echo json_encode($status);
exit;
}
http_response_code(200);
header('Content-Type: application/json');
echo json_encode($status);systemctl restart php-fpm nginx
sleep 3
# 确认进程数
ps aux | grep php-fpm | grep -v grep | wc -l
# 输出 20
# 确认内存占用
ps aux | grep php-fpm | grep -v grep | awk '{sum+=$6} END {print sum/1024, "MB"}'wrk -t4 -c200 -d30s --latency https://api.example.com/health压测结果对比(wrk -t4 -c200 -d30s,同一台机器,同一接口)
| 指标 | 调整前(max_children=30, dynamic) | 调整后(max_children=20, static) | 变化 |
|---|---|---|---|
| 请求总数 | 4576 | 40215 | +778% |
| 失败请求 | 1308 | 0 | -100% |
| 错误率 | 28.6% | 0% | -28.6 pct |
| 平均延迟 | 1.92s | 302ms | -84% |
| p99延迟 | 10.11s | 752ms | -92.6% |
| QPS | 152 | 1340 | +781% |
| FPM内存占用 | 4.5GB(30进程×150MB) | 3.2GB(20进程×160MB) | -28.9% |
| max_children reached | 42次/小时 | 0次 | -100% |
线上稳定运行30天结果
修复完成后的效果,从监控系统拉取的数据:运行时长:30天
502次数:0
504次数:2(均为单次外部API超时触发)
FPM内存占用:稳定在3.2-3.5GB
FPM重启次数:1(健康检查脚本触发,原因是临时磁盘空间不足)
CPU使用率:峰值65%,日常30-40%
平均响应时间:220ms(压测数据是302ms,线上略低于压测)
避坑段落
避坑指南
以下5个坑,都是这30天里实际踩过的。坑一:健康检查脚本里用了数据库查询
第一次写健康检查脚本时,我在health.php里查了一次数据库:$pdo = new PDO('mysql:host=127.0.0.1;dbname=app', 'user', 'pass', [PDO::ATTR_TIMEOUT => 3]);
$pdo->query("SELECT 1");
结果数据库抖动的时候,健康检查挂起3秒,curl的`--max-time 2`超时,连续3次判定FPM不健康,自动重启了FPM。但实际上FPM完全正常。
**教训:健康检查必须够轻,不依赖任何外部服务**。最终版本只检查内存和磁盘。
坑二:调max_children只看内存,没算CPU
当时算的是8G内存 / 150MB = 50个进程没问题,但忽略了CPU只有4核。FPM的worker是CPU密集型——一旦PHP代码有慢逻辑,50个worker同时跑,CPU直接跑满。
**教训:`max_children`的上限是`min(内存上限, CPU核数 × 5)`**。4核CPU最多设置20-25个worker,多了CPU上下文切换就会吃掉性能。
坑三:max_requests设置过小导致频繁回收
`pm.max_requests`最初设的是100。然后发现FPM日志里有大量`NOTICE: [pool www] child 12345 started`的日志——每个worker处理100个请求就被回收了,创建新进程的开销反而拖慢了整体性能。
**教训:把`max_requests`设为500-1000比较合理**,既要防内存泄漏,又不能太频繁回收。100太激进,1000又有点多,500是平衡点。
坑四:Nginx upstream keepalive忘加了
调优后Nginx配置加了一个:
upstream php_backend {
server unix:/var/run/php-fpm.sock;
keepalive 32; # 这个必须加
}
不加的话,Nginx和FPM之间的连接每次都要新建,TCP握手开销在压测的200并发下会额外损失约8%的QPS。加上后压测QPS从1240提升到1340。
坑五:FPM状态页没限制IP,直接被公网扫描攻击
PHP-FPM状态页的`allow`/`deny`配置一开始写的是:
location ~ ^/php-fpm-status {
fastcgi_pass unix:/var/run/php-fpm.sock;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
# 没写allow/deny
}
结果一天后被扫描器刷了几十万次,FPM日志刷屏,负载飙升。
**教训:状态页、非业务接口必须限制IP访问**。`allow 127.0.0.1;`是第一道防线,如果有多台内网机器需要访问,把`allow 10.0.0.0/8;`加进去。
总结一张常见原因排查对照表
坑四:Nginx upstream keepalive忘加了
调优后Nginx配置加了一个:
upstream php_backend {
server unix:/var/run/php-fpm.sock;
keepalive 32; # 这个必须加
}
不加的话,Nginx和FPM之间的连接每次都要新建,TCP握手开销在压测的200并发下会额外损失约8%的QPS。加上后压测QPS从1240提升到1340。
坑五:FPM状态页没限制IP,直接被公网扫描攻击
PHP-FPM状态页的`allow`/`deny`配置一开始写的是:
location ~ ^/php-fpm-status {
fastcgi_pass unix:/var/run/php-fpm.sock;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
# 没写allow/deny
}
结果一天后被扫描器刷了几十万次,FPM日志刷屏,负载飙升。
**教训:状态页、非业务接口必须限制IP访问**。`allow 127.0.0.1;`是第一道防线,如果有多台内网机器需要访问,把`allow 10.0.0.0/8;`加进去。
总结一张常见原因排查对照表
upstream php_backend {
server unix:/var/run/php-fpm.sock;
keepalive 32; # 这个必须加
}location ~ ^/php-fpm-status {
fastcgi_pass unix:/var/run/php-fpm.sock;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
# 没写allow/deny
}| 现象 | 检查项 | 命令/日志位置 |
|---|---|---|
| 502,且Nginx报111 Connection refused | FPM进程池是否耗尽 | FPM状态页active processes = max_children |
| 502,但FPM进程数正常 | FPM是否崩溃,socket文件是否存在 | ls -la /var/run/php-fpm.sock,FPM日志 |
| 502,重启FPM后恢复 | FPM卡死/内存泄漏 | 查看FPM内存占用趋势,检查pm.max_requests |
| 504,等待长时间后返回 | 应用执行时间超过fastcgi_read_timeout | PHP-FPM慢日志,MySQL慢查询日志 |
| 504,请求瞬间返回 | Nginx与FPM连接被拒或上游挂起 | Nginx access log的upstream_response_time字段 |
| 间歇性502/504 | MySQL连接池耗尽 | MySQL SHOW PROCESSLIST; 查看连接数 |