暗色模式

Linux 连接跟踪 conntrack:为什么表是空的、满了会怎样

技术教程
2026-10-01
7
0
本文要点
  • 内核的连接跟踪表(conntrack)记录每条经过 netfilter 的连接:五元组、状态、剩余超时、是否双向确认。查看用 conntrack -L,装包只要 conntrack-tools
  • 表是空的,往往不是没在跟踪,而是跟踪压根没启动:conntrack 的 hook 只在「规则集里有人引用 ct」时才注册。实测不加任何规则时,跑完一次 HTTPS 请求 conntrack -C 仍然是 0;加一条 ct state established counter 之后,连规则生效前就存在的老连接也会被半路补录
  • 补录的条目先按 unacknowledged 档计 300 秒并标记 [UNREPLIED];一看到反方向流量就升级为 [ASSURED],此后每个包按 established 档刷新——实测表里那条连接的剩余时间是 431999(即 432000 秒 = 5 天)
  • 一条 TCP 连接在 conntrack 里的迁移顺序是 SYN_SENT → SYN_RECV → ESTABLISHED → FIN_WAIT → LAST_ACK → TIME_WAIT,超时分别是 120 / 60 / 432000 / 120 / 30 / 120 秒,和 sysctl 读出来的一一对应
  • 表满不是「变慢」,是直接失败:把 nf_conntrack_max 压到 64 后实测 sendto 返回 [Errno 1] Operation not permitted,表 64/64,内核日志刷 nf_conntrack: table full, dropping packet。一条条目在内核 slab 里占 320 字节,默认的 65536 上限约值 20MB 内存,调大之前先算账;表本身是按网络命名空间隔离的

Linux 连接跟踪 conntrack:为什么表是空的、满了会怎样

做 NAT、做状态防火墙、做限流,都会碰到同一个东西:连接跟踪表。它记着「这条连接是谁跟谁、走到哪一步了、还要记多久」,是 netfilter 实现「有状态」的底层数据结构。

麻烦在于它的表现很反直觉。第一次上手的人通常会撞上两件事:

  1. 明明在跑服务、在下载、在 SSH,conntrack -L 却一条都没有——于是怀疑工具装错了、内核没编进去;
  2. 某天服务开始零星失败,日志里只有一句 table full, dropping packet——于是下意识去调大上限,却不知道每个条目要花多少内存。

这两个问题的答案都不复杂,但需要把「跟踪什么时候启动」「条目怎么计时」「满了之后发生什么」三件事串起来看。下面按这个顺序,在一台 1 核 1.9G、无 swap 的 Ubuntu 22.04 演示机(内核 5.15.0-30-generic、conntrack-tools 1.4.6)上一步步实测。

conntrack 记的是什么

先明确它在内核里的位置。每个包经过 netfilter 的钩子点时,conntrack 会拿它的五元组(协议、源地址、源端口、目的地址、目的端口)去哈希表里查:

  • 查到:刷新这条条目的计时器,把包标记成「属于已跟踪连接」,继续往下走;
  • 查不到:如果这是个 SYN,就新建一条条目;如果是半路插进来的包,得看 nf_conntrack_tcp_loose 的态度(后面细说)。

条目本身长这样,conntrack -L 的一行就是一条:

tcp      6 431999 ESTABLISHED src=127.0.0.1 dst=127.0.0.1 sport=57810 dport=15008 src=127.0.0.1 dst=127.0.0.1 sport=15008 dport=57810 [ASSURED] mark=0 use=1

从左到右读:

  • tcp / 6——四层协议名和协议号;
  • 431999——距离这条条目被删除还有多少秒(是倒计时,不是已存活时间,这一列最容易看错);
  • ESTABLISHED——conntrack 自己的状态机,不完全等于 ss 里的 TCP 状态,但大体同步;
  • 前一组四元组是原始方向(谁发起),后一组是应答方向;有 NAT 时,后一组显示的是转换后的地址端口;
  • [ASSURED]——已经看到过双向流量;如果是 [UNREPLIED],说明只看见一个方向;
  • mark=0 是 connmark(配合策略路由、QoS 用),use=1 是引用计数。

装工具包,一条命令:

apt-get install -y conntrack
conntrack --version

conntrack 命令本身只是这张表的读写接口,真正干活的是内核模块 nf_conntrack。

上限:65536 条,每条 320 字节

先看这张表被允许长多大,以及每条要花多少内存:

sysctl net.netfilter.nf_conntrack_max net.netfilter.nf_conntrack_buckets
grep -E "^nf_conntrack" /proc/slabinfo
net.netfilter.nf_conntrack_max = 65536
net.netfilter.nf_conntrack_buckets = 65536
nf_conntrack          72     72    320   12    1 : tunables    0    0    0 : slabdata      6      6      0

三个数字各有含义:

  • max = 65536——表里最多同时存在多少条条目,超了就往日志里喊 table full;
  • buckets = 65536——哈希桶数量。桶越多冲突越少、查找越快。它由 nf_conntrack 模块的 hashsize 参数在加载时确定,运行时一般只调 max;
  • slabinfo 里的 320——每个条目对象在内核里占 320 字节(那一行的格式是 对象总数 活跃对象数 对象大小)。

最后这个数字很关键,它让「调大上限」变成一道算术题:65536 × 320B ≈ 20MB,这是默认上限对应的内存开销;如果把 max 提到 100 万,光这张表就要预留 约 320MB——在一台 1.9G 内存的机器上,这个决定会直接改变 OOM 的命运。所以调大之前先算一下,而不是看到 table full 就无脑加零。

(顺带注意:slabinfo 里当前只有 72 个对象,这张表是按需增长的,不是开机就把 65536 条都占满。)

表是空的:hook 要有人用才会注册

现在进正题。下面这段脚本先清空表,跑一次真实的 HTTPS 请求,再起一条「规则生效前就存在」的环回连接,最后加上一条最普通的 conntrack 规则:

modprobe nf_conntrack
sysctl net.netfilter.nf_conntrack_max net.netfilter.nf_conntrack_buckets
conntrack -F 2>&1
curl -s -o /dev/null -m 8 https://www.baidu.com
echo "--- 跑过一次 HTTPS 请求之后,表里的条目数 ---"
conntrack -C
conntrack -L 2>&1 | tail -1
echo "--- 起一条环回连接挂着,此刻还没有任何规则 ---"
( timeout 20 conntrack -E -p tcp > /tmp/pickup.txt 2>&1 & )
( timeout 26 python3 -c "
import socket, time
srv = socket.socket(); srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(('127.0.0.1', 15008)); srv.listen(1)
cli = socket.create_connection(('127.0.0.1', 15008), 3)
conn, _ = srv.accept()
time.sleep(5)
for i in range(12):
    cli.sendall(b'ping')
    conn.recv(16)
    conn.sendall(b'pong')
    cli.recv(16)
    time.sleep(1)
" & )
sleep 2
nft delete table inet ctdemo 2>/dev/null
nft add table inet ctdemo
nft 'add chain inet ctdemo output { type filter hook output priority 0 ; }'
nft add rule inet ctdemo output ct state established counter
sleep 6
echo "--- 加上规则之后,只挑出演示连接的事件 ---"
grep 127.0.0.1 /tmp/pickup.txt | head -4
echo "--- 此刻表里的条目数,以及那条连接的详情 ---"
conntrack -C
conntrack -L -p tcp -d 127.0.0.1 2>&1 | head -2
nft delete table inet ctdemo
rm -f /tmp/pickup.txt

关键的两段输出:

--- 跑过一次 HTTPS 请求之后,表里的条目数 ---
0
conntrack v1.4.6 (conntrack-tools): 0 flow entries have been shown.

明明刚刚建立了一条到外网的 HTTPS 连接,表里却是 0。 这不是 bug,是设计:nf_conntrack 模块加载了,但它挂在 netfilter 上的钩子函数只有在规则集里有人引用连接跟踪时才会注册。

也就是说,真正的开关不是 modprobe,而是「有没有一条规则关心 ct 状态」——nftables 里的 ct state、iptables 里的 -m state / -m conntrack,或者任何 NAT 规则。没有这些,内核就没必要为每个包做哈希查找,于是什么也不记。

脚本后半段加的那条规则在 output 钩子上做了 ct state established counter——它不拦截任何包,只是「看一眼状态、记个数」,但足以让 conntrack 的 hook 注册进来:

加规则前后对比:没有规则时 HTTPS 请求跑完表里是 0 条;规则一加上,一条规则生效前就存在的环回连接立刻被半路补录,事件流显示它从 NEW、UNREPLIED 升级到 ASSURED,表里那条连接的剩余时间是 431999

图的下半段就是关键:那条环回连接在规则生效之前就已经建立了,规则一加上,它立刻出现在表里。

半路补录:300 秒档与 [UNREPLIED] → [ASSURED]

「半路补录」由 nf_conntrack_tcp_loose 控制,它默认就是开的:

sysctl net.netfilter.nf_conntrack_tcp_loose
net.netfilter.nf_conntrack_tcp_loose = 1

它的意思是:允许一个没看到握手过程的包,直接在表里建一条 ESTABLISHED 条目——否则这样的包会被判为 INVALID 丢掉,那意味着任何「先建连接、后加规则」的场景(热更新防火墙、刚启动 Docker、加载 NAT 模块)都会把存量连接全部打断。

上图事件流里那三行就是完整过程:

[NEW] tcp      6 300 ESTABLISHED src=127.0.0.1 dst=127.0.0.1 sport=57806 dport=15008 [UNREPLIED] src=127.0.0.1 dst=127.0.0.1 sport=15008 dport=57806
[UPDATE] tcp      6 300 src=127.0.0.1 dst=127.0.0.1 sport=57806 dport=15008 src=127.0.0.1 dst=127.0.0.1 sport=15008 dport=57806
[UPDATE] tcp      6 300 src=127.0.0.1 dst=127.0.0.1 sport=57806 dport=15008 src=127.0.0.1 dst=127.0.0.1 sport=15008 dport=57806 [ASSURED]
  • [NEW] … 300 ESTABLISHED [UNREPLIED]:补录时状态直接给 ESTABLISHED(毕竟连接本来就是通的),但计时先按 unacknowledged 档的 300 秒走,并打上 [UNREPLIED] 表示「只看见一个方向」;
  • 第一次 [UPDATE]:又看到一个包,刷新计时;
  • 第二次 [UPDATE] … [ASSURED]:反方向的包出现了(演示里的环回连接是双向收发的),于是标记为已确认。

[ASSURED] 之后计时档换成 established,此后每个包都把倒计时重置回 432000 秒——所以再查这张表,看到的是 431999:

tcp      6 431999 ESTABLISHED src=127.0.0.1 dst=127.0.0.1 sport=57810 dport=15008 src=127.0.0.1 dst=127.0.0.1 sport=15008 dport=57810 [ASSURED] mark=0 use=1

这两个档位的差距(300 秒 vs 5 天)解释了线上一个常见现象:如果一台机器是在「已经跑着业务」的状态下才第一次启用 conntrack 规则,那么它所有的存量长连接都是补录进来的。此时若反向流量一直没出现(单向推送、只写不读的连接),这些条目会一直按 300 秒一档走,每 300 秒就要重新补录一次;而一旦双向流量跑起来,它们就稳稳待上 5 天。

演示用环回连接,是因为这里只需要「一条在规则生效前就已建立、之后还会继续通信」的连接,环回连接不依赖外网、随时可复现,现象和真实连接完全一致。真实环境里这个角色通常是你自己的 SSH 会话或数据库长连接——本次实测中,加规则那一刻本机正在使用的 SSH 连接确实立刻以 [NEW] … 300 ESTABLISHED [UNREPLIED] 的形式被补录,随后随反向流量升级成 [ASSURED]。

一条 TCP 连接的完整状态迁移

补录是特例,正常握手的条目会走完整的状态机。下面用 conntrack -E 订阅事件,同时发起一条真实的 TCP 连接再关闭,最后把各档超时一并读出来:

nft delete table inet ctdemo 2>/dev/null
nft add table inet ctdemo
nft 'add chain inet ctdemo output { type filter hook output priority 0 ; }'
nft add rule inet ctdemo output ct state established counter
( timeout 9 conntrack -E -p tcp --dport 80 > /tmp/states.txt 2>&1 & )
sleep 1
python3 -c "
import socket
s = socket.create_connection(('223.5.5.5', 80), 5)
s.close()
"
sleep 6
cat /tmp/states.txt
echo "--- 各状态的超时档(秒)---"
sysctl net.netfilter.nf_conntrack_tcp_timeout_syn_sent net.netfilter.nf_conntrack_tcp_timeout_syn_recv
sysctl net.netfilter.nf_conntrack_tcp_timeout_established net.netfilter.nf_conntrack_tcp_timeout_fin_wait
sysctl net.netfilter.nf_conntrack_tcp_timeout_last_ack net.netfilter.nf_conntrack_tcp_timeout_time_wait
sysctl net.netfilter.nf_conntrack_tcp_timeout_unacknowledged net.netfilter.nf_conntrack_tcp_timeout_max_retrans
rm -f /tmp/states.txt
nft delete table inet ctdemo

conntrack -E 事件流实测:一条 TCP 连接从 SYN_SENT(120) 到 SYN_RECV(60)、ESTABLISHED(432000) 并标记 ASSURED、FIN_WAIT(120)、LAST_ACK(30)、TIME_WAIT(120) 的完整迁移,以及各状态超时档的 sysctl 取值

事件流里的数字就是这一档的超时秒数,和下面 sysctl 读出来的一一对应:

conntrack 状态超时参数本机实测值
SYN_SENTnf_conntrack_tcp_timeout_syn_sent120 秒
SYN_RECVnf_conntrack_tcp_timeout_syn_recv60 秒
ESTABLISHEDnf_conntrack_tcp_timeout_established432000 秒(5 天)
FIN_WAITnf_conntrack_tcp_timeout_fin_wait120 秒
LAST_ACKnf_conntrack_tcp_timeout_last_ack30 秒
TIME_WAITnf_conntrack_tcp_timeout_time_wait120 秒
(补录未确认)nf_conntrack_tcp_timeout_unacknowledged300 秒
(重传上限)nf_conntrack_tcp_timeout_max_retrans300 秒

几个值得记的点:

  • ESTABLISHED 的 5 天是「空闲超时」,不是连接寿命。只要连接上还有包,倒计时就一直被刷回 432000。反过来说,一条完全空闲的长连接可以在表里躺 5 天才消失——这也是表容易被长连接慢慢占满的原因;
  • SYN_SENT 的 120 秒是排查利器。对端不可达时,半连接会在表里以 SYN_SENT 挂满 120 秒并不断重传。查表时看到一堆 SYN_SENT,就说明有大量连不上的目标;
  • SYN_RECV 只有 60 秒,它是「我回了 SYN-ACK 但对方没回 ACK」的中间态,正常情况下应该一闪而过。如果这里持续堆积,通常意味着被扫端口或半开攻击;
  • TIME_WAIT 的 120 秒是 conntrack 侧的计时,和 ss 里看到的 TCP TIME_WAIT(2MSL,通常 60 秒)是两套账,别混为一谈。

conntrack -E 是排查时最有用的子命令:它把「新建 / 更新 / 销毁」当作事件流打出来,正好补上 conntrack -L 只有「此刻快照」的短板。上面的演示只监听了 --dport 80,实际可以按协议、地址、端口过滤,也可以加 -o timestamp 带上时间戳。

表满之后:不是变慢,是直接失败

最后压一下上限。把 nf_conntrack_max 临时改小到 64,然后用一堆 UDP socket 去填(目标用 192.0.2.1,RFC 5737 保留的测试网段,不会真的发到公网):

ORIG=$(cat /proc/sys/net/netfilter/nf_conntrack_max)
echo "默认上限 nf_conntrack_max = $ORIG"
nft delete table inet ctdemo 2>/dev/null
nft add table inet ctdemo
nft 'add chain inet ctdemo output { type filter hook output priority 0 ; }'
nft add rule inet ctdemo output ct state established counter
sysctl -w net.netfilter.nf_conntrack_max=64
python3 -c "
import socket
for i in range(300):
    s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    try:
        s.sendto(b'x' * 8, ('192.0.2.1', 9999))
    except OSError as e:
        print('第 %d 个 socket 发包失败:%s' % (i + 1, e))
        break
"
echo "--- 现在 count / max = $(cat /proc/sys/net/netfilter/nf_conntrack_count) / $(cat /proc/sys/net/netfilter/nf_conntrack_max) ---"
dmesg | grep -i conntrack | tail -1
echo "--- 恢复上限、删掉测试条目和演示规则 ---"
sysctl -w net.netfilter.nf_conntrack_max=$ORIG
conntrack -D -p udp -d 192.0.2.1 > /dev/null 2>&1
nft delete table inet ctdemo
conntrack -F > /dev/null 2>&1
echo "清理后条目数:$(conntrack -C)"

连接跟踪表满实测:nf_conntrack_max 压到 64 后第 43 个 socket 发包就返回 Errno 1 Operation not permitted,count/max 是 64/64,内核日志刷出 nf_conntrack: table full, dropping packet,恢复上限并清理后条目数回到 0

三个观察:

  1. 报错是 [Errno 1] Operation not permitted,不是超时、也不是 ENOBUFS。 这最容易误判——写脚本时若只捕获「超时类」异常,就会漏掉这个 EPERM,表现成「程序莫名其妙报权限错误」,然后去查 SELinux / capabilities,方向全错;
  2. 填到第 43 个就失败了,不是第 65 个。 因为表里本来就有这台机器自己的条目(SSH 会话、DNS、NTP 等二十来条),它们和新填的 UDP 条目共享同一个 64 的额度;
  3. 内核日志会留下证据:nf_conntrack: nf_conntrack: table full, dropping packet。看到 table full 就该去查这张表,而不是查磁盘或内存。

count / max 这两个数可以直接读 procfs,做监控最方便:

cat /proc/sys/net/netfilter/nf_conntrack_count
cat /proc/sys/net/netfilter/nf_conntrack_max

conntrack -S 则给出每 CPU 的计数器,用来区分「表满」和「包本身有问题」:

cpu=0       found=0 invalid=3 insert=0 insert_failed=2 drop=8 early_drop=0 error=0 search_restart=6

invalid 是包本身不符合协议(畸形包、状态错乱),insert_failed 是插入失败(绝大多数情况就是表满),drop 是因此被丢掉的包。insert_failed 持续增长,基本可以确诊表满。

演示完的收尾很关键,就两步:先把上限改回去,再定向删掉测试条目和演示规则(conntrack -D 支持按协议、地址、端口、状态过滤)。表满急救时也应该用 -D 定向删,而不是直接 -F 全清——全清会把 NAT 会话一起抹掉,正在转发的连接会断。

常用命令速查

需求命令
看表里有多少条conntrack -C
列出全部条目conntrack -L
只看某个目标conntrack -L -p tcp -d 203.0.113.5
只看某个端口conntrack -L --dport 80
只看某个状态conntrack -L --state SYN_SENT
实时订阅事件conntrack -E -p tcp --dport 80 -o timestamp
每 CPU 统计conntrack -S
定向删除conntrack -D -p udp -d 192.0.2.1
清空整张表conntrack -F

监控告警盯的是 nf_conntrack_count / nf_conntrack_max 的比值,超过 80% 就该介入——等 table full 出现,业务已经在报错了。

调参分临时和持久两种:

# 临时生效,立即起作用,重启丢失
sysctl -w net.netfilter.nf_conntrack_max=262144

# 持久化:写进 sysctl.d,重启后自动生效
echo 'net.netfilter.nf_conntrack_max = 262144' > /etc/sysctl.d/99-conntrack.conf
sysctl --system

三个提醒:

  • 先算内存再动手:每条 320 字节,262144 × 320B ≈ 80MB;
  • buckets 不是运行时改的,它跟随模块参数 hashsize。改大 max 之后桶数不变,条目变多会让哈希链变长、查找变慢——真要大幅提高 max,需要在加载模块时一并指定 hashsize;
  • 表大只是缓解,不是解决。如果 count 长期贴着上限,真正该查的是「谁在制造这么多短连接」:爬虫、压测、UDP 洪水,或者某个忘了复用连接的应用。不要把调大上限当成修复。

还有一个容易忽略的点:连接跟踪表是按网络命名空间隔离的,每个 netns 各有一张自己的表、各自的 nf_conntrack_max。容器里看到的 count 和宿主机不是一回事,排查容器网络问题时别查错表(netns 的用法见 Linux 网络命名空间:ip netns 与 veth 对)。

小结

回到开头那两个反直觉的现象,现在都有答案了:

  1. 表是空的——不是没装、没编进内核,而是没有规则引用 ct,hook 就没注册。想让它开始工作,加一条 ct state … 的规则即可;加上的瞬间,存量连接会被「半路补录」,先按 300 秒档计时并标 [UNREPLIED],看到反向流量后升级 [ASSURED]、切到 5 天档。
  2. 表满之后——不是变慢,是直接失败:发包方拿到 EPERM,内核刷 table full, dropping packet,conntrack -S 里 insert_failed 上涨。此时该做的是「定向删除 + 找源头」,而不是无脑调大上限。

把这张表和 socket 层配合起来看,才是一张完整的图:ss 告诉你进程和连接现在什么状态,conntrack -L 告诉你内核为这些连接记住了什么、还会记多久——做 NAT、限流、状态防火墙时,真正生效的是后者。socket 层的完整排查方法见 Linux TCP 连接诊断:从 ss 到内核计数器。

本文全部演示在一台 1 核 1.9G、无 swap 的 Ubuntu 22.04(内核 5.15.0-30-generic、conntrack-tools 1.4.6)上完成,只装了一个 conntrack 包;收尾已恢复 nf_conntrack_max、删除演示用的 nft 表和测试条目,结束时表里 0 条、规则集 0 张表。

发表评论

暂无评论,快来抢沙发吧!