Golang笔记-09-DNS

文章目录

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连接建立为例子,例如连接本站点

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

沿着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查询域名

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

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

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

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

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

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

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

1resolvConf.tryUpdate("/etc/resolv.conf")
2resolvConf.mu.RLock()
3conf := resolvConf.dnsConfig
4resolvConf.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#  This file is part of systemd.
 2#
 3#  systemd is free software; you can redistribute it and/or modify it
 4#  under the terms of the GNU Lesser General Public License as published by
 5#  the Free Software Foundation; either version 2.1 of the License, or
 6#  (at your option) any later version.
 7#
 8# Entries in this file show the compile time defaults.
 9# You can change settings by editing this file.
10# Defaults can be restored by simply deleting this file.
11#
12# See resolved.conf(5) for details
13
14[Resolve]
15DNS=1.1.1.1 8.8.8.8 2606:4700:4700::1111 2001:4860:4860::8888
16FallbackDNS=1.0.0.1 8.8.4.4 2606:4700:4700::1001 2001:4860:4860::844
17#Domains=
18LLMNR=yes
19MulticastDNS=yes
20DNSSEC=allow-downgrade
21DNSOverTLS=opportunistic
22Cache=yes
23DNSStubListener=yes

启动该服务

1systemctl enable --now systemd-resolved 

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

1nameserver 127.0.0.53

3.2 CoreDNS

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

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

/lib/systemd/system/coredns.service

 1[Unit]
 2Description=CoreDNS DNS server
 3Documentation=https://coredns.io
 4After=network.target
 5
 6[Service]
 7PermissionsStartOnly=true
 8LimitNOFILE=1048576
 9LimitNPROC=512
10CapabilityBoundingSet=CAP_NET_BIND_SERVICE
11AmbientCapabilities=CAP_NET_BIND_SERVICE
12NoNewPrivileges=true
13User=root
14WorkingDirectory=~
15ExecStart=/usr/bin/coredns -conf=/etc/coredns/Corefile
16ExecReload=/bin/kill -SIGUSR1 $MAINPID
17Restart=on-failure
18
19[Install]
20WantedBy=multi-user.target

/etc/coredns/Corefile

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

国内可使用阿里云的配置

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

启动该服务

1systemctl enable --now coredns 

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

1nameserver 127.0.0.1

3.3 对比测试

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

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

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

 1➜  ~ journalctl -u coredns -f
 2-- Logs begin at Wed 2020-05-06 11:10:27 CST. --
 3May 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
 4May 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
 5May 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
 6May 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
 7May 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
 8May 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
 9May 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
10May 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
11May 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
12May 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[main]
2dns=none

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