运维踩坑:nginx 缓存上游容器旧 IP,请求转发到了错误的服务

容器化微服务环境,前置 nginx 反代到 Spring Cloud Gateway。某次 gateway 容器重建后,所有经 nginx 的 /api/** 请求开始返回 500,而 gateway 自身、服务发现、各业务服务均正常。

排查绕了一大圈,根因是 nginx 的一个经典行为:proxy_pass 写主机名时只在启动时解析一次,之后永久缓存。上游容器重建换 IP 后,nginx 仍在连旧 IP——而旧 IP 已被 Docker 分配给了另一个服务。

现场

前置 nginx + Spring Cloud Gateway + Nacos 架构,全部跑在同一个 Docker 自定义网络(172.18.0.0/16)里:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
                     Docker 网络 172.18.0.0/16
┌─────────┐ ┌──────────────────────────────────────────────┐
│ 客户端 │ ──8000─▶│ nginx 容器 172.18.0.10 │
└─────────┘ │ location /api/ { proxy_pass ...:8080 } │
└───────────────────┬──────────────────────────┘
│ proxy_pass http://project-gateway:8080
▼ (nginx 在启动时把主机名解析成 IP 并缓存)
┌─────────────────────────────┴─────────────────────────────┐
▼ ▼ ▼
┌───────────────────┐ ┌───────────────────┐ ┌───────────────────┐
│ project-gateway │ │ project-order │ │ project-auth │
│ Spring Cloud │ │ 业务服务 │ │ 业务服务 │
│ Gateway │ │ 172.18.0.20 │ │ 172.18.0.22 │
│ 172.18.0.21 │ └───────────────────┘ └───────────────────┘
│ 订阅 Nacos 实例 │ ▲
└─────────┬─────────┘ │
│ │ nginx worker 实际连的是这个旧 IP
▼ │ —— gateway 重建前它属于 gateway,
┌───────────────────┐ │ 重建后被 Docker 重新分配给了 order
│ Nacos │ │
│ 172.18.0.2 │ ─ ─ ─ ─ ─ ─ ─ ┘
└───────────────────┘ 服务发现正常,但与 nginx 转发目标无关

请求链路本该是 客户端 → nginx → gateway → 业务服务,gateway 再按 Nacos 的实例列表把请求路由到 project-order / project-auth。但出问题时 nginx 根本没把请求交给 gateway——某个经 nginx 的接口请求报错:

1
2
GET http://10.20.30.40:8000/api/xxx
{"traceId":null,"code":1,"data":null,"message":"unknow.exception"} HTTP 500

几个看似矛盾的现象,把排查方向带偏了数次:

  • 服务发现正常:目标服务已注册到 Nacos,gateway 也订阅到了实例。
  • gateway 容器内直连正常curl gateway:8080/xxx 返回 200。
  • 但经 nginx 的请求,gateway 一条日志都没有——请求根本没到 gateway。
  • nginx 配置看不出问题location /api/ { proxy_pass http://project-gateway:8080/; }proxy_pass 带尾部 / 会剥前缀,没毛病。
  • docker exec nginx curl project-gateway:8080 也是通的。

配置对、直连通、exec 也通,唯独经 nginx 就 500 且 gateway 收不到——问题出在 nginx worker 实际把请求转发到了哪。抓 nginx worker 的真实 TCP 连接目标(/proc/net/tcp 解码):

1
38   -> 172.18.0.20:8080     ← nginx worker 在连这个 IP

对照各容器当前 IP:

1
2
3
project-gateway -> 172.18.0.21
project-order -> 172.18.0.20 ← nginx 连的是它,不是 gateway
project-auth -> 172.18.0.22

nginx 把请求转发到了 172.18.0.20(业务服务 project-order),而非 gateway(.21)。该服务没有对应路由,抛异常被框架兜底成 500。

为什么 docker exec 通、worker 却不通?exec 启动的新 shell 用当前 DNS(解析到 .21),而 nginx worker 用的是启动时缓存的旧 IP(.20)。同一容器、同一网络栈,DNS 解析的时机不同。

根因

1
2
3
location /api/ {
proxy_pass http://project-gateway:8080/; # 静态主机名,全配置无 resolver
}
  • proxy_pass 写的是主机名,且全配置没有 resolver 指令 → nginx 在启动时解析一次并永久缓存,之后不再重新解析。
  • gateway 容器重建后 IP 由 .20.21,旧 IP .20 被 Docker 重新分配给了 project-order
  • nginx 仍在连旧 IP,于是所有 /api/** 请求都打到了 project-order

一句话:proxy_pass 写主机名 = 启动期解析一次后缓存,容器 IP 一变就连错。

修复

resolver + 变量版 proxy_pass,让 nginx 按 TTL 动态解析:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
http {
resolver 127.0.0.11 valid=30s ipv6=off; # Docker 内置 DNS
}

location /api/ {
set $upstream_gateway project-gateway; # 变量触发动态解析
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;

rewrite ^/api/(.*)$ /$1 break; # 显式剥 /api/ 前缀

proxy_pass http://$upstream_gateway:8080; # 不带尾部 /,配 rewrite 用纯地址
}

三个关键点:

  1. resolver 127.0.0.11 valid=30s —— Docker 内置 DNS,解析结果 30 秒过期。
  2. set $upstream + 变量版 proxy_pass —— proxy_pass 一旦含变量,nginx 就不再启动期静态解析,而是运行时走 resolver。这是社区版 nginx 动态解析上游的唯一方式。
  3. rewrite ^/api/(.*)$ /$1 break —— 必须显式剥前缀(原因见下)。

连环坑:变量版 proxy_pass 不剥前缀

从纯文本 proxy_pass http://x:8080/; 改成变量版后,原来自动剥 /api/ 前缀的能力消失了,后端会收到错误的 path(实测 gateway 收到的是 /)。所以必须配 rewrite ... break 显式剥,并且 proxy_pass 去掉尾部 /(配 rewrite break 时必须是纯地址,带 URI 会二次改写)。

别走这条路

upstream {} 块里写主机名同样是启动期一次性解析,不动态刷新(只有 Nginx Plus 的 resolve 参数才动态)。upstream 块解决不了这个问题。

改完 nginx -t && nginx -s reload,容器怎么重建、IP 怎么变,nginx 都会按 30 秒 TTL 自动跟上。