晚上十一点,/api/v1/messages 的 502 开始在监控上冒出来,密度不高,一分钟几个。十分钟后变成 504,重启一下 Nginx 好了八分钟,然后接着来。这篇把我这次和之前几次的排查整理成一套固定套路——如果一开始就把 502 和 504 分开看,前面二十分钟可以省掉。
一、先把判据定死:502 是"没连上/谈崩了",504 是"等太久"
| 状态码 | 一句话 | 出事在哪个阶段 |
|---|---|---|
| 502 | 后端不可达,或者响应不合法 | 建连失败(refused / reset / DNS)、协议错(把 https 当 http 连)、响应头非法或过大、上游提前关闭连接、no live upstreams |
| 504 | 连上了,但等不到回答 | 建连超时、写请求超时、读响应超时 |
| 499 | 客户端先挂了 | 不是后端的问题,是调用方等不了(Nginx 特有) |
这里有个必须记住的细节:连接上游超时返回的是 504,不是 502。 很多人凭直觉觉得"连不上就该是 502",于是看到 upstream timed out (110: Connection timed out) while connecting to upstream 配着一个 504 就对不上,还以为是上游自己返回的 504。
所以我把判据写得更细一点:
不要用状态码区分上游超时的阶段,要用
error.log里的while ...短语。
while connecting to upstream→ 卡在建连,对应proxy_connect_timeoutwhile sending request to upstream→ 卡在发请求,对应proxy_send_timeoutwhile reading response header from upstream→ 卡在读响应,对应proxy_read_timeout
二、error.log:先对号入座,几行字定方向
先解决一个小问题:error.log 到底在哪。发行版默认路径不一样,编译安装可能在 /usr/local/nginx/logs/。别猜,读配置:
nginx -T 2>/dev/null | grep -n 'error_log'
tail -f /var/log/nginx/error.log
我在这上面栽过:项目在 server 块里另写了 error_log /data/logs/gateway-error.log warn;,我在默认路径上 tail 了二十分钟,一个字都没看到。error_log 可以逐层覆盖,nginx -T | grep error_log 能看出哪一份真正生效;顺便它还能确认你改的文件确实被 include 了——我也有过改了 conf.d/x.conf、实际生效的却是 sites-enabled/x.conf 的经历。
error.log 关键行 | 意思 | 先去查什么 |
|---|---|---|
connect() failed (111: Connection refused) while connecting to upstream | 那台机器上没有进程在听这个端口 | 后端机上 ss -lntp;端口对不对、监听地址是 127.0.0.1 还是 0.0.0.0 |
connect() failed (13: Permission denied)(unix socket) | socket 文件权限不对 | ls -l /run/im/gateway.sock,看 Nginx worker 用户能不能读 |
upstream timed out (110: Connection timed out) while connecting | 包被丢了(防火墙 DROP、安全组、路由),不是被拒绝 | nc -vz -w2 <ip> <port>;后端 ss -lnt state listen 看 Send-Q(accept 队列是否满) |
upstream timed out ... while reading response header | 后端收到请求了,但迟迟不返回第一个字节 | 后端自己的耗时日志、DB、下游依赖;proxy_read_timeout 是不是被下游拖长了 |
upstream prematurely closed connection while reading response header | 后端在返回响应前把连接关了 | 后端 panic / 自杀 / keepalive 竞态(第五节专门讲) |
recv() failed (104: Connection reset by peer) | 对端发 RST:进程崩了、被 OOM kill、中间设备重置 | dmesg -T | grep -i oom;后端日志最后几行 |
no live upstreams while connecting to upstream | 所有上游节点都被 max_fails 标记成 down | upstream 块里的 max_fails / fail_timeout;节点只有一两个时特别致命 |
SSL_do_handshake() failed ... while SSL handshaking to upstream | 上游协议配错(http↔https)或证书链有问题 | openssl s_client -connect <ip>:<port> -brief </dev/null |
upstream sent too big header | 后端响应头超过 proxy_buffer_size | 后端是不是把一大坨东西塞进了 header 或 cookie |
upstream sent invalid header | 后端返回的根本不是 HTTP(自研协议、或者端口连到了别的服务) | curl -v 直连,看响应首行 |
三、把上游的耗时和状态码写进访问日志
error.log 只在失败的时候说话,要看趋势和"是哪台后端在慢",得改 log_format:
log_format upstream_detail '$remote_addr - $request_id "$request" $status '
'len=$body_bytes_sent '
'up=$upstream_addr '
'us=$upstream_status '
'uct=$upstream_connect_time '
'uht=$upstream_header_time '
'urt=$upstream_response_time '
'rt=$request_time';
access_log /var/log/nginx/access.log upstream_detail;
| 变量 | 含义 | 关键判读 |
|---|---|---|
$upstream_addr | 这次请求最终发给了哪台(重试时是逗号分隔的一串) | 出现多个值 = 发生了重试,说明第一个节点有问题 |
$upstream_status | 上游返回的状态码 | - = 根本没连上,对应 error.log 的 connect() failed |
$upstream_connect_time | 建 TCP 连接花了多久 | 正常是 0.00x;接近 proxy_connect_timeout 就是网络问题 |
$upstream_header_time | 从建连到收到响应头 | 比 urt 小很多 = 后端在慢写/流式输出 |
$upstream_response_time | 后端完整响应的耗时 | 用来定超时值和看趋势,别看平均值,看分位 |
$request_time | Nginx 视角的端到端耗时 | 远大于 urt = 慢在 Nginx 或客户端(大响应体、下行慢) |
日志是空格分隔的,所以这几个字段能直接 awk 出来。我常用的就两条:
# 最近 50 条 502/504,重点看 up / us / urt 三个字段
awk '$9==502 || $9==504' /var/log/nginx/access.log | tail -50
# urt 最大的 5 条(urt= 后面是秒数或 "-")
awk '{for(i=1;i<=NF;i++) if($i ~ /^urt=/) {split($i,a,"="); if(a[2] ~ /^[0-9.]+$/) print a[2]}}' \
/var/log/nginx/access.log | sort -n | tail -5
第二条的实际用途是回答一个问题:504 是"所有请求都慢",还是"少数请求特别慢"。 前者是容量或依赖问题,后者通常是某个特定查询、或者某一台后端节点。
四、绕过 Nginx 直连后端,这一步最关键
# 1) 在 Nginx 所在的那台机器上,直连后端
curl -sS -o /dev/null \
-w 'code=%{http_code} connect=%{time_connect} ttfb=%{time_starttransfer} total=%{time_total}\n' \
-H 'Host: api.ruilin.xin' --max-time 10 \
http://10.0.1.7:8090/api/v1/messages
# 2) 走 Nginx 打同一个接口(--resolve 把域名指到 Nginx 自己)
curl -sS -o /dev/null -w '%{http_code} %{time_total}\n' \
--resolve api.ruilin.xin:443:127.0.0.1 --max-time 10 \
https://api.ruilin.xin/api/v1/messages
判读逻辑很干脆:
- 直连又快又对,走 Nginx 才 502/504 → 问题在 Nginx 到后端这一段,或者 Nginx 自己的配置(keepalive、DNS 缓存、缓冲区,也可能中间还夹着一台 SLB)。
- 直连也慢也错 → 问题在后端,Nginx 只是把问题如实报了出来。
两个细节必须注意:别忘 -H 'Host: ...'——后端按 Host 分流时,不带 Host 直连会拿到一个 404,于是你又花时间怀疑"后端是不是挂了",其实只是你问错了问题;以及必须在 Nginx 那台机器上执行——502 经常是"某台机器到某台机器"的网络问题,你在自己电脑上直连正常,什么都说明不了。
这一轮里 curl -v 比 -w 更有用:它会打印 * Connected to ...、> GET ...、< HTTP/1.1 502,能直接看出是连不上(* connect to ... failed: Connection refused)还是连上了但响应不对。我第一次失败一定加 -v,不省那两秒。
五、连接池与 keepalive:偶发 502 最常见的元凶
有一类 502 的特征很鲜明:不是一直 502,而是零星几个;并发一高就变多;重启 Nginx 后安静十分钟。 这种我第一怀疑 upstream keepalive。
默认配置下,Nginx 每个请求都跟后端新建一条 TCP 连接。开了 upstream keepalive 之后连接会被复用——但如果后端的 idle 超时比 Nginx 的短,就会出现:后端认为这条连接该关了,Nginx 认为还能用,于是把新请求写到一条正在关闭的连接上 → upstream prematurely closed connection 或 recv() failed (104) → 502。这就是"偶发 502"的经典成因。
upstream gateway {
server 10.0.1.7:8090 max_fails=3 fail_timeout=10s;
server 10.0.1.8:8090 max_fails=3 fail_timeout=10s;
keepalive 64; # 每个 worker 保持的空闲连接数
keepalive_timeout 60s; # 空闲连接保留多久
keepalive_requests 1000; # 一条连接最多复用多少次
}
location /api/ {
proxy_pass http://gateway;
proxy_http_version 1.1; # 必须:1.0 不支持 keepalive
proxy_set_header Connection ""; # 必须:清掉 close,否则后端每次收到的都是 close
proxy_set_header Host $host;
proxy_next_upstream error timeout http_502 http_503;
}
三条最容易漏的:
proxy_http_version 1.1和proxy_set_header Connection ""要一起写。只写前者,后端仍然会收到Connection: close,连接池等于没有。- 后端的 idle 超时必须大于 Nginx 的
keepalive_timeout。 Go 的http.Server默认IdleTimeout是 0(跟着ReadTimeout走),很多项目压根没设。我现在的固定值是 Go 侧IdleTimeout: 75s、Nginx 侧keepalive_timeout 60s——留 15 秒余量,让"关连接"这个决定永远由 Nginx 先做。 max_fails和fail_timeout决定节点被摘多久。只有两个后端时,一台抖一下就可能两个都被摘掉 →no live upstreams→ 全站 502。节点这么少的集群里,我宁愿设max_fails=0(关掉被动摘除,靠上游 LB 或主动探测兜底),也不愿意整个池子被摘空。
六、三个 proxy_*_timeout 各管哪一段
| 参数 | 默认值 | 管哪一段 | 超时后 error.log 里的句子 |
|---|---|---|---|
proxy_connect_timeout | 60s | 与后端建立 TCP 连接 | upstream timed out ... while connecting to upstream |
proxy_send_timeout | 60s | 把请求发送给后端(两次写之间) | upstream timed out ... while sending request to upstream |
proxy_read_timeout | 60s | 读后端响应(两次读之间) | upstream timed out ... while reading response header from upstream |
两个必须记住的点:这三个超时都返回 504,从状态码上看不出是哪一段,只能看日志里的 while ...;以及 proxy_read_timeout 是"两次读之间的间隔",不是整个响应的总时长——一个慢慢吐数据的接口可以在 proxy_read_timeout=60s 下跑二十分钟都不超时,因为它每几秒就吐一点出来。想控总耗时要到应用层做,别指望这个参数。
调超时我的顺序是先量再调:从 access.log 里把 $upstream_response_time 的 P99 拉出来,把 proxy_read_timeout 设成 P99 的两到三倍,然后在后端加"超过预算就自己返回错误"的逻辑。反过来的做法——先把 proxy_read_timeout 调到 300s 让报错消失——只是把 504 变成一个更长的 504,用户等得更久而已。
七、看起来像 502,其实不是的三种
第一种:unix socket 权限。 后端监听 /run/im/gateway.sock,Nginx worker 以 www-data 运行,而 socket 文件的权限是 srw-rw---- root root。表现是 502,error.log 写的是 connect() failed (13: Permission denied) while connecting to upstream。验证:
ls -l /run/im/gateway.sock
sudo -u www-data curl --unix-socket /run/im/gateway.sock -sS \
-o /dev/null -w '%{http_code}\n' http://localhost/healthz
用 www-data 的身份去连一次,比盯着一堆权限位快得多。注意这个 socket 是后端启动时创建的,你手动 chown 之后后端一重启就白改——要固化在 systemd 里(RuntimeDirectory= 加 RuntimeDirectoryMode=,或者 ExecStartPost=/bin/chgrp www-data ...)。
第二种:证书或协议。 后端其实配了 TLS(或者 80/443 写反了),而 Nginx 用 http 去连它,报 SSL_do_handshake() failed 或 upstream sent invalid header,状态码仍然是 502。验证:
openssl s_client -connect 10.0.1.7:8090 -brief </dev/null
同源的还有一类:upstream 里写域名时,Nginx 只在启动和 reload 时解析一次,后端换了 IP 而 Nginx 没 reload,就会一直 502 到下次 reload。用 nginx -T | grep server 核对里面是 IP 还是域名;若是域名又用了变量形式的 proxy_pass,还得配 resolver,否则会直接报 no resolver defined to resolve ...,结果同样是 502。
第三种:静态资源的 404 被伪装成 502。 这个骗过我一次。配置里有:
proxy_intercept_errors on;
error_page 404 =502 /502.html;
再加上 try_files $uri @fallback;(@fallback 打到上游),一个不存在的静态文件最终会以 502 的形式返回给客户端。排查时看到 502,第一反应是"后端挂了",实际上后端一点事没有——只是文件不在。验证:
grep -rn 'proxy_intercept_errors\|recursive_error_pages\|error_page' /etc/nginx/
curl -sSI https://www.ruilin.xin/assets/not-exist.js | head -1
如果这个请求的 $upstream_status 是 404 而 $status 是 502,答案其实早就写在日志里了。
八、我现在每次都走的顺序
nginx -T | grep -nE 'error_log|access_log|proxy_pass|upstream|proxy_(read|connect|send)_timeout'—— 先确认生效的配置,别改错文件。nginx -t。tail -f那份真正生效的error.log,抓到一条关键行,对照第二节的表定位阶段(连不上 / 建连超时 / 读超时 / 提前关闭 / 无可用节点)。- 在 Nginx 那台机器上
curl -v -H 'Host: ...'直连后端,和走 Nginx 的结果对比。 - 用
$upstream_addr/$upstream_status/$upstream_response_time判断"是一台慢还是全慢""有没有在重试"。 - 偶发型 502 → 查 keepalive 两端是否匹配(Nginx 60s、后端 75s)。
- 504 型 → 先量
urt的 P99,再决定要不要动proxy_read_timeout。 - 全都看着正常 → 回到第七节,查 socket 权限、TLS 协议、
proxy_intercept_errors。
九、现象对照表
| 现象 | 最可能原因 | 一条命令验证 |
|---|---|---|
全站 502,日志有 no live upstreams | 所有节点被 max_fails 摘空 | grep -c 'no live upstreams' /var/log/nginx/error.log,再核对 upstream 块 |
| 偶发 502,并发一高就多 | upstream keepalive 与后端 idle 超时不匹配 | grep 'prematurely closed' error.log | tail;核对两端超时值 |
502,日志 connect() failed (111) | 后端没在听这个端口,或只听了 127.0.0.1 | 后端机上 ss -lntp | grep 8090 |
502,日志 connect() failed (13) | unix socket 权限 | sudo -u www-data curl --unix-socket <sock> ... |
502,日志 SSL_do_handshake failed | 上游协议或证书配错 | openssl s_client -connect <ip>:<port> -brief </dev/null |
| 只有某个静态路径 502 | proxy_intercept_errors 把 404 伪装成 502 | grep -rn proxy_intercept_errors /etc/nginx/;看该请求的 $upstream_status |
| 固定 60 秒后必 504 | proxy_read_timeout 撞上后端的慢接口 | tail -f error.log | grep 'while reading response header' |
504,日志写的是 while connecting | 建连超时:防火墙 DROP,或 accept 队列满 | nc -vz -w2 <ip> <port>;后端 ss -lnt state listen 看 Send-Q |
| 504 只集中在某一台后端 | 单节点容量问题或慢查询 | 按 $upstream_addr 分组统计 urt |
| 后端换 IP 后一直 502,reload 才好 | upstream 里的域名只在启动时解析 | nginx -T | grep server,确认后 nginx -s reload |
$upstream_status 是 404,$status 却是 502 | 错误页替换 | grep -rn error_page /etc/nginx/ |
那天晚上最后是什么?error.log 里刷的是 upstream prematurely closed connection while reading response header,$upstream_addr 集中在 10.0.1.8,$upstream_status 有不少是 -。查到底,是两件事叠在一起:新加的那个后端节点没配 upstream keepalive 的客户端参数(它的 idle 超时只有 15 秒,比 Nginx 的 60 秒短),同时一台老节点的连接数打满在排队。
proxy_read_timeout 从头到尾没动过。这也是我现在养成的习惯:先分清 502 和 504 是哪一类问题,再动手,别让"重启一下试试"变成排查手段——重启会让症状消失,也会把证据一起清掉。那次重启后安静的那八分钟,正好够把人骗过去。
留一句当判据:502 问的是"连上了吗",504 问的是"等了多久"。 这两个问题各有一条命令能回答——curl -v 直连、urt 分位。把这两条命令变成一个固定动作,比记住十条配置项有用得多。