这次的问题挺有意思的~
项目上会涉及到私有化部署,在边缘侧我们使用K3S(轻量级K8S)进行中间件、存储及服务的部署。
由于大部分业务平台都是直接下沉到边缘,没有做服务优化及裁剪,基本上边缘单机上会有上百个Pod,每次部署的时间都比较长,但是一直也没有太大的问题。
直到有一天,我们在修复和更新某个客户的边缘服务器时,出现了令人费解的异常。
问题现象和原因
在批量更新服务的过程中,部分服务在启动时出现了下面异常:
从结论上来看,只是域名解析异常,首先想到的是CoreDNS
,但是发现CoreDNS
是正常的。
当我们登录到容器里尝试dig
一把时,也在不同的容器中出现了下面不同的现象。
(1)部分容器内,域名正常解析
(2)部分容器内,域名解析失败
(3)还有一批容器,刚进入时解析异常,隔一段时间后正常解析
这就让我们有点抓瞎,排查了一整天都没有思路,而客户那一层也在不断追问进度。
直到半夜,实在没想法的我们,找来了运维大佬,直接一语道破其中的问题:
ARP缓存满了,调大一下arp gc阈值
抱着试一试的态度,我们调整了相关参数后,问题果然解决了。
这就很让人费解了,DNS解析异常为什么会和ARP缓存有关?
让我们一步一步来拆解和还原整个问题。
问题分析
要了解这个问题的原貌,首先需要直到边缘服务器网络。
K3S属于轻量级的K8S,我们先从K8S的网络模型开始说起。
K8S容器网络模型
在该网络模型中,
(1)同Pod内的容器通过loopback
环回接口通信;
(2)同节点内的不同Pod,通过构建veth
与网桥cni0
连接,实现Pod之间的通信;
(3)不同节点之间,通过flannel
构建的vxlan
设备,将数据报文进行vxlan
封/解包,实现跨节点通信;
中间的数据报文路由,也会涉及到基于ip route及iptables的转发,不展开细说。
K8S网络模型下的DNS解析
在我们的场景中,不涉及多节点,网络结构相对简单一些。当在容器中访问某个域名,且本地没有DNS缓存时,就会向DNS服务器请求获取解析的结果。
这样看还是会比较抽象,来看个更具体的例子。
当我们在容器A中首次发起ping baidu.com
时,完整的DNS解析及ping
过程如下。
看到这里,你可能就会些疑惑,大家应该能理解ping
的过程涉及DNS解析,但是应该会困惑为什么要发ARP请求,且还有两次,我们逐次来看。
- 第一次ARP请求,容器A请求获取
cni0
的MAC地址
进行DNS解析,首先看resolv.conf
的配置。
对应的nameserver
是10.96.0.10
,所以发起DNS请求会向该地址查询,但是发起查询前,需要知道发往该地址的包从哪个网口发出。
由此可以判断,default
走10.244.1.1
,即cni0
,因此会先查询其MAC地址,有了第一次ARP请求。
- 第二次ARP请求,CoreDNS请求获取容器A的MAC地址
这个相对好理解,CoreDNS收到容器A的DNS请求,回复时需要容器A对应的MAC地址,因此发送ARP请求查询其MAC。
在实际场景中,这两次ARP并不一定会发生,取决于即ARP缓存中是否有对应的记录,如果没有记录机会发起请求进行查询。
综上所述,在我们的场景中,CoreDNS
是正常的,也就意味着不是由DNS服务器导致的问题,那么有没有可能在DNS解析的过程中,由于没有ARP记录、ARP查询请求失败导致DNS解析失败呢?
要验证这个想法,首先我们需要先了解一下ARP缓存。
ARP缓存及GC策略
通常描述的ARP缓存,在Linux
内核中被称为neighbor
,记录了IP地址对应的MAC地址。
我们一般可以通过ip neigh
来查看其内容。
每条记录的基础信息,包含其IP对应的网口、MAC地址及记录状态。
记录的状态包含INCOMPLETE、REACHABLE、STALE、DELAY、PROBE、FAILED中,其状态转换如下(图转),同时定期触发记录GC,回收释放掉无效的记录。
GC有两个场景触发,分别是周期性GC和强制GC,逻辑如下:
周期性GC(net/core/neighbour.c#neigh_periodic_work)
即当ARP记录没有被引用,且非PERMANENT状态,且(记录状态为FAILED或记录距离上一次使用已经超过过期时间),记录会被释放
强制GC(net/core/neighbour.c#neigh_forced_gc)
即当ARP记录没有被引用,且非PERMANENT状态时,就会被GC释放掉
关于ARP缓存的GC策略,由内核参数控制:
ini
# 当记录数量<gc_thresh1时,不会进行周期性gc
sysctl net.ipv4.neigh.default.gc_thresh1=128
# 当记录数量≥gc_thresh2,且距离上一次GC超过5s时,进行强制GC
sysctl net.ipv4.neigh.default.gc_thresh2=512
# 当记录数量≥gc_thresh3,则立刻进行强制GC
sysctl net.ipv4.neigh.default.gc_thresh3=1024
# 周期性GC间隔时间
sysctl net.ipv4.neigh.default.gc_interval=30
# ARP记录过期时间
sysctl net.ipv4.neigh.default.gc_stale_time=60
那么,怎样才算ARP缓存满呢? 在net/core/neighbour.c#neigh_alloc
中我们可以找到答案。
当ARP记录数量超过gc_thresh3
,且neigh_forced_gc
强制GC后,记录仍然超过gc_thresh3
时,会无法创建新的ARP记录。
所以简单理解,gc_thresh3
就是上限记录数(不包含PERMANENT记录),
但是这也意味着ARP列表的上限是1024,真的会有那么多的ARP记录吗?
这就涉及到另外一个比较特殊的场景,容器。
NetworkNamespace中的ARP缓存
容器的资源隔离和限制是通过namespace
和cgroup
实现的,其中network namespace
就是用于隔离网络资源。
network namespace
提供系统网络资源的隔离,包括网络设备、IPv4及IPv6协议栈、IP路由表、防火墙规则、若干个网络资源目录。在K8S网络模型中,每个Pod有独立的network namespacke
,Pod内的容器共享。
既然network namespace
隔离,会不会觉得ARP缓存也是独立的?
其实这个network namespace
的描述可能有一些不准确,本质上宿主机的资源都是全局的,namespace
实现的是资源"可见性"层面的隔离,在对应协议栈中ARP缓存表(neighbour table
)其实只有一份。
(协议栈neighbour table
源码对应的结构,参考neighbour.h
)
正常情况下,我们只能看到所在空间下的网络设备,因此只会查询出当前网络设备关联的记录(因为network namespace
的网络设备隔离,neighbour
记录中其实也记录其对应的dev
)。我们可以去不同的network namespace
中看看各自的ARP列表。
看到的结果其实是每个network namespace
中,和其中的虚拟网络设备关联的ARP记录。比如,cni-f4282581-35fd-e1dd-ffcb-13f34b325d6e
下有两条ARP记录,网络出口对应是该网络空间下的eth0。
那既然ARP缓存表是全局资源,那么就是所有network namespace
共同使用同一个缓存表,那么如果单台宿主机上如果有非常多的Pod
,有没有可能会导致ARP缓存表满,进而导致DNS解析失败呢?
问题复现
要模拟这个过程并不复杂,只要把ARP缓存GC阈值改小就好了。
首先,我们把neigh.default.gc_thresh
的三个值全改成11。
然后在容器A里发起ping www.baidu.com
时发现会阻塞住一段时候,并最终报错。
我们对cni0
进行抓包后,可以看到下面结果。
我们还原一下整个过程。
可以看到,此时CoreDNS为了向容器A回复DNS解析结果,需要通过ARP请求获取到容器A的MAC,但是由于ARP缓存表已满,此时A即使收到了ARP请求也无法进行回复。
net/ipv4/arp.c#arp_process
实际上,由于ARP记录获取/请求失败导致异常的地方比较多,比如第一次ARP请求就可能发不出去或者回复不了。
解决方案
既然知道了原因,解决方法也比较简单,调大ARP缓存GC策略的阈值即可。
问题总结
问题本质上是由于ARP列表满,导致向CoreDNS
请求DNS解析的过程中,容器之间无法正常请求或回复导致。
一般正常情况下,很少会发生这个问题,但是当单台宿主机的Pod数量较多时,就很容易到达这个上限。