上周接手一台做 NAT 转发的网关机器,是台 2 核 4G 的小机器,跑 iptables 的 SNAT/DNAT,把内网十几台设备的流量统一出口出去。平时跑得好好的,结果业务方把并发从几百拉到 5000 的那一瞬间,网关就开始大量丢包ping 外网通、TCP 能建连但传数据卡死、应用层报超时。我第一反应是带宽不够、`tc` 限速、网卡队列满了,翻了一圈都不是,最后在 `dmesg` 里翻到一行不起眼的报错:`nf_conntrack: table full, dropping packet`。从那一刻起,方向就完全不一样了:这不是带宽问题,是内核的连接跟踪表被塞满了,新连接进不来,表一满,所有要过 NAT 的包直接被丢在网关这一侧。
如果你也在做网关转发、出口 NAT 或者高并发代理,先把这两家的机器规格和线路看一遍再动手调参数:edgeNAT(服务器与主机,网络托管与虚拟专用服务器)和justg(原生IP精品线路,路由面向中国和全球)一个提供网络托管与定制化虚拟主机,一个专注路由质量,换机器和调内核参数是两条路,别只盯着一条走。
先解释下机制,不然后面的排查看不懂。Linux 内核的 netfilter 框架里,iptables 的 nat 表要实现 SNAT,就必须记住「这个连接的源地址要改成哪个出口 IP、源端口要改成哪个端口」,这样回来的包才能被认出来并反向还原。这个「记住」的动作,就叫连接跟踪(connection tracking),每一条连接在系统里对应一条 conntrack 表项,里面存着五元组、超时计时器、NAT 映射关系、连接状态这些信息。
换句话说:只要你的机器上跑了 iptables 的 nat 表规则、或者用了 kube-proxy 的 iptables 模式、或者起了 docker 的 iptables 转发规则,conntrack 就已经在跑了,哪怕你从没听说过这个词。它是内核级模块,所有进程共享同一张表,表项数有全局上限。
上限这个参数叫 nf_conntrack_max。而真正决定哈希桶数量的是 nf_conntrack_hashsize,默认会按 max 自动取一个合适的 2 的幂次。表项数量超过 max 之后,内核不会覆盖老表项,而是直接丢弃新的连接,同时在 dmesg 里刷 `table full, dropping packet`。注意,这行日志淹没得很快,抓日志不及时基本就错过了。
这里有个反直觉的点:连接跟踪表项的数量,不是由「当前活跃连接数」决定的,而是由「内核曾经见过的、还没被清理掉的连接数」决定的。很多短连接在应用层早就 close 了,但内核那边的表项要等超时才回收,这段时间里它一直占着位置。
最典型的元凶就是 TIME_WAIT。我们都知道 TCP 主动关闭的一方会进 TIME_WAIT,默认要等 60 秒。这 60 秒里,conntrack 表项也还挂着(nf_conntrack_tcp_timeout_time_wait 默认也是 120 秒,比 socket 的 TIME_WAIT 还长)。算一笔账:应用每秒发起 100 个请求、每个连接活 3 秒,那么稳态下 socket 层的连接只有 300 个左右,但 conntrack 表里可能同时躺着 100 × 120 = 12000 条表项。并发 5000、每个连接多几次重试,这个数轻松过 65536。
另外几个常见的放大因素:
1. UDP 和 ICMP 的表项寿命太长。UDP 没有连接概念,内核只能靠固定超时(默认 30 秒)来回收。如果你的业务有大量 DNS 查询、SNMP 轮询、或者 P2P 打洞,UDP 表项会持续堆积。
2. 重传和连接失败也在占位置。每次 SYN 重传、每次被拒绝的连接尝试,都会先建一条表项再删掉,高峰期短时间内就能吃掉不少。
3. 容器环境的天生放大。每条 Pod 流量都要过一遍宿主机 netfilter,K8s 集群里几百个 Pod 各自发起大量短连接,conntrack 压力比裸机大一个量级。我见过一台 4 核 8G 的控制节点,nf_conntrack_max 还是默认的 65536,直接被撑爆。
第一步,看内核有没有报这个错:
grep -c 'nf_conntrack: table full' /var/log/messages
有这条日志,基本就锁定了。内核版本不同,日志可能打在 /var/log/kern.log 或者 journald 里,用 journalctl -k | grep conntrack 也一样。
第二步,看当前实际用满了多少。内核会把这个计数暴露成一个目录:
cat /proc/sys/net/netfilter/nf_conntrack_count
这个数字就是当前表项数。跟上限比一下:
cat /proc/sys/net/netfilter/nf_conntrack_max
如果 count 已经贴着 max 在跑(比如 65000/65536),那不用再猜了。我一般把这两个值一起放进监控,count/max 超过 0.8 就告警,比等丢包了再回头看强得多。
第三步,看有没有 insert_failed 之类的丢包计数:
conntrack -S 2>/dev/null | grep -E 'insert_failed|drop|early_drop'
insert_failed 是尝试插入新表项失败的次数,这个数字只要不为 0 且在涨,就说明正在丢连接。老内核没装 conntrack 工具的话,直接读 /proc/net/stat/nf_conntrack 这个文件,里面每一列都有对应的名字和累计值。
第四步,排除掉其它可能。很多人一看到丢包就怀疑带宽,其实很好区分:带宽不够的表现是慢,conntrack 满了的表现是「连不上」或者「新建连接超时」。跑一把 ss -s 看有没有大量 SYN-SENT 堆积,再 ss -tan state time-wait | wc -l 数一下 TIME_WAIT 的规模,如果 TIME_WAIT 有几万个,那基本就是它了。
1. 把 max 调大,但别调成天文数字。conntrack 表项本身占内存,内核一般按每条几百字节估算,dmesg 里 nf_conntrack 模块加载时通常会打印一行估算值,比如 `16384 entries` 用了多少内存。粗略按 300 字节一条算,100 万条就是 300MB 内存,你可以按机器实际内存倒推一个数:
sysctl -w net.netfilter.nf_conntrack_max=1048576
写进 /etc/sysctl.d/ 下的文件使其持久化。调大只是把天花板抬高,不解决表项周转慢的问题,所以下面两条要一起做。
2. 缩短 TIME_WAIT 的跟踪超时。如果你的业务短连接为主、且能接受极短时间内同源端口复用,把这两个参数调小:
sysctl -w net.netfilter.nf_conntrack_tcp_timeout_time_wait=30
sysctl -w net.netfilter.nf_conntrack_tcp_timeout_established=3600
注意副作用:调得太小会让长连接被提前清理,出现「连着连着就断了」的反向问题。所以我的做法是先观察 conntrack -S 里的表项年龄分布,确认绝大多数表项确实是短命连接,再下手。
3. 对确认不需要跟踪的流量直接跳过。如果你这台机器上跑的是 K8s,节点之间的 Pod 流量走的是 Calico 之类的 overlay 网络,内核这层 NAT 根本用不上,那可以在 raw 表里直接 NOTRACK,跳过跟踪这一段开销。这是最干净的做法,但它需要你非常清楚自己的流量路径,改错了会导致连接直接不通,所以务必先在测试环境验证。
4. 长连接尽量复用,别每次都新建。连接池、keep-alive 这些手段看着老套,但直接决定了 conntrack 表项的生成速率。业务侧能做一次连接复用,表项压力能降一大截。
最后一点也是我觉得最实际的:conntrack 撑爆,往往说明这台机器已经在超负荷跑边界了。你可以把「表项数 / 内存 / CPU」三个维度一起看,如果这台网关既要跑转发又要跑业务、内存本来就是 4G 卡的,那调参数只是把崩溃时间从半夜推迟到周末。真正该做的是把纯转发的流量挪到专门的网关机器上,让它只干 NAT 这一件事,参数怎么调都不会是根因。
回过头复盘那次故障,我最后是三件事一起做的:max 提到 100 万、TIME_WAIT 跟踪超时压到 30 秒、再把一个跑在网关上的业务挪走了。之后压到 8000 并发,count 也只到 12 万左右,没再满过。写下来主要是想给后面接手的人留点提示:遇到 NAT 机器丢包,先看 dmesg,再看 nf_conntrack_count,别一上来就加带宽。



