DNS 做服务发现,是坑吗 ?
遇到过这么一个问题,有童鞋的 Go 程序用 DNS 解析做服务发现(内网用的 CoreDNS 做的域名解析服务器)。比如,内网有个服务域名,对应 7 个后端节点。为了做服务发现,故障的剔除等服务,在 Client 端对一个给定的域名调用 Go 标准库的 Resolver.LookupHost 方法来解析 ip 列表。如果解析得到的 ip 列表有变化,那么在 Client 内对相应的对后端节点的链接做创建和销毁。
addrs, err := resolver.LookupHost(ctx, /*某服务域名 A */)
// addrs 的结果会变化,一会返回 6 个 ip,一会返回 7 个 ip
就是这么一个典型的服务发现的应用场景,还是精准踩坑。那什么坑?
坑就是:解析得到的 ip 列表反复变化,导致反复创、删连接和对应的结构体。让人误以为 DNS 的后端节点一直在故障,从而导致一系列的问题。
还遇到另一个有趣的问题:同一份业务代码,Go 1.15 编译的版本总会频繁截断成 6 个 ip ,Go 1.16 以上的版本则非常稳定,一直返回 7 个 ip ? 这又是为啥呢。
这个问题很简单,但其实也很隐蔽。因为很少人会这么用,也很少人会注意到这个问题。
Go 的 DNS Lookup 的接口语义
先看下 Go 标准库的接口语义,看下 Resolver.LookupHost 在 Go 的注释怎么说的。文件在 Go 的标准库 net/lookup.go :
// LookupHost looks up the given host using the local resolver.
// It returns a slice of that host's addresses.
func (r *Resolver) LookupHost(ctx context.Context, host string) (addrs []string, err error) {
//...
}
LookupHost 查询一个给定的域名,返回值是一个地址列表。注意:它并没有保证,要返回该域名的所有 ip 列表。 所以啊,这本来就是用法不对,Go 的接口没声明说要返回全部的 ip 。哪怕有域名对应有 100 个 ip ,这个接口只返回 1 个也是对的。
Go 1.15 和 Go 1.16之上的区别 ?
域名对应 7 个 ip ,同一份解析代码, Go 1.15 编译的程序时而返回 6 个?但 Go 1.16 之上的版本编译则总是 7 个,感觉非常稳定。为什么呢?
笔者还真翻了一下 Go 1.15 和 Go 1.16 的区别,DNS 解析的代码几乎一致,只在 dnsPacketRoundTrip 函数中,改了一个 buffer 的大小。
Go 1.15 是这样的( 文件:src/net/dnsclient_unix.go ):
func dnsPacketRoundTrip(c Conn, id uint16, query dnsmessage.Question, b []byte) (dnsmessage.Parser, dnsmessage.Header, error) {
// 发送请求
if _, err := c.Write(b); err != nil {
}
// 创建一个装响应包的 buffer
b = make([]byte, 512) // see RFC 1035
for {
// 读取 dns 响应
n, err := c.Read(b)
// ...
return p, h, nil
}
}
Go 1.16 是这样的( 文件:src/net/dnsclient_unix.go ):
const (
// Maximum DNS packet size.
// Value taken from https://dnsflagday.net/2020/.
maxDNSPacketSize = 1232
)
func dnsPacketRoundTrip(c Conn, id uint16, query dnsmessage.Question, b []byte) (dnsmessage.Parser, dnsmessage.Header, error) {
// 发送请求
if _, err := c.Write(b); err != nil {
}
// 创建一个装响应包的 buffer
b = make([]byte, maxDNSPacketSize)
for {
// 读取 dns 响应
n, err := c.Read(b)
// ...
return p, h, nil
}
}
函数逻辑是发送请求给 DNS Server ,并等待它的响应。两个版本完全一致,只有 buffer 的大小不一样,Go 1.16 之上用了 1232 这个大小。请注意,这个大小其实是有讲究的,这个值是在尽量避免 IP 包分片又能尽量多装数据而拍的一个值。详细看 DNS FLAG DAY 2020[1] 。
这就是 Go 1.15 ,Go 1.16 版本在内网域名解析中的差异。DNS 服务端虽然发了 7 个 ip 过来,但是 Go 1.15 编译的版本用 512 个字节 buffer 装不下,只解析到 6 个有效 ip,Go 1.16 版本则好点,客户端用的 1232 个字节的 buffer 大一点,差别就在这个地方。
这里有个细节提一下:
DNS 的协议,Message 的 Header 有四个字段 QDCOUNT,ANCOUNT,NSCOUNT,ARCOUNT,是指明了数据包里各个 Record 有多少个,Answer 有多少个的。但是在协议实现的时候,往往不依赖于这几个字段,因为它们可能被伪造攻击。所以解析的 ip 列表都是按照实际解析结果来的,解析到多少个就多少个,而不是 Header 里声明了多少个。
// Qdcount, Ancount, Nscount, Arcount can't be trusted, as they are
// attacker controlled.
简单说下 DNS
DNS 协议默认使用 UDP 协议作为其传输层协议。UDP 的数据包是有限制的,DNS 的消息大小也是有限制的,基本大小限制为 512 字节,长消息会被截断并且设置标记位。
所以,DNS 协议本身就从来没承诺过,给你解析完整的 IP 列表。它这个名字对应的 IP 而已,至于全不全,它从没承诺过。
本文并不详细介绍 DNS 协议的原理。详细见 RFC 1035[2] 和相关的文档。为了突破数据包大小,或者其他的限制,也有扩展协议 EDNS ,可以参考 RFC 6891[3] 。
总之,用 DNS 解析 ip 这种方式来替代 consul 这种服务发现。感觉还是有欠缺的,或者说它的使用场景是不一样的。
总结
Go 默认的解析方式其实有两种,一种是 cgo 的方式调用 libc 库的函数去解析,一种是 Go 自己的实现。本文讨论的是默认的 Go 的方式。 DNS 做服务发现好像并不合适,和 consul 等组件不同。它有自己特定的协议约束着,如果一定要用 DNS 来做服务发现,那么请千万要注意本文提到的知识点。 DNS 解析的 ip 列表,并不承诺它是全的。如果业务想用来做服务发现和剔除的功能,请千万牢记。 DNS 服务端和客户端的行为配合缺一不可,服务端会不会发全部?客户端能不能收全部?各种差异都会导致解析出来的 ip 可能不一样。
参考资料
DNS FLAG DAY 2020: https://dnsflagday.net/2020/
[2]RFC 1035: https://www.rfc-editor.org/rfc/rfc1035
[3]RFC 6891: https://www.rfc-editor.org/rfc/rfc6891
后记
本文分享的是 Go 编程一个非常小的知识点,Lookup 域名解析,你对它的期望是什么?点赞、在看 是对我们最大的支持。
- EOF -
↓推荐关注↓
「Go 开发大全」参与维护一个非常全面的Go开源技术资源库。日常分享 Go, 云原生、k8s、Docker和微服务方面的技术文章和行业动态。关注后回复 Go 获取6万star的Go资源库
点赞和在看就是最大的支持❤️
微信扫码关注该文公众号作者