Skip to content

IP 协议与路由 ​

标签
基本功/IP
基本功/网络
字数
15858 字
阅读时间
66 分钟

IP(Internet Protocol)只做一件事:把数据报从源主机送到目的主机。它不保证送达、不保证顺序、不保证不重复——这些交给上层的 TCP 或应用自己处理。

它的两个版本在本篇都涉及:IPv4(RFC 791)是当前部署的主体,IPv6(RFC 8200)的首部结构完全不同,单独说明。

1. 协议定位 ​

1.1 与上下层的关系 ​

应用层    HTTP / MySQL / DNS / ...
传输层    TCP / UDP              ← 端到端,靠端口区分进程
─────────────────────────────────
网络层    IP                     ← 主机到主机,靠地址区分机器
链路层    以太网 / WiFi           ← 一跳之内

IP 的核心抽象是「尽力而为的、无连接的数据报」。三个词各有含义:

特性含义后果
无连接每个包独立路由,不维护流状态同一对主机的前后两个包可以走不同路径
尽力而为不保证送达、不保证顺序、可能重复可靠性必须由上层补(TCP)或应用自己处理
数据报以包为单位的独立实体没有「连接」的概念,也就没有连接建立的开销

UDP 直接架在 IP 上,就是「拿掉所有保障的 TCP」;TCP 则在 IP 之上重建了顺序、可靠性、流量控制。

1.2 IP 层承担的四件事 ​

职责手段见哪节
标识主机32 位地址 + 子网划分§3
决定往哪送路由表 + 最长前缀匹配§4
大包拆小分片与重组§5
防止包永远打转TTL§2

2. 首部格式 ​

2.1 字段逐个 ​

固定部分 20 字节,加选项最长 60 字节。

字段位宽含义
Version4IPv4 填 4
IHL4首部长度,单位是 32 位字
Type of Service8现在拆成 DSCP(6) + ECN(2)
Total Length16数据报总长,以八位组计,含首部与数据
Identification16分片重组时标识同一个原始数据报
Flags3DF / MF,见 §2.2
Fragment Offset13该分片在原数据报中的位置,单位是 8 字节
Time to Live8每经一跳减 1,减到 0 则丢弃并回 ICMP
Protocol8上层协议号:ICMP=1、TCP=6、UDP=17
Header Checksum16只校验首部,不含数据
Source Address32源地址
Destination Address32目的地址
Options可变见 §2.4
Padding可变补零,使首部在 32 位边界结束

首部固定 20 字节,也是 5 个 32 位字;但各字段宽度差别很大。

   偏移  0                    16                    31
   ───────────────────────────────────────────────────────────
    0    │版本(4)│ IHL(4) │  ToS(8)   │   总长 Total Length (16) │
         │       │        │ DSCP │ECN │                          │
    4    │        标识 Identification (16)   │Flags│ 分片偏移(13)  │
         │                                   │ (3) │              │
    8    │  生存时间 TTL (8) │ 协议 Protocol (8) │  首部校验和 (16) │
   12    │                源地址 Source Address (32)             │
   16    │             目的地址 Destination Address (32)         │
   20    │          选项 Options(可变)│  填充 Padding           │
   ───────────────────────────────────────────────────────────

   三处长度与单位要分清
     IHL 是 4 位、单位是 32 位字 ⟹ 首部长度 = IHL × 4
       IHL = 5 → 20 字节(正确首部的最小值);IHL = 15 → 60 字节
     Total Length 以八位组计,含首部与数据,最大 65535
     Fragment Offset 单位是 8 字节 ⟹ 除最后一片外,每个分片的数据长度
       必须是 8 的倍数 —— 这就是「最小分片 8 字节」的由来

   两个必须支持的下限(RFC 791)
     576 字节   目的主机接收   = 512 字节数据 + 64 字节首部余量
     68 字节    中间设备转发   = 60 字节最大首部 + 8 字节最小分片
     └─ 576 直接决定了 TCP 的默认 MSS:576 − 20 − 20 = 536

   两处与其他层的呼应
     IHL 与 TCP 的数据偏移同构:都是 4 位、单位都是 32 位字、都乘 4
     Header Checksum 只校验首部、不含数据 ——
       所以每跳 TTL 减 1 之后必须重算

2.2 三个尺寸定义 ​

IHL 与 TCP 的数据偏移同构——都是 4 位、单位都是 32 位字、都要乘 4 才是字节数:

首部长度(字节)= IHL × 4
最小:IHL = 5  → 20 字节
最大:IHL = 15 → 60 字节(选项最多 40 字节)

RFC 791 对 IHL 的原文:

Internet Header Length is the length of the internet header in 32 bit words, and thus points to the beginning of the data. Note that the minimum value for a correct header is 5.

Total Length 以八位组计,含首部与数据,最大 65535。RFC 791 同时给出两个必须支持的下限:

All hosts must be prepared to accept datagrams of up to 576 octets (whether they arrive whole or in fragments).

Every internet module must be able to forward a datagram of 68 octets without further fragmentation.

数值约束谁由来
576 字节目的主机接收512 字节数据 + 64 字节首部余量
68 字节中间设备转发60 字节最大首部 + 8 字节最小分片

576 这个数直接决定了上一节 TCP 的默认 MSS:576 − 20 − 20 = 536。

Fragment Offset 单位是 8 字节,所以除最后一片外,每个分片的数据长度必须是 8 的倍数。这是"最小分片 8 字节"的由来。

2.3 Flags 与分片 ​

  0   1   2
+---+---+---+
| 0 | D | M |
|   | F | F |
+---+---+---+
位名称含义
bit 0保留必须为 0
bit 1DF(Don't Fragment)置 1 表示不允许分片;路径 MTU 小于包长时丢弃并回 ICMP Type 3 Code 4
bit 2MF(More Fragments)置 1 表示后面还有分片;最后一片为 0

DF 位是路径 MTU 发现的机制基础:发送方置位、路由器遇大包时丢弃并回报,发送方据此调小 MSS。

分片的重组只在目的主机做,中间路由器不分片重组(IPv6 连分片本身都取消了,见 §8)。

2.4 校验和与选项 ​

Header Checksum 只覆盖首部,这一点与 TCP 不同——TCP 的校验和覆盖「伪首部 + 首部 + 数据」。由此带来一个实操差异:

协议校验范围每跳是否重算
IP仅首部要(TTL 每跳都变,必须重算)
TCP伪首部 + 首部 + 数据不要

IPv6 取消了首部校验和,把这件事完全交给上层(TCP/UDP 本来就有自己的校验和),理由是链路层已有 CRC,重复校验收益不抵开销。

选项格式与 TCP 一样,两种情形:

情形 1:Kind(1)                    EOL(0)、NOP(1)
情形 2:Kind(1) + Length(1) + Data 其余全部

Length 把 Kind 与 Length 自身计入。填充规则也相同:首部必须在 32 位边界结束,填充补零。RFC 791:

The internet header padding is used to ensure that the internet header ends on a 32 bit boundary. The padding is zero.

IPv4 的选项在实践中用得很少(记录路由、时间戳基本废弃,安全选项被 IPsec 取代),IPv6 则改成用扩展首部链表达。

3. 地址与子网 ​

3.1 地址的结构 ​

CIDR 模型下,地址就是一个「前缀 + 主机号」的划分:

IP-address ::= { <Network-prefix>, <Host-number> }

前缀长度 = 子网掩码中 1 的个数(RFC 1812):

The length of the prefix equals the number of ones in the subnet mask.

掩码必须是连续的——高位一串 1、低位一串 0,中间不能夹杂。RFC 1812 要求路由器一律按前缀模型处理路由,拒绝不符合该模型的配置。

写法等价可用主机数
10.1.2.0/24掩码 255.255.255.0254(去掉网络号与广播)
10.1.0.0/16掩码 255.255.0.065534
10.0.0.0/8掩码 255.0.0.016777214
0.0.0.0/0掩码 0.0.0.0全部 —— 默认路由

3.2 私有地址与特殊地址 ​

网段用途
10.0.0.0/8、172.16.0.0/12、192.168.0.0/16私有地址(RFC 1918),公网不可路由
127.0.0.0/8环回
169.254.0.0/16链路本地(DHCP 失败时自动配置)
224.0.0.0/4组播
0.0.0.0本机任意地址(作源地址)或默认路由(作目的前缀)

同一台机器可以配多个地址,路由表里每条会带 src 告诉内核该用哪个源地址发出。这也是 ip route get 输出里 src 字段的意义。

3.3 分类地址与 CIDR 的取代 ​

分类寻址是 IPv4 最早的分配模型:地址的最高几位决定它属于哪一类,类的边界写死在地址里,与网络的实际拓扑无关(来源:RFC 791 §3.2)。当时的判断理由很直接——路由器不必做掩码运算,读开头几位就能切出网络号。

类别前导位网络号位数网络数每网可用主机数地址范围
A08128(可用 126)2^24 − 2 = 167772140.0.0.0–127.255.255.255
B1016163842^16 − 2 = 65534128.0.0.0–191.255.255.255
C1102420971522^8 − 2 = 254192.0.0.0–223.255.255.255
D1110——无主机号224.0.0.0–239.255.255.255
E1111——无主机号(保留)240.0.0.0–255.255.255.255

识别一个地址属于哪一类只看开头几个二进制位:最高位是 0 就是 A 类,是 10 就是 B 类,是 110 就是 C 类(来源:RFC 791 §3.2)。D 类与 E 类没有网络号与主机号的划分,前者用于组播,后者保留未用。

这套模型有三个结构性缺陷,正是 CIDR 要解决的问题(来源:RFC 1519;该文档已被 RFC 4632 取代,见 §3.6):

  1. 类的边界与真实拓扑无关。 网络规模只有 /8、/16、/24 三档可选。一个需要 300 台主机的组织只能拿到 B 类,16 位主机号里 65534 个地址只用掉 300 个,利用率约 0.46%(300 ÷ 65534);反过来 C 类的 254 个地址又常常不够用。
  2. 地址利用率低,加速耗尽。 分配按类整块给出,用不完的部分无法收回给别的组织,浪费直接转化成地址空间的消耗。
  3. 路由表规模失控。 分类寻址下路由条目的粒度就是网络号,一个 C 类网络一条路由,无法把连续地址聚合成一条。随着 C 类网络被大量切分分配,全局路由表条数随网络数线性增长,路由器的内存与查表开销跟着涨(来源:RFC 1519)。

CIDR 的改法是把「类别」从地址里拿掉:网络前缀可以是 0–32 之间的任意长度,地址块按 2 的幂边界切分并尽量按拓扑连续分配,于是连续块能聚合成一条更短前缀的路由(来源:RFC 1519)。分类寻址与 CIDR 的对照:

维度分类寻址CIDR
网络号长度由最高几位决定,只有 8 / 16 / 24任意 0–32 位
单块规模256 / 65536 / 16777216 三档按需聚合,可细分到单个地址
路由聚合不可能,网络号是硬边界核心能力,10.0.0.0/8 可代表其下所有子网
查找方式读前导位掩码 AND + 最长前缀匹配(§4.1)
text
   分类地址:类的边界就是地址的边界
   ┌────────────┬────────────┬────────────┬───────┬───────┐
   │  A  /8     │  B  /16    │  C  /24    │ D/E   │ 保留  │
   │ 0xxx xxxx  │ 10xx xxxx  │ 110x xxxx  │ 1110  │ 1111  │
   └─────┬──────┴─────┬──────┴─────┬──────┴───┬───┴───┬───┘
         │            │            │          │       │
         │  网络规模只能三选一,与真实拓扑无关            │
         ▼            ▼            ▼           ▼
   一块网 6.5 万地址 → 实际只用 300 个 → 路由条目按网络号线性增长

   CIDR:把「类」拿掉,前缀长度自由
   ┌──────────────────────────────────────────────────────┐
   │  10.1.0.0/16 ─┐                                       │
   │  10.1.1.0/24 ─┼─▶ 可聚合为 10.0.0.0/8 一条路由         │
   │  10.1.2.0/24 ─┘                                       │
   └─────────────────────────┬────────────────────────────┘
                             ▼
   前缀长度任意 0–32,块按 2 的幂连续分配 ⟹ 既聚合又细分
   └─ 聚合块用短前缀、具体子网用长前缀,共存靠「最长前缀匹配」(§4.1)

分类寻址今天只留下几个按位固定的特殊块(见下节):240.0.0.0/4 保留、127.0.0.0/8 环回、224.0.0.0/4 组播。分配与路由都按前缀走,不再看类别。

3.4 私有地址:RFC 1918 的三段 ​

RFC 1918 划出三段私有地址,只在组织内部可用,公网路由器应丢弃(来源:RFC 1918 §3):

前缀覆盖的旧类别块地址数相对规模
10.0.0.0/81 个 A 类网16777216256
172.16.0.0/1216 个连续 B 类网(172.16–172.31)104857616
192.168.0.0/16256 个连续 C 类网(192.168.0–192.168.255)655361

三段的规模差 256 倍与 16 倍。172.16/12 恰好覆盖 16 个 B 类网、192.168/16 恰好覆盖 256 个 C 类网,这两处的写法来自分类寻址时代,用 CIDR 前缀表达后又反过来印证「一段前缀可以横跨多个旧类别」。选哪段有经验可循:小办公室用 192.168/16,需要跨多个互联组织时用 10/8,中等规模且不想与家用路由器预置的 192.168.1.0/24 撞车时用 172.16/12。

3.5 特殊地址块的去向 ​

除私有段外,还有若干固定用途的地址块,用途和「能不能作目的地址」各不相同(来源:RFC 5735、RFC 6890 §2.1):

块名称去向
0.0.0.0/8本网络(this network)只能作源地址,且仅在本机未获地址时;作目的地址无意义
127.0.0.0/8环回内核内部回环,不出网卡;127.0.0.1 是约定俗成的本机
169.254.0.0/16链路本地 / APIPA取不到地址时自动配置,只在本链路内有效(来源:RFC 3927)
100.64.0.0/10运营商级 NAT 共享地址运营商在 CGN 之后分给用户,避免与用户私有网段冲突(来源:RFC 6598)
192.0.2.0/24、198.51.100.0/24、203.0.113.0/24文档示例只用于文档与教材,不分配(来源:RFC 5737)
224.0.0.0/4组播见本篇「组播与 IGMP」
240.0.0.0/4保留(原 E 类)除 255.255.255.255 外不可作目的地址
255.255.255.255/32受限广播只在本地链路有效,路由器不转发

这几类不能混为一谈,失败表现不同:

  • 内核直接拒收的(0.0.0.0/8、240.0.0.0/4 作目的):正常抓包里不该出现把这类地址当目的地址的包。出现即是伪造或扫描,按安全事件处理,先怀疑是不是攻击流量。
  • 只在本地链路有效的(169.254.0.0/16、255.255.255.255):不会被路由,跨网段必然失败。ip addr 上出现 169.254.x.x 几乎总是 DHCP 没拿到地址后的兜底(APIPA),此时先查链路上的 DHCP,不必先查路由表。
  • 可路由但语义特殊的(127.0.0.0/8、100.64.0.0/10):127/8 只在本机内部;100.64/10 由运营商路由,用户侧看到对端落在这一网段,往往意味着自己处在 CGN 之后,外部访问要经过运营商映射。

地址从哪来可以归成三条来源:静态配置、链路层协商拿到(DHCP,见 11-主机接入网络:DHCP 与地址配置)、以及域名解析的结果——DNS 把域名解析成 IP,/etc/resolv.conf 决定用哪个解析器、options ndots:N(默认 1)决定域名要先当绝对名查还是先拼 search 域(来源:resolv.conf(5))。本篇把它当作「IP 地址的一个来源」,解析过程不在这里展开。

3.6 「无类别」改了什么,以及聚合成立的前提 ​

§3.3 引的依据是 RFC 1519(1993),它已被 RFC 4632 取代 —— 后者是现行 BCP,摘要里写明 This document obsoletes the original Classless Inter-domain Routing (CIDR) spec in RFC 1519。§3.3 的结论在新版里都保留着,本节按 4632 补它讲得更准的两块:前缀记法的准确含义,以及聚合要成立需要什么前提。

「无类别」的准确含义是显式化。 规范的原话是 to make explicit which bits in a 32-bit IPv4 address are interpreted as the network number ... and which are the used to number individual end systems within the site(来源:RFC 4632 §3.1):网络位与主机位的分界由前缀长度写出来,不再由地址的高位隐含决定。分类寻址里 172.16.0.0 之所以是「/16」,因为它落在 128–191 这个区间;CIDR 里这个长度就是斜杠后面那个数字。

地址块的尺寸因此不再只有三档:Where formerly only three network sizes were available, prefixes may be defined to describe any power of two-sized block of between one and 2^32 end system addresses(同节)。换算只有一条公式 —— /N 的块大小是 2^(32-N) 个地址,/N 的前缀个数是 2^N:

前缀地址数可用块数备注
/3214294967296主机路由
/3122147483648点对点链路
/3041073741824互联地址常用
/2425616777216旧「C 类」的规模
/166553665536旧「B 类」的规模
/816777216256旧「A 类」的规模,也是 IANA 分给 RIR 的单位
/042949672961默认路由

短于 /8 的前缀从未真正分配过,但运营商误配会让 128.0.0.0/1、192.0.0.0/2 这类超短路由出现在某些网络里(来源:RFC 4632 §3.1)。

分配是分层的,且按 2 的幂对齐。 IANA 从池子里按 2^24 地址的连续对齐块(即 /8)分给 RIR,RIR 再分给 LIR 与 ISP,ISP 再分给客户。规范里的两个词有明确分工:allocate 指交给一个还会继续往下分的组织,assign 指交给直接使用它的站点(来源:RFC 4632 §3.1)。中文里这两者常都译成「分配」,读规范时要留意差别。

然后是 CIDR 最容易被讲漏的一点:聚合不自动发生。 规范把 CIDR 的目标写成两件事 —— 省地址、压住全局路由状态的增长 —— 只有后一件依赖聚合;而聚合要成立,地址分配必须大致跟着网络拓扑走:

The assignment of prefixes is intended to roughly follow the underlying Internet topology so that aggregation can be used to facilitate scaling of the global routing system.(来源:RFC 4632 §3)

拓扑是运营商建起来的,所以分配与聚合自然沿「运营商—客户」这条关系进行。规范对聚合机制的表述是:单宿主站点用的是 ISP 分配的地址,那段地址本来就是 ISP 大块里的一小片,ISP 只需要通告一条聚合路由,站点自己不必出现在全局路由表里。

text
   聚合成立与失效的两种情形(来源:RFC 4632 §4.1)

   ① 单宿主:站点用 ISP 给的地址
      ┌──────────────────┐
      │ ISP  200.1.0.0/16 │  ◀── 只通告这一条
      └────────┬─────────┘
               ├── 200.1.1.0/24  ◀── 不单独通告
               ├── 200.1.2.0/24  ◀── 不单独通告
               └── 200.1.3.0/24  ◀── 不单独通告

   ② 多宿主:同一个站点接两家运营商
      ┌────────┐              ┌────────┐
      │ ISP A  │◀──── 同一站点 ────▶│ ISP B  │
      └────────┘              └────────┘
           └── 两家都必须各通告一次 ⟹ 聚合块被打断

两种会削弱聚合的情形(来源:RFC 4632 §4.1):

  • 多宿主。 站点必须被它的每家运营商分别通告,无法并进任何一家的聚合块。规范因此直言 the global routing cost for a multi-homed organization is generally the same as it was prior to the adoption of CIDR。
  • 换运营商但不重编号。 这等于在原子网的聚合通告里打个洞,新运营商必须为该站点单独通告一条更具体的路由 —— 它能生效靠的正是最长前缀匹配(§4.1)。

缓解办法有两条:给多宿主站点分配一段连续的 2 的幂块(而不是几个互不相邻的前缀),这样站点自己的路由仍可聚合成一条;以及换运营商时做重编号,规范建议配合 DHCP 一类动态地址配置降低迁移成本。多级聚合也能救一部分场面:如果两家小运营商都从同一个大运营商取地址,那么在大运营商这一层,多宿主站点的路由又会被包进大块里。

一条历史注脚:CIDR 当初被提出来时只打算顶三到五年,等一套更长期的寻址与路由架构做出来就替换掉,结果它一直用到现在(来源:RFC 4632 §3)。规范也明确不要求重新分配那些不适合聚合的老「C 类」地址(被称为 the swamp,当时估计约 15,000 条条目),因为重编号的代价压过了收益。

4. 路由表与匹配 ​

4.1 最长前缀匹配 ​

规则只有一条,RFC 1812 §2.2.5.2 的原文:

A route describing a smaller set of destinations (a longer prefix) is said to be more specific than a route describing a larger set of destinations (a shorter prefix) … Routers must use the most specific matching route (the longest matching network prefix) when forwarding traffic.

CIDR 的三个组成要素里,第三项就是这个:

consistent forwarding algorithm ("longest match")

匹配过程:

1. 取目的地址
2. 对每条路由:目的地址 AND 掩码 == 网络地址        → 命中
3. 所有命中的路由里取前缀最长的
4. 前缀长度相同时,比 metric(越小越优先)

为什么必须是最长匹配:CIDR 允许路由聚合,一条 10.0.0.0/8 可能代表整个大网,而 10.1.2.0/24 是其中某个具体子网。若按「先匹配到谁算谁」,具体子网的流量会被大网段抢走。最长匹配保证了「越具体的路由越优先」,聚合与细化才能共存。

默认路由 0.0.0.0/0 前缀长度 0,任何地址都命中它——它是兜底,优先级天然最低,只有在没有任何更具体匹配时才会用到。

匹配规则只有一条:越具体的路由越优先。

   目的地址 10.1.2.7
        │
        ▼
   对每条路由:目的地址 AND 掩码 == 网络地址 ?      ← 逐条试
        │
        ├─ 10.0.0.0/8     AND ──▶ 10.0.0.0    命中(前缀长度 8)
        ├─ 10.1.0.0/16    AND ──▶ 10.1.0.0    命中(前缀长度 16)
        └─ 10.1.2.0/24    AND ──▶ 10.1.2.0    命中(前缀长度 24)
        │
        ▼
   所有命中的路由里取前缀最长的 ──▶ 10.1.2.0/24
        │
        └─ 前缀长度相同时,比 metric(越小越优先)

   为什么必须是最长匹配
     CIDR 允许路由聚合:一条 10.0.0.0/8 可能代表整个大网,
     而 10.1.2.0/24 是其中某个具体子网。
     └─ 若按「先匹配到谁算谁」,具体子网的流量会被大网段抢走;
        最长匹配让「聚合」与「细化」能共存

   默认路由 0.0.0.0/0 的前缀长度是 0,任何地址都命中
     └─ 它是兜底,优先级天然最低 ——
        只有在没有任何更具体匹配时才会用到

4.2 路由表的字段 ​

default via 192.168.1.1 dev eth0 proto dhcp metric 100
10.1.0.0/16 dev eth1 proto kernel scope link src 10.1.0.5
字段含义
default / 10.1.0.0/16目的前缀。default 即 0.0.0.0/0
via下一跳地址。没有 via 说明是直连,直接 ARP 找目的
dev出接口
proto路由来源:kernel(内核自动)、static(手工)、dhcp、boot
metric优先级,越小越优先;同前缀长度时才参与比较
scope link该路由的下一跳在链路上直连,不需要网关
src这条路由优选的源地址

proto kernel 的路由由内核在配地址时自动生成(直连网段),手工加的同前缀路由不会覆盖它,要用 ip route replace。

4.3 Linux 的实现 ​

路由表在 Linux 里叫 FIB(Forwarding Information Base),底层数据结构是 LC-trie(Level Compressed trie,层压缩字典树)。

内核文档定义了两类节点:

节点作用
leaf叶子节点。存一个 key 副本,以及按前缀长度排序的路由项 hlist
tnode内部节点。持有子节点指针数组,用 key 的某个 bit 段索引(pos + bits)

两种压缩让树保持矮而宽:

  • 路径压缩(Path Compression):一条没有分叉的路径被跳过,不建中间节点。代价是查找时要拿叶子里存的 key 复核一次。
  • 层压缩(Level Compression):内部节点的子数组按需膨胀/收缩(inflate() 加倍、halve() 减半),由 resize() 与 trie_rebalance() 在插入删除后维护。树的形状不再是纯二叉树。

查找过程(fn_trie_lookup()):

  1. 沿 trie 逐段下降,直到抵达叶子;
  2. 命中则结束;
  3. 未命中则进入「前缀匹配模式」——把 key 的最低位逐步清零、沿 trie 向上回溯,不断尝试更短的前缀,直到 fib_semantic_match 返回正结果。这样得到的就是最长匹配前缀。

**3.6 内核移除了路由缓存。**此前每条流查一次表、结果缓存进 rtable;现在每个包直接查 trie。删掉缓存的理由是:缓存会被大量短期流污染,且路由变更时的失效逻辑复杂易错;而在现代 CPU 上 LC-trie 的查找已经足够快。

直接观察这棵树:

bash
cat /proc/net/fib_trie

4.4 这个 trie 与教科书的前缀树不同 ​

有一个常见误解值得先澄清:LC-trie 的叶子存的是完整的 32 位 key,不是前缀。

c
struct key_vector {
    t_key key;          /* 完整 32 位 */
    unsigned char pos;
    unsigned char bits; /* bits == 0 表示这是叶子 */
    unsigned char slen; /* = KEYLENGTH − 前缀长度 */
    struct hlist_head leaf;   /* 一个叶子可挂多条 fib_alias */
};

前缀长度不在 key 里,而是编码进 slen(suffix length):

c
u8 slen = KEYLENGTH - plen;                  /* 插入时 */
res->prefixlen = KEYLENGTH - fa->fa_slen;    /* 查回来时换回去 */

于是:

路由leaf 的 keyslen
10.0.0.0/80x0A00000024
10.1.0.0/160x0A01000016

两条路由是两个平级的叶子,不是「祖先—后代」。教科书的二进制前缀树里 /16 会挂在 /8 的子树内;LC-trie 里它们各占一个叶子,层级关系不存在。

最长前缀匹配因此靠回溯实现,不靠树的深度(fib_table_lookup 的 backtrace 段):

c
backtrace:
    while (!cindex) {
        t_key pkey = pn->key;
        if (IS_TRIE(pn))
            return -EAGAIN;              /* 彻底无匹配 */
        pn = node_parent_rcu(pn);        /* 向上退一级 */
        cindex = get_index(pkey, pn);
    }

查 10.1.2.3:按完整 key 降到底找不到 0x0A010203 这个叶子 → 进入回溯,每退一级砍掉 key 的一段低位再试 → 命中 0x0A010000(/16)即停,不命中继续退到 0x0A000000(/8),最后退到 key = 0。「最长」由回溯顺序天然保证——先试的总是更长的前缀。

一个叶子可以挂多条路由,前提是 key 相同。leaf 字段是 hlist_head,多条 fib_alias(不同 dscp / 优先级 / 表)挂在同一叶子上,用 slen 区分。0.0.0.0/0 与 0.0.0.0/32 的 key 都是 0,能共存于一个叶子;/8 与 /16 的 key 不同,必须各占一个。

根只承载「全集入口」,不挂路由。0.0.0.0/0 是 key = 0 的普通叶子,不是根:

c
/* fib_table_insert:默认路由单独计数,但仍是个正常 leaf */
if (!plen)
    tb->tb_num_default++;

/* dump 代码注释 */
/* Note: 0.0.0.0/0 (ie default) is first key. */

根是 struct trie 内嵌的 kv[0],靠 pos 判定:

c
t->kv[0].pos  = KEYLENGTH;    /* 32 */
t->kv[0].slen = KEYLENGTH;

#define IS_TRIE(n)    ((n)->pos >= KEYLENGTH)

而且根只有一个子槽:

c
static inline void put_child_root(struct key_vector *tp, t_key key, struct key_vector *n)
{
    if (IS_TRIE(tp))
        rcu_assign_pointer(tp->tnode[0], n);   /* 根下只有 index 0 */
    else
        put_child(tp, get_index(key, tp), n);
}

所以「0.0.0.0/0 就是前缀树的根」这个说法只对经典模型成立,对 Linux 的实现不成立。

4.5 策略路由:先选表,再匹配 ​

Linux 支持多张路由表。ip rule 先按优先级决定用哪张表,再在那张表内做最长前缀匹配:

bash
ip rule show
# 0:      from all lookup local
# 32766:  from all lookup main
# 32767:  from all lookup default
表编号内容
local255本机地址与广播地址,优先级最高
main254默认表,ip route 不加参数时看的就是它
default253兜底表,通常为空

规则可以按「源地址 / 目的地址 / 防火墙标记 / 入接口」分流,指向自定义表。容器网络就建立在这套机制上:每个容器一个 netns,配自己的路由表,再加规则把流量导向 overlay 或网桥。

排查时注意:ip route show 只看 main 表,跨表问题必须用 ip route show table all。

5. 转发与分片 ​

5.1 一次转发的完整路径 ​

收到包
  ├─ 校验首部 → 失败则丢弃
  ├─ 目的地址是本机? → 是则交给上层协议(Protocol 字段)
  └─ 否 → 需要转发
       ├─ TTL 减 1;减到 0 → 丢弃 + 回 ICMP Time Exceeded
       ├─ 查 FIB(最长前缀匹配)→ 得到下一跳与出接口
       ├─ 有命中?无 → 丢弃 + 回 ICMP Destination Unreachable
       ├─ 包长 > 出接口 MTU?
       │    ├─ DF=0 → 分片
       │    └─ DF=1 → 丢弃 + 回 ICMP Fragmentation Needed
       ├─ 重算首部校验和(TTL 变了)
       └─ 交给链路层(ARP 解析下一跳的 MAC)

TTL 的两个作用:防止路由环路导致包永远打转;让 traceroute 能工作——它逐跳递增 TTL,靠路由器回来的 Time Exceeded 定位路径。

TTL 的默认值能反推对端系统:Linux 64、Windows 128、部分网络设备 255。抓包里看到的 TTL 是被路径消耗过的剩余值,用默认值减去它可估算跳数。

5.2 分片与重组 ​

环节谁做
分片源主机或路径上 MTU 变小处的路由器
重组只在目的主机,中间路由器不重组

重组靠三个字段配合:Identification(同属哪个原包)、Fragment Offset(在原包中的位置)、MF(后面还有没有)。

分片的代价:每个分片都有独立首部(放大开销)、任一分片丢失则整个数据报作废、重组要占目的端缓冲区(可被用于资源耗尽攻击)。所以实践中倾向于避免分片:

  • 现代 TCP 靠路径 MTU 发现(PMTUD)把包控制在不触发分片的范围内;
  • IPv6 干脆禁止中间路由器分片,只留源端分片 + PMTUD。

分片由谁做、在哪里重组,这两条规则是后面所有代价的来源。

   ┌─ 分片 ─────────────────────────────────────────────────┐
   │ 源主机,或路径上 MTU 变小处的路由器                        │
   │ 触发条件:包长 > 出接口 MTU,且 DF = 0                     │
   └─────────────────────────┬──────────────────────────────┘
                             ▼
   ┌─ 沿途路由器 ───────────────────────────────────────────┐
   │ 一律不重组                                              │
   └─────────────────────────┬──────────────────────────────┘
                             ▼
   ┌─ 重组 ─────────────────────────────────────────────────┐
   │ 只在目的主机做                                          │
   └────────────────────────────────────────────────────────┘

   重组端靠三个字段把分片拼回原包
     Identification     同属哪个原包
     Fragment Offset    在原包中的位置(单位 8 字节)
     MF                 后面还有没有分片

   三笔代价,决定了工程上倾向避免分片
     每个分片都带独立首部          放大开销
     任一分片丢失则整个数据报作废    放大重传
     重组要占目的端的缓冲区         可被用于资源耗尽攻击
     └─ 现代 TCP 靠路径 MTU 发现把包控制在不触发分片的范围内;
        IPv6 干脆禁止中间路由器分片,只留源端分片 + PMTUD

5.3 与 MSS 的关系 ​

TCP MSS 就在这一层被约束:

每段数据 ≤ min(对端 MSS, 路径 MTU − IP 首部) − TCP 选项长度

IP 层提供的是路径 MTU 这个上限,TCP 负责按它切分。两者的关系在 05-TCP 协议 §3.6 有完整推导(含 PMTU 黑洞与三种处置)。

6. NAT:网络地址转换 ​

NAT(Network Address Translation)在数据报穿过路由器时改写 IP 首部的地址字段。它的目标不在协议本身,而在外部的现实约束:让私有地址能够访问公网、让一个公网地址承载多台主机,从而缓解 IPv4 地址耗尽(来源:RFC 2663 §1)。这一节讲清 NAT 与 NAPT 的分工、映射表怎么建和维护、Linux 上三种动作的差别、它给端到端带来的代价、它在 IPv6 里的处境,以及怎么观测。

6.1 NAT 与 NAPT 的区别 ​

「NAT」是泛指,具体有两种基本形态(来源:RFC 2663 §4.1):

  • Basic NAT(传统 NAT):只改写 IP 地址,一对一。内部地址与公网地址建立固定对应,端口原样不动,因此 N 台内部主机同时上网需要 N 个公网地址。它解决的是地址迁移与地址隐藏,缓解不了地址耗尽(来源:RFC 3022 §2.1)。
  • NAPT(Network Address Port Translation):同时改写 IP 地址和传输层端口。多台内部主机的连接被映射到同一个公网地址的不同端口上,靠端口区分,因此一个公网地址可以承载成千上万条连接(来源:RFC 2663 §4.1、RFC 3022 §2.2)。
维度Basic NATNAPT
改写内容仅 IP 地址IP 地址 + 传输层端口
映射粒度内部 IP ↔ 公网 IP,一对一内部 (IP, 端口) ↔ 公网 (IP, 端口),多对一
公网地址需求每个内部主机 1 个一组内部主机共用 1 个(或少数几个)
依赖传输层不依赖,只动 IP 首部依赖,要用 TCP / UDP / ICMP 的标识字段
典型场景网络整体换地址段、隐藏内部编址家庭 / 办公出口、云上 VPC 出公网

口语里说的「NAT」绝大多数是 NAPT。Linux 内核也不例外:conntrack 记录的是「连接」而不是「包」,iptables -t nat 里的 SNAT / DNAT / MASQUERADE 默认都带端口改写,也就是 NAPT 语义。这个区分有实际意义——只有 NAPT 的多对一复用,才会引出端口耗尽、conntrack 表满、外部无法主动连入这些问题;Basic NAT 是一对一的,没有这些现象。

6.2 NAPT 的四元组映射与 conntrack 生命周期 ​

一条 NAPT 映射要回答两个方向的问题:内部主机发出的包该被改成什么,以及回程的包该改回给谁。改写前,一条流由五元组唯一确定:源 IP、源端口、目的 IP、目的端口、协议。改写后源侧的 (IP, 端口) 换成出口的公网 (IP, 端口),目的侧不动,于是回程包用 (公网 IP, 公网端口, 目的 IP, 目的端口, 协议) 就能反查到原始的内部 (IP, 端口):

text
   内部主机 A  192.168.1.10:1025  ─┐
   内部主机 B  192.168.1.11:1025  ─┤   内部端口相同、目的相同
                                   │
                                   ▼
   NAT 出口 120.229.175.121 分配公网端口,写进 conntrack 表
   ┌───────────────────────────────────────────────────────────┐
   │  conntrack 表项(original → reply 两个方向)                 │
   │  ┌────────────────────────────┬───────────────────────────┐ │
   │  │ original                   │ reply                     │ │
   │  │ 192.168.1.10:1025 → :80    │ 183.232.231.172:80 → 62000│ │
   │  │ 192.168.1.11:1025 → :80    │ 183.232.231.172:80 → 62001│ │
   │  └────────────────────────────┴───────────────────────────┘ │
   │  120.229.175.121:62000 ◀── 内部 A    两个 1025 不冲突,      │
   │  120.229.175.121:62001 ◀── 内部 B    对外端口不同即可         │
   └───────────────────────────────────────────────────────────┘
                                   │
                                   ▼
   到 183.232.231.172:80 的包,源已变成 120.229.175.121:62xxx
   └─ 服务端抓包永远看不到 192.168.1.10 这个地址

映射表就是 conntrack(connection tracking)表,一个表项一条连接,含 original 与 reply 两个方向,由内核管理。

建立:TCP 的首包 SYN、UDP 的首个报文、ICMP 的首个 echo 请求,只要匹配上 NAT 规则就建表并分配公网端口。

老化:表项各有空闲超时,超时未刷新即删除(来源:内核 nf_conntrack-sysctl 文档):

协议 / 状态参数默认(秒)
TCP 已建立nf_conntrack_tcp_timeout_established432000(5 天)
TCP TIME_WAITnf_conntrack_tcp_timeout_time_wait120
TCP SYN_SENTnf_conntrack_tcp_timeout_syn_sent120
UDP 单包nf_conntrack_udp_timeout30
UDP 流nf_conntrack_udp_timeout_stream180
ICMPnf_conntrack_icmp_timeout30

TCP 与 UDP 的超时差了两个量级,对应两种行为:TCP 连接一进入 ESTABLISHED,表项默认保留 5 天,靠长连接保活不断刷新;UDP 没有连接概念,30 秒没有报文就回收,同一个客户端端口的 UDP 映射几分钟后就换成另一个公网端口。

删除:TCP 四次挥手后进入 TIME_WAIT,等 120 秒才删除。内核保留这段时间,是为了让迟到的最后一个 ACK 仍能命中映射、被正确转换回内部地址。因此一条 TCP 连接两端都结束后,还会在 conntrack 表里停留约 2 分钟。

工程含义:长连接会长期占住表项(默认 5 天不老化),比短连接更快把 conntrack 表填满;而 UDP 应用(DNS、游戏、QUIC)的映射频繁回收又重建,映射数波动大。

6.3 SNAT、DNAT 与 MASQUERADE ​

Linux netfilter 提供三个动作,区别在改哪个方向的地址、以及是否依赖固定地址(来源:iptables(8)):

动作改写方向匹配链是否要固定地址典型用途
SNAT源地址(+ 端口)POSTROUTING要,--to-source 写死私网出口映射到已知公网地址
DNAT目的地址(+ 端口)PREROUTING / OUTPUT要,--to-destination 写死端口转发,把公网访问导到内部服务器
MASQUERADE源地址(+ 端口)POSTROUTING不要,取出接口当前地址DHCP / PPPoE 这类地址会变的链路

MASQUERADE 是 SNAT 的特例:不写死源地址,每个新连接建立时去读出接口当前的地址来用。它存在的理由是链路地址不固定——家宽的 PPPoE、DHCP 租约都会换地址,写死地址的 SNAT 在换址后立刻失效。代价有两处:一是每个新连接多一次查出接口地址的开销,比 SNAT 慢;二是出口地址一变,绑定了旧地址的既有连接全部失效。因此地址固定时应当用 SNAT,MASQUERADE 只留给真正会变的链路。

text
   入口方向:DNAT(改目的地址)
   公网访问 203.0.113.9:8080 ──▶ 出口设备改写目的 ──▶ 192.168.1.10:8080
                                        │
   出口方向:SNAT / MASQUERADE(改源地址)
   192.168.1.10:8080 ──▶ 出口设备改写源 ──▶ 203.0.113.9:62000
        │
        └─ 端口转发就是 DNAT(入) 与 SNAT(出) 的配对;
           回程若不经过同一台 NAT,映射对不上,连接无法建立

家宽做端口转发就是这套配对:入方向配 DNAT,出方向配 SNAT 或 MASQUERADE。它也是「非对称路由会打断 NAT」的来源——内部服务器的回包若从另一条路径出去、绕过了做映射的这台设备,就没有表项可查,连接卡在 SYN 之后。

6.4 NAT 对端到端语义的破坏 ​

IP 的原始设计里任意两台主机可以互相发起连接,地址就是身份(来源:RFC 2663 §1)。NAPT 把「地址 + 端口即身份」换成了「映射表项即身份」,由此产生三类代价。

外部无法主动连入。 NAPT 表项是内部主机发首包时建立的,外部来的首包没有对应表项,直接被丢弃。要让外部连进来只有三条路:

  1. 静态映射:手工配 DNAT,把固定的公网 IP:端口 映射到内部主机。能用,但每个服务都要预先配置,失去「内部随便开」的便利。
  2. 打洞(hole punching):双方先各自朝对方方向发一个包,让两侧 NAT 都建出映射,之后对方的首包就能命中表项。能不能打成取决于 NAT 的映射与过滤是否「端点无关」(endpoint-independent mapping / filtering):同一内部端口对不同目的若被映射成不同公网端口(对称型 NAT),打洞就失败(来源:RFC 4787;TCP 对应 RFC 5382)。
  3. 中继:先用 STUN 发现自己的公网映射,打不通时改用 TURN 中继转发(来源:RFC 5389、RFC 8656)。

6.5 ALG 与 NAT-T:载荷和协议层的补救 ​

载荷里带地址的协议要 ALG。 FTP 的 PORT / PASV 命令把 IP 和端口写在应用层载荷里,NAT 只改 IP 首部、不改载荷,对端拿到的是内部私网地址,回连必然失败(来源:RFC 959、RFC 6384 §1)。解决办法是 ALG(Application Layer Gateway):NAT 设备解析载荷、把里面的地址与端口同步改写,并预建对应表项。代价是它必须逐一理解每种应用协议,协议一改就失效;把协议解析放进数据路径,历史上大量 NAT 穿越问题与安全漏洞都出在 ALG。

IPsec 的兼容问题。 ESP 没有端口字段,NAT 无法用端口区分同一公网地址后的多条 IPsec 流;AH 的校验覆盖整个包(含 IP 地址),地址一改校验就失败。IKEv2 加入了 NAT 检测,并用 UDP 封装(NAT-T,端口 4500)让 ESP 跑在 UDP 里从而获得端口,IPsec 才能穿过 NAT(来源:RFC 3715、RFC 3948)。

这三类代价是同一个根因的三种补救:NAT 打破了「地址即身份」,凡是依赖地址在端到端之间保持不变的协议或场景,都得额外加一层(打洞、中继、ALG、UDP 封装)来补偿。

6.6 NAT 与地址族:NAT66 与 NPTv6 ​

IPv6 的设计目标之一是恢复端到端、取消对地址转换的依赖:128 位地址空间足够给每台设备全局可路由地址,重编号靠多地址与前缀委派,不靠 NAT(来源:RFC 4864 §1、RFC 6296 §1)。但 IPv6 里仍然出现了两类「想做地址转换」的需求,对应两个层次的机制:

  • NAT66:照搬 IPv4 的 NAPT 语义,在 v6 之间做有状态的地址 + 端口转换。它把 IPv4 NAT 的问题(表状态、外部无法主动连入、破坏端到端)原样搬进 v6,因此没有 IETF 标准,只在个别设备上以私有特性存在。
  • NPTv6(IPv6-to-IPv6 Network Prefix Translation):无状态的前缀翻译,1:1 换前缀,不改端口、不维护连接状态(来源:RFC 6296 §1)。它针对多宿主与重编号:内部使用 ULA(唯一本地地址)前缀,边界路由器把内出与外入的包在内部前缀和上游分配的前缀之间换算,上游换前缀时只改边界一条规则,内部寻址不动。

两者的取舍是这场张力的焦点。NPTv6 无状态、开销小,内部主机仍可被外部通过映射后的地址访问,比 NAT44 温和;但它依然让「地址不变」不再成立,会打断 IPsec、任何把地址写进载荷或日志的协议与系统,也让端到端调试(traceroute 看到的地址与实际不符)变复杂。反对它的立场是:IPv6 本来就用「一台主机同时持有多个上游前缀的地址」加内部 ULA 路由来解决重编号,不必再引入转换。

判断可以简化成一句:需求只是「换运营商时不想重编内部地址」,优先用多前缀 + 内部 ULA;只有在无法改内部编址、又必须向外发布单一前缀时才考虑 NPTv6。观测上 NPTv6 配在边界路由器上,IPv6 的 nat 表只有在配了 NAT66 时才有内容(ip6tables -t nat -L)。

6.7 NAT 后的并发连接上限 ​

一台 NAT 后面能同时维持多少连接,取决于三个瓶颈里最小的那个:

瓶颈对应参数 / 空间默认量级打满的表现
单主机对单一目的的源端口net.ipv4.ip_local_port_range32768–60999,共 28232 个connect() 失败,Cannot assign requested address(EADDRNOTAVAIL)
出口公网端口16 位端口空间(低端口通常保留)约 6 万个NAT 无法为新连接分配端口,连接被拒
全局连接跟踪表net.netfilter.nf_conntrack_max随 nf_conntrack_buckets 缩放内核日志 nf_conntrack: table full, dropping packet,新连接丢包

第一项为什么是「对单一目的」:内核新建连接选源端口时会跳开已占用的 (源端口, 目的) 组合;只要目的 IP 或目的端口不同,同一个源端口可以复用。所以 32768–60999 这 28232 个端口是「同一台主机连同一个远端」的上限,换一个远端可以再开一批(来源:内核 ip-sysctl 文档)。

真正的全局瓶颈是第三项。nf_conntrack_max 是整机 conntrack 表项的总数上限,一旦到顶,内核直接丢弃需要新建表项的包,并在日志里打印固定字符串 nf_conntrack: table full, dropping packet(来源:内核 nf_conntrack-sysctl 文档)。由于 TCP ESTABLISHED 表项默认保留 5 天,被大量长连接打满的机器表项几乎不回收,报错会持续出现。

这也解释了一个常见现象:单台客户端的端口没用完、出口的公网端口也没用尽,连接却建不起来——瓶颈在机器的 conntrack 表,不在端口。调的方向是提高 nf_conntrack_max、缩短长连接超时、或在中间层做连接复用以减少表项数,单靠扩端口范围解决不了。

6.8 NAT 的观测 ​

NAT 在路径中间改写四元组,排查时要在「两端 + 中间」同时看:

bash
conntrack -L                    # 列出当前所有映射(original / reply 两个方向)
conntrack -C                    # 当前表项数,与上面提到的上限对比
sysctl net.netfilter.nf_conntrack_max
ss -tn                          # 在内部主机看本地 (IP, 端口),看到的是未翻译的原始四元组
tcpdump -nn -i eth0 tcp         # 在内部主机与服务端分别抓,对比同一条流的源四元组

判据有三条:

  • 表项数逼近上限:conntrack -C 接近 nf_conntrack_max,且日志出现 table full,就是表满。它和「路由不通」的区别在于新老连接的表现——表满时老连接仍通、新连接随机失败;路由不通时新老都失败。
  • 同一条流两端四元组不同:内部主机抓到源 192.168.1.10:1025,对端同一时刻抓到源 120.229.175.121:62000,说明中间有 NAPT。这是判断「路径上有 NAT」的直接证据,与 TTL 递减(§5.1)是两个独立判据——TTL 只能说明经过了路由器,四元组变化才能说明经过了 NAT。
  • 映射没有建立:外部连不进来,且内部 conntrack -L 里根本没有对应目的的表项,说明既没有静态 DNAT,也没有内部主机先发过包,外部首包无从命中。

7. 组播与 IGMP ​

单播一对一,广播发给同一链路的所有主机,组播发给「加入了某个组」的主机。IP 层用 D 类地址标识组,链路层用组播 MAC 收帧,主机与直连路由器之间靠 IGMP 声明会籍。这三样拼起来才是完整的组播,本节按这个顺序讲。

7.1 D 类组播地址与固定组 ​

D 类地址 224.0.0.0/4 的前 4 位固定为 1110,剩下 28 位是组 ID。组地址只是标识,一台主机是否属于某个组由 IGMP 单独表达,与地址本身无关(来源:RFC 1112)。几个固定的组:224.0.0.1 本网段所有主机、224.0.0.2 本网段所有路由器、224.0.0.5/224.0.0.6 OSPF、224.0.0.9 RIPv2。

组播地址按用途分成几段(来源:RFC 5771 §4、§8、§10、RFC 4607 §1):224.0.0.0/24 是链路本地控制块,TTL 为 1、路由器不转发;224.0.1.0–238.255.255.255 是可由 IANA 分配、跨网段可用的普通组播;232.0.0.0/8 预留给源特定组播 SSM;239.0.0.0/8 是管理范围组播,只在组织内部有效。组地址没有「网络号 + 主机号」的划分,因此不参与子网划分、也不能像单播前缀那样聚合——一台主机加入哪个组是一次单独的会籍动作,与它在哪个子网无关。

7.2 组播 IP 到组播 MAC 的折叠 ​

以太网收帧靠 MAC,所以 IPv4 组播要映射到以太网组播 MAC:前缀固定 01:00:5e,随后 23 位取自组播地址的低 23 位,第 25 位固定为 0(来源:RFC 7042 §2.1.1)。问题在于组 ID 有 28 位,MAC 只放得下 23 位,高 5 位被丢弃:

text
   组播 IPv4 地址(D 类,组 ID 28 位)与以太网组播 MAC 的映射

   224 .   0 .   0 .   1              组 ID 共 28 位
   └──────┴──────┴──────┘
   ┌──────────┬────────────────────────────────────┐
   │  高 5 位  │            低 23 位                 │  ← 高 5 位被丢弃
   └──────────┴─────────────────┬──────────────────┘
        丢弃                      │
                                  ▼
   01 : 00 : 5e : 0x : xx : xx         MAC 低 23 位
   └──────┬──────┘  └──┬──┘
     固定前缀         第 25 位固定 0

   28 − 23 = 5 位被折叠 ⟹ 2^5 = 32 个组播 IP 映射到同一个 MAC
     224.0.0.1        → 01:00:5e:00:00:01
     239.255.255.255  → 01:00:5e:7f:ff:ff
        └─ 组 ID 低 23 位 = 0x7FFFFF,与 224.127.255.255 相同 ⟹ 撞在同一个 MAC

折叠的后果是网卡会收到不关心的组播:一台主机加入 239.1.1.1,实际收到的是所有映射到同一 MAC 的帧,内核还要按完整 IP 组地址再过滤一次。同一条链路上组播组多时,主机 CPU 会被这些「顺带收到」的组播吃掉。这与 10-以太网与 ARP 里 MAC 地址的 I/G 位是同一处细节——组播 MAC 的第一个字节最低位为 1(01 即 0000 0001),交换机对这类帧默认泛洪,除非开启 IGMP snooping。

7.3 IGMP:会籍协议与版本差异 ​

IGMP(Internet Group Management Protocol)工作在主机与「最后一跳路由器」(直连的那台)之间,作用是主机向路由器声明要加入 / 离开哪个组,路由器据此决定是否把组播流量转发到该链路(来源:RFC 1112、RFC 2236、RFC 3376)。报文用 IP 封装,协议号 2,TTL 通常为 1,不会跨出本链路(来源:RFC 2236 §2)。

三个版本逐代补能力(来源:RFC 1112、RFC 2236、RFC 3376):

版本规范关键变化
v1RFC 1112只有查询与报告;没有离组报文,主机退出靠路由器停止收到响应后超时(静默退出),退出延迟可达分钟级
v2RFC 2236增加离组报文与组特定查询,退出延迟降到秒级;增加查询者选举
v3RFC 3376报告里带 (组, 源列表) 与 INCLUDE / EXCLUDE 模式,支持源特定组播 SSM;报告由定长变为变长

v2 的两个流程最常被问到:

  • 常规查询与响应:查询者周期向 224.0.0.1 发通用查询(默认间隔 125 秒,来源:RFC 2236 §8.1);收到查询的主机为每个组启动一个 0–10 秒的随机延迟计时器,超时后回一条成员关系报告。若在计时器超时前听到同组其他主机的报告,自己就不再发(报告抑制),减少重复报文。
  • 离组:主机要离开某组时,向 224.0.0.2(本网段所有路由器)发离组报文;路由器收到后以 1 秒间隔连发 2 个组特定查询,确认该链路是否还有该组的主机;无人响应才停止转发该组。

IGMP 与 ICMP 尽管名字相近,协议号分别是 2 和 1,用途与报文都不同,两者没有关系。

7.4 组播为什么没普及,以及容器里的处境 ​

IP 组播在公网几乎见不到,原因不在技术不可行,而在路由与商业模型(来源:RFC 8815 §1、RFC 4607 §1):

  1. 域间组播路由部署稀疏。 ASM(任意源组播)需要一条跨 AS 的共享分布树和一个汇聚点(RP),要多个 ISP 协同才能跑通。IETF 已经把域间 ASM 标为不推荐,鼓励改用 SSM(来源:RFC 8815 §1)。
  2. 计费模型缺失。 单播按流量在源和目的两端分别计费;组播是「一份流量被多方接收」,复制点在哪、谁为复制付费没有共识,跨 ISP 缺少 SLA,运营商没有动力开通。
  3. 应用层替它做了。 CDN、HTTP 直播、会议系统用大量单播或应用层组播实现一对多,绕开了对 IP 组播基础设施的依赖。

容器与 K8s 里的处境更具体:每个容器在独立 netns 里,通过 veth 接到网桥,默认不共享宿主机的二层广播域,组播必须由 CNI 与网桥显式支持。常见的两个卡点:

  • 网桥的 IGMP snooping:网桥开了 snooping 时,按收到的 IGMP 报告决定把组播帧转发到哪些端口。容器若没有发出 IGMP 报告,网桥就不会把该组播转给它。排查时先看 bridge mdb show 里有没有该组、/sys/class/net/<网桥>/bridge/multicast_snooping 是不是 1,必要时关掉 snooping 让组播泛洪(内核默认值未逐版本核对)。
  • 跨节点组播:多数 CNI 只做单播转发,不提供跨节点的组播路由。需要组播时通常改用 host network(容器共享宿主的 netns)或 macvlan / ipvlan 接口,而不是指望 overlay。

判据:容器里组播收不到,先在宿主机看 ip maddr show(接口加入了哪些组播组)和 bridge mdb show(网桥的组播转发表),再查 snooping 与 CNI 是否支持组播,最后才怀疑应用侧。

8. IPv6 的差异 ​

IPv6(RFC 8200)首部结构大改,固定 40 字节,字段数反而变少:

变化说明
地址从 32 位扩到 128 位解决地址耗尽
首部固定 40 字节,去掉了 IHL不再是变长,也就没有「首部长度」字段
去掉了首部校验和链路层已有 CRC,交给上层校验
去掉分片相关字段没有 Identification、Flags、Fragment Offset
选项改为扩展首部链需要时才带,靠 Next Header 串联
新增 Flow Label给等价的流打标,便于设备做无状态调度

两条实践约束:

  • IPv6 路由器不允许分片,只有源端能分。所以 PMTUD 在 IPv6 下是强制的,对应 ICMPv6 的 Packet Too Big(Type 2)。
  • 最小 MTU 是 1280 字节,比 IPv4 的 576 大得多——所以 IPv6 的 TCP MSS 下限是 1280 − 40 − 20 = 1220,不会出现 IPv4 那种 536 的极小值。

9. 常见故障与排查 ​

9.1 症状到方向 ​

症状先看什么常见成因
Network is unreachableip route show没有匹配路由,也没有默认路由
No route to hostip route get <目标>有路由但下一跳不可达(ARP 失败 / 网段配错)
能 ping 通 IP、域名不通/etc/resolv.confDNS 配置,不是路由问题
同网段内互相不通ip addr 看掩码掩码配错导致对方被判为「非同网段」
小包通、大包卡死ping -M do -s 1472典型 PMTU 黑洞,见 05-TCP 协议 §3.6
跨网段不通、同网段通ip route get、对端网关缺默认路由或缺回程路由
改路由不生效ip route show table all流量走的是别的表(策略路由)
外部连不进、内部能连出iptables -t nat -L -n -v 的 DNAT 计数NAT 后没有静态映射,外部首包无 conntrack 表项可命中
连接偶发失败、日志有 table fullconntrack -C 与 nf_conntrack_maxconntrack 表满,新连接被丢(老连接仍通)
两端抓包的源端口对不上路径中间设备的 conntrack -L路径上有 NAPT 改写,见「NAT 的观测」

9.2 三个直接可用的命令 ​

看内核实际选哪条路由(排查第一命令):

bash
ip route get 10.1.2.3
# 10.1.2.3 via 10.0.0.1 dev eth1 src 10.0.0.5 uid 0

输出把「下一跳 / 出接口 / 优选源地址」一次给全,比逐条读路由表猜要快。

验证是否被分片 / 是否触发 PMTU 问题:

bash
ping -M do -s 1472 10.1.2.3     # -M do 即置 DF 位;1472 + 20 + 8 = 1500
tcpdump -i eth0 -nn 'icmp and icmp[0] == 3 and icmp[1] == 4'   # 只看 Fragmentation Needed

-s 1472 是能通过 1500 MTU 的最大 ICMP 载荷(1472 + 20 IP + 8 ICMP = 1500)。从 1472 往下二分,能探出实际路径 MTU。

看协议栈计数器:

bash
nstat -az | grep -i -E 'frag|reasm|noroute|inaddr'

关注 IpReasmReqds / IpReasmFails(重组压力)、IpFragFails(分片失败,通常是 DF 置位又超 MTU)、IpOutNoRoutes(无路由丢弃)。看增速,不看累计值。

ping 与 traceroute 这两个连通性工具依赖的 ICMP 报文类型、差错语义与常见伪造手法见 09-ICMP 与 ping。

9.3 参数与取舍 ​

参数默认改它的后果
net.ipv4.ip_forward0(主机)/ 1(路由器)置 1 才转发;容器、K8s 节点必须开
net.ipv4.ip_no_pmtu_disc0置 1 关闭 PMTUD,全链路按最小 MTU 跑
net.ipv4.tcp_mtu_probing0置 1 在检测到黑洞时自动递减探测
net.ipv4.conf.*.rp_filter视发行版反向路径校验;多宿主 / 非对称路由环境下会误杀
net.ipv4.ip_local_port_range32768–60999影响主动连接可用端口数
net.ipv6.conf.*.accept_ra1是否接受路由通告,路由器上要关

rp_filter 是容器与多网卡环境的常见坑:它要求「回程路径与来程一致」,非对称路由下会静默丢包,症状是「能收到请求但回不去」。

9.4 NAT 与多表场景的定位顺序 ​

§9.1 到 §9.3 假设流量走的是普通转发路径。叠上 NAT 与策略路由后,判据要按「内核先选表 → 表内选路由 → NAT 建映射 → 两端报文」的顺序逐步收窄(来源:iproute2、conntrack-tools 的观测口径):

text
   1  ip route get <目标>
        └─ 内核为这个流选了哪张表、哪条路由、用哪个源地址
              │
              ▼
   2  ip rule show        (第 1 步的 src 不对时)
        └─ 是否被某条规则拉到了别的表(策略路由)
              │
              ▼
   3  iptables -t nat -L -n -v   (或 nft list ruleset)
        └─ SNAT/DNAT 规则的匹配计数在不在涨、命中的是哪个链
              │
              ▼
   4  conntrack -L / -C  +  sysctl net.netfilter.nf_conntrack_max
        └─ 映射建了没有、表离上限还有多远
              │
              ▼
   5  两端 tcpdump
        └─ 确认同一条流两端四元组的差异与 NAT 规则一致

每一步的判据:

现象停在第几步结论
ip route get 的 src 不是你以为的地址1 → 2被策略路由分流,NAT 规则(常带 -s / -o)匹配不上
DNAT 规则计数不涨3包没进 PREROUTING,或规则被前面的 ACCEPT / RETURN 截断
外部连不进、内部 conntrack -L 无对应目的4没有静态映射,外部首包无从命中 NAPT 表
conntrack -C 逼近上限、日志有 table full4conntrack 表满,调 nf_conntrack_max 或缩短长连接超时
两端四元组不一致且与规则吻合5NAT 生效;若连接仍不通,回到第 1 步查回程路由

一条经验:内核日志里的 nf_conntrack: table full, dropping packet 是固定字符串,见到它就直奔 conntrack 表容量,不必在路由层兜圈子。

相关 ​

参考 ​

ip / nstat / tcpdump 的观测口径与内核参数来自 iproute2 与内核文档,未逐条对应到单一规范。

贡献者 ​

文件历史 ​