Node.js 服务出现 502:从反向代理到日志定位的排查清单

171 次浏览6 条回复

这份清单用于 Linux 上由 Nginx 反向代理、systemd 托管的 Node.js 服务。命令基线为 systemd 245+、Nginx 1.18+、curl 7.68+;Node.js 版本不限,但应记录服务实际使用的二进制版本。示例约定 systemd 单元为 node-app、上游为 127.0.0.1:3000、健康检查路径为 /healthz,执行前请替换为真实值。若 Nginx 或应用位于容器中,127.0.0.1 只代表各自容器,连通性检查必须在 Nginx 所在网络命名空间内执行。

以下步骤以只读取证为主。先保留故障现场,不要一看到 502 就立即重启,否则可能丢失进程退出原因和时间关联。

0. 记录版本、时间和服务入口

date -u
node --version
nginx -v
systemctl --version | head -n 1
curl --version | head -n 1
systemctl show node-app -p MainPID -p ExecStart -p User -p WorkingDirectory -p EnvironmentFiles

含义:建立统一的 UTC 时间线,并确认 systemd 实际启动的命令、用户和工作目录。交互式终端里的 node --version 可能与 ExecStart 指向的 Node.js 不同;应以 ExecStart 中的绝对路径为准再次查询版本。

验证:MainPID 应为非零值,ExecStart 的脚本和工作目录应存在,版本信息应与部署清单一致。

1. 确认 502 确实来自目标反向代理

curl --resolve app.example.com:443:127.0.0.1 \
  --silent --show-error --max-time 5 \
  -D - -o /dev/null https://app.example.com/healthz

含义:绕过外部 DNS 和负载均衡,直接访问本机 443,同时保留正确的 Host 与 TLS SNI。没有 HTTPS 时改用实际的 HTTP 端口。

验证:响应状态若仍为 502,故障范围已缩小到本机 Nginx、上游连接或应用响应;若本机正常而外部入口异常,应转查负载均衡、入口网关及其健康检查。不要只凭 Server 响应头判断请求经过了哪个代理。

2. 核对 Nginx 实际生效的上游配置

sudo nginx -T 2>&1 | grep -nE 'server_name|location|proxy_pass|upstream|proxy_(connect|read|send)_timeout'
sudo nginx -t

含义:nginx -T 展开主配置及 include 文件,便于找到命中的虚拟主机、location 和 proxy_pass;nginx -t 只验证语法和引用文件,不验证上游可达。

验证:逐项确认协议、主机、端口、Unix socket 路径及目标 location。特别检查 proxy_pass 是否误写成 HTTPS、域名是否解析到旧地址,以及 URI 末尾斜杠是否改变了转发路径。若使用 upstream,所有成员都应是当前实例。

3. 检查进程状态与监听地址

systemctl is-active node-app
systemctl status node-app --no-pager -l
sudo ss -lntp | grep -E 'LISTEN.*:3000([[:space:]]|$)'
ps -fp "$(systemctl show node-app -p MainPID --value)"

含义:依次确认单元是否存活、最近一次退出原因、3000 端口由谁监听,以及 systemd 记录的主进程是否仍存在。

验证:状态应为 active,端口应处于 LISTEN,PID 应与 MainPID 对应。Nginx 转发到 127.0.0.1:3000 时,应用监听 127.0.0.1:3000 或 0.0.0.0:3000 均可;只监听 ::1:3000 无法接受发往 IPv4 回环地址的连接。容器之间不能把另一容器写成 127.0.0.1,应使用容器服务名或共享网络中的地址。

4. 绕过代理直连健康检查

curl --fail --silent --show-error --max-time 3 \
  -D - -o /dev/null http://127.0.0.1:3000/healthz
printf 'exit=%s\n' "$?"

含义:直接验证 Node.js 的 TCP 建连、HTTP 解析和健康检查处理,不经过 Nginx。curl 退出码 7 常见于无法连接,28 表示超时,22 表示服务返回了 400 及以上状态。

验证:预期是退出码 0 和约定的成功状态,通常为 200。若没有 /healthz,使用一个确定存在且副作用为零的 GET 路径。若同时提供存活与就绪接口,两者都检查:存活成功但就绪失败通常指向数据库、缓存或其他依赖未就绪。

若直连成功而代理仍为 502,从 Nginx 所在主机或容器重复同一请求。结果不同通常表示网络命名空间、地址、端口、socket 权限或安全策略不一致。

5. 用同一时间窗口关联 Nginx 与应用日志

START="$(date --iso-8601=seconds)"
curl --silent --show-error --max-time 5 -o /dev/null \
  https://app.example.com/healthz
sudo journalctl -u nginx -u node-app --since "$START" --no-pager -o short-iso
sudo tail -n 200 /var/log/nginx/error.log
sudo journalctl -k --since '-30 min' --no-pager | grep -iE 'out of memory|killed process'

含义:先记时间再触发一次请求,把代理错误、应用异常和内核事件放进同一时间窗口。若 Nginx 错误日志不在默认路径,应从 nginx -T 的 error_log 指令获取实际路径。

常见日志与下一步:

  • connect() failed (111: Connection refused):目标地址没有监听,或进程正在重启;回到步骤 2 和 3。
  • upstream timed out:已连接但未及时响应;检查事件循环阻塞、慢依赖以及 proxy_read_timeout,不要先用放大超时掩盖根因。
  • upstream prematurely closed connection:应用在完整响应前关闭连接;查同一秒内的未捕获异常、进程退出、OOM 或请求体处理错误。
  • no live upstreams:所有上游均被判定不可用;核对成员地址、失败计数和恢复状态。
  • upstream sent too big header:响应头超过代理缓冲区;先定位异常膨胀的 Cookie 或响应头,再评估缓冲区配置。

如果应用没有收到对应请求日志,问题位于 Nginx 到监听端口之间;如果应用记录了请求并报错,继续沿该请求的时间戳或请求 ID 检查堆栈和下游依赖。日志中出现密钥、会话或用户数据时不要直接粘贴到公开讨论。

6. 检查健康检查配置是否与服务契约一致

确认负载均衡或编排平台使用的路径、端口、协议、Host 头、超时和成功状态范围。健康检查路径应快速、无副作用,并明确区分“进程存活”和“实例可接流量”。

可从代理侧重复验证:

for i in 1 2 3; do
  curl --silent --show-error --max-time 3 -o /dev/null \
    -w 'status=%{http_code} total=%{time_total}\n' \
    http://127.0.0.1:3000/healthz
  sleep 1
done

验证:三次请求都应返回约定的成功状态,耗时应低于健康检查超时;若状态交替变化,检查多实例上游、滚动发布状态及共享依赖。

7. 恢复后的闭环标准

只有同时满足以下条件,才可认为 502 已闭环:

  1. systemd 单元持续为 active,PID 与监听进程一致。
  2. 从 Nginx 所在网络环境直连上游连续成功。
  3. 经过目标虚拟主机访问同一路径连续成功。
  4. Nginx 错误日志和应用日志在复测窗口内没有新增对应异常。
  5. 外部负载均衡重新判定实例健康,真实业务路径返回预期状态。

若最终需要重启或修改配置,应先保存上述时间线和错误片段;变更后重新执行 nginx -t、直连检查与代理检查,并记录变更前后的可观察差异。

可以把原文中的“从 Nginx 所在网络环境直连”细化为一组容器侧对照检查。以下以 Docker Compose v2、代理服务 proxy、应用服务 app、容器端口 3000 为例;执行前替换服务名,且容器内需具备 getent、curl、ss,极简镜像缺少工具时应使用接入同一网络的临时诊断容器。

# 在宿主机确认两个服务实际加入的网络;两边至少应有一个共同网络
docker inspect "$(docker compose ps -q proxy)" \
  --format '{{json .NetworkSettings.Networks}}'
docker inspect "$(docker compose ps -q app)" \
  --format '{{json .NetworkSettings.Networks}}'

# 解析和连通性都必须从代理容器内检查
docker compose exec -T proxy getent hosts app
docker compose exec -T proxy curl --fail --show-error --max-time 3 \
  http://app:3000/healthz

# 同时确认应用不是只绑定在自己的回环地址
docker compose exec -T app ss -lnt

这里不要用宿主机上的 getent app 代替第一条解析检查:Compose 服务名通常只在共同的用户自定义网络内可解析。ports 发布的是宿主机到容器的入口,也不能证明 proxy 能通过 app:3000 访问容器端口。若服务名无法解析,先核对共同网络和 proxy_pass 中的名称;能解析但连接被拒绝时,再看应用是否只监听 127.0.0.1:3000。两个容器网络命名空间分离时,应用绑定自己的 127.0.0.1,代理即使解析出应用容器 IP 也无法连接;此时应用通常应监听 0.0.0.0:3000,再由容器网络控制可达范围。

还可在代理容器内分别请求 http://127.0.0.1:3000/healthz 与 http://app:3000/healthz:前者失败、后者成功,恰好说明两个地址属于不同语义,不应把 proxy_pass 写成回环地址。若服务名直连成功而 Nginx 仍报 502,应把 nginx -T 中实际生效的 proxy_pass、代理容器内当前解析结果,以及 docker inspect 得到的应用容器 IP 放在同一时间点比较;应用容器重建后 IP 变化时,也不要假定 Nginx 一定已经重新解析名称,其行为取决于所用配置方式和 Nginx 版本。

Kubernetes 需要再区分一层:同一 Pod 内的容器共享网络命名空间,sidecar 代理访问应用时可以使用 127.0.0.1;不同 Pod 之间则应通过 Service 名称和 targetPort。可从代理 Pod 内执行 getent hosts app.<namespace>.svc.cluster.local 和同一路径的 curl,并用 kubectl get svc、kubectl get endpointslice -l kubernetes.io/service-name=app 核对 Service 是否选中了就绪的后端。DNS 有结果但 EndpointSlice 为空,重点检查 selector 与 readiness;EndpointSlice 有地址但连接被拒绝,则回到应用监听地址、容器端口和 targetPort 的对应关系。

橘子过期Lv1#1

可以把原文中的“从 Nginx 所在网络环境直连”细化为一组容器侧对照检查。以下以 Docker Compose v2、代理服务 proxy、应用服务 app、容器端口 3000 为例;执行前替换服务名,且容器内需具备 getent、curl、ss,极简镜像缺少工具时应使用接入同一网络的临时诊断容器。

# 在宿主机确认两个服务实际加入的网络;两边至少应有一个共同网络
docker inspect "$(docker compose ps -q proxy)" \
  --format '{{json .NetworkSettings.Networks}}'
docker inspect "$(docker compose ps -q app)" \
  --format '{{json .NetworkSettings.Networks}}'

# 解析和连通性都必须从代理容器内检查
docker compose exec -T proxy getent hosts app
docker compose exec -T proxy curl --fail --show-error --max-time 3 \
  http://app:3000/healthz

# 同时确认应用不是只绑定在自己的回环地址
docker compose exec -T app ss -lnt

这里不要用宿主机上的 getent app 代替第一条解析检查:Compose 服务名通常只在共同的用户自定义网络内可解析。ports 发布的是宿主机到容器的入口,也不能证明 proxy 能通过 app:3000 访问容器端口。若服务名无法解析,先核对共同网络和 proxy_pass 中的名称;能解析但连接被拒绝时,再看应用是否只监听 127.0.0.1:3000。两个容器网络命名空间分离时,应用绑定自己的 127.0.0.1,代理即使解析出应用容器 IP 也无法连接;此时应用通常应监听 0.0.0.0:3000,再由容器网络控制可达范围。

还可在代理容器内分别请求 http://127.0.0.1:3000/healthz 与 http://app:3000/healthz:前者失败、后者成功,恰好说明两个地址属于不同语义,不应把 proxy_pass 写成回环地址。若服务名直连成功而 Nginx 仍报 502,应把 nginx -T 中实际生效的 proxy_pass、代理容器内当前解析结果,以及 docker inspect 得到的应用容器 IP 放在同一时间点比较;应用容器重建后 IP 变化时,也不要假定 Nginx 一定已经重新解析名称,其行为取决于所用配置方式和 Nginx 版本。

Kubernetes 需要再区分一层:同一 Pod 内的容器共享网络命名空间,sidecar 代理访问应用时可以使用 127.0.0.1;不同 Pod 之间则应通过 Service 名称和 targetPort。可从代理 Pod 内执行 getent hosts app.<namespace>.svc.cluster.local 和同一路径的 curl,并用 kubectl get svc、kubectl get endpointslice -l kubernetes.io/service-name=app 核对 Service 是否选中了就绪的后端。DNS 有结果但 EndpointSlice 为空,重点检查 selector 与 readiness;EndpointSlice 有地址但连接被拒绝,则回到应用监听地址、容器端口和 targetPort 的对应关系。

再补一个容易造成“应用容器重建后直连正常、经 Nginx 仍为 502”的版本点:容器 DNS 已返回新地址,不等于 Nginx 正在使用新地址。以下前提是 Docker Compose 用户自定义网络,容器内 DNS 为 127.0.0.11。

对于开源版 Nginx,upstream 中 server app:3000 resolve; 的动态解析能力从 1.27.3 起可用;此前该用法属于商业版能力。1.27.3 及以上可采用:

upstream node_backend {
    zone node_backend 64k;
    resolver 127.0.0.11 valid=10s ipv6=off;
    resolver_timeout 2s;
    server app:3000 resolve;
    keepalive 32;
}

server {
    location / {
        proxy_pass http://node_backend;
    }
}

执行前先确认实际版本和生效配置:

docker compose exec -T proxy nginx -v
docker compose exec -T proxy nginx -T 2>&1 | \
  grep -nE 'upstream|server app:3000|resolver|resolver_timeout|proxy_pass'

若开源版低于 1.27.3,静态写法 server app:3000; 通常在读取配置时解析服务名;应用容器更换 IP 后,仅在代理容器内执行 getent hosts app 不能证明 Nginx 已更新上游地址。兼容做法是在部署流程确认新应用已就绪后,对 Nginx 做一次受控 reload;使用变量触发运行时解析也是可选方案,但 proxy_pass 带变量时 URI 拼接语义可能改变,必须回归测试带前缀、重写和查询参数的路径,不能只验证健康检查。

变更动态解析配置前后,可以用下面的同一网络对照验证。示例假定 Nginx 容器内监听 80,虚拟主机为 app.example.com:

# DNS 视角和应用直连
docker compose exec -T proxy getent ahostsv4 app
docker compose exec -T proxy curl --fail --show-error --max-time 3 \
  http://app:3000/healthz

# 经 Nginx 的同一路径
docker compose exec -T proxy curl --fail --show-error --max-time 3 \
  -H 'Host: app.example.com' http://127.0.0.1/healthz

# 检查语法;确认成功后再按部署流程 reload
docker compose exec -T proxy nginx -t

闭环标准应是:应用容器地址变化后,服务名解析、上游直连和经 Nginx 请求都连续成功,代理错误日志不再出现指向旧 IP 的连接失败。若 Nginx 版本不足且暂不升级,应把 reload 明确放进应用容器替换后的部署步骤,而不是依赖 DNS TTL 自动生效。

如果 proxy_pass 指向 Unix socket,502 还应单独检查路径权限和安全策略;TCP 端口与容器 DNS 的结论不能直接套用。以下沿用原文的 Linux、Nginx 1.18+、curl 7.68+ 前提,假设实际配置类似 proxy_pass http://unix:/run/node-app/app.sock:,路径和 Host 需替换。

先确认生效配置、实际 worker 用户以及 socket 的每一级目录权限:

sudo nginx -T 2>&1 | grep -nE '^[[:space:]]*user|proxy_pass[[:space:]]+http://unix:'
ps -eo user,pid,ppid,comm,args | grep '[n]ginx: worker process'
sudo namei -l /run/node-app/app.sock
sudo stat -Lc 'type=%F mode=%a owner=%U group=%G inode=%i' \
  /run/node-app/app.sock
sudo ss -xlpn | grep -F '/run/node-app/app.sock'

验证时不要只看 socket 文件本身:Nginx worker 用户对 /run 到 socket 父目录的每一级都需要搜索权限,Linux 上连接流式 Unix socket 还受 socket 文件写权限约束。ss 应显示该路径正在监听;文件存在但没有监听者时,仍会连接失败。若 socket 路径末端是符号链接,namei -l 也能把实际目标和中间目录展开。

随后以实际 worker 用户绕过 Nginx 直连。下面的 www-data 必须替换为上一步看到的用户:

sudo -u www-data curl --unix-socket /run/node-app/app.sock \
  --fail --show-error --max-time 3 \
  -H 'Host: app.example.com' http://localhost/healthz
printf 'exit=%s\n' "$?"

若应用自身用户直连成功而 worker 用户失败,范围已缩小到目录、socket 的属主/组或强制访问控制;若两者都失败,则先检查应用是否真的绑定了该路径,以及启动日志中是否有 EADDRINUSE、EACCES 或旧 socket 残留。不要用 chmod 777 作为修复,它会掩盖实际的用户组设计。

当普通权限看起来正确但 Nginx 日志仍是 Permission denied,再检查主机启用的安全模块:

getenforce 2>/dev/null || true
sudo ausearch -m AVC -ts recent 2>/dev/null | tail -n 50
sudo journalctl -k --since '-15 min' --no-pager | \
  grep -iE 'apparmor=.*DENIED|avc:.*denied'

有 AVC 或 AppArmor 拒绝记录时,应修正 socket 所在目录的标签或对应策略,而不是关闭安全模块。对于 /run 下的临时 socket,还要确认重启后的目录与权限可重建;systemd 服务可用 RuntimeDirectory=、RuntimeDirectoryMode= 和明确的用户组关系管理父目录。闭环验证应至少包括:应用重启后 socket 重新出现并处于监听状态、worker 用户直连成功、经目标虚拟主机请求成功,且 Nginx 错误日志没有新增该路径的 connect() failed。

再补一个上游本身使用 HTTPS 的分支:TCP 端口可达、应用也在监听,并不代表 Nginx 到上游的 TLS 握手成功。若错误日志出现 SSL_do_handshake() failed、证书名称不匹配或上游主动关闭连接,应单独核对 SNI、信任链与双向 TLS。以下沿用 Nginx 1.18+ 前提,并要求在 Nginx 所在主机或容器网络命名空间内执行;名称、端口和 CA 路径必须替换为实际值。

先确认实际生效的 HTTPS 上游配置:

sudo nginx -T 2>&1 | grep -nE \
  'proxy_pass[[:space:]]+https://|proxy_ssl_(server_name|name|verify|trusted_certificate|certificate|certificate_key)'
sudo tail -n 200 /var/log/nginx/error.log | \
  grep -iE 'SSL_do_handshake|certificate|upstream'

proxy_ssl_server_name 默认不发送 SNI;后端按 SNI 选择证书或虚拟主机时,应显式开启。若 proxy_pass 使用 upstream 组名,默认校验名称也可能不是证书中的 DNS 名,应核对 proxy_ssl_name。一组典型配置是:

location / {
    proxy_pass https://node_tls_backend;
    proxy_ssl_server_name on;
    proxy_ssl_name api.internal.example;
    proxy_ssl_verify on;
    proxy_ssl_trusted_certificate /etc/nginx/ca/upstream-ca.pem;
    proxy_ssl_verify_depth 3;
}

在代理所在网络环境中,用同一 SNI 和 CA 做只读握手验证:

openssl s_client \
  -connect <backend-name-or-ip>:<tls-port> \
  -servername <backend-certificate-name> \
  -verify_return_error \
  -CAfile <upstream-ca.pem> </dev/null

应确认返回的证书 SAN 包含 proxy_ssl_name 使用的名称、证书链可验证、有效期覆盖当前 UTC 时间,并且协商出的协议和密码套件符合两端策略。只执行不带 -servername 或不校验证书的探测,可能得到与 Nginx 不同的结果。

若后端要求双向 TLS,还要核对 proxy_ssl_certificate 与 proxy_ssl_certificate_key 是否为当前客户端身份,并在受控环境中用 openssl s_client -cert <client-cert> -key <client-key> 复现;私钥内容和完整证书清单不应贴入公开日志。不要通过关闭 proxy_ssl_verify 把验证错误变成长期配置。变更后先运行 nginx -t,再按部署流程 reload;闭环标准是从代理网络直连握手成功、经目标虚拟主机连续请求成功,并且复测时间窗内不再出现新的上游握手错误。

再补一层对 upstream timed out 的阶段定位。错误日志里的 while connecting to upstream、while reading response header from upstream 和 while reading upstream 指向的阶段不同;只看到 502 状态码时,容易把网络建连、Node.js 首字节延迟和响应体传输混在一起。沿用原文 Nginx 1.18+ 前提,先检查现有访问日志是否已经记录上游耗时:

sudo nginx -T 2>&1 | grep -nE \
  'log_format|access_log|proxy_(connect|read|send)_timeout'

若现有格式没有相关字段,可在目标虚拟主机使用一份专用格式;diag_id 只用于把一次探测与日志对齐,不要放用户数据:

log_format upstream_timing
    '$time_iso8601 diag_id=$http_x_diag_id status=$status '
    'request_time=$request_time upstream_addr=$upstream_addr '
    'upstream_status=$upstream_status connect=$upstream_connect_time '
    'header=$upstream_header_time response=$upstream_response_time';

access_log /var/log/nginx/upstream-timing.log upstream_timing;

配置变更后先执行 sudo nginx -t,通过后再按现有部署流程 reload。随后用无副作用路径触发一条带唯一标记的请求,并立即按标记取日志:

DIAG_ID=20260729T0711Z
curl --silent --show-error --max-time 10 \
  -H "X-Diag-Id: $DIAG_ID" \
  -D - -o /dev/null https://app.example.com/healthz
sudo grep -F "diag_id=$DIAG_ID" /var/log/nginx/upstream-timing.log

解释时把同一行的字段放在一起看:

  • connect 接近 proxy_connect_timeout:优先查路由、监听队列、防火墙或上游地址,而不是 Node.js 业务处理。
  • connect 很小但 header 接近 proxy_read_timeout:连接已建立,重点查事件循环阻塞、同步 CPU 工作、依赖调用或连接池等待。
  • header 很小而 response 很大或中途失败:重点查流式响应、响应体生成、客户端回压和上游提前断开。
  • upstream_addr、upstream_status 出现多个值时,说明发生过上游尝试或切换;各耗时字段也可能有多个值,应按相同顺序比对,不能只看最后一个状态。字段为 - 通常表示该阶段没有取得可记录值。

为排除 Nginx 自身等待,可从 Nginx 所在网络命名空间直连同一路径并输出 curl 分阶段耗时:

curl --silent --show-error --max-time 10 -o /dev/null \
  -w 'code=%{http_code} connect=%{time_connect} starttransfer=%{time_starttransfer} total=%{time_total}\n' \
  http://127.0.0.1:3000/healthz

直连和代理请求必须使用同一协议、Host 需求及无副作用路径;若上游是 HTTPS,还应沿楼上所述保持相同 SNI 与信任链。闭环时用新的唯一标记连续复测,确认对应行的 upstream_status 为预期状态、各阶段耗时低于配置阈值,并且错误日志在同一时间窗内没有新增超时。若专用日志只为临时诊断,取证结束后按变更流程移除并再次执行 nginx -t。

隔壁鱼号Lv1#5

再补一层对 upstream timed out 的阶段定位。错误日志里的 while connecting to upstream、while reading response header from upstream 和 while reading upstream 指向的阶段不同;只看到 502 状态码时,容易把网络建连、Node.js 首字节延迟和响应体传输混在一起。沿用原文 Nginx 1.18+ 前提,先检查现有访问日志是否已经记录上游耗时:

sudo nginx -T 2>&1 | grep -nE \
  'log_format|access_log|proxy_(connect|read|send)_timeout'

若现有格式没有相关字段,可在目标虚拟主机使用一份专用格式;diag_id 只用于把一次探测与日志对齐,不要放用户数据:

log_format upstream_timing
    '$time_iso8601 diag_id=$http_x_diag_id status=$status '
    'request_time=$request_time upstream_addr=$upstream_addr '
    'upstream_status=$upstream_status connect=$upstream_connect_time '
    'header=$upstream_header_time response=$upstream_response_time';

access_log /var/log/nginx/upstream-timing.log upstream_timing;

配置变更后先执行 sudo nginx -t,通过后再按现有部署流程 reload。随后用无副作用路径触发一条带唯一标记的请求,并立即按标记取日志:

DIAG_ID=20260729T0711Z
curl --silent --show-error --max-time 10 \
  -H "X-Diag-Id: $DIAG_ID" \
  -D - -o /dev/null https://app.example.com/healthz
sudo grep -F "diag_id=$DIAG_ID" /var/log/nginx/upstream-timing.log

解释时把同一行的字段放在一起看:

  • connect 接近 proxy_connect_timeout:优先查路由、监听队列、防火墙或上游地址,而不是 Node.js 业务处理。
  • connect 很小但 header 接近 proxy_read_timeout:连接已建立,重点查事件循环阻塞、同步 CPU 工作、依赖调用或连接池等待。
  • header 很小而 response 很大或中途失败:重点查流式响应、响应体生成、客户端回压和上游提前断开。
  • upstream_addr、upstream_status 出现多个值时,说明发生过上游尝试或切换;各耗时字段也可能有多个值,应按相同顺序比对,不能只看最后一个状态。字段为 - 通常表示该阶段没有取得可记录值。

为排除 Nginx 自身等待,可从 Nginx 所在网络命名空间直连同一路径并输出 curl 分阶段耗时:

curl --silent --show-error --max-time 10 -o /dev/null \
  -w 'code=%{http_code} connect=%{time_connect} starttransfer=%{time_starttransfer} total=%{time_total}\n' \
  http://127.0.0.1:3000/healthz

直连和代理请求必须使用同一协议、Host 需求及无副作用路径;若上游是 HTTPS,还应沿楼上所述保持相同 SNI 与信任链。闭环时用新的唯一标记连续复测,确认对应行的 upstream_status 为预期状态、各阶段耗时低于配置阈值,并且错误日志在同一时间窗内没有新增超时。若专用日志只为临时诊断,取证结束后按变更流程移除并再次执行 nginx -t。

这里需要补一个配置作用域细节:log_format 只能出现在 http 上下文。上文所说的“在目标虚拟主机使用”如果被理解为把 log_format 直接放进 server {},Nginx 1.18+ 会在 nginx -t 时报告 log_format directive is not allowed here。应在 http {} 中定义格式,再在目标 server 或 location 中选择对应的 access_log;使用拆分配置文件时,也要确认 include 发生在哪一层。

http {
    log_format upstream_timing
        '$time_iso8601 probe=$http_x_diag_id rid=$request_id '
        'status=$status request_time=$request_time upstream_addr=$upstream_addr '
        'upstream_status=$upstream_status connect=$upstream_connect_time '
        'header=$upstream_header_time response=$upstream_response_time';

    server {
        access_log /var/log/nginx/upstream-timing.log upstream_timing;

        location / {
            proxy_set_header X-Request-ID $request_id;
            proxy_pass http://node_backend;
        }
    }
}

沿用 Nginx 1.18+ 前提时可以使用 $request_id。客户端提供的 X-Diag-Id 适合作为临时探测标记,但不宜直接当作跨层唯一标识,因为它可重复或伪造;由 Nginx 生成 $request_id,覆盖传给上游的 X-Request-ID,再让 Node.js 的现有结构化日志记录该请求头,才能把代理阶段耗时和应用堆栈可靠地关联起来。若系统已经使用 traceparent 等追踪机制,应优先接入现有链路,避免再造一套互不对应的 ID。

变更前后至少验证配置作用域和实际生效内容:

sudo nginx -t
sudo nginx -T 2>&1 | grep -nE \
  'log_format upstream_timing|access_log.*/upstream-timing|proxy_set_header X-Request-ID'

PROBE=20260729T0826Z
curl --silent --show-error --max-time 10 \
  -H "X-Diag-Id: $PROBE" -o /dev/null \
  https://app.example.com/healthz
sudo grep -F "probe=$PROBE" /var/log/nginx/upstream-timing.log

从该行取得 rid 后,应能在对应时间窗的 Node.js 日志中找到同一值。还要注意:若 $upstream_addr 显示多个尝试,同一个 $request_id 可能被传给多个上游实例;多个应用日志条目未必代表客户端重复发起请求,应结合地址和尝试顺序解释。闭环标准是探测标记只定位到预期访问日志行、该行的 rid 能关联到应用日志,并且耗时字段与错误日志指向同一故障阶段。