一、先说清楚 conntrack 到底在干什么
很多人第一次碰到 nf_conntrack: table full, dropping packet 这条内核日志时,第一反应是「内存不够了」,于是去加内存、调大 net.netfilter.nf_conntrack_max,然后过几天又复发。真正的原因几乎从来不是内存,而是对这张表「记什么、什么时候删、谁在刷新」没有概念。
Linux 里的 conntrack 是 netfilter 的连接跟踪模块。它存在的初衷不是为了「统计连接」,而是为了让 iptables 能做有状态的判断:一条进来的包属于哪条已有连接,是不是同一条会话里的后续包,NAT 之后要不要改回原地址。为此内核必须为每条「连接」维护一份记录,里面记着五元组、两个方向的状态、期望的超时时间、NAT 映射关系、以及一些标记位。这份记录就是 conntrack entry。
关键点在于:这里说的「连接」是内核视角的连接,不是 TCP 视角的连接。UDP 也有 conntrack 条目,ICMP 也有,甚至你 ping 一次就会产生一条短命的记录。所以一张表里往往混着 TCP、UDP、ICMP 三类记录,光看「连接数」这个数字,根本不知道是谁把表占满的。
再看三个容易混淆的数字,这是定位问题的起点:
nf_conntrack_count:当前表里有多少条记录。这是实时值。nf_conntrack_max:表的上限。到顶之后新连接不会被拒绝,而是被直接丢包,同时内核按限速打印table full, dropping packet。nf_conntrack_buckets:哈希桶的数量。它影响的是查找效率,不是容量。桶太少时即使在 max 以下,CPU 也会因为长链遍历而升高。
这三个值都可以在 /proc/sys/net/netfilter/ 下读到。如果这个目录不存在,说明 conntrack 模块没加载,那这条日志压根不会出现——但反过来说,如果你的机器在跑 Docker、跑 NAT、跑任何用了 state 或 conntrack 匹配的防火墙规则,这个模块就一定在。
二、为什么小站也会被 conntrack 打满
直觉上「我一天才几千 PV,怎么可能把几万条连接表填满」。实际踩坑的多数是这样几种场景,和流量大小关系不大:
第一种:Docker 的默认 NAT 规则。Docker 起容器时会往 nat 表插 DNAT 规则,容器内所有出站流量都要经过 MASQUERADE。一个跑着爬虫、跑着定时任务、跑着监控探针的容器,很容易在短时间内建立大量对外连接。容器越多、任务越碎,条目涨得越快。而且容器重启后旧条目不会立刻消失,要等超时。
第二种:CDN 回源或反向代理把客户端连接放大。Nginx 作为反代时,一条用户请求在源站这侧至少产生两条连接(客户端到 Nginx、Nginx 到上游)。如果同时开着 keepalive 到上游、又有健康检查探针,条目数是用户数的两倍以上。健康检查尤其阴险:每秒一次、并发几十个 upstream,一天一千多万次连接尝试,哪怕每次只存活几百毫秒,峰值也会堆起来。
第三种:UDP 服务的短命条目。DNS 服务、syslog 转发、监控 agent 上报,每一个 UDP 包对都可能产生一条条目,默认 nf_conntrack_udp_timeout 是 30 秒。一个每秒 500 包的 DNS 查询场景,稳态下就是一万五千条 UDP 记录挂在表里,看起来毫无道理,但确实如此。
第四种:被扫描或被打。这台机器如果有公网 IP、又没在前面挡一层,端口扫描、SSH 爆破、各种僵尸网络的探测,每一个 SYN 都会建条目。半开连接的超时是 nf_conntrack_tcp_timeout_syn_sent,默认 120 秒,扫描器一秒钟发几千个 SYN,两分钟就能把 65536 的表打满。
这四种成因的处理方式完全不同,所以第一件事不是调参数,而是先把表里的东西分类看清楚。
三、现场取证:看清表里到底是谁
最直接的工具是 conntrack 命令(Debian/Ubuntu 在 conntrack-tools 包里,CentOS 在 conntrack-tools 同名包)。不要一上来就 conntrack -L 全量打印——表大时这个命令会卡住几十秒,还可能把本来就紧张的内存再压一把。正确做法是先分类计数。
# 按协议分布,一眼看出是 TCP 还是 UDP 占表
conntrack -L 2>/dev/null | awk '{print $1}' | sort | uniq -c | sort -rn
# 按状态分布,找 SYN_SENT(扫描/爆破)与 TIME_WAIT(短连接风暴)
conntrack -L -p tcp 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i ~ /^(ESTABLISHED|SYN_SENT|SYN_RECV|TIME_WAIT|CLOSE_WAIT|FIN_WAIT)$/) print $i}' | sort | uniq -c | sort -rn
# 按源 IP 排行,找把表打满的那一个
conntrack -L 2>/dev/null | grep -oE 'src=[0-9.]+' | sort | uniq -c | sort -rn | head -20跑完基本就能定性:如果 SYN_SENT 占大头且源 IP 集中,是被扫或被打;如果 TIME_WAIT 占大头,是本机向外发起的短连接太多;如果 UDP 占一半以上,回去查 DNS 或监控 agent。
再看两个实时值确认是否真到顶:
cat /proc/sys/net/netfilter/nf_conntrack_count
cat /proc/sys/net/netfilter/nf_conntrack_max
dmesg -T | grep -i 'nf_conntrack' | tail -20
cat /proc/net/stat/nf_conntrack | head -2最后一行 /proc/net/stat/nf_conntrack 是很多人不知道的:它给出每个 CPU 上的 found、invalid、insert、insert_failed、drop、early_drop 计数。insert_failed 持续上涨就是表满的证据;early_drop 说明内核在提前淘汰「最没价值」的条目来腾位置,这个值大于 0 意味着已经开始影响正常连接了。
四、超时时间:真正的容量控制器
表的容量不是 max 决定的,是 max 和各个超时参数共同决定的。一条记录活多久,取决于它现在是什么状态,而每个状态的超时都可以单独设:
sysctl net.netfilter.nf_conntrack_tcp_timeout_established # 默认 432000 秒(5 天)
sysctl net.netfilter.nf_conntrack_tcp_timeout_time_wait # 默认 120 秒
sysctl net.netfilter.nf_conntrack_tcp_timeout_close_wait # 默认 60 秒
sysctl net.netfilter.nf_conntrack_tcp_timeout_syn_sent # 默认 120 秒
sysctl net.netfilter.nf_conntrack_udp_timeout # 默认 30 秒
sysctl net.netfilter.nf_conntrack_udp_timeout_stream # 默认 120 秒
sysctl net.netfilter.nf_conntrack_generic_timeout # 默认 600 秒那个 5 天是历史上最有争议的一个默认值。它的设计意图是「让长连接不被误删」,代价是一条已经死掉、两端都没发 FIN 的连接(比如客户端断网、NAT 设备重启)要在表里躺五天。家用宽带出口、手机网络切基站、CDN 边缘节点回收,都会造成大量「僵尸 established」。
小站服务器把 established 从 5 天改到 1800 秒或 3600 秒,是最有效的一招。风险很明确:如果确实有应用依赖超过半小时空闲不断开的长连接(比如某些数据库连接池、某些长轮询接口),改小会导致连接被内核静默丢弃,应用侧表现为偶发的「connection reset」或「查询超时」。所以改之前先确认没有这类长连接,或者给这类流量单独处理——用 raw 表 NOTRACK 放行,让它根本不进 conntrack。
NOTRACK 这个思路值得展开说。对于明确不需要状态跟踪的流量(比如只是转发出去的大流量、或某些内部服务间的固定端口),在 raw 表里打标记可以完全跳过 conntrack,既省表又省 CPU:
iptables -t raw -A PREROUTING -p tcp --dport 8080 -j CT --notrack
iptables -t raw -A OUTPUT -p tcp --sport 8080 -j CT --notrack注意进和出两个方向都要标,只标一边会留下一堆状态为 UNREPLIED 的残条。这个做法的代价是失去状态判断能力,所以只该用在「你明确知道这是同一个服务的流量」的端口上。
五、调大 max 之前必须先调桶
nf_conntrack_max 的默认值通常是 内存KB / 64 / 4 之类的公式算出来的,小内存机器上可能是 16384 或 65536。调大本身很简单:
sysctl -w net.netfilter.nf_conntrack_max=262144
echo 262144 > /sys/module/nf_conntrack/parameters/hashsize但这里有个坑:hashsize 必须在 max 调大之后一起调,合理的比例是 max 的四分之一左右。如果只调 max 不动 hashsize,所有条目会挤在原有的桶里,每条链变长,查找从 O(1) 退化成接近 O(n),表现为「表没满,但网络延迟突然变高、CPU 软中断飙升」。这个现象很难和「流量变大」区分开,第 1796 篇讲内核参数时就提过「调了参数没效果」的一类,这里是它的具体成因。
另外,nf_conntrack_max 是通过 sysctl 调的,重启会还原;hashsize 是模块参数,只能通过 /sys/module/ 改,而且某些内核版本下改了也不能超过启动时分配的桶数组上限,需要卸载重载模块才生效。稳妥的写法是把两者都固化到启动配置里:
# /etc/modprobe.d/nf_conntrack.conf
options nf_conntrack hashsize=65536
# /etc/sysctl.d/99-conntrack.conf
net.netfilter.nf_conntrack_max = 262144
net.netfilter.nf_conntrack_tcp_timeout_established = 1800六、比调参更重要的三件事
参数调完只是把天花板抬高,如果不做下面三件事,过几个月还会复发。
第一,把 DNS 与健康检查的短连接改成长连接。很多自建服务的默认配置是「每次请求新建连接」,DNS 转发、对象存储客户端、监控探针都这样。改成连接复用,条目数能降一个数量级。这一改动在应用侧,不在内核侧,所以最容易被忽略。
第二,把不需要跟踪的流量挡在 conntrack 之外。除了上面的 NOTRACK,更彻底的做法是把公网暴露面收窄:SSH 换端口或加 IP 白名单、把源站藏到 CDN 后面、对扫描密集的端口用 ipset 直接 DROP。注意顺序——DROP 要发生在 conntrack 之前(raw 表或 mangle 的 PREROUTING 早期),否则包已经建了条目才被丢,等于白丢。
第三,给 count 加监控。监控 nf_conntrack_count / nf_conntrack_max 的比值,阈值设在 70% 告警。这个比值是线性增长还是尖峰,能直接区分「缓慢泄漏」和「瞬时攻击」两种完全不同的成因。只看「有没有报 table full」已经太晚——报错的那一刻,业务已经在丢包了。
顺便提一个容易误判的现象:nf_conntrack_count 明明很低,但日志里还在刷 table full。这通常是两个原因之一:一是内核对这条日志做了限速打印,日志里的是十几分钟前的旧消息;二是容器有自己的 network namespace,你查的是宿主机的表,打满的是容器里那张。后者在 Docker 环境里非常常见,nsenter -t <pid> -n cat /proc/sys/net/netfilter/nf_conntrack_count 才看得到真实值。
七、一次真实的排查推演
把这套流程串起来看一遍。周一早上收到告警:首页 502,持续时间约 40 秒,反复出现。登录服务器。
第一步看日志,dmesg -T | grep nf_conntrack 打出十几行 table full, dropping packet,时间正好压在 502 的时间点上。第二步看 count/max,是 65536/65536,满了。第三步分类,conntrack -L 统计发现 UDP 占了 4 万多条,源地址集中在内网网段。第四步定位,源地址是同一台机器上的监控容器,它每秒向三百多个目标端口发探测包。第五步处置,先临时把 nf_conntrack_udp_timeout 从 30 秒降到 10 秒止血,再给监控容器换成「只探关键端口 + 提高探测间隔」,条目数掉到八千。最后补监控和持久化配置。
整个过程中,没有调过 max。如果当时第一反应是调大 max,表会从 65536 涨到 262144,问题确实消失一两周,然后在下一次监控扩容或爬虫爆发时以更大的规模复发,而且那时更难定位,因为表里混着更多东西。
八、小结
conntrack 打满这件事,本质是「记录的生命周期 × 发起频率」超过了表容量。调 max 只是把容量做大,调 timeout 才是控制生命周期,减少短连接和把流量挡在跟踪之外才是控制发起频率。三者里前两个改内核参数五分钟搞定,第三个要动应用和网络架构,所以要花时间——但只有第三个能真正解决问题。
判断顺序记牢:先看 count/max 是否真到顶,再按协议和状态分类找出元凶,然后决定是改超时、改应用还是加防护。early_drop 一开始上涨就该动手,别等到 table full 开始丢包。