go dns解析过程及调优
背景
有同学通过zipkin
发现dns
解析偶尔会花费40ms(预期是1ms以内),并且猜测和alpine
镜像有关系。
第一反应不太可能是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
,则解析顺序如下: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次,是不是很恐怖?当然只有在最坏的情况(比如域名确实不存在时)才会有这么多次请求。
⚠️ 串行和并行请求是如何结合的?
并行是指同一个域名的去同一个dns server解析不同的类型时是并行的,不同的域名之间还是串行的。
把请求放在时间线上就像下面这样:
上图话的是最坏的情况,实际上过程中只要有一次解析成功就返回了。
内置解析器参数默认值
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)
},
}
}
总结
go
默认使用内置dns
解析器,不依赖操作系统,跟基础镜像无关go
内置解析器会读取/etc/resov.conf
配置,标准配置都有实现,手动修改配置5秒后生效Go1.17
之后可以禁用ipv6
解析go
内置解析器解析过程默认是并行和串行结合的- 相同域名的不同请求类型是并行的
- 不同域名之间是串行的
优化建议
修改
ndots
为合适的值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记录查询了,世界突然安静了。
本作品采用《CC 协议》,转载必须注明作者和本文链接
可以 很强