Nginx 健康检查误判,服务节点怎么被批量剔除?

问题背景

公司 API Gateway 层使用 Nginx 作为反向代理,后端 10 台 Tomcat 服务器(Java 应用)组成 upstream 池,配置了被动健康检查(max_fails + fail_timeout)。正常情况下 10 台后端均匀承担约 2000 QPS 的流量。

故障现象

晚上 8:00,运维监控突然告警:API Gateway 返回大量 502 Bad Gateway。登录 Nginx 服务器查看后端状态:

1
2
3
4
5
6
7
8
$ curl http://127.0.0.1/nginx_status
upstream backend {
    server 10.0.1.1:8080 weight=1 max_fails=2 fail_timeout=30s;
    server 10.0.1.2:8080 weight=1 max_fails=2 fail_timeout=30s down;
    server 10.0.1.3:8080 weight=1 max_fails=2 fail_timeout=30s down;
    server 10.0.1.4:8080 weight=1 max_fails=2 fail_timeout=30s down;
    ... 全部10台后端显示 down ...
}

10 台后端服务器全部被 Nginx 标记为 down——流量没有地方去,全部返回 502。但登录后端服务器检查,Tomcat 进程运行正常,直接访问 10.0.1.x:8080/health 返回 200 OK!

排查过程

第一步:检查 Nginx 错误日志

1
2
3
4
5
6
7
$ tail -100 /var/log/nginx/error.log
2023/11/22 20:08:12 [error] upstream timed out (110: Connection timed out) 
  while connecting to upstream, client: 172.16.100.50, 
  server: api.company.com, request: "GET /v1/orders HTTP/1.1", 
  upstream: "http://10.0.1.1:8080/v1/orders"
2023/11/22 20:08:13 [error] upstream timed out (110: Connection timed out) 
  while connecting to upstream ...

所有被标记 down 的服务器都出现了 upstream timed out

第二步:检查后端 Tomcat 日志

1
2
$ grep "Full GC" /opt/tomcat/logs/catalina.out | grep "20:08"
2023-11-22 20:08:11 [Full GC (Allocation Failure) 2348M->1890M(4096M), 2.45 secs]

20:08:11,Tomcat 触发了一次 Full GC,耗时 2.45 秒。Full GC 期间所有应用线程被暂停(Stop-The-World),Tomcat 无法处理任何请求——包括 Nginx 的健康检查请求。

第三步:分析 Nginx 超时配置

Nginx 的 proxy 超时配置:

1
2
proxy_connect_timeout 2s;     # 建立连接超时 2 秒
proxy_read_timeout 30s;       # 读取响应超时 30 秒

proxy_connect_timeout 2s 控制了建立 TCP 连接的超时时间。Full GC 期间 Tomcat 虽然 accept 了 TCP 连接(内核态完成三次握手),但应用层无法处理——所以 proxy_connect_timeout 没有触发超时。

但 Nginx 在建立连接后发送 HTTP 请求,等待 Tomcat 返回响应——这走的是 proxy_read_timeout。在 Full GC 结束前,Tomcat 不会返回任何响应。30 秒后 Nginx 判定该请求超时,计为一次 failure。

Nginx 配置中 max_fails=2 意味着 2 次超时就标记 downstream。10 台 Tomcat 因为部署一致,几乎在同一时间触发 Full GC(相同的内存压力模式),于是短时间内全部被标记为 down。

第四步:验证时间线

1
2
3
4
5
20:08:10  流量高峰,所有Tomcat堆内存接近上限
20:08:11  第1轮:4台Tomcat触发Full GC(2.5秒),期间2次请求超时→被标记down
20:08:12  流量转移到剩余6台Tomcat,内存压力骤增
20:08:13  第2轮:剩余6台Tomcat触发Full GC(3秒),请求超时→被标记down
20:08:15  全部10台后端down,502风暴开始

健康检查的 fail_timeout=30s 后节点被重新加入 upstream 池,但流量涌回又导致新的 Full GC,节点再次被标记 down——形成恶性循环。

解决方案

紧急恢复

1
2
3
4
5
6
7
# 1. 立即重启所有Tomcat(释放堆内存)
for host in 10.0.1.{1..10}; do
    ssh $host "systemctl restart tomcat"
done

# 2. 重载Nginx(清除down标记)
nginx -s reload

修复配置

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
upstream backend {
    server 10.0.1.1:8080 max_fails=3 fail_timeout=10s;
    server 10.0.1.2:8080 max_fails=3 fail_timeout=10s;
    ...
}

server {
    location / {
        proxy_connect_timeout 3s;
        proxy_read_timeout 10s;              # 从30s降到10s
        proxy_next_upstream error timeout http_502 http_503;
    }
}

关键改动:

  • fail_timeout 从 30s 降到 10s——节点恢复更快
  • proxy_read_timeout 从 30s 降到 10s——不会因 GC 等待太久

应用层优化

  1. 调大 Tomcat 堆内存:从 4GB 扩大到 8GB
  2. 优化 GC 策略:从 ParallelGC 切换到 G1GC,减少 Full GC 频率和时延
  3. 添加主动健康检查端点/health 接口增加内存压力检测,堆使用 >90% 返回 503 让 Nginx 主动摘除

根因分析

直接原因:Java Full GC 的 STW 暂停(2.5 秒)导致 Tomcat 无法处理请求,Nginx 在 proxy_read_timeout(30 秒)后判定超时,累积触发 max_fails=2 将节点标记 down。所有节点因相同的内存模式同时触发 Full GC,被批量剔除。

深层原因:健康检查配置没有区分"节点过载"和"节点宕机"——Full GC 期间节点还在但暂时无法响应,应该给予缓冲而非直接剔除。

预防措施

  1. 主动健康检查:使用 Nginx Plus 或第三方模块(nginx_upstream_check)做主动健康检查,区分 503(过载)和 超时(宕机)
  2. 渐进式摘除:节点因超时被标记 down 后,先降权而非直接剔除
  3. JVM GC 监控:监控 Full GC 频率和耗时,Full GC >1 秒 / 频率 >1次/小时 告警
  4. 后端容错设计:至少保留 50% 的健康节点,即使全触发 GC 也不会全 down
  5. Nginx upstream 冗余server 块配置 backup 节点作为最后兜底

总结

Nginx 的健康检查本质上是"事后惩罚"——请求已经超时了,它才标记节点 down。当所有后端同时"卡顿"(GC、死锁、资源耗尽)时,健康检查不仅帮不上忙,还会把最后活着的节点也干掉。真正靠谱的做法是在应用层做主动降级(堆内存高了返回 503 主动退出),让 Nginx 在节点真正不可用之前就把流量导走,而非被动等到超时。

使用 Hugo 构建
主题 StackJimmy 设计