Skip to content

端口分配与会话老化

前面留了两个问题:那个外部端口是怎么挑出来的?会话什么时候被回收? 这一篇回答它们 —— 顺带解释一类最难查的 NAT 故障。

端口是怎么挑的:先试着不改

你可能已经注意到,好几次实测里源端口都原样保留了:

text
    i2o 10.10.0.11  proto TCP port 39637
    o2i 203.0.113.1 proto TCP port 39637      ← 一模一样

这不是巧合。NAT 的端口分配策略是端口保持优先(port preservation): 能不改就不改。原因有二 —— 一是省事,二是有些老旧协议会在载荷里带自己的端口号, 改了会出问题(虽然正确做法是用 ALG 处理,但少改总是少一分风险)。

那什么时候必须改?冲突的时候。

制造一次端口冲突

让 h1 和 h2 用同一个源端口 30000 访问同一个目的:

bash
ssh chenzhong@192.168.49.182 '
docker exec clab-natbase-h1 sh -c "echo x | nc -u -p 30000 -w 1 203.0.113.11 9001"
docker exec clab-natbase-h2 sh -c "echo x | nc -u -p 30000 -w 1 203.0.113.11 9001"'
text
[s1 udp/9001] 我看到你的地址是 203.0.113.1:30000     ← h1,保留了
[s1 udp/9001] 我看到你的地址是 203.0.113.1:49895     ← h2,被迫改了

会话表:

text
    i2o 10.10.0.11  proto UDP port 30000
    o2i 203.0.113.1 proto UDP port 30000       ← 先到先得
    i2o 10.10.0.12  proto UDP port 30000
    o2i 203.0.113.1 proto UDP port 49895       ← 只好另挑一个

为什么第二个必须改?看 o2i 方向就明白了。如果两条会话都用 203.0.113.1:30000, 那么 s1 回过来的包(目的 203.0.113.1:30000)到底该发给 h1 还是 h2?没法判断。 o2i 键必须唯一,这是硬约束。

text
  o2i 键必须唯一
  ┌──────────────────────┬──────────────────┐
  │ 203.0.113.1:30000    │ →  10.10.0.11    │  ✓
  ├──────────────────────┼──────────────────┤
  │ 203.0.113.1:30000    │ →  10.10.0.12    │  ✗ 冲突!
  └──────────────────────┴──────────────────┘
                            所以第二条只能换端口

ED 的端口复用优势

这里能看到 ED 的一个实际好处。ED 的 o2i 键包含外网端,所以:

text
  ED 的 o2i 键(唯一性要求宽松)
  ┌──────────────────────┬──────────────────────┬────────────┐
  │ 外部地址端口          │ 外网端                │ → 内网      │
  ├──────────────────────┼──────────────────────┼────────────┤
  │ 203.0.113.1:23456    │ 203.0.113.11:9001    │ 10.10.0.11 │  ✓
  │ 203.0.113.1:23456    │ 203.0.113.12:9001    │ 10.10.0.11 │  ✓ 不冲突
  └──────────────────────┴──────────────────────┴────────────┘
                                                   ↑ 目的不同,键就不同

这正是上一篇里 ED 能对两个目的复用同一个外部端口 23456 的原因。 换成 EI,一个外部端口一旦被某个内网端点占用,就对所有目的都被占用了。

结论:ED 的端口利用率高于 EI。 运营商级 NAT 要在有限的公网地址上撑起几万用户, 端口是最稀缺的资源 —— 这是 CGN 普遍选择端点相关(对称型)的现实原因, 哪怕它牺牲了 P2P 体验。

一个公网地址到底能撑多少用户

端口号是 16 位,理论上 65536 个。但:

text
  65536  理论总数
  -1024  0-1023 是知名端口,通常不用于源端口
  ──────
  64512  可用池

  一个用户同时能开多少个会话?
    · 轻度浏览:几十条
    · 打开一个现代网页:单页就可能几十上百条(各种资源、追踪、长连接)
    · 挂着几个 App 后台同步:持续几十条
    · 用 P2P 下载:能到上千条

  按每用户平均 100 条并发会话算:
    64512 / 100 ≈ 645 个用户 / 每个公网地址

这个数字解释了两件事:

  1. 为什么运营商 NAT 要用地址池而不是单地址 —— 一个地址几百个用户, 一个城域网几十万用户,需要成百上千个公网地址。
  2. 为什么 P2P 下载会被运营商限制 —— 一个重度 P2P 用户能吃掉几十个普通用户的端口配额。

VPP 的会话上限是另一个独立限制

bash
./labctl vppctl base show nat44 summary
text
max translations per thread: 64512 fib 0

注意这是每线程的会话数上限,和端口数是两回事:

  • 端口耗尽 → 某个池地址上没有可用端口了,新会话拿不到映射
  • 会话表满 → 表里塞不下更多条目了,无论端口够不够

调整方式是在启用插件时指定:

bash
vppctl nat44 plugin enable sessions 1000000

会话老化:四种超时

会话不能永久保留,否则表迟早撑爆。VPP 按协议和状态分了四类超时:

bash
./labctl vppctl base show nat timeouts
text
udp timeout: 300sec
tcp-established timeout: 7440sec
tcp-transitory timeout: 240sec
icmp timeout: 60sec
text
  ┌──────────────────┬────────┬────────────────────────────────────────┐
  │ 类型             │ 默认值 │ 为什么是这个数量级                       │
  ├──────────────────┼────────┼────────────────────────────────────────┤
  │ tcp-established  │ 7440s  │ ≈124 分钟。RFC 5382 要求至少 2 小时 4 分。│
  │                  │        │ TCP 连接可以长时间静默(SSH 挂着不动),  │
  │                  │        │ 提前回收会导致连接莫名其妙断掉。          │
  ├──────────────────┼────────┼────────────────────────────────────────┤
  │ tcp-transitory   │  240s  │ 握手中、挥手中的连接。这些状态本就该很快    │
  │                  │        │ 结束,长时间停留说明对端已经没了。         │
  ├──────────────────┼────────┼────────────────────────────────────────┤
  │ udp              │  300s  │ UDP 无连接,只能靠"多久没流量"来判断死活。 │
  │                  │        │ RFC 4787 建议不低于 2 分钟。             │
  ├──────────────────┼────────┼────────────────────────────────────────┤
  │ icmp             │   60s  │ ping / traceroute 都是短命交互。          │
  └──────────────────┴────────┴────────────────────────────────────────┘

TCP 的两个超时差了三十倍,这个设计很关键:大部分表项压力来自半开连接 (SYN 发出去没人应、FIN 之后对端消失)。给它们一个短超时,能让表快速自愈; 而真正建立好的连接享受长超时,不会被误伤。

会话表里能直接看到剩余时间:

text
       last heard 484.35        ← 最后一次见到这条流的包
       timeout in 235.88        ← 还有 235 秒老化

调整超时

bash
# 单独调 UDP
./labctl vppctl base set nat timeout udp 120

# TCP 两个必须一起给
./labctl vppctl base set nat timeout tcp-established 3600 tcp-transitory 120

# 恢复默认
./labctl vppctl base set nat timeout reset

调短超时是有代价的

"表快满了,把超时调短点" 是个很自然的念头,但要想清楚代价:

  • 调短 tcp-established → 静默的长连接(SSH、数据库连接池、MQTT)会被悄悄回收。 症状是用户抱怨"放着不动一会儿就断了",而且只有闲置久的连接受影响,极难复现。
  • 调短 udp → 依赖 NAT 保活的应用(VoIP 注册、游戏心跳)需要更频繁地发保活包, 否则通话中途单向没声音。

更好的做法通常是加地址池、加会话上限,而不是压缩超时。

懒回收:为什么会话数会"超过"上限

VPP 不跑定时器去扫全表 —— 那样在几百万会话时开销不可接受。它用的是懒回收: 只在需要腾位置时,顺手清理过期项。

bash
./labctl vppctl base show nat44 summary
text
transitory tcp LRU min session timeout 417 (now 177)
total sessions: 2 (timed out: 0)
tcp sessions:
    total: 2 (timed out: 0)
        established: 0 (timed out: 0)
        transitory: 2 (timed out: 0)

注意 timed out: 那个计数 —— 它统计的是已经过期但还占着位置的会话。 这个数字大于零是正常的,不是故障。那个 LRU 就是懒回收用的最近最少使用链表。

一类最难查的故障:会话被提前回收

把上面几件事串起来,就能理解 NAT 故障里最讨厌的一类:

症状是"长连接用着用着就废了,重连一下就好"。排查要点:

  1. show nat44 summary 看会话总数是不是逼近上限
  2. show errors | grep -i natno translation 计数是不是在涨
  3. 对着 show nat timeouts 和应用的保活间隔比一比 —— 保活间隔必须明显小于 NAT 超时

经验值

应用的保活间隔取 NAT 超时的一半以内比较安全。 比如 UDP 超时 300 秒,保活最好 60~120 秒发一次。

反过来,如果你是运维 NAT 设备的一方,收到"长连接会断"的报障, 先问对方保活间隔是多少,再对照自己的超时配置 —— 十有八九对不上。

顺带:EI 能看到每个地址的端口占用

一个小差异。nat44-ei 的地址显示会带上端口占用统计:

bash
./labctl vppctl base show nat44 ei addresses
text
NAT44 pool addresses:
203.0.113.1
  tenant VRF independent
  0 busy other ports
  0 busy udp ports
  0 busy tcp ports
  0 busy icmp ports

nat44-edshow nat44 addresses 没有这几行。想在 ED 下判断端口压力, 就得靠 show nat44 summary 的会话数,或者 show errors 里的分配失败计数。

小结

问题答案
端口怎么挑?优先保留原端口,冲突时才另选
什么算冲突?o2i 键重复。ED 的键含外网端,所以冲突概率更低、端口利用率更高
一个公网地址能撑多少人?数量级几百,取决于人均并发会话数
会话什么时候回收?四种超时:TCP 已建立 7440s / TCP 中间态 240s / UDP 300s / ICMP 60s
回收怎么执行?懒回收 + LRU,不扫全表;timed out 计数大于零是正常的
最难查的故障?保活间隔 > NAT 超时,导致长连接被静默回收

第 1 章到此结束。你现在手上有了拆解任何 NAT 行为所需要的全部概念: 改写字段、会话表结构、EI/ED 索引差异、端口分配、老化机制。

第 2 章开始,我们把这些概念一个个落到 VPP 的具体配置上。

实验环境基于 netlab + containerlab + VPP 26.06