有业务方反馈生产环境中某些服务有DNS解析失败的报错,这种偶发性错误已经持续了很长时间,临时的解决方法是配置pod使用主机上配置的dns,而不是kube-dns。
环境信息
本次分析基于以下软件版本。
- 操作系统 : Centos7.4
- 内核版本 : 3.10
- kubernetes版本 : v1.9.2
- kube-proxy 负载均衡模式:iptables
- cni插件 : calico ipip
dns解析流程概述
在以上软件版本中,dns解析的流程如下 :
- 业务方的pod向kube-dns的service的ip发起dns解析请求;
- dns请求数据包经由veth 网卡对 流到宿主机的命名空间内;
- iptables的dnat规则修改kube-dns的serviceip为某一个真实的kube-dns的pod的ip;
- 经过tunl0封装,经由物理网卡发送到kube-dns的pod所在的物理机;
- kube-dns的pod所在的物理机将dns请求转到kube-dns的pod内;
- kube-dns解析dns请求,并将解析结果回复给业务方。
可以看到一次dns解析的流程还是很复杂的,而且dns解析失败还是特别偶现的,这也给排查问题原因增加了很大的难度。
搭建环境复现dns解析失败。
在测试环境按照生产环境中的软件版本搭建一套k8s环境,最初对于如何压测kube-dns也是没有思路的,直到看到这位大佬的压测dns服务的代码,压测的问题算是解决了。
加上参数CGO_ENABLED=0编译go代码(加上此参数后,编译出来的二进制程序不再依赖C库)。启动一个busybox的pod,将编译出来的二进制程序放到busybox的pod中,启动压测程序。
这里稍微解释下这些参数的含义,-c 表示多少个并发, -d表示压测持续的时间,-l 表示要统计dns解析时长超过3s的请求的个数。
~ # ./stress_dns -host www.baidu.com -c 10 -d 10 -l 3000
request count:1973
error count:0
request time:min(18ms) max(5040ms) avg(50ms) timeout(1n)
并发数只有10,居然也会有超时现象的发生,多次执行都是相似的结果,能够在测试环境中稳定得复现问题,就可以认为问题已经解决了一半了。
解析失败的原因
经过在网上不断的搜索,发现了几篇很有深度的解析dns失败的文章,结合自己的猜测,偶尔发性的dns解析失败可能是以下原因 :
- kubelet默认会给非HostNetwork类型的pod的resolv.conf中增加很多个search域和ndots的配置,这会导致增加很多不必要的dns查询,会不会是过多的dns请求导致kube-dns过载?查看kube-dns的监控,发现pod的cpu和内存使用率并不高。
- conntrack冲突造成内核丢包,使得dns请求被丢弃。因为对于conntrack模块比较陌生,网上的英文文章又写的十分有道理,在问题排查的初期,我们也一直认为是这个内核bug导致的问题,但是
conntrack -S
显示的结果中insert_failed
并未发现异常。
经过不断的压测和抓包分析,问题的原因已经很明确,kube-dns的pod的网卡收到了dns请求的网络包,但是没有回复。使用dropwatch工具观察到udp_queue_rcv_skb
函数丢包的增长很明显,这就需要分析下该函数的执行流程中有哪些可能的丢包的原因。
在内核中搜索到相关代码,UDP_MIB_RCVBUFFERRORS
的值可能会增加,一看到MIB
的字眼,自然是要看snmp
的指标,进入kube-dns的pod的命名空间中,执行cat /proc/net/snmp
发现UDP中RcvbufErrors
的值与压测工具中超时的个数大致相当,到这里,基本就可以锁定是因为udp缓冲区不足导致内核丢包,这也与抓包分析的结果是一致的。
以下代码基于4.19内核,代码逻辑与3.10相差不大。在函数__udp_enqueue_shedule_skb
中会判断使用的内存是否超限,如果超限会增加相应的metrics的值,并返回非0的错误码。
static int __udp_queue_rcv_skb(struct sock *sk, struct sk_buff *skb)
{
rc = __udp_enqueue_schedule_skb(sk, skb);
if (rc < 0) {
int is_udplite = IS_UDPLITE(sk);
/* Note that an ENOMEM error is charged twice */
if (rc == -ENOMEM)
UDP_INC_STATS(sock_net(sk), UDP_MIB_RCVBUFERRORS,
is_udplite);
UDP_INC_STATS(sock_net(sk), UDP_MIB_INERRORS, is_udplite);
kfree_skb(skb);
trace_udp_fail_queue_rcv_skb(rc, sk);
return -1;
}
}
int __udp_enqueue_schedule_skb(struct sock *sk, struct sk_buff *skb)
{
int rmem, delta, amt, err = -ENOMEM;
rmem = atomic_read(&sk->sk_rmem_alloc);
if (rmem > sk->sk_rcvbuf)
goto drop;
drop:
return err;
}
udp 接收缓冲区
下面代码展示了udp的sk创建时,会被赋值默认的内存限制。
void sock_init_data(struct socket *sock, struct sock *sk)
{
sk_init_common(sk);
sk->sk_rcvbuf = sysctl_rmem_default;
sk->sk_sndbuf = sysctl_wmem_default;
}
static struct net_protocol udp_protocol = {
.early_demux = udp_v4_early_demux,
.early_demux_handler = udp_v4_early_demux,
.handler = udp_rcv,
.err_handler = udp_err,
.no_policy = 1,
.netns_ok = 1,
};
void sk_get_meminfo(const struct sock *sk, u32 *mem)
{
memset(mem, 0, sizeof(*mem) * SK_MEMINFO_VARS);
mem[SK_MEMINFO_RMEM_ALLOC] = sk_rmem_alloc_get(sk);
mem[SK_MEMINFO_RCVBUF] = sk->sk_rcvbuf;
mem[SK_MEMINFO_WMEM_ALLOC] = sk_wmem_alloc_get(sk);
mem[SK_MEMINFO_SNDBUF] = sk->sk_sndbuf;
mem[SK_MEMINFO_FWD_ALLOC] = sk->sk_forward_alloc;
mem[SK_MEMINFO_WMEM_QUEUED] = sk->sk_wmem_queued;
mem[SK_MEMINFO_OPTMEM] = atomic_read(&sk->sk_omem_alloc);
mem[SK_MEMINFO_BACKLOG] = sk->sk_backlog.len;
mem[SK_MEMINFO_DROPS] = atomic_read(&sk->sk_drops);
}
go源码dns请求逻辑分析
在华为、阿里云的官网也搜索到相关的dns解析失败的问题,给出的解决方案是调整pod中的/etc/resolv.conf中options的值。在查看了resolv.conf的man手册之后,并没有对于这些参数有更深的理解,要理解这些options的值是如何生效的,这就要深入到各个语言的lib库中寻找答案了。幸运的是,go语言可以很方便的查看到这个实现,而且go语言是一种比较新的语言,肯定会借鉴其他语言中的实现。
在go源码目录的src/net/dnsconfig_unix.go
中可以看到解析resolv.conf中options的值。
// See resolv.conf(5) on a Linux machine.
func dnsReadConfig(filename string) *dnsConfig {
conf := &dnsConfig{
ndots: 1,
timeout: 5 * time.Second,
attempts: 2,
}
file, err := open(filename)
if err != nil {
conf.servers = defaultNS
conf.search = dnsDefaultSearch()
conf.err = err
return conf
}
defer file.close()
if fi, err := file.file.Stat(); err == nil {
conf.mtime = fi.ModTime()
} else {
conf.servers = defaultNS
conf.search = dnsDefaultSearch()
conf.err = err
return conf
}
for line, ok := file.readLine(); ok; line, ok = file.readLine() {
if len(line) > 0 && (line[0] == ';' || line[0] == '#') {
// comment.
continue
}
f := getFields(line)
if len(f) < 1 {
continue
}
switch f[0] {
case "nameserver": // add one name server
if len(f) > 1 && len(conf.servers) < 3 { // small, but the standard limit
// One more check: make sure server name is
// just an IP address. Otherwise we need DNS
// to look it up.
if parseIPv4(f[1]) != nil {
conf.servers = append(conf.servers, JoinHostPort(f[1], "53"))
} else if ip, _ := parseIPv6Zone(f[1]); ip != nil {
conf.servers = append(conf.servers, JoinHostPort(f[1], "53"))
}
}
case "domain": // set search path to just this domain
if len(f) > 1 {
conf.search = []string{ensureRooted(f[1])}
}
case "search": // set search path to given servers
conf.search = make([]string, len(f)-1)
for i := 0; i < len(conf.search); i++ {
conf.search[i] = ensureRooted(f[i+1])
}
case "options": // magic options
for _, s := range f[1:] {
switch {
case hasPrefix(s, "ndots:"):
n, _, _ := dtoi(s[6:])
if n < 0 {
n = 0
} else if n > 15 {
n = 15
}
conf.ndots = n
case hasPrefix(s, "timeout:"):
n, _, _ := dtoi(s[8:])
if n < 1 {
n = 1
}
conf.timeout = time.Duration(n) * time.Second
case hasPrefix(s, "attempts:"):
n, _, _ := dtoi(s[9:])
if n < 1 {
n = 1
}
conf.attempts = n
case s == "rotate":
conf.rotate = true
case s == "single-request" || s == "single-request-reopen":
// Linux option:
// http://man7.org/linux/man-pages/man5/resolv.conf.5.html
// "By default, glibc performs IPv4 and IPv6 lookups in parallel [...]
// This option disables the behavior and makes glibc
// perform the IPv6 and IPv4 requests sequentially."
conf.singleRequest = true
case s == "use-vc" || s == "usevc" || s == "tcp":
// Linux (use-vc), FreeBSD (usevc) and OpenBSD (tcp) option:
// http://man7.org/linux/man-pages/man5/resolv.conf.5.html
// "Sets RES_USEVC in _res.options.
// This option forces the use of TCP for DNS resolutions."
// https://www.freebsd.org/cgi/man.cgi?query=resolv.conf&sektion=5&manpath=freebsd-release-ports
// https://man.openbsd.org/resolv.conf.5
conf.useTCP = true
default:
conf.unknownOpt = true
}
}
case "lookup":
// OpenBSD option:
// https://www.openbsd.org/cgi-bin/man.cgi/OpenBSD-current/man5/resolv.conf.5
// "the legal space-separated values are: bind, file, yp"
conf.lookup = f[1:]
default:
conf.unknownOpt = true
}
}
if len(conf.servers) == 0 {
conf.servers = defaultNS
}
if len(conf.search) == 0 {
conf.search = dnsDefaultSearch()
}
return conf
}
在go源码目录src/net/dnsclient_unix.go
中可以看到dns解析的流程,以及这些参数是如何生效的,省略掉无关代码。
func (r *Resolver) goLookupIPCNAMEOrder(ctx context.Context, network, name string, order hostLookupOrder) (addrs []IPAddr, cname dnsmessage.Name, err error) {
conf := resolvConf.dnsConfig
resolvConf.mu.RUnlock()
type result struct {
p dnsmessage.Parser
server string
error
}
lane := make(chan result, 1)
qtypes := []dnsmessage.Type{dnsmessage.TypeA, dnsmessage.TypeAAAA}
switch ipVersion(network) {
case '4':
qtypes = []dnsmessage.Type{dnsmessage.TypeA}
case '6':
qtypes = []dnsmessage.Type{dnsmessage.TypeAAAA}
}
var queryFn func(fqdn string, qtype dnsmessage.Type)
var responseFn func(fqdn string, qtype dnsmessage.Type) result
if conf.singleRequest {
queryFn = func(fqdn string, qtype dnsmessage.Type) {}
responseFn = func(fqdn string, qtype dnsmessage.Type) result {
dnsWaitGroup.Add(1)
defer dnsWaitGroup.Done()
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
}
}
for _, fqdn := range conf.nameList(name) {
for _, qtype := range qtypes {
queryFn(fqdn, qtype)
}
hitStrictError := false
for _, qtype := range qtypes {
result := responseFn(fqdn, qtype)
}
return addrs, cname, nil
}
至此,可以对方案中resolv.conf的值做出很明确的解释。
- ndots,如果域名中的小数点的个数小于该值,那么会优先将域名拼接上search域中的值进行解析,默认值是1,但是kubelet默认会修改该值为5。
- timeout,如果一次dns请求在这个时间内没有返回,就认为此次解析失败,默认值5s。
- attempts,当一次解析失败时,会进行重试,attempts就是重试的次数,默认值是2。
- single-request与single-request-reopen,在我看的这个go版本中,这两个参数并没有什么区别,都是将dns的A与AAAA请求串行化。
对于外部域名较多的场景,减少ndots的值可以有效的减少不必要的dns请求的次数;减小timeout的值,可以让lib库快速的重试,而不是等待5s之后再重试;打开single-request-reopen选项,可以让A与AAAA请求串行化,可以有效的减小kube-dns的并发数。我认为,减小timeout的值至关重要,某些应用对于时延比较敏感,dns解析失败的可能性不可能为0,5s的默认值对于某些业务可能是比较长的,而且我猜测业务中设置的http的超时时间理论上是包含dns请求的时间的。
知识总结
之前写过k8s中service的两种实现(iptables与ipvs)的内核原理,经过此次问题的排查,对于内核conntrack的工作原理有了更深入的理解。关于conntrack的原理,推荐这篇文章。