线上的 go 应用运行在 k8s 里环境里,在与 redis 建连时,经常报如下错误: dial tcp 172.17.2.214:6379: i/o timeout,dial tcp: i/o timeout 。如果 redis 的地址是 host 域名,而不是 IP 时,首先会进行 DNS 解析,解析完成在进行建连。上面的报错都是建连时产生的,报错形式不一样,说明报错的原因并不一样。本文从代码层面分析下这个过程,通过分析 redis 建连流程,顺便分析下在 k8s 里,DNS 解析有什么不一样的行为。
redis 建连流程分析
golang 里标准库有完整的网络库。一般会使用 net.Dialer 来进行建立连接。 net.Dialer 有一系列的参数选项来控制建连行为。 Timeout 设置建连的超时时间,Deadline 则是一个时间点,一般这样 Deadline = 当前时间点 + Timeout 来设置。net.Dialer 有 Dial 方法来进行实际的建连操作。
我们使用 redigo 库来进行 redis 操作。redigo 提供了 redis pool 使用, 使用方式如下(简化的代码)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
dialFunc := func() (redis.Conn, error) { addr := fmt.Sprintf("%s:%d", Host, Port) conn, err := redis.DialTimeout("tcp", addr, connectTimeout, readTimeout, writeTimeout) if err != nil { return nil, err } return conn, nil } pool := &redis.Pool{ MaxIdle: MaxIdle, IdleTimeout: 180 * time.Second, Dial: dialFunc, } |
当 redis pool 生成新的 redis connection 时, 会调用 Dial 方法,也就是上面的自定义的 dialFunc 方法。真正的连接由 redis.DialTimeout 返回, 方法里面设置了三个时间,连接时间,读超时和写超时。
进入到 redigo/redis/conn.go 里,查看 DialTimeout 的具体实现。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
func DialTimeout(network, address string, connectTimeout, readTimeout, writeTimeout time.Duration) (Conn, error) { return Dial(network, address, DialConnectTimeout(connectTimeout), DialReadTimeout(readTimeout), DialWriteTimeout(writeTimeout)) } // 在建连阶段,只需要关注 connectTimeout // 这里的 do.dialer 就是 net.Dialer, 而 connectTimeout 会设置成 net.Dailer 的 Timeout func DialConnectTimeout(d time.Duration) DialOption { return DialOption{func(do *dialOptions) { do.dialer.Timeout = d }} } |
具体的逻辑封装到 Dial 逻辑里。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
do := dialOptions{ dialer: &net.Dialer{ KeepAlive: time.Minute * 5, }, } for _, option := range options { option.f(&do) } if do.dial == nil { // do.dialer.Dial 实际是 net.Dialer 的 Dial 方法 do.dial = do.dialer.Dial } netConn, err := do.dial(network, address) if err != nil { return nil, err } |
到这里,redis 的建连的流程由 net.Dialer 的 Dial 方法来处理。net.Dialer 中的 Timeout 值被设置成 connectTimeout。
net.Dialer 的流程分析
这里会进入到 golang 的源码 net 库里,进一步分析 Dial 的方法流程。
在 src/net/dial.go 文件里,看到 Dial 进一步调用了 DialContext 方法。
DialContext 首先是构造 context.Context, 简单理解就是根据 Timeout 设置 deadline, 把 deadline 设置成为 Context 的 deadline, 然后整个方法调用都会传递这个 Context,如果超时了,就会及时返回超时错误。
1 2 3 4 5 6 |
// 发起 DNS 解析 addrs, err := d.resolver().resolveAddrList(resolveCtx, "dial", network, address, d.LocalAddr) // 超时报错返回 if err != nil { return nil, &OpError{Op: "dial", Net: network, Source: nil, Addr: nil, Err: err} } |
这里可以看到 dns 解析失败的时候,会返回 OpError. 这里 Op 为 dial, 而源地址 Source, 目的地址 Addr 都是 nil. OpError 定义可以在 net.go 里找到。查看 Error 方法,就清楚报错是什么形式了。 这里如果解析超时的话,会报 dial tcp: i/o timeout 这个错误。
dial tcp 172.17.2.214:6379: i/o timeout 那这个错误就是 dns 已经解析成功,但是连接阶段超时了。这里的超时时间就是 redis 设置的 connectTimeout,是包含 dns 解析时间 + 服务建连时间。
后面重点关注下 resolveAddrList 这里的解析逻辑。
DNS 解析逻辑
https://blog.bruceding.me/516.html 这里有过一次 DNS 的解析分析。这里的分析路径是不同的。
resolveAddrList 这里首先解析 network, 在 redis 的场景里就是 tcp 。然后进入 internetAddrList 方法。 resolveAddrList 这里的返回值是 addrList, 是一个地址列表,是至少包含一个地址的。
internetAddrList 在 ipsock.go 文件里。首先解析 address 地址,拆解成 host, port 。 如果 port 是一个 service 形式(比如说 http) , 通过 LookupPort 找到映射关系,变成 portnum int 值。 后续要查找 host 对应的地址。是通过 lookupIPAddr 方法来实现。
lookupIPAddr 在 lookup.go 文件里。如果 host 本身是 ip 形式的话,到这里就处理完成了。不需要 DNS 解析。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
func (r *Resolver) lookupIPAddr(ctx context.Context, network, host string) ([]IPAddr, error) { ... // 这里是解析 host ,如果 host 本身是 ip 形式,直接返回,不用解析 if ip, zone := parseIPZone(host); ip != nil { return []IPAddr{{IP: ip, Zone: zone}}, nil } ... // The underlying resolver func is lookupIP by default but it // can be overridden by tests. This is needed by net/http, so it // uses a context key instead of unexported variables. // 这里指定默认的解析方法是 lookupIP resolverFunc := r.lookupIP if alt, _ := ctx.Value(nettrace.LookupIPAltResolverKey{}).(func(context.Context, string, string) ([]IPAddr, error)); alt != nil { resolverFunc = alt } // We don't want a cancellation of ctx to affect the // lookupGroup operation. Otherwise if our context gets // canceled it might cause an error to be returned to a lookup // using a completely different context. However we need to preserve // only the values in context. See Issue 28600. lookupGroupCtx, lookupGroupCancel := context.WithCancel(withUnexpiredValuesPreserved(ctx)) // 这里是一个优化,如果同一个 host 同时多次解析,相同的 lookupKey 只会解析一次 lookupKey := network + "\000" + host dnsWaitGroup.Add(1) ch, called := r.getLookupGroup().DoChan(lookupKey, func() (interface{}, error) { defer dnsWaitGroup.Done() return testHookLookupIP(lookupGroupCtx, resolverFunc, network, host) }) ... |
在 lookup_unix.go 文件里找到 lookupIP 实现。go 在解析 dns 上,有两种方式,go 自身的实现和 cgo 的方式。go 实现会运行在 goroutine 里, cgo 需要占用系统的线程。默认是使用 go 自身的实现。lookupIP 一开始就进行了判断。在 dnsclient_unix.go 里找到 goLookupIP 实现。
goLookupIP 主要逻辑由 goLookupIPCNAMEOrder 实现。首先通过 goLookupIPFiles 来检查 /etc/hosts 文件,看是否 host 是否有配置,如果找到了 ip 信息就直接返回了。然后加载 /etc/resolv.conf 配置信息。这个文件控制了 DNS 解析的行为。配置的具体说明可以参考 /etc/resolv.conf 。这个文件的具体解析参考 dnsconf_unix.go 里的 dnsConfig 结构。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
// 解析的类型,分别是 ip4, ip6 格式 qtypes := [...]dnsmessage.Type{dnsmessage.TypeA, dnsmessage.TypeAAAA} // 定义查询方法 var queryFn func(fqdn string, qtype dnsmessage.Type) // 定义结果返回处理方法 var responseFn func(fqdn string, qtype dnsmessage.Type) result // resolv.conf 如果配置了 single-request" 或者 "single-request-reopen // singleRequest 为 true if conf.singleRequest { queryFn = func(fqdn string, qtype dnsmessage.Type) {} responseFn = func(fqdn string, qtype dnsmessage.Type) result { dnsWaitGroup.Add(1) defer dnsWaitGroup.Done() // tryOneName 解析单一的 qtype p, server, err := r.tryOneName(ctx, conf, fqdn, qtype) return result{p, server, err} } } else { queryFn = func(fqdn string, qtype dnsmessage.Type) { dnsWaitGroup.Add(1) go func(qtype dnsmessage.Type) { p, server, err := r.tryOneName(ctx, conf, fqdn, qtype) lane < - result{p, server, err} dnsWaitGroup.Done() }(qtype) } responseFn = func(fqdn string, qtype dnsmessage.Type) result { return <-lane } } |
singleRequest 这里的控制行为是,是依次解析 ip4, ip6 的地址,还是同时发起解析。两种情况提供的 queryFn,
responseFn 不同。同时解析是通过 go + channel 实现。具体的解析调用 tryOneName 实现。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
// 根据要解析的 name 和 resolv.conf, 可能返回多个域名地址,分别解析 for _, fqdn := range conf.nameList(name) { for _, qtype := range qtypes { queryFn(fqdn, qtype) } hitStrictError := false for _, qtype := range qtypes { result := responseFn(fqdn, qtype) if result.error != nil { if nerr, ok := result.error.(Error); ok && nerr.Temporary() && r.strictErrors() { // This error will abort the nameList loop. hitStrictError = true lastErr = result.error } else if lastErr == nil || fqdn == name+"." { // Prefer error for original name. lastErr = result.error } continue } |
这里可以看到 conf.nameList(name) 可能会返回多个地址需要 dns 解析,这个后面详细分析。这里假设只返回一个地址需要解析。后面有两个 for 循环,分别对应调用 queryFn, responseFn。循环的列表是 qtypes ,包含 ip4, ip6 的解析。也就是说,一个地址需要两次解析,分别是ip4, ip6 的解析。当顺序解析时,singleRequest 为true,queryFn 为空函数, 会依次调用 responseFn。而 singleRequest 为 false 时, queryFn 为 goroutine 运行,相当于两次查询同时进行,responseFn 进行结果汇总。singleRequest 决定对于同一个 dns name, ip4, ip6 的解析调用是否并行进行。
再来看下 conf.nameList(name) ,何时会返回多个解析地址。还是在 dnsclient_unix.go 这个文件里能找到其定义。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 |
func (conf *dnsConfig) nameList(name string) []string { if avoidDNS(name) { return nil } // Check name length (see isDomainName). l := len(name) rooted := l > 0 && name[l-1] == '.' if l > 254 || l == 254 && rooted { return nil } // If name is rooted (trailing dot), try only that name. if rooted { return []string{name} } // resolv.conf 文件 options 的 ndots // hasNdots := count(name, '.') >= conf.ndots name += "." l++ // Build list of search choices. // 返回的长度 1 + resolv.conf 中 search 条目数 names := make([]string, 0, 1+len(conf.search)) // If name has enough dots, try unsuffixed first. // 如果 name 中的 dots 数量 >= options 的 ndots 的数值, name 作为第一个解析地址 if hasNdots { names = append(names, name) } // Try suffixes that are not too long (see isDomainName). for _, suffix := range conf.search { if l+len(suffix) < = 254 { // 解析地址拼接, name + resolv.conf 中 search 条目 names = append(names, name+suffix) } } // Try unsuffixed, if not tried first above. // 如果 name 中的 dots 数量 < options 的 ndots 的数值, name 作为最后一个解析地址 if !hasNdots { names = append(names, name) } return names } |
也就是说 host 在 dns 解析的时候,地址并不只是 host, 数量实际上由resolv.conf 的 search 条目决定。要解析的 host 的位置由 dots 数量和 options 的 ndots 值决定的。
我们再看下 具体的 DNS 的解析实现, tryOneName 的具体逻辑。如何具体查询,由 resolv.conf 控制,比如说重试次数,name server 的选择,以及查询的超时时间。从 https://blog.bruceding.me/516.html 这里找到更详细的说明。
小结 DNS 解析流程
主机实验验证
上面已经分析了 DNS 解析的整个流程,我们来做一些实验来看实际情况是怎么样的。
实验代码如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
package main import ( "fmt" "time" "github.com/garyburd/redigo/redis" ) func newPool(host string, port int) (*redis.Pool, error) { dialFunc := func() (redis.Conn, error) { addr := fmt.Sprintf("%s:%d", host, port) conn, err := redis.DialTimeout("tcp", addr, time.Second, time.Second, time.Second) if err != nil { return nil, err } return conn, err } pool := &redis.Pool{ MaxIdle: 10, IdleTimeout: 180 * time.Second, Dial: dialFunc, } c := pool.Get() defer c.Close() return pool, c.Err() } func main() { // 只关注解析逻辑,地址只要能解析到就行 pool, err := newPool("baidu.com", 6379) fmt.Println(pool, err) } |
go build main.go 生成 main 之后, 运行 strace -fT ./main 观察结果。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 |
// 通过 53 端口,连接 100.100.2.136 connect(3, {sa_family=AF_INET, sin_port=htons(53), sin_addr=inet_addr("100.100.2.136")}, 16) = 0 <0.000030> [pid 16412] epoll_ctl(4, EPOLL_CTL_ADD, 3, {EPOLLIN|EPOLLOUT|EPOLLRDHUP|EPOLLET, {u32=3089272584, u64=140212296650504}}) = 0 <0.000016> [pid 16412] getsockname(3, {sa_family=AF_INET, sin_port=htons(53703), sin_addr=inet_addr("172.17.23.238")}, [16]) = 0 <0.000015> [pid 16412] getpeername(3, {sa_family=AF_INET, sin_port=htons(53), sin_addr=inet_addr("100.100.2.136")}, [16]) = 0 <0.000014> [pid 16413] <... nanosleep resumed> NULL) = 0 <0.000264> // 写入 dns 请求信息 [pid 16412] write(3, "\261\260\1\0\0\1\0\0\0\0\0\0\5baidu\3com\0\0\1\0\1", 27 <unfinished ...> [pid 16413] nanosleep({0, 20000}, <unfinished ...> [pid 16412] <... write resumed> ) = 27 <0.000070> [pid 16412] read(3, 0xc000106000, 512) = -1 EAGAIN (Resource temporarily unavailable) <0.000026> [pid 16413] <... nanosleep resumed> NULL) = 0 <0.000129> [pid 16412] epoll_pwait(4, <unfinished ...> [pid 16413] nanosleep({0, 20000}, <unfinished ...> [pid 16412] <... epoll_pwait resumed> [{EPOLLOUT, {u32=3089272584, u64=140212296650504}}], 128, 0, NULL, 0) = 1 <0.000024> [pid 16412] epoll_pwait(4, [{EPOLLIN|EPOLLOUT, {u32=3089272584, u64=140212296650504}}], 128, -1, NULL, 0) = 1 <0.000041> [pid 16413] <... nanosleep resumed> NULL) = 0 <0.000114> // read 读取返回的数据 [pid 16412] read(3, <unfinished ...> [pid 16413] nanosleep({0, 20000}, <unfinished ...> [pid 16412] <... read resumed> "\261\260\201\200\0\1\0\2\0\0\0\0\5baidu\3com\0\0\1\0\1\300\f\0\1\0"..., 512) = 59 <0.000029> [pid 16412] epoll_ctl(4, EPOLL_CTL_DEL, 3, 0xc0000fdd34) = 0 <0.000036> [pid 16413] <... nanosleep resumed> NULL) = 0 <0.000106> [pid 16412] close(3 <unfinished ...> [pid 16413] nanosleep({0, 20000}, <unfinished ...> [pid 16412] <... close resumed> ) = 0 <0.000028> [pid 16412] socket(AF_INET, SOCK_DGRAM|SOCK_CLOEXEC|SOCK_NONBLOCK, IPPROTO_IP <unfinished ...> [pid 16413] <... nanosleep resumed> NULL) = 0 <0.000099> [pid 16412] <... socket resumed> ) = 3 <0.000052> [pid 16413] nanosleep({0, 20000}, <unfinished ...> [pid 16412] setsockopt(3, SOL_SOCKET, SO_BROADCAST, [1], 4) = 0 <0.000038> // 再往下看,会看到另一个 dns 请求 [pid 16412] connect(3, {sa_family=AF_INET, sin_port=htons(53), sin_addr=inet_addr("100.100.2.138")}, 16 <unfinished ...> [pid 16413] <... nanosleep resumed> NULL) = 0 <0.000107> [pid 16412] <... connect resumed> ) = 0 <0.000031> [pid 16413] nanosleep({0, 20000}, <unfinished ...> [pid 16412] epoll_ctl(4, EPOLL_CTL_ADD, 3, {EPOLLIN|EPOLLOUT|EPOLLRDHUP|EPOLLET, {u32=3089272584, u64=140212296650504}}) = 0 <0.000018> [pid 16412] getsockname(3, <unfinished ...> [pid 16413] <... nanosleep resumed> NULL) = 0 <0.000119> [pid 16412] <... getsockname resumed> {sa_family=AF_INET, sin_port=htons(45657), sin_addr=inet_addr("172.17.23.238")}, [16]) = 0 <0.000048> [pid 16413] nanosleep({0, 20000}, <unfinished ...> [pid 16412] getpeername(3, {sa_family=AF_INET, sin_port=htons(53), sin_addr=inet_addr("100.100.2.138")}, [16]) = 0 <0.000017> [pid 16412] write(3, "\242\17\1\0\0\1\0\0\0\0\0\0\5baidu\3com\0\0\34\0\1", 27 <unfinished ...> [pid 16413] <... nanosleep resumed> NULL) = 0 <0.000139> [pid 16413] nanosleep({0, 20000}, <unfinished ...> [pid 16412] <... write resumed> ) = 27 <0.000080> [pid 16412] read(3, 0xc000106200, 512) = -1 EAGAIN (Resource temporarily unavailable) <0.000020> [pid 16413] <... nanosleep resumed> NULL) = 0 <0.000111> [pid 16412] epoll_pwait(4, <unfinished ...> [pid 16413] nanosleep({0, 20000}, <unfinished ...> [pid 16412] <... epoll_pwait resumed> [{EPOLLIN|EPOLLOUT, {u32=3089272584, u64=140212296650504}}], 128, 0, NULL, 0) = 1 <0.000038> [pid 16412] read(3, "\242\17\201\200\0\1\0\0\0\1\0\0\5baidu\3com\0\0\34\0\1\300\f\0\6\0"..., 512) = 70 <0.000039> |
在 dns 解析过程中,有两次解析请求。从之前的源码分析可知,这两次请求分别是 ip4, ip6 的解析请求,并且是对同一个 host 的解析。从上面分析看,实际的解析 host 数目是 search 的条目 + 1 。 从这里分析,可以反推出 resolv.conf 没有 search 条目。可以看下机器的 resolv.conf 内容
1 2 3 4 5 |
# cat /etc/resolv.conf options timeout:2 attempts:3 rotate single-request-reopen ; generated by /usr/sbin/dhclient-script nameserver 100.100.2.136 nameserver 100.100.2.138 |
可以看到并没有 search 条目。有两个 nameserver, 在上面的请求中,也都能找到。从配置中可以看到 single-request-reopen , 也就是说 singleRequest 为 true,两次解析应该是顺序的。这个从两次请求的 socket 的 fd 值也可能看出来。两次都是 3, 也就是说,完成了一次请求,才发起了下一次。我们可以把 single-request-reopen 删掉,再看下这个过程。
可以找到类似的内容,两次 connect 几乎是并发发起,当然两次 fd 肯定是不同的。
1 2 3 |
[pid 14108] connect(3, {sa_family=AF_INET, sin_port=htons(53), sin_addr=inet_addr("100.100.2.138")}, 16 <unfinished ...> [pid 14105] connect(5, {sa_family=AF_INET, sin_port=htons(53), sin_addr=inet_addr("100.100.2.136")}, 16 </unfinished><unfinished ...> </unfinished> |
k8s 环境验证
可以把上面的代码放到 k8s 环境,执行,看看有什么不同。
可以在 k8s 里创建 golang 环境,然后运行我们的代码。
kubectl run dns-test -it --image=golang -- bash 可以运行 golang 容器,进入到 golang 环境中。
安装用到的测试软件
1 2 |
apt-get update apt-get install -y strace vim |
在 /go/src 目录下,新建 main.go 文件,就是我们的测试程序。
1 2 |
go get github.com/garyburd/redigo/redis go build main.go |
执行 strace -fT ./main 查看结果。可以搜索 connect 关键字,可以发现有好多次调用。也就是说 dns 解析了多个 host 。 也就是说 search 存在多个条目。看下 /etc/resolv.conf 文件
1 2 3 |
nameserver 10.96.0.10 search default.svc.cluster.local svc.cluster.local cluster.local tbsite.net aliyun.com options ndots:5 |
可以看到 search 有多个条目,而且 ndots 是5。 结合上面的代码分析, hasNdots 是 false 。 那么解析列表就是
- baidu.com.default.svc.cluster.local
- baidu.com.svc.cluster.local
- baidu.com.cluster.local
- baidu.com.tbsite.net
- baidu.com.aliyun.com
- baidu.com
一个简单的 host ,在 k8s 环境里需要解析多次。并且我们想要解析的,放到了最后。
那么如果修改 ndots 为1, hasNdots 就是 true, baidu.com 就变成了第一个解析的,重新运行 strace -fT ./main 是不是不一样的结果了,baidu.com 解析成功后,就直接返回了,不需要进行后面的解析。
看到这里,可以再延伸下, k8s 中的 service 的解析过程,service 全域名是比较长的,但是使用 service name + namespace 当成域名就可以了,实际上,在解析过程中,会拼接上 search 的条目,构成完整的域名来解析。对于 https://kubernetes.io/docs/concepts/services-networking/dns-pod-service/ 这里的内容是不是可以更深刻的理解了。
最后总结
- go 的源码是比较易读的,通过理解源码,然后通过设计实验来论证代码,加深理解
- DNS 的解析流程,是与 /etc/resolv.conf 内容密切相关的
- 在 k8s 里, 有 search 条目,解析过程链路会比较长
- 对于同一个host, ip4, ip6 都是要解析的。存在顺序,并发两种方式
结合上面的 redis 超时问题,通过 k8s 的解析流程,更合理的设计程序
- 充分使用 redis pool, 合理设置大小,减少 DNS 解析过程
- 如果服务的 ip 地址不变,尽量使用 ip 形式,减少 DNS 解析。或者统一放到 /etc/hosts 里
- DNS 结果缓存,程序可以做 DNS 结果缓存,服务连接使用直连 IP 的形式