深入理解 DNS 解析


  1. DNS 查询
    1. 迭代查询
    2. 递归查询
  2. 递归解析器
    1. 轮询 DNS
    2. 缓存插件
  3. 域名服务
    1. 传输协议
    2. 路由策略
  4. Resolver 库
    1. nsswitch.conf
    2. host.conf
    3. resolv.conf
    4. gai.conf
  5. Dial:连接创建
  6. 总结

作为互联网的基本设施,DNS 通过将域名转换为一组 IP 地址,在不同的连接尝试中,客户端将接收来自不同 IP 的服务器的服务,从而将整体负载分配到不同服务器之间。

在一些对响应延迟极度敏感的场景下,服务端负载不均会显著增加 P99/P999 延迟,例如:Redis 服务接入。假如后端服务能力一致,使用 DNS 作为服务发现的情况下,怎样才能让负载均衡到不同的服务器(注意:不仅仅是负载分配,而是负载均衡)。通常意义上,我们倾向于认为 DNS 解析返回的结果是 Round-robin 的,然而实际上并非如此。

DNS 查询

迭代查询

所有 DNS 服务器都属于以下四个类别之一:

  • 递归解析器(Local DNS)
  • 根域名服务器(Root Nameserver)
  • TLD 域名服务器(TLD Nameserver)
  • 权威性域名服务器(Authoritative Nameserver)

在典型 DNS 查找中,四种 DNS 服务器协同工作来完成客服端发起的域名到 IP 地址的解析任务。

客户端不会直接与 DNS 域名服务器通信,递归解析器(也称为 DNS 解析器)作为客户端与 DNS 域名服务器的中间人,是 DNS 查询中的第一站。从客户端收到 DNS 查询后,递归解析器将使用缓存的数据进行响应,或向 Root 域名服务器发送请求,接着向 TLD 域名服务器发送另一个请求,然后向权威性域名服务器发送最后一个请求。收到来自权威性域名服务器的响应后,递归解析器将向客户端发送响应。

递归查询

为了满足访问加速、私有(内部)域名、防止 DNS 劫持、智能路由等需求,实际生产环境中会有多级的递归解析器。递归解析器会缓存上游 DNS 服务的查询记录,并根据配置转发未命中缓存的 DNS 查询请求给上游 DNS 服务。

以公有云 VPC 为例,可以在主机部署 node-local-dns,在 Kubernetes 集群部署 CoreDNS,在 VPC 内使用 AWS Route 53 等 DNS 服务。整体效果,如下图:

当 Kubernetes 集群内的容器进行 DNS 解析时,请求首先被转发给主机的 DNS 服务,在未命中缓存是逐级转发给上游的递归解析器。最后一级递归解析器,通过迭代查询返回解析结果。

递归解析器

使用 CoreDNS 搭建域名服务,配置如下:

# Corefile
.:53 {
    log
    errors
    forward . 192.168.65.7    # 未命中私有域名、缓存的请求转发给主机 DNS
    file /etc/coredns/db/example.com example.com # 私有域名
    cache 30  # 缓存 30 秒
    loop
    reload
    loadbalance round_robin # 充当循环DNS负载均衡器,随机响应中 A、AAAA 和 MX 记录的顺序。

}

# /etc/coredns/db/example.com
# www.example.com 两条 A 记录, 两 IP 均为 mock IP
# www.cname.example.com CNAME 到 www.example.com
...
www    IN A     192.168.8.7 
www    IN A     192.168.8.8
www.cname    IN CNAME  www
...

CoreDNS 通过 forward 插件实现递归查询;loadbalance 插件实现轮询 DNS;cache 插件根据域名和记录进行缓存。

使用 dig 验证私有域名 www.example.comwww.cname.example.comserverfault.com,可以看到正常解析,响应中 IP 顺序随机:

$> dig -p 53 @127.0.0.1 +noall +answer  www.example.com      
www.example.com.	21	IN	A	192.168.8.8
www.example.com.	21	IN	A	192.168.8.7

$> dig -p 53 @192.168.3.2 +noall +answer  www.cname.example.com
www.cname.example.com.	18	IN	CNAME	www.example.com.
www.example.com.	18	IN	A	192.168.8.7
www.example.com.	18	IN	A	192.168.8.8

$> dig -p 53 @127.0.0.1 +noall +answer  serverfault.com
serverfault.com.	30	IN	A	104.18.23.101
serverfault.com.	30	IN	A	104.18.22.101

轮询 DNS

统计 serverfault.com 返回记录的首位结果:

$> for i in $(seq 1 10); do  dig +short serverfault.com | head -n 1; done | sort | uniq -c
      4 104.18.22.101
      6 104.18.23.101

即使排除缓存失效再缓存的干扰,CoreDNS 结果也并不总是 5:5,看起来与想象的 round-robin 不同。

深入 CoreDNS loadbalance 插件的源代码,可以看到:

  • 仅对 MX 和 A、AAAA 记录 round-robin shuffle
  • A、AAAA 记录会合并到一起 round-robin shuffle
func roundRobin(in []dns.RR) []dns.RR {
	cname := []dns.RR{}
	address := []dns.RR{}
	mx := []dns.RR{}
	rest := []dns.RR{}
	for _, r := range in {
		switch r.Header().Rrtype {
		case dns.TypeCNAME:
			cname = append(cname, r)
		case dns.TypeA, dns.TypeAAAA: // IPv4, IPv6
			address = append(address, r)
		case dns.TypeMX:
			mx = append(mx, r)
		default:
			rest = append(rest, r)
		}
	}

	roundRobinShuffle(address)
	roundRobinShuffle(mx)

	out := append(cname, rest...)
	out = append(out, address...)
	out = append(out, mx...)
	return out
}

再看 roundRobinShuffle 的实现,可以看到排序规则:根据随机的消息 ID 做 random_shuffle(随机排列组合),而非像击球队伍中的运动员一样:每个人都轮到一次,然后移到队伍的后面

func roundRobinShuffle(records []dns.RR) {
	switch l := len(records); l {
	case 0, 1:
		break
	case 2:
		if dns.Id()%2 == 0 {
			records[0], records[1] = records[1], records[0]
		}
	default:
		for j := 0; j < l; j++ {
			p := j + (int(dns.Id()) % (l - j))
			if j == p {
				continue
			}
			records[j], records[p] = records[p], records[j]
		}
	}
}

// Id by default returns a 16-bit random number to be used as a message id. The
// number is drawn from a cryptographically secure random number generator.
// This being a variable the function can be reassigned to a custom function.
// For instance, to make it return a static value for testing:
//
//	dns.Id = func() uint16 { return 3 }
var Id = id

// id returns a 16 bits random number to be used as a
// message id. The random provided should be good enough.
func id() uint16 {
	var output uint16
	err := binary.Read(rand.Reader, binary.BigEndian, &output)
	if err != nil {
		panic("dns: reading random id failed: " + err.Error())
	}
	return output
}

从 Wiki 的解释可以看出来,此 Round-robin 是指排列组合,更类似于 Random

The order in which IP addresses from the list are returned is the basis for the term round robin. With each DNS response, the IP address sequence in the list is permuted.  – Round-robin DNS

缓存插件

cache [TTL] [ZONES...]
  • TTL:最大TTL(秒)。如果未指定,将使用最大 TTL,对于 NOERROR 响应为 3600,对于拒绝存在的响应为 1800。将 TTL 设置为 300 : cache 300 将缓存最多 300 秒的记录。
  • ZONE:它应该缓存的区域。如果为空,则使用配置块中的区域。

缓存中的每个元素都根据其 TTL 进行缓存(TTL为最大值)。缓存有 256 个 Shard,默认情况下每 Shard 最多保存 39 条数据,总大小为 256*39=9984 条数据。

域名服务

如果一个域名有多条 A 记录,当发送 DNS 请求时:

  1. DNS 服务是否会返回全部记录?
  2. DNS 服务会以什么顺序返回记录?

由于 RFC 缺少相关的规定,在传输协议的范围内,不同的名称服务器有不同的路由策略。两者共同决定了返回的记录和顺序

传输协议

大多数 DNS RFC1034 请求通过 UDP RFC 768 进行。IPv4规定主机必须能够重组 少于等于 576 字节的数据包,包含 IPv4 报头和 8 字节 UDP报头。

因此基于 UDP 的 DNS ,有效载荷限制为小于 512 字节,保证了如果 DNS 数据包在传输中被分段,可以重新组装,降低数据包被随机丢弃的可能性。超过 512 字节的响应将被截断,解析器必须通过 TCP 重新发出请求。

如果解析器支持 EDNS0,也可以通过 UDP 响应最多 4096 字节,且不会被截断。

路由策略

常见的一种路由策略设置是:轮询 DNS

当查询有多条记录时,名称服务器执行循环 DNS。在一个请求和下一个请求时,发送响应的顺序会有所不同。大多数客户端将连接到第条记录,因此可以实现负载平衡。

分别使用 8.8.8.8 和 CoreDNS 分别作为名称服务器。前者直接解析返回,后者配置 loadbalance round_robin shuffle 返回。

loadbalance [round_robin | weighted WEIGHTFILE] { reload DURATION }

查看 serverfault.com 返回记录的顺序,可以看到响应首位的结果差异

$> dig +short serverfault.com
104.18.23.101
104.18.22.101

# 8.8.8.8
$> for i in $(seq 1 10); do  dig +short serverfault.com | head -n 1; done | sort | uniq -c
     10 104.18.23.101

# CoreDNS: round-robin
 $> for i in $(seq 1 10); do  dig +short serverfault.com | head -n 1; done | sort | uniq -c
      4 104.18.22.101
      6 104.18.23.101

除了 CoreDNS 的 round-robinAWS route 53 之类的 DNS 服务提供了更多路由策略,常见:

  • Geolocation routing policy
  • IP-based routing policy
  • Weighted routing policy

值得注意的是,由于 CoreDNS 等下游递归解析器,在启用缓存时,并不感知上游的路由策略,因此会导致上游策略失效,甚至导致缺陷。

假设,上游域名服务随机返回部分 IP,该部分 IP 会持续缓存直至缓存失效。在缓存失效前所有请求都会集中到该部分 IP,导致较为严重的访问倾斜。

Resolver 库

在 Linux 上并不存在一个 syscall 用于域名解析,实际上大多数程序是通过一个 C 标准库调用 getaddrinfo 完成的。

dig 、nslookup 等,是查询 DNS 域名服务的工具,因此没有调用 resolver 库

通过 strace 命令可以看到执行的部分细节:

$> strace -e trace=openat -f ping -c1 serverfault.com
openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libcap.so.2", O_RDONLY|O_CLOEXEC) = 3
openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libidn2.so.0", O_RDONLY|O_CLOEXEC) = 3
openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libc.so.6", O_RDONLY|O_CLOEXEC) = 3
openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libunistring.so.2", O_RDONLY|O_CLOEXEC) = 3
openat(AT_FDCWD, "/etc/nsswitch.conf", O_RDONLY|O_CLOEXEC) = 5
openat(AT_FDCWD, "/etc/host.conf", O_RDONLY|O_CLOEXEC) = 5
openat(AT_FDCWD, "/etc/resolv.conf", O_RDONLY|O_CLOEXEC) = 5
openat(AT_FDCWD, "/etc/hosts", O_RDONLY|O_CLOEXEC) = 5
openat(AT_FDCWD, "/etc/gai.conf", O_RDONLY|O_CLOEXEC) = 5
PING serverfault.com (104.18.22.101) 56(84) bytes of data.
openat(AT_FDCWD, "/etc/hosts", O_RDONLY|O_CLOEXEC) = 5
64 bytes from 104.18.22.101 (104.18.22.101): icmp_seq=1 ttl=62 time=68.6 ms

可以看到依次读取了 /etc/nsswitch.conf/etc/host.conf/etc/resolv.conf /etc/gai.conf 四个配置文件, DNS 解析的策略也跟他们相关。通过 POSIX 文档,可以了解四个配置文件的作用

nsswitch.conf

Name Service Switch (NSS) 配置文件,管理了各种信息来源的类别和顺序。每一行可以当做是一个数据库,冒号前面的是信息类型,冒号后面是数据来源或服务。 举例:

...

hosts:          files dns
networks:       files

...

域名解析时,gethostbyname 会读取 hosts 一行,并从 files 和 dns 两个来源依次获取数据:

  • /lib/libnss_files.so.X:实现了 “files” 数据源,读取本地文件:/etc/hosts
  • /lib/libnss_dns.so.X:实现 “dns” 数据源,访问远端 DNS 服务。

相比于固定搜索顺序的硬编码, NSS 提供了一种更灵活的方法可以动态更新搜索顺序,插件化的增减来源。

host.conf

host.conf 包含了为解析库声明的配置信息. 每行含一个配置关键字,其后跟着合适的配置信息.。举例:

# The "order" line is only used by old versions of the C library.
order hosts,bind
multi on
  • order:管理解析顺序。表示先使用 /etc/hosts 文件,再使用 name server 解析。bind(Berkeley Internet Name Domain),一种开源 DNS 协议实现。(仅 glibc 2.4及更早版本生效,更新版本见 NSS
  • multi on:允许主机名对应多个 IP 地址,如果机器有多张网卡,就设置为 on

resolv.conf

resolv.conf 是解析器的核心配置,举例:

$> cat /etc/resolv.conf
options rotate     
options timeout:2  
options attempts:3  
options single-request-reopen
nameserver 8.8.4.4
nameserver 8.8.8.8

其配置项既要满足解析的基本要求:

  1. 首先,在发起查询前要填补 local domain 得到 FQDN (Fully Qualified Domain Name 全限定域名): searchndots:n
  2. 其次,有多个 nameserver 时,需要定义查询选择的 nameserver 策略: nameserverrotate

配置 rotate
- 以 Round Robin 的形式挑选 nameserver,而非每次都选择第一个,起到负载均衡的的作用。一次性请求的工具不生效,因为只有一次请求。

不配置 rotate 时
- 首先使用第一个 nameserver
- 如果请求成功,永远不会继续尝试后续的 nameserver
- 如果请求失败且尚未超时,则继续使用后续 nameserver,直至成功

  1. 再次,既然是远程调用,更要控制好请求超时时间,以及出错时的重试次数: timeoutattempts
  2. 最后,支持对返回的多个结果排序: sortlist

也要兼容历史变迁的沧桑:

  1. 首先,要兼容 IPv4 和 IPv6
  2. 其次,数据包过大时,可以 TCP 解析: use-vc
  3. 最后,兼容种种历史缺陷: single-request-reopensingle-request

gai.conf

调用 getaddrinfo 可能会返回多个结果。根据 rfc3484 / rfc6724 的要求,需要根据根据来源 IP 与结果 IP 进行最长匹配排序,以便相同子网里的 IP 在列表中排在首位,以得到成功率最高的结果。当然相关排序机制也可以通过 /etc/gai.conf 配置控制。

示例:

IPv4/IPv6双栈网络下配置IPv4链路优先

换句话说,按照最新规范,DNS 解析返回的结果应当是固定顺序的,而非 round-robin,那么当 DNS server 返回 round-robin 的结果时,就会因为解析器的排序而不生效,导致新旧版本 library 之间行为不一。

最新的规范的前提都是 IPv6,然而 IPv6 到目前位置支持的并不理想,并且考虑基于兼容性的考虑:当返回结果中仅有 IPv4 时,不适用最长匹配相关的规则,也就不会调整结果的相对顺序(稳定排序)

Dial:连接创建

func Dial(network, address string) (Conn, error)

Golang 创建连接时,使用 Dial 连接到 named network 的地址。

已知 network 类型有:

  • TCP:”tcp”、”tcp4” (IPv4-only)、”tcp6” (IPv6-only)
  • UDP:”udp”、”udp4” (IPv4-only)、”udp6” (IPv6-only)
  • IP:”ip”、”ip4” (IPv4-only)、”ip6” (IPv6-only)
  • Unix domain socket:”unix”, “unixgram” and “unixpacket”.

Golang 默认使用双栈(IPv4&IPv6)DNS 解析,当 IPV6 不能访问时,支持 IPv6 的程序需要延迟几秒钟才能正常切换到 IPv4,为了不影响用户体验可以指定 network 为 tcp4,直接禁用 IPv6。

总结

综述,一次 DNS 解析,如果指定 network 为 TCP,在启用 IPv6 时:

  1. Golang Resolver 会并发发出 IPv4 和 IPv6 DNS 查询请求。查询的域名服务节点是 /etc/resolv.conf 指定的递归解析器,策略:详见 resolv.conf 节
  2. 递归解析器如果从缓存中发现结果,则直接使用,否则递归查询上游的域名服务,并将结果缓存。得到结果之后,再根据路由策略返回。每一级域名服务均如是
  3. Golang net.Dial 选择 IP 列表中的第一个 IP 建立连接

DNS 本身作为服务发现,通过轮询 DNS 提供了最基本的负载分配功能,而不能保证完美的负载均衡。对负载有极致需求的业务,建议自行负载均衡,策略参考:

  1. 动态(定时)更新 DNS 对应的 IP 列表
  2. 根据负载均衡策略从 IP 列表中选择合适的 IP
  3. 根据 IP 从连接池中获取连接,发起请求

备注:由于 Linux 发行版本众多,也有多种 Resolver 库、DNS 递归解析器,再叠加复杂的版本历史。因此本文中的众多细节仅供参考,实际情况建议使用 strace、tcpdump、ebpf tools 等工具确认

本文作者 : cyningsun
本文地址https://www.cyningsun.com/10-08-2023/dive-into-dns-resolution.html
版权声明 :本博客所有文章除特别声明外,均采用 CC BY-NC-ND 3.0 CN 许可协议。转载请注明出处!

# Network