一、一个让人抓狂的场景:后端 IP 换了,Nginx 死活不认
这个场景几乎每个用过 Nginx 反代的人都会撞上一次:
你的 Nginx 反向代理到一个域名上游,比如 proxy_pass http://api.example.com;。某天 api.example.com 的 DNS 记录换了 IP——可能是云厂商迁移、可能是你切到了新的负载均衡。你 nginx -s reload 了,甚至重启了整个 Nginx,结果发现请求还是打到老 IP 上,一直报 502 或者超时。
更让人困惑的是:在服务器上 curl http://api.example.com 一切正常,dig api.example.com 返回的也是新 IP,唯独 Nginx 不认。你去查 Nginx 配置,配置里写的是域名啊,它凭什么不用新的?
这个问题背后是 Nginx 一个非常古老的、几乎从不改变的设计决策,理解它只需要一句话:Nginx 在解析配置时就把 proxy_pass 里的域名解析成 IP 了,之后永远不会再解析。它不查 /etc/resolv.conf,不看系统缓存,每个 worker 进程只在启动/重载那一刻调用一次 getaddrinfo(),然后把结果永久留在内存里。
所以你以为的「动态上游」,实际上是一个「启动时快照的静态 IP」。DNS 变了,Nginx 不知道,也不会去问。
二、三种写法的真实行为差异
这个坑最阴险的地方在于:只在某些写法下才会触发。用 IP 直接写 proxy_pass 是没事的,因为你本来就把 IP 写死了。真正出问题的是用域名。下面把三种写法摆在一起对比。
写法一:upstream 块 + 域名(最隐蔽的坑)
upstream backend {
server api.example.com:8080;
}
server {
location / {
proxy_pass http://backend;
}
}这是很多教程推荐的写法,看起来最规范,实际上坑最深。原因是:upstream 块里的 server 指令在配置解析阶段就完成了解析,而且这个解析结果会缓存到 upstream 块的整个生命周期,连 reload 都不敢保证刷新(取决于 Nginx 版本和实现细节,早期版本确实存在 reload 后仍复用旧 IP 的情况)。
关键点:在 upstream 块里,resolver 指令是无效的。也就是说,你没法通过加 resolver 让 upstream 块里的域名动态解析。resolver 只对「运行时解析」的指令生效,而 upstream 里的 server 属于配置期解析。这是 Nginx 官方文档里明确说明但极易被忽略的一条:
# 这样写 resolver 完全没有作用
upstream backend {
server api.example.com:8080; # 配置期就解析死了
}
server {
resolver 8.8.8.8; # 管不到 upstream 里
location / { proxy_pass http://backend; }
}写法二:proxy_pass 直接带域名 + resolver(正确解法)
server {
resolver 127.0.0.53 valid=10s ipv6=off;
resolver_timeout 3s;
location / {
proxy_pass http://api.example.com:8080;
}
}这个写法才会走运行时动态解析。只要 proxy_pass 里出现域名而不是 upstream 名,Nginx 就会在运行时按需解析,并受 resolver 与 valid 控制。
这里有几个细节必须注意:
- 没有
resolver指令会直接报错。 这是新手最常见的困惑:单独写proxy_pass http://api.example.com而不加resolver,Nginx 启动时会报no resolver defined to resolve "api.example.com"。原因是「运行时解析」必须明确指定用哪个 DNS 服务器,Nginx 不会默认读/etc/resolv.conf。 valid=10s是缓存有效期。Nginx 会按 DNS 记录自身的 TTL 和这个值取较小者作为缓存时长。如果你的域名 TTL 是 600 秒,而valid=10s,实际缓存 10 秒;反过来 TTL 是 5 秒、valid=10s,则缓存 5 秒。所以想快速切换,得两边都调小。ipv6=off强烈建议加上。默认 Nginx 会同时请求 A 和 AAAA 记录,如果解析出 AAAA 但你的服务器没有 IPv6 出口,就会持续尝试连接 IPv6 地址然后超时。这是「配置看着没问题但一直超时」的常见元凶。
写法三:先用变量绕一下(进阶技巧)
有些场景必须用 upstream 块(比如要做负载均衡、要配 keepalive 连接池),但又要域名动态解析。这时候可以利用一个特性:只要 proxy_pass 里含变量,Nginx 就会强制走运行时解析。所以可以用 set 把域名塞进变量:
server {
resolver 127.0.0.53 valid=10s ipv6=off;
location / {
set $upstream_host "api.example.com";
proxy_pass http://$upstream_host:8080;
}
}不过请注意,这种写法失去了 upstream 块的全部能力:不能做负载均衡、不能用 keepalive 连接池、不能做被动健康检查。它是一个「用功能换动态性」的取舍,不是免费的。如果你既要动态解析又要连接池,那就得走 OpenResty 的 balancer_by_lua,那是另一个话题了。
三、`resolver` 到底该填什么
这个参数看着简单,但填错会引出非常难查的问题。分场景说明。
3.1 优先用本机 DNS 缓存服务
resolver 127.0.0.53 valid=10s ipv6=off;127.0.0.53 是 systemd-resolved 的本地存根监听地址,Debian/Ubuntu 上默认就有。这是首选,因为请求会走本机缓存,省一次网络往返,也不会因为外部 DNS 抖动而影响解析。可以用 resolvectl status 确认这个地址确实在监听:
resolvectl status | grep -A3 "Current DNS Server"
ss -lunp | grep 53如果系统没用 systemd-resolved(比如装着 dnsmasq 或 unbound),就填那个服务的监听地址,常见的是 127.0.0.1。
3.2 填公共 DNS 的注意事项
# 可用,但不推荐作为首选
resolver 223.5.5.5 119.29.29.29 valid=10s ipv6=off;
resolver 8.8.8.8 1.1.1.1 valid=10s ipv6=off;能填多个,Nginx 会轮询使用,起到冗余作用。但要注意两点:第一,跨网访问公共 DNS 会引入额外延迟(比如国内服务器用 8.8.8.8 可能被污染或超时);第二,公共 DNS 因为接入量大,返回的 CDN 节点可能离你的服务器很远,导致回源绕路。如果上游是 CDN 域名,用运营商 DNS 或本机 127.0.0.53 往往能得到更近的节点。
3.3 `resolver_timeout` 别忘了配
resolver_timeout 3s;默认是 30 秒,太长了。DNS 服务器无响应时,请求会挂 30 秒才失败,用户体验极差。调成 3 秒是个合理的起点,让失败快速暴露而不是让用户干等。
四、验证:怎么确认 Nginx 真的在用新 IP
改完配置不能靠感觉,得实测。下面是一套完整的验证流程。
4.1 确认 Nginx 解析到了哪个 IP
最直接的办法是在响应头里暴露上游地址:
location / {
proxy_pass http://api.example.com:8080;
add_header X-Upstream $upstream_addr always;
}$upstream_addr 显示的是 Nginx 实际连接的 IP:端口。反复请求,观察它是否随 DNS 变化:
for i in $(seq 1 5); do
curl -sI "http://your-site.com/" | grep -i x-upstream
sleep 12
done如果 valid=10s,每 12 秒请求一次,应该在 DNS 变更后的第一个周期内就看到新 IP。如果一直显示老 IP,说明你写到了 upstream 块里,或者忘了加 resolver 导致走的还是配置期解析。
4.2 用 hosts 文件做安全实验
不要拿线上域名做实验。可以在测试环境用 /etc/hosts 模拟 DNS 变化,但要理解一个关键点:Nginx 的 resolver 不读 /etc/hosts。所以用 hosts 是测不出动态解析的,必须真的改 DNS 或者搭一个本地 DNS 服务器。
更实用的验证方法是搭一个临时的 dnsmasq,把域名指向一个可控地址,然后改它的配置观察 Nginx 是否跟随:
# 安装并配置 dnsmasq 做测试 DNS
apt-get install -y dnsmasq
cat > /etc/dnsmasq.d/test.conf <<'EOF'
address=/test-upstream.local/10.0.0.11
listen-address=127.0.0.1
port=5353
EOF
systemctl restart dnsmasq
# Nginx 里用这个测试 DNS
# resolver 127.0.0.1:5353 valid=5s ipv6=off;
# proxy_pass http://test-upstream.local:8080;验证时把 dnsmasq 配置里的 10.0.0.11 改成 10.0.0.12,systemctl reload dnsmasq,然后观察 Nginx 的 $upstream_addr。注意 dnsmasq 的 address= 写法默认 TTL 很短,正好方便测试。
4.3 从错误日志里找线索
tail -f /var/log/nginx/error.log | grep -i -E "resolve|upstream|no resolver"典型报错和对应原因:
no resolver defined to resolve "xxx"→ 缺少resolver指令,加在server或http块里。api.example.com could not be resolved (110: Operation timed out)→resolver指定的 DNS 不可达,检查地址和防火墙(DNS 一般走 UDP 53,别被本机防火墙挡了)。upstream timed out (110: Connection timed out) while connecting to upstream→ 解析出来的 IP 连不上,重点怀疑解析到了 IPv6 地址而本机没有 IPv6 出口,加ipv6=off试试。
五、这套机制在真实场景里的三个应用
5.1 上游是云服务商的内网域名
很多云服务(对象存储、数据库代理、消息队列)只提供域名接入,IP 会不定期变化。用 upstream 块写死就会在某次 IP 变更后全站故障。正确做法是 proxy_pass 直接带域名 + resolver。
5.2 蓝绿发布 / 灰度切换
把上游指向一个受自己控制的域名,切换发布时只改 DNS 记录,Nginx 完全不用 reload:
resolver 127.0.0.53 valid=5s ipv6=off;
location / {
proxy_pass http://bluegreen.internal:8080;
}valid=5s 让切换在 5 秒内生效。这比改 Nginx 配置 + reload 优雅得多,也避免了 reload 期间可能出现的连接中断。但要注意 valid 是下限不是上限——如果 DNS 记录 TTL 是 300 秒,实际还是 300 秒,务必把 TTL 也一起调小。
5.3 多活 / 就近接入
如果上游域名是 GeoDNS 或智能解析,让 Nginx 每次按需解析(配合较小的 valid)能让它跟随网络状况变化。但也要注意:解析频率太高会给 DNS 服务器压力,valid 不要低于 5 秒,否则可能触发 DNS 限流。
六、几个容易被忽略的坑
- 变量会改变 TTL 语义。 用
set $var包装域名时,Nginx 对 DNS 缓存的处理和直接写域名不完全一致,实测中有时会出现缓存不按valid过期的情况,改完务必实测验证。 proxy_pass带变量时路径拼接规则会变。 不带变量时 Nginx 会做 URI 替换,带变量时则会把请求 URI 原样拼上去。如果写了proxy_pass http://$host/api/;,实际发出的路径可能跟你预期不同,需要用rewrite明确处理。- 重载不一定刷新解析。 对于配置期解析的 upstream,
reload有时复用旧结果。真正想强制刷新,只能restart。这也是为什么应该在架构上就避免依赖 reload 来更新 IP。 - `resolver` 可以写在 `http` 块里。 如果多个
server都要用,写在http块顶层更简洁,子块会继承。 - DNS 故障会导致新连接失败。 用动态解析后,如果 DNS 挂了,Nginx 在缓存过期后无法解析就会直接返回 502。所以
resolver一定要配两个以上地址做冗余,本机缓存服务是更稳的选择。
七、结论与配置模板
把整篇内容浓缩成一个可以直接抄的模板:
http {
# 全局 DNS 解析器:本机缓存优先,公共 DNS 冗余
resolver 127.0.0.53 223.5.5.5 valid=10s ipv6=off;
resolver_timeout 3s;
server {
listen 80;
server_name www.example.com;
location / {
# 关键:proxy_pass 直接写域名,不要用 upstream 块
proxy_pass http://api-upstream.example.com:8080;
# 调试用,生产确认无误后可移除
add_header X-Upstream $upstream_addr always;
proxy_connect_timeout 3s;
proxy_read_timeout 30s;
proxy_set_header Host api-upstream.example.com;
proxy_set_header X-Real-IP $remote_addr;
}
}
}判断该用哪种写法的决策逻辑很简单:
- 上游 IP 固定不变 → 随便写,
upstream块里写 IP 最省事,还能用连接池和负载均衡。 - 上游 IP 可能变化 →
proxy_pass直接带域名 +resolver valid=Xs ipv6=off,放弃 upstream 块的功能。 - 既要动态解析又要负载均衡 → 上 OpenResty 用
balancer_by_lua_block,或者用 Nginx Plus 的resolve参数(server api.example.com resolve;,注意这也是 Plus 专属)。
最后强调一句:这个坑的可怕之处在于它是静默的。配置本身没有语法错误,nginx -t 通过,服务正常启动,只有在 DNS 变更的那一刻才突然爆发,而且因为「服务器上 curl 域名是通的」,很容易把人引向错误的方向去排查。如果你现在的配置里 upstream 块写的是域名,建议今天就去检查一下上游 IP 是不是已经和 DNS 不一致了——那可能是一个正在等待引爆的定时炸弹。