最近线上服务经常会出现异常,从错误日志来看是因为域名解析失败导致的,我们在 /etc/resolv.conf 中配置了多个域名服务器,第一个是内网的,用于解析内网域名,如果是外网域名,则会通过其他的域名服务器进行解析,按道理来说应该不会有问题,但是最近却频繁发生这样的故障,为了彻底解决问题,特意研究了一下 golang 中进行 dns 查询的源码并最终解决了此问题。
背景
nameserver 配置
/etc/resolv.conf
中配置了多个 nameserver
nameserver 10.10.100.3
nameserver 114.114.114.114
nameserver 8.8.8.8
10.10.100.3
用于解析内网域名,外网域名通过 114.114.114.114
或者 8.8.8.8
来解析。
测试代码
package main
import (
"net"
"fmt"
)
func main() {
hostname := "www.baidu.com"
addrs, err := net.LookupHost(hostname)
if err != nil {
fmt.Printf("lookup host error: %v\n", err)
} else {
fmt.Printf("addrs: %v", addrs)
}
}
结果
lookup host error: lookup www.baidu.com on 10.10.100.3:53: no such host
使用 go1.5 版本进行编译,发现程序并没有按照预想的过程来解析,通过 10.10.100.3
无法解析后就直接返回了错误信息。
而使用 go1.4 版本编译运行后,确得到了正确的结果。
调试标准库的方法
调试 golang 的标准库非常简单,先找到标准库源码的存放位置,然后将要修改的文件备份一份,之后直接在其中添加输出语句,大部分可以 import "fmt"
后使用 fmt.Printf
函数进行输出,有的包中需要使用其他方式,避免循环引用,这里不详述,因为我们要改的 net
包并不涉及这个问题,注意调试完之后将标准库的文件恢复。
查找标准库所在的目录
执行 go env
查看 go 的环境变量如下:
GOARCH="amd64"
GOBIN=""GOCHAR="6"GOEXE=""GOHOSTARCH="amd64"GOHOSTOS="linux"GOOS="linux"
GOPATH="/home/wcl/go_projects"
GORACE=""
GOROOT="/usr/lib/golang"
GOTOOLDIR="/usr/lib/golang/pkg/tool/linux_amd64"
CC="gcc"
GOGCCFLAGS="-fPIC -m64 -pthread -fmessage-length=0"
CXX="g++"
CGO_ENABLED="1"
GOROOT 的值即是标准库所在的目录,net
包的具体路径为 /usr/lib/golang/src/net
go 1.4 与 1.5 版本中 dns 查询逻辑的不同
因为最近很多程序都是使用 go1.5 版本进行编译的,所以理所当然查看了两个版本这部分源码的区别,还真的有所改变。
标准库对外暴露的 dns 查询函数是 func LookupHost(host string) (addrs []string, err error)
(net/lookup.go)
这个函数会调用实际处理函数 lookupHost
(net/lookup_unix.go)
cgo 与纯 go 实现的 dns 查询
go1.4 版本源码
func lookupHost(host string) (addrs []string, err error) {
addrs, err, ok := cgoLookupHost(host)
if !ok {
addrs, err = goLookupHost(host)
}
return
}
go1.5 版本源码
func lookupHost(host string) (addrs []string, err error) {
order := systemConf().hostLookupOrder(host)
if order == hostLookupCgo {
if addrs, err, ok := cgoLookupHost(host); ok {
return addrs, err
}
// cgo not available (or netgo); fall back to Go's DNS resolver
order = hostLookupFilesDNS
}
return goLookupHostOrder(host, order)
}
可以明显的看到 1.4 的源码中默认使用 cgo 的方式进行 dns 查询(这个函数最终会创建一个线程调用c的 getaddrinfo 函数来获取 dns 查询结果),如果查询失败则会再使用纯 go 实现的查询方式。
而在 1.5 的源码中,这一点有所改变,cgo 的方式不再是默认值,而是根据 systemConf().hostLookupOrder(host)
的返回值来判断具体使用哪种方式。这个函数定义在 net/conf.go 中,稍微看了一下, 除非通过编译标志强制使用 cgo 方式或者在某些特定的系统上会使用 cgo 方式,其他时候都使用纯 go 实现的查询方式。
cgo 的方式没有问题,看起来程序会并发地向 /etc/resolv.conf
中所有配置的域名服务器发送 dns 解析请求,然后将最先成功响应的结果返回。
纯 go 实现的 dns 查询分析
问题就出在纯 go 实现的查询上,主要看一下 go1.5 的实现。
函数调用逻辑如下:
LookupHost (net/lookup.go)
lookupHost (net/lookup_unix.go)
goLookupHostOrder (net/dnsclient_unix.go)
goLookupIPOrder (net/dnsclient_unix.go)
tryOneName (net/dnsclient_unix.go)
大部分实现代码在 net/dnsclient_unix.go
这个文件中。
重点看一下 tryOneName
这个函数
func tryOneName(cfg *dnsConfig, name string, qtype uint16) (string, []dnsRR, error) {
if len(cfg.servers) == 0 {
return "", nil, &DNSError{Err: "no DNS servers", Name: name}
}
if len(name) >= 256 {
return "", nil, &DNSError{Err: "DNS name too long", Name: name}
}
timeout := time.Duration(cfg.timeout) * time.Second
var lastErr error
for i := 0; i < cfg.attempts; i++ {
for _, server := range cfg.servers {
server = JoinHostPort(server, "53")
msg, err := exchange(server, name, qtype, timeout)
if err != nil {
lastErr = &DNSError{
Err: err.Error(),
Name: name,
Server: server,
}
if nerr, ok := err.(Error); ok && nerr.Timeout() {
lastErr.(*DNSError).IsTimeout = true
}
continue
}
cname, rrs, err := answer(name, server, msg, qtype)
if err == nil || msg.rcode == dnsRcodeSuccess || msg.rcode == dnsRcodeNameError && msg.recursion_available {
return cname, rrs, err
}
lastErr = err
}
}
return "", nil, lastErr
}
第一层 for 循环是尝试的次数,第二层 for 循环是遍历 /etc/resolv.conf
中配置的所有域名服务器,exchange
函数是发送 dns 查询请求并将响应结果解析到 msg
变量中返回,初看到这里,觉得实现是没问题的,顺序向每一个域名服务器发送 dns 查询请求,如果成功就返回,如果失败就尝试下一个。
问题出现在判断是否成功的那一行代码 if err == nil || msg.rcode == dnsRcodeSuccess || msg.rcode == dnsRcodeNameError && msg.recursion_available
,这里的意思是如果 dns 查询成功,或者出错了但是对方支持递归查询的话,就直接返回,不继续请求下一个域名服务器。如果对方支持递归查询但是仍然没有查到的话,说明上级服务器也没有这个域名的记录,没有必要继续往下查。(这个逻辑在 go1.6 版本中被修改了,出错了以后不再判断是否支持递归查询,仍然尝试向下一个域名服务器发送请求)
msg.rcode
这个值很重要,是问题的关键。
dns 查询协议格式
我们只需要关注首部的12字节。
- ID:占16位,2个字节。此报文的编号,由客户端指定。DNS回复时带上此标识,以指示处理的对应请应请求。
- QR:占1位,1/8字节。0代表查询,1代表DNS回复
- Opcode:占4位,1/2字节。指示查询种类:0:标准查询;1:反向查询;2:服务器状态查询;3-15:未使用。
- AA:占1位,1/8字节。是否权威回复。
- TC:占1位,1/8字节。因为一个UDP报文为512字节,所以该位指示是否截掉超过的部分。
- RD:占1位,1/8字节。此位在查询中指定,回复时相同。设置为1指示服务器进行递归查询。
- RA:占1位,1/8字节。由DNS回复返回指定,说明DNS服务器是否支持递归查询。
- Z:占3位,3/8字节。保留字段,必须设置为0。
- RCODE:占4位,1/2字节。由回复时指定的返回码:0:无差错;1:格式错;2:DNS出错;3:域名不存在;4:DNS不支持这类查询;5:DNS拒绝查询;6-15:保留字段。
- QDCOUNT:占16位,2字节。一个无符号数指示查询记录的个数。
- ANCOUNT:占16位,2字节。一个无符号数指明回复记录的个数。
- NSCOUNT:占16位,2字节。一个无符号数指明权威记录的个数。
- ARCOUNT:占16位,2字节。一个无符号数指明格外记录的个数。
其中 RCODE 是回复时用于判断查询结果是否成功的,对应前面的 msg.rcode
。
bind 的 dns 回复问题
10.10.100.3
上是使用 bind 搭建的本地域名服务器。
使用 dig @10.10.100.3 www.baidu.com
命令查看解析结果如下:
; <<>> DiG 9.8.2rc1-RedHat-9.8.2-0.23.rc1.el6_5.1 <<>> @10.10.100.3 www.baidu.com ;
(1 server found)
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 55909
;; flags: qr rd; QUERY: 1, ANSWER: 0, AUTHORITY: 13, ADDITIONAL: 0
;; WARNING: recursion requested but not available
;; QUESTION SECTION:
;www.baidu.com. IN A
;; AUTHORITY SECTION:
. 518400 IN NS H.ROOT-SERVERS.NET.
. 518400 IN NS K.ROOT-SERVERS.NET.
. 518400 IN NS C.ROOT-SERVERS.NET.
. 518400 IN NS A.ROOT-SERVERS.NET.
. 518400 IN NS B.ROOT-SERVERS.NET.
. 518400 IN NS F.ROOT-SERVERS.NET.
. 518400 IN NS L.ROOT-SERVERS.NET.
. 518400 IN NS D.ROOT-SERVERS.NET.
. 518400 IN NS I.ROOT-SERVERS.NET.
. 518400 IN NS E.ROOT-SERVERS.NET.
. 518400 IN NS G.ROOT-SERVERS.NET.
. 518400 IN NS M.ROOT-SERVERS.NET.
. 518400 IN NS J.ROOT-SERVERS.NET.
;; Query time: 1 msec
;; SERVER: 10.10.100.3#53(10.10.100.3)
;; WHEN: Wed Apr 27 17:35:15 2016
;; MSG SIZE rcvd: 242
bind 并没有返回 www.baidu.com
的 A 记录,而是返回了13个根域名服务器的地址,并且 status 的状态是 NOERROR(这个值就是前述的 RCODE,这里返回0表示没有错误),问题就在这里,没有查到 A 记录还返回 RCODE=0
,回顾一下上面 go 代码中的判断条件
if err == nil || msg.rcode == dnsRcodeSuccess || msg.rcode == dnsRcodeNameError && msg.recursion_available
如果返回的 RCODE 值为 0,则直接退出,不继续尝试后面的域名服务器,从而导致了域名解析失败。
解决方案
仍然使用 go1.4 版本进行编译
不推荐这么做,毕竟升级后在 gc 以及很多其他方面都有优化。
使用 go1.5 及以上版本编译但是通过环境变量强制使用 cgo 的 dns 查询方式
export GODEBUG=netdns=cgo go build
使用 cgo 的方式会在每一次调用时创建一个线程,在并发量较大时可能会对系统资源造成一定影响。而且需要每一个使用 go 编写的程序编译时都加上此标志,较为繁琐。
修改 bind 的配置文件
在 bind 中彻底关闭对递归查询的支持也可以解决此问题,但是由于对 bind 不是很熟悉,具体是什么原因导致没有查到 A 记录但仍然返回 NOERROR 不是很清楚,猜测可能和递归转发的查询方式有关,有可能 bind 认为返回了根域名服务器的地址,client 可以去这些地址上查,所以该次请求并不算做出错。
修改配置文件加上以下内容以后,再次查询时会返回 RCODE=5,拒绝递归查询,这样可以达到我们的目的,查询非内网域名时通过其他域名服务器查询
recursion no;
allow-query-cache { none; };
allow-recursion { none; };
更新
发现在 go1.7 版本中对这个问题做了修复,使用纯 go 实现的 dns 解析方式也已经运行正常。具体信息可以参考 issue 15434。