遇到了域名解析超时的问题,顺便梳理下 GO 的 DNS 解析流程。出错的域名是这个 p.qpic.cn, 当用 sudo tcpdump port 53 -nn 分析解析过程发现,ipv4 解析过程非常快,ipv6 很慢,过了一段时间才有响应。也就是说 ipv6 解析慢,导致 go http client dns 解析超时。
可以用 dig 确认下
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 | dig p.qpic.cn ; <<>> DiG 9.9.4-RedHat-9.9.4-51.el7 <<>> p.qpic.cn ;; global options: +cmd ;; Got answer: ;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 1729 ;; flags: qr rd ra; QUERY: 1, ANSWER: 16, AUTHORITY: 0, ADDITIONAL: 0 ;; QUESTION SECTION: ;p.qpic.cn.         IN  A ;; ANSWER SECTION: p.qpic.cn.      5   IN  A   14.17.41.196 p.qpic.cn.      5   IN  A   183.61.38.193 p.qpic.cn.      5   IN  A   113.96.208.23 p.qpic.cn.      5   IN  A   14.215.140.25 p.qpic.cn.      5   IN  A   113.96.208.22 p.qpic.cn.      5   IN  A   183.3.233.232 p.qpic.cn.      5   IN  A   113.96.232.48 p.qpic.cn.      5   IN  A   113.96.232.105 p.qpic.cn.      5   IN  A   14.215.140.21 p.qpic.cn.      5   IN  A   183.36.108.188 p.qpic.cn.      5   IN  A   14.215.140.24 p.qpic.cn.      5   IN  A   14.215.140.53 p.qpic.cn.      5   IN  A   113.96.232.100 p.qpic.cn.      5   IN  A   113.96.208.21 p.qpic.cn.      5   IN  A   183.61.38.202 p.qpic.cn.      5   IN  A   183.36.108.166 ;; Query time: 14 msec ;; SERVER: 172.16.185.2#53(172.16.185.2) ;; WHEN: 四 2月 14 10:53:01 CST 2019 ;; MSG SIZE  rcvd: 283 | 
发现解析的非常快,发现返回的都是 A 记录,也就是 ipv4 的结果,显示的指定查询 ipv6 结果,可以这样
| 1 2 3 4 5 | dig AAAA p.qpic.cn ; <<>> DiG 9.9.4-RedHat-9.9.4-51.el7 <<>> AAAA p.qpic.cn ;; global options: +cmd ;; connection timed out; no servers could be reached | 
发现,超时解析失败了。
go 进行 dns 查询有两种实现方式, cgo 和 纯 go 的实现。推荐纯 go 的实现。 cgo 在查询时,会占用系统的线程,而 纯 go 的 是使用 goroutine 进行查询的。
下面一步步分析 dns 解析流程。 测试代码如下:
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | # cat net.go package main import (     "fmt"     "net"     "os" ) func main() {     ns, err := net.LookupHost("p.qpic.cn")     if err != nil {         fmt.Fprintf(os.Stderr, "Err: %s", err.Error())         return     }     for _, n := range ns {         fmt.Fprintf(os.Stdout, "--%s\n", n)     } } | 
使用 go build net.go 会生成 net 执行文件。使用 sudo strace -T ./net 跟踪执行流程。
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | connect(3, {sa_family=AF_INET, sin_port=htons(53), sin_addr=inet_addr("172.16.185.2")}, 16) = 0 <0.000009> epoll_ctl(4, EPOLL_CTL_ADD, 3, {EPOLLIN|EPOLLOUT|EPOLLRDHUP|EPOLLET, {u32=3685703344, u64=140225777983152}}) = 0 <0.000005> getsockname(3, {sa_family=AF_INET, sin_port=htons(58518), sin_addr=inet_addr("172.16.185.133")}, [16]) = 0 <0.000012> getpeername(3, {sa_family=AF_INET, sin_port=htons(53), sin_addr=inet_addr("172.16.185.2")}, [16]) = 0 <0.000004> write(3, "7\352\1\0\0\1\0\0\0\0\0\0\1p\4qpic\2cn\0\0\34\0\1", 27) = 27 <0.000131> read(3, 0xc4200ac000, 512)              = -1 EAGAIN (Resource temporarily unavailable) <0.000006> futex(0x5bf520, FUTEX_WAIT, 0, {4, 999552904}) = -1 ETIMEDOUT (Connection timed out) <4.999927> futex(0x5bf7d8, FUTEX_WAKE, 1)          = 1 <0.000025> futex(0xc42002cb90, FUTEX_WAKE, 1)      = 1 <0.000028> epoll_ctl(4, EPOLL_CTL_DEL, 3, 0xc42003bb14) = 0 <0.000023> close(3)                                = 0 <0.000029> futex(0xc42002cb90, FUTEX_WAKE, 1)      = 1 <0.000024> socket(AF_INET, SOCK_DGRAM|SOCK_CLOEXEC|SOCK_NONBLOCK, IPPROTO_IP) = 3 <0.000224> setsockopt(3, SOL_SOCKET, SO_BROADCAST, [1], 4) = 0 <0.000029> connect(3, {sa_family=AF_INET, sin_port=htons(53), sin_addr=inet_addr("172.16.185.2")}, 16) = 0 <0.000017> epoll_ctl(4, EPOLL_CTL_ADD, 3, {EPOLLIN|EPOLLOUT|EPOLLRDHUP|EPOLLET, {u32=3685703344, u64=140225777983152}}) = 0 <0.000013> getsockname(3, {sa_family=AF_INET, sin_port=htons(59338), sin_addr=inet_addr("172.16.185.133")}, [16]) = 0 <0.000008> getpeername(3, {sa_family=AF_INET, sin_port=htons(53), sin_addr=inet_addr("172.16.185.2")}, [16]) = 0 <0.000008> futex(0x5bf520, FUTEX_WAKE, 1)          = 1 <0.000020> write(3, ">\360\1\0\0\1\0\0\0\0\0\0\1p\4qpic\2cn\0\0\34\0\1", 27) = 27 <0.001253> read(3, 0xc4200ac200, 512)              = -1 EAGAIN (Resource temporarily unavailable) <0.000155> | 
会发现有两次 connect 操作, 一次是 4.99 秒后超时了,一次成功返回了。从 go 源码分析下。
在 net/lookup.go 中,看到 LookupHost 调用的是 DefaultResolver 的 LookupHost。DefaultResolver 是 Resolver 默认实现实例,在相同的文件里可以找到 func (r *Resolver) LookupHost(ctx context.Context, host string) (addrs []string, err error) 定义。
这个函数首先对 host 进行分析,如果 host 直接 是 IP, 不管是 ipv4 或者 ipv6 形式,就直接返回了。否则继续查询。
后面具体的实现是和系统相关的。在 net/lookup_unix.go 中找到 func (r *Resolver) lookupHost(ctx context.Context, host string) (addrs []string, err error) 。我们是找纯 go 的实现,直接找 goLookupHostOrder 定义。在 net/dnsclient_unix.go 可以找到。
goLookupHostOrder 首先是查找 /etc/hosts 里有没有域名的配置,如果有则直接返回,否则继续查找。
继续在本文件找到 goLookupIPCNAMEOrder 实现。
在这里加载了 /etc/resolv.conf 内容,在这个配置里,包含 dns server 地址及查询超时的具体控制。具体内容参考 resolv.conf 。这个配置会加载到 dnsConfig 中。加载的文件在 net/dnsconfig_unix.go 中,参考函数 dnsReadConfig。
在这里有两个参数比较重要, 一个 timeout 查询 dns server 的超时时间,attempts 指的是重试次数。timeout 默认是 5s, strace 跟踪的超时时间得到验证。这两个选项可以在 /etc/resolv.conf 自定义。
| 1 2 3 4 5 6 | cat /etc/resolv.conf ; generated by /usr/sbin/dhclient-script search localdomain nameserver 172.16.185.2 options timeout:2 options attempts:1 | 
我们设置了超时时间为 2s, 重试次数为 1 次。再次使用 strace 进行跟踪的话,可以看到超时时间变成了 2s 左右。
继续来看 goLookupIPCNAMEOrder 的实现。
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 | qtypes := [...]uint16{dnsTypeA, dnsTypeAAAA}     var lastErr error     for _, fqdn := range conf.nameList(name) {         for _, qtype := range qtypes {             go func(qtype uint16) {                 cname, rrs, err := r.tryOneName(ctx, conf, fqdn, qtype)                 lane <- racer{cname, rrs, err}             }(qtype)         }         hitStrictError := false         for range qtypes {             racer := <-lane             if racer.error != nil {                 if nerr, ok := racer.error.(Error); ok && nerr.Temporary() && r.StrictErrors {                     // This error will abort the nameList loop.                     hitStrictError = true                     lastErr = racer.error                 } else if lastErr == nil || fqdn == name+"." {                     // Prefer error for original name.                     lastErr = racer.error                 }                 continue             }             addrs = append(addrs, addrRecordList(racer.rrs)...)             if cname == "" {                 cname = racer.cname             }         }        if hitStrictError {             // If either family hit an error with StrictErrors enabled,             // discard all addresses. This ensures that network flakiness             // cannot turn a dualstack hostname IPv4/IPv6-only.             addrs = nil             break         }         if len(addrs) > 0 {             break         }     } | 
这里启动了两个 goroutine,分别取查询 ipv4 和 ipv6。然后通过 channel 进行结果的汇总。那也就解释了 就算 ipv4 查询很快,ipv6 查询很慢的话,照样会卡主应用,造成 dns 查询超时。
tryOneName 函数就是 dns 查询的具体逻辑。
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 | func (r *Resolver) tryOneName(ctx context.Context, cfg *dnsConfig, name string, qtype uint16) (string, []dnsRR, error) {     var lastErr error     serverOffset := cfg.serverOffset()     sLen := uint32(len(cfg.servers))     for i := 0; i < cfg.attempts; i++ {         for j := uint32(0); j < sLen; j++ {             server := cfg.servers[(serverOffset+j)%sLen]             msg, err := r.exchange(ctx, server, name, qtype, cfg.timeout)             if err != nil {                 lastErr = &DNSError{                     Err:    err.Error(),                     Name:   name,                     Server: server,                 }                 if nerr, ok := err.(Error); ok && nerr.Timeout() {                     lastErr.(*DNSError).IsTimeout = true                 }                 // Set IsTemporary for socket-level errors. Note that this flag                 // may also be used to indicate a SERVFAIL response.                 if _, ok := err.(*OpError); ok {                     lastErr.(*DNSError).IsTemporary = true                 }                 continue             }             // libresolv continues to the next server when it receives             // an invalid referral response. See golang.org/issue/15434.             if msg.rcode == dnsRcodeSuccess && !msg.authoritative && !msg.recursion_available && len(msg.answer) == 0 && len(msg.extra) == 0 {                 lastErr = &DNSError{Err: "lame referral", Name: name, Server: server}                 continue             }             cname, rrs, err := answer(name, server, msg, qtype)             // If answer errored for rcodes dnsRcodeSuccess or dnsRcodeNameError,             // it means the response in msg was not useful and trying another             // server probably won't help. Return now in those cases.             // TODO: indicate this in a more obvious way, such as a field on DNSError?             if err == nil || msg.rcode == dnsRcodeSuccess || msg.rcode == dnsRcodeNameError {                 return cname, rrs, err             }             lastErr = err         }     }     return "", nil, lastErr } | 
如果配置了多个 name server, 那么是通过轮询的方式查询,还是每次都从第一个 server 进行查询。选项 rotate 控制的。配置了,就是轮询,否则就是从第一个开始来。
这里有两层循环,第一层就是 attempts 控制重试次数,第二层就是遍历 name server,如果查到了就返回,失败了就轮询下一个。
总结
如果遇到了 dns 问题,具体怎么做呢?
- 直接配置映射关系, 放到 /etc/hosts 中。如果 ip, host 关系固定,好说,否则,就无法实现
- 修改 go 源码,控制不解析 ipv6, 小场景下,合适,但不是长期方案
- 精心配置 /etc/resolv.conf,尤其是与应用设置的超时时间对应起来,timeout, attempts, rotate 都要考虑好。name server 可以选不同的服务商,保证可用性。
 
								
Pingback引用通告: k8s 里 DNS 解析超时追踪分析 | bruceding.blog