引言 — dig 能通,唯独应用找不到名字
故障排查中最容易让人犯迷糊的组合就是这个。
dig +short api.internal.example.com
10.20.4.31
DNS 看起来是正常的。可是在同一台主机上,应用却这样挂掉。
java.net.UnknownHostException: api.internal.example.com
到这一步,大多数人会得出"DNS 服务器好好的,是应用有问题"的结论。然后开始翻应用代码和依赖库。这个方向几乎总是白费力气。
原因很简单。dig 和应用本来就走不同的路径解析名字。dig 成功这件事,并不能成为应用会成功的依据。反方向也一样,dig 失败而应用照样跑得好好的情况也存在。
本文按顺序追踪应用真正走过的解析路径,并整理每个阶段可能出岔子的地方。
应用解析名字的真实路径
C、Python、Ruby、PHP、Node 的默认解析器,乃至 Linux 上的 Java,大多最终都会调用 glibc 的 getaddrinfo()。glibc 会走下面这个顺序。
- 读取
/etc/nsswitch.conf的 hosts 行,决定用哪些来源、按什么顺序用。 - 若是 files 模块,就查
/etc/hosts。 - 若是 dns 模块,就读
/etc/resolv.conf,套用 nameserver、search、options 并发出查询。 - 若是 resolve 模块,就通过 D-Bus 去问 systemd-resolved。
- myhostname、mdns4_minimal 这类特殊模块可能夹在中间。
从第一步开始确认。
grep '^hosts' /etc/nsswitch.conf
hosts: files mdns4_minimal [NOTFOUND=return] dns myhostname
这一行里藏着两个坑。
mdns4_minimal [NOTFOUND=return] 的意思是:名字以 .local 结尾时只用 mDNS 解析,失败就地停止。根本走不到后面的 dns 模块。把内部域名取成 cluster.local 或 svc.local 这类名字的组织经常掉进这个坑。dig 不看 nsswitch,所以会返回正常应答,只有应用失败。
另一个是 resolve 模块。
hosts: files resolve [!UNAVAIL=return] dns myhostname
这样配置的话,应用会通过 D-Bus 去问 systemd-resolved,并不直接使用 resolv.conf 里的 nameserver 值。也就是说,改了 /etc/resolv.conf 也可能不会改变应用的行为。
resolv.conf 也确认一下。
cat /etc/resolv.conf
nameserver 127.0.0.53
options edns0 trust-ad
search ap-northeast-2.compute.internal
dig 和 nslookup 不看 /etc/hosts
这是本文最实用的一个事实。dig、nslookup、host 都是 DNS 协议专用工具。它们不看 nsswitch.conf,不看 /etc/hosts,也不看 systemd-resolved 的按链路配置。它们只从 resolv.conf 里取出 nameserver 地址,然后往 UDP 53 扔查询。
因此下面这种情况完全没有矛盾地成立。
# 只存在于 /etc/hosts 的名字 — dig 找不到
dig +short legacy-billing.internal
(空输出)
# 与应用相同的路径 — 能正常找到
getent hosts legacy-billing.internal
10.20.9.14 legacy-billing.internal
反方向也有。如果某人调试时临时写进 /etc/hosts 的旧 IP 还留在那里,dig 返回的是新 IP,而应用会一直连旧 IP。这种情况的症状不是 DNS 错误,而是 connection refused 或超时,于是人就不会往 DNS 上找原因了。
所以,诊断的基本工具必须换掉。
# 原样复现与应用相同的路径
getent hosts api.internal.example.com
# 同时给出 IPv4 和 IPv6,并展示尝试顺序
getent ahosts api.internal.example.com
10.20.4.31 STREAM api.internal.example.com
10.20.4.31 DGRAM
10.20.4.31 RAW
getent ahosts 的输出顺序,就是应用实际尝试的顺序。如果 AAAA 排在前面而又没有 IPv6 路径,就意味着第一次连接尝试会失败,这也正是前面说的"Network is unreachable"的成因。
概括起来这样用:dig 是看 DNS 服务器答案的工具,getent 是看应用将会收到什么答案的工具。两者结果不一致时,这个差异本身就告诉了你原因所在的位置。
systemd-resolved 与 127.0.0.53 造成的混乱
在 Ubuntu 等较新的发行版上,resolv.conf 里的 nameserver 通常是 127.0.0.53。这是 systemd-resolved 起的存根解析器,真正的上游服务器另有其人。
resolvectl status
Global
Protocols: -LLMNR -mDNS +DNSOverTLS DNSSEC=no/unsupported
resolv.conf mode: stub
Link 2 (ens5)
Current Scopes: DNS
Protocols: +DefaultRoute +DNSSEC=no/unsupported
Current DNS Server: 10.0.0.2
DNS Servers: 10.0.0.2
DNS Domain: ap-northeast-2.compute.internal
Link 5 (tun0)
Current Scopes: DNS
Protocols: +DNSOverTLS
Current DNS Server: 10.60.0.53
DNS Servers: 10.60.0.53
DNS Domain: ~corp.example.com ~internal.example.com
这里有两点很关键。
第一,每条链路各有自己的 DNS 服务器和域名。上面的例子里,以 corp.example.com 结尾的名字会走 VPN 接口的 10.60.0.53,其余的走 10.0.0.2。这叫分离 DNS。但如果像 dig @10.0.0.2 vault.corp.example.com 这样直接指定上游服务器,就绕过了这套路由,于是失败。开着 VPN 时"dig 失败但浏览器能打开"的情况就是这么来的。
第二,dig 的默认目标是 127.0.0.53,所以你看到的是存根的缓存。即使上游服务器已经改好了,也可能因为存根缓存而一直返回旧答案。
把三个层次分开确认才准确。
# 1) 原样走应用路径
resolvectl query vault.corp.example.com
# 2) 直接问存根解析器
dig @127.0.0.53 vault.corp.example.com
# 3) 直接问上游服务器
dig @10.60.0.53 vault.corp.example.com
vault.corp.example.com: 10.60.4.7 -- link: tun0
-- Information acquired via protocol DNS in 2.1ms.
-- Data is authenticated: no; Data was acquired via local or encrypted transport: yes
resolvectl query 连链路名都告诉你,这一点很有用。查询走了哪个接口的 DNS 会直接显示出来。
怀疑缓存时,就按层清空。
resolvectl flush-caches
resolvectl statistics
DNSSEC verdicts
Secure: 0
Insecure: 0
Bogus: 0
Indeterminate: 0
Transactions
Current Transactions: 0
Total Transactions: 184213
Cache
Current Cache Size: 0
Cache Hits: 156402
Cache Misses: 27811
缓存层次并不止于此。应用内部还有单独的缓存。Java 是典型代表。在没有安装安全管理器的默认环境里,JVM 会把解析成功的结果缓存 30 秒,把解析失败的结果按 networkaddress.cache.negative.ttl 的默认值缓存 10 秒。在使用安全管理器的老式部署里,成功的结果会被永久缓存。如果故障切换后 IP 变了,却只有某个服务一直抱着旧 IP 不放,就该先看这个值。
# 启动 Java 时显式指定
java -Dsun.net.inetaddr.ttl=30 -Dsun.net.inetaddr.negative.ttl=0 -jar app.jar
# 或者在 java.security 文件里
# networkaddress.cache.ttl=30
# networkaddress.cache.negative.ttl=0
概括起来缓存共有四层:应用内部、存根解析器、递归解析器,以及权威服务器给出的 TTL。如果不弄清自己清的是哪一层,"缓存都清了还是老样子"这样的报告就会反复出现。
search 域与 ndots — Kubernetes 变慢的真正原因
Kubernetes Pod 内的 resolv.conf 长这样。
kubectl exec -it deploy/api -- cat /etc/resolv.conf
search prod.svc.cluster.local svc.cluster.local cluster.local ap-northeast-2.compute.internal
nameserver 10.96.0.10
options ndots:5
ndots:5 的意思是"要查询的名字里点数少于 5 个时,先把 search 列表拼上去试,再按绝对名字去试"。正因为有这个设置,Pod 里才能用 api 或 api.prod 这样的短名字调用服务。
问题出在外部域名上。api.stripe.com 只有 2 个点,少于 5 个。于是查询会按下面的顺序发出去。
kubectl exec -it deploy/api -- sh -c 'tcpdump -nn -i any port 53' &
kubectl exec -it deploy/api -- curl -sS -o /dev/null https://api.stripe.com/v1/charges
10:11:02.512331 IP 10.244.1.9.41822 > 10.96.0.10.53: 41955+ A? api.stripe.com.prod.svc.cluster.local. (55)
10:11:02.512402 IP 10.244.1.9.41822 > 10.96.0.10.53: 8271+ AAAA? api.stripe.com.prod.svc.cluster.local. (55)
10:11:02.513880 IP 10.96.0.10.53 > 10.244.1.9.41822: 41955 NXDomain 0/1/0 (148)
10:11:02.513991 IP 10.96.0.10.53 > 10.244.1.9.41822: 8271 NXDomain 0/1/0 (148)
10:11:02.514552 IP 10.244.1.9.55110 > 10.96.0.10.53: 12043+ A? api.stripe.com.svc.cluster.local. (50)
10:11:02.514601 IP 10.244.1.9.55110 > 10.96.0.10.53: 39218+ AAAA? api.stripe.com.svc.cluster.local. (50)
10:11:02.516001 IP 10.96.0.10.53 > 10.244.1.9.55110: 12043 NXDomain 0/1/0 (143)
10:11:02.516120 IP 10.96.0.10.53 > 10.244.1.9.55110: 39218 NXDomain 0/1/0 (143)
10:11:02.516644 IP 10.244.1.9.39004 > 10.96.0.10.53: 55129+ A? api.stripe.com.cluster.local. (46)
10:11:02.516702 IP 10.244.1.9.39004 > 10.96.0.10.53: 21008+ AAAA? api.stripe.com.cluster.local. (46)
10:11:02.518233 IP 10.96.0.10.53 > 10.244.1.9.39004: 55129 NXDomain 0/1/0 (139)
10:11:02.518344 IP 10.96.0.10.53 > 10.244.1.9.39004: 21008 NXDomain 0/1/0 (139)
10:11:02.518881 IP 10.244.1.9.60122 > 10.96.0.10.53: 3311+ A? api.stripe.com.ap-northeast-2.compute.internal. (64)
10:11:02.518944 IP 10.244.1.9.60122 > 10.96.0.10.53: 61190+ AAAA? api.stripe.com.ap-northeast-2.compute.internal. (64)
10:11:02.523115 IP 10.96.0.10.53 > 10.244.1.9.60122: 3311 NXDomain 0/1/0 (157)
10:11:02.523240 IP 10.96.0.10.53 > 10.244.1.9.60122: 61190 NXDomain 0/1/0 (157)
10:11:02.523774 IP 10.244.1.9.44911 > 10.96.0.10.53: 9042+ A? api.stripe.com. (32)
10:11:02.523821 IP 10.244.1.9.44911 > 10.96.0.10.53: 55403+ AAAA? api.stripe.com. (32)
10:11:02.531002 IP 10.96.0.10.53 > 10.244.1.9.44911: 9042 3/0/0 A 34.111.87.9 ... (98)
为了解析一个外部域名发出了 10 个查询,其中 8 个纯粹是去领 NXDOMAIN 的。当不是一个 Pod 而是数百个 Pod 每秒做几十次这种事时,CoreDNS 的负载大部分都被注定失败的查询填满了。而且只要 UDP 有一点丢包,每个丢失的查询都会附带 5 秒的重试超时,延迟会明显抖起来。
这里必须纠正一个流传甚广的处方。"把 ndots 降到 1 就行了"这条建议会把集群搞坏。ndots 为 1 时,点数大于等于 1 的名字都会跳过 search 直接按绝对名字查询。这样一来,像 payments.prod 这种带命名空间的跨命名空间调用就全都坏掉了,因为只有 1 个点,search 不再适用。
安全的下限是 2。ndots 为 2 时,api 和 payments.prod 依然会走 search,而点数大于等于 2 的外部域名则会立刻按绝对名字查询。
spec:
dnsConfig:
options:
- name: ndots
value: '2'
如果能改应用,还有更确定的办法。给外部域名末尾加一个点使其成为绝对名字,就能整个跳过 search 列表。
# 末尾的这一个点消掉了 4 个 search 域
curl -sS -o /dev/null -w '%{time_namelookup}\n' https://api.stripe.com./v1/charges
使用 NodeLocal DNSCache 也是不错的缓解手段。失败的查询在节点本地缓存就结束了,不会一直打到 CoreDNS,UDP 丢包导致的 5 秒重试也会大幅减少。
NXDOMAIN、SERVFAIL 与空的 NOERROR
把响应码笼统地报成"DNS 不通",原因范围就收敛不了。必须确认 dig 输出里的 status 字段。
dig api.internal.example.com A
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 51204
;; flags: qr rd ra; QUERY: 1, ANSWER: 0, AUTHORITY: 1, ADDITIONAL: 1
ANSWER 是 0,而 status 却是 NOERROR。这个组合是最常被误诊的。
| 响应码 | 在 dig 里的样子 | 实际含义 | 常见原因 | 下一步做什么 |
|---|---|---|---|---|
| NOERROR 且 ANSWER 至少 1 条 | 正常打印记录 | 名字和记录类型都存在 | 正常 | 前往下一层 |
| NOERROR 且 ANSWER 为 0 | 只有 AUTHORITY,没有答案 | 名字存在但没有该类型的记录 | 有 A 没有 AAAA,只有 CNAME 而目标未创建 | 换查询类型重新确认 |
| NXDOMAIN | status: NXDOMAIN | 权威服务器确认该名字本身不存在 | 打错字、记录未创建、拼上了 search 域的名字 | 直接向权威服务器查询以确认 |
| SERVFAIL | status: SERVFAIL | 解析器没能构造出答案 | DNSSEC 校验失败、上游服务器无应答、区域加载失败 | 查解析器日志并检查 DNSSEC |
| REFUSED | status: REFUSED | 服务器不打算处理这个查询 | 不允许递归、ACL 拦截、并不持有该区域 | 确认查询的服务器是否正确 |
出现空 NOERROR 的典型场景是 IPv6。当只缺 AAAA 记录而应用又先查 AAAA 时,日志里可能留下"找不到名字"。要明确指定查询类型来确认。
dig +noall +answer api.internal.example.com A AAAA CNAME
api.internal.example.com. 300 IN CNAME api-lb.internal.example.com.
api-lb.internal.example.com. 60 IN A 10.20.4.31
SERVFAIL 是解析器自身的问题。有一个办法可以快速分辨是不是 DNSSEC 校验失败导致的。
# 关掉校验再问一次。这时如果成功,就是 DNSSEC 的问题
dig +cd api.internal.example.com
# 沿委派路径一路追到权威服务器
dig +trace api.internal.example.com
看到 NXDOMAIN 时,要把查询的名字原样读一遍。如果 tcpdump 里是针对 api.stripe.com.prod.svc.cluster.local 这种拼了 search 域的名字返回的 NXDOMAIN,那属于正常行为,并不是问题的原因。
512 字节、TCP 回退,以及只有大响应失败的情况
DNS 本来无法通过 UDP 发送超过 512 字节的响应。一旦超过,服务器就会置上 TC(truncated)标志,客户端再用 TCP 53 重发同一个查询。EDNS0 就是让客户端告知自己能接收更大 UDP 缓冲区、从而减少这次往返的扩展。
# 关掉 EDNS0 并限制到 512 字节试试
dig +notcp +bufsize=512 TXT _dmarc-report.example.com
;; Truncated, retrying in TCP mode.
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 40912
;; flags: qr tc rd ra; QUERY: 1, ANSWER: 8, AUTHORITY: 0, ADDITIONAL: 0
标志里出现 tc 就是被截断的证据。
实务事故就是在这里诞生的。老旧的防火墙配置常常只开 UDP 53 而把 TCP 53 堵住。平时完全没问题,因为响应都很小。可是一旦记录变多,或者加上 DNSSEC 签名让响应超过 512 字节,那一个名字就解析不了了。"只有某一个域名不通"的报障,原因有时正在于此。
# 直接确认 TCP 53 是否开放
dig +tcp @10.0.0.2 example.com
# 确认 EDNS0 路径是否被中间盒挡住
dig +bufsize=4096 @10.0.0.2 dnssec-failed.org
# 确认响应大小本身
dig +noall +stats example.com
;; Query time: 12 msec
;; SERVER: 10.0.0.2#53(10.0.0.2) (UDP)
;; MSG SIZE rcvd: 512
如果 MSG SIZE rcvd 恰好紧贴在 512 或 1232 附近,而且只有那一个名字间歇性失败,那么怀疑路径上有大小限制就有充分依据了。顺带一提,如今的解析器为了避免 IP 分片,会把 EDNS 缓冲区的推荐值降到 1232 字节来用。
结语 — 名字解析并不止于 dig
要记住的只有一句话。dig 展示的是 DNS 服务器的答案,getent 展示的是应用将会收到的答案。如果两个结果不同,这个差异就是原因所在的位置。
实战中按下面的顺序收敛,大多数情况几分钟就能结束。先把 getent hosts 和 dig +short 并排跑一遍,看结果会不会分叉。如果分叉,就去查 nsswitch.conf 的 hosts 行、/etc/hosts,以及 systemd-resolved 的按链路配置。如果结果一致,问题就在解析器之上,于是用 dig 的 status 字段把 NXDOMAIN、SERVFAIL、空 NOERROR 分开,必要时用 +trace 沿委派路径追下去。如果答案对但耗时长,就用 tcpdump 数一数实际发出去的查询有多少个。search 域和 ndots 制造出来的多余查询会原原本本地暴露在那里。
相当一部分 DNS 故障并非出在服务器,而是出在客户端一侧的解析顺序上。先确认顺序,就不必去翻服务器了。
현재 단락 (1/164)
故障排查中最容易让人犯迷糊的组合就是这个。