Nginx 反代域名上游不跟随 DNS 变化排查实战:resolver 与 valid 的运行时解析机制

一、一个让人抓狂的场景:后端 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 不一致了——那可能是一个正在等待引爆的定时炸弹。

Last modification:September 25th, 2026 at 12:25 pm

Leave a Comment