go dns解析过程及调优

背景

有同学通过zipkin发现dns解析偶尔会花费40ms(预期是1ms以内),并且猜测和alpine镜像有关系。
image-20220111220415183

第一反应不太可能是alpine镜像的问题(alpine镜像使用这么频繁,如果有问题应该早就修复了),下面针对这个问题进行分析。

Go中dns解析过程

首先我们了解下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。

Dns 解析策略

上面说到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
    }

禁用IPv6解析

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 协议》,转载必须注明作者和本文链接
您的点赞、评论和关注,是我创作的不懈动力。 学无止境,让我们一起加油,在技术的胡同里越走越深!
讨论数量: 1

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!