☰
IPv6 PMTUD一致性测试失败?内核补丁修复PTB处理逻辑
2026/10/17 3:16:26 网站建设 项目流程

如果只看项目标题,你可能会觉得这又是一个“网络工程师日常修 bug”的活儿。但实际做完这一轮“IPv6 一致性测试”之后,我才真正意识到:Path MTU Discovery for IPv6 这套机制,平时不显山不露水,一旦在内核或者路由实现里埋了雷,排查起来比普通路由问题痛苦得多。这次测试最终落到了一次内核修改上,不是改配置、不是换驱动,而是动了 IPv6 路由路径上更新 PMTU 的核心逻辑。

我想把这次从“测试失败”到“内核补丁验证通过”的完整过程整理出来。别急着拉代码,先搞明白一个问题:IPv6 一致性测试里为什么要专门为 PMTUD 设用例,而这玩意儿又为什么会牵扯到内核实现。搞懂了这两点,后面看补丁、复现问题都会顺很多。

1. 先把“PMTUD for IPv6 ”为什么特殊讲清楚

1.1 IPv4 时代那套分片逻辑,在 IPv6 里直接失效

先说最容易被忽略的一个概念区分。IPv4 网络里,一个 1500 字节的 IP 包进入一条 MTU 只有 1400 的链路时,中间路由器通常可以自己把这个包拆成两个分片继续转发,不需要源端知道任何信息。但 IPv6 把这个能力收掉了:中间路由器不做分片,如果一个 IPv6 报文超过出接口的 MTU,路由器只能丢掉它,然后给源节点回一个 ICMPv6“Packet Too Big”消息,俗称 PTB 消息。

源节点收到 PTB 之后,必须在本地路由缓存里记录一个更小的路径 MTU,后续发往同一目的地的数据包都按这个新值来发送。这个过程就是 PMTU 发现。IPv6 里还有一个硬性底线:所有接口至少能处理 1280 字节的 IPv6 报文,路径 MTU 不会低于这个值,除非上层协议有特殊约定。

你如果用过 IPv4 的“不分片标记 + 回显超时”来测 PMTUD,换成 IPv6 以后会明显感觉不适应。IPv4 的数据包经过路由器分片后,源端根本感觉不到链路变化;IPv6 则把“链路变小”这个信息通过网络层控制消息直接反馈给源端。我用一个生活化的类比给你感受一下:IPv4 很像你在快递驿站寄一个大箱子,驿站看到箱子超过运输车的尺寸,会把里面东西拆成几个小箱子分开运,收件方再拼起来;IPv6 则是驿站看到箱子太大,直接拒收并把整个箱子退回来,告诉你“换个更小的箱子再寄”。

1.2 一致性测试里的 PMTUD 检查点

既然 PMTUD 依赖源端主动适配,那一致性测试就会重点验证“源端适配行为”。这类测试在正式入网测试里一般会覆盖这么几个维度:

  • 源节点是否能在收到 PTB 后,对目标地址的后续数据包执行更小 MTU 发送;
  • 中间路由器在转发超长 IPv6 报文时,是否准确回送 ICMPv6 PTB;
  • 收到 PTB 中的 MTU 值小于 1280 时,源节点是否按 1280 处理;
  • 路径 MTU 变化后,TCP 连接是否仍能继续传输,而不是无限重传或者挂死。

注意,一致性测试不是“功能连通测试”。连通性测试只要你能 ping 通、TCP 能握手就算通过;一致性测试会把报文大小、ICMPv6 消息类型、路由缓存变化全部拆开检查。哪怕只是源节点对 PTB 消息的处理延迟了一拍,都可能被判定为不符合 RFC 8201 的行为。

1.3 为什么测试结果会牵扯到内核

我们这次被测的是一台基于 Linux 内核算法的 IPv6 网关设备,整个 IPv6 协议栈都跑在内核里。用户态程序只是发起连接、收发数据,真正决定“收到 PTB 后改不改路由缓存”的,是内核 TCP/IP 协议栈那一大堆路由更新逻辑。所以一致性测试一旦在 PMTUD 用例上亮红灯,八成不是用户态的问题,得往内核的 IPv6 输出路径和路由更新逻辑里查。

这也是我给这次的“内核修改”项目定位时的出发点:协议一致性测试测的是设备行为,但行为背后的实现往往藏在操作系统内核里。如果你只盯着配置文件和防火墙规则找原因,大概率只能在表面打转。

2. 测试环境与复现场景

2.1 拓扑和参数配置

测试拓扑并不复杂,我做了两个 Linux 网络命名空间当作主机,中间再放一个路由命名空间模拟小 MTU 链路。主机 A 和主机 B 都能正常通信,中间路由器的出接口 MTU 被我手动调成了 1400,用来人为制造路径上的“小水管”。

路由器和两个命名空间之间的接口都用 veth 连接,IPv6 地址按简单的链路本地加全局地址配置。关键命令大概是这样的:

# 在路由命名空间里,把通往主机 B 的接口 MTU 设为 1400 ip netns exec router ip link set dev veth-rb mtu 1400 ip netns exec router sysctl -w net.ipv6.conf.all.forwarding=1

主机 A 侧保持默认的 1500 MTU,这样只要 A 往 B 发送超过 1400 字节的 IPv6 包,中间路由器就必须丢弃并回 PTB。整个设计没有用额外的网络模拟工具,纯靠链路 MTU 差就能稳定触发。

2.2 看到的失败现象:TCP 连接半路挂死

一开始我用一条 TCP 长连接做数据吞吐测试,发送缓冲区调到 1MB,数据很容易超过 1400 字节。结果发现连接在握手完成后很快进入停滞状态,应用层一直拿不到新数据。从主机 A 的网卡统计上看,IPv6 报文持续重传,但重传内容一成不变,始终是最开始那几个大包。

关键点在于:抓包文件里能看到中间路由器确实回送了 ICMPv6 PTB,但主机 A 后续的报文仍然是 1500 字节,没有任何收敛迹象。换句话讲,PTB 消息在网络里“走了个过场”,但源节点根本没有根据它调整自己的发送大小。

再看中间路由器那边的表现,它的日志里有“Packet Too Big”的发送记录,说明路由器侧行为是符合预期的。问题从“路由器不回 PTB”转移成了“主机侧收到 PTB 后无动于衷”。

2.3 最小化复现和系统日志

TCP 的拥塞控制和重传行为会干扰问题定位,所以我很快把复现场景缩小到 ICMPv6 层。在主机 A 上直接用 ping6 发一个 1450 字节的 payload,观察 PTB 回包后面的第二次发送:

# 在主机 A 上 ping6 -s 1450 -M do -c 3 2001:db8::2

如果 PMTUD 正常,第一次 1450 字节包会被路由器拒绝,回一个 PTB;紧接着第二次探测包应该自动降为不超过 1400 字节。但我这边的实测结果是:连续三发探测包全是大包,中间路由器也只是机械地回 PTB,双方进入一种“你丢我回、你发我丢”的死循环。

同时打开/proc/net/ip6_rt_cache观察路由缓存条目的状态,发现目标地址的缓存项里 MTU 一直没有变化。这基本可以断定:PTB 消息到没到路由更新函数是一回事,到了之后更新函数有没有执行成功是另一回事。

3. 定到内核函数:PMTU 更新链路拆解

3.1 ICMPv6 PTB 进来以后发生了什么

先梳理一条大致的执行链:IPv6 报文从网卡收上来后,经过协议栈识别,ICMPv6 的 PTB 消息会进入 ICMPv6 处理逻辑。内核会从这个 PTB 携带的信息中取出“出错包的源地址、目的地址、建议 MTU”,然后去路由表里查找对应的目的路由条目,再调用路径 MTU 更新函数修改条目的 MTU 值。

理论上这个过程是无条件的:只要 PTB 本身合法,就应该把路由条目的 MTU 下调。但实际上 Linux 内核在这种更新上做了很多防滥用处理。比如它要核对 PTB 是否来自“曾经发送过的数据包”,避免任意第三方伪造一个低 MTU 值把路径搞瘫痪;还要判断当前更新的 MTU 是否低于某条阈值,防止攻击者反复发送极小的 PTB。

这些防滥用机制初衷都没有问题,但某些实现细节会和一致性测试的预期产生冲突。RFC 8201 要求主机必须响应有效的 PTB,而内核为了抗攻击,可能会在“有效”这两个字上做额外的条件判断,导致真正的 PTB 被忽略。

3.2 第一处根因:路由条目的更新条件太严格

我在内核代码里沿着icmpv6_rcv -> icmpv6_notify -> ip6_rt_update_pmtu这条线索查下去,发现更新函数里有一个判断逻辑:只有当 PTB 中携带的 MTU 值“小于当前路由条目的 MTU”时才会更新。乍一听很合理,路径变小当然要下调 MTU。但问题在于测试场景里,那条缓存条目可能已经被之前的低 MTU 值更新过一次,后来又因为有某些原因被恢复回默认值。

举个例子:第一次 PTB 已经把路径 MTU 降到了 1280,随后路由条目被下一次路由查找重建,MTU 又回到 1500。这个时候如果再来一个 MTU=1400 的 PTB,按旧逻辑它会认为 1400 大于当前值 1280,于是拒绝更新。从外部行为看,源节点明明收到过合法 PTB,却死守着一个小 MTU 不放,或者守着错误的大 MTU 不放。

这种“严格比较”的写法在抗攻击上有价值,但一致性测试不会管你是不是为了防攻击。它只看行为:收到 PTB 后,路径 MTU 必须朝 PTB 给出的值收敛。

3.3 第二处根因:mtu 锁与黑洞对抗策略

另一个让问题更隐蔽的机制是“MTU 锁”。内核里为了避免 PMTUD 黑洞,会对某些路由条目的 MTU 做锁定处理。比如 TCP 协议栈在建立连接时可能根据 MSS 锁定了 PMTU,后续即使收到更大的 PTB,也不会把 MTU 调回去。这种设计在防止“黑洞检测”时很有效,但会带来一个副作用:它可能让路由条的“锁”状态被错误地带到新的 PMTU 更新过程里。

我们的测试结果表明:当 PTB 尝试把 MTU 从 1500 降低到 1400 时,更新函数发现路由条目前面已经被打上了一个“锁定”标记,于是一律拒绝任何 MTU 调整。这个标记的来源并不是本次测试链路,而是之前某一次大报文发送时内核自动做的“PMTU 黑洞保护”。

这个现象特别坑,因为你在sysctl里看不到任何直观开关,只能通过查看路由缓存条目的 flags 字段发现异常。也就是说,用户层面看起来一切正常,实际行为已经被内核的防御机制悄悄改掉了。

3.4 修改时怎么对齐 RFC 8201

按 RFC 8201 的语义,有效的 PTB 消息是路径上的明确反馈,应该优先于本地的防呆逻辑。防黑洞策略的目标是防止“PTB 丢失导致的无限重传”,而不是阻止一切 MTU 变化。所以我们修改内核的总体思路很清晰:保留正常的防攻击条件,但增加一个“严格一致性模式”,在该模式下,合法 PTB 必须直接触发路由条目的 MTU 更新,不再被脏标记和锁状态干扰。

4. 内核修改方案的设计与实现

4.1 修改点选择的思路:最小改动优先

我给自己定的原则是:能加条件就不改流程,能用新开关就不动默认行为。虽然项目标题写的是“内核修改”,但内核代码牵一发动全身,没必要为了通过一个测试用例把整个 PMTU 链路推翻重写。考虑再三,决定在原有更新函数入口处加一个全局开关,默认关闭;一旦一致性测试场景需要,就通过sysctl打开“严格 PTB 处理模式”。

这样既不影响生产环境默认行为,又能保证测试场景里的严格语义。补丁范围控制在net/ipv6/route.c和对应头文件,其他模块一概不碰。

4.2 核心补丁逻辑

下面是一段精简版的补丁示意,保留了我认为最重要的几个处理步骤:

static inline bool ip6_pmtud_strict_mode(struct net *net) { return net->ipv6.sysctl.ip6_pmtud_strict; } void ip6_rt_update_pmtu(struct dst_entry *dst, u32 mtu) { u32 old_mtu = dst_mtu(dst); bool strict; if (mtu < IPV6_MIN_MTU) mtu = IPV6_MIN_MTU; strict = ip6_pmtud_strict_mode(dst->dev_net); if (strict) { // 严格模式下:合法 PTB 直接更新缓存条目值 dst_metric_set(dst, RTAX_MTU, mtu); dst_metric_set(dst, RTAX_LOCK, 0); return; } // 原有默认逻辑:保留防黑洞、防锁定等判断 if (mtu >= old_mtu) return; dst_metric_set(dst, RTAX_MTU, mtu); }

注意我上面的代码是示意性质,实际补丁还要处理网络命名空间参数、并发访问、路由缓存引用计数等细节。但核心意图表达得很直接:严格模式下,PTB 携带的 MTU 值直接写入路由条目的指标里,同时清掉可能阻止更新的锁标记。

为什么要把小于 1280 的值强行拉回 1280?因为 IPv6 最小 MTU 是硬要求,哪怕 PTB 传了一个更夸张的值,源节点也不能把 PMTU 设到 1280 以下。这个兜底判断是无论哪个模式都必须有的。

4.3 回退与影响控制

补丁合入前我最担心的是影响面。所以把严格模式开关设计成netns级可配置,这样即使在生产环境误打开,也只影响当前网络命名空间,不会波及其他容器或主机。

另一个控制手段是只让严格模式作用于“来自 ICMPv6 PTB 的主动更新”,不改变 TCP MSS 协商、接口 MTU 变化等正常触发路径。否则很容易出现“测试通过了,但真实网络里行为变得更激进”的副作用。

实际打补丁时,我在ip6_rt_update_pmtu函数入口处打印了追踪日志,确认每次 PTB 更新都走到了预期的分支。这一步很重要,因为内核模块不像用户态程序那么好打断点,日志是最稳妥的验证手段。

5. 合入前后的回归验证与数据

5.1 回归验证矩阵

补丁合入后,我把之前所有跟 MTU 相关的测试用例全部重跑了一遍,不只是单点验证。重点覆盖下面几类场景:

验证项期望结果合入前合入后
1400 MTU 链路下发送 1450 字节 ping第二次探测包降为 1400 以下失败,持续大包通过
TCP 长连接跨 1400 MTU 链路吞吐恢复,无永久重传挂死通过
PTB 携带 MTU 小于 1280实际按 1280 发送部分失败通过
无 PTB 环境下默认路由行为保持原有发送逻辑通过通过
严格模式关闭时使用老行为与旧版内核一致通过通过

从结果看,核心用例通过得很干净。尤其值得注意最后一行:默认关闭严格模式时,老逻辑完全保留。这有助于后续给其他项目移植补丁时降低风险。

5.2 性能影响和注意事项

性能上,我做了大报文吞吐对比,严格模式开启后由于 MTU 及时收敛,TCP 重传率大幅下降,反而比之前“反复发大包、反复收 PTB”的死循环更省资源。整个过程 CPU 占用率没有明显波动,加进来的只是几个分支判断,不会成为瓶颈。

但有一点必须提醒:严格模式不能当作万能开关一直开着。它在一致性测试环境里是正确的,因为测试仪会精确构造合法 PTB;在真实公网环境里,如果遇到伪造 PTB 报文,严格模式下的持续更新有可能带来更多路由缓存抖动。所以我的建议是:测试床打开,生产环境务必保持默认关闭。

6. 验证中的常见问题与排错速查

6.1 现象、可能原因和排查命令

这次测试花了不少时间在“定位问题”上,真正写补丁反而很快。我把踩过的坑整理成一张速查表,后面再遇到 PMTUD 相关诡异现象,可以直接按表走:

现象可能原因排查命令/方法
收到 PTB 但发送大小不变路由缓存更新被锁或条件判断拦截查看ip -6 route get 目标地址里的 mtu 字段
大量 TCP 重传且长度固定不变PMTUD 黑洞,PTB 被防火墙丢弃抓包过滤icmpv6 && icmpv6.type == 2
路径 MTU 显示很小但业务正常路由缓存条目被锁定在低值查看/proc/net/ip6_rt_cache的 flags
中间路由器不回 PTB出接口 MTU 配置错误或转发未开启检查路由器接口 mtu、net.ipv6.conf.all.forwarding
PTB 携带值小于 1280正常现象,按 1280 兜底即可观察发送端最终包长,不应低于 1280

6.2 两个特别值得说的坑

第一个坑是抓包位置。刚开始我在主机 A 的入口抓包,看到 ICMPv6 PTB 确实进来了,就开始怀疑 TCP 协议栈不处理。后来发现中间路由器回 PTB 是在出接口,两者之间还有一层 veth,抓包位置完全错了。正确做法是同时在主机 A 的入口、路由器的出口各开一个抓包点,先把“PTB 有没有真实到达源节点”这步彻底钉死,再往下追。

第二个坑是 netfilter 干扰。有些系统默认的 IPv6 防火墙规则会丢弃未关联的 ICMPv6 类型,PTB 虽然是控制面消息,但一样可能被拦。排查时务必执行一下ip6tables -L INPUT -v,先把规则清空再做测试。很多“内核不支持 PMTUD”的假象,其实是防火墙在背后帮你把 PTB 丢了。

写在后面

改内核这件事,圈外看热闹,圈内看门道。我个人最大的体会是:协议一致性测试暴露出的问题,往往是实现细节和标准语义的偏差,而不是功能完全缺失。像 PMTUD 这种依赖 ICMPv6 反馈的机制,任何一层过滤、缓存、锁策略都可能把标准行为带偏。

如果非要说一句给后来者的建议,我会说:在动内核以前,先把“PTB 有没有到”、“到了以后有没有进更新函数”、“进了函数以后有没有被条件拦截”这三步用日志和抓包分别验证清楚。大部分时间都不是内核不会做,而是它在某个细节上做了太多保护,最后反而和标准的预期对不上。

这次修改之后,我又把严格模式对应的sysctl配置项加到了项目的默认配置模板里,只对测试网段生效。以后再跑同类一致性用例,至少不用重新翻内核源码了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询