一次 DNS 解析超时引发的线上告警

周五下午四点半,离下班还有半小时,告警群突然炸了。某个核心服务的 P99 延迟从平时的 50ms 飙到了 2s,上游调用方开始批量超时。

第一反应是看最近有没有发版——没有。看数据库慢查询——正常。看 CPU 和内存——纹丝不动。Redis 延迟——也没问题。

折腾了二十分钟,最后发现问题出在一个完全没想到的地方:DNS 解析。

Go 的两套 DNS 解析器

在聊问题之前,先说一个很多 Go 开发可能不太注意的事:Go 有两套 DNS 解析器。

第一套是纯 Go 实现的,直接读 /etc/resolv.conf,自己构造 DNS 查询包,往 nameserver 发 UDP 请求。这是默认使用的解析器。

第二套是通过 cgo 调用系统的 getaddrinfo 函数,走操作系统的 DNS 解析流程。

可以通过环境变量 GODEBUG=netdns=goGODEBUG=netdns=cgo 来强制指定用哪个。不设的话,Go 会根据一些条件自动选择,大部分情况下会用纯 Go 的那个。

这两套有什么区别呢?最关键的一点:纯 Go 解析器不会读 /etc/nsswitch.conf,也不支持一些系统级的 DNS 扩展(比如 mDNS)。但好处是不依赖 cgo,交叉编译友好,而且是非阻塞的,不会占用系统线程。

平时这个差异感知不强,但在特定环境下,它会成为一个隐藏的坑。

罪魁祸首:search domain 和 ndots

我们的服务跑在 K8s 里。先来看一下容器里的 /etc/resolv.conf 长什么样:

nameserver 10.96.0.10
search default.svc.cluster.local svc.cluster.local cluster.local
options ndots:5

重点看两个东西:searchndots

search 是搜索域列表。当你解析一个"不完整"的域名时,系统会把这些后缀依次拼上去尝试解析。

ndots 是一个阈值,表示域名里点的个数。如果域名里的点少于 ndots 指定的值,就会被认为是"不完整"的,会先走 search 域拼接;只有拼接全部失败后,才会用原始域名去查。

K8s 默认把 ndots 设成了 5。

这意味着什么呢?假设我们的服务要请求 api.example.com,这个域名里有 2 个点,少于 5,所以它会被当成"不完整"的域名。实际的 DNS 查询过程变成了这样:

1. api.example.com.default.svc.cluster.local  → 查询失败
2. api.example.com.svc.cluster.local          → 查询失败
3. api.example.com.cluster.local              → 查询失败
4. api.example.com                            → 查询成功

一次域名解析,变成了 4 次 DNS 查询。而且前 3 次注定失败,纯粹是在浪费时间。

如果每次查询耗时 10ms,光 DNS 这一环就多了 30ms。平时这个开销不明显,但如果 DNS 服务器本身响应变慢了呢?

那天到底发生了什么

回到那个周五下午。后来查到的原因是:集群的 CoreDNS 那段时间在处理一波大量的 DNS 请求(另一个服务在做批量外部调用),导致 CoreDNS 的响应延迟从平时的几毫秒涨到了几百毫秒。

对于一个走 search domain 的外部域名请求,原本 4 次查询可能只需要 40ms,但 CoreDNS 变慢之后,每次查询要 500ms,4 次查询就是 2s。直接把接口的 P99 干上去了。

而且 Go 的纯 Go DNS 解析器,默认的超时策略是这样的:先发一个 UDP 请求,等 5 秒没回应就重试,总共重试次数取决于 resolv.conf 里的 attempts 配置(默认是 2)。如果再加上 search domain 的多次尝试,最坏情况下一次域名解析可以卡上几十秒。

tcpdump 在容器里抓包验证一下:

tcpdump -i eth0 port 53 -nn

输出如下:

16:31:02.001 IP 10.244.1.5.43210 > 10.96.0.10.53: A? api.example.com.default.svc.cluster.local.
16:31:02.503 IP 10.96.0.10.53 > 10.244.1.5.43210: NXDomain
16:31:02.504 IP 10.244.1.5.43210 > 10.96.0.10.53: A? api.example.com.svc.cluster.local.
16:31:03.008 IP 10.96.0.10.53 > 10.244.1.5.43210: NXDomain
16:31:03.009 IP 10.244.1.5.43210 > 10.96.0.10.53: A? api.example.com.cluster.local.
16:31:03.511 IP 10.96.0.10.53 > 10.244.1.5.43210: NXDomain
16:31:03.512 IP 10.244.1.5.43210 > 10.96.0.10.53: A? api.example.com.
16:31:03.520 IP 10.96.0.10.53 > 10.244.1.5.43210: A 93.184.216.34

清清楚楚,4 次查询,前 3 次全是 NXDomain。每次间隔 500ms 左右,加起来 1.5 秒。这还是 CoreDNS 没有特别慢的情况。

怎么解

知道原因之后,解法就比较清晰了。几个方向:

方案一:域名末尾加点

DNS 里有个概念叫 FQDN(Fully Qualified Domain Name),就是以点结尾的域名。一个以点结尾的域名会被认为是"完整"的,不会再走 search domain 拼接。

把代码里请求的外部域名从 api.example.com 改成 api.example.com.,多加一个点就行了:

// 之前
resp, err := http.Get("https://api.example.com/v1/data")

// 之后
resp, err := http.Get("https://api.example.com./v1/data")

这是最简单直接的改法。但有个问题:这个点容易被忽略,也不太符合日常习惯,代码 review 的时候可能被人当成 typo 删掉。而且如果域名是从配置中心读出来的,你得确保配置那边也带上这个点。

方案二:调低 ndots

在 K8s 的 Pod spec 里可以自定义 DNS 配置:

spec:
  dnsConfig:
    options:
      - name: ndots
        value: "2"

ndots 从 5 降到 2,这样 api.example.com 有 2 个点,不少于 ndots,就会直接用原始域名查询,不再走 search domain。

但这个改法有个副作用:集群内部的短域名解析会受影响。比如你用 my-service.default 这样的短域名访问集群内服务,它只有 1 个点,还是会走 search domain,没问题。但如果你用 my-service.default.svc 这种 3 段式的(2 个点),就不会走 search domain 了,会直接查 my-service.default.svc,而这个域名在公网上当然查不到。

所以降 ndots 的时候,得先盘一下自己的服务里是怎么调用集群内其他服务的。如果都是用短域名(1 个点以内),降到 2 就够了。

方案三:本地 DNS 缓存

在 Pod 里跑一个轻量的 DNS 缓存,比如 dnsmasq 或者用 K8s 的 NodeLocal DNSCache。第一次查询该慢还是慢,但后续的相同域名查询直接走本地缓存,毫秒级返回。

NodeLocal DNSCache 是 K8s 官方推荐的方案,它在每个节点上跑一个 DNS 缓存 Pod,所有 DNS 请求先到本地缓存,命中就直接返回,不命中再去 CoreDNS。

这个方案的好处是不用改代码,不用改 ndots,对业务完全透明。缺点是需要在集群层面做部署和维护。

我们最后怎么做的

三个方案不是互斥的,我们最后的做法是:

  • 短期:外部域名加点,快速止血
  • 中期:集群统一部署 NodeLocal DNSCache
  • 同时把 ndots 降到了 2,因为我们内部服务调用都是走的 K8s Service 短域名

上线之后,DNS 相关的延迟基本消失了。

额外聊两句

这次踩坑之后我去翻了一下 Go 标准库里 DNS 解析的代码,在 net/dnsclient_unix.go 里,有一段逻辑专门处理 search domain 的拼接:

func (conf *dnsConfig) nameList(name string) []string {
    // 如果域名以点结尾,直接返回
    if avoidDNS(name) {
        return nil
    }
    rooted := len(name) > 0 && name[len(name)-1] == '.'
    if rooted {
        return []string{name}
    }

    hasNdots := count(name, '.') >= conf.ndots
    // ...
    if hasNdots {
        // 点数够了,先查原始域名,再查 search domain
        names = append(names, name+".")
    }
    for _, suffix := range conf.search {
        names = append(names, name+"."+suffix+".")
    }
    if !hasNdots {
        // 点数不够,最后才查原始域名
        names = append(names, name+".")
    }
    return names
}

注意看,当点数够了(hasNdots 为 true)的时候,原始域名会被放在列表最前面,search domain 的拼接放后面。这意味着即使触发了 search domain 逻辑,也是先查原始域名,成功就直接返回,不会浪费时间。

但当点数不够的时候,原始域名被放到了最后面。这就是为什么外部域名请求会变慢——它得把 search domain 列表全试一遍,全失败了,才轮到那个正确的原始域名。

顺便说一句,这个行为不是 Go 自己发明的,是遵循 resolv.conf 的标准语义。Linux 的 man resolv.conf 里写得很清楚。只是平时不跑在 K8s 里的话,ndots 默认是 1,外部域名一般都有 1 个以上的点,根本不会触发这个逻辑,所以感知不到。

这大概就是很多"配置型的坑"的共性:平时好好的,换个环境就炸了,而且炸的方式和你写的代码完全无关,排查的时候很容易跑偏。

guest
0 评论
最新
最旧
内联反馈
查看所有评论
0
希望看到您的想法,请发表评论。x