1. 前言

记得Rob Pike曾经说过,Go在设计之初,并没有考虑到它会被大量应用在嵌入式设备上。于是我们除了程序体积问题外,还遇到了DNS问题。

我们的网关Bridge程序运行在基于工业版树莓派的Linux系统上,可以通过以太网和4G连接网络,支持网络自动切换,支持手工配置IP和DNS。

除了通过网络设备、拨号连接判断网络状态外,还通过访问某些HTTP服务器,进一步判断网络是否连通,这个时候就回到一个常见的问题:发起一个HTTP请求的过程中,会有哪些操作?我们挂在了第一步,查询DNS上。主要症状如下

  • 以太网、4G自动切换时,会一并切换DNS服务器,修改 /etc/resolv.conf
  • 以太网和4G下自动获取的DNS服务器属于各自的内部DNS服务器,外网访问会被拒绝
  • DNS请求默认为UDP请求,容易被干扰、阻断和劫持
  • 在网络发起请求时,DNS查询是同步的,DNS查询超时直接导致网络请求失败

由于网络连通性的探测依赖HTTP请求,HTTP请求中的域名需要被解析,因此

  • 域名解析失败可能导致网络切换
  • 网络刚刚切换后,链路不稳定时可能导致域名解析失败
  • 4G拨号稳定的情况下(4G网卡到4G网关稳定),假设MQTT探测到链路不稳定后执行重连,如果域名解析失败会导致重连失败

总之这些情况混杂在一起,以至于无法判断到底是拨号不稳定还是网络链路不稳定,于是为了解决这个问题,笔者做了一些功课。

2. Go域名解析

在Linux环境下:

  • Go推荐使用Go实现的内部DNS客户端而不是CGo,因为前者发起一个请求只阻塞一个goroutine,而后者将阻塞一个系统线程
  • Go内置的DNS客户端不缓存域名解析结果,因此修改域名TTL不会产生实际影响
  • 在Linux下,Go内置的DNS客户端读取 /etc/nsswitch.conf 确定域名解析优先顺序,例如文件配置优先还是域名服务器优先
  • 在Linux下,Go内置的DNS客户端读取并持续监控 /etc/resolv.conf 文件变动,使用文件内的域名服务器直接发起DNS请求
  • Go发起网络请求时,DNS解析所占时间也包含在超时时间内

以一个tls连接建立为例子,例如连接本站点

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
package main                                                          
                                                                      
import (                                                              
        "crypto/tls"                                                  
        "io"                                                          
                                                                      
        log "github.com/sirupsen/logrus"                              
)                                                                     
                                                                      
func main() {                                                         
        tlsConfig := &tls.Config{                                     
                ServerName:         "wbuntu.com",                     
                InsecureSkipVerify: true,                             
                MaxVersion:         tls.VersionTLS13,                 
                MinVersion:         tls.VersionTLS12,                 
        }                                                             
        log.Info("client: dialing")                                   
        conn, err := tls.Dial("tcp", "wbuntu.com:443", tlsConfig)         
        if err != nil {                                               
                log.Fatalf("client: dial: %s", err)                   
        }                                                             
        defer conn.Close()                                            
        log.Info("client: connected to: ", conn.RemoteAddr())         
                                                                      
        state := conn.ConnectionState()                               
        log.Infof("TLS Version: %0x", state.Version)                      
        log.Infof("TLS CipherSuite: %0x", state.CipherSuite)              
        for _, v := range state.PeerCertificates {                    
                log.Infof("Subject: %s", v.Subject)                   
        }                                                             
        log.Info("client: handshake: ", state.HandshakeComplete)      
        log.Info("client: mutual: ", state.NegotiatedProtocolIsMutual)
                                                                      
        message := "Hello\n"                                          
        n, err := io.WriteString(conn, message)                       
        if err != nil {                                               
                log.Fatalf("client: write: %s", err)                  
        }                                                             
        log.Infof("client: wrote %q (%d bytes)", message, n)          
                                                                      
        reply := make([]byte, 512)                                    
        n, err = conn.Read(reply)                                     
        log.Infof("client: read %q (%d bytes)", string(reply[:n]), n) 
        log.Info("client: exiting")                                   
}                                                                                                                      

沿着tls.Dial,我们可以一步步寻找到函数调用顺序,直到获取DNS的那一步

  • tls.Dial
  • tls.DialWithDialer
  • net.Dialer.Dial
  • net.Dialer.DialContext
  • net.Resolver.resolveAddrList
  • net.Resolver.internetAddrList
  • net.Resolver.lookupIPAddr
  • net.Resolver.lookupIP
  • net.Resolver.goLookupIP

默认情况下,会使用net包的DefaultResolver来执行DNS查询,在解析Addr的过程中,会依次判断网络类型(tcp/udp/unixsocket)、网络地址类型(域名或IP,IP为IPv4或IPv6等),最终返回一组addr对应的实际IP地址。

lookupIP函数会判断使用纯Go实现的内置DNS客户端还是通过CGo查询域名

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
func (r *Resolver) lookupIP(ctx context.Context, network, host string) (addrs []IPAddr, err error) {
	if r.preferGo() {
		return r.goLookupIP(ctx, host)
	}
	order := systemConf().hostLookupOrder(r, host)
	if order == hostLookupCgo {
		if addrs, err, ok := cgoLookupIP(ctx, network, host); ok {
			return addrs, err
		}
		// cgo not available (or netgo); fall back to Go's DNS resolver
		order = hostLookupFilesDNS
	}
	ips, _, err := r.goLookupIPCNAMEOrder(ctx, host, order)
	return ips, err
}

以纯Go的实现为例,会先获取系统配置的域名查询顺序,然后执行DNS查询

1
2
3
4
5
6
7
// goLookupIP is the native Go implementation of LookupIP.
// The libc versions are in cgo_*.go.
func (r *Resolver) goLookupIP(ctx context.Context, host string) (addrs []IPAddr, err error) {
	order := systemConf().hostLookupOrder(r, host)
	addrs, _, err = r.goLookupIPCNAMEOrder(ctx, host, order)
	return
}

由于 goLookupIPCNAMEOrder 函数比较长,下面做逐步拆解

(1)如果优先文件且在文件中找到了静态DNS记录则立刻返回

1
2
3
4
5
6
if order == hostLookupFilesDNS || order == hostLookupFiles {
	addrs = goLookupIPFiles(name)
	if len(addrs) > 0 || order == hostLookupFiles {
		return addrs, dnsmessage.Name{}, nil
	}
}

(2)尝试获取 /etc/resolv.conf 的更新信息(文件中定义了域名服务器地址、域名解析规则等),然后获取一份dnsConfig的副本,用于后续网络查询

1
2
3
4
resolvConf.tryUpdate("/etc/resolv.conf")
resolvConf.mu.RLock()
conf := resolvConf.dnsConfig
resolvConf.mu.RUnlock()

这里插一下tryUpdate的更新逻辑

  • 在resolverConfig中记录了最新检查时间lastChecked,并使用了基于channel实现的锁,确保每5秒内只有一个goroutine执行resolverConfig更新和检查
  • resolverConfig.dnsConfig保存了 /etc/resolv.conf 的修改时间mtime,使用 os.Stat 获取文件 ModTime
    • 若文件自上次检查以来未发生过变动,则停止后续操作,直接返回
    • 若文件发生变动,则读取并解析文件,然后更新dnsConfig

这样的逻辑确保 /etc/resolv.conf 的变动能够被快速探测到,同时减少了文件的读取解析

(3)执行DNS查询,由于代码很长,这里直接贴出解读的代码内容

  • 构造查询函数,这里会同时查询A记录和AAAA记录
  • 执行查询函数获取记录,实际执行查询的是tryOneName
  • 解析获取到的数据,返回IP地址列表

这里插一下tryOneName的逻辑

  • 构造DNS请求
  • 根据dnsConfig配置发起DNS查询,在这里可以看到实际网络请求落在exchange函数,通过网络连接获取DNS记录
  • 处理网络返回数据

所以最终的结论还是,Go没有DNS缓存。

关于Go的DNS缓存在issue上有过讨论,不过根据Russ Cox的回答,还是建议使用外部缓存,因为有一些系统,例如macOS是禁止程序直接发起DNS请求的,所以实际应用中,最好增加一个本地域名缓存。

3. 本地域名缓存

在排查问题的过程中,笔者总结:

  • 域名解析应当是系统级的配置,因为任何程序的网络访问都有可能使用到域名解析吗,每个程序内部各自维护一份缓存并不合适。
  • DNS请求默认使用UDP获取数据,UDP不提供可靠传输,在公网上QoS低,且容易被劫持、干扰和阻断。

因此在配置本地域名缓存时,优先选择能够支持可靠传输数据加密的本地工具和域名服务器。

截至目前:

  • 国内的阿里云:223.5.5.5,223.6.6.6,域名为dns.alidns.com
  • 国外的谷歌DNS:8.8.8.8,8.8.4.4,域名为dns.google
  • 国外的Cloudflare:1.1.1.1,1.0.0.1,域名为tls.cloudflare-dns.com

都已经支持了DoH(DNS over HTTPS)DoT(DNS over TLS)

需要注意的是,基于TCP的协议都无法避免队头阻塞的情况,最好启用本地缓存,使用DoH时最好启用HTTP2。

由于DoH的生态目前还未完善,下面的例子都使用DoT获取DNS记录。

3.1 systemd-resolved

官方文档链接如下:

CentOS 7当前的systemd版本为systemd-219,systemd-resolved需要单独安装,且不支持配置缓存,无法正常使用。

CentOS 8当前的systemd版本为systemd-239,已经包含systemd-resolved,典型的配置如下:

/etc/systemd/resolved.conf

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#  This file is part of systemd.
#
#  systemd is free software; you can redistribute it and/or modify it
#  under the terms of the GNU Lesser General Public License as published by
#  the Free Software Foundation; either version 2.1 of the License, or
#  (at your option) any later version.
#
# Entries in this file show the compile time defaults.
# You can change settings by editing this file.
# Defaults can be restored by simply deleting this file.
#
# See resolved.conf(5) for details

[Resolve]
DNS=1.1.1.1 8.8.8.8 2606:4700:4700::1111 2001:4860:4860::8888
FallbackDNS=1.0.0.1 8.8.4.4 2606:4700:4700::1001 2001:4860:4860::844
#Domains=
LLMNR=yes
MulticastDNS=yes
DNSSEC=allow-downgrade
DNSOverTLS=opportunistic
Cache=yes
DNSStubListener=yes

启动该服务

1
systemctl enable --now systemd-resolved 

该程序默认监听127.0.0.53地址的53端口和5355端口,手动修改 /etc/resolv.conf 文件,配置如下

1
nameserver 127.0.0.53

3.2 CoreDNS

CoreDNS又是一个使用Go语言编写的程序,从官方直接下载可执行程序后,配置systemd的service文件和Corefile后即可使用。

coredns下载后放置在 /usr/bin/coredns,两个配置文件内容如下:

/lib/systemd/system/coredns.service

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
[Unit]
Description=CoreDNS DNS server
Documentation=https://coredns.io
After=network.target

[Service]
PermissionsStartOnly=true
LimitNOFILE=1048576
LimitNPROC=512
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
AmbientCapabilities=CAP_NET_BIND_SERVICE
NoNewPrivileges=true
User=root
WorkingDirectory=~
ExecStart=/usr/bin/coredns -conf=/etc/coredns/Corefile
ExecReload=/bin/kill -SIGUSR1 $MAINPID
Restart=on-failure

[Install]
WantedBy=multi-user.target

/etc/coredns/Corefile

1
2
3
4
5
6
7
8
9
.:53 {
    forward . tls://1.1.1.1 tls://1.0.0.1 {
        tls_servername tls.cloudflare-dns.com
        health_check 5s
    }
    log
    errors
    cache
}

国内可使用阿里云的配置

1
2
3
4
5
6
7
8
9
.:53 {                                         
    forward . tls://223.5.5.5 tls://223.6.6.6 {
        tls_servername dns.alidns.com          
        health_check 5s                        
    }                                          
    log                                        
    errors                                     
    cache                                      
}                                              

启动该服务

1
systemctl enable --now coredns 

该程序默认监听127.0.0.1地址的53端口,手动修改 /etc/resolv.conf 文件,配置如下

1
nameserver 127.0.0.1

3.3 对比测试

在VPS上ping cloudflare的服务器,可以看到延迟在0.5毫秒左右,已经很低了

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
PING 1.1.1.1 (1.1.1.1) 56(84) bytes of data.
64 bytes from 1.1.1.1: icmp_seq=1 ttl=60 time=0.614 ms
64 bytes from 1.1.1.1: icmp_seq=2 ttl=60 time=1.11 ms
64 bytes from 1.1.1.1: icmp_seq=3 ttl=60 time=0.494 ms
64 bytes from 1.1.1.1: icmp_seq=4 ttl=60 time=0.568 ms
64 bytes from 1.1.1.1: icmp_seq=5 ttl=60 time=0.482 ms
64 bytes from 1.1.1.1: icmp_seq=6 ttl=60 time=0.527 ms
64 bytes from 1.1.1.1: icmp_seq=7 ttl=60 time=0.517 ms
64 bytes from 1.1.1.1: icmp_seq=8 ttl=60 time=0.589 ms
64 bytes from 1.1.1.1: icmp_seq=9 ttl=60 time=0.724 ms
^C
--- 1.1.1.1 ping statistics ---
9 packets transmitted, 9 received, 0% packet loss, time 211ms
rtt min/avg/max/mdev = 0.482/0.625/1.110/0.185 ms

但是查看coredns日志,会发现本地DNS响应速度更快,特别是有缓存的情况下,甚至可以达到0.08毫秒

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
➜  ~ journalctl -u coredns -f
-- Logs begin at Wed 2020-05-06 11:10:27 CST. --
May 07 10:50:44 centos coredns[687]: [INFO] 127.0.0.1:20169 - 24120 "AAAA IN play.google.com. udp 33 false 512" NOERROR qr,aa,rd,ra 76 0.000139122s
May 07 10:50:44 centos coredns[687]: [INFO] 127.0.0.1:20169 - 43927 "A IN play.google.com. udp 33 false 512" NOERROR qr,aa,rd,ra 64 0.000142118s
May 07 10:50:55 centos coredns[687]: [INFO] 127.0.0.1:54751 - 53488 "AAAA IN www.gstatic.com. udp 33 false 512" NOERROR qr,aa,rd,ra 76 0.000122535s
May 07 10:50:55 centos coredns[687]: [INFO] 127.0.0.1:54751 - 49080 "AAAA IN fonts.gstatic.com. udp 35 false 512" NOERROR qr,aa,rd,ra 144 0.000133828s
May 07 10:50:55 centos coredns[687]: [INFO] 127.0.0.1:54751 - 34549 "A IN www.gstatic.com. udp 33 false 512" NOERROR qr,aa,rd,ra 64 0.000072029s
May 07 10:50:55 centos coredns[687]: [INFO] 127.0.0.1:54751 - 39814 "A IN fonts.gstatic.com. udp 35 false 512" NOERROR qr,aa,rd,ra 132 0.000087276s
May 07 10:51:01 centos coredns[687]: [INFO] 127.0.0.1:55423 - 21727 "AAAA IN ssl.gstatic.com. udp 33 false 512" NOERROR qr,aa,rd,ra 76 0.000152958s
May 07 10:51:01 centos coredns[687]: [INFO] 127.0.0.1:55423 - 65338 "AAAA IN apis.google.com. udp 33 false 512" NOERROR qr,aa,rd,ra 124 0.000098076s
May 07 10:51:01 centos coredns[687]: [INFO] 127.0.0.1:55423 - 36935 "A IN apis.google.com. udp 33 false 512" NOERROR qr,aa,rd,ra 112 0.000048262s
May 07 10:51:01 centos coredns[687]: [INFO] 127.0.0.1:55423 - 28271 "A IN ssl.gstatic.com. udp 33 false 512" NOERROR qr,aa,rd,ra 64 0.000150586s

机房网络的环境尚且如此,移动网络环境下差距应该会更大。

3.4 其他配置

由于 /etc/revolv.conf 文件会被多个渠道修改,例如NetworkManager、DHCP、静态IP配置等,这里以CentOS 7和CentOS 8为例,禁用掉其他的所有配置,防止 /etc/revolv.conf 被自动修改

首先检查 /etc/sysconfig/network-scripts/ifcfg- 开头的文件,若配置静态IP时,一般会设置DNS,这里需要删除这些文件中NS配置。

然后配置NetworkManager,添加文件 /etc/NetworkManager/conf.d/90-dns-none.conf ,内容如下

1
2
[main]
dns=none

重启NetworkManager,关闭它对 /etc/revolv.conf 的自动修改。