深度解析:高并发下 K8s Ingress 节点 `nf_conntrack` 锁竞争导致的 TCP 丢包排查实录

凌晨三点半。刚把一个极其难缠的 P1 级别故障单关掉。这会儿机房外边漆黑一片,只有工位上的显示器还在亮着。咖啡早就凉透了,但我现在毫无睡意。
今晚处理的这个问题,非常有代表性。很多在 K8s 环境下做高并发微服务架构的人,迟早会踩到这个坑里。不是应用代码的问题,也不是简单的 CPU、内存打满,而是 Linux 内核网络协议栈底层机制与现代容器网络(基于 netfilter/iptables 或 IPVS)碰撞出的暗礁。

现场还原:幽灵般的 P99 延迟毛刺

晚上十点左右,监控系统告警。核心交易链路的 API Gateway(部署在 K8s Ingress Controller 上)出现间歇性的 P99 延迟突刺。平时的 P99 在 20ms 左右,毛刺发生时直接飙到 3000ms 以上,甚至伴随少量的 502 和 504。
排查初期的现象非常诡异:
1. 查看 Node 的 CPU 和 Load,整体都在 40% 以下。
2. 内存充裕,没有 OOM 迹象。
3. 网卡带宽远未达到瓶颈(万兆网卡,跑了不到 2Gbps)。
4. 应用层日志干干净净,没有任何 Error 或者 Full GC 停顿。

抽丝剥茧:深入内核协议栈

常规指标看不出问题,那只能向下钻,去查内核网络栈。这种偶发性的长尾延迟,大概率是发生了 TCP 重传。
我直接登录到发生告警的 Ingress 节点,抓了一段包,同时用 nstat 查网络计数器:

# 观察 TCP 异常丢包与重传指标的变化增量
watch -n 1 'nstat -a | grep -iE "TcpExtTCPRetransFail|TcpExtListenDrops|TcpExtTCPReqQFullDrop|TCPSynRetrans"'

果然,在延迟突刺的瞬间,TCPSynRetransTcpExtListenDrops 出现了明显的跳变。这说明三次握手阶段就出了问题,包被丢了。
是半连接队列(SYN Queue)或者全连接队列(Accept Queue)满了吗?
查了一下 ss -lnt,发现 Send-Q(应用的 backlog 设置)高达 4096,而当时的 Recv-Q 根本没跑满。系统级别的 net.core.somaxconnnet.ipv4.tcp_max_syn_backlog 早就调优过,不可能在这里卡住。
如果不是 Socket 队列满,那包在进入 TCP 层之前就被干掉了。K8s 环境下,最可疑的就是 Netfilter 框架。
我调出了 dmesg,想看看有没有经典的 nf_conntrack: table full, dropping packet 报错。结果出乎意料,系统日志里连个屁都没有。
既然 table 没满,为什么会丢包?
为了抓现行,我写了一段 bpftrace 脚本,直接挂载到内核的 kfree_skb tracepoint 上,看看是谁在扔我的包:

// skb_drop_trace.bt
#include 
tracepoint:skb:kfree_skb
{
    $skb = (struct sk_buff *)args->skbaddr;
    // 过滤掉正常的释放,只看异常丢包
    if (args->reason != 0) {
        printf("Time: %s, Comm: %s, CPU: %d, Location: %s, Reason: %d\n",
               strftime("%H:%M:%S", nsecs),
               comm, cpu, kstack, args->reason);
    }
}

跑了几分钟,抓到了致命线索。大量的丢包栈指向了同一个地方:__nf_conntrack_find_getnf_conntrack_in
随后,我用 perf top -C <网卡中断绑定的CPU号> 看了一下当时的 CPU 消耗,发现 queued_spin_lock_slowpath 占据了该核近乎 60% 的 CPU 周期,而调用方正是 nf_conntrack 相关的函数。

根因剖析:Hash 碰撞与自旋锁地狱

到这里,真相已经大白了。这不是容量问题,而是并发锁竞争问题
在 Linux 内核中,nf_conntrack 用于跟踪每个网络连接的状态,这是 K8s Service (无论是 iptables 还是 IPVS 模式) 实现 NAT 的基础。
它在内核中维护了一个全局的 Hash 表。一条连接由一个 tuple(源 IP、源端口、目的 IP、目的端口、协议)通过 Hash 算法映射到具体的 Bucket 中。
问题在于:
1. Hash 冲突严重:如果 hashsize(Bucket 数量)太小,而并发连接数很高,大量的连接会挤在同一个 Bucket 的链表中。
2. 锁的粒度太粗:在查询、插入或更新连接状态时,内核必须对该 Bucket 加自旋锁(spin_lock)。
3. 软中断风暴:网络包的处理是在 NET_RX_SOFTIRQ 软中断上下文中进行的。当大批量的并发短连接(比如 HTTP/1.1 突发流量)涌入时,多个 CPU 核心同时在软中断里尝试获取同一个 Hash Bucket 的自旋锁。获取不到的 CPU 只能原地自旋(Spin),导致该核的 %si(软中断 CPU 使用率)瞬间飙升至 100%。
在这个自旋的过程中,网卡送上来的新包堆积在 Ring Buffer 中来不及处理,引发 NAPI 轮询超时,最终导致底层的网卡驱动或者上层的 TCP/IP 协议栈主动丢包(Drop)。这就完美解释了为什么 TCP 会重传,以及为什么没有 table full 的日志。

现场手术:调优与 Bypass

解决这个问题,不能靠盲目扩容节点。需要对症下药。
第一步:扩容 Hash 表规模,稀释锁竞争冲突率
很多文档只教你调大 nf_conntrack_max,但这治标不治本。真正决定 Hash 冲突率的是 hashsize
内核默认的 hashsize 通常较小(早期版本是 max 的 1/4,后来有些变成了 1/8)。我直接将其拉大。

# 查看当前的 hashsize
cat /sys/module/nf_conntrack/parameters/hashsize
# 动态修改 hashsize (注意:这个值必须是 2 的 n 次方)
# 我将 Bucket 数调大到了 4194304
echo 4194304 > /sys/module/nf_conntrack/parameters/hashsize
# 同步调整 max 的值,保持 max = hashsize * 2 (或者 *4)
sysctl -w net.netfilter.nf_conntrack_max=8388608

注:不要把这个值设得无限大,每个 Bucket 会占用少量不可交换的内核内存(slab 分配器)。8M 的 max 大约消耗几十 MB 内存,这对于现在的物理机来说九牛一毛。
第二步:对于纯内网的高并发透传流量,直接 Bypass Conntrack
即便调大了 Hash 表,对于 Ingress 这种千万级 PPS 的纯转发节点,Netfilter 依然是极大的开销。
我们的业务特点是,部分流量是集群内互访的已知流量,不需要做复杂的 NAT 转换。
利用 iptables 的 raw 表,我们可以让特定的流量提前打上 NOTRACK 标记,彻底绕开 nf_conntrack 机制:

# 针对特定高并发的已知后端 IP 段,免除连接跟踪
iptables -t raw -I PREROUTING -s 10.244.0.0/16 -d 10.244.0.0/16 -j NOTRACK
iptables -t raw -I OUTPUT -s 10.244.0.0/16 -d 10.244.0.0/16 -j NOTRACK

应用这两步之后,通过监控观察,P99 延迟立刻稳如死狗,死死压在了 25ms 以内,TCPSynRetrans 增量归零。

总结

干了二十年的系统底层,看了无数的技术演进。从裸机到虚机,再到如今号称“基础设施即代码”的 K8s,抽象的层级越来越高。很多年轻人以为只要 YAML 写得溜,就能把架构玩转。
但现实往往是残酷的。当流量达到一定量级,那些被精美封装起来的底层机制——软中断、自旋锁、Hash 冲突、CPU 缓存失效,会像幽灵一样从内核深处爬出来,给你致命一击。不懂底层原理,面对这种毫无明显报错的丢包,只能是两眼一抹黑。
四点钟了,监控曲线上再也没出现那个该死的尖刺。合上电脑,抽根烟,该睡觉了。明天还有明天的事。