k8s 里 DNS 解析超时追踪分析

线上的 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 使用, 使用方式如下(简化的代码)

当 redis pool 生成新的 redis connection 时, 会调用 Dial 方法,也就是上面的自定义的 dialFunc 方法。真正的连接由 redis.DialTimeout 返回, 方法里面设置了三个时间,连接时间,读超时和写超时。

进入到 redigo/redis/conn.go 里,查看 DialTimeout 的具体实现。

具体的逻辑封装到 Dial 逻辑里。

到这里,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,如果超时了,就会及时返回超时错误。

这里可以看到 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 解析。

在 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 结构。

singleRequest 这里的控制行为是,是依次解析 ip4, ip6 的地址,还是同时发起解析。两种情况提供的 queryFn,

responseFn 不同。同时解析是通过 go + channel 实现。具体的解析调用 tryOneName 实现。

这里可以看到 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 这个文件里能找到其定义。

也就是说 host 在 dns 解析的时候,地址并不只是 host, 数量实际上由resolv.conf 的 search 条目决定。要解析的 host 的位置由 dots 数量和 options 的 ndots 值决定的。

我们再看下 具体的 DNS 的解析实现, tryOneName 的具体逻辑。如何具体查询,由 resolv.conf 控制,比如说重试次数,name server 的选择,以及查询的超时时间。从 https://blog.bruceding.me/516.html 这里找到更详细的说明。

小结 DNS 解析流程

dns流程

主机实验验证

上面已经分析了 DNS 解析的整个流程,我们来做一些实验来看实际情况是怎么样的。

实验代码如下

go build main.go 生成 main 之后, 运行 strace -fT ./main 观察结果。

在 dns 解析过程中,有两次解析请求。从之前的源码分析可知,这两次请求分别是 ip4, ip6 的解析请求,并且是对同一个 host 的解析。从上面分析看,实际的解析 host 数目是 search 的条目 + 1 。 从这里分析,可以反推出 resolv.conf 没有 search 条目。可以看下机器的 resolv.conf 内容

可以看到并没有 search 条目。有两个 nameserver, 在上面的请求中,也都能找到。从配置中可以看到 single-request-reopen , 也就是说 singleRequest 为 true,两次解析应该是顺序的。这个从两次请求的 socket 的 fd 值也可能看出来。两次都是 3, 也就是说,完成了一次请求,才发起了下一次。我们可以把 single-request-reopen 删掉,再看下这个过程。

可以找到类似的内容,两次 connect 几乎是并发发起,当然两次 fd 肯定是不同的。

k8s 环境验证

可以把上面的代码放到 k8s 环境,执行,看看有什么不同。

可以在 k8s 里创建 golang 环境,然后运行我们的代码。

kubectl run dns-test -it --image=golang  -- bash 可以运行 golang 容器,进入到 golang 环境中。

安装用到的测试软件

在 /go/src 目录下,新建 main.go 文件,就是我们的测试程序。

执行 strace -fT ./main 查看结果。可以搜索 connect 关键字,可以发现有好多次调用。也就是说 dns 解析了多个 host 。 也就是说 search 存在多个条目。看下 /etc/resolv.conf 文件

可以看到 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 的形式
此条目发表在GO, k8s分类目录,贴了, 标签。将固定链接加入收藏夹。

发表评论

邮箱地址不会被公开。