coredns解析缓慢分析

 
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镜像没有关系,因为go 中dns解析是自己实现的,不依赖于系统调用。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-request和single-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解析策略、次数和ndots、search domain和nameserver配置强相关:
默认情况下dns查询会同时解析IPv4和IPv6地址(不论容器是否支持IPv6)
ndots和待解析的域名决定要不要优先使用search domain,通俗一点说,如果你的域名请求参数中,点的个数比配置的ndots小,则会优先拼接search domain后去解析,比如有如下配置:
search meipian-test.svc.cluster.local svc.cluster.local cluster.local
options ndots:3

 

如果现在解析的域名是www.baidu.com,ndots配置的是3,待解析域名中的点数(2)比 ndots 小,所以会优先拼接搜索域名去解析,解析顺序如下:
  • www.baidu.com.meipian-test.svc.cluster.local.
  • www.baidu.com.svc.cluster.local.
  • www.baidu.com.cluster.local.
  • www.baidu.com.
如果配置文件中ndots等于2,则解析顺序如下:
www.baidu.com.
www.baidu.com.meipian-test.svc.cluster.local.
www.baidu.com.svc.cluster.local.
www.baidu.com.cluster.local.
serach domain和nameserver决定了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为例,重写Transport的DialContext方法,将原来的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内置解析器解析过程默认是并行和串行结合的
  • 相同域名的不同请求类型是并行的
  • 不同域名之间是串行的
优化建议
修改ndots为合适的值
推荐部署 Nodelocal DNS Cache 扩展来解决这个问题,同时也提升 DNS 解析的性能
k8s中如何配置的dnsPolicy是ClusterFist,默认ndots会是5`
  • 如果微服务之前请求使用的是service name,那么不需要修改(拼接搜索域名之后是可以成功解析的)
  • 如果微服务之间请求使用的是域名(或者说拼接搜索域名之后一定解析不到的情况下),需要将ndots设置成合适值,目标是把原始域名放在前面解析(拼接搜索域名放在后面)
修改timeout为合适的值
go默认是5s,因为udp请求的不可靠性,一旦遇到丢包情况,就会让程序等到天荒地老
禁用Ipv6解析开启single-request
对于go内置解析器而言single-request和single-request-reopen是同一个意思,这决定了不同解析请求(A或者AAAA)是并发还是串行,默认是并行。如果禁用了IPv6,就没有并发解析的必要了,建议开始single-request
优化效果
dns解析只有有效的A记录查询了,世界突然安静了。
image-20220112121135900
 
posted on 2022-05-19 10:54  李栋94  阅读(1012)  评论(0编辑  收藏  举报