在一次验证系统对 DNS 解析抖动容忍度的 GameDay 实战中,仅向 CoreDNS 注入 200ms 延迟,竟导致数十个业务节点接连失联。核心结论:高并发下微服务的无脑重试,配合 Linux UDP 默认 30 秒的 nf_conntrack_udp_timeout 状态保留,会瞬间撑爆 nf_conntrack 表。在进行网络层混沌工程时,必须提前调优内核连接跟踪参数或使用 raw 表豁免 DNS 流量,并设置基于系统底层指标的爆炸半径熔断器。
1. 故障现场:一场失控的 GameDay
近期的 SLO 验证计划中,我们需要验证核心交易链路在 DNS 存在长尾延迟(P99 200ms)时的系统表现,以确认超时控制和降级策略是否按预期生效。
使用的故障注入工具是 ChaosBlade (v1.7.0),目标集群为 Kubernetes 1.22,宿主机内核为 5.4.x。通过以下命令对 CoreDNS Pod 所在节点注入网络延迟:
blade create k8s network delay \
--time 200 --offset 50 \
--interface eth0 \
--namespace kube-system \
--names coredns-xxxx,coredns-yyyy
预期现象:业务监控面板中,外部接口调用的 P99 延迟略微上升,部分请求触发 500ms 的断路器,系统平稳降级。
实际现象:注入 1 分钟后,监控大盘不仅没有展现预期的降级曲线,反而出现了严重的“断崖”。更致命的是,API Server 报错频繁,多个承载高并发业务的 Worker 节点状态变为 NotReady,节点上的所有 Pod 被判定为不可用,开始触发大规模漂移重调度的雪崩。
2. 现场排查:谁拔了宿主机的网线?
由于故障节点 SSH 已经无法连入(连接直接超时),通过带外管理(IPMI)登录物理机终端。
执行 top 查看,CPU 使用率不到 30%,Load Average 在 4.0 左右(对于 64 核机器极低),内存也很充足。但网络就是不通,ping 网关全丢包。
习惯性拉出内核环形缓冲区日志排查:
dmesg -T | tail -n 20
满屏的红色报错直击痛点:
[Tue Oct 24 14:12:33 2023] nf_conntrack: nf_conntrack: table full, dropping packet
[Tue Oct 24 14:12:33 2023] nf_conntrack: nf_conntrack: table full, dropping packet
查看当前的连接跟踪数和系统上限:
cat /proc/sys/net/netfilter/nf_conntrack_count
# 输出: 262144
cat /proc/sys/net/netfilter/nf_conntrack_max
# 输出: 262144
毫无疑问,宿主机的 nf_conntrack 表被打满了,导致 Netfilter 丢弃了所有新建连接的包(包括 Kubelet 与 API Server 的心跳,以及新的 SSH 握手请求)。
到底是什么流量塞满了 conntrack 表?通过 conntrack 工具统计状态:
conntrack -L | awk '{print $1}' | sort | uniq -c
输出结果令人诧异:
4 ICMP
1024 tcp
261116 udp
超过 99% 的表项全被 UDP 占满。
3. 为什么 200ms 的 DNS 延迟会击穿几十万的 conntrack 表?
这涉及到 Linux 内核对 UDP 连接跟踪的处理机制,以及业务代码在面对网络抖动时的“疯狂”行为。
底层原理:UDP 的伪状态机与超时机制
UDP 本身是无连接的,但为了让 iptables/Netfilter 能够实现状态防火墙(例如允许外部响应包返回内部),内核强行给 UDP 设计了连接跟踪机制。
当一个 UDP 包发出时,内核在 nf_conntrack 中建立一条状态,标记为 UNREPLIED。如果在默认时间内收到了对端的回包,状态变更为 ASSURED。
在 CentOS/Ubuntu 等主流发行版中,相关的内核参数默认值如下:
-
net.netfilter.nf_conntrack_udp_timeout= 30 (未收到回包时的保留时间,30秒) -
net.netfilter.nf_conntrack_udp_timeout_stream= 120 (收到回包,被认定为流时的保留时间,120秒)
业务雪崩推演
-
注入延迟:ChaosBlade 通过底层的
tc netem让 CoreDNS 的响应延迟了 200ms。 -
业务超时:业务代码(Go
net/http或某些缺乏连接池管理的短连接客户端)设置的 DNS 查询超时可能极短(例如 100ms)。 -
疯狂重试:因为请求 DNS 超时,业务线程/协程被唤醒,立即发起下一次 DNS 解析。关键点来了:每次重试,客户端都会申请一个新的临时端口(Ephemeral Port)作为 UDP 源端口。
-
表项爆炸:由于源端口改变,Netfilter 将其视为一条全新的“连接”。旧的 DNS 请求由于没收到回包(还在 tc 的队列里排队),其在 conntrack 表中的
UNREPLIED记录将死死挂住 30秒 才会过期回收。
算一笔账:假设单节点有 5000 QPS 的请求,由于 DNS 延迟,导致业务每秒产生 3 次重试。
每秒新增 UDP conntrack 数 = 5000 * 3 = 15000。
经过 30 秒的积累,15000 * 30 = 450,000 条记录。
这就轻而易举地击穿了默认的 nf_conntrack_max (262144),最终导致宿主机网络瘫痪。
4. 混沌工程防御性设计与底层加固
发现原因后,解决问题并不难。但在混沌工程实践中,如何防止测试把系统“真搞挂”,才是考验架构师功底的地方。
1. 系统底层加固:收紧 UDP conntrack 超时
对于承载高并发微服务的宿主机,默认的 30s 容忍度过于宽泛。建议在 sysctl.conf 中激进地调低这个值,并适当抬高 max 上限。
# 修改 /etc/sysctl.d/99-kubernetes-cri.conf
net.netfilter.nf_conntrack_max = 1048576
net.netfilter.nf_conntrack_udp_timeout = 10
net.netfilter.nf_conntrack_udp_timeout_stream = 60
执行 sysctl -p /etc/sysctl.d/99-kubernetes-cri.conf 立即生效。
2. 根治方案:在 Netfilter 层面豁免 DNS 流量
既然 DNS 是典型的极短生命周期 RPC,且集群内部互信,完全可以直接跳过 conntrack 跟踪。利用 iptables 的 raw 表(优先级高于 conntrack)注入 NOTRACK 规则。
# 对发往 53 端口的 UDP 流量不进行状态跟踪
iptables -t raw -I PREROUTING -p udp --dport 53 -j NOTRACK
iptables -t raw -I OUTPUT -p udp --dport 53 -j NOTRACK
# 相应的回包也不跟踪
iptables -t raw -I PREROUTING -p udp --sport 53 -j NOTRACK
iptables -t raw -I OUTPUT -p udp --sport 53 -j NOTRACK
注:如果集群内使用了依赖于状态跟踪的复杂 NetworkPolicy,此操作可能引发冲突,需配合实际 CNI 插件(如 Calico/Cilium)的具体实现来权衡。
3. GameDay 的熔断设计:爆炸半径控制
混沌工程的第一原则是控制爆炸半径。我们在后续的演练体系中,强制引入了基于 Prometheus+Alertmanager+Webhook 的“自动停止机制”。
编写一个看门狗脚本或部署专门的 DaemonSet,高频采集节点的 /proc/sys/net/netfilter/nf_conntrack_count。
当 (count / max) > 80% 时,触发最高级别告警,Webhook 直接调用 ChaosBlade API 强制销毁当前所有的混沌实验:
# Webhook 触发的急救脚本片段
blade status --type create | grep Success | awk '{print $4}' | xargs -I {} blade destroy {}
常见问题
Q1:为什么不直接使用 NodeLocal DNSCache 来解决这个问题?
NodeLocal DNSCache 确实能极大缓解跨节点的 DNS 压力和 conntrack 表争抢(它将 DNS 请求拦截在本地并复用 TCP)。但如果针对 NodeLocal DNSCache 自身所在节点进行注入,或者上游 CoreDNS 完全不可用导致本地缓存穿透,业务依旧会疯狂重试击穿本地的 UDP conntrack 表。底层参数调优依然是最后一道防线。
Q2:当节点已经 NotReady 且 SSH 无法连接时,如何快速恢复?
如果物理机有 OOB(如 IPMI、iLO),登录后直接调高上限:echo 1048576 > /proc/sys/net/netfilter/nf_conntrack_max 即可瞬间恢复网络通信。若无 OOB 且节点已“僵死”,只能通过 IaaS 控制台硬重启实例,此时务必确认 StatefulSet 挂载的存储卷是否正常卸载,以防脑裂。
Q3:使用基于 eBPF 的故障注入(如 Chaos Mesh 的 BPF 模式)是否能避免此问题?
不能完全避免。虽然 eBPF 可以在 Socket 层或 TC 层更高效地丢包/延迟,避开部分 Netfilter 链的处理开销。但应用层(如 Go 的 HTTP Client)感知不到底层是被 eBPF 拦截还是真实网络延迟,它依然会发起重试。只要这些重试的 UDP 数据包依然经过宿主机的 Netfilter 协议栈,nf_conntrack 就会尽职尽责地去记录它们,最终依然会导致表满。