本文要点
- 内核的连接跟踪表(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 实现「有状态」的底层数据结构。
麻烦在于它的表现很反直觉。第一次上手的人通常会撞上两件事:
- 明明在跑服务、在下载、在 SSH,
conntrack -L却一条都没有——于是怀疑工具装错了、内核没编进去; - 某天服务开始零星失败,日志里只有一句
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 --versionconntrack 命令本身只是这张表的读写接口,真正干活的是内核模块 nf_conntrack。
上限:65536 条,每条 320 字节
先看这张表被允许长多大,以及每条要花多少内存:
sysctl net.netfilter.nf_conntrack_max net.netfilter.nf_conntrack_buckets
grep -E "^nf_conntrack" /proc/slabinfonet.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 注册进来:

图的下半段就是关键:那条环回连接在规则生效之前就已经建立了,规则一加上,它立刻出现在表里。
半路补录:300 秒档与 [UNREPLIED] → [ASSURED]
「半路补录」由 nf_conntrack_tcp_loose 控制,它默认就是开的:
sysctl net.netfilter.nf_conntrack_tcp_loosenet.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
事件流里的数字就是这一档的超时秒数,和下面 sysctl 读出来的一一对应:
| conntrack 状态 | 超时参数 | 本机实测值 |
|---|---|---|
SYN_SENT | nf_conntrack_tcp_timeout_syn_sent | 120 秒 |
SYN_RECV | nf_conntrack_tcp_timeout_syn_recv | 60 秒 |
ESTABLISHED | nf_conntrack_tcp_timeout_established | 432000 秒(5 天) |
FIN_WAIT | nf_conntrack_tcp_timeout_fin_wait | 120 秒 |
LAST_ACK | nf_conntrack_tcp_timeout_last_ack | 30 秒 |
TIME_WAIT | nf_conntrack_tcp_timeout_time_wait | 120 秒 |
| (补录未确认) | nf_conntrack_tcp_timeout_unacknowledged | 300 秒 |
| (重传上限) | nf_conntrack_tcp_timeout_max_retrans | 300 秒 |
几个值得记的点:
ESTABLISHED的 5 天是「空闲超时」,不是连接寿命。只要连接上还有包,倒计时就一直被刷回 432000。反过来说,一条完全空闲的长连接可以在表里躺 5 天才消失——这也是表容易被长连接慢慢占满的原因;SYN_SENT的 120 秒是排查利器。对端不可达时,半连接会在表里以SYN_SENT挂满 120 秒并不断重传。查表时看到一堆SYN_SENT,就说明有大量连不上的目标;SYN_RECV只有 60 秒,它是「我回了 SYN-ACK 但对方没回 ACK」的中间态,正常情况下应该一闪而过。如果这里持续堆积,通常意味着被扫端口或半开攻击;TIME_WAIT的 120 秒是 conntrack 侧的计时,和ss里看到的 TCPTIME_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)"
三个观察:
- 报错是
[Errno 1] Operation not permitted,不是超时、也不是ENOBUFS。 这最容易误判——写脚本时若只捕获「超时类」异常,就会漏掉这个EPERM,表现成「程序莫名其妙报权限错误」,然后去查 SELinux / capabilities,方向全错; - 填到第 43 个就失败了,不是第 65 个。 因为表里本来就有这台机器自己的条目(SSH 会话、DNS、NTP 等二十来条),它们和新填的 UDP 条目共享同一个 64 的额度;
- 内核日志会留下证据:
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_maxconntrack -S 则给出每 CPU 的计数器,用来区分「表满」和「包本身有问题」:
cpu=0 found=0 invalid=3 insert=0 insert_failed=2 drop=8 early_drop=0 error=0 search_restart=6invalid 是包本身不符合协议(畸形包、状态错乱),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 对)。
小结
回到开头那两个反直觉的现象,现在都有答案了:
- 表是空的——不是没装、没编进内核,而是没有规则引用
ct,hook 就没注册。想让它开始工作,加一条ct state …的规则即可;加上的瞬间,存量连接会被「半路补录」,先按 300 秒档计时并标[UNREPLIED],看到反向流量后升级[ASSURED]、切到 5 天档。 - 表满之后——不是变慢,是直接失败:发包方拿到
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 张表。
评论 (0)
暂无评论,快来抢沙发吧!