k8s环境dns解析偶发行失败问题分析

有业务方反馈生产环境中某些服务有DNS解析失败的报错,这种偶发性错误已经持续了很长时间,临时的解决方法是配置pod使用主机上配置的dns,而不是kube-dns。

环境信息

本次分析基于以下软件版本。

  • 操作系统 : Centos7.4
  • 内核版本 : 3.10
  • kubernetes版本 : v1.9.2
  • kube-proxy 负载均衡模式:iptables
  • cni插件 : calico ipip

dns解析流程概述

在以上软件版本中,dns解析的流程如下 :

  1. 业务方的pod向kube-dns的service的ip发起dns解析请求;
  2. dns请求数据包经由veth 网卡对 流到宿主机的命名空间内;
  3. iptables的dnat规则修改kube-dns的serviceip为某一个真实的kube-dns的pod的ip;
  4. 经过tunl0封装,经由物理网卡发送到kube-dns的pod所在的物理机;
  5. kube-dns的pod所在的物理机将dns请求转到kube-dns的pod内;
  6. 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解析失败可能是以下原因 :

  1. kubelet默认会给非HostNetwork类型的pod的resolv.conf中增加很多个search域和ndots的配置,这会导致增加很多不必要的dns查询,会不会是过多的dns请求导致kube-dns过载?查看kube-dns的监控,发现pod的cpu和内存使用率并不高。
  2. 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缓冲区不足导致内核丢包,这也与抓包分析的结果是一致的。

/proc/net/snmp.png

以下代码基于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的原理,推荐这篇文章。

你可能感兴趣的:(k8s环境dns解析偶发行失败问题分析)