本期作者

一. 背景
如下配置所示,我们在 /etc/resolv.conf 中配置了两个 nameserver,其中 server2 在灾备机房 ,作为一种 failover 策略。
            
            
              bash
              
              
            
          
          nameserver server1
nameserver server2
options timeout:1 attempts:1我们的预期是如果 server1 服务正常,则所有的 DNS 请求应该由 server1 处理,且 server2 故障不应对业务有任何影响 。只有当 server1 服务异常,DNS 请求才应该重试到 server2。
然而我们在线上观察到一直有 AAAA 类型的 DNS 请求发送到 server2,而且如果 client 到 server2 的网络异常时,业务的 http 请求耗时会增加 1s,这并不符合预期。同时因为我们的内网域名都没有 AAAA 记录,且内网服务器也是关闭了 IPv6 协议的,AAAA 请求也不符合预期。
二. 问题排查
经过和业务同学求证,相关程序语言为 Go ,请求使用的是 Go 原生 net 库。在 Go net 库中,最经常使用的方式如下:
            
            
              go
              
              
            
          
          package main 
import ( 
    "net"   
    "net/http"
) 
func main() {
    http.Get("https://internal.domain.name")
    net.Dial("tcp", "internal.domain.name:443")
}1. 梳理源码
让我们顺着源码分析 net 库的解析逻辑。无论是 http.Get 还是 net.Dial 最终都会到 func (d *Dialer) DialContext() 这个方法。然后层层调用到 func (r *Resolver) lookupIP() 方法,这里定义了何时使用 Go 内置解析器或调用操作系统 C lib 库提供的解析方法,以及 /etc/hosts 的优先级。
同时补充一个比较重要的信息:windows 、darwin(MacOS等)优先使用 C lib 库解析,debug 时需要注意。
            
            
              scss
              
              
            
          
          func (d *Dialer) DialContext(ctx context.Context, network, address string) (Conn, error) {    
    ...    
    addrs, err := d.resolver().resolveAddrList(resolveCtx, "dial", network, address, d.LocalAddr)    
    ...
}
func (r *Resolver) resolveAddrList(ctx context.Context, op, network, addr string, hint Addr) (addrList, error) {    
    ... 
    addrs, err := r.internetAddrList(ctx, afnet, addr)    
    ...
} 
func (r *Resolver) internetAddrList(ctx context.Context, net, addr string) (addrList, error) {    
    ...   
    ips, err := r.lookupIPAddr(ctx, net, host)    
    ...
} 
func (r *Resolver) lookupIPAddr(ctx context.Context, network, host string) ([]IPAddr, error) {   
    ...  
    resolverFunc := r.lookupIP  
    ...   
    ch := r.getLookupGroup().DoChan(lookupKey, func() (any, error) {        
        return testHookLookupIP(lookupGroupCtx, resolverFunc, network, host)    
    })    
    ...
} 
func (r *Resolver) lookupIP(ctx context.Context, network, host string) (addrs []IPAddr, err error) {    
    if r.preferGo() {  
        return r.goLookupIP(ctx, network, host) 
    }    
    order, conf := systemConf().hostLookupOrder(r, host)    
    if order == hostLookupCgo {        
           return cgoLookupIP(ctx, network, host)    
    }    
    ips, _, err := r.goLookupIPCNAMEOrder(ctx, network, host, order, conf)   
    return ips, err
}我们线上的操作系统是 Debain,确认会使用 Go 内置解析器。所以下一步来到了 func (r *Resolver) goLookupIPCNAMEOrder() 方法。这里我们可以通过 qtypes 看到如果 net.Dial 的 network 参数传入的是 tcp ,域名的 A 和 AAAA 记录都会被查询,无论服务器是否关闭 ipv6。
            
            
              go
              
              
            
          
          func (r *Resolver) goLookupIPCNAMEOrder(ctx context.Context, network, name string, order hostLookupOrder, conf *dnsConfig) (addrs []IPAddr, cname dnsmessage.Name, err error) {    
    ...    
    lane := make(chan result, 1)     
    qtypes := []dnsmessage.Type{dnsmessage.TypeA, dnsmessage.TypeAAAA}    
    switch ipVersion(network) {   
    case '4':      
        qtypes = []dnsmessage.Type{dnsmessage.TypeA}   
    case '6':        
        qtypes = []dnsmessage.Type{dnsmessage.TypeAAAA}    
    }   
    var queryFn func(fqdn string, qtype dnsmessage.Type)   
    var responseFn func(fqdn string, qtype dnsmessage.Type) result   
    if conf.singleRequest {  
        queryFn = func(fqdn string, qtype dnsmessage.Type) {}  
        responseFn = func(fqdn string, qtype dnsmessage.Type) result {            
            dnsWaitGroup.Add(1)           
            defer dnsWaitGroup.Done()          
            p, server, err := r.tryOneName(ctx, conf, fqdn, qtype)           
            return result{p, server, err}     
        }  
    } else {   
        queryFn = func(fqdn string, qtype dnsmessage.Type) {   
            dnsWaitGroup.Add(1)         
            go func(qtype dnsmessage.Type) {     
                p, server, err := r.tryOneName(ctx, conf, fqdn, qtype)    
                lane <- result{p, server, err}          
                dnsWaitGroup.Done()         
            }(qtype)        
        }     
        responseFn = func(fqdn string, qtype dnsmessage.Type) result {        
            return <-lane       
        }    
    }  
    
    for _, fqdn := range conf.nameList(name) {      
        for _, qtype := range qtypes {          
            queryFn(fqdn, qtype)     
        }   
    }
    
    ... 
    for _, qtype := range qtypes {       
        result := responseFn(fqdn, qtype)   
    }
    ...
}从 goLookupIPCNAMEOrder 方法中我们可以看到由 tryOneName 方法分别处理 A 和 AAAA 记录。深入 tryOneName 内部,我们终于发现具体的 nameserver 选择逻辑,在某些错误情况下会重试请求到下一个 nameserver。
            
            
              go
              
              
            
          
          func (r *Resolver) tryOneName(ctx context.Context, cfg *dnsConfig, name string, qtype dnsmessage.Type) (dnsmessage.Parser, string, error) {  
    ...  
    q := dnsmessage.Question{     
        Name:  n,       
        Type:  qtype,      
        Class: dnsmessage.ClassINET,   
    }  
    
    for i := 0; i < cfg.attempts; i++ {    
        for j := uint32(0); j < sLen; j++ {  
            server := cfg.servers[(serverOffset+j)%sLen]     
            p, h, err := r.exchange(ctx, server, q, cfg.timeout, cfg.useTCP, cfg.trustAD)   
            ...  
            if err := checkHeader(&p, h); err != nil {  
                dnsErr := &DNSError{ 
                    Err:    err.Error(),             
                    Name:   name,        
                    Server: server,      
                }               
                if err == errServerTemporarilyMisbehaving {   
                    dnsErr.IsTemporary = true   
                }              
                if err == errNoSuchHost {                
                    // The name does not exist, so trying               
                    // another server won't help.   
                    
                    dnsErr.IsNotFound = true           
                    return p, server, dnsErr             
                }          
                lastErr = dnsErr          
                continue           
          }    
     ...
}2. 线上 debug
接下来我们可以构造一个简单的程序在线上 debug,看看到底是因为原因导致 AAAA 请求重试到了下一个 nameserver。(tips: debug 需要把 resolv.conf 的 timeout 调长一些)
            
            
              go
              
              
            
          
          package main
import (    
    "net"
) 
func main() { 
    c, err := net.Dial("tcp", "internal.domain.name:80")  
    if err != nil { 
        return  
    }  
    _ = c.Close()
}
            
            
              go
              
              
            
          
          dlv debug main.go
(dlv) break /usr/local/go/src/net/dnsclient_unix.go:279
(dlv) break /usr/local/go/src/net/dnsclient_unix.go:297
(dlv) continue
(dlv) print err
error(*errors.errorString) *{   
    s: "lame referral",}通过 debug 我们最终定位到 err 由下面这段代码抛出。
            
            
              go
              
              
            
          
          func checkHeader(p *dnsmessage.Parser, h dnsmessage.Header) error {  
    ...   
    // libresolv continues to the next server when it receives  
    // an invalid referral response. See golang.org/issue/15434.   
    if h.RCode == dnsmessage.RCodeSuccess && !h.Authoritative && !h.RecursionAvailable && err == dnsmessage.ErrSectionDone {       
        return errLameReferral  
    }    
    ....
}原来如果返回的 DNS response 以下4个条件全部满足,就会触发重试逻辑:
- 响应没有错误
- 应答 Server 不是权威服务器
- 应答 Server 不支持递归请求
- 应答的 records 为空
这里有一个疑点是我们的 DNS Server 是支持递归请求的,经过排查,我们发现是因为在 DNS Server 有一层 NetScaler 作为负载均衡器,负载均衡是以 DNS proxy server 的方式运行,默认并没有开启对递归请求的支持。
我们可以运行 dig 命令观察是否有如下输出来判断 server 是否支持递归请求。
            
            
              ini
              
              
            
          
          ;; WARNING: recursion requested but not available3. 原因梳理
至此,我们已经弄清楚了为什么会有 AAAA 类型的请求发送到 nameserver2。而文章开头提到的业务 http 请求耗时增加 1s 的原因则是因为 client 到 server2 网络异常时,需要等待重试的 AAAA 请求超时,才会返回解析结果。
还有一个问题困扰着我们,为什么用 ping 等程序验证,并没有发现类似的问题。我们通过直接用 C getaddrinfo 函数测试,以及通过 -tags 'netcgo' 编译相同的 go 程序验证,发现在 A 记录有值的情况下,AAAA 请求都不会重试到下一个 nameserver。回到 Go 中触发重试的这段代码深入分析,注释中可以看到由 golang.org/issue/15434 引入,提交代码的作者是为了解决 issue 中的问题复制了 libresolv 的行为。然而翻阅 glibc 的代码可以看到 next_ns 中还有这样一段逻辑:只要 A 或者 AAAA 任意一个有记录值,都不会重试到下一个 nameserver。这段逻辑并没有引入 Go 中。所以我们需要注意 Go 内置解析器与 glibc 中的行为和结果都有差异,它可能会影响到我们的服务。
            
            
              ini
              
              
            
          
          next_ns:   
    if (recvresp1 || (buf2 != NULL && recvresp2)) {   
      *resplen2 = 0; 
      return resplen;    
    } 
... 
if (anhp->rcode == NOERROR && anhp->ancount == 0   
    && anhp->aa == 0 && anhp->ra == 0 && anhp->arcount == 0) {   
    goto next_ns;
}三. 优化
经过上面的排查,我们已经确认了 AAAA 请求的源头,以及为什么会重试到下一个 server。接下来可以针对性的优化。
- 对于 Go 程序中 AAAA 请求重试到下一个 server 的优化方案:
a. 代价相对较小的方案,程序构建时添加 -tags 'netcgo' 编译参数,指定使用 cgo-based 解析器。
b. DNS Server proxy 层支持递归请求。这里有必要说明递归支持不能在 proxy 层简单的直接开启,proxy 和 recursion 在逻辑上有冲突的地方,务必做好必要的验证和确认,否则可能会带来新的问题。
- 如果业务程序不需要支持 IPv6 网络,可以通过指定网络类型为 IPv4,来消除 AAAA 请求,同时避免随之带来的问题。(也顺带减少了相关开销)
a. net.Dial 相关方法可以指定 network 为 tcp4、udp4 来强制使用 IPv4
            
            
              arduino
              
              
            
          
          net.Dial("tcp4", "internal.domain.name:443")
net.Dial("udp4", "internal.domain.name:443")b. net/http 相关方法可以通过如下示例来强制使用 IPv4
            
            
              go
              
              
            
          
          package main
import (
    "context"   
    "log"   
    "net"  
    "net/http"   
    "time"
)
func main() { 
    dialer := &net.Dialer{  
        Timeout:   30 * time.Second,        
        KeepAlive: 30 * time.Second,   
    }   
    
    transport := http.DefaultTransport.(*http.Transport).Clone()
    transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {        
        return dialer.DialContext(ctx, "tcp4", addr)   
    }     
    
    httpClient := &http.Client{  
        Timeout: 30 * time.Second,   
    }    
    httpClient.Transport = transport  
    
    resp, err := httpClient.Get("https://internal.domain.name")  
    if err != nil {  
        log.Fatal(err)    
    }    
    log.Println(resp.StatusCode)
}四. 总结
- 
Go net 库中提供了两种解析逻辑:自实现的内置解析器和系统提供的解析函数。windows 、darwin(MacOS等)优先使用系统提供的解析函数,常见的 Debain、Centos 等优先使用内置解析器。 
- 
Go net 库中的内置解析器和系统提供的解析函数行为和结果并不完全一致,它可能会影响到我们的服务。 
- 
业务应设置合理的超时时间,不易过短,以确保基础设施的 failover 策略有足够的响应时间。 
推荐阅读:
studygolang.com/topics/1502...
pkg.go.dev/net 中的 Name Resolution 章节