You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

27 KiB

08 | 分段MTU引发的血案

你好,我是胜辉。

第1讲我给你介绍过TCP segmentTCP段作为“部分”是从“整体”里面切分出来的。这种切分机制在网络设计里面很常见但同时也容易引起问题。更麻烦的是这些概念因为看起来都很像特别容易引起混淆。比如你可能也听说过下面这些概念

  • TCP分段segmentation
  • IP分片fragmentation
  • MTU最大传输单元
  • MSS最大分段大小
  • TSOTCP分段卸载
  • ……

所以这节课我就通过一个案例来帮助你彻底搞清楚这些概念的联系和区别这样你以后遇到跟MTU、MSS、分片、分段等相关的问题的时候就不会再茫然失措也不会再张冠李戴了而是能清晰地知道问题在哪里并能针对性地搞定它。

案例:重传失败导致应用压测报错

我先来给你介绍下案例背景。

在公有云服务的时候一个客户对我们公有云的软件负载均衡LB进行压力测试结果遇到了大量报错。要知道这是一个比较大的客户这样的压测失败意味着可能这个大客户要流失所以我们打起十二分的精神投入了排查工作。

首先,我们看一下这个客户的压测环境拓扑图:

这里的香港和北京都是指客户在我们平台上租赁的云计算资源。从香港的客户端机器发起对北京LB上的VIP的压力测试也就是短时间内有成千上万的请求会发送过来北京LB就分发这些请求到后端的那些同时在北京的服务器上。照理说我们的云LB的性能十分出色承受数十万的连接没有问题。不夸张地说就算客户端垮了LB都能正常工作。

但是在这次压测中客户发现有大量的HTTP报错。我们看了具体情况这个压测对每个请求的超时时间设置为1秒。也就是说如果请求不能在1秒内得到返回就会报错。而问题症状就是出现了大量这样的超时报错。

既然通过LB做压测有问题客户就绕过LB从香港客户端直接对北京服务器进行测试结果发现是正常的压测可以顺利完成。当然因为单台服务器的能力比不上LB加多台服务器的配置所以压测数据不会太高但至少没有报错了。

那这样的话客户是用不了我们的LB了

我们还是用抓包分析来查看这个问题。显然,我们知道了两种场景,一种是正常的,一种是异常的。那么我们就在两种场景下分别做了抓包。

绕过LB的压测

我们来看一下绕过LB的话请求是如何到服务器的。香港机房的客户端发起HTTP请求通过专线进入广州机房然后经由广州-北京专线,直达北京机房的服务器。

我们来看一下这种场景下的抓包文件:

抓包文件已经脱敏后上传至gitee

考虑到压测的问题是关于HTTP的我们需要查看一下HTTP事务的报文。这里只需要输入过滤器http就可以了

图片

我们选中任意一个这样的报文然后Follow -> TCP Stream来看看整个过程

图片

从这个正常的TCP流里我们可以获取到以下信息

  • 时延往返时间在36.8ms因为SYN+ACK2号报文和ACK9号报文之间就是隔了0.036753秒即36.8ms。
  • HTTP处理时间也很快从服务端收到HTTP请求在10号报文到服务端回复HTTP响应14到16号报文一共只花费了大约6ms。
  • 在收到HTTP响应后客户端在25ms324号报文发起了TCP挥手。

看起来服务器确实没有问题整个TCP交互的过程也十分正常。那么我们再来看一下“经过LB”的失败场景又是什么样的然后在报文中“顺藤摸瓜”进一步就能逼近根因了。

经过LB的压测

我们打开绕过LB的压测的抓包文件第7讲我提到过利用Expert Information专家信息来展开排查的小技巧。那么这里也是如此。

抓包文件已经脱敏后上传至gitee

可以看到里面有标黄色的Warning级别的信息有50个需要我们注意的RST报文

我们展开Warning Connection reset (RST)选一个报文然后找到它的TCP流来看一下

图片

比如上图中我们选中575号报文然后Follow -> TCP Stream就来到了这条TCP流

图片

不过这里,我先考考你,目前这个抓包是在客户端还是服务端抓取的呢?

如果你对第2讲还有印象应该已经知道如何根据TTL来做这个判断了。没错因为SYN+ACK报文的TTL是64所以这个抓包就是在发送SYN+ACK的一端也就是服务端做的。

接下来就是重点了。显然这个TCP流里面有两个重复确认54和55号报文还有两个重传154和410号报文在它们之后就是客户端源端口53362发起了TCP挥手最终以客户端发出的RST结束。

这里隐含着两个疑问,我们能回答这两个疑问了,那么问题的根因也就找到了。

第一个疑问为什么有重复确认DupAck

重复确认在TCP里面有很重要的价值它的出现一般意味着传输中出现了丢包、乱序等情况。我们来看看这两个重复确认报文的细节。

图片

我们很容易发现这两个DupAck报文的确认号是1。这意味着什么呢你现在对TCP握手已经挺熟悉了显然应该能想到这个1的确认号其实就是握手阶段完成时候的确认号。也就是说客户端其实并没有收到握手后服务端发送的第一个数据报文所以确认号“停留”在1。

那么为什么是两个重复确认报文呢我们把视线从2个DupAck报文往上挪关注到整个TCP流的情况。

握手完成后客户端就发送了POST请求然后服务端先回复了一个ACK确认收到了这个请求。之后有连续3个报文作为HTTP响应返回给客户端。

按照TCP的机制它可以收一个报文就发送一个确认报文也可以收多个报文发送一个确认报文。反过来说一端发送几次确认报文就意味着它收到了至少同样数量的数据报文。

在当前的例子里因为有2个DupAck报文那么客户端一定至少收到了2个数据报文。是哪两个呢一定是连续3个报文的第二和第三个也就是1388字节报文的后面两个。因为如果是收到了1388字节那个那确认号就一定不是1而是13891388+1了。

我们再把视线从2个DupAck往下挪这里有2个TCP重传。

我们关注一下Time列第一个重传是隔了大约200ms第二次重传隔了大约472毫秒。这就是TCP的超时重传机制引发的行为。关于重传这个话题,后续课程里会有大量的展开,你可以期待一下。

图片

那么结合上面这些信息我们也就理解了“通过LB压测失败”的整个过程在TCP里面具体发生了什么。我还是用示意图来展示一下

不过你也许会问“每次这样画一个示意图好像比较麻烦啊难道Wireshark就不能提供类似的功能吗

Wireshark主窗口里展示的报文确实有点类似“一维”也就是从上到下依次排列在解读通信双方的具体行为时如果能添加上另外一个“维度”比如增加向左和向右的箭头是不是可以让我们更容易理解呢

其实我们能想到的Wireshark的聪明的开发者也想到了Wireshark里确实有一个小工具可以起到这个作用它就是Flow Graph

你可以这样找到它点开Statistics菜单在下拉菜单中找到Flow Graph点击它就可以看到这个抓包文件的“二维图”了。不过因为我们要查看的是过滤出来的TCP流而Flow Graph只会展示抓包文件里所有的报文所以我们需要这么做

  • 先把过滤出来的报文,保存为一个新的抓包文件;
  • 然后打开那个新文件再查看Flow Graph。

比如这次我就可以看到下面这个Flow Graph

上图读起来是不是感觉信息量要比主界面要多一些特别是有了左右方向箭头给我们大脑形成了“第二个维度”报文的流向可以直接看出来而不再去看端口或者IP去推导出流向了。

好了,“为什么会有重复确认”的问题,我们搞清楚了,它就是由于三个报文中,第一个报文没有到达客户端,而后两个到达的报文触发了客户端发送两次重复确认。我们接下来看更为关键的问题。

第二个疑问:为什么重传没有成功?

第一个报文就算暂时丢失,后续也有两次重传,为什么这些重传都没成功呢?既然我们同时有成功情况和不成功情况下的抓包文件,那我们直接比较,也许就能找到原因了。

让我们把两个文件中的类似的TCP流对比一下

图片

你能发现其中的不同吗?这应该还是比较容易发现的,它就是:HTTP响应报文的大小。两次测试中虽然HTTP响应报文都分成了3个TCP报文但最大报文大小不同左边是1348右边是1388相差有40字节。既然已经提到了报文大小那你应该会联想到我们这节课的主题MTU了吧

**MTU中文叫最大传输单元也就是第三层的报文大小的上限。**我们知道网络路径中小的报文相对容易传输而大的报文遇到路径中某个MTU限制的可能会更大。那么在这里假如这个问题真的是MTU限制导致的显然1388会比1348更容易遇到这个问题

就像上面示意图展示的那样如果路径中有一个偏小的MTU环节那么完全有可能导致1388字节的报文无法通过而1348字节的报文就可以通过。

而且因为MTU是一个静态设置在同样的路径上一旦某个尺寸的报文一次没通过后续的这个尺寸的报文全都不能通过。这样的话后续重传的两次1388字节的报文也都失败这个事实也就可以解释了。

既然问题跟MTU有关我们就检查了客户端到服务端之间的一整条链路发现了一个之前没注意到的情况除了广州到北京之间有一条隧道在北京LB到服务端之间还有一条额外的隧道。我们在第5讲里学习过,隧道会增加报文的大小。而正是这条额外隧道造成了报文被封装后超过了路径最小MTU的大小从下面的示意图中我们能看到两次路径上的区别所在

经过LB的时候报文需要做2次封装Tunnel 1和Tunnel 2而绕过LB就只要做1次封装只有Tunnel 1。跟生活中的例子一样同样体型的两个人穿两件衣服的那个看起来比穿单衣的那个要显胖一点也是理所当然。要显瘦穿薄点。或者实在要穿两件那只好自己锻炼瘦身改小自己的MTU

另外由于Tunnel 1比Tunnel 2的封装更大一些所以服务端选择了不同的传输尺寸一个是1388一个是1348。

第三个疑问:为什么重传只有两次?

一般我们印象里TCP重传会有很多次为什么这个案例里只有两次呢如果你能联想到第3讲里提到的多个内核TCP配置参数那可能你会想到net.ipv4.tcp_retries2这个参数。确实,通过这个参数的调整,是可以把重传次数改小,比如改为两次的。不过在这个案例里不太可能。一方面,除非有必要,没人会特地去改动这个值;另外一个原因,是因为我们找到了更合理的解释。

这个解释就是客户端超时这一点其实我在前面介绍案例的时候就提到过。从TCP流来看从发送POST请求开始到FIN结束一共耗时正好在1秒左右。我们可以把Time列从显示时间差delta time改为显示绝对时间absolute time得到下图

图片

可见客户端在0.72秒发出了POST请求在1.72秒发出了TCP挥手第一个FIN相差正好1秒更多的重传还来不及发生连接就结束了。

这种“整数值”一般是跟某种特定的有意的配置有关而不是偶然。那么显然这个案例里客户端压测程序配置了1秒超时目的也容易理解这样可以保证即使一些请求没有得到回复客户端还是可以快速释放资源开启下一个测试请求。

一般对策

其实我估计你在日常工作中也可能遇到过这种MTU引发的问题。那一般来说我们的对策是把两端的MTU往下调整使得报文发出的时候的尺寸就小于路径最小MTU这样就可以规避掉这类问题了。

举个例子,在我的测试机上,执行ip addr命令就可以查看到各个接口的MTU比如下面的输出里enp0s3口的当前MTU是1500

$ ip addr
1: enp0s3: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000
    link/ether 08:00:27:09:92:f9 brd ff:ff:ff:ff:ff:ff
    inet 192.168.2.29/24 brd 192.168.2.255 scope global dynamic enp0s3
       valid_lft 82555sec preferred_lft 82555sec
    inet6 fe80::a00:27ff:fe09:92f9/64 scope link
       valid_lft forever preferred_lft forever

而假如路径上有一个比1500更小的MTU那为了适配这个状况我们就需要调小MTU。这么做很简单比如执行以下命令就可以把MTU调整为1400字节

$ sudo ip link set enp0s3 mtu 1400

“暗箱操作”

那除了这个方法,是不是就没有别的方法了呢?其实,我喜欢网络的一个重要原因是,它有很强的“可玩性”。**只要我们有可能拆解网络报文,然后遵照协议规范做事情,那还是有不少灵活的操作空间的。**你可能会好奇:这听起来有点像“灰色地带”一样,难道网络还能玩“潜规则”吗?

比如这次的案例网络环节都是软件路由和软件网关所以“暗箱操作”也成了可能我们不需要修改两端MTU就能解决这个问题。是不是有点神奇不过你理解了TCP和MTU的关系就会明白这是如何做到的了。

MTU本身是三层的概念而在第四层的TCP层面有个对应的概念叫MSSMaximum Segment Size最大分段尺寸也就是单纯的TCP载荷的最大尺寸。MTU是三层报文的大小在MTU的基础上刨去IP头部20字节和TCP头部20字节就得到了最常见的MSS 1460字节。如果你之前对MTU和MSS还分不清楚的话现在应该能搞清楚了。

MSS在TCP里是怎么体现的呢其实我在TCP握手那一讲里提到过 Window Scale你很容易能联想到MSS其实也是在握手阶段完成“通知”的。在SYN报文里客户端向服务端通报了自己的MSS。而在SYN+ACK里服务端也做了类似的事情。这样两端就知道了对端的MSS在这条连接里发送报文的时候双方发送的TCP载荷都不会超过对方声明的MSS。

当然如果发送端本地网口的MTU值比对方的MSS + IP header + TCP header更低那么会以本地MTU为准这一点也不难理解。这里借用一下 RFC879 里的公式:

SndMaxSegSiz = MIN((MTU - sizeof(TCPHDR) - sizeof(IPHDR)), MSS)

MTU是两端的静态配置除非我们登录机器否则改不了它们的MTU。但是它们的TCP报文却是在网络上传送的而我们做“暗箱操作”的机会在于**TCP本身不加密这就使得它可以被改变**也就是我们可以在中间环节修改TCP报文让其中的MSS变为我们想要的值比如把它调小。

这里立功的又是一张熟悉的面孔:iptables。在中间环节比如某个软件路由或者软件网关在iptabes的FORWARD链这个位置我们可以添加规则修改报文的MSS值。比如在这个案例里我们通过下面这条命令把经过这个网络环节的TCP握手报文里的MSS改为1400字节

iptables -A FORWARD -p tcp --tcp-flags SYN SYN -j TCPMSS --set-mss 1400

它工作起来就是下图这样是不是很巧妙通过这种途中的修改两端就以修改后的MSS来工作了这样就避免了用原先过大的MSS引发的问题。我称之为“暗箱操作”就是因为这是通信双方都不知道的一个操作而正是这个操作不动声色地解决了问题。

什么是TSO

前面说的都是操作系统会做TCP分段的情况。但是这个工作其实还是有一些CPU的开销的毕竟需要把应用层消息切分为多个分段然后给它们组装TCP头部等。而为了提高性能网卡厂商们提供了一个特性就是让这个分段的工作从内核下沉到网卡上来完成,这个特性就是TCP Segmentation Offload

这里的offload如果仅仅翻译成“卸载”可能还是有点晦涩。其实它是off + load那什么是load呢就是CPU的开销。如果网卡硬件芯片完成了这部分计算任务那么CPU就减轻负担了这就是offload一词的真正含义。

TSO启用后发送出去的报文可能会超过MSS。同样的在接收报文的方向我们也可以启用GROGeneric Receive Offload。比如下图中TCP载荷就有2800字节这并不是说这些报文真的是以2800字节这个尺寸从网络上传输过来的而是由于接收端启用了GRO由接收端的网卡负责把几个小报文“拼接”成了2800字节。

图片

所以如果以后你在Wireshark里看到这种超过1460字节的TCP段长度不要觉得奇怪了这只是因为你启用了TSO发送方向或者是GRO接收方向而不是TCP报文真的就有这么大

想要确认你的网卡是否启用了这些特性可以用ethtool命令比如下面这样

$ ethtool -k enp0s3 | grep offload
tcp-segmentation-offload: on
generic-segmentation-offload: on
generic-receive-offload: on
large-receive-offload: off [fixed]
rx-vlan-offload: on
tx-vlan-offload: on [fixed]
l2-fwd-offload: off [fixed]
hw-tc-offload: off [fixed]
esp-hw-offload: off [fixed]
esp-tx-csum-hw-offload: off [fixed]
rx-udp_tunnel-port-offload: off [fixed]
tls-hw-tx-offload: off [fixed]
tls-hw-rx-offload: off [fixed]

当然在上面的输出中你也能看到有好几种别的offload。如果你感兴趣可以自己搜索研究下这里就不展开了。

对了要想启用或者关闭TSO/GRO也是用ethtool命令比如这样

$ sudo ethtool -K enp0s3 tso off
$ sudo ethtool -k enp0s3 | grep offload
tcp-segmentation-offload: off

IP分片

IP层也有跟TCP分段类似的机制它就是IP分片。很多人搞不清IP分片和TCP分段的区别甚至经常混为一谈。事实上它们是两个在不同层面的分包机制互不影响。

在TCP这一层分段的对象是应用层发给TCP的消息体message。比如应用给TCP协议栈发送了3000字节的消息那么TCP发现这个消息超过了MSS常见值为1460就必须要进行分段比如可能分成1460146080这三个TCP段。

在IP这一层分片的对象是IP包的载荷它可以是TCP报文也可以是UDP报文还可以是IP层自己的报文比如ICMP。

为了帮助你理解segmentation和fragmentation的区别我现在假设一个“奇葩”的场景也就是MSS为1460字节而MTU却只有1000字节那么segmentation和fragmentation将按照如下示意图来工作

补充为了方便讨论我们假设TCP头部就是没有Option扩展的20字节。但实际场景里很可能MSS小于1460字节而TCP头部也超过20字节。

当然实际的操作系统不太会做这种自我矛盾的傻事这是因为它自身会解决好MSS跟MTU的关系比如一般来说MSS会自动调整为MTU减去40字节。但是我们如果把视野扩大到局域网也就是主机再加上网络设备那么就有可能发生这样的情况1460字节的TCP分段由这台主机完成1000字节的IP分片由路径中某台MTU为1000的网络设备完成。

这里其实也有个隐含的条件就是主机发出的1500字节的报文不能设置 DFDont Fragment否则它既超过了1000这个路径最小MTU又不允许分片那么网络设备只能把它丢弃。

在Wireshark里我们可以清楚地看到IP报文的这几个标志位

图片

现在我们假设主机发出的报文是不带DF位的那么在这种情况下这台网络设备会把它切分为一个1000也就是960+20+20字节的报文和一个520也就是500+20字节的报文。1000字节的IP报文的 **MF位More Fragment**会设置为1表示后续还有更多分片而520字节的IP报文的MF字段为0。

这样的话接收端收到第一个IP报文时发现MF是1就会等第二个IP报文到达又因为第二个报文的MF是0那么结合第二个报文的fragment offset信息这个报文在分片流中的位置就把这两个报文重组为一个新的完整的IP报文然后进入正常处理流程也就是上报给TCP。

不过在现实场景里,IP分片是需要尽量避免的原因有很多主要是因为互联网是一个松散的架构这就导致路径中的各个环节未必会完全遵照所有的约定。比如你发出了大于PMTU的报文寄希望于MTU较小的那个网络环节为你做分片但事实上它可能不做分片而是直接丢弃比如下面两种情况

  • 它考虑到开销等问题,未必做分片,所以直接丢弃。
  • 如果你的报文有DF标志位那么也是直接丢弃。

即使它帮你做了分片,但因为开销比较大,增加的时延对性能也是一个不利因素。

另外一个原因是分片后TCP报文头部只在第一个IP分片中后续分片不带TCP头部那么防火墙就不知道后面这几个报文用的传输层协议是什么可能判断为有害报文而丢弃。

总之为了避免这些麻烦我们还是不要开启IP分片功能。事实上Linux默认的配置就是发出的IP报文都设置了DF位就是明确告诉每个三层设备“不要对我的报文做分片如果超出了你的MTU那就直接丢弃好过你慢腾腾地做分片反而降低了网络性能”。

小结

这节课我们通过拆解一个典型的MTU引发的传输问题学习了MTU和MSS、分段和分片、各种卸载offload机制等概念。这里我帮你再提炼几个要点

  • 在案例分析的过程中我们解读了Wireshark里的信息特别是两次DupAck和两次重传推导出了问题的根因。这里你需要了解 200ms超时重传这个知识点,这在平时排查重传问题时也经常用到。
  • 借助 Wireshark的Flow graph,我们可以更加清晰地看到两端报文的流动过程,这对我们推导问题提供了便利。
  • 如果能稳定重现成功和失败这两种不同场景,那就对我们排查工作提供了极大的便利。我们通过对比成功和失败两种场景下的不同的抓包文件,能比较快地定位到问题根因。
  • 如果排查中遇到有“整数值”出现,可以重点查一下,一般这跟人为的设置有关系,也有可能就是根因,或者与根因有关。
  • 如果你对网络中间环节包括LB、网关、防火墙等有权限又不想改动两端机器的MTU那么可以选择在中间环节实施“暗箱操作”也就是用iptables规则改动双方的MSS从而间接地达到“双方不发送超过MTU的报文”的目的。
  • 我们也学习了如何用ethtool工具查看offload相关特性包括TSO、LRO、GRO等等。同样通过ethtool我们还可以对这些特性进行启用或者禁用这为我们的排查和调优工作提供了更大的余地。

思考题

最后再给你留两道思考题:

  • 在LB或者网关上修改MSS虽然可以减小MSS从而达到让通信成功这个目的但是这个方案有没有什么劣势或者不足也同样需要我们认真考量呢可以从运维和可用性的角度来思考。
  • 你有没有遇到过MTU引发的问题呢欢迎你分享到留言区一同交流。

附录

抓包示例文件:https://gitee.com/steelvictor/network-analysis/tree/master/08