深夜的软中断风暴:当 nf_conntrack 扩容变成一场对哈希链表的谋杀

凌晨两点半,监控系统的连环告警把我从浅睡眠中直接砸醒。某核心业务集群的几台入口网关节点全部飘红,告警内容很直接:网络吞吐断崖式下跌,且伴随着极其严重的丢包。

我登进机器,习惯性地扫了一眼系统负载。CPU 使用率并没有达到 100%,但 si(软中断)的指标高得吓人,个别核心的软中断几乎被吃满。dmesg -T 敲下去,满屏的红色日志触目惊心: nf_conntrack: table full, dropping packet

这是一个极其经典的连接跟踪表爆满问题。但让我眉头一皱的不是这个报错本身,而是接下来的发现。

为了确认当前的连接数限制,我跑了一下 sysctl:

sysctl net.netfilter.nf_conntrack_max
net.netfilter.nf_conntrack_max = 4194304

看到这个数字,我大概猜到了前面排查问题的人干了什么蠢事。 四百多万的上限?这机器的内存确实不小,但把 nf_conntrack_max 闭着眼睛调大,真的是解决丢包的万灵药吗?

我顺手查了一下连接跟踪表底层的哈希桶大小:

cat /sys/module/nf_conntrack/parameters/hashsize
65536

对着这两个数字,我揉了揉太阳穴,有点想笑,但更多的是对这种低级操作的无语。

在这个点上犯错,是对内核基础数据结构的完全无知。nf_conntrack 在内核中维护的是一个哈希表结构。每一个连接记录(tuple)通过哈希算法映射到一个具体的桶(bucket)里。当发生哈希冲突时,内核会用双向链表把这些记录串起来。

如果你把最大连接数 max 设为 4194304,但哈希桶的数量 hashsize 依然保持默认的 65536,这意味着什么? 这意味着在极端负载下,每个哈希桶里平均要挂载 4194304 / 65536 = 64 个节点。更要命的是,这个数字只是平均值。在真实的流量洪峰里,由于哈希分布不均,某些长链表的长度可能会达到几百。

当一个数据包进入 Netfilter 协议栈,走到 PREROUTING 链的 conntrack 钩子时,内核函数 __nf_conntrack_find_get 会被调用。为了找到这个包对应的连接状态,CPU 必须拿到自旋锁(spinlock),然后顺着那条长达几十上百个节点的链表,逐一对比五元组(源IP、目的IP、源端口、目的端口、协议)。

每秒几十万的 PPS,每个包都要在软中断上下文里锁住哈希桶去遍历长链表。这种级别的锁竞争和 Cache Line 颠簸,不把 CPU 软中断打爆才怪。这就像是你建了一个能容纳四百万辆车的大型停车场,却只留了 6 万个收费站,当晚高峰到来时,整个交通系统的瘫痪是必然的。

我快速敲下命令,先把哈希桶的大小提上来,给软中断止血:

echo 1048576 > /sys/module/nf_conntrack/parameters/hashsize

(注:调整 hashsize 必须通过写 /sys/module/nf_conntrack/parameters/hashsize,它会自动按比例重置 nf_conntrack_max,所以随后需要重新设置 max,且桶大小建议设为 max 的 1/4 或 1/2,取决于内存余量。)

系统负载眼看着在十几秒内降了下来,丢包停止,流量曲线重新爬升。

但排查到这里并没有结束。一个网关节点,为什么会在半夜突然产生几百万的连接?

我导出了当前的连接跟踪状态表,做了一个简单的聚合:

conntrack -L 2>/dev/null | awk '{print $4, $5, $6}' | sort | uniq -c | sort -nr | head -n 10

结果令人啼笑皆非。霸榜的根本不是什么外部用户的突发访问,而是全部指向本机的 Redis 集群同步端口(6379)以及一个内部的日志收集 Agent(UDP 8125)。开发团队在凌晨跑了一个全量数据的重算批处理任务,大量的本地短连接把 conntrack 表瞬间塞满。

这就引出了另一个极其愚蠢的逻辑:为什么要对纯内网、甚至是 Loopback/同子网的流量进行连接跟踪?

iptables/netfilter 的 conntrack 机制是为了配合 NAT 和复杂的状态防火墙(Stateful Firewall)而生的。但对于同节点内部的组件通信,或者确信不需要进行 NAT 转换的高频内网 RPC,走完整的 conntrack 状态机纯粹是在交智商税。

在 Netfilter 的报文流转图里,raw 表的优先级是最高的。它挂载在 NF_INET_PRE_ROUTINGNF_INET_LOCAL_OUT 钩子上,比 conntrack 的执行时机还要早。

对于这种毫无疑问的内部高频流量,最优雅的解法是直接在 raw 表中将其标记为 NOTRACK,彻底绕过连接跟踪引擎。

我在凌晨的终端里敲下这两组规则:

# 对于进入本机的 Redis 高频流量免追踪
iptables -t raw -I PREROUTING -p tcp --dport 6379 -j NOTRACK
iptables -t raw -I PREROUTING -p tcp --sport 6379 -j NOTRACK

# 对于本机发出的流量免追踪
iptables -t raw -I OUTPUT -p tcp --dport 6379 -j NOTRACK
iptables -t raw -I OUTPUT -p tcp --sport 6379 -j NOTRACK

(注意:如果在 filter 表中有类似 -m state --state ESTABLISHED,RELATED -j ACCEPT 的规则,需要补充针对 UNTRACKED 状态的放行规则,否则被 NOTRACK 的包会被默认策略 Drop 掉:)

iptables -I INPUT -m state --state UNTRACKED -j ACCEPT
iptables -I OUTPUT -m state --state UNTRACKED -j ACCEPT

规则生效后,conntrack 表的条目数像泄了气的皮球一样,直接从两百多万掉到了不到十万的常态水位。软中断 CPU 使用率彻底恢复到了个位数。

运维体系里的很多组件就像是一把把精密的瑞士军刀。Netfilter 极其强大,但如果你对它底层的链表结构、钩子顺序(Hooks)和优先级缺乏最基本的敬畏心,遇到问题只知道去百度搜索“conntrack table full 怎么解决”,然后闭着眼睛去改一个孤立的系统参数,那么下一次业务雪崩,就是在为你当初的草率买单。

不要试图用空间去掩盖架构逻辑上的瑕疵。把 max 调到四百万不是优化,那叫挖坑。真正懂包过滤的人,懂得在 raw 表里让不该被追踪的流量安静地溜走。