☰
IPv6重定向、PMTU与GRE隧道交叉故障排查指南
2026/10/26 2:35:26 网站建设 项目流程

IPv6 重定向、PMTU、GRE 隧道这三个词,单拎出来任何一个都够写一篇长文,凑在一起却往往出现在同一类故障单上:链路看起来是通的,ping 小包也回得干脆,可一旦跑业务就时快时慢、TLS 握手卡在证书阶段、SSH 登录成功却打不出ls,或者隧道两端明明是直连却绕了一大圈。我这些年处理过的 IPv6 隧道问题里,十有八九最后都落在这三件事的交叉点上——GRE 把可用 MTU 压下去了,PMTU 发现机制因为 ICMPv6 被挡而失效,重定向又在隧道环境里悄悄改写了下一跳。

这篇内容面向已经接触过 IPv6 基础、手上管着几条隧道或者准备上隧道的朋友,也照顾刚接手 IPv6 运维、被"大包不通"折磨过的新人。我会把三个概念的报文细节、触发条件、封装开销算法、Linux 下的配置命令、探测手法和踩过的坑一次讲透,你能直接抄走配置,也能明白每一行为什么要这么写。

1. 三个词摆在一起,说的其实是一条因果链

1.1 一条"能 ping 通、打不开网页"的链路

先说个我印象最深的场景。两台主机之间拉了一条 GRE 隧道,物理链路 MTU 1500,隧道建好之后ping对端隧道地址秒回,延迟个位数。可只要在上面跑 HTTP,浏览器就转圈;用curl -v看,TCP 三次握手正常,TLS ClientHello 发出去,ServerHello 回来了,然后卡在证书链传输上,几十秒后超时。换成curl --http1.1访问一个纯文本小页面,又能打开。

这个现象非常有代表性。小包能过,说明路由、邻居发现、基本连通性都没问题;大包过不去,说明路径上某一跳的 MTU 小于发送方认为的 MTU,而发送方又没有收到"包太大"的通知。在 IPv4 时代,中间路由器会帮你分片,虽然效率低但至少能通;到了 IPv6,路由器不再分片,源端如果不知道真实 PMTU,就只能一直重传、一直丢。TLS 证书动辄两三 KB,正好踩在这条线上。

再往深挖一层,为什么发送方会误判 MTU?因为它收到的是隧道接口的 MTU,而这个值是人配的,配大了。人为什么配大?因为 GRE 的封装开销容易被漏算。整条链路的因果关系就这么串起来了:GRE 隧道改变了实际可用 MTU,PMTU 发现负责把这个变化告知两端,而 ICMPv6 报文一旦被过滤,通知就到不了,黑洞由此产生。至于重定向,它是另一条支线上的变量,后面单独说。

1.2 因果链:隧道压 MTU,MTU 逼出 PMTU,重定向改路径

把这三个概念的逻辑关系摆清楚,后面所有配置才有依据。

GRE 隧道做的事情是封装:把一个完整的 IPv6 或 IPv4 报文塞进一个新的外层 IPv6 报文里发出去。外层头 40 字节,GRE 基本头 4 字节,这 44 字节是纯开销,不承载任何业务数据。物理链路 MTU 是 1500,那么隧道里能过的最大内层报文就只有 1456。这个数字不是"建议值",是硬约束——超过它,要么隧道入口自己分片(IPv6 下会插 Fragment Header,性能掉一大截),要么外层包超过物理 MTU 被丢弃。

PMTU 发现则是一套运行时的协商机制。源端先按自己认为的 MTU 发,路径上任何一跳转不动了,就回一个 ICMPv6 Type 2(Packet Too Big),把这一跳的 MTU 告诉源端,源端降低缓存值重发。这套机制在 IPv6 里不是可选项,是唯一能保证大包送达的手段,因为路由器不再分片。

重定向是路由器的一句"好心提醒":你把这个包交给我转,其实同链路上有个更近的邻居能直接送到,下次别绕我了。它优化的是路径,不改变 MTU,但在隧道场景下,它可能把流量导向一个你自己都没预料到的出口,让原本设计好的隧道走向失效,甚至造成来回路径不一致,进而让 PMTU 的探测结果在两条路径之间反复横跳。

1.3 这三件事在什么规模下才会同时出现

小规模环境下,你可能一辈子碰不到三者叠加。但只要出现下面任意一种情况,就得把三件事一起考虑:

一是跨域隧道,两端分属不同管理域,中间隔着你不控制的安全策略,ICMPv6 能不能通、通哪些类型,全靠猜。二是多宿主或双出口,主机或路由器有两条以上出口路径,重定向就有发挥空间。三是叠加封装,GRE 之上再跑 VLAN、再跑 PPPoE,每层都要扣字节,扣到最后隧道 MTU 逼近 1280 这条红线。四是容器或虚拟化环境,宿主机网卡 MTU 改了,容器内的 veth、网桥、隧道接口不一定跟着改,很容易出现"宿主通、容器不通"。

我个人的判断标准很简单:只要一条链路上出现了隧道,并且隧道另一端不是你能完全掌控的设备,就把 PMTU 和 ICMPv6 放行策略当成必查项,而不是等出问题再回头翻。

2. IPv6 重定向:省一跳的优化,也是事故的起点

2.1 Type 137 报文里到底装了什么

ICMPv6 重定向的 Type 字段值是 137,Code 为 0。报文主体除了标准的校验和,还有两个关键的 16 字节地址字段:一个是Target Address,指的是"更优的下一跳",也就是路由器建议你改用的那个邻居;另一个是Destination Address,指的是"被重定向的目的地址",也就是你原本想去的那个终点。后面可以跟两个选项:Target Link-Layer Address 给出目标邻居的链路层地址,省得主机再做一次邻居请求;Redirected Header 则截取触发这次重定向的原始报文的一部分,让主机能核对上下文。

这两个地址字段的组合决定了一切。Target 必须是和主机在同一链路上的邻居,Destination 必须不是链路本地地址、不是多播地址。换句话说,重定向只对"同一链路上存在更优出口"这一种情形有意义。如果目标根本不在同一网段,这条重定向就是伪造的,主机必须丢弃。

抓包看到 Type 137 的时候,我习惯先看 Target 是不是真的是我邻居表里的设备。很多所谓的"重定向攻击"排查,第一步就是把 Target 地址和 NDP 邻居缓存对一遍,对不上基本就能定性。

2.2 路由器什么时候发、主机什么时候收

发送侧的门槛比想象中高。按 RFC 4861 的定义,路由器要同时满足一串条件才会发重定向:收到包的入接口和准备转发的出接口必须是同一个接口(也就是这个包本来就该在本地链路上直接送达);目的地址不能是本地链路上的邻居(否则源端本来就该直接发);转发接口的地址不能等于包的源地址;目的地址不能是多播;下一跳不能就是源地址本身。少一条都不发。

接收侧的门槛更严。主机收到重定向后,必须验证:Target 地址与当前正在使用的默认路由器在同一链路上;Target 不是多播地址;这条报文的源地址确实是自己当前默认路由器的地址,并且是目的地址当前的第一跳;目的地址不能已经存在于邻居缓存里,除非报文携带的 Redirected Header 与缓存内容不一致。这条"不能在邻居缓存里"的规则是为了防重定向循环——A 让你去 B,B 让你回 A,来回踢皮球。

我在实际排障里发现一个很常见的误解:很多人以为关掉主机的accept_redirects就万事大吉。但如果链路上有设备在发重定向,而接收侧配置不一致(比如一部分主机开着、一部分关着),会出现同一网段内行为分裂——关掉的主机走次优路径但稳定,开着的主机走最优路径但可能踩到某条链路的 MTU 问题。排查时看到"同网段两台机器表现不同",先查这个开关。

2.3 隧道场景下为什么要主动关掉

隧道接口本质上是点对点链路,只有两个端点,不存在"同链路上还有第三个更优邻居"这种情况。所以在隧道接口上接受重定向,逻辑上就没有意义。更麻烦的是物理接口上的重定向可能影响隧道的承载流量。

设想这样一张拓扑:主机 A 的默认网关是 R1,A 到远端要经过一条建在 R2 上的 GRE 隧道。R1 和 R2 在同一链路上。A 把包交给 R1,R1 查表发现下一跳是 R2,而 R2 和 A 同链路,于是发一条重定向给 A,Target 写 R2。A 接受之后,后续流量直接发给 R2。如果 R2 上的隧道配置是完整的,这没什么问题,甚至更快;但如果 R2 只是作为转发节点、隧道其实建在 R1 上,A 的流量就被导到了一个没有隧道出口的设备上,直接黑洞。

还有一种更隐蔽的情况:隧道本身建在 A 上,A 同时有物理接口和隧道接口。A 的默认路由指向 R1,特定前缀走隧道。R1 看到 A 发来的隧道封装包,外层目的地址是隧道对端,如果 R1 判断该对端与 A 同链路,就可能发重定向让 A 直接发——但隧道对端根本不在 A 的链路层可达范围内,这条重定向是错的或者至少是不适用的。这种时候如果 A 盲目接受,会污染路由决策。

提示:在承载隧道的设备上,物理接口和隧道接口的accept_redirects都建议显式置 0,不要依赖默认值。

2.4 查看与关闭重定向的实操

Linux 下三个 sysctl 控制这件事,按接口粒度生效:

# 查看当前值,1 表示接受,0 表示忽略 sysctl net.ipv6.conf.all.accept_redirects sysctl net.ipv6.conf.eth0.accept_redirects # 全局关闭(写入 /etc/sysctl.d/ 下的配置文件持久化) sysctl -w net.ipv6.conf.all.accept_redirects=0 sysctl -w net.ipv6.conf.default.accept_redirects=0 # 针对已存在的接口补一刀,all 不覆盖已 up 的接口 for i in $(ls /sys/class/net); do sysctl -w net.ipv6.conf.${i}.accept_redirects=0 done

这里有个坑值得单独讲:net.ipv6.conf.all.*只对之后新建的接口生效,已经在跑的接口必须单独设,或者用default加遍历。很多人改完all以为完事,重启服务后新起的隧道接口又恢复了默认值 1,问题复现。

另一个坑是转发开关。net.ipv6.conf.all.forwarding=1打开之后,内核会把该接口的accept_redirects视为无效(因为路由器角色不应该接受重定向),但不同内核版本的行为不完全一致,有些版本是"忽略但值仍显示 1"。所以我的习惯是显式写死,不依赖内核的隐式行为。

发送侧如果这台机器本身在转发,还有send_redirects:

sysctl -w net.ipv6.conf.eth0.send_redirects=0

在隧道端点设备上关掉它,可以避免对承载流量发出不适用的重定向。

3. PMTU:IPv6 里没有"路上分片"这回事

3.1 路由器不再分片带来的连锁反应

IPv4 的路由器遇到出接口 MTU 小于报文长度时,如果 DF 位没置,会就地分片。这让很多网络问题被掩盖了——反正能通,只是慢一点。IPv6 把这个兜底机制直接砍掉了:只有源节点可以分片,中间路由器一律不分片。路由器转不动就丢包,然后回一个 ICMPv6 Type 2 告诉源端"太大了"。

这个设计让网络更干净,但把责任全部压到了源端和 PMTU 发现机制上。源端必须做三件事:维护每条路径的 PMTU 缓存、正确解析 ICMPv6 Type 2、在收到通知后降低缓存并重传。任何一环断了,结果就是大包静默丢失。

我在实际环境里见过的失效原因排前三的是:安全策略把 ICMPv6 全类型一刀切封了;中间设备丢弃 Type 2 是因为它不认这个报文类型(老设备对 ICMPv6 的支持不完整);源端应用自己设了IPV6_MTU_DISCOVER为关闭,绕过 PMTU 直接按接口 MTU 发。第三条在自定义网络程序里特别常见,开发者从 IPv4 代码搬过来,没注意 IPv6 下这个选项的语义变了。

3.2 ICMPv6 Type 2 与 PMTU 缓存的老化

Type 2 报文的格式很简单:Type=2,Code=0,校验和,然后是 32 位的 MTU 字段,后面附带触发报文的一部分。MTU 字段有约束——不能小于 1280,也不能大于实际出接口 MTU(防止恶意压低)。

源端收到之后,把这条目的地址对应的 PMTU 降到报文里给的值,然后重传。同时启动一个老化计时器,Linux 下由net.ipv6.route.mtu_expires控制,默认 600 秒。600 秒之后缓存失效,重新按接口 MTU 试探,如果路径变好了就提升,变差了就再降。

这个老化机制是把双刃剑。路径 MTU 恢复时它能自动跟上;但如果路径上存在"时好时坏"的情况(比如 ECMP 里有一条链路 MTU 更小),缓存会在 600 秒周期里反复震荡,表现为大包间歇性失败。遇到这种周期性故障,先把mtu_expires调大,让缓存稳定在一个保守值,再排查路径。

# 查看与调整 PMTU 缓存老化时间(秒) sysctl net.ipv6.route.mtu_expires sysctl -w net.ipv6.route.mtu_expires=1800

还有一点:PMTU 缓存是按目的地址(精确说是按路由缓存条目)维护的,不是按连接。所以同一个目的地址上的多条 TCP 连接共享同一个 PMTU 值,一条连接触发了下调,其他连接立刻受益。这也是为什么有时候"重连一下就好了"——新连接复用了已经修正过的缓存值。

3.3 1280 这条底线和 MSS 的换算

IPv6 规定链路 MTU 不得小于 1280,任何承载 IPv6 的链路层必须能传 1280 字节的报文。这条规定是所有隧道设计的锚点:隧道接口的 MTU 绝对不能低于 1280,否则隧道里跑的 IPv6 就无法正常工作,你会看到邻居发现都失败。

MSS 的换算是实操里最常算错的地方。记住公式:

场景MSS 计算MTU 1500 时
IPv6 + TCPMTU - 40 - 201440
IPv4 + TCPMTU - 20 - 201460
GRE 隧道内 IPv6 + TCP(MTU 1456)1456 - 601396
GRE 隧道内 IPv4 + TCP(MTU 1456)1456 - 401416

IPv6 的固定头是 40 字节,TCP 头最少 20 字节,所以 IPv6 下的 MSS 比 IPv4 少 20。很多人习惯性按 IPv4 的 1460 去推,隧道里就会多算 20 字节,正好卡在边界上。

实操里我一般会再留 20 到 40 字节余量。原因不是算错,而是路径上可能存在你没发现的额外封装(VLAN、MPLS、PPPoE),或者某些设备对 IPv6 扩展头的处理比预期多耗字节。MSS 设 1396 和设 1360,对吞吐的影响在千兆以下链路基本感知不到,但能挡掉一批偶发故障。

3.4 手工探测路径 MTU 的完整操作

Linux 下的ping支持指定负载大小并禁止本地分片,这是探测 PMTU 最直接的手段:

# -6 走 IPv6,-M do 禁止本地分片,-s 指定 ICMP 负载长度 # 总报文长度 = 40 (IPv6头) + 8 (ICMPv6头) + s ping -6 -M do -s 1452 2001:db8:200::1

s=1452对应总长 1500。如果返回Packet too big或者Message too long,说明路径 MTU 小于 1500,逐步往下调:

for s in 1452 1408 1372 1232; do ping -6 -M do -c 2 -W 1 -s $s 2001:db8:200::1 >/dev/null 2>&1 \ && echo "s=$s OK, total=$((s+48))" \ || echo "s=$s FAIL, total=$((s+48))" done

s=1408对应隧道 MTU 1456 的满载测试,s=1232对应 1280 红线。这四个值覆盖了绝大多数隧道场景,值得存成脚本随手跑。

tracepath更省事,它会自己二分查找并打印每一跳的 MTU:

tracepath -6 2001:db8:200::1

输出里如果看到某一跳之后 PMTU 从 1500 掉到 1456,那一跳就是瓶颈所在。注意tracepath依赖 ICMPv6 的 Time Exceeded,如果中间设备不回这个,结果会不完整。

还有一个快速判断当前路由缓存值的方法:

ip -6 route get 2001:db8:200::1 # 输出里会带 mtu 字段,如果没有则用接口 MTU ip -6 route get 2001:db8:200::1 from 2001:db8:1::1

4. GRE 隧道:把封装开销一层层算清楚

4.1 GRE 头到底占几个字节

GRE 的基本头是 4 字节:2 字节的标志位和版本号,2 字节的协议类型。协议类型决定内层承载什么——0x0800 是 IPv4,0x86DD 是 IPv6,0x6558 是透明以太网桥接(也就是 gretap)。

除了基本头,GRE 还有两个可选字段:Checksum(4 字节)和Key(4 字节),以及Sequence Number(4 字节)。按 RFC 2890,Checksum 和 Sequence 不能同时启用(C 位和 S 位互斥),Key 可以和其中任意一个组合。Key 的作用是让接收端区分同一个源目的对之间的多条隧道,实际部署里用得很普遍。

所以在算开销时,你要先确认这条隧道启用了什么:

GRE 配置GRE 头长度加 IPv6 外层(40B)总开销
基本头444
基本头 + Key848
基本头 + Checksum848
基本头 + Key + Sequence1252

外层如果是 IPv4,把 40 换成 20 重算。IPv4 外层 + 基本头 = 24 字节开销,这也是老资料里最常见的"GRE 开销 24"的由来。现在跑 IPv6 隧道,这个数字要改成 44,很多人就是这么算错的。

4.2 逐层扣减:从物理 MTU 到隧道 MTU

算隧道 MTU 的正确姿势是自上而下逐层扣,而不是拍脑袋给个 1400。我按一条真实链路走一遍:

物理链路 MTU 1500。中间有一段 VLAN 子接口,802.1Q 标签 4 字节,实际可用 1496。再往上跑一条 GRE over IPv6 隧道,外层 IPv6 头 40 字节,GRE 基本头 4 字节,合计 44 字节。那么隧道接口的 MTU 就是 1496 - 44 = 1452。如果这条 GRE 还启用了 Key,再减 4,得 1448。

如果底层是 PPPoE,MTU 从 1500 掉到 1492,再叠 VLAN 得 1488,再减 44 得 1444。这类环境里如果你按标准 1456 去配,多出来的 12 字节就会在 PPPoE 那层被丢。

我习惯在文档里画一张扣减表,从最底层写到最上层,每一层写清楚"减多少、剩多少、谁负责"。这张表比任何配置注释都有用,尤其在交接或者排查时——只要发现某一层没人维护,问题多半就出在那儿。

注意:隧道 MTU 设得比实际能力大,会出现"小包通大包不通";设得太小,会浪费带宽并可能低于 1280 这条红线。宁可略保守,也不要冒险。

还有一类容易忽略的开销:如果隧道内层跑的是 IPv6,而外层也是 IPv6,那么内层 IPv6 的 40 字节头是算在隧道 MTU 之内的,不需要额外扣。但如果隧道做的是二层桥接(gretap),内层是以太网帧,还要考虑以太网头 14 字节和可能的 VLAN 标签。

4.3 三种隧道 MTU 设法的取舍

实际操作里有三种流派,各有适用场景。

第一种,精确扣减。按上面算出来的 1452 或 1456 配置隧道 MTU,然后把 MSS 钳到对应值。优点是带宽利用率最高,缺点是对底层变更敏感——底层链路换了一段、多了一个封装层,隧道 MTU 配置不跟着改就会出问题。

第二种,保守固定值。不管底层怎么算,统一配 1400 或者 1280。这种方法在跨域环境里非常实用,因为你根本不知道中间会不会有额外封装。1280 是下限,配到这个值几乎不会出 MTU 问题,代价是每个包少装 200 多字节数据,大流量场景下吞吐会有可感知的下降。

第三种,隧道 MTU 配大 + MSS 钳制兜底。隧道接口 MTU 保持 1500,靠 MSS clamp 把 TCP 的段大小压下来。这种做法只对 TCP 有效,UDP、QUIC、ICMP 大包照样会出问题。我一般只在过渡期用,不作为长期方案。

我自己的选择是:隧道接口 MTU 按精确扣减配,同时在转发路径上做 MSS clamp 作为第二道防线。两层保险,出问题时至少 TCP 业务是通的,能给你留出排查时间。

4.4 Linux 下建一条 ip6gre 隧道并压 MTU

下面是完整的搭建流程,用文档专用前缀2001:db8::/32做示例。

# 1. 建隧道:本地 2001:db8:1::1,对端 2001:db8:2::2,承载在 eth0 上 ip -6 tunnel add gre1 mode ip6gre \ local 2001:db8:1::1 \ remote 2001:db8:2::2 \ ttl 64 \ dev eth0 # 2. 先把 MTU 压到算好的值,再 up ip link set dev gre1 mtu 1452 ip link set dev gre1 up # 3. 配隧道内层地址 ip -6 addr add 2001:db8:ff::1/64 dev gre1 # 4. 加路由,把目标网段指向隧道 ip -6 route add 2001:db8:aa::/64 dev gre1 # 5. 验证 ip -6 tunnel show gre1 ip -d link show gre1 ip -6 route get 2001:db8:aa::1

几个细节值得说。mode ip6gre表示外层用 IPv6 承载 GRE,内层可以是 IPv4 也可以是 IPv6。如果是二层桥接要用ip6gretap。ttl 64是给外层包的跳数限制,不设的话内核会继承内层包的值,容易出现内外层 TTL 语义混淆的问题。

第 2 步的顺序很重要:先设 MTU 再 up。如果先 up 再改 MTU,中间那一小段时间里隧道按默认 1500 在跑,正好有流量经过就会触发分片。我吃过一次这个亏,脚本里把两条命令写反了,重启网络的时候监控报了几分钟的大包丢弃。

MSS 钳制用 nftables 做:

nft add table inet mssclamp nft 'add chain inet mssclamp post { type filter hook postrouting priority mangle; }' nft 'add rule inet mssclamp post tcp flags syn tcp option maxseg size set rt mtu'

set rt mtu的意思是取路由表里的 MTU 值来设置 MSS,也就是跟着 PMTU 走。如果 nft 版本不支持这个表达式,退回到固定值:

nft 'add rule inet mssclamp post tcp flags syn tcp option maxseg size set 1392'

1392 对应 1452 的隧道 MTU 再减 60,留了 0 余量;我一般写 1372,留 20 字节余量。

持久化方面,ip命令是一次性的,重启就没了。生产环境建议用 systemd-networkd 的.netdev加.network配置,或者 NetworkManager 的连接配置,把 MTU 和隧道参数都写进去。手工ip命令只在调试时用。

5. 三者叠加后的故障现场与排查手册

5.1 PMTU 黑洞:最隐蔽的一类

PMTU 黑洞的典型特征是"小包通、大包不通,且没有 ICMP 错误返回"。判断方法很简单:用ping -6 -M do -s从小到大扫,找到第一个失败的尺寸,然后看那个尺寸是否恰好等于某个已知的 MTU 减 48。

如果s=1452(总长 1500)失败,s=1400(总长 1448)成功,那瓶颈大概在 1448 到 1500 之间,很可能就是隧道 MTU 1452 没有正确配置或者没有被 PMTU 发现覆盖。

反过来,如果所有尺寸都成功但 TCP 大流量还是卡,问题就不在 PMTU,去看 TCP 窗口、丢包重传、或者应用层。

排查黑洞时,抓包是最直接的证据。在隧道两端同时抓 ICMPv6:

# 只看 ICMPv6 Type 2(Packet Too Big) tcpdump -ni any 'icmp6 and ip6[40] == 2' -vv # 同时看隧道封装流量 tcpdump -ni eth0 'ip6 proto 47' -vv

如果入口侧发出了 Type 2 但出口侧没收到,中间有设备在过滤;如果出口侧根本没发出 Type 2,说明它自己就没意识到包太大,可能是配置问题。

5.2 重定向和隧道打架:来回路径不一致

这类故障的表现是"时通时不通,重连就好"。原因是重定向改变了某一方向的下一跳,导致去程走隧道、回程走物理链路,或者反过来。两条路径的 MTU 不同,PMTU 缓存就会在两个值之间来回跳。

诊断方法是看两端的路由和邻居状态:

# 看本机到对端的实际下一跳 ip -6 route get 2001:db8:aa::1 # 看邻居缓存里有没有预期之外的条目 ip -6 neigh show dev eth0 # 看重定向计数(如果有) nstat -az | grep -i redirect

nstat里的Ip6InRedirects和Ip6OutRedirects能直接告诉你有没有收到或发出重定向。这个计数器很多人不知道,排查时非常好用。如果Ip6InRedirects在持续增长,而你的网络设计里根本不应该有重定向,那就是有设备在发不该发的报文。

处理方式就是第 2 章说的:两端都关掉accept_redirects和send_redirects,让路径固定下来。隧道环境追求的是可预测,不是最优。

5.3 排查命令清单与速查表

我把常用的命令整理成一张表,出问题时按顺序跑一遍,基本能定位到层:

目的命令关注点
确认隧道状态ip -6 tunnel showlocal/remote/mtu 是否符合预期
确认接口 MTUip -d link show gre1mtu 字段
确认路由 MTUip -6 route get <dst>输出里的 mtu 值
探测路径 MTUping -6 -M do -s N第一个失败点
自动探测tracepath -6 <dst>每跳 MTU 变化
看 TCP MSSss -6 -tin dst <ip>mss 字段
看 ICMPv6tcpdump -ni any icmp6Type 2 / 137
看重定向计数nstat -az | grep -i redirect是否异常增长
看分片情况nstat -az | grep -i frag隧道内是否在分片

再补一张症状对照表,覆盖我遇到过的绝大多数组合:

症状最可能原因验证手段处理
ping 通,TCP 大响应超时PMTU 黑洞ping -M do -s 1452放行 ICMPv6 Type 2,钳 MSS
隧道内 iperf3 速率骤降隧道内分片nstat看 frag隧道 MTU 改小
时通时不通,重连恢复重定向改路径nstat看 Redirects关闭 accept_redirects
只有 UDP 业务失败MSS 钳制对 UDP 无效抓包看包长应用层限制包大小
容器内不通,宿主通veth/网桥 MTU 未同步ip link show逐层看统一 MTU
邻居发现失败隧道 MTU < 1280ip -6 neigh提高隧道 MTU

5.4 防火墙放行 ICMPv6 的正确姿势

这一节单独拎出来讲,因为它是黑洞的头号成因。很多安全基线把 ICMP 一刀切成"只放 echo",在 IPv4 下勉强能用(有分片兜底),在 IPv6 下就是灾难。

必须放行的 ICMPv6 类型至少有这些:

  • Type 1:Destination Unreachable,路径不可达通知。
  • Type 2:Packet Too Big,PMTU 的核心,绝对不能挡。
  • Type 3:Time Exceeded,traceroute 和环路检测依赖它。
  • Type 4:Parameter Problem,扩展头处理出错时用。
  • Type 128/129:Echo Request/Reply,基础连通性测试。
  • Type 133/134:Router Solicitation/Advertisement,无状态地址配置必需。
  • Type 135/136:Neighbor Solicitation/Advertisement,地址解析必需。
  • Type 137:Redirect,按需放行,隧道环境建议直接丢。

nftables 下的最小放行规则:

nft add table inet icmpv6base nft 'add chain inet icmpv6base input { type filter hook input priority filter; }' nft 'add rule inet icmpv6base input meta l4proto ipv6-icmp icmpv6 type { 1, 2, 3, 4 } accept' nft 'add rule inet icmpv6base input meta l4proto ipv6-icmp icmpv6 type { 128, 129 } accept' nft 'add rule inet icmpv6base input meta l4proto ipv6-icmp icmpv6 type { 133, 134, 135, 136 } accept' nft 'add rule inet icmpv6base input meta l4proto ipv6-icmp icmpv6 type 137 drop'

注意:Type 2 的放行要同时覆盖入方向和转发方向。很多配置只在 input 链放行,forward 链还是默认 drop,隧道流量照样黑洞。

还有一条经验:如果安全策略不允许你全类型放行,至少保证 Type 2 的速率限制放宽。PMTU 发现在大流量路径上会产生密集的 Type 2 报文(每个连接首次探测都可能触发),限速设得太严(比如每秒 10 个)会导致一部分连接收不到通知。我一般把 Type 2 的限速设在每秒 200 个以上,配合突发 500。

6. 一份可以直接抄的基线配置与踩坑记录

6.1 主机侧 sysctl 基线

下面这份配置我在多个环境里用过,覆盖了隧道端点、边界转发设备和普通主机三种角色。写到/etc/sysctl.d/90-ipv6-tunnel.conf里:

# 转发开关,路由器角色打开 net.ipv6.conf.all.forwarding = 1 # 重定向:隧道环境统一关闭 net.ipv6.conf.all.accept_redirects = 0 net.ipv6.conf.default.accept_redirects = 0 net.ipv6.conf.all.send_redirects = 0 net.ipv6.conf.default.send_redirects = 0 # PMTU 缓存老化,长路径建议拉长 net.ipv6.route.mtu_expires = 1800 # 路由缓存的其他参数,按需 net.ipv6.route.gc_timeout = 60

写完执行sysctl --system生效。别忘了第 2 章说的:all不覆盖已存在接口,新接口才用default。所以启动脚本里还要补一段遍历:

#!/bin/bash for i in /sys/class/net/*; do dev=$(basename "$i") [ -e "$i/ifindex" ] || continue sysctl -w net.ipv6.conf."$dev".accept_redirects=0 >/dev/null 2>&1 sysctl -w net.ipv6.conf."$dev".send_redirects=0 >/dev/null 2>&1 done

这段跑在隧道接口创建之后。如果隧道是 systemd-networkd 管的,可以用[Network]段的IPv6AcceptRA=no加自定义 post-up 脚本,或者直接用 networkd 的IPv6ProxyNDP之类参数间接控制。

6.2 隧道侧配置模板

一个可复用的隧道配置模板,变量抽出来:

#!/bin/bash set -e UNDERLAY_DEV="eth0" UNDERLAY_MTU=$(cat /sys/class/net/${UNDERLAY_DEV}/mtu) TUN_NAME="gre1" LOCAL_ADDR="2001:db8:1::1" REMOTE_ADDR="2001:db8:2::2" INNER_ADDR="2001:db8:ff::1/64" INNER_NET="2001:db8:aa::/64" # GRE 基本头 4 + 外层 IPv6 头 40 = 44 OVERHEAD=44 TUN_MTU=$((UNDERLAY_MTU - OVERHEAD)) # 红线保护 if [ "$TUN_MTU" -lt 1280 ]; then echo "ERROR: computed mtu ${TUN_MTU} below 1280, check underlay mtu" >&2 exit 1 fi ip -6 tunnel add "$TUN_NAME" mode ip6gre \ local "$LOCAL_ADDR" remote "$REMOTE_ADDR" ttl 64 dev "$UNDERLAY_DEV" ip link set dev "$TUN_NAME" mtu "$TUN_MTU" ip link set dev "$TUN_NAME" up ip -6 addr add "$INNER_ADDR" dev "$TUN_NAME" ip -6 route add "$INNER_NET" dev "$TUN_NAME" mtu "$TUN_MTU" echo "tunnel ${TUN_NAME} up, mtu=${TUN_MTU}, mss=$((TUN_MTU - 60))"

这里有几个设计上的考量。第一,MTU 是从底层接口动态读的,底层变了模板自动跟着变,不用手工改。第二,加了 1280 红线检查,宁可创建失败也不要建一条跑不起来的隧道。第三,路由上加mtu参数,把隧道 MTU 固化到路由条目里,这样rt mtu的 MSS 钳制能直接读到正确值。第四,MSS 只是打印出来供核对,实际钳制交给 nftables 统一做。

如果隧道要长期运行,建议改用 systemd-networkd。一个.netdev文件大致是:

[NetDev] Name=gre1 Kind=ip6gre MTUBytes=1452 [Tunnel] Local=2001:db8:1::1 Remote=2001:db8:2::2 TTL=64

配套.network文件配内层地址和路由。这样开机自动建,不用脚本。

6.3 我踩过的几个坑

第一个坑是隧道 MTU 和路由 MTU 不一致。有次隧道接口配 1452,路由条目上被另一条静态路由覆盖成了默认 1500,结果 PMTU 缓存读的是 1500,MSS 钳制也按 1500 算,实际包到了隧道就被丢。现在我的模板里两处都显式写 MTU,并且加一条检查逻辑核对两者相等。

第二个坑是IPv6 外层带扩展头导致开销多算。有一条隧道外层加了流标签相关的扩展头,实际开销比 44 多了 8 字节,1456 的隧道 MTU 就是过不去。这类情况很难提前算到,我的应对是把 MSS 余量从 20 加到 40,宁可少装一点。后来发现用tcpdump -v看外层包的总长度最直接,抓几个满载包量一下实际封装长度,比任何公式都准。

第三个坑是容器网络没有继承宿主 MTU。宿主的隧道接口 1452,但容器里的 veth 默认 1500,容器内应用按 1500 发,到宿主隧道口被丢。解决方式是在 CNI 配置里显式设置 MTU,或者用ip link set dev vethX mtu 1452逐层设。这个问题在新环境部署时特别容易漏,因为容器内ping小包完全正常。

第四个坑是关闭重定向后某些老应用的行为变化。有一批依赖"同网段最优路径"的应用,在重定向关闭后走次优路径,延迟从 1ms 变成 3ms。这类业务感知非常敏锐,会来投诉。处理方式是把它放到一个不关重定向的专用网段,或者调整路由让最优路径显式生效。这提醒我,安全基线不能无脑全量套,得看业务特性。

第五个坑是ICMPv6 限速误伤 PMTU。有次在中转设备上配了 ICMPv6 全类型限速每秒 50 个,正常业务没问题,但只要有一个大文件传输场景,PMTU 探测报文一密集就被丢,表现为传输速率锯齿状波动。后来把 Type 2 单独提出来放宽限速才解决。这个细节在配置文件里看不出问题,只有结合流量特征才能定位。

最后分享一个小技巧:排查 MTU 相关问题时,先别急着改配置,用tcpdump在隧道两端各抓 100 个包,把内层报文长度和外层报文长度的差值算出来。这个实测开销比任何公式都可靠,尤其是路径上有你不了解的封装时。我现在的排查顺序是:先抓包量开销,再算隧道 MTU,再看路由 MTU 是否一致,最后才动防火墙。按这个顺序走,大多数问题在第二步就能定位,省下大量来回试错的时间。

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

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

立即咨询