IPv6 路由器在向目的地转发报文时,不会替它进行分片。如果报文大于下一条链路所能承载的大小,路由器通常丢弃它,并发送 ICMPv6 Packet Too Big 反馈。源端随后可以调整报文大小,或在适当情况下通过 IPv6 分片扩展头发送分片;这些分片在目的端重组。明确各阶段由谁负责,是判断超大报文故障的基础。
坏掉的样子
假定路由器会自动拆开过大的 IPv6 包,会掩盖与大小相关的故障。相同地址与路由上,小 echo 可以成功,大 echo 却失败。端点本地接口可能支持原始大小,中间链路却不支持。应检查整条路径约束,不能仅因接口配置了 MTU 1500,就推断该大小能够端到端通过,也不能用一次小探测成功替代完整大小检查。
Packet Too Big 反馈缺失,会使经典发现更难学到这个限制。分片被过滤,或者其中一片丢失,也可能阻止正确重组。能够生成分片扩展头,不证明沿途策略都允许相应报文。不能把源端分片当作正确路径大小与必要控制流量的万能替代,它有自己的交付条件,需要通过对应流量验证,而不是只检查发送选项已经开启。
反方向也有自己的路径估计。请求发送端学到 MTU,不会自动教会响应端怎样发送较大回复。因此双向 echo 测试需要分别了解两个方向。TCP 分段还不同于 IP 分片:TCP 发送端学到限制后,可以构造更小的段,而不必把每个 IP 包再分片。应先确定观察的是哪一层的切分,避免看到多个包就统称为分片。
快速验证
将脚本保存为 check-ipv6-fragmentation.py,准备 Docker 后运行 python3 check-ipv6-fragmentation.py。一次性环境使用 Python 3.11.16、iproute2 6.1.0、iputils ping 和 tcpdump 4.99.3,在 Linux 6.12.76 上验证。NET_ADMIN 与 SYS_ADMIN 支持四个嵌套命名空间。两台路由器和两个端点使用新建 veth,不修改宿主接口或路由:
from pathlib import Path
from tempfile import TemporaryDirectory
import subprocess,json,uuid
recipe='FROM python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84\nRUN apt-get update && apt-get install -y --no-install-recommends iproute2=6.1.0-3 iputils-ping=3:20221126-1+deb12u1 nftables=1.0.6-2+deb12u2 tcpdump=4.99.3-1 traceroute=1:2.1.2-1 procps=2:4.0.2-3 && rm -rf /var/lib/apt/lists/*\nCMD ["python3"]\n'
program=r"""
from contextlib import ExitStack,contextmanager
from pathlib import Path
from tempfile import TemporaryDirectory
import json,os,select,signal,subprocess,time
def run(*args,check=True,input=None,timeout=8):
result=subprocess.run(args,input=input,capture_output=True,text=True,timeout=timeout)
if check:assert result.returncode==0,(args,result.returncode,result.stdout,result.stderr)
return result
def ns(name,*args,**kwargs):return run('ip','netns','exec',name,*args,**kwargs)
def ip(name,*args,**kwargs):return ns(name,'ip',*args,**kwargs)
def stop(process):
if process.poll() is None:
process.terminate()
try:process.wait(timeout=2)
except subprocess.TimeoutExpired:process.kill();process.wait(timeout=2)
def spawn(stack,command):
process=subprocess.Popen(command,stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.PIPE,text=True)
stack.callback(stop,process);return process
def line(process,timeout=3):
assert select.select([process.stdout],[],[],timeout)[0],'child response deadline exceeded'
value=process.stdout.readline();assert value,process.stderr.read();return value
@contextmanager
def lab():
with TemporaryDirectory(prefix='mtu-path-') as directory,ExitStack() as stack:
root=Path(directory);proc=root/'proc';proc.mkdir();run('mount','-t','proc','proc',str(proc));stack.callback(run,'umount',str(proc))
for name in ['client','r1','r2','target']:
run('ip','netns','add',name);stack.callback(run,'ip','netns','delete',name,check=False);ip(name,'link','set','lo','up')
def setting(name,key,value):
assert name in ['client','r1','r2','target']
path=proc/'sys'/key.replace('.','/')
ns(name,'python3','-c',f'from pathlib import Path; p=Path({str(path)!r});p.write_text({str(value)!r});assert p.read_text().strip()=={str(value)!r}')
yield root,stack,setting
def topology(setting,ipv6=False):
links=[('client','c0','r1','a0',1,2,1,1500),('r1','a1','r2','b0',2,1,2,1280),('r2','b1','target','t0',3,1,2,1500)]
for left,left_if,right,right_if,network,left_host,right_host,mtu in links:
run('ip','link','add',left_if,'type','veth','peer','name',right_if)
for name,interface,host in [(left,left_if,left_host),(right,right_if,right_host)]:
run('ip','link','set',interface,'netns',name);ip(name,'link','set',interface,'mtu',str(mtu))
address=f'2001:db8:{network}::{host}/64' if ipv6 else f'10.210.{network}.{host}/30'
ip(name,'addr','add',address,'dev',interface,*(['nodad'] if ipv6 else []));ip(name,'link','set',interface,'up')
family='-6' if ipv6 else '-4'
for name in ['r1','r2']:setting(name,'net.ipv6.conf.all.forwarding' if ipv6 else 'net.ipv4.ip_forward',1)
routes=[('client','default','2001:db8:1::1' if ipv6 else '10.210.1.1'),('target','default','2001:db8:3::1' if ipv6 else '10.210.3.1'),
('r1','2001:db8:3::/64' if ipv6 else '10.210.3.0/30','2001:db8:2::2' if ipv6 else '10.210.2.2'),
('r2','2001:db8:1::/64' if ipv6 else '10.210.1.0/30','2001:db8:2::1' if ipv6 else '10.210.2.1')]
for name,destination,gateway in routes:ip(name,family,'route','add',destination,'via',gateway)
if ipv6:
deadline=time.monotonic()+4
while any('tentative' in ip(name,'-6','address','show').stdout for name in ['client','r1','r2','target']):
assert time.monotonic()<deadline,'IPv6 link-local DAD did not finish';time.sleep(.02)
def nft(name,script):return ns(name,'nft','-f','-',input=script)
def counters(name):
result={}
for item in json.loads(ns(name,'nft','-j','list','ruleset').stdout)['nftables']:
rule=item.get('rule',{})
if 'comment' in rule:
for expression in rule['expr']:
if 'counter' in expression:result[rule['comment']]=expression['counter']['packets']
return result
def capture(stack,root,name,interface,expression,label):
path=root/(label+'.pcap')
process=subprocess.Popen(['ip','netns','exec',name,'tcpdump','--immediate-mode','-Z','root','-U','-n','-i',interface,'-w',str(path),expression],stdout=subprocess.DEVNULL,stderr=subprocess.PIPE,text=True)
stack.callback(stop,process);deadline=time.monotonic()+3
while time.monotonic()<deadline:
assert process.poll() is None,'capture exited'
if select.select([process.stderr],[],[],.1)[0] and 'listening on' in process.stderr.readline():return process,path
raise AssertionError('capture did not become ready')
def captured(process,path):
process.send_signal(signal.SIGINT);_,diagnostic=process.communicate(timeout=3);assert process.returncode==0,diagnostic
return run('tcpdump','-nn','-vv','-r',str(path)).stdout
with lab() as (root,stack,setting):
topology(setting,ipv6=True);target='2001:db8:3::2'
small=ns('client','ping','-6','-n','-c','1','-W','1','-s','32',target);assert small.returncode==0
tap,path=capture(stack,root,'client','c0','ip6','too-big')
large=ns('client','ping','-6','-n','-M','do','-s','1400','-c','1','-W','1',target,check=False)
feedback=captured(tap,path);assert large.returncode!=0 and 'too big' in feedback.lower(),(large.stdout,large.stderr,feedback)
route=ip('client','-6','route','get',target).stdout;assert 'mtu 1280' in route,route
reverse=ns('target','ping','-6','-n','-M','do','-s','1400','-c','1','-W','1','2001:db8:1::2',check=False)
reverse_route=ip('target','-6','route','get','2001:db8:1::2').stdout
assert reverse.returncode!=0 and 'mtu 1280' in reverse_route,(reverse.stdout,reverse.stderr,reverse_route)
tap,path=capture(stack,root,'client','c0','ip6 and src 2001:db8:1::2','source-fragments')
fragmented=ns('client','ping','-6','-n','-M','dont','-s','2000','-c','1','-W','2',target,check=False)
trace=captured(tap,path);assert fragmented.returncode==0,(fragmented.stdout,fragmented.stderr,trace)
assert 'frag (' in trace and trace.count('frag (')>=2,trace
refused=ns('client','ping','-6','-n','-M','do','-s','2000','-c','1','-W','1',target,check=False)
assert refused.returncode!=0 and 'too long' in (refused.stdout+refused.stderr).lower(),(refused.stdout,refused.stderr)
print(json.dumps({'small_echo':small.stdout,'oversize_before_learning':{'stdout':large.stdout,'stderr':large.stderr,'returncode':large.returncode},
'packet_too_big_capture':feedback,'learned_route':route,'reverse_path_learning':reverse.stdout+reverse.stderr,'reverse_route':reverse_route,'source_fragmented_echo':fragmented.stdout,
'source_side_fragment_capture':trace,'dont_fragment_after_learning':{'returncode':refused.returncode,'stdout':refused.stdout,'stderr':refused.stderr}}))
"""
name='xbcatm-mtupath-'+uuid.uuid4().hex[:10]
def docker(*args,**kwargs):return subprocess.run(['docker',*args],capture_output=True,text=True,timeout=300,**kwargs)
try:
with TemporaryDirectory(prefix=name+'-') as directory:
root=Path(directory);(root/'Dockerfile').write_text(recipe)
build=docker('build','-t',name,str(root));assert build.returncode==0,build.stderr
result=docker('run','--rm','-i','--name',name,'--network','none','--cap-add','NET_ADMIN','--cap-add','SYS_ADMIN','--memory','256m','--pids-limit','128',name,'python3','-',input=program)
assert result.returncode==0,result.stderr
print(json.dumps(json.loads(result.stdout),sort_keys=True))
finally:
docker('rm','-f',name);docker('image','rm',name)
端点链路 MTU 为 1500,中间路由器链路为 1280。静态全局地址使用 nodad,设置过程同时明确等待自动生成的链路本地地址离开 tentative 状态。这个就绪检查避免把最初地址设置延迟误判成转发或 MTU 故障。实验没有声称验证重复地址检测算法本身,只是把网络准备完成作为后续比较的前置条件。
小 echo 往返首先成功。随后使用 do 策略发送 1400 字节 echo 载荷,加上基础 IPv6 与 ICMPv6 头后,形成 1448 字节报文。它适合源端接口,却超过中间链路。捕获显示原始请求和路由器返回的 Packet Too Big,报告 MTU 1280;客户端路由查询随后记录该值。这样能区分源端本来就拒绝发送,与中间路径发现更小限制。
目标向客户端发送对应的超大探测,也为反向路径学到 MTU 1280。这一步明确准备较大 echo 回复,不假定一个端点的缓存同时覆盖两个方向。两个失败发现探测都保留在输出中,包含路由器反馈和路由状态,让后续成功所依赖的设置可见。请求方向已经正确,不代表响应方向无需独立检查。
客户端接着用 dont 策略发送 2000 字节 echo 载荷,允许根据已知上限在源端分片。抓包位于客户端接口,并限定客户端源地址,在两台路由器处理之前,就看到了标识相同的两个分片。工具显示数据偏移为零和 1232 字节,分片数据长度合计 2008,包含 ICMPv6 头。这直接证明分片已由发送端产生,而非后来由路由器拆开。
目的端重组并完成大小正确的返回交换后,echo 成功。用 do 策略重复相同载荷,则得到本地 message-too-long 错误与 MTU 1280。结果区分了路由器报告此前未知瓶颈、发现后源端主动分片,以及已知较小上限后的本地拒绝。这些是不同失败和交付阶段,不应全都写成同一种“包太大所以网络不通”。
推荐分片之前,先诊断大小与方向
计算包大小时包含所有相关头部。每片还会增加自己的分片头,IPv6 Fragment Offset 字段以八字节为单位,诊断工具却可能显示字节偏移。重建范围时必须统一单位。本例两个源端分片提供具体证据;另一份捕获里没有看到某片,则仍需要接口、方向与捕获时机等上下文,不能直接推断由哪一段网络丢弃。
IPv6 路由器仍可以作为自身报文的源端,这与替过境流量分片属于不同角色。阅读路由器或隧道端点的捕获时,应保持这个区分。也要区分路径最小链路 MTU 与应用选择的载荷大小:二者通过协议开销及发送端组包策略关联,并非同一个数字。角色和层次明确,才能准确解释设备为何生成某种报文。
条件允许时,优先让应用和传输行为适应已知路径,并通过真实流量验证必要的分片处理。本例证明源端位置、反馈、路由学习与一次成功的分片 echo,没有验证任意扩展头链、分片过滤策略或所有传输协议在控制消息缺失时的行为。结论应停留在已经观察的范围,后续配置则需要与实际负载相匹配的证据。
来源
- RFC 8200 IPv6,源端分片、分片扩展头与目的端重组,已核验 2026-09-12。
- RFC 8201 IPv6 Path MTU Discovery,Packet Too Big 反馈与各方向路径估计,已核验 2026-09-12。
- Linux ping(8),载荷大小与 do/dont 路径 MTU 策略,已核验 2026-09-12。