问题描述

一个golang写的客户端程序,向云端发起一个http 请求,报错:

Get http://XXXXXXX: net/http: request canceled while waiting for connection (Client.Timeout exceeded while awaiting headers)

使用curl请求正常返回。

问题定位

光从错误提示上看,没有任何关于DNS错误的提示。在我一顿分析http请求有没有问题后,抓包发现,根本就没有tcp请求发出去。在/etc/hosts内写死域名地址,程序运行正常。于是定位应该是dns解析有问题。

环境描述

运行环境

[root@localhost ~]# cat /etc/redhat-release 
CentOS Linux release 7.2.1511 (Core)
[root@localhost ~]# cat /etc/resolv.conf 
# Generated by NetworkManager
nameserver A
nameserver B

编译环境

go version go1.11 linux/amd64

我们先看结论吧,具体分析过程放到最下面,方便遇到相同问题的小伙伴。有兴趣的可以看下面分析过程。

问题结论

  • golang 1.11 版本。如果/etc/resolv.conf 文件的nameserver有不可达的地址。那么使用go实现的dns解析将会非常耗时。耗时取决于resolv.conf文件options选项attempts * timeout。默认10秒。
  • 其他版本,我实验了go1.11.1、go1.11.2、go1.9.7。如果/etc/resolv.conf 文件的nameserver有不可达的地址,且设置了options rotate,go实现的dns解析耗费timeout秒,默认5。
  • 其他版本。如果/etc/resolv.conf 文件的nameserver有不可达的地址,没有设置options rotate,go实现与cgo实现耗时相同,取决于nameserver不可达的地址的位置,如果第一位会耗费timeout秒,默认5.
版本rotatenameserver位置耗时(秒)
1.11--attempts * timeout
其他-1 * timeout
其他不可达在第一位1 * timeout
其他不可达在第二位-
[root@localhost ~]$ time GODEBUG=netdns=go ./dns -h xxx.alicdn.com
addrs: [x.x.x.2 x.x.x.235 x.x.x.238 x.x.x.237 x.x.x.7 x.x.x.233 x.x.x.234 x.x.x.236 x.x.x.6 x.x.x.239]

real    0m10.048s
user    0m0.002s
sys     0m0.011s

[root@localhost ~]$ time GODEBUG=netdns=cgo ./dns -h xxx.alicdn.com
addrs: [x.x.x.237 x.x.x.7 x.x.x.233 x.x.x.234 x.x.x.236 x.x.x.6 x.x.x.239 x.x.x.2 x.x.x.235 x.x.x.238]

real    0m0.031s
user    0m0.005s
sys     0m0.005s

解决方案

  1. 如果你能修改运行主机配置(服务端),那当然是直接修改/etc/resolv.conf文件了。
  2. 如果你无权修改运行主机(比如客户端程序),需要在编译时使用-tags ‘netcgo’ 强制go使用cgo方式做dns解析。虽然不能根本解决问题,但至少能表现的和其他工具一样的结果。不要被别人喷你写的东西屎。

问题分析

既然 问题定位 已经确认了是DNS问题,那么自然要看/etc/resolv.conf文件了,结果发现,nameserver B无法ping通。

那同样的主机配置为何curl请求没有问题呢?

官方文档 有对golang DNS的说明,这篇文章对其进行了翻译go (golang) DNS域名解析实现 :

域名解析函数,Dial函数会间接调用到,而LokupHost和LookupAddr则会直接调用域名解析函数,不同的操作系统实现不同,  在Unix系统中有两种方法进行域名解析:
1. 纯GO语言实现的域名解析,从/etc/resolv.conf中取出本地dns server地址列表, 发送DNS请求(UDP报文)并获得结果
2. 使用cgo方式, 最终会调用到c标准库的getaddrinfo或getnameinfo函数

GO语言默认使用纯GO的域名解析,因为这样一个阻塞的DNS请求只会消耗一个协程, 使 用cgo的
方式则会阻塞一个系统线程, 只有某些特定条件下才会使用系统提供的cgo方式, 例如: 
1) 在OS X系统中不允许程序直接发送DNS请求;  
2) LOCALDOMAINH环境变量存在,即使为空;  
3) ES_OPTIONS或HOSTALIASES或ASR_CONFIG环境变量非空; 
4) /etc/resolv.conf或/etc/nsswitch.conf指定的使用方式GO解析器没有实现;
5) 当要解析的域名以.local结束, 或者是一个mDNS域名。

可以通过GODEBUG环境变量来设置go语言的默认DNS解析方式 纯go或cgo,
> export GODEBUG=netdns=go    # force pure Go resolver 纯go 方式
> export GODEBUG=netdns=cgo   # force cgo resolver   cgo 方式

也可以在编译时指定netgo或netcgo的编译tag来设置
在plan 9中 域名解析只能通过 /net/cs和 /net/dns
在windows中 域名解析只能通过windows提供的C标准库函数GetAddrInfo或DnsQuery

正是由于这种差异化,造成了curl与go实现程序表现出了不同的结果。

go到底如何实现的?

列出关键代码 net/dnsclient_unix.go :

func (r *Resolver) goLookupIPCNAMEOrder(ctx context.Context, name string, order hostLookupOrder) (addrs []IPAddr, cname dnsmessage.Name, err error) {

    // 读取配置
    resolvConf.tryUpdate("/etc/resolv.conf")                                                                                                 
    conf := resolvConf.dnsConfig

    // 同时查询A记录(ipv4),AAAA记录(ipv6)
    qtypes := [...]dnsmessage.Type{dnsmessage.TypeA, dnsmessage.TypeAAAA}
    // 默认conf.nameList(name)会返回两个 xxxx.alicdn.com. 和 xxxx.alicdn.com.bja
    for _, fqdn := range conf.nameList(name) {
        for _, qtype := range qtypes {
            go func(qtype dnsmessage.Type) {
                // 起两个goroutine执行dns请求
                p, server, err := r.tryOneName(ctx, conf, fqdn, qtype)
                lane <- racer{p, server, err}
            }(qtype)
        } 
        // 要等到A记录(ipv4),AAAA记录(ipv6)都有结果才结束循环。
        for range qtypes {
            racer := <-lane
        } 
    }
}
func (r *Resolver) tryOneName(ctx context.Context, cfg *dnsConfig, name string, qtype dnsmessage.Type) (dnsmessage.Parser, string, error) {

    // 根据/etc/resolv.conf中options选项rotate计算
    serverOffset := cfg.serverOffset()
    sLen := uint32(len(cfg.servers))

    // 超时时间,重试次数对应与/etc/resolv.conf中options的timeout默认为2, attempts默认为5(秒)
    for i := 0; i < cfg.attempts; i++ {
        for j := uint32(0); j < sLen; j++ {
            // 获取nameserver
            server := cfg.servers[(serverOffset+j)%sLen]
            // 发起dns请求
            p, h, err := r.exchange(ctx, server, q, cfg.timeout)
            // 1.11版本 (之前版本不确定) 
            // 如果没有查询到该记录的结果(errNoSuchHost),重试
            // 1.11.1 之后
            // 如果没有查询到该记录的结果(errNoSuchHost),返回
            // 比1.11版本优化了无效重试
        }
    }    
}
// serverOffset returns an offset that can be used to determine
// indices of servers in c.servers when making queries.
// When the rotate option is enabled, this offset increases.
// Otherwise it is always 0.
func (c *dnsConfig) serverOffset() uint32 {
    // 如果/etc/resolv.conf中options的rotate被设置,开始轮训
    if c.rotate {
        return atomic.AddUint32(&c.soffset, 1) - 1 // return 0 to start
    }
    return 0
}

总结

从源码中可以看到,

  1. go实现会同时进行A记录(ipv4),AAAA记录(ipv6) 的dns请求。
  2. go实现对于/etc/resolv.conf文件的解析,轮训方式与glibc可能不同的。
  3. 如果你有两个nameserver,且设置了options rotate,如果你nameserver中有一个是坏的,那么go实现,肯定会轮到这个坏的,因为会多请求一个AAAA记录,而两个请求用的dnsConfig.soffset是同一变量。
  4. 对于3情况,1.11版本 情况更恶劣。如果dns服务器没有你请求域名的AAAA记录,会阻塞timeout*attempts秒,因为造成errNoSuchHost错误,会进行重试,跑满attempts循环条件。

参考

源码

package main

import (
        "flag"
        "fmt"
        "net"
)
var arg = flag.String("h", "localhost", "domain")
func main() {
        flag.Parse()

        ips, err := net.LookupIP(*arg)
        if err != nil {
                fmt.Printf("lookup ip error: %s\n", err)
        } else {
                fmt.Printf("addrs: %v\n", ips)
        }
}
#include <stdio.h>
#include <sys/socket.h>
#include <netdb.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <stdlib.h>
#include <unistd.h>

int main(int argc, char **argv)
{
        char                    *ptr, **pptr;
        char                    str[INET_ADDRSTRLEN];
        struct hostent  *hptr;

        while (--argc > 0) {
                ptr = *++argv;
                if ( (hptr = gethostbyname(ptr)) == NULL) {
                        printf("gethostbyname error for host: %s: %s\n",
                                        ptr, hstrerror(h_errno));
                        continue;
                }
                printf("official hostname: %s\n", hptr->h_name);

                for (pptr = hptr->h_aliases; *pptr != NULL; pptr++)
                        printf("\talias: %s\n", *pptr);

                switch (hptr->h_addrtype) {
                        case AF_INET:
                                pptr = hptr->h_addr_list;
                                for ( ; *pptr != NULL; pptr++)
                                        printf("\taddress: %s\n",
                                                        inet_ntop(hptr->h_addrtype, *pptr, str, sizeof(str)));
                                break;

                        default:
                                printf("unknown address type\n");
                                break;
                }
        }
        exit(0);
}