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

79 次浏览3 条回复

这份清单用于 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 文件,便于找到命中的虚拟主机、locationproxy_passnginx -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:30000.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 -Terror_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 为例;执行前替换服务名,且容器内需具备 getentcurlss,极简镜像缺少工具时应使用接入同一网络的临时诊断容器。

# 在宿主机确认两个服务实际加入的网络;两边至少应有一个共同网络
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/healthzhttp://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 svckubectl get endpointslice -l kubernetes.io/service-name=app 核对 Service 是否选中了就绪的后端。DNS 有结果但 EndpointSlice 为空,重点检查 selector 与 readiness;EndpointSlice 有地址但连接被拒绝,则回到应用监听地址、容器端口和 targetPort 的对应关系。

麦田书签#1

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

# 在宿主机确认两个服务实际加入的网络;两边至少应有一个共同网络
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/healthzhttp://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 svckubectl 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,upstreamserver 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 的属主/组或强制访问控制;若两者都失败,则先检查应用是否真的绑定了该路径,以及启动日志中是否有 EADDRINUSEEACCES 或旧 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