# 背景
有同学通过`zipkin`发现`dns`解析偶尔会花费40ms(预期是1ms以内),并且猜测和`alpine`镜像有关系。
![image-20220111220415183](https://bbk-images.oss-cn-shanghai.aliyuncs.com/typora/20220111220415.png)
第一反应不太可能是`alpine`镜像的问题(`alpine`镜像使用这么频繁,如果有问题应该早就修复了),下面针对这个问题进行分析。
# Go中dns解析过程
首先我们了解下`golang`中如何进行dns解析的。直接看代码,关键函数`goLookupIPCNAMEOrder`
```go
// 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
//go:build aix || darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris
// +build aix darwin dragonfly freebsd linux netbsd openbsd solaris
```
## 内置解析器会读取配置文件
`go`程序会读取并解析`/etc/resolv.conf`文件,并且[标准选项](http://man7.org/linux/man-pages/man5/resolv.conf.5.html)都有实现,包括`single-request`和`single-request-reopen option`设置。
```go
// 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`解析的时候是串行的
```go
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解析`是并行的(真实情况是并行和串行结合的)。
```go
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`配置强相关:
1. 默认情况下`dns`查询会同时解析`IPv4`和`IPv6`地址(不论容器是否支持`IPv6`)
2. `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.
3. `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](https://bbk-images.oss-cn-shanghai.aliyuncs.com/typora/20220112015048.png)
> ⚠️ 串行和并行请求是如何结合的?
>
> 并行是指**同一个域名**的去**同一个dns server**解析**不同的类型**时是并行的,不同的域名之间还是串行的。
把请求放在时间线上就像下面这样:
![image-20220112094110024](https://bbk-images.oss-cn-shanghai.aliyuncs.com/typora/20220112094110.png)
上图话的是最坏的情况,**实际上过程中只要有一次解析成功就返回了**。
## 内置解析器参数默认值
```go
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`的系统解析器。
```go
// 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`的时候指定。
```go
export GODEBUG=netdns=go # force pure Go resolver
export GODEBUG=netdns=cgo # force cgo resolver
```
## 内置解析器解析策略
当`goos=linux`下使用的是 `hostLookupFilesDNS` ,也就是说,`hosts`解析优先`dns`解析(go1.17.5)。
```go
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`
```go
// src/net/conf.go
fallbackOrder := hostLookupCgo
// ...
if c.forceCgoLookupHost || c.resolv.unknownOpt || c.goos == "android" {
return fallbackOrder
}
```
# 禁用IPv6解析
在`go1.17`之前是没有办法禁用`ipv6`解析的。`1.17`之后`go`提供了一些方式
```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`。
```go
&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`配置,[标准配置](https://man7.org/linux/man-pages/man5/resolv.conf.5.html)都有实现,手动修改配置5秒后生效
3. `Go1.17`之后可以禁用`ipv6`解析
4. `go`内置解析器解析过程默认是并行和串行结合的
- 相同域名的不同请求类型是并行的
- 不同域名之间是串行的
## 优化建议
1. 修改`ndots`为合适的值
`k8s`中如何配置的`dnsPolicy`是`ClusterFist`,默认`ndots会是`5`
- 如果微服务之前请求使用的是`service name`,那么不需要修改(拼接搜索域名之后是可以成功解析的)
- 如果微服务之间请求使用的是域名(或者说拼接搜索域名之后一定解析不到的情况下),**需要将`ndots`设置成合适值**,目标是把原始域名放在前面解析(拼接搜索域名放在后面)
2. 修改`timeout`为合适的值
`go`默认是`5s`,因为`udp`请求的不可靠性,一旦遇到丢包情况,就会让程序等到天荒地老
3. 禁用`Ipv6`解析开启`single-request`
对于`go`内置解析器而言`single-request`和`single-request-reopen`是同一个意思,这决定了不同解析请求(`A`或者`AAAA`)是并发还是串行,默认是并行。如果禁用了`IPv6`,就没有并发解析的必要了,建议开始`single-request`
## 优化效果
dns解析只有有效的A记录查询了,世界突然安静了。
![image-20220112121135900](https://bbk-images.oss-cn-shanghai.aliyuncs.com/typora/20220112121135.png)
有疑问加站长微信联系(非本文作者)