本期作者

精心设计的 DNS Failover 战略在 Go 中居然带来了反效果,发生了什么?

一. 布景

如下装备所示,咱们在 /etc/resolv.conf 中装备了两个 nameserver,其中 server2 在灾备机房 ,作为一种 failover 战略。

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 库中,最常常运用的办法如下:

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 时需求留意。

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.Dialnetwork参数传入的是tcp,域名的 A 和 AAAA 记载都会被查询,不管服务器是否封闭 ipv6。

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。

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 调长一些)

package main
import (  
    "net"
) 
func main() { 
    c, err := net.Dial("tcp", "internal.domain.name:80") 
    if err != nil { 
        return 
    } 
    _ = c.Close()
}
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 由下面这段代码抛出。

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个条件全部满足,就会触发重试逻辑:

  1. 呼应没有错误
  2. 应对 Server 不是权威服务器
  3. 应对 Server 不支撑递归恳求
  4. 应对的records为空

这儿有一个疑点是咱们的 DNS Server 是支撑递归恳求的,经过排查,咱们发现是由于在 DNS Server 有一层 NetScaler 作为负载均衡器,负载均衡是以 DNS proxy server 的办法运转,默许并没有开启对递归恳求的支撑。

咱们能够运转 dig 指令调查是否有如下输出来判断 server 是否支撑递归恳求。

;; WARNING: recursion requested but not available

3. 原因整理

至此,咱们已经弄清楚了为什么会有 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 中的行为和成果都有差异,它可能会影响到咱们的服务。

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。接下来能够针对性的优化。

1. 对于 Go 程序中 AAAA 恳求重试到下一个 server 的优化计划:

a. 代价相对较小的计划,程序构建时增加-tags ‘netcgo’ 编译参数,指定运用 cgo-based 解析器。

b. DNS Server proxy 层支撑递归恳求。这儿有必要阐明递归支撑不能在 proxy 层简略的直接开启,proxy 和 recursion 在逻辑上有冲突的当地,必须做好必要的验证和承认,否则可能会带来新的问题。

  1. 假如事务程序不需求支撑 IPv6 网络,能够经过指定网络类型为 IPv4,来消除 AAAA 恳求,一起防止随之带来的问题。(也顺带减少了相关开支)

a.net.Dial相关办法能够指定networktcp4udp4来强制运用 IPv4

net.Dial("tcp4", "internal.domain.name:443")
net.Dial("udp4", "internal.domain.name:443")

b.net/http相关办法能够经过如下示例来强制运用 IPv4

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)
}

四. 总结

  1. Go net 库中供给了两种解析逻辑:自实现的内置解析器和体系供给的解析函数。windows、darwin(MacOS等)优先运用体系供给的解析函数,常见的 Debain、Centos 等优先运用内置解析器。

  2. Go net 库中的内置解析器和体系供给的解析函数行为和成果并不完全一致,它可能会影响到咱们的服务。

  3. 事务应设置合理的超时时刻,不易过短,以保证基础设施的 failover 战略有足够的呼应时刻。

引荐阅读:

studygolang.com/topics/1502…

pkg.go.dev/net中的 Name Resolution 章节