Kubernetes上和DNS相关的问题
这是一篇译文,原文地址:Racy conntrack and DNS lookup timeouts
最近出现了很多关于K8S中DNS查找超时的BUG报告,某些情况下Pod发起的DNS查找耗时高达5s甚至更久。在这篇文章中我将解释DNS查找延迟的根本原因,讨论缓和此延迟的途径,以及如何修改内核解决此问题。
在K8S中,Pod访问DNS的最常用途径是通过Service,要解释DNS延迟,首先需要知道Service如何工作,以及底层的DNAT机制。
在默认的Iptables模式下,kube-proxy为每个Service,在宿主机网络命名空间的NAT表中,创建一些iptables规则。
假设kube-dns服务有两个实例,则相应的规则可能是:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
-A PREROUTING -m comment --comment "kubernetes service portals" -j KUBE-SERVICES # 如果目的地址是DNS服务的ClusterIP,则跳转 -A KUBE-SERVICES -d 10.96.0.10/32 -p udp -m comment --comment "kube-system/kube-dns:dns cluster IP" -m udp --dport 53 -j KUBE-SVC-TCOU7JCQXEZGVUNU # 负载均衡 -A KUBE-SVC-TCOU7JCQXEZGVUNU -m comment --comment "kube-system/kube-dns:dns" -m statistic --mode random --probability 0.50000000000 -j KUBE-SEP-LLLB6FGXBLX6PZF7 -A KUBE-SVC-TCOU7JCQXEZGVUNU -m comment --comment "kube-system/kube-dns:dns" -j KUBE-SEP-LRVEW52VMYCOUSMZ # DNAT到实际Pod -A KUBE-SEP-LLLB6FGXBLX6PZF7 -p udp -m comment --comment "kube-system/kube-dns:dns" -m udp -j DNAT --to-destination 10.32.0.6:53 -A KUBE-SEP-LRVEW52VMYCOUSMZ -p udp -m comment --comment "kube-system/kube-dns:dns" -m udp -j DNAT --to-destination 10.32.0.7:53 |
在我们的例子中,每个Pod都在/etc/resolv.conf中包含了DNS服务器条目 nameserver 10.96.0.10,因此,Pod发起的DNS查找会发送给10.96.0.10这个ClusterIP。
从上面的规则中可以看到,经过简单的负载均衡后,请求被DNAT到DNS Pod的IP地址:10.32.0.6 或者 10.32.0.7
通过上面的分析可以看到,iptables模式下的服务依赖于内核的DNAT。
DNAT的主要职责是:同时修改出站封包的目的地址、回复封包的源地址,并确保对所有后续封包执行同样的修改。要保证后一点,需要非常依赖内核中的conntrack模块,此模块负责跟踪系统的网络连接。
在最简单的情况下,每个连接在conntrack中呈现为两个元组:
- 一个针对原始请求:IP_CT_DIR_ORIGINAL
- 一个针对应答:IP_CT_DIR_REPLY
对于UDP来说,每个元组由SIP+SPT+DIP+DPT这4个元素组成。应答元组IP_CT_DIR_REPLY的src字段中,存放请求目的真实地址。
例如,如果具有IP地址10.40.0.17的Pod,向kube-dns服务的ClusterIP发送请求,并且被DNAT到10.32.0.6的话,则元组如下:
- IP_CT_DIR_ORIGINAL:src=10.40.0.17 sport=53378 dst=10.96.0.10 dport=53
- IP_CT_DIR_REPLY:src=10.32.0.6 sport=53 dst=10.40.0.17dport=53378
有了这两个元组后,内核就能够修改任何相关的封包的目的地址、源地址,而不需要再次遍历DNAT规则。同时,内核也知道如何修改应答,将其转发给最初的请求者。
当一个conntrack条目创建后,它最初处于未确认(unconfirmed)状态。随后,如果内核发现,不存在已确认的、具有相同的ORIGINAL元组或者REPLY元组的conntrack条目,则确认这个新条目。
下面是一个简化的conntrack创建、执行DNAT的流程:
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 |
+---------------------------+ 如果不存在,则为封包创建一个conntrack | | IP_CT_DIR_REPLY是IP_CT_DIR_ORIGINAL元组的反转 | 1. nf_conntrack_in | | | REPLY元组的源地址尚未改变 +------------+--------------+ | v +---------------------------+ | | | 2. ipt_do_table | 找到一个匹配的DNAT规则 | | +------------+--------------+ | v +---------------------------+ | | 修改REPLY元组的源地址部分,同时保证此元组不被现有 | 3. get_unique_tuple | conntrack占用 | | +------------+--------------+ | v +---------------------------+ | | 根据REPLY元组来修改封包的目的地址 | 4. nf_nat_packet | | | +------------+--------------+ | v +----------------------------+ | | 如果没有已确认的、具有相同ORIGINAL/REPLY元组的conntrack条目 | 5. __nf_conntrack_confirm | 则确认之 | | +----------------------------+ 否则增加insert_failed计数,并丢弃封包 |
当两个UDP包通过相同套接字(绑定到相同的源地址/端口)、 在相同时间,通过不同线程发送,就会出现问题。
UDP是无连接的协议,connect系统调用之后,不会发送任何封包,因此也就不会创建conntrack条目。
仅当第一个UDP包发送时,条目才创建。因此可能出现以下3种竞态条件:
- 在nf_conntrack_in阶段,两个封包都没有条目,因此它们都创建conntrack,使用相同元组
- 在1的基础上:封包1的conntrack条目在封包2调用get_unique_tuple 之前确认。封包2得到一个不同的REPLY元组,通常改变了源端口
- 在1的基础上:两个封包在ipt_do_table选择了不同的目的地(DNS的Pod地址)
后果是一样的,其中一个封包在 __nf_conntrack_confirm被丢弃。
这就是发生在DNS查询场景中的问题。glibc、musl libc都会并行的执行A、AAAA查询。其中一个封包可能因为竞态条件而被内核丢弃,客户端会在超时(通常5s)之后重新发送请求。
这并不是K8S特有的问题,任何Linux应用程序,只要使用多线程发送UDP,都可能遭遇此问题。
甚至,即使没有DNAT规则,第二种情况也会发生。只要加载了nf_nat内核模块,就会调用get_unique_tuple。
执行命令 conntrack -S,如果计数器insert_failed增加,提示遭遇了此问题。
主要手段是避免UDP并发:
- 禁用并行DNS查找
- 禁用IPv6,从而禁止AAAA查找
- 使用TCP协议
- 将Pod使用的DNS地址设置为Endpoint的真实地址
某些手段由于musl libc的限制无法使用,此libc在Alpine Linux中被广泛应用。
IPVS模式不能解决此问题,因为conntrack仍然处于启用状态。使用rr作为负载均衡策略时,在低DNS负载的情况下也容易复现。
在/etc/resolv.conf中增加single-request-reopen选项:
1 |
options rotate timeout:1 attempts:3 single-request-reopen |
DNS解析器使用相同的套接字(源地址+源端口一样)执行A和AAAA查询。某些硬件/DNS服务器会错误的仅发回一个应答,这导致客户端等待第二个应答直到超时。启用此选项后,并向查找被禁用,并且会在发送第二个请求时重新打开端口。
CentOS 5等系统,都是使用独立源端口发起AAAA和A查询的,CentOS 6则使用同一端口,导致并行查找问题。
基于一些本地DNS前置缓存的方案,通过TCP访问CoreDNS。例如NodeLocal DNSCache。
如果内核版本足够高,可以使用Cilium kube-proxy这样的方案,不使用iptables。
另一个方向是,Pod直接访问DNS endpoint。这样的endpoint可以是本地前置缓存。
直到2020年4月,这3个竞态条件仍然没有完美解决方案:https://github.com/kubernetes/kubernetes/issues/56903#issuecomment-613589347。
有人建议使用Daemonset运行DNS服务,且跳过conntrack。他的方案是在HostNetwork中运行Dnsmasq的Daemonset,作为CoreDNS的前端。据测试哪怕把CoreDNS打爆OOM也不会出现timeout问题。
Cilium的Kube Proxy替代品,由于不使用netfilter/iptables,因此不会面临conntrack问题,也可以尝试。注意内核版本要求:v4.19.57, v5.1.16, v5.2.0或者更新版本
这个特性在1.18的Kubernetes处于Stable状态:https://kubernetes.io/docs/tasks/administer-cluster/nodelocaldns/。可以认为是Daemonset方案的官方版本。
通过在每个节点上运行DNS缓存来改善DNS性能问题,避免了DNAT和conntrack(Pod往本地的DNS查询,在iptables中通过NOTRACK跳过conntrack)。本地DNS缓存通过查询kube dns来应对缓存丢失。
- 在当前架构下,高DNS请求负载的Pod可能需要将查询发往不同的DNS节点。引入缓存降低了DNS的负载
- 跳过iptables DNAT和conntrack,可以减少竞态条件,以及避免UDP DNS条目充斥conntrack表
- 从本地缓存代理到kube-dns服务的连接,可以升级为TCP。TCP conntrack条目会在连接关闭时移除。而DNS条目需要等待超时,默认nf_conntrack_udp_timeout=30
- 将DNS查询从UDP升级为TCP,可以减少尾延迟(tail latency),它这会导致最多30s的超时(3次重试 x 10s超时)。同时本地缓存仍然沿用UDP,应用程序不需要改变
如果并发度极高,依赖于conntrack还会面临表被撑爆的问题:
1 2 3 4 5 |
# 表的容量 sysctl net.netfilter.nf_conntrack_max # 已经占用的量 sysctl net.netfilter.nf_conntrack_count |
如果DnsPolicy 是 ClusterFirst,则/etc/resolv.conf内容如下:
1 2 3 4 |
nameserver 10.96.0.10 search default.svc.cluster.local svc.cluster.local cluster.local # 搜索后缀列表,对于针对少于ndots(默认1)个.号的名字的DNS查询,会自动尝试添加这些后缀进行查询 options ndots:5 |
这样,当default命名空间中,有个工作负载查询外部域名gmem.cc,需要发起多次DNS查询才能完成:
- gmem.cc.default.svc.cluster.local.
- gmem.cc.svc.cluster.local.
- gmem.cc.cluster.local.
- gmem.cc.
这也增加了DNS服务器的压力。解析外部域名时,如果代码可以控制,使用全限定名称(点号结尾)可以避免反复查询的成本
在使用CoreDNS的forward插件的情况下,如果上游DNS服务器行为异常,会导致搜索后缀无效。
在Linux下,DNS查询失败后,客户端可能会附加搜索列表后缀,继续查询,这个行为是由C运行时库提供的。这里说可能,是因为DNS服务器返回某些响应的情况下,客户端就不会遍历后缀列表,从而导致DNS解析失败。具体哪些响应会导致不遍历,没有详细的研究,但是REFUESED肯定会导致,SERVFAIL则不会。
Leave a Reply