gitbook/网络排查案例课/docs/482610.md
2022-09-03 22:05:03 +08:00

27 KiB
Raw Blame History

07 | 保活机制:心跳包异常导致应用重启?

你好我是胜辉。这节课咱们来聊聊TCP的保活机制。

以前的电视剧里经常会有这样的剧情:女主因为车祸失去了记忆,男主一边摇着女主的肩膀,一边痛苦地问道:“还记得我吗?我是欧巴啊!”可是女主已经对此毫无记忆,迷茫地反问道:“欧巴是谁?”

类似地TCP其实也需要一种机制让双方能保持这种“记忆”。Keep-alive这个词你可能也听说过。特别是当遇到一些连接方面的报错的时候可能有人会告诉你“嗯你需要设置下Keep-alive”然后问题确实解决了。

不过,你有没有深入思考过这样几个问题呢:

  • Keep-alive跟长连接是什么关系
  • 它是应用层代码独立实现还是依赖操作系统的TCP协议栈去实现
  • 在HTTP层面也有一个Keep-alive的概念它跟TCP的Keep-alive是同一个东西吗

如果你对这几个问题的答案还不清楚那么这节课我就来帮助你厘清这些概念。以后你再遇到长连接失效、被重置、异常关闭等问题的时候就知道如何通过抓包分析解读出心跳包相关的信息然后运用Keep-alive的相关知识点去真正解决前面说的一系列问题。

好,按惯例,我们还是从案例说起。

TCP长连接为何总中断

当时我在云计算公司就职有个客户的应用基于TCP长连接但长连接经常中断引起了应用方面的大量报错。由于客户的业务是支付相关的对实时性和安全性要求很高这类报错就产生了较大的负面影响所以急需解决。

应用概况

这个应用的架构比较简单客户在云平台上部署了多台云主机其中一台云主机专门做加解密称之为加密服务器。另外几台云主机作为这台加密服务器的客户端跟这台加密服务器保持TCP长连接。这些客户端会不时地跟加密服务器进行通信完成加密操作。每45秒客户端还会发送一次心跳包这有两个作用

  • 维持这个长连接不被中断,即心跳保活,让长连接在两端保持下去;
  • 探测加密服务器的可用性,即健康检查,一旦服务不可用,客户端就要去掉这个失效的连接。

就像下图这样:

如果加密服务器能在1秒内对心跳包进行回复那么客户端就认为服务端正常可用后续的数据交互即加密请求将继续在这条长连接上进行。而如果服务端未能在1秒内回复那么客户端会认为该长连接已经中断于是重启应用发起一条新的长连接并在日志中记录一次报错。

用类Python语法来描述大体是这样的

while true:
    sleep(45)
    if Keep_alive_probe() is true:
        continue
    else:
        restart()
        log_error()

排查思路

整体的排查思路跟网络分层模型有点类似,逐层往下。我们需要先从应用层查找原因,然后是操作系统层面,最后是网络层分析。排查的顺序就是这样的:

应用层代码 -> 操作系统的时间配置 -> 网络的抓包分析

首先客户自查了应用没有发现可疑代码并且尝试过重新部署代码、重装系统、更换IP等但都未奏效。

那么会不会是时间服务ntp的问题呢我们检查了两侧机器的ntpd服务的状态都是正常的。对比了两端机器的实际时间也没有发现明显的误差。于是这个可能性也被排除了。

然后就需要排查网络了。我们在一台客户端上启动了抓包程序tcpdump过滤条件是对端加密服务器的IP和端口。抓多久呢因为问题大约十几分钟出现一次那么按照这个频率我们设定抓包时长为半个小时得到了抓包文件。这样就能覆盖到一到两次的错误了。

补充:抓包示例文件已经上传至Gitee建议用Wireshark打开文件结合文稿学习。

因此,通过检查应用日志,我们发现在抓包时间段内,应用日志又记下了两次报错,比如下面这个:

图片

从日志上看17:08:02这个时间点发生了一次报错17:08:10发生了一次程序的重启。

有了应用层的信息,接下来,我们就需要在抓包文件中,找到传输层和网络层的信息来与应用层信息对应,这样排查工作才能继续推进。

但是日志记录的时间戳粒度太粗了只精确到了秒级而网络报文在Wireshark中都是微秒级的一秒内可能会有成百上千个报文。所以仅凭精确到秒的一个时间戳还不足以让我们在几十个报文中精确地找到对应的报文。这个时候我们需要调整一下抓包文件分析工作的切入点。

一个常规的做法是,跳过案例本身的问题特殊性不谈,先从宏观上把握一下排查思路。也就是先不纠结于根据日志时间来寻找报文的难题而是先看Expert Information找一找有没有比较可疑的报文。打开Expert Information窗口如下

图片

可见有80个Error级别的Malformed Packet格式错误报文和2个RST报文都很可疑。

我们先看这80个Malformed Packet报文这传递了什么信息呢在Protocol栏显示SIGCOMP说明Wireshark认为这80个报文是SIGCOMP协议的SIP的信令压缩协议。不过客户的应用不是SIP相关的显然这些报文应该是被Wiresharek误会了。

在这里,我也给你一个小小的提醒毕竟只有被公开广泛支持的数据格式和协议才能在Wireshark中正确展示所以如果你看到类似这种Malformed Packet的时候还是要统筹考虑当前案例的实际情况未必Malformed报文就真的是格式错误也可能只是Wireshark不了解某种“方言”而已。

让我们看看这些报文究竟是什么。点开Error选中一个报文。比如我这里选择37号报文

图片

随后光标会自动定位到37号报文这里

图片

然后选了另外几个Malformed Packet发现它们都是成对出现的。它们还有一个特征是

  • 前一个报文的TCP载荷数据是01十六进制
  • 后一个报文的TCP载荷数据是41十六进制

这就像是某种“联动”机制了会不会就是心跳报文呢你注意下TCP Seglen这一栏这是我添加的表示TCP载荷长度的列有没有发现这类报文的长度都是1 ?我们就以 tcp.len eq 1 为过滤器,过滤出报文:

图片

可见37和38是0秒发生的76和77是45秒后发生的然后98和154以及后面每一对报文也都符合45秒间隔的规律正好跟客户程序的45秒心跳包机制对上了。我们可以确认这些报文就是心跳报文了!更确切地说,每次成对出现的两个报文中:

  • 前一个是心跳探测包它的TCP载荷数据为01十六进制
  • 后一个是心跳回复包它的TCP载荷数据为41十六进制

**这是第一个比较明显的进展。**也就是说,我们已经可以找到应用层的心跳包在网络层的展示形式了,这对于后续的排查非常有帮助。

不过,也不要忘了,我们是来排查“心跳包失败导致连接中断”的问题的,现在只是找到了心跳包的特征,还需要找到真正的跟日志报错相关的报文,而这个报文是跟“连接中断”现象有关的。

那么会是什么样的报文呢其实我们在第4讲就研究过TCP挥手。你应该还记得TCP的连接断开无非跟两种报文有关FIN和RST

我们先尝试寻找FIN报文。输入过滤器

tcp.flags.fin eq 1

结果发现什么报文都没有出来。可见这次抓包里连接的断开并不是用FIN完成的。

图片

接着就是寻找RST报文。其实在Expert Information里面就有2个RST报文我们可以直接从那里入手。当然用过滤器也同样方便输入

tcp.flags.reset eq 1

我们能找到2个RST报文

图片

我们选中575号报文然后Follow -> TCP Stream得到了这个RST所在的TCP流的全部报文

图片

报文很多上图显示的只是一部分报文。我往前翻阅了更前面的报文也能看到很多相对长的时间间隔有17秒、39秒、11秒的但也没有什么规律。这时候我们就需要结合前面刚分析到的一些信息要不然就要在报文的海洋里迷失方向了。

我们通过前面的分析发现心跳探测包的报文数据是01心跳回复包的报文数据是41。所以让我们再次借助强大的过滤器。在 tcp.stream eq 0 后面添加 and tcp.payload eq 01,即整体过滤器变为:

tcp.stream eq 0 and tcp.payload eq 01

这样就过滤出了这个TCP流里面所有的心跳探测包

图片

可见这些心跳探测包也是很明显遵循了45秒间隔的规律。不过等一下为什么572号心跳探测包跟它的上一次心跳探测间隔的时间是58秒而不是45秒呢

图片

**这很反常,也是第二个很重要的发现。**要知道这45秒的机制是在代码里实现的照理说不可能出现13秒这么大的误差。现在我们把 tcp.payload eq 01 这个条件去掉回到这个TCP流的完整报文区域来综合分析

图片

我来给你解读一下:

  • 572号报文客户端发出是心跳探测包
  • 573号报文服务端发出是心跳回复包
  • 574号报文客户端发出是客户端对573号报文的确认
  • 575号报文客户端发出是一个RST报文它跟客户端日志报错有直接的关系。

以上4个报文之间几乎没有时间上的停顿。这里你可能会有个小疑问为什么572号报文跟上一个报文的间隔是17秒而不是45秒呢其实这是Time列的类型导致的我这里用的是Delta time displayed类型是跟前一个被显示的报文的间隔时间。

之前我们看到很多个整齐的45秒是因为那个窗口里显示的报文都是过滤过的心跳报文跟这里显示的报文不同所以显示的间隔时间也不同。Wireshark里的Time一列有多种配置可选所以你一方面要理解这里面各种Time的区别一方面自己做分析的时候也可以根据实际需要灵活选择Time类型。

图片

考虑到这里的问题就是跟心跳包和挥手包直接相关,排除掉无关报文,可以让我们的分析思路也变得更加清晰。所以,我们再一次调整过滤器,改为:

tcp.stream eq 0 and (tcp.len == 1 or tcp.flags.reset == 1)

就得到这个TCP流里面的心跳包和挥手包

图片

我们最后再确认一下时间戳。这个隔了58秒才发出的心跳包发送的时间是在17:08:10

图片

跟日志中restart时间一致

图片

现在事实已经很清楚了客户端在连接被断开之前的心跳探测包并没有遵循客户声称的“每隔45秒”而是很意外地隔了58秒。我们结合程序逻辑已经可以推断出真实的状况了。看示意图

结论:排除网络,追查代码

现在我们已经清楚了这个报错的原因客户端程序出了一个Bug某一次心跳探测包发出的时候是在上一次心跳的58秒以后也就是相对正常情况迟了13秒。那么服务端虽然立即发送了心跳回复包但也一样是在58秒以后了。程序的逻辑非常简单粗暴一旦心跳回复包的到达时间超过了第46秒也就是45秒+1秒超时就认为是心跳探测失败。

**为什么探测包本身会比预定的时间晚了13秒才发出呢**根据这个很明确的信息,客户再次检查了应用代码,终于定位到了出问题的代码段。修复代码后,问题随之解决。

补充一下,在这个案例中,异常时的心跳包探测和回复的耗时本身是正常的。我们可以看到包号572是客户端发出的心跳探测包包号573是服务端发出的心跳回复包。两者之间间隔只有0.000370秒即0.37毫秒,完全是同机房内网时延的正常水平。

理解心跳机制的原理

显然,这个案例是关于应用层自己实现的心跳机制的,有一定的特殊性。但在机制上,也体现了心跳包的一些特点,比如:

  • 定时发送心跳探测包;
  • 对于心跳回复包有超时限制。

而从更普适的尺度上来看其实TCP本身提供的Keep-alive机制更为安全易用。

一般来说,对于操作系统已经实现的特性,我们最好直接去利用,而不是自己创造一个类似的轮子。这好比你想基于UDP在应用层实现类似TCP的种种传输保障机制也不是不可以但实现起来会相当复杂参考QUIC协议。TCP心跳机制看似简单但从上面这个案例来看稍有不慎还是很容易发生错误的。

TCP Keep-alive

那么TCP自身的Keep-alive究竟是怎样的一个存在呢

其实如果不做显式的配置默认创建出来的TCP Socket是不启用Keep-alive的也就是都不会发送心跳包。不过大部分应用程序已经在代码里启用了Keep-alive所以你平时不太会遇到连接失效的问题。比如我稍后要演示的一个含心跳包的抓包文件抓取的就是Chrome浏览器的流量里面就有很多心跳包因为Chrome浏览器启用了TCP心跳保活机制。

要打开这个TCP Keep-alive特性你需要使用setsockopt()系统调用对已经创建的Socket进行配置启用Keep-alive。具体的调用方法你可以参考man setsockopt

在Linux操作系统层级也有三个跟Keep-alive有关的全局配置项。

  • 间隔时间net.ipv4.tcp_keepalive_time其值默认为7200也就是2个小时。
  • 最大探测次数net.ipv4.tcp_keepalive_probes在探测无响应的情况下可以发送的最多连续探测次数其默认值为9
  • 最长间隔net.ipv4.tcp_keepalive_intvl在探测无响应的情况下连续探测之间的最长间隔其值默认为75

补充你可以在Linux系统里面执行man tcp查看内核对TCP协议栈的详细文档。这里我摘录一下关于Keep-alive的部分
 

  • tcp_keepalive_intvl (integer; default: 75; since Linux 2.4)
     
    The number of seconds between TCP keep-alive probes.
     
  • tcp_keepalive_probes (integer; default: 9; since Linux 2.2)
     
    The  maximum number of TCP keep-alive probes to send before giving up and killing the connection if no response is obtained from the other end.
     
  • tcp_keepalive_time (integer; default: 7200; since Linux 2.2)
     
    The number of seconds a connection needs to be idle before TCP  begins  sending  out  keep-alive probes.   Keep-alives are sent only when the SO_KEEPALIVE socket option is enabled.  The default value is 7200 seconds (2 hours).  An idle connection is terminated after approximately an  additional 11 minutes (9 probes an interval of 75 seconds apart) when keep-alive is enabled.

如果我们连接启用了Keep-alive但没有设定自定义的数值那么就会使用上面这些默认值当连接闲置没有数据交互达到7200秒2小时时发送心跳包每次心跳包超时时间为75秒最多重试9次。

这样的话对于一个已经失效的TCP连接最大需要7200+75*9=7875秒约等于2小时11分钟才能探测到。

毫无疑问这个时间是相当长的。不过结合时代背景这个其实也可以理解TCP Keep-alive被设计的时候是八十年代当时因特网还很初级所以设计者们并不想让心跳包占据太多的网络资源。从而就有了这么一个感知时间很长的心跳机制。关于TCP Keep-alive的一些更多信息在RFC1122里,你有兴趣的话可以去研究一下。

另外一个值得注意的地方是Keep-alive报文本身的特点。在上面的案例中这个特定的应用层代码设定的心跳包特征是这样的

  • 心跳探测包载荷为1个字节其值为01。
  • 心跳回复包载荷也为1个字节其值为41。

但是TCP本身提供的Keep-alive报文特征就非常不同了。首先它的序列号就很奇特是上一个报文的序列号减1,载荷为0。回复的报文也同样特别,确认号为收到的序列号加1。而且,无论是探测包还是回复包,其载荷长度都为0

文字描述不是很容易理解我给你看一个实际的例子。这是某一次我用Chrome浏览器访问网站时做的抓包

图片

上图的红色底色的多个报文就是Wireshark识别出来的TCP心跳包。我也把需要关注的信息用红色方框标注出来了。

25号报文是离心跳包最近的一个常规报文Wireshark告诉我们它的下一个序列号图中的NextSeq是1578。也就是说如果下一个是常规报文那么这个常规报文的序列号就是1578。然后看同是这个客户端发出的报文27这就是一个心跳包它的序列号却是1577也就是1578-1载荷为0Len=0。对端对这个心跳包做了回应包号28确认号为15781577+1载荷也为0。

我们再来看一下示意图:

要是你了解TCP握手和挥手阶段的确认号的话你对这个+1机制是不是感觉很熟悉可见TCP认为心跳包也是十分重要的它跟握手和挥手一样都属于控制报文它的确认号机制也体现了这一特点。

有趣的是RFC1122里并没有规定心跳探测包的载荷一定是0它也可以是1。只是从我有限的抓包经验来看心跳包都是载荷为0的看来这是比较常见的实现方式。

HTTP Keep-alive

那么我们也经常听到的HTTP Keep-alive又是一个什么东西呢难道是应用层实现的心跳保活机制意味着HTTP也有心跳包这种东西吗

其实没有那么复杂。**HTTP的Keep-alive是用Connection这样一个HTTP header来实现的。**你应该知道HTTP报文的header形式是Key: value。比如常见的header有

  • User-Agent: curl/7.68.0 (客户端发出)
  • Host: www.baidu.com (客户端发出)
  • Content-Type: text/html (服务端发出)
  • Server: bfe/1.0.8.18 (服务端发出)

而Keep-alive头部就是这个形式Connection: Keep-alive(注意这里有一个“-”符号)。

客户端和服务端都可以发送这个保活头部。表达的意思也跟外交人员的语言一样优雅专业:“我方真诚地希望,贵方能切实履行我们的协议,按照长连接待遇来处理本次连接,谢谢配合”。

你应该也知道HTTP的版本有0.9、1.0、1.1、2.0。HTTP/0.9现在基本不再使用了而HTTP/1.0占的流量比例也已经很低了在公网上小于1%。目前占主流的是HTTP/1.1和HTTP/2而这两者都默认使用长连接

那既然HTTP/1.1默认是长连接了为什么还要有这个Connection头部呢在我看来这有两个原因。

  • 协议兼容性

在HTTP/1.0版本中默认用的是短连接。这导致了一个明显的问题每次HTTP请求都需要创建一个新的TCP连接。随着因特网带宽和各类资源迅速增长每次建立TCP连接的开销变成了主要矛盾。

为了克服这个不足Connection: Keep-alive头部被扩充进了HTTP/1.0用来显式地声明这应该是一次长连接。当然从HTTP/1.1开始长连接已经是默认设置了但为了兼容1.0版本的HTTP请求Connection这个头部在HTTP/1.1里得到了保留。

  • 关闭连接的现实需求

即使在HTTP/1.1里也并非所有的长连接都需要永久维持。有时候任意一方想要关闭这个连接又想用一种“优雅”graceful的方式那么就可以用 Connection: Close 这个头部来达到“通知对端关闭连接”的目的体面而有效。另外在HTTP/2里连接关闭是通过另外的机制实现的与Connection头部无关。

这个Connection头部看似简单其实有时候也能起到很大的作用。在eBay我们每年有大量的流量迁移的工作。其中这个Connection头部就在迁移的平滑度和收敛速度上,起到了很关键的作用。

具体来说我们在新老两个VIP之间迁移流量用的是名称解析基于DNS/GSLB的返回值的比例来控制真实的HTTP流量比例这就可以逐步从新老比例为1:99到50:50最后到100:0也就是全部到新VIP。

但是这样会有这么个问题因为流量基本都是HTTP/1.1即默认是长连接客户端依然坚持使用着老的VIP造成DNS/GSLB的比值调整没有起到应有的效果真正观察到的流量经常跟DNS/GSLB的设置值相差甚远或者说有很强的滞后性

从上图中可以看到客户端在查询GSLB相当于智能DNS的时候拿到的新老IP的比例为2:2那么访问量应该也是符合这个比例。但是因为长连接的存在这些拥有长连接的客户端连问都不会去问GSLB而是会继续往老的连接也就是连着老VIP的连接上发送流量那么我们迁移流量的工作就受到影响了。

为了解决这个问题我们当然可以选择等待比如几小时甚至几天它自然收敛但其实我们找到了更好的办法在新老VIP上都添加了rewrite policy使得每一定比例的HTTP响应里面都带上Connection: Close这个头部。

客户端收到这个头部后按照协议规定它必须关闭这条长连接。在下一次HTTP请求的时候客户端就会遵循“DNS解析->发起新连接->发送HTTP请求”这样的工作路径于是新发起的连接数跟DNS解析数量基本对齐也就达到了我们的目的。

下图是在迁移尾声时候的流量趋势图。在这个阶段老VIP已经从DNS/GSLB里禁用了。但原先不插入这种Connection: Close头部的话总还是有很多请求在老的VIP上。在插入了Connection: Close头部后老VIP上的流量几乎立刻停止可谓立竿见影。

图片

小结

这节课我们通过一个奇特的案例详细探究了一种应用层保活机制的Bug引发的报错。在这个排查过程中Wireshark过滤器的使用,很大程度上帮助了这次排查。所以,这里我推荐你要熟悉以下这些过滤器,在你以后的网络排查工作中,应该能给到不少的帮助:

tcp.len eq 长度
tcp.flags.fin eq 1
tcp.flags.reset eq 1
tcp.payload eq 数据

抓包分析中面对Wireshark里千奇百怪的报文有时候也会遇到不知道从何下手的窘况那么你可以直接查看Expert Information,从那里寻找线索也不失为一个有效的办法。

另外在原理部分我还给你介绍了TCP层面的Keep-alive和HTTP层面的Keep-alive的联系和区别。应该说这确实是两个容易令人困惑的概念不仅名称一样作用也接近。通过这次讲解希望能帮助你彻底理解这两个概念。

最后,我再给你梳理提炼一下这节课的关键知识点。

首先对于TCP Keep-alive你需要掌握

  • 默认TCP连接并不启用Keep-alive若要打开的话要显式地调用setsockopt()来设置保活包的发送间隔、等待时间、重试个数等配置。在全局层面Linux还默认有3个跟Keep-alive相关的内核配置项可以调整tcp_Keepalive_timetcp_Keepalive_probes还有tcp_Keepalive_intvl。
  • TCP心跳包的特点是它的序列号是上一个包的序列号-1,而心跳回复包的确认号是这个序列号-1+1即还是这个序列号

然后对于HTTP Keep-alive的知识点你需要理解

  • HTTP/1.0默认是短连接HTTP/1.1和2默认是长连接。
  • Connection: Keep-alive 在HTTP/1.0里能起到维持长连接的作用而在HTTP/1.1里面没有这个作用(因为默认就是长连接)。
  • Connection: Close 在HTTP/1.1里可以起到优雅关闭连接的作用。这个头部在流量调度场景下也很有用能明显加快基于DNS/GSLB的流量调整的收敛速度。

思考题

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

  1. tcp.payload eq abc这个过滤器可以搜索到精确匹配“abc”字符串的报文。那么如果是模糊匹配比如只要**包含“abc”**的报文我都想搜到,这个过滤器又该如何写呢?
  2. 在工作中你遇到过跟TCP Keep-alive相关的问题吗你是怎么解决的呢

欢迎你把答案分享到留言区,我们一起进步成长。

附录

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