凌晨三点半,刚把一杯已经彻底冷掉的黑咖灌下去。就在十分钟前,我切断了线上故障排查会议的语音。问题解决了,但排查过程值得复盘。
这是一个潜伏得很深的连环雷。业务团队反馈,在晚高峰期间,K8s 集群内的微服务调用偶尔会出现 1 秒以上的延迟,部分请求直接抛出 502 或 Timeout。总体报错率不高,大概在万分之五左右,但由于是核心交易链路,上游重试导致了不小的雪崩隐患。
很多人遇到这种偶发网络抖动,第一反应是去查宿主机的 CPU 负载,或者是看网卡的 Ring Buffer 有没有溢出。我看过监控,CPU Steal Time 正常,netstat -s | grep -i listen 也没有增加的 ListenOverflows 或 ListenDrops。不是应用层的 backlog 打满,问题出在更底层的网络栈里。
我登录到其中一台报错最频繁的 Node 节点,直接抓包:
tcpdump -i any -nn -S host 10.244.3.15 and port 8080
抓包结果显示了一个典型的现象:客户端发送了 SYN,但服务端没有回 SYN-ACK,大约 1 秒后客户端触发了超时重传(Retransmission),第二次 SYN 过去后,连接瞬间建立。
首包静默丢弃。在 Linux Kernel 网络栈中,这种情况如果排除了上层应用的 Accept 队列满,大概率是 Netfilter 搞的鬼。
按照常规思路,查一下内核日志:
dmesg -T | grep -i conntrack
干干净净。没有那句经典的 nf_conntrack: table full, dropping packet。说明当前连接数并没有触及 net.netfilter.nf_conntrack_max 的上限。
既然不是容量问题,那就是逻辑丢包。Netfilter 的全景图中,丢包最严重的地方往往在 NAT 和 Conntrack 的交互处。K8s 默认的 kube-proxy(无论是 iptables 还是 ipvs 模式),以及 CNI(如 Flannel/Calico)大量使用了 SNAT 和 DNAT。
为了精准定位到底是内核哪个函数把包 Drop 掉了,我写了一个简单的 bpftrace 脚本,直接 Hook 内核的 kfree_skb(内核释放网络数据包的核心函数),并过滤掉正常的释放逻辑,只抓取异常丢包:
#!/usr/bin/env bpftrace
#include
#include
#include
kprobe:kfree_skb
{
$skb = (struct sk_buff *)arg0;
$iph = (struct iphdr *)($skb->head + $skb->network_header);
$tcph = (struct tcphdr *)($skb->head + $skb->transport_header);
// 过滤 IPv4 且为 TCP SYN 包
if ($iph->version == 4 && $iph->protocol == 6 && $tcph->syn == 1 && $tcph->ack == 0) {
printf("Time: %s, SYN dropped at %s\n", strftime("%H:%M:%S", nsecs), kstack);
}
}
跑了大约十分钟,屏幕上终于弹出了几条堆栈信息。关键的调用链如下:
Time: 02:14:32, SYN dropped at
kfree_skb+1
nf_conntrack_confirm+0x9c
ipv4_confirm+0x4a
nf_hook_slow+0x44
ip_local_deliver+0x8b
...
看到 nf_conntrack_confirm,破案了一半。
在 Linux 内核源码 net/netfilter/nf_conntrack_core.c 中,__nf_conntrack_confirm 函数负责在数据包即将离开 Netfilter 系统(比如 POSTROUTING 或 LOCAL_IN 阶段)时,将之前在 PREROUTING 阶段建立的、处于 unconfirmed 状态的连接记录,正式插入到系统的 Conntrack Hash 表中。
它为什么会失败并返回 NF_DROP?
在 K8s 高并发场景下,同一个 Node 上的多个 Pod 如果同时通过 SNAT(例如访问集群外的服务,或者经过 Service 的 NodePort 转发)发起连接,Netfilter 需要为它们分配源端口(Source Port)。分配端口发生在上半部的 NAT 阶段,但确认插入 Hash 表发生在下半部的 Confirm 阶段。
这里存在一个经典的 Race Condition(竞态条件):
1. 包 A 和包 B 几乎同时到达,目标 IP 和端口相同。
2. 在 NAT 阶段,系统为包 A 寻找了一个未被使用的五元组(假设分配了源端口 30001),为包 B 也进行了分配。由于此时包 A 的五元组还没进 Hash 表,系统认为 30001 还是空闲的,于是也可能把 30001 分配给包 B。
3. 当到达 nf_conntrack_confirm 阶段时,包 A 抢先一步插入 Hash 表。
4. 包 B 尝试插入时,内核调用 nf_conntrack_hash_check_insert,发现五元组冲突(insert_failed),于是直接将包 B 标记为 NF_DROP。
这就是为什么连接没有满,但依然会发生偶发性 SYN 丢包的原因。并发越高,冲突概率越大。
既然找到了根因,解决思路就明确了。打破这个竞态条件。
最直接且成本最低的改法,是强制 NAT 在分配端口时使用完全随机的算法,而不是默认的顺序查找。在 iptables 中,这对应的是 --random-fully 参数。这个参数会让内核调用 prandom_u32() 去选取端口,极大地降低了多个并发包在未确认期间撞击同一个源端口的概率。
我检查了当前集群 Calico 生成的 NAT 规则:
iptables -t nat -S | grep MASQUERADE
输出显示:
-A cali-nat-outgoing -j MASQUERADE
果然没有带随机化参数。
我当即修改了 Calico 的配置,开启了 SNAT 的完全随机化(在 Calico 中可以通过修改 FelixConfiguration 的 NatOutgoingAddress 相关行为,或直接在较新的 kube-proxy 配置中开启 InsertSNATRandomFully)。
对应的底层规则变更实际上是让规则变成了这样:
-A cali-nat-outgoing -j MASQUERADE --random-fully
为了稳妥,我同时微调了这台机器的内核参数,加速 TIME_WAIT 状态的回收,减少 Conntrack 表里的无效堆积,缩小碰撞域:
# sysctl.conf
net.netfilter.nf_conntrack_tcp_timeout_time_wait = 30
net.netfilter.nf_conntrack_tcp_timeout_close_wait = 15
net.ipv4.tcp_tw_reuse = 1
执行 sysctl -p 生效。
再次打开 bpftrace 脚本盯着,观察了半个小时,再也没有抓到 nf_conntrack_confirm 抛出的 kfree_skb。业务群里的超时告警也随之消失。
做了二十年技术,看着系统架构从单机演进到微服务,再到现在的 Cloud Native 和 Service Mesh,抽象层叠得越来越高。很多人习惯于在 K8s 的 Yaml 里打转,遇到性能问题就去改 Request/Limit,或者横向扩容。但流量的本质并没有变,底层依然是那个兢兢业业但也充满脾气的 Linux Kernel。
高并发不是玄学,丢包必有其因。理解从 Socket 到 eBPF,从 VFS 到 Netfilter 的完整链路,才是运维架构师面对这类 P0 故障时,能保持拔剑即斩的底气。
合上电脑,外面天已经快亮了。这杯冷咖啡,足够让我清醒地迎接几个小时后的早会。