go dns解析過程及調優

nanjingfm發表於2022-01-12

有同學通過zipkin發現dns解析偶爾會花費40ms(預期是1ms以內),並且猜測和alpine映象有關係。
image-20220111220415183

第一反應不太可能是alpine映象的問題(alpine映象使用這麼頻繁,如果有問題應該早就修復了),下面針對這個問題進行分析。

首先我們瞭解下golang中如何進行dns解析的。直接看程式碼,關鍵函式goLookupIPCNAMEOrder

// src/net/dnsclient_unix.go
func (r *Resolver) goLookupIPCNAMEOrder(ctx context.Context, network, name string, order hostLookupOrder) (addrs []IPAddr, cname dnsmessage.Name, err error) {
   // 省略檢查程式碼

   // 讀取/etc/resolv.conf,防止讀取頻繁,5秒鐘生效一次
   resolvConf.tryUpdate("/etc/resolv.conf")

  // ...

  // 預設解析ipv4和ipv6
   qtypes := []dnsmessage.Type{dnsmessage.TypeA, dnsmessage.TypeAAAA}
  // 【關鍵】根據network的不同,4結尾的只解析ipv4,6結尾的只解析ipv6
   switch ipVersion(network) {
   case '4':
      qtypes = []dnsmessage.Type{dnsmessage.TypeA}
   case '6':
      qtypes = []dnsmessage.Type{dnsmessage.TypeAAAA}
   }

   // ...

   // 判斷/etc/resolv.conf裡面的single-request和single-request-reopen引數,如果設定的話,就是序列請求,否者是並行請求
   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關鍵字了麼?沒有設定single-request就是併發解析
         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
      }
   }

  // 下面程式碼也很重要
   var lastErr error
  // len(namelist) = len(search domain) + 1
  // 遍歷nameserver,resolv.conf中可以配置多個nameserver,比如下面的配置namelist長度就是4:
  // nameserver 169.254.20.10
  // nameserver 172.16.0.10
  // search meipian-test.svc.cluster.local svc.cluster.local cluster.local
   for _, fqdn := range conf.nameList(name) {
      // ...
      // 遍歷解析型別,這裡就是ipv4和ipv6
      for _, qtype := range qtypes {
        // ....
      }
   }
   // ...
   return addrs, cname, nil
}

通過以上程式碼我們可以得出以下結論:

go實現了dns解析

Dns解析跟是不是alpine映象沒有關係,因為godns解析是自己實現的,不依賴於系統呼叫。go build tag也證明了這一點

//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris
// +build aix darwin dragonfly freebsd linux netbsd openbsd solaris

內建解析器會讀取配置檔案

go程式會讀取並解析/etc/resolv.conf檔案,並且標準選項都有實現,包括single-requestsingle-request-reopen option設定。

// src/net/dnsconfig_unix.go
case s == "single-request" || s == "single-request-reopen":
  // Linux option:
  // http://man7.org/linux/man-pages/man5/resolv.conf.5.html
  // "By default, glibc performs IPv4 and IPv6 lookups in parallel [...]
  //  This option disables the behavior and makes glibc
  //  perform the IPv6 and IPv4 requests sequentially."
  conf.singleRequest = true

single-request引數是有效的

如果設定了single request選項,dns解析的時候是序列的

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

如果沒有設定single-request選項,dns解析是並行的(真實情況是並行和序列結合的)。

if conf.singleRequest {
        // ...
    } 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
        }
    }

解析過程和配置相關

dns解析策略、次數和ndotssearch domainnameserver配置強相關:

  1. 預設情況下dns查詢會同時解析IPv4IPv6地址(不論容器是否支援IPv6

  2. ndots和待解析的域名決定要不要優先使用search domain通俗一點說,如果你的域名請求引數中,點的個數比配置的ndots小,則會優先拼接search domain後去解析,比如有如下配置:

    search meipian-test.svc.cluster.local svc.cluster.local cluster.local
    options ndots:3

    如果現在解析的域名是www.baidu.comndots配置的是3,待解析域名中的點數(2)比 ndots 小,所以會優先拼接搜尋域名去解析,解析順序如下:

    如果配置檔案中ndots等於2,則解析順序如下:

  3. serach domainnameserver決定了dns最多查詢的次數,即查詢次數等於搜素域的數量+1乘以dnsserver的數量。比如有以下配置:

    
    nameserver 169.254.20.10
    nameserver 172.16.0.10
    search meipian-test.svc.cluster.local svc.cluster.local cluster.local
    options ndots:3

    當我們解析www.baidu.com域名時,解析順序如下:

    解析域名 查詢型別 dns server
    www.baidu.com.meipian-test.svc.cluster.local. A 169.254.20.10
    www.baidu.com.meipian-test.svc.cluster.local. A 172.16.0.10
    www.baidu.com.meipian-test.svc.cluster.local. AAAA 169.254.20.10
    www.baidu.com.meipian-test.svc.cluster.local. AAAA 172.16.0.10
    www.baidu.com.svc.cluster.local. A 169.254.20.10
    www.baidu.com.svc.cluster.local. A 172.16.0.10
    www.baidu.com.svc.cluster.local. AAAA 169.254.20.10
    www.baidu.com.svc.cluster.local. AAAA 172.16.0.10
    www.baidu.com.cluster.local. A 169.254.20.10
    www.baidu.com.cluster.local. A 172.16.0.10
    www.baidu.com.cluster.local. AAAA 169.254.20.10
    www.baidu.com.cluster.local. AAAA 172.16.0.10
    www.baidu.com. A 169.254.20.10
    www.baidu.com. A 172.16.0.10
    www.baidu.com. AAAA 169.254.20.10
    www.baidu.com. AAAA 172.16.0.10

    一共16次,是不是很恐怖?當然只有在最壞的情況(比如域名確實不存在時)才會有這麼多次請求。

    image-20220112015048040

    ⚠️ 序列和並行請求是如何結合的?

    並行是指同一個域名的去同一個dns server解析不同的型別時是並行的,不同的域名之間還是序列的。

    把請求放在時間線上就像下面這樣:

    image-20220112094110024

上圖話的是最壞的情況,實際上過程中只要有一次解析成功就返回了

內建解析器引數預設值

ndots:    1,
timeout:  5 * time.Second, // dns解析超時時間為5秒,有點太長了
attempts: 2, // 解析失敗,重試兩次
defaultNS   = []string{"127.0.0.1:53", "[::1]:53"} // 預設dns server
search:os.Hostname // 

其中需要注意的就是timeout,建議在resolv.conf上加上這個引數,並且寫個較小的值。因為dns解析預設是udp請求(不可靠),如果發生丟包情況就會等5s。

上面說到go使用的是內建解析器,其實並不是所有情況都是這樣的。

兩種解析器

golang有兩種域名解析方法:內建go解析器和基於cgo的系統解析器。

// src/net/cgo_stub.go
//go:build !cgo || netgo
// +build !cgo netgo
func init() { netGo = true }

// src/net/conf_netcgo.go
//go:build netcgo
// +build netcgo
func init() { netCgo = true }

預設情況下用的是內建解析,如果你想指定使用cgo解析器,可以build的時候指定。

export GODEBUG=netdns=go    # force pure Go resolver
export GODEBUG=netdns=cgo   # force cgo resolver

內建解析器解析策略

goos=linux下使用的是 hostLookupFilesDNS ,也就是說,hosts解析優先dns解析(go1.17.5)。

const (
    // hostLookupCgo means defer to cgo.
    hostLookupCgo      hostLookupOrder = iota
    hostLookupFilesDNS                 // files first
    hostLookupDNSFiles                 // dns first
    hostLookupFiles                    // only files
    hostLookupDNS                      // only DNS
)

var lookupOrderName = map[hostLookupOrder]string{
    hostLookupCgo:      "cgo",
    hostLookupFilesDNS: "files,dns",
    hostLookupDNSFiles: "dns,files",
    hostLookupFiles:    "files",
    hostLookupDNS:      "dns",
}

根據作業系統的不同,使用的解析策略也會略有不同,比如android平臺就會強制使用cgo

// src/net/conf.go

fallbackOrder := hostLookupCgo
// ...
if c.forceCgoLookupHost || c.resolv.unknownOpt || c.goos == "android" {
        return fallbackOrder
    }

go1.17之前是沒有辦法禁用ipv6解析的。1.17之後go提供了一些方式

// 預設是IPv4和IPv6都解析
qtypes := []dnsmessage.Type{dnsmessage.TypeA, dnsmessage.TypeAAAA}

// 根據network的不同可以只解析ipv4或者只解析ipv6
switch ipVersion(network) {
case '4':
    qtypes = []dnsmessage.Type{dnsmessage.TypeA}
case '6':
    qtypes = []dnsmessage.Type{dnsmessage.TypeAAAA}
}

// ipVersion returns the provided network's IP version: '4', '6' or 0
// if network does not end in a '4' or '6' byte.
func ipVersion(network string) byte {
    if network == "" {
        return 0
    }
    n := network[len(network)-1]
    if n != '4' && n != '6' {
        n = 0
    }
    return n
}

所以想要禁用IPv6解析的話就很容易了,我們只需要在建立連線的時候指定network型別。以http為例,重寫TransportDialContext方法,將原來的network(預設是tcp)強制寫成tcp4

&http.Client{
        Transport: &http.Transport{
         // ....
            DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
          // 強制使用ipv4解析
          return zeroDialer.DialContext(ctx, "tcp4", addr)
            },
        }
    }
  1. go預設使用內建dns解析器,不依賴作業系統,跟基礎映象無關
  2. go內建解析器會讀取/etc/resov.conf配置,標準配置都有實現,手動修改配置5秒後生效
  3. Go1.17之後可以禁用ipv6解析
  4. go內建解析器解析過程預設是並行和序列結合的
    • 相同域名的不同請求型別是並行的
    • 不同域名之間是序列的

優化建議

  1. 修改ndots為合適的值

    k8s中如何配置的dnsPolicyClusterFist,預設ndots會是5`

    • 如果微服務之前請求使用的是service name,那麼不需要修改(拼接搜尋域名之後是可以成功解析的)
    • 如果微服務之間請求使用的是域名(或者說拼接搜尋域名之後一定解析不到的情況下),需要將ndots設定成合適值,目標是把原始域名放在前面解析(拼接搜尋域名放在後面)
  2. 修改timeout為合適的值

    go預設是5s,因為udp請求的不可靠性,一旦遇到丟包情況,就會讓程式等到天荒地老

  3. 禁用Ipv6解析開啟single-request

    對於go內建解析器而言single-requestsingle-request-reopen是同一個意思,這決定了不同解析請求(A或者AAAA)是併發還是序列,預設是並行。如果禁用了IPv6,就沒有併發解析的必要了,建議開始single-request

優化效果

dns解析只有有效的A記錄查詢了,世界突然安靜了。

image-20220112121135900

本作品採用《CC 協議》,轉載必須註明作者和本文連結
您的點贊、評論和關注,是我創作的不懈動力。 學無止境,讓我們一起加油,在技術的衚衕裡越走越深!

相關文章