Skip to content

五元组与会话表

NAT 是有状态的。这句话的全部含义就是:它维护一张表,把"内网视角的连接"和"外网视角的连接" 对应起来。这一篇把 VPP 那张表逐字段拆开。

先说五元组

要唯一标识一条网络连接,需要五个值:

text
  ┌─────────────┬─────────────┬──────────┬──────────┬──────────┐
  │   源 IP     │   目的 IP    │  源端口   │ 目的端口  │   协议    │
  ├─────────────┼─────────────┼──────────┼──────────┼──────────┤
  │ 10.10.0.11  │203.0.113.12 │  52515   │   9001   │   UDP    │
  └─────────────┴─────────────┴──────────┴──────────┴──────────┘

为什么是五个?因为少任何一个都会撞车:

  • 只有源和目的 IP → 同一台主机开两个浏览器标签访问同一个网站,分不开
  • 加上端口,但不带协议 → TCP 的 53 端口和 UDP 的 53 端口会被当成同一条连接

NAT 会话不完全等于五元组

一个常见误解是"NAT 表就是五元组表"。实际上不同的 NAT 类型,索引用的字段数量不一样, 这正是下一篇 EI/ED 的核心。VPP 的 nat44-ed 用全部五元组,nat44-ei 只用其中三个。 先记住这个伏笔。

VPP 会话表的完整结构

把上一章那条会话再贴一次,这次逐行注释:

text
NAT44 ED sessions:
-------- thread 0 vpp_main: 1 sessions --------          ← ①
    i2o 10.10.0.11  proto TCP port 39637 fib 0           ← ②
    o2i 203.0.113.1 proto TCP port 39637 fib 0           ← ③
       external host 203.0.113.11:9000                   ← ④
       i2o flow: match:   saddr 10.10.0.11  sport 39637
                          daddr 203.0.113.11 dport 9000 proto TCP fib_idx 0
                 rewrite: saddr 203.0.113.1 sport 39637
                          daddr 203.0.113.11 dport 9000 txfib 0      ← ⑤
       o2i flow: match:   saddr 203.0.113.11 sport 9000
                          daddr 203.0.113.1  dport 39637 proto TCP fib_idx 0
                 rewrite: saddr 203.0.113.11 daddr 10.10.0.11
                          dport 39637 txfib 0                        ← ⑥
       index 0                                           ← ⑦
       last heard 484.35                                 ← ⑧
       timeout in 235.88                                 ← ⑨
       total pkts 9, total bytes 541                     ← ⑩
       dynamic translation                               ← ⑪

① 会话是按线程分片的

thread 0 vpp_main 说明这条会话属于 0 号线程。VPP 多 worker 运行时, 每条会话只属于一个 worker,各 worker 的会话表互不共享,也不加锁。

这带来一个必须理解的约束:同一条流的来回两个方向,必须被同一个 worker 处理, 否则回程包会在另一个 worker 的表里查不到会话。VPP 靠一致性哈希(RSS / 软件哈希) 保证同一条流的双向包落到同一个 worker 上。

这是一类真实故障的根源

如果哈希算法在两个方向上算出的结果不一致(比如网卡 RSS 只哈希了源地址), 就会出现"去程能出去,回程被丢"的诡异现象,而且是部分连接受影响。 第 7 章会讲怎么用 show nat44 hash tablesshow interface rx-placement 定位。

②③ 两个索引键

这是同一条会话的两个"入口",对应两个查找方向:

text
  i2o 键(内→外查)        o2i 键(外→内查)
  ┌──────────────────┐    ┌──────────────────┐
  │ 10.10.0.11:39637 │    │ 203.0.113.1:39637│
  │ TCP, fib 0       │    │ TCP, fib 0       │
  └──────────────────┘    └──────────────────┘
       ↓                        ↓
       └────── 同一条会话 ───────┘

fib 0 是路由表(VRF)编号。多租户场景下不同租户可以用相同的内网地址段, 靠 fib 编号区分 —— 第 2 章的 VRF 一节会用到。

④ external host:ED 的身份证

text
       external host 203.0.113.11:9000

这一行说:这条会话是专门为了和 203.0.113.11 的 9000 端口通信而建的。

这就是 "Endpoint-Dependent"(端点相关)的全部含义。同一个内网 10.10.0.11:39637 如果去访问另一台服务器 203.0.113.12:9001,会新建另一条会话,可能分到不同的外部端口。

nat44-ei 的会话表里就没有这一行。这个差别的后果很大,值得单独一篇: 端点无关 vs 端点相关

⑤⑥ 两个方向的 match + rewrite

这是会话表最核心的部分,也是最能体现"NAT 是一对改写而不是一个改写"的地方。

text
  i2o(出方向)
    match   : saddr 10.10.0.11  sport 39637  daddr 203.0.113.11  dport 9000
                ↓ 命中后                        ↓ 目的不变
    rewrite : saddr 203.0.113.1 sport 39637  daddr 203.0.113.11  dport 9000
                ★改源                            保持

  o2i(回方向)
    match   : saddr 203.0.113.11 sport 9000  daddr 203.0.113.1  dport 39637
                ↓ 源不变                        ↓ 命中后
    rewrite : saddr 203.0.113.11             daddr 10.10.0.11   dport 39637
                保持                            ★改目的

两个方向严格互为镜像。VPP 在建会话的那一刻就把两个方向的规则都算好存下来, 后续包只需查表 + 套用预先算好的改写,不需要再做任何决策。这是它能跑得快的原因之一。

排错时先看这四行

遇到"NAT 行为不对"的问题,直接看这四行 match/rewrite。 它们是 NAT 对这条流的全部意图,比推测配置效果可靠得多。

⑦⑧⑨⑩⑪ 元数据

字段含义排错用途
index 0会话在表里的槽位号配合 nat44 del session 精确删除
last heard 484.35最后一次收到该流的包的时间戳(VPP 启动后秒数)判断流是不是已经静默
timeout in 235.88还有多少秒就老化判断是不是"表还没到期但对端已经断了"
total pkts / bytes累计包数和字节数判断流量是否真的在走;pkts 停止增长说明流已死
dynamic translation会话来源:动态建立对比 static translation(静态映射产生的)

快路径与慢路径

会话表带来一个自然的优化:首包要建会话(慢),后续包只需查表(快)。 VPP 把这两条路径做成了不同的 graph node。

从实测 trace 里能直接看到差别。下面是 ping -c 3 抓到的节点序列 (同一次 ping 的三个包 ICMP id 相同,所以是同一条会话):

真实的节点序列(show trace 输出整理后):

text
Packet 1(首包,in2out)
  → af-packet-input → ethernet-input → ip4-input → ip4-sv-reassembly-feature
  → nat-pre-in2out → nat44-ed-in2out → nat44-ed-in2out-slowpath   ← 多了这一跳
  → ip4-lookup → ip4-rewrite → host-eth2-output → host-eth2-tx

Packet 2(回包,out2in)
  → af-packet-input → ethernet-input → ip4-input → ip4-sv-reassembly-feature
  → nat-pre-out2in → nat44-ed-out2in                              ← 回程分支
  → ip4-lookup → ip4-rewrite → host-eth1-output → host-eth1-tx

Packet 3(第二个请求,in2out)
  → af-packet-input → ethernet-input → ip4-input → ip4-sv-reassembly-feature
  → nat-pre-in2out → nat44-ed-in2out                              ← 没有 slowpath 了
  → ip4-lookup → ip4-rewrite → host-eth2-output → host-eth2-tx

自己验证快慢路径

bash
./labctl reset base && ./labctl apply base 01
./labctl vppctl base clear trace
./labctl vppctl base trace add af-packet-input 20
ssh chenzhong@192.168.49.182 \
  'docker exec clab-natbase-h1 ping -c 3 -i 0.3 -W 1 203.0.113.12'
./labctl vppctl base show trace

想只看节点序列不看细节,可以过滤一下:

bash
./labctl vppctl base show trace \
  | grep -E '^Packet |^[0-9]{2}:[0-9]{2}:[0-9]{2}:[0-9]{6}: '

应该观察到

  • 第 1 个包有 nat44-ed-in2out-slowpath,第 3、5 个包没有
  • 回包走的是 nat-pre-out2innat44-ed-out2in,出接口变成 host-eth1
  • 回包的 NAT 节点里写着 NAT44_OUT2IN_ED_FAST_PATH ... via o2if —— 因为出方向的首包已经把 o2i 规则一起建好了,回包天生就在快路径上

这个设计还有一个重要含义:慢路径的成本远高于快路径。 一条长连接摊下来几乎没有 NAT 开销,但如果流量模式是大量短连接(每个连接只有几个包), NAT 设备就要不停地走慢路径 —— 这是 CGN 设备的主要压力来源,也是评估 NAT 性能时 "新建会话速率(CPS)"比"吞吐(bps)"更关键的原因。

会话表的容量

bash
./labctl vppctl base show nat44 summary
text
max translations per thread: 64512 fib 0
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)
udp sessions:
    total: 0 (timed out: 0)
icmp sessions:
    total: 0 (timed out: 0)
other sessions:
    total: 0 (timed out: 0)

几个要点:

  • max translations per thread: 64512 —— 每个线程的会话上限,默认值。 注意是"per thread",8 个 worker 就是 8 × 64512。可以在启用插件时调: nat44 plugin enable sessions 1000000
  • TCP 会话分 established 和 transitory —— 已建立的连接和处于握手/挥手中间态的连接, 两者超时时间差了三十倍(7440s vs 240s)。下一篇细讲。
  • timed out 计数 —— 已经超时但还没被回收的会话。VPP 是懒回收: 不跑定时器扫全表,而是在需要腾位置时顺手清理过期项(那个 LRU 就是干这个的)。

手工删会话

调试时经常需要"把这条流的状态清掉,重新走一次慢路径":

bash
# 按内网侧删
./labctl vppctl base nat44 del session in 10.10.0.11:39637 tcp \
    external-host 203.0.113.11:9000

# 按外网侧删
./labctl vppctl base nat44 del session out 203.0.113.1:39637 tcp \
    external-host 203.0.113.11:9000

nat44-ed 必须带 external-host

不带会直接失败:

text
nat44 del session: nat44_del_session returned -6

道理就在这一页讲的会话键上:ED 的键包含外网端, 少了它这个键就是不完整的,当然找不到。外网端的地址端口从 show nat44 sessionsexternal host 那一行读。

nat44-ei 的键里没有外网端,所以它的 nat44 ei del session 不需要这个参数。)

观察一次强制重建

  1. 建一条会话,记下端口:
    bash
    ssh chenzhong@192.168.49.182 \
      'docker exec clab-natbase-h1 nc -w 2 203.0.113.11 9000'
  2. show nat44 sessions 找到它
  3. 删掉它,再连一次,会话表里会出现一条全新的(index 变了,端口可能也变了)

小结

一条 NAT 会话 = 两个索引键 + 两个方向的改写规则 + 一组元数据

text
              ┌─────────────── 一条会话 ───────────────┐
              │                                        │
  i2o 键 ────→│  i2o: match … → rewrite …              │←──── o2i 键
              │  o2i: match … → rewrite …              │
              │  external host / timeout / pkts / …    │
              └────────────────────────────────────────┘
                   ↑ 建于首包(慢路径)
                     后续包只查表(快路径)

理解了这张表,NAT 的大部分行为都能推导出来。下一篇讲那个被我们埋了两次的伏笔: external host 这一行在与不在,会带来什么天差地别的后果。

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