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

本期作者

一. 背景

如下配置所示,我们在 /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.Dialnetwork 参数传入的是 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个条件全部满足,就会触发重试逻辑:

  1. 响应没有错误
  2. 应答 Server 不是权威服务器
  3. 应答 Server 不支持递归请求
  4. 应答的 records 为空

这里有一个疑点是我们的 DNS Server 是支持递归请求的,经过排查,我们发现是因为在 DNS Server 有一层 NetScaler 作为负载均衡器,负载均衡是以 DNS proxy server 的方式运行,默认并没有开启对递归请求的支持。

我们可以运行 dig 命令观察是否有如下输出来判断 server 是否支持递归请求。

ini 复制代码
;; 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 中的行为和结果都有差异,它可能会影响到我们的服务。

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

  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

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

四. 总结

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

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

  3. 业务应设置合理的超时时间,不易过短,以确保基础设施的 failover 策略有足够的响应时间。

推荐阅读:

studygolang.com/topics/1502...

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

相关推荐
石牌桥网管8 小时前
DNS Resolver解析服务器出口IP查询
运维·网络·tcp/ip·dns
郝同学的测开笔记1 天前
云原生探索系列(十二):Go 语言接口详解
后端·云原生·go
一点一木2 天前
WebAssembly:Go 如何优化前端性能
前端·go·webassembly
千羽的编程时光2 天前
【CloudWeGo】字节跳动 Golang 微服务框架 Hertz 集成 Gorm-Gen 实战
go
火山引擎边缘云3 天前
QCon演讲实录|赵彦奇:HTTPDNS 边缘下沉,性能、成本和稳定性之间的取舍与思考
http·边缘计算·dns
CXDNW3 天前
【网络面试篇】其他面试题——Cookie、Session、DNS、CDN、SSL/TLS、加密概念
网络·笔记·面试·cdn·dns·cookie
桃酥4033 天前
day08|计算机网络重难点之 DNS查询过程、CDN是什么,有什么作用?、Cookie和Session是什么?有什么区别?
计算机网络·cdn·dns·cookie·session
27669582924 天前
阿里1688 阿里滑块 231滑块 x5sec分析
java·python·go·验证码·1688·阿里滑块·231滑块
UestcXiye4 天前
《TCP/IP网络编程》学习笔记 | Chapter 8:域名及网络地址
c++·计算机网络·ip·tcp·dns
Moment5 天前
在 NodeJs 中如何通过子进程与 Golang 进行 IPC 通信 🙄🙄🙄
前端·后端·go