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.

248 lines
22 KiB
Markdown

2 years ago
# 15 | Nginx的499状态码是怎么回事
你好,我是胜辉。
“实战一TCP真实案例解密篇”刚刚结束。在过去的十几讲里我们全面回顾了TCP的各种技术细节从握手到挥手从重传等容错机制到传输速度等效率机制应该说也是对我们的TCP知识做了一个全面的“体检”。如果你发现自己对TCP的掌握还有不少漏洞也别着急可以回头复习一下相应部分的内容或者在留言区提问我会给你解答。
从这节课开始我们要进入网络排查的“实战二应用层真实案例解密篇”了。今天要给你讲解的是一个关于Nginx的排查案例。
## Nginx的499状态码是怎么回事
你肯定听说过Nginx或者经常用到它。作为一个高性能的HTTP和反向代理服务器Nginx不管是用来搭建Web Server还是用作负载均衡都很合适并且它可供配置的日志字段也很丰富从各类HTTP头部到内部的性能数据都有。
不过你在日常维护Nginx时有没有遇到过这种情况**在Nginx的访问日志中存在499状态码的日志。**但是常见的4xx家族的状态码只有400、401、403、404等这个499并未在HTTP的RFC文档中定义是不是很奇怪
这个499错误日志在流量较大的场景下特别是面向Internet的Web站点场景下还是很常见的 。但如果你遇到过第一感觉可能会是一头雾水不知道499这个状态码具体是用来干啥的因为确实跟其他的400系列状态码太不同了。
我在公有云的时候做过的一个案例正好是关于Nginx的499日志。当时一位客户向我反馈他们的Nginx服务器会连续几天记录较多的499错误日志之后几天可能趋零然后再回升整体状况起伏不定。
这个客户经营的是To C的电子产品跟手机端App协同工作。这个App会定时把消息上传到微信消息网关后者再把这些消息推送到该客户的服务端在公有云上做业务处理整体的消息量约每日三十万条。那么对消息网关来说这个服务端就是一个Web回调接口。下面是架构简图
![](https://static001.geekbang.org/resource/image/93/46/93ea615940b140d18e2c3087e0545346.jpg?wh=1697x809)
他们给我提供了499日志趋势图
![图片](https://static001.geekbang.org/resource/image/4d/71/4d99d187880ecaa09e454526e8a9b371.png?wh=1405x368)
由于大量499日志的存在客户非常担心业务已经受到影响比如他们的终端消费者是否经常上传数据失败是否已经严重影响了消费者的体验所以我们需要搞清楚499错误日志的含义。
那么499这个状态码本身能帮到我们什么呢我们可以查一下它在Nginx里的[官方定义](https://www.Nginx.com/resources/wiki/extending/api/http)
> NGX\_HTTP\_CLIENT\_CLOSED\_REQUEST | 499
可是什么叫client closed request客户端关闭了请求好像说了跟没说也没太大区别。我们知道499是客户端关闭请求引起的那又是什么原因引起了“客户端关闭了请求”呢关于这个问题Nginx的文档并没有提及。
有一句话叫做“解决问题的办法,可能不在问题自身所处的这个层面”。**应用层日志,其实记录的依然是表象。**更深层次的原因,很可能在更底层,比如在传输层或者网络层。
所以搞清楚499这个状态码是什么意思对于我们来说不仅是理解这个499码的底层含义而且通过这种排查我们还能掌握一套**对HTTP返回码进行网络分析的方法**。这种方法对于维护好Nginx以及其他Web服务都是很有帮助的。
那么接下来我们就根据这个案例一起探讨下如何用抓包分析来拆解HTTP返回码的真正含义。
## **锚定到网络层**
如前面所说,我是选择用**抓包分析**这个方法来展开排查的。之所以采用这个方法是因为我前面也说过从软件文档已经无法查清楚问题根因了所以需要下沉到网络层排查。如果你在处理应用层故障比如HTTP异常返回码4xx和5xx系列场景中也遇到了在应用层找不到答案的情况你就可以考虑采用抓包分析的方法。
> 补充:下文中的“客户端”都指微信消息网关,“服务端”指这个客户在公有云的服务器。
这样,我在**服务端**使用tcpdump工具做了抓包然后用Wireshark打开抓包文件展开分析。从抓包文件中我一般会寻找一些比较可疑的报文。正好这次抓包里有不少RST报文于是我过滤出了一个典型的带RST报文的TCP流请看下图
![](https://static001.geekbang.org/resource/image/63/8c/63546e3e94690a685f9291eyy837318c.jpg?wh=2418x446)
> 补充:抓包示例文件已经上传至[Gitee](https://gitee.com/steelvictor/network-analysis/tree/master/15)建议用Wireshark打开文件结合文稿一起学习。
相信你也一眼就看到了那个结尾处的RST。但问题是**这个TCP流一定跟499日志有关系吗**
得益于TCP/IP的精妙的分层设计应用层只需要通过系统调用就可以像使用文件IO那样使用网络IO具体的网络细节都由内核处理了。可是由此也带来了一个问题**以应用层的视角,是无法“看到”具体的网络报文的**。
![](https://static001.geekbang.org/resource/image/ba/a5/ba258d0aa729da8dc7d35a746cec55a5.jpg?wh=1893x831)
我们需要根据一些关键信息来确定应用层日志跟网络报文的对应关系。比如在这里我可以确认上面这个带有RST的TCP流就是日志中记录的一条499日志记录。这是如何做到的呢
就是因为以下三点。
* **客户端IP**日志中的remote IP跟抓包文件里面的IP符合。
* **时间戳**日志的时间戳也跟这个TCP流的时间吻合。
* **应用层请求**日志里的HTTP URL路径和这个TCP流里的URL相同。
> 补充:如果你对[第4讲](https://time.geekbang.org/column/article/480068)有印象,应该记得当时也是用类似的方式找到了应用日志跟报文的对应关系。
实际上在真实的抓包分析场景中“如何把应用层问题跟网络层抓包关联起来”始终是一个关键环节。同时这也是比较令人困扰的关键技术障碍很多人就是在这一关前败下阵来导致没有办法真正彻底地查到根因。所以这里的方法可以作为给你的参考当你以后再处理这种关键环节的时候也可以根据上面提到的三个维度的信息即IP、时间戳、应用层请求包括URL和header来达到“把应用层问题锚定到网络层数据包”的目的。
既然确定这个流就是代表了一次499事件那么我们就需要好好分析一下这些报文里面的文章了。
## TCP流的解读
这里你可以先注意一下我在下图的这个TCP流示意图中标记出的红框部分在后续的分析过程中我会重点分析这几个部分。
![](https://static001.geekbang.org/resource/image/e3/93/e32e1ef5e9006c7ef650d7a60930d193.jpg?wh=2230x452)
首先是报文1~3表示TCP握手成功。
然后是报文4客户端发出表示客户端消息网关向服务器发送报文这个报文里只包含HTTP header其声明该请求为POST方法但不含POST body。这其实是正常的因为HTTP协议就是这样规定的数据的先后顺序是先header包含method、URL、headers后body。所以既然方法method和URL单独位于一个报文里面了那么按顺序来说body就是在后续的报文里面。
接下来是报文5服务端发出它是一个确认报文。它的意思是服务端确认收到了你客户端发过来的报文4。
紧接着是报文6客户端发出此时距离上一个报文的时间是2秒。这个报文被Wireshark标记为了红色注释为TCP Previous segment not captured意思是它之前的TCP报文段没有被抓到。
什么叫做“之前的TCP报文”呢其实就是按TCP序列号顺序排在当前报文之前的报文。我对这个6号报文标注了3处红框它们都有很重要的含义。这里我们先关注下右边一个红框圈出来的FIN标志位这说明**这是一个客户端主动关闭连接的报文。**
我们可以把到目前为止的报文情况,用下面这个示意图来表示:
![](https://static001.geekbang.org/resource/image/54/85/54b3cf96a80e2ac2638840c226ea7285.jpg?wh=2000x1125)
你看这里是不是很奇怪明明HTTP POST请求的body也称为HTTP载荷部分还没发过来这个客户端就嚷嚷着要关闭连接了这就好比有个朋友跟你说“我有个事情要你帮忙拜拜~”,你刚听到上半句他的求助意向,还没听到这个忙具体是什么,他就跟你说再见了。惊不惊喜,意不意外?你可能暂时看不出这里究竟出了什么问题,不过没关系,先放一放。
我们继续看报文7服务端发出。服务器收到了FIN+ACK报文6号报文但发现序列号并不是它期待的309而是777于是服务器TCP协议栈判断有一个长度为777-309=468的TCP段TCP segment丢失了。
按TCP的约定这时候服务端只可以确认其收到的字节的最后位置在这里就是上一次报文5的ACK位置。形式上报文7就成了一个DupAck重复确认
当客户端收到DupAck的时候它就需要长一个心眼了“情况有点微妙如果凑满3个DupAck可能有丢包啊”。
> 补充如果凑满3个DupAck就重传的机制被称为快速重传机制我们在[第12讲](https://time.geekbang.org/column/article/487082)我们有深入学习过。
为了帮助理解这里我再展示下报文4的TCP信息
![](https://static001.geekbang.org/resource/image/b2/83/b253c16c2b861402a215fec7d2598883.jpg?wh=1406x396)
那么按TCP的设计客户端将要发送的下一个报文的序列号309= 本次序列号1 + 本次数据长度308也就是图中的Next sequence number。
我们再来看报文8客户端发出过了16秒之久客户端**重传**了这个报文包含POST body的数据长度为 **468** 字节。你看这是不是就跟前面说的777-309=468对应起来了。
可能你在这里又有点困惑了明明这个468字节的报文是第一次出现怎么就算重传了呢
其实是因为这个抓包文件是在服务端生成的所以它的视角是无法看到多次传送同样这个报文的现象的。但我判断在客户端抓包的话一定可以看到这个468字节的报文被试图传送了多次。
![](https://static001.geekbang.org/resource/image/1e/c9/1ed4b43aba2eb399a8137d0a6f9869c9.jpg?wh=1639x722)
我们就以服务端视角来判断一开始这个报文应该是走丢了没有达到服务端所以没有在这个服务端抓包文件里现身。又因为过了16秒之久才到达很可能不是单纯一次重传而是多次重传后才最终到达的。因此从这一点上讲确实属于重传。
我们继续分析。接下来就是报文9服务端对这个POST body的数据包回复了确认报文。
最后是报文10服务端发送了HTTP 400的响应报文给消息网关。这个信息并没有被Wireshark直接按HTTP格式进行展示但是因为HTTP是文本编码的所以我们可以鼠标选中Transmission Control Protcol部分在底下的文本栏直接看到HTTP 400这段文本
![](https://static001.geekbang.org/resource/image/ae/2a/aeed98d07a4cbaeb97a202877e33cc2a.jpg?wh=1656x850)
有趣的是,这个 **HTTP 400报文也是带FIN标志位的**也就是服务端操作系统“图省事”了把应用层的应答数据HTTP 400跟操作系统对TCP连接关闭的控制报文这个FIN合并在同一个报文里面了。也就是我们在[第3讲](https://time.geekbang.org/column/article/479163)提到的搭顺风车Piggybacking提升了网络利用效率。
这个阶段的报文图示如下:
![](https://static001.geekbang.org/resource/image/37/e2/37c17a924795069688bed6b9d69838e2.jpg?wh=1523x739)
那么,从这些报文的顺序来看,我们会发现它确实是有问题的。特别是有以下几个疑点:
* 服务端先收到了HTTP header报文随后并没有收到期望的HTTP body报文而是收到了FIN报文即客户端试图关闭连接。这个行为十分古怪要知道HTTP请求还没发送到服务端服务端回复HTTP响应更是无从谈起这个时候客户端发送FIN就不符合常理了即前面说的朋友求帮忙的类比
* 服务端回复了HTTP 400并且也发送FIN关闭了这个连接。
* 客户端回复RST彻底关闭这个连接。
而把上面这几条信息综合起来看,你有没有发现一个重要的线索?**客户端先发送了FIN之后才发送POST body。**现在让我们把全部过程拼接起来,看一下全景图:
![](https://static001.geekbang.org/resource/image/a4/17/a45e1cf729d62b8e7a4ce6258e3fe817.jpg?wh=2000x1125)
这么古怪的行为,可以描述为“**服务端还没回复数据而客户端已经要关闭连接**”。按照499的官方定义这种行为就被Nginx判定为了499状态。对内表现为记录499日志对外表现为回复HTTP 400给消息网关。
所以在服务端的Nginx日志中就留下了大量的499日志条目而在消息网关那头如果它也做Web日志的话相信就不是499日志而是400的报错了。
那么到这里,问题是水落石出了吗?其实不是。
## **从现象到本质**
我们还需要搞清楚最底层的疑问为什么客户端先发送FIN然后才发送POST body
我们回到Wireshark窗口再次关注下6号报文
![](https://static001.geekbang.org/resource/image/d2/01/d2ee60e008e21e9b971ac7894f915601.jpg?wh=1712x84)
它离上一个报文相差了2秒而我们知道这个信息是因为Wireshark很友好地显示了报文之间的间隔时长。
我们再往前看4号报文
![](https://static001.geekbang.org/resource/image/8f/29/8f79f673e07621c9a8dyycyy151c1929.jpg?wh=1568x82)
离3号报文相差了2.997秒几乎就是3秒整了。那么加起来6号报文离TCP握手完成正好隔了 **5秒整**
一般出现这种整数,就越发可疑了,因为如果是系统或者网络的错乱导致的行为,其时间分布上应该是**随机的**,不可能卡在整数时间上。就我的经验来看,**这往往跟某种人为的设置有关系**。
所以经过我的提醒客户自己仔细查看了微信网关的使用文档果然发现了它确实有5秒超时的设置。也就是说如果一个HTTP事务在这个例子里是HTTP POST事务无法在5秒内完成就关闭这个连接。
![](https://static001.geekbang.org/resource/image/1d/80/1d35834f2da80aa64da94b0edca08980.jpg?wh=1659x711)
这个“无法完成”在这个抓包里面体现为HTTP header报文发过去了但HTTP body报文没有一起过去网络原因导致。而由于初始阶段报文少**无法凑齐3个DupAck**,所以快速重传没有被启动,只好依赖超时重传(关于超时重传的知识在[第12讲](https://time.geekbang.org/column/article/487082)也有详细的介绍而且这多次超时重传也失败了服务端只好持续等待这个丢失的报文。5秒钟过后客户端微信消息网关没有收到服务端的响应就主动关闭了这次连接可以下次再试这次就不继续干等了
也就是说这个场景里的Nginx 499错误日志的产生主要是由于两个因素造成的
* **“消息网关—>服务器”方向上的一个TCP包丢失案例里是HTTP POST body报文引起服务端空闲等待**
* **消息网关有一个5秒超时的设置即连接达到5秒时消息网关就发送FIN关闭连接。**
所以到这里,想必你也明白了这里的逻辑链条,也就是:
* 要解决499报错的问题就需要解决5秒超时的问题
* 要解决5秒超时的问题就需要解决丢包问题
* 要解决丢包的问题,就需要改善网络链路质量。
最根本的解决方案,就是如何确保客户端到服务端的**网络连接**可靠稳定使得类似的报文延迟的现象降到最低。只要不丢包不延迟HTTP事务就能在5秒内完成消息网关就不会启动5秒超时断开连接的机制。
这样,我们跟客户还有网关的工程师一起配合,确实发现网关到我们公有云的一条链路有问题。更换为另外一条链路后,丢包率大幅降低,问题得到了极大改善。虽然还是有极小比例的错误日志(大约万分之一),但是这对于客户来说,完全在可接受范围之内了。
另外因为丢包的存在客户端的FIN报文跟HTTP POST body报文一样也可能会丢失。不过无论这个FIN是否被服务端及时收到这次HTTP事务本身也已经在客户端被记为失败了也就是不改变这件事的结果。
你可能会问了:链路丢包这种问题应该挺明显的,为什么没有在第一时间发现呢?
这其实是多种因素导致的:
* 我们虽然对主要链路的整体状况有细致的监控,但这里的网关到客户的公有云服务属于“点到点”的链接,本身也属于客户自身的业务,公有云难以对这种情况做监控,理想情况是客户自己来实现监控。
* 客户的消息量很大,哪怕整体失败比例不高,但乘以绝对的消息量,产生的错误的绝对数也就比较可观了。
至于Nginx为什么要“创造”499这个独有的状态码的原因其实在 [Nginx源码](https://github.com/Nginx/Nginx/blob/a6cb8210905f35977276cb3861184e4dad99cc2a/src/http/ngx_http_request.h)的注释部分里已经写得非常清楚了。它并非标新立异而确实是为了弥补标准HTTP协议的不足。相关代码如下
```
/*
* HTTP does not define the code for the case when a client closed
* the connection while we are processing its request so we introduce
* own code to log such situation when a client has closed the connection
* before we even try to send the HTTP header to it
*/
#define NGX_HTTP_CLIENT_CLOSED_REQUEST 499
```
翻译过来就是HTTP并没有对服务端还在处理请求的时候客户端就关闭连接的情况做一个状态码的定义。所以我们定义了自己的状态码499以记录这种“还没来得及发送返回客户端就关闭了连接”的情形。
## 小结
现在我们就清楚在这个例子里造成499状态码的根因了。不过基于普适性的应用需求我想把这个案例再延伸拓展一下希望可以帮助你了解到更多的知识并且在理解了这些知识点之后你能够有效应用在类似的HTTP异常码的故障排查里。
首先,我们要知道,**Nginx 499是Nginx自身定义的状态码并非任何RFC中定义的HTTP状态码**。它表示的是“Nginx收到完整的HTTP request前或者已经接收到完整的request但还没来得及发送HTTP response前客户端试图关闭TCP连接”这种反常情况。
第二,**超时时间跟499报错数量也有直接关系**。如果我们有办法延长消息网关的超时时间比如从5秒改为50秒那么客户端就有比较充足的时间去等待丢失的报文被成功重传从而在50秒内完成HTTP事务499日志也会少很多。
第三,**我们要关注网络延迟对通信的影响**。比如客户端发出的两个报文报文3和报文4间隔了3秒钟这在网络通信中是个非常大的延迟。而造成这么大延迟的原因会有两种可能一是消息网关端本身是在握手后隔了3秒才发送了这个报文属于**应用层问题**二是消息网关在握手后立刻发送了这个报文但在公网上丢失了微信消息网关就根据“超时重传”的机制重新发了这个报文并于3秒后到达。这属于**网络链路问题**。
由于上面的抓包是在服务端做的,所以未到达服务器的包自然也不可能抓到,也就是无法确定是具体哪一种原因(客户端应用层问题或网络链路问题)导致,但这并不影响结论。
最后一点,就是我们要清楚,**公网上丢包现象不可能完全消失**。千分之一左右的公网丢包率属于正常范围。由于客户发送量比较大这是主要原因加上微信消息网关设置的5秒超时相对比较短这是次要原因这两个因素一结合问题就会在这个案例中被集中暴露出来。
那么像上面第二点说的那样设置更长的超时阈值比如50秒能解决问题吗相信出错率会降低不少但是这样新的问题也来了
* 消息网关会有更多的资源消耗内存、TCP源端口、计算能力等
* 消息网关处理事务的平均耗时会增加。
所以选择5秒应该是一个做过权衡后的适中的方案。
而从排查的方法论上来说,对于更广泛的应用层报错日志的排查,我的推荐是这样的:
* **首先查看应用文档,初步确定问题性质,大体确定排查方向。**
* **通过对比应用日志和抓取的报文,在传输层和网络层寻找可疑报文。**在这一步,可以采用以下的比对策略来找到可疑报文:
* 日志中的IP跟报文中的IP对应
* 日志和报文的时间戳对应;
* 应用层请求信息和报文信息对应。
* **结合协议规范和报文现象,推导出根因。**
## **思考题**
给你留两个思考题,欢迎在留言区分享你的答案和思考,我们一起交流讨论。
* 第7个报文是DupAck为什么没有触发快速重传呢
* 消息网关那头的应用日志应该不是499那会是什么样的日志呢
欢迎你把今天的内容分享给更多的朋友,我们一起成长。