用 Rust 从零开始写 QUIC:MTU 探测

on 2025-06-06

MTU 探测


什么是 MTU 探测

首先这里得回答一个问题,什么是 MTU(maximum transmission unit),MTU 是数据链路层能够承载最大 IP 数据报文的长度大小。举个例子,如果数据链路层是以太网,那么以太网可以承载最大的数据帧长度是 1518 字节,再减去以太网帧头部和尾部的字节数,那么 1500 字节就是以太网上面可以承载最大的 IP 数据报文长度,即 MTU 数值。这里,我们也简单介绍下 MSS(maximum segment size),MSS 是 TCP 传输层提出的一个概念,是在 MTU 的基础上减去 IP 报文头和 TCP 报文头的开销,即单个 TCP 报文段能够承载的最大数据量,换个说法,MSS 也是单个数据报文能够承载应用层数据的最大长度。

MTU 探测则是探测两个设备之间整个网络路径上 MTU 的最小值,这里可能有人会有疑问,通信双方是有能力感知到自身的网卡设备 MTU 数值的,甚至像 TCP 协议在握手的时候都会协商一下双方的 MSS 大小,那为什么还需要去做探测。这个问题,可以从下面这张图发现答案,其实原因很简单,就是在进行 TCP/IP 协议交互的两个设备之间,可能拼接了多条数据链路,作为传输层的两端并不知道其中每一条物理链路的 MTU 值。

arch-01

另外,本身机器设备的 MTU 数值可能是不准的,比如说 WIreGuard 虚拟网卡的 MTU 数值,需要减去 WIreGuard 额外报文头开销,毕竟运行在上面的 TCP/IP 协议栈是不清楚这个情况的。虽然,在使用 WIreGuard 的时候,MTU 都会重新计算再配置,但是既然是人为配置,那么就有配错的风险。所以,只有进行 MTU 探测,才能获取到真实链路中的最低 MTU 数值。

为什么需要 MTU 探测

接下来就是讨论一个非常关键的问题了,为什么需要 MTU 探测,即探测出两个通信设备之间网络路径的最低 MTU 值。当然,可能大家第一反应是标准答案,尽可能的贴近网络路径 MTU 来发送数据,提升传输链路使用效率,同时防止 IP 切片,影响传输性能。但在这里我想进一步分析下,为什么 IP 切片会影响传输性能,甚至有时候会影响可用性

IP Fragmentation 介绍

在这里,先简单介绍下,什么是 IP 切片(IP Fragmentation),其实就是 IP 层可以将上层协议栈数据报文或者要被转发的数据报文进行切分,切分成多个较小的 IP 报文来进行传输,并且在目的地机器的 IP 协议层再拼装起来。为什么这么做的原因,主要是 IP 协议层作为最接近物理链路层的传输协议,为了确保数据报文可以正常的被物理链路层发送,即不超过 MTU 数值,而额外做了这件事情。如果上层数据报文(比如说是 UDP 或者 TCP 报文)超过 MTU 数值, 那么 IP 层就会主动去进行切片,确保数据能够在物理链路层正常传输

关于 IP 切片实现的细节,在 IP RFC 中有着详细介绍,主要是 IP 报文头中有 more fragments flagfragment offset 来讲一个大的 IP 报文切分成小的 IP 报文,并且能够在目的主机的 IP 协议层重组恢复。这里,需要特别注意的一个点是,IP 报文头中有一个 don't fragment 标志位,携带该标志位的 IP 报文抵达非目的主机的任意中间设备的 IP 协议层,并且 IP 数据报文长度大于下一跳物理链路的 MTU 值,则 IP 协议层会强制丢弃该 IP 报文。IP 上这个标志位的设计,也是接下来 MTU 探测实现的基石。

上面都是关于 IP 切片的老生常谈,在这里,我想结合 Linux 内核源码,来再稍微深入的讨论下 IP 切片的实现细节。首先我们引入几个问题,IP 切片发生在什么时候,怎么判断需要进行 IP 切片,切片后的 IP 数据报文什么时候会被重组,携带 DF 标志位的 IP 数据报文什么时候会被判断要被丢弃。虽然在上面介绍 IP 切片的时候,我特意用粗体标注了这几个问题的答案,但是我们可以结合 Linux 内核源码来加深印象。如果 Linux IP 层如果开启了转发功能,那么在接收 IP 报文的时候,主要会有两种处理流程。一种是确认自己就是该 IP 报文的目的主机,进一步处理,然后抛给上一层传输协议栈。另外一种是知道该 IP 报文目的地并非本机,于是会继续按照路由表的优先级进行 IP 报文的转发。

arch-02

从代码角度,Linux IP 层首先是通过 ip_rcv 总入口来处理 IP 报文,然后在查询完路由表之后,明确了该 IP 报文真正抵达了目的机器,交给 IP 层之上的协议层处理,而正是在 ip_local_deliver 这里会判断接收到的 IP 报文是否是被切片的,从而进行切片后的重组。如果查询完路由表之后,发现自己不是该 IP 报文的目的路由,那么就会走 IP 报文转发逻辑,即 ip_forward 会进一步去调用 IP 报文的发送函数 ip_output 去发送 IP 报文。也正是在这个流程里,Linux 内核会去查询下一跳链路的 MTU 数值,并且判断是否要进行切片。同时还是在 IP 报文的发送流程里面,Linux 内核 会根据 DF flag 和是否超出下一跳链路的 MTU 数值,来判断是否要直接丢弃这个 IP 报文。

arch-03

最后来一个有趣的边界问题收尾这一小节,如果在一段网络路径中,每段物理链路的 MTU 数值是递减的,那么传输数据的时候,可能会导致反复切片吗?还是说要在中间设备重新组装好,再重新进行 IP 切片,这样会不会违背了只在目的主机组装的实现。举个更具体的例子,中间设备 A 收到 1500 字节大小的 IP 报文,然后下一跳链路到中间设备 B 的 MTU 数值是 1400,那么设备 A 将 1500 字节 IP 报文切成 1400 字节和 100 字节两个 IP 报文。紧接着,中间设备 B 收到 1400 的 IP 切片报文之后,继续转发到下一跳,但是下一跳的 MTU 数值是 1300,这个时候中间设备 B 应该怎么做。正常来说,中间设备 B 可以将 1400 字节的 IP 报文再次切片,因为 IP 报文设计的非常简练,可以支持对已经切片之后的某部分切片再次切片,所以这里 1400 字节可以再次被切成 1300 和 100 字节,最后三个 IP 报文可以在目的主机重新组装起来。但是,Linux 内核中会默认强制承载 TCP 流量的所有 IP 数据报文都携带 DF flag,所以在这个场景中,Linux 内核会直接丢弃这个切片,而不是再次切片。

需要注意的是,这里 100 字节 IP 报文长度其实是个虚数,在假设没有 TCP 扩展项的情况下,1500 IP 报文减去 IP 和 TCP 报文头 40 字节,会剩下 1460 TCP payload,那么 1400 IP 报文,可以承载 1360 字节 TCP 数据,则剩下切片后的 IP 报文真实大小应该是 1460 - 1360 + 40 = 140 字节。

为什么要尽可能的踩着 MTU 的限制发包

在聊 IP 切片的危害之前,我想先说一说,为什么我们在进行数据传输的时候,要尽可能的踩着 MTU 的限制来发送 IP 报文。其实原因很简单,是为了尽可能的提升传输效率, 如果每个 IP 报文都能尽可能的发满 MTU 的数据,那么意味着发送相同的数据,会发送更少的 IP 报文。也就是说报文包头在整个数据传输的占比会更少,可以很好的提升链路的利用率。另外发送更少的 IP 报文,意味着可以减少操作系统中协议栈处理、中断这些开销,降低 CPU 使用率。在 CPU 使用率是瓶颈的场景,可以有效减少缓存因为 CPU 跑满得不到处理而堆积的情况,也能提升传输性能。所以,我们看到高性能数据中心内部物理链路可能使用 Jumbo frame 来提升传输效率。

但是,MTU 是越大越好吗,这里可能也存在一些场景,MTU 越大,可能会带来一些负面影响。比较经典的就是高丢包传输环境,比如 WiFi 网络链路,如果因为信号弱等情况,出现了丢包,又或者因为传输错误,出现了损坏(CRC 校验不通过被丢弃)。而较大的 IP 报文一旦发生丢失,意味着更多的数据要被重传,可能会反而会降低传输效率、甚至会带来更多的延迟。所以,我们永远走在一直在权衡利弊的道路上,不过总体上,尽可能用满 MTU 来传输 IP 报文,是一个很好的实践原则,后文中我们会看下 Linux TCP 协议栈为了达成这个目的,做了哪些事情。

IP Fragmentation 的危害

到底怎么影响了传输性能

毫无疑问, IP 切片会影响传输的性能。但具体是如何影响传输性能,并且影响的程度是什么样子的,我突然有点好奇。如果从原理上来说,IP 切片和重组会导致系统资源的开销增大,显而易见,避免了 IP 切片在系统性能上会有优势。同时 IP 切片会导致传输性能的下降,因为被动切片的时候,很显然无法再做到每个 IP 报文都能用满链路的 MTU 数值。最后,在弱网环境下,IP 切片后的数据包任意一个发生了丢失,由于 IP 层没有重传机制,所以必须由上一层的传输层来重传,比如 TCP 或者 QUIC,切片后的任一数据丢失都需要重传整个切片前的数据,确实增加了重传的成本,并且 IP 报文个数的增多也相应的增加了丢包的概率。同时,丢包的后果不仅仅是传输层需要重传,同时也会影响了基于丢包的拥塞算法的发送窗口,加大了对传输效率影响负面影响。

arch-04

分析到这里,我突然有一个奇怪的想法,考虑到 IP 切片后也能利用满链路 MTU 数值,那是不是只是系统资源的开销增大,在系统资源不是瓶颈的场景下,其实传输效率是不怎么受影响。我没忍住写了一个 脚本来做了一下测试。为了人工实现有多条数据链路来模拟中间链路是较低的 MTU,我在脚本里面使用了 namespace、veth 和 bridge,在 Linux 宿主机上构建了一个测试环境。然后在 Server namespace 里面启动 iperf3 server,在 Client namespace 里面启动 iperf3 client 去做传输性能测试。

测试条件Path MTU 设置是否 IP 切片平均传输速率 (Gbits/sec)
中间路由器将 1500 字节切片为两个 800 左右8002.04
原始传输,无需 IP 切片8002.28

这里 MTU 的值是可以选了 800,因为这样在需要 IP 切片的测试场景中,1500 的 IP 报文到来被 Router 切片成两个接近 800 长度的 IP 报文时,还是能最大化利用链路的 MTU 数值,确保只是系统资源开销不同的条件下进行对比。在这样的测试对比下来,有 IP 切片的场景下传输均值速率是 2.04 Gbits/sec,没有 IP 切片的场景下速率是 2.28 Gbits/sec,差距基本很小。这里,如果把中间 MTU 800 的数值改成 1200 的话,IP 切片后的 IP 报文长度分别是 1200 和 300 左右,没有利用满链路 MTU 数值,所以得到的传输测试结果差距是比较明显的。

测试条件是否 IP 切片平均传输速率 (Gbits/sec)TCP 重传次数Client 上发包数量(相对)
Server 上模拟 1% 接收丢包,中间路由器将 1500 字节切片为两个 800 左右1.47~25,000半数
Server 上模拟 1% 接收丢包,无需 IP 切片1.72~29,000全量

当然,这个测试很随意,只能作为一个简单的参考,即网络正常的情况下,IP 切片如果不影响链路数据利用率,那么对网络传输性能的影响可能没我们想象的那么大。另外,我又简单测试了一下弱网的场景,我在 Server namespace 上面用 ebpf XDP 模拟了 1% 接收丢包,同样还是使用 iperf3 进行传输数据测试。有 IP 切片的场景速率均值是 1.47 Gbits/sec,没有 IP 切片的场景均值是 1.72 Gbits/sec,看起来传输性能差距没有那么大。另外,我还在 Client namespace 上面使用 bpftrace tcpretrans 脚本统计了测试过程中 TCP 重传的次数。有 IP 切片的场景,重传次数是 2.5 万次左右,没有 IP 切片的场景,重传次数是 2.9 万次。当然,由于 IP 切片发生在 Router namespace 上,所以没有 IP 切片的场景中,在 Client 上面发包的个数大概是有 IP 切片场景的两倍,所以在 IP 切片场景下,有着更高的重传率。考虑到这一点,可以很明显感知到,在有丢包的场景下,IP 切片会放大丢包的概率。

还有非常有意思的点在于,在高版本的 Linux 系统上,想要模拟出 IP 切片其实并不容易。我的脚本中加了很多额外的配置,来确保这一点。首先,我们要关闭 GSO、TSO 和 GRO 功能,至于这些功能是干什么的,后文中会做一些具体的介绍。接着,我们需要关闭 Linux 系统中的所有 MTU 探测功能,确保 Linux 在发送承载 TCP 的 IP 报文时,没有设置 DF flag,只有这样,IP 切片才能够在后续不满足 MTU 大小的数据链路上真正发生。

IP Fragmentation 的安全隐患

上面聊了 IP 切片会导致协议传输性能的下降, 除此之外,IP 切片还存在安全性问题。这里,最容易想到的 IP 切片安全隐患,肯定是针对目的或者中间机器的泛洪攻击,即大量发送无效的部分分片,目的机器在处理不完整的切片的时候,势必要缓存切片,哪怕有合理的缓存淘汰时间,也可能会出现设备 CPU 或者内存被消耗完,从而导致拒绝处理服务的问题。

另外,既然设备需要缓存 IP 切片来进行重组,这里的处理逻辑,一旦出现漏洞,很容易被攻击者利用,特别是一些边界场景。比如说,多个同组的 IP 切片数据范围有重叠,传说中的 teardrop 攻击就是利用这个。还有刻意构造出 IP 切片大小非法的情况,来试探设备的重组逻辑是否靠谱等等。但是这些恶意攻击应该只能很老的设备上,才能有作用,现代设备都不会有这样的漏洞,毕竟 IP 切片出来的时间比我年纪都大得多。

但是,很多具备防火墙功能的中间设备,为了达到流量检测的目的,不得不对切片后的数据报文进行重组,只有重组之后,才能看到真实的流量,进行检查。所以防火墙一般会在切片能够进行重组之前,就开始进行检查,确保达到尽早丢弃异常 IP 分片的效果,来防御攻击者。所以,IP 切片很有可能在严格防火墙策略下直接被丢弃,这样很可能导致传输层不可用。

MTU 探测有什么方案

Path MTU Discovery

古老的 RFC 1191 规定了 pmtud 的实现细节。最核心的实现机制是源主机会通过设置 Don't Fragment 标志位,来确保在网络路径中遇到 MTU 小于该 IP 报文长度的链路,中间设备会丢弃该 IP 报文,并且返回一个 Packet Too Big 的 ICMP 消息。源主机会根据接收到的 ICMP 消息,来判断调整发送的 MTU 数值大小,直到满足当前网络路径的最低需要,从而完成 MTU 探测。

这里,我想聊一个有趣的现象,就是 TCP pmtud 在 Linux 2.x 版本以来就是默认打开的,但是我在平时论坛上,经常看到有很多人错误的配置了 MTU 之后,特别是在使用了 WIreGuard 等隧道功能之后,会遇到网络功能基本不可用的问题,难道是 pmtud 没有生效吗?我觉得这是因为 ICMP 消息本身存在一些安全隐患,导致现实中很多中间设备会主动禁止掉 ICMP 消息,也就是 ICMP 黑洞引发的问题。要知道,pmtud 是完全依赖 Packet Too Big ICMP 消息来探测链路的 MTU,所以出现 ICMP 黑洞的话,pmtud 是完全没有办法探测出当前路径上准确的 MTU 数值的。

在分析 ICMP 消息安全隐患之前,我们先看看正常 Linux 系统是怎么处理 Packet Too Big ICMP 消息的。Packet Too Big ICMP 格式 如链接中所示,会携带触发该 ICMP 消息的 IP 报文头和 TCP 报文头起始部分给 TCP 协议栈来进行校验分析。首先,TCP 协议栈在处理 ICMP 消息的时候,会校验四元组以及 TCP 状态机是否符合要求,只有属于真实的 TCP 连接的 ICMP 消息才会被进一步处理,当然,按照惯例,TCP 协议栈还会校验下序列号是否合法,即在接收窗口的范围内。最后,在真正处理 Packet Too Big 消息的地方, 再进行 MTU 的动态调整。

所以,我们可以根据上面的校验机制,来分析 ICMP 消息可能存在哪些安全隐患。虽然 Linux 内核已经努力做了这些校验,但是攻击者还是可以依赖大量构造 ICMP 报文,来越过 TCP 四元组和序列号的校验限制。这样,就可以构造较小的 Next-Hop MTU 来降低连接的传输效率。当然,RFC 5927 也给了一些应对策略,比如说 MTU 有最低阈值,避免被降低到完全不合理的数值上,等等。

除此之外,防火墙或者中间设备一般都会禁止或限制 ICMP 消息,毕竟 traceroute 这样的工具,可以根据 ICMP 消息响应来探测网络拓扑图,导致信息泄露。另外,ICMP 泛洪攻击始终也是一个威胁,还有就是刚才我们提到的特定 ICMP 报文来影响 TCP 协议栈的可用性。Cloudflare blog 还记录一个由于复杂网络拓扑实现,导致的 ICMP 报文会出现丢失的案例。最后,ICMP 消息不是可靠的,会存在丢失的风险,也会极大的影响 pmtud 的探测效率。所以,依赖 ICMP 消息的 pmtud 存在这么多问题,那么我们还有什么样的方案,来进行 MTU 探测呢?

Packetization Layer Path MTU Discovery

plpmtud 方案也就应运而生,该探测方案针对 ICMP 黑洞的情况做了优化升级,核心是借助 TCP 传输层的确认机制来判断携带 DF 标志位的 IP 报文有没有被发送到目标主机。这样的话,就摆脱了对 Packet Too Big ICMP 消息的依赖。当然,这里也会存在各种各样要考虑的问题。首当其中的一个问题就是,怎么判断负责探测 MTU 的 IP 报文已经丢失了。一般来说,网络丢失有非常多的情况,最常见的是因为网络拥塞导致数据包丢失。所以,TCP 传输层需要结合当前网络的实际情况,考虑探测报文是不是因为 MTU 限制导致丢失的。既然是判断,那么肯定是会存在有误差的情况。

另外的话,还有很多探测算法落地的细节问题。比如说,TCP 协议栈目前只能拿真实的应用数据来做 MTU 探测,这样的话必然有比较高的数据丢失的风险,会影响到传输性能。像 QUIC 这种基于 UDP 的传输协议,都是基于 dplpmtud 来实现 MTU 探测的,即尽量使用非真实应用数据来进行 MTU 探测,来避免影响真实业务数据,当然这部分探测报文,也会受拥塞发送窗口的管控。还有就是如果是探测报文的丢失,需要确保不要影响到拥塞控制算法,避免拥塞发送窗口收缩,导致传输性能下降,毕竟现在主流的拥塞控制算法还都是基于丢包的。

探测策略

抛开探测反馈的机制不谈,不管是 pmtud 还是 plmtud 都要面临非常多的抉择。比如说探测 MTU 数值的选择,包括首次探测的 MTU 数值选择,后续探测范围选择,是二分法快速查找精准 MTU 值,还是只探测现代网络场景的 MTU 数值。再比如说,探测失败和回退机制,失败后是否需要再次重试、还是立刻快速回退。探测的频率应该如何抉择,能不能很好的应对路径上出现 MTU 动态变化的的情况,探测频率高了,容易浪费带宽,探测频率太低,MTU 升不上去,效率反而更低。这些都是我们工程实践中经常遇到的 trade-off,我觉得 QUIC 协议栈应该实现的时候,向应用层提供灵活的配置,让用户根据自身业务形态和更具体的质量数据,来做出最终的抉择,并且考虑根据需要实时更新这些策略

一些有趣的细节

DF 标志位是否需要强制设置

既然 IP 协议的设计中,存在 Don't Fragment 标志位来避免 IP 切片,那么我们实际传输中是否需要强制开启 DF 标志位,而不是仅仅给 MTU 探测报文开启。这个答案是很明确的,我们就是需要给每个 IP 报文开启 DF 标志位,确保在传输路径过程中,不会发生切片。当然,这个前提是,我们的传输层是支持 MTU 探测的,而且最好是 plpmtud 的探测方案,不然光是遇到 ICMP 黑洞,就够喝一壶的。

另外一个可以佐证这个答案的证明是,IPV6 在协议设计上就直接取消了 DF 标志位,要求所有中间设备都不能对 IPV6 报文进行切片,只有在源主机才可以对 IPV6 报文切片。当然哪怕是在源主机上切片,这种情况基本上都不会发生。如果你配置了正确的 MTU 数值,TCP 也不会做傻事,硬塞给 IP 层一个大于当前链路的数据段,除非使用了 Wireguard 这种隧道传输,让 TCP 传输层计算错了 MSS,当然 TCP 传输层会很快利用 MTU 探测,再次修正 MSS 数值。

最后,Linux 内核是默认给 TCP 流量强制开启了 DF 标志位的,一个有意思的点是,这个强制设置 DF flag 操作是和 Linux 内核是否开启了 pmtud 配置强绑的,而如上文所说,Linux 内核默认开启 pmtud 配置,所以 TCP 流量的 DF 标志位也是默认开启的。另外 Linux 内核没有给 UDP 流量强制开启 DF 标志位,所以一般应用层需要根据自己的需求,来主动设置。

现代网络链路的 MTU 一般是什么情况

IPV6 RFC 规定了能够承载 IPV6 协议的传输链路 MTU 最小值是 1280 字节,所以我们大概可以心里有预期,现代网络环境 MTU 数值基本上是大于 1280 字节的。而 Cloudflare blog 中也详细介绍了他们统计的真实网络环境中,传输链路 MTU 数值分布范围,这个是非常有参考价值的。所以,很多隧道配置都推荐将 MTU 设置成 1280,依据也是这个,我瞄了一眼我的机器上跑的 tailscale 虚拟网卡的 MTU 也是被默认配置成了 1280 字节,稳健的不行。

如果 MTU 发生动态变化怎么办

这也是一个非常小概率的场景,但是也不得不考虑,毕竟也是有发生的可能。一般来说,这样的场景会出现在路由层发生了改变,导致整个路径上最小 MTU 数值发生改变。至于路由层为什么会发生变化,我没有在运营商工作过,我猜想他们处于成本、质量、甚至故障容灾的目的,可能会刻意调整传输路径。另外,如果在使用路由层动态加速这类加速服务的时候,也很有可能遇到底层传输路径变化。但是哪怕路由层的路径发生了变化,除非是新路径的 MTU 发生变化,不然 MTU 数值还是不会变的。另外,还有一个可能比较高频的场景,就是 VPN 这类隧道软件在传输过程中突然打开或者关闭,也会导致 MTU 发生变化。

一般来说,MTU 探测都是在 TCP 传输链路刚建立的时候就开始进行了,而 MTU 动态变化很可能发生在连接正在传输的过程中。不过 TCP 协议栈倒是可以应对这个场景,如果有 MTU 的变化,TCP 协议栈还是按照之前 MTU 探测的实现机制,不管是 pmtud 还是 plpmtud,都会实时判断是否发生了因为 MTU 不符合要求导致的丢包,然后动态的调整 MTU 进行新一轮探测,确保传输稳定。更具体的说,是会根据大数据报文的丢失、ICMP 消息这类事件来判断是否要动态调整已经探测好的 MTU 值。至于说 QUIC 协议,因为对连接的定义设计和 TCP 不一样,所以除了上面这些事件,QUIC 协议还要求在发生了连接迁移(Connection Migration)的时候,也需要重新做 MTU 探测。

工程实践


网络编程相关的细节

首先,一个老生常谈的问题,TCP 网络编程的时候,需不需要考虑 MTU 的问题,答案当然是不需要的。应用层在 TCP 之上强行考虑 MSS 数值,去按照 MSS 数值大小来切分数据去调用 socket send 系统调用,是一件非常吃力不讨好的事情。如果开了隧道 VPN 软件,这里面的额外附加的报文头计算就够应用层喝一壶,甚至应用层都无法感知这些情况。另外,哪怕业务环境很稳定,应用层对隧道软件是否存在一清二楚。那 TCP 段是否携带 TCP option 报文头,也会影响数值的计算,要知道可不是只有 TCP 握手报文才会携带 option 报文头,用户哪怕获取了当前的 TCP MSS 数值,没办法去计算出精确的最大 TCP payload 长度。

更关键的是,从 socket send 到 TCP 协议栈构建 TCP 段的过程中,存在非常多的优化逻辑,应用层是根本无法使用 socket send 这样的系统调用,通过参数满足不超过 MSS 的大小,来控制 TCP 段的大小。要知道,TCP 协议栈为我们做了很多事情,应用层只需要把数据通过系统调用塞进 TCP 协议栈。TCP 协议栈自己会来确保数据能够既不去触发 IP 切片,又可以充分利用当前传输链路,接近 MTU 最低限制来发送 IP 报文。开发者在网络编程的时候,根本不需要考虑是否会触发 IP 分片,是否能利用满 MSS,达到最大的传输效率。

这里有一个有意思的 TCP 配置,我记得当初还是在排查线上数据传输延迟有点高的时候,才了解到了这个配置,即 TCP_CORK 配置。这个配置很有意思的一点是,当应用层主动开启这个配置之后,TCP 协议栈会主动聚合发送队列的数据,直到数据长度达到了当前的 MSS 之后,才会去真正发送 TCP 段到 IP 层。一般来说,是大流量传输的时候,会主动打开,确保尽可能聚合数据发送,减少 IP 报文数量,应用层发送完毕之后,再关闭该配置。不过,现在主流 Linux 系统都支持了 GSO 和 TSO,我理解 TCP_CORK 配置应该在大流量传输的场景没那么有用了

聊完了 TCP,再聊聊 UDP,我觉得其中一个比较有意思的点是 UDP 报文的最大长度到底是多少。UDP 报文头中携带了两字节大小的 UDP 完整报文头长度,所以我们很明显知道 UDP 报文的理论上限长度是 65535 字节。但是,UDP 报文头是紧接着 IPV4 或者 IPV6 报文头之后的,所以 UDP 报文最大长度也受 IP 报文头长度字段的限制。IPV4 的报文头长度字段是两字节,代表整个 IPV4 报文的完整长度,所以在 IPV4 的场景下, UDP 报文最大长度是 IPV4 报文头最大长度减去 IPV4 报文头长度再减去 UDP 报文头长度(65535 - 20 - 8 = 65507),即 65507 字节。而 IPV6 的报文头中长度字段代表 IPV6 payload 的长度,所以在 IPV6 的场景下,UDP 报文头最大长度是 IPV4 报文头最大长度减去 UDP 报文头长度(65535 - 8 = 65527)。其实我探寻这个,只是因为我在写 QUIC MTU 探测的时候,在想最大的 MTU 探测数值上限应该是多少。

另外一个问题是 UDP 没有像 TCP 那样可以自己来重新组建应用层数据来构建 TCP 段,每一次 UDP send 就会对应创建一个新的 UDP 数据报。如果说,UDP socket 主动开启了 DF 标志位,同时发送的 UDP 数据长度超过了当前网卡 MTU 数值大小(当然还要考虑 IP 报文头长度),那么 UDP send 会直接返回 EMSGSIZE (Message Too Long)的错误。所以应用层应该处理好这个错误,毕竟 UDP 协议不像 TCP 协议栈那么全能,TCP 协议栈可是会主动获取下一跳链路的 MTU 配置,从而计算出正确的 MSS 数值。基于 UDP 的传输层协议,应该要考虑到这些错误逻辑的处理,来及时调整自己的 MTU 数值,而不是等待探测包丢弃确认或者 ICMP 消息。

TCP MTU 探测的实现细节

Linux 下 TCP MTU 的相关配置

Linux 官方配置文档 上面,我们可以找到非常多和 MTU 相关的配置,其中大部分配置涉及的功能点都在上面有所提及。比如说 ip_no_pmtu_disc 这个配置,在 Linux 上面是默认开启 pmtu,并且需要注意的是,这个配置还决定了 TCP 流量底层是否会加上 DF 标志位。另外还有 plpmtud 的开关配置,即 tcp_mtu_probing 配置,该配置是默认关闭 plpmtud 功能的,正常来说我是希望主动打开这个功能的。除此之外,还有 tcp_base_mss 配置,是 pmtud 和 plmtud 探测失败时候的探测下限,可以有效防御 ICMP 攻击恶意降低 MSS 数值。另外,如果 TCP 握手时候没有发送 MSS 扩展来协商,那么 TCP 协议栈会使用 tcp_base_mss 作为默认 MSS 值。这里聊到 TCP MSS option 扩展,一般还有一种简单粗暴的解决方案,MSS clamping(MSS 钳制)。这是一种在路由器或防火墙等中间设备上,通过修改 TCP SYN 报文中的 MSS (Maximum Segment Size) 选项,实现对经过该设备的 TCP 连接最大分段大小的限制,从而避免路径 MTU 问题(如 ICMP 被过滤导致 PMTUD 失效)。

文档中还有一些配置,是 MTU 缓存相关的配置,比如 MTU 缓存的淘汰时间 mtu_expires,以及 MTU 缓存中 MTU 数值的最大最小区间。是的,Linux 系统还会做一个全局 MTU 探测结果的缓存,来提升传输效率。我们可以用 ip route get {dip} 来获取具体缓存的情况,也可以使用 ip route flush cache 来清理缓存。

通过抓包分析 TCP pmtud 和 plpmtud 实现细节

接下来,我们可以构造一个场景来通过抓包来展示 Linux 下 TCP pmtud 和 plpmtud 实际运行情况。我们还是复用之前的那个脚本,只不过我们要修改一下 MTU 设置的命令,确保整个传输链路的 MTU 数值如下图所示。完成了这些,我们就可以分别去模拟复现 pmtud 和 plpmtud 的场景了。

arch-07

下面这个抓包的截图是 pmtud 运行的细节,由于 Linux 系统默认打开 pmtud 和关闭 plmtud,所以我们不需要额外的配置,直接在 Namespace Client 上面抓包,就可以看到 pmtud 具体运行的情况。可以看到及时的 Packet Too Big ICMP 消息可以让 TCP 协议栈快速调整自己的 MSS 数值,重新构建满足新 MSS 的重传 TCP 段。不过,这也会导致应用数据会被重传,略微影响一点点传输性能。

arch-05

关于复现 plpmtud,稍微有一些地方需要特别注意一下。比如说,先在 Namespace Router 上面把 ICMP 流量都丢掉,确保 pmtud 是不工作的。另外,考虑到 Linux 上面 pmtud 配置和 DF flag 设置是绑死的,所以还是需要把 pmtud 和 plmtud 一起开启。还要考虑 Linux 的 MTU 缓存机制,重复测试的时候记得刷新掉缓存。最后就是 plpmtud 的实际抓包展示,可以看到在 plmtud 使用应用流发送了一个略微大于其余报文的探测包。虽然 ICMP 消息没有到来,但是 plmtud 通过重复 ACK 可以很快反应过来数据大概率可能是因为 MTU 限制被丢弃了,从而快速只重传了这个探测包,整个过程看起来比较丝滑。

arch-06

最后需要强调一点,如果把 plpmtud 给刻意关闭,那么在 ICMP 关闭的情况下,并且中间链路 MTU 数值比较低的情况下,Linux TCP 协议栈是无法正常工作。换句话说,如果有 ICMP 黑洞,并且真实链路 MTU 数值比 TCP 双端协商出的 MSS 数值小的话,那么只有打开 plpmtud,Linux TCP 协议栈才能正常工作。所以使用了 WireGuard 之类隧道传输协议,最好还是开启一下 plpmtud 功能

QUIC MTU 探测实现细节

可能大家会注意到 QUIC 没有 MSS 的概念,但是 QUIC 在一开始握手的时候,也会像 TCP MSS 可选项一样协商相关数值,即 max_udp_payload_size,意味着 QUIC 报文的大小不应该超过这个协商后的数值。当然 max_udp_payload_size 的协商,只代表了 QUIC 通信双方各自物理链路的承载能力,并不包含网络路径中的中间链路,这个还是得靠 MTU 探测来实现。另外一个值得注意的地方是,QUIC RFC 里面明确指出了 UDP payload 的最大值是 65527,哈哈,正如我上面提及的那样,IPV6 承载的 UDP 报文只需要减去 UDP 报文头,最大值正好是 65527 字节。

关于 QUIC MTU 探测的实现,我们肯定是选择了适用性更强的 plpmtud 解决方案,但是 IETF 有专门给数据报文传输层准备了特供版的 plmtud RFC, 即 dplpmtud。该扩展主要是将 plpmtud 方案扩展到了无连接的数据报传输协议上,当然 QUIC 是基于 UDP 的面向连接传输协议,但是也能受益于次。在 TCP 层中,plpmtud 和 pmtud 都是使用真实的应用层数据来做 MTU 探测,当然上文有讨论过这个做法的优缺点。dplpmtud 默认无连接的数据报协议没有消息应答确认机制,所以需要特别构建探测数据包来进行 MTU 探测。QUIC 使用的探测包是 Ping FramePadding Frame,并且 Ping Frame 是可以触发对端 ACK 确认的,所以整体上 QUIC 规避了使用真实应用层数据来做探测包,保证真实数据不会因为 MTU 限制而出现丢包,某种程度上提升了一些传输效率、并且丢包检测的判断也更加简单。但是,市面上大部分 QUIC 开源库都没有去特别针对 MTU 动态变化的场景进行处理,如果 QUIC 协议栈完成了 MTU 探测,过了段时间,MTU 由于某种原因出现了缩减,这里很有可能导致 QUIC 协议栈不可用,feather-quic 在这里也是没有做针对性的优化处理。

至于 QUIC RFC 还重点强调了怎么处理 ICMP 消息,这里可能有人奇怪,为什么选择了 plpmtud 的方案,还要考虑 ICMP 消息,我觉得答案很简单,两手都要抓,两手都要硬。本质上,可以给协议层提供因为 MTU 限制导致丢包的信息其实非常少,总结下来无非就以下两类:Packet Too Big ICMP 消息、MTU 探测包或者连续大数据包丢失。后者要结合一系列因素来判断,准确性是要低于 ICMP 消息。在没有 ICMP 黑洞的情况下,ICMP 消息肯定更准确及时。当然,还有就是 ICMP 消息的安全性问题,我们在上文已经聊过了 TCP 是怎么确认 ICMP 消息安全性,现在我们看下 QUIC 是怎么应对这个问题的,也就是怎么认证 ICMP 消息属于这个 QUIC 连接。

从 UDP socket 上面获取到 ICMP 报文,并不代表 UDP socket 四元组被校验了,毕竟很有可能所有 QUIC 连接共用同一个 UDP socket。另外还有一个问题是,QUIC 连接也不是靠 UDP 四元组来做唯一标识的,哪怕我们校验了 UDP 四元组也无济于事。同时,QUIC 也没有办法像 TCP 那样直接去校验序列号是否在接收窗口,毕竟 QUIC 序列号是被加密的,ICMP 也没有携带 QUIC 序列号数据,那我们还有什么好的解决方案呢?QUIC RFC 针对这个场景,给了一个解决方案。ICMP 消息不是会携带一段触发 ICMP 消息的报文开头原始数据吗。正常来说,我们进行 MTU 探测的环节是在连接建立后,也就是 QUIC Short Header 来承载探测数据。这样的话,ICMP 携带的原始数据就是 QUIC Short Header 的头部,其中包含 dcid 数据。这样的话,我们可以根据 dcid 来判断这个 ICMP 消息是否属于这个 QUIC 连接,虽然还有被攻击的风险,但是攻击成本肯定是增加了。

但是 RFC 其实并不是要强调这个,而是想解决一个隐藏问题,即承载 QUIC 的 UDP 数据报如果存在被四层转发的场景,很可能 UDP 报文无法被转到属于它的服务节点,而 QUIC-LB 就是通过 QUIC Connection ID 来解决这个问题。所以 ICMP 报文想要顺利回到 QUIC 探测包所在的机器,最好是携带上 QUIC 探测包发起方的 scid。但是 QUIC Short Header 没有携带 scid,QUIC RFC 里面提出了一个非常酷炫的解决方案,即 QUIC 探测包对应的 UDP 数据报文上,先组装一个 QUIC Long Header 包,然后再组装 QUIC Short Header 探测包,这样的话,ICMP 消息携带的原始数据,就是 QUIC Long Header 的头部,可以让 ICMP 消息有能力被四层转发回真正的源机器上。这里,可能有人会问,QUIC Long Header 对应的密钥可是早就被丢弃了,这不会有问题吗?但是 QUIC 协议栈会自动丢弃密钥被淘汰的 QUIC packet,然后继续处理后续的 QUIC 数据,毕竟 QUIC length 字段又没有被加密,不会影响后面 QUIC 探测包被 QUIC 协议栈正常处理。

GSO TSO GRO

一个抓包发现的奇怪现象

很多人会尝试通过抓包来观察 pmtud 或者 plmtud 的运行情况,正常来说这样的思路是没有问题的。场景的抓包工具 Wireshark、Tcpdump 等都是在数据链路层进行抓包的,在接收路径上抓包时机会早于 IP 层处理(比如在 IP 切片重组之前),发送路径上会在 IP 层处理之后(即在 IP 分片发生之后)。所以,我们是可以通过抓包看到 TCP 协议根据当前 MSS 来切分出来的 TCP 段,来感知当前 TCP 协议栈中 MSS 的数值,也能感知到 IP 切片是否发生。

arch-08

然而,在高版本的 Linux 内核,大家在源主机进行抓包的时候,大概率会看到远大于当前网卡 MTU 配置的 IP 报文出现,不要怀疑,这里的罪魁祸首就是 GSO 或者 TSO。

GSO TSO GRO 介绍

TSOGSO 核心都是让 TCP 传输层构建 TCP 段的时候,不要再根据 MSS 大小上限来进行切分。而是直接组成一个巨型 TCP 段交付给 IP 层,最后在数据被网卡发出的时候再根据 MSS 数值大小,切分合适的 TCP 段发送出去。所以,我们可以知道,为什么我们想通过抓包观察真实的 TCP 段的长度那么困难了,原来真正内核中根据 MSS 进行切分的流程居然还在抓包之后。

TSO 和 GSO 主要的差异在于数据切分的时机不同,TSO 是需要网卡硬件特别支持的,即由网卡硬件完成切分。而 GSO 则是在 TSO 不被硬件支持情况下的 Linux 内核软件层面提供的替代品,在驱动层进行大数据包拆分 TCP 段的工作。显而易见,TSO 效率会更高一些。至于为什么要这么做,其实就是降低 Linux 内核的开销,提升网络吞吐能力。毕竟,TCP/IP 协议栈也是有执行开销的,特别是切分和构建 TCP 段,而现在都放在硬件或者驱动里面去执行了。此外,Linux 内核要发送数据包的个数变少了,对应的软中断和锁争用频率也降低了,性能也显然是得到了提升。

GRO 则是在 Linux 内核接收数据的时候,把多个连续并且四元组相同的 TCP 段或者 UDP 报文合并成一个大包,然后送到上层协议栈,最后在交给 TCP 协议栈或者 UDP 协议栈来处理。目的也是类似的,为了降低系统资源开销,提升吞吐性能。

相关问题

关于网络编程的时候,是否需要感知 GSO、TSO 以及 GRO 等内核优化,其实 TCP 是完全无感的。原因很简单,对于 TCP socket 来说,这些 offload 的影响全部在 TCP 协议栈里面已经被处理掉了,不管是接收的巨型 TCP 段,还是发送巨型 TCP 段,TCP 协议栈全程接管,应用层只需要正常读写 TCP socket 即可。但是对于 UDP 应用层就不一样了,每一次 UDP socket send 都是创建了一个独立的 UDP 数据报,这是因为 UDP 协议层非常简单,基本不会干涉 UDP 数据报生成。在 UDP 应用层数据想进行发送的时候使用 GSO 或者 TSO,必须显式的通过 UDP_SEGMENT 告知 sendmsg 系统调用,并且主动告知 udp max payload 大小(像上文所说的那样,UDP 应用层需要自行维护它),内核才会为 UDP 流量使用 GSO 或者 TSO 功能。Cloudflare blog accelerating-udp-packet-transmission-for-quic 也给出详细的使用细节和性能对比。如果是 GRO 的话,UDP 应用层则是不需要特殊处理,因为 GRO 只是合并了相同大小、相同四元组的 UDP 数据报文,UDP 协议层会为应用层将这个巨型 UDP 报文给自动拆分掉,UDP 应用层能够正常的读取被拆分后的 UDP 报文。

那 GSO TSO 会和 MTU 探测有冲突吗?答案当然是不会,我一开始也比较好奇这个问题,因为 GSO 和 TSO 工作在较底层,担心 TCP 动态维护的 MSS 数值,很难在底层生效。后来翻了一下代码发现,Linux 内核会将自己实时维护的 MSS 数值传递下去,确保 MTU 探测的效果不会被浪费的。当然,如果是基于 UDP 的协议,刚才说了,UDP 应用层进行的 MTU 探测结果,也可以通过 sendmsg 中 cmsg_len 字段来传递给 GSO 和 TSO 使用,确保一切正常运行。如果有 WireGuard 这样的隧道软件,会对 GSO、TSO 有影响吗?这里的话,完全取决于传递给 GSO 或者 TSO 的 MSS 数值和 udp max payload 大小是否准确。换句话说,也就是看 MTU 探测是否给力了。

还有一个问题,GRO 会影响切片后的 IP 报文重组吗,想到这个问题,我就觉得有点头大,然后去翻了翻代码,发现 Linux 内核中会对 GRO 有非常严格的限制,切片后的 IP 报文完全不会走到 GRO 优化逻辑中。另外,如果 GRO 生效了,但是到了 IP 层之后发现不是目标主机,需要继续转发,那该怎么办。这里的话,有个 Linux 内核 有一个非常巧妙的解法,在 GRO 聚合出大的 skb 之后,会给这个 skb 打上 GSO 的标记,这样的话,如果在 IP 层发现不是目标主机,无法送到 TCP 协议栈进行处理,那么在转发的时候会走 GSO 的逻辑,确保合并后的 skb 还是会被拆分掉,以确保功能正常执行。这里不选择 TSO 的原因,自然是 GSO 是纯内核层实现的功能,不会像 TSO 有硬件依赖,可能会缺席。

尾声


感觉写这篇博客,花了比我预想要多不少时间,感觉写的有点拖泥带水的,当然,feather-quic 的代码完成倒是很快,不过完成度不是特别高,有一些上文提及的细节都没有处理😁。一方面是我虽然以前实现过 MTU 探测相关的功能,但是没有做过 Linux 内核协议栈开发,对很多细节要额外花一些时间去思考。另外一方面,就是我又开始玩 ea fc 25 了,违背了祖训,又偷偷开了个小号,主要是我的主队切尔西拿了欧协杯冠军,ea 出了一套欧协杯冠军球员卡,让我实在没忍住,用我老婆的话,就是又开始复吸了,属于晚期患者。

最后,聊了那么多 MTU 探测的内容,还没有一个关于 MTU 配置的建议也说不过去。在这里,我只能说,对于喜欢玩路由器、特别是喜欢开隧道软件玩内网穿透的,最好把 plpmtud 功能给打开,如果还是有问题,那就一刀切把 MTU 数值设到 1280 吧(数值够小,哪怕有一些额外的开销,也能躲过中间物理链路的 MTU 限制,又没那么小,不至于影响传输性能)。