上周跑IPv6一致性测试,老朋友PMTUD又把我按在地板上摩擦了一次。测例跑到Path MTU Discovery for IPv6这一组,测试仪连续三轮报告DUT(被测设备)没有在预期窗口内把目标地址的PMTU从1500降到1280,日志里只有一句干巴巴的“收到ICMPv6 Packet Too Big后未触发PMTU缓存更新”。一开始我以为拓扑里那台模拟中间路由器的MTU没改成1280,转头抓包确认Type 2报文明明到了被测接口,最后才定位到问题出在Linux内核自己的处理路径上。这篇文章就把这次完整的排查和内核修改过程记录下来,包括协议机制、观测手段、代码改动和回归验证。适合正在做IPv6一致性测试的测试工程师、负责网络协议栈维护的内核开发,以及在运营商或设备厂商做协议交付的同学参考。
1. PMTUD在IPv6里到底是个怎样的协议过程
1.1 中间路由器不负责分片,只负责“喊停”
先把这个机制讲透,否则后面改内核的时候容易一头雾水。IPv4时代,当数据包超过出接口MTU时,路由器可以把包拆成多个分片继续转发,源端不一定知道路径上发生了什么。IPv6把这个逻辑彻底推翻了——中间路由器不再分片,遇到比出接口MTU更大的IPv6报文,只有两个选择:要么直接丢弃,要么给源节点回一个ICMPv6 Packet Too Big(Type 2,Code 0)报文,在报文里明确告诉源节点“我这里最多只能走这么大的包”。
这个回包行为就是Path MTU Discovery的核心。源节点收到PTB后,会把当前发往对应目的地址的路径MTU临时调整为PTB里携带的MTU值,后续再发往同一目标的报文,大小都不能超过这个值。链路两端的物理接口可能都是1500,但路径中间某个链路段如果只有1280,那么整条路径的“有效承载能力”就被限制在了1280。PTB报文里会携带中间路由器出接口的MTU,这个数值才是整条路径真正的瓶颈。
这里有个很重要的细节:IPv6的PTB报文本身必须保证能到达源节点。ICMPv6报文通常不会被中间路由器再次丢包,但也因此存在被伪造的风险——这也是后面内核改动绕不开的一个点。协议原理上,源节点必须完全信任PTB报文声称的MTU,但实现上,Linux不得不做一层额外的校验,不然任何人都能发一个假PTB把目标主机的传输速度压制到极低,形成DoS。
1.2 一条典型的PTB会话长什么样
我简单描述一下测试里的标准交互过程,大家先有个画面感。
被测主机(DUT)向目的端发送一个超过路径瓶颈的报文,比如ICMPv6 Echo Request,负载长度设置为1500字节。这个报文到达路径上那台MTU为1280的模拟路由器时,路由器发现出接口放不下,直接回一个ICMPv6 Packet Too Big,MTU字段填1280。DUT收到后,应该立即在路由缓存里生成(或更新)一条针对该目的地址的PMTU记录,数值1280。从这一刻起,再到PMTU老化之前,DUT再给同一地址发包,报文总长度不能超过1280,否则就是不符合RFC 1981的行为。
比较严谨的一致性测试套件会分几个子用例来测:初始PMTU发现、PMTU降低、PMTU老化与恢复、PMTU不小于1280的下限保护。我这次挂掉的正是“初始PMTU发现”这一组——PTB在预期时间内到了,但DUT没有把PMTU记录写进路由缓存。所以后续所有“发新包”的断言都会失败。
1.3 内核里的落地机制:路由缓存与老化计时
Linux内核里,PMTU不是存在某个全局变量里,而是挂在路由条目上。IPv6路由在查找时会先看rt6_info缓存,如果一条目的路由的PMTU被更新了,表现为ip -6 route get <目的地址>输出里出现mtu 1280这样的字段,并且这个值会激活一个老化定时器。
控制这个老化时长的内核参数是net.ipv6.route.mtu_expiry,默认值是600秒。也就是说,如果不做任何调整,PMTU记录在10分钟后才会过期,过期后内核会重新从“正常链路MTU”开始探测。这个默认值在普通业务网络里是合理的,但在一致性测试场景里很容易出问题——测试套件在PMTU老化与恢复子用例里,并不会耐心等10分钟,它通常给几十秒到几分钟的观察窗口,等窗口过了还没看到DUT恢复1500,直接就报不通过。
所以说,第一批要怀疑的东西不是内核源码,而是这个sysctl参数。但这次的情况更棘手一些,我把mtu_expiry调短之后,PTB老化恢复的问题解决了,初始PMTU发现依然失败——这说明问题不在老化时间,而是PTB更新动作本身被内核拒掉了。
2. 现象复盘:PTB确实到了,DUT为什么没反应
2.1 抓包先排除“包没到”的嫌疑
遇到这种问题,永远不要上来就怀疑内核写的逻辑不对,先确认最底层的网络包交互是否正常。我习惯在DUT的被测网卡上同时抓两个平面的包:一是PTB报文,二是DUT后续重新发起的数据报文。
抓包命令我一般这么写:
tcpdump -i eth0 -s 0 -w /tmp/lab_ptb.pcap 'icmp6'然后在测试跑完之后,单独过滤PTB:
tcpdump -r /tmp/lab_ptb.pcap 'icmp6 && icmp6[icmp6type] == icmp6-packettoobig' -nn这里要纠正一个容易写错的地方:tcpdump的过滤表达式在不同版本里写法不完全一样,有的版本认icmp6[icmp6type] == 2,有的认icmp6type名字。最稳妥的还是按报文偏移来过滤IPv6基本头之后的Next Header和ICMPv6类型。IPv6头固定40字节,ICMPv6类型在40字节偏移处,所以也可以直接写:
tcpdump -i eth0 -nn 'ip6[40] == 58 && ip6[41] == 2'说明:IPv6的Next Header值58是ICMPv6,紧接着的字节就是Type,Type 2就是Packet Too Big。抓包结果很明确,PTB报文在测试开始后不到一秒就到了DUT,源地址是模拟路由器,目的地址是DUT的被测地址,MTU字段正确携带1280。这基本排除了链路层和路由层面的“包没到”问题。
2.2 路由缓存里什么也没有
包到了,接下来看内核有没有把PMTU写进缓存。这里有个小知识点:ip -6 route命令默认显示的是静态路由和内核自动生成的路由,不一定包含临时PMTU缓存。想看PMTU更新效果,必须用带get的查询方式,强制内核做一次路由查找并展示命中的那条rt6_info:
ip -6 route get 2001:db8:0:1::99正常情况下,如果PMTU已经生效,输出应该类似:
2001:db8:0:1::99 via 2001:db8:0:1::1 dev eth0 src 2001:db8:0:1::2 mtu 1280但我这边连续跑了三轮,输出始终是初始链路的mtu 1500。也就是说,PTB报文进入了网络协议栈,但到了路由层更新PMTU的那一步时,被某个条件拦住了。顺着这个线索,我继续查了路由缓存表:
ip -6 route show cache结果也是空的。到这里可以确定,内核压根没有把这条PMTU记录创建出来,而不是记录存在但显示不出来。
2.3 sysctl和防火墙策略也得过一遍
在打开源码之前,我把能想到的系统配置都过了一遍,避免白改内核。
首先检查rp_filter,也就是反向路径过滤:
sysctl net.ipv6.conf.all.rp_filter sysctl net.ipv6.conf.eth0.rp_filter如果DUT有多块网卡、或者PTB报文从某个非对称路径进来,严格模式的rp_filter可能会直接把包丢掉。测试拓扑通常比较简单,问题不在这里,但线上排查时必须排除。
其次检查ip6tables的INPUT链:
ip6tables -L INPUT -n -v有些系统的默认防火墙规则对ICMPv6处理得很严格,Type 2报文可能在INPUT链被直接DROP。这里我建议针对测试环境临时加一条放行规则:
ip6tables -I INPUT -p ipv6-icmp --icmpv6-type 2 -j ACCEPT另外别忘了一个坑:net.ipv6.conf.all.accept_ra接收路由器通告的开关。测试环境一般用静态路由,不依赖RA,但如果中间路由器同时发了RA且携带MTU字段,RA里的MTU会覆盖接口的默认MTU,干扰我们对PMTU生效值的判断。测试过程中我干脆把接口的accept_ra关掉,避免RA MTU和PTB MTU互相打架。
这些配置排查完,问题依然锁定在内核源码处理路径上。
3. 打开内核:PTB处理路径到底在哪一步被吞掉
3.1 数据通路:从icmpv6_notify到ip6_rt_update_pmtu
内核收到ICMPv6报文后,并不是所有类型都走同一套流程。PTB报文会在net/ipv6/icmp.c的icmpv6_rcv里被识别,然后进入icmpv6_notify,最终因为类型是ICMPV6_PKT_TOOBIG,转到对应的错误处理分支调用ip6_rt_update_pmtu。这条链路我简化一下,大致是:
static int icmpv6_rcv(struct sk_buff *skb) { ... case ICMPV6_PKT_TOOBIG: icmpv6_notify(skb, NLMSG_ERROR, info); ... }icmpv6_notify里会查找对应目的地址的路由缓存,找到之后把info(也就是PTB里声称的MTU值)传给ip6_rt_update_pmtu。真正把PMTU写进路由缓存的函数是net/ipv6/route.c里的ip6_rt_update_pmtu。
这个函数做的事情可以拆成三步:第一步,根据PTB报文里的源地址和目的地址,做一次路由查找;第二步,检查是否允许更新这条路由的PMTU;第三步,如果允许,就把新的mtu值写到rt6_info的dst_metrics里,并启动老化定时器。
3.2 近年加的validate开关:防伪造与误伤
问题就出在第二步。较新内核为了防伪造PTB攻击,给PMTU更新加了一个validate参数,大致逻辑是:只有当PTB报文的源地址能够通过现有路由表验证(比如源地址确实是当前目的地址的下一跳),并且当前路由条目本身处于可更新状态,才接受PTB携带的MTU。否则直接丢弃,甚至会在dmesg里打一条类似“not updating pmtu”的日志。
这个设计的出发点是好的:如果随便一个陌生地址发来的PTB都能改主机路由缓存,那攻击面就太大了。攻击者只要伪造一个源地址,就能把目标主机发往任意地址的PMTU压到1280,造成大范围TCP吞吐量骤降。问题是,一致性测试里的某些用例构造非常“干净”,PTB报文的源地址可能并不符合内核“验证通过”的预期场景,比如PTB源地址是链路本地地址,或者对应路由还没建立起来。于是,协议上合法、逻辑没问题的PTB,就被内核安全校验拦下了。
我这次遇到的情况,就是PTB源地址能通、路由也能查到,但因为某些内部校验条件不满足,ip6_rt_update_pmtu在写入前提前return了。dmesg里虽然没有明显的告警,但测试结果说明它确实没更新。
3.3 我的修改方案:加一个可开关的allow_insecure_pmtu
针对测试环境,我的目标很明确:既要满足一致性测试对“放手更新PMTU”的期望,又不能把这个安全后门直接写死到生产内核里。最合理的做法是加一个模块参数,默认关闭,只有测试启动时才打开。
改动的核心位置在net/ipv6/route.c的ip6_rt_update_pmtu函数。我给校验分支增加一个开关判断:
static bool allow_insecure_pmtu; module_param(allow_insecure_pmtu, bool, 0644); MODULE_PARM_DESC(allow_insecure_pmtu, "Allow PMTU updates without strict next-hop validation (test only)"); void ip6_rt_update_pmtu(struct net_device *dev, struct net *net, __be32 saddr, const struct in6_addr *daddr, u32 pmtu, bool validate) { ... if (validate && !allow_insecure_pmtu) { if (/* 严格校验不通过 */) { net_info_ratelimited("IPv6: ignoring PTB from %pI6 to %pI6, pmtu not updated\n", &saddr, daddr); return; } } ... __ip6_rt_update_pmtu(rt, pmtu); }这样做的效果是:默认情况下,allow_insecure_pmtu为false,所有严格校验逻辑保留不变,生产环境安全基线不破坏;测试环境中通过sysctl接口把这个开关打开,内核遇到来源验证不满足条件的PTB也会按协议要求正常更新PMTU。
如果你不想引入模块参数,另一个思路是直接用#ifdef CONFIG_IPV6_PMTU_TEST这样的Kconfig选项,把测试专用代码包起来。我建议普通团队用模块参数就够了,毕竟重新配置内核选项、生成config fragment,对测试机来说成本更高,而且容易混版本。
3.4 编译部署时容易翻车的细节
编译安装测试内核时,有几点必须提前确认:
第一,确认CONFIG_IPV6是y而不是m。如果IPv6协议栈以模块方式加载,你改的route.c代码虽然也会编译进模块里,但加载顺序和符号引用容易出问题,测试环境直接编译进内核更省心。
第二,改完代码后一定改版本号,在Makefile的EXTRAVERSION里加个标记,比如-pmtutest:
make menuconfig make -j$(nproc) bzImage make -j$(nproc) modules make modules_install make install然后更新grub配置,重启时手动选择带-pmtutest的内核条目。测试机和产线环境千万别混用一个内核镜像,出问题很难回溯。
第三,内核启动后第一件事就是确认模块参数已经生效:
sysctl -w net.ipv6.route.allow_insecure_pmtu=1 cat /sys/module/ipv6/parameters/allow_insecure_pmtu第四,别忘了加回防火墙放行规则。很多测试环境在内核升级后ip6tables规则会被重置,如果PTB报文被新的默认规则丢掉,前面的一切修改都白搭。
4. 改完了先别急着提交:这些地方用sysctl就够了
4.1 mtu_expiry不是越大越好
我在前面提到,默认mtu_expiry是600秒,对一致性测试里的PMTU老化恢复用例来说,这个值往往会拖后腿。但直接把它调成86400也是错的——老化恢复用例要求DUT在指定时间窗口内丢失PMTU记录、恢复使用更大的MTU,如果老化时间给得太长,测试仪完成一轮“目标地址PMTU应该恢复”的断言时,DUT还死守着1280不放,照样判定不通过。
所以正确的做法是先看清楚测试套件在老化子用例里给的等待窗口是多少,再把mtu_expiry调整到比这个窗口略小一点。比如等待窗口是120秒,那就设:
sysctl -w net.ipv6.route.mtu_expiry=60给内核留足余量,让老化动作发生在测试仪观察窗口之前。
4.2 接口MTU、RA MTU和路由MTU的优先级
还有一类问题不用改代码,调整接口MTU就能解决,但很多人搞不清优先级。Linux里IPv6路由条目最终使用的有效MTU,不是简单取某个固定值,而是有取舍顺序的:显式配置在接口上的MTU优先,其次是RA通告的MTU,然后是接口默认值。PTB更新后的PMTU值,会作为临时路由metric覆盖在上述取值之上,直到老化。
测试拓扑里,如果模拟路由器既发RA又发PTB,而RA里携带的MTU和PTB里携带的MTU不一致(比如RA是1500,PTB是1280),内核在老化恢复之后可能重新采用RA给的1500,导致下一轮用例又变回“看起来没生效”。我的建议很直接:测试期间把接收RA关掉。
sysctl -w net.ipv6.conf.eth0.accept_ra=0这样接口MTU就只取决于手动配置和PTB更新,变量少很多。
4.3 内核改动与sysctl的边界
做完上面这些,我大概理出个分类,方便以后遇到类似问题按顺序排查:
- 接口MTU配置错误、RA MTU干扰:
ip link set mtu、accept_ra调整,不需要碰代码。 - PMTU老化恢复窗口不匹配:调整
net.ipv6.route.mtu_expiry,不需要碰代码。 - PTB报文到不了协议栈:查
ip6tables、rp_filter、网卡驱动,不需要碰代码。 - PTB到了路由层却被安全校验拒绝更新:必须走源码级修改,比如我加的那个开关。
这次测试真正需要改内核的,只有最后一个场景。前面两项配置排查能省掉90%的“白改代码”时间。我一开始差点为了一个老化问题直接去看ip6_rt_update_pmtu的老化定时器逻辑,后来发现本质是mtu_expiry和测试窗口错配,先调sysctl就解决了。
5. 验证与回归:从测例通过到业务无损
5.1 同一测例连跑三轮的性能反馈
代码改完、内核装好、参数打开,接下来的事情没有悬念但也最容易翻车:回归测试不能只跑一遍。一致性测试套件里的PMTUD用例,不同轮次之间如果发生PMTU缓存残留,结果会互相干扰。我习惯每个子用例之间主动清一下缓存:
ip -6 route flush cache然后连续跑三轮完整测试组,观察结果是否稳定一致。单纯看“是否通过”不够,还要关注用例执行时间。如果某一轮因为老化定时器还没触发、残留的1280缓存影响到了下一个用例,执行时间会明显异常,这种“假通过”是很坑的。
5.2 用真实业务流量验证PMTUD不误伤
测试套件过了,不等于真实业务就一定是好的。PMTUD最重要的两个影响对象是TCP MSS和UDP大数据块。我回归时会在DUT上发一大笔TCP流量,模拟典型业务下载,比如scp一个50MB的文件。抓包时重点看两个东西:
第一,TCP SYN握手时通告的MSS,正常情况下是链路MTU减去IPv6头40字节和TCP头20字节,即1500-60=1440。但如果路径MTU降到1280,MSS就应该是1220左右。握手阶段能不能正确反映这个值,直接决定后续数据段大小。
第二,数据传输阶段,DUT发出的TCP段是否超过路径MTU。TCP层面虽然会参考MSS,但某些实现里如果拥塞窗口内包已经发出去了,收到PTB后会重传并调整MSS。抓包只要看到TCP段长度长时间稳定在路径MTU以内,没有反复触发PTB,就说明PMTUD机制在真实业务中没有造成“乒乓”效应。
5.3 留下的排障三板斧
改完这一轮,我把排障步骤整理成了固定动作,下次再有人报类似问题,我直接按三板斧操作:
第一板斧,抓包看PTB有没有到。命令就那一条,tcpdump -i eth0 -nn 'ip6[40] == 58 && ip6[41] == 2',三秒钟出结论。
第二板斧,查路由缓存有没有更新。ip -6 route get <目的地址>,看到mtu字段变化就说明内核接受并写入了PMTU,看不到就去查sysctl和源码路径。
第三板斧,确认老化与恢复。检查net.ipv6.route.mtu_expiry的值,配合测试窗口做调整。
最后再说一个实际操作中的细节:我这次改动只加了模块参数和一处校验分支,编译两次就稳定了。但如果你要在自己的环境里复现,记得先确认内核版本对应代码里的函数签名。不同版本的ip6_rt_update_pmtu参数和validate逻辑略有差异,照抄补丁前,花十分钟读一下本机内核源码里的实际代码,比盲目套patch省事得多。