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