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 的自动修改。