精心設計的 DNS Failover 策略在 Go 中竟然帶來了反效果,發生了什麼?

架構師修行手冊發表於2024-02-21

來源:嗶哩嗶哩技術


一. 背景


如下配置所示,我們在 /etc/resolv.conf 中配置了兩個 nameserver,其中 server2 在災備機房 ,作為一種 failover 策略。





nameserver server1nameserver server2options 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(")    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.Dial 的 network 引數傳入的是 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 errerror(*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 在邏輯上有衝突的地方,務必做好必要的驗證和確認,否則可能會帶來新的問題。

2. 如果業務程式不需要支援 IPv6 網路,可以透過指定網路型別為 IPv4,來消除 AAAA 請求,同時避免隨之帶來的問題。(也順帶減少了相關開銷)

   a. net.Dial 相關方法可以指定 network 為 tcp4udp4 來強制使用 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(")    if err != nil {        log.Fatal(err)    }    log.Println(resp.StatusCode)}


四. 總結


  1. Go net 庫中提供了兩種解析邏輯:自實現的內建解析器和系統提供的解析函式。windows 、darwin(MacOS等)優先使用系統提供的解析函式,常見的 Debain、Centos 等優先使用內建解析器。

  2. Go net 庫中的內建解析器和系統提供的解析函式行為和結果並不完全一致,它可能會影響到我們的服務。

  3. 業務應設定合理的超時時間,不易過短,以確保基礎設施的 failover 策略有足夠的響應時間。


來自 “ ITPUB部落格 ” ,連結:https://blog.itpub.net/70027824/viewspace-3006913/,如需轉載,請註明出處,否則將追究法律責任。

相關文章