# 16 | 服务器为什么回复HTTP 400? 你好,我是胜辉。 在上节课里,我们回顾了一个与HTTP协议相关的Nginx 499的案例。在应用层的众多“明星”里,HTTP协议无疑是“顶流”了,可以说目前互联网上的大部分业务(电商、社交等),都是基于HTTP协议,当然也包括我们用极客时间学习的时候,也是在用HTTP。那么相应的,**HTTP方面的排查能力**,对于我们做开发和运维技术工作来说,就更加重要了。因为不少现实场景中的故障和难题,就与我们对HTTP的理解以及排查能力,有着密切的联系。 所以这一讲,我们会来看一个HTTP相关的报错案例,深入学习这其中的排查技巧。同时,我也会带你学习HTTP这个重要协议的规范部分。这样,以后你处理类似的像HTTP 4xx、5xx的报错,或者其他跟HTTP协议本身相关的问题时,就有分寸,知道问题大概的方向在哪里、如何开展排查了。 那么在介绍案例之前,我们先简单地回顾一下HTTP协议。 ## HTTP协议的前世今生 HTTP的英文全称是Hypertext Transfer Protocol,中文是超文本传输协议,它的奠基者是英国计算机科学家蒂姆·博纳斯·李(Tim Berners-Lee)。1990年,他为了解决任职的欧洲核子研究组织(CERN)里,科学家们无法方便地分享文件和信息的问题,由此创造了HTTP协议。 实际上,在当时也有其他一些协议能实现信息共享的功能,比如FTP、SMTP、NNTP等,为什么还要另外创造HTTP呢?这是因为这些协议并不满足博纳斯·李的需求,比如: * FTP只是用来传输和获取文件,它无法方便地展示文本和图片; * NNTP用来传输新闻,但不适合展示存档资料; * SMTP是邮件传输协议,缺乏目录结构。 而博纳斯·李需要的是“图形化的、只要点击一下就能进入到其他资料的系统”。鉴于以上协议无法实现,他就设计了HTTP。也因为这个巨大的贡献,博纳斯·李获得了2016年的图灵奖,可以说是图灵奖的一次“回国”。 在2015年之前,HTTP先后有0.9、1.0、1.1三个版本,其中HTTP/1.0和1.1合称HTTP/1.x。虽然谷歌在2009年就提出了SPDY,但最终被接纳成为HTTP/2,也已经是2015年的事了。最近几年蓬勃发展的还有HTTP/3(也就是QUIC上的HTTP/2)。**但从语义上说,HTTP/2跟HTTP/1.x是保持一致的。**HTTP/2不同,主要是在传输过程中,在TCP和HTTP之间,增加了一层传输方面的逻辑。 > 补充:[RFC7540](https://datatracker.ietf.org/doc/html/rfc7540)定义了HTTP/2的协议规范,而HTTP/1.1在1999年6月的[RFC2616](https://datatracker.ietf.org/doc/html/rfc2616)里已经确定了大部分内容。 什么叫做“语义上是一致的”呢?举个例子,在HTTP/2里面,header和body的定义和规则,就跟HTTP/1.x一样。比如 `User-agent: curl/7.68.0` 这样一个header,在HTTP/1.x里是代表了这次访问的客户端的名称和版本,而在HTTP/2里,依然是这个含义,没有任何变化。 从这一点上看,你甚至可以把HTTP/2理解为是在HTTP/1.x的语义的基础上,增加了一个介于TCP和HTTP之间的新的“传输层”。也就是下图这样: ![](https://static001.geekbang.org/resource/image/78/12/784ef6da887086ef500b955b90dc2512.jpg?wh=2000x700) 目前最新的HTTP/3仍在讨论过程中,还未正式发布。它也依然保持了之前版本HTTP的语义,但在传输层上做了彻底的“革命”:把传输层协议从TCP换成了 **UDP**。根据w3techs.com(一家网络技术调查网站)的[数据](https://w3techs.com/technologies/details/ce-http3),截至2022年2月22日,有25.2%的站点已经支持了HTTP/3。 好,回顾完HTTP的历史,我们已经比较清楚它的来龙去脉了。那么接下来要讲的案例,就会帮助我们拆解HTTP协议的一些细节,梳理对这种类型的问题的排查思路。 ## 案例:服务器为什么回复HTTP 400? 这是前几年我在公有云服务时候的一个案例。当时一个客户测试我们的对象存储服务,这个服务是通过HTTP协议存放和读取文件的。它比较适合存放非结构化的数据,比如日志文件、图片文件等。因为依托于HTTP协议,浏览这种存储的方法很方便,比如用浏览器就可以直接访问。 但是,在客户的测试结果中报告大量HTTP 400的报错。我们也很意外,其他客户用的都挺好,为什么这个客户就不行呢? ### 开始排查 按照惯例,我们还是进行了抓包。这次是在客户端抓取的,我们看一下Expert Information: ![图片](https://static001.geekbang.org/resource/image/cb/e7/cb1521098f852a9642462d4e0a36cee7.jpg?wh=1810x290) 其中,我们需要重点关注HTTP事务,也就是上图中的`Chat HTTP/1.1 200 OK\r\n`这部分,这里面都是HTTP事务的报文。由于第一个被Wireshark判定为HTTP事务的报文,是一个HTTP 200 OK的返回报文,所以就显示为这里的Summary栏的信息。 > 补充:这里我修改过抓包,所以展现在Expert Information里面的样子,跟正常抓取完整报文的情况略有不同。比如这个示例文件里,第一个HTTP报文其实是POST,那么Summary栏显示的,应该是POST请求而不是HTTP/1.1 200 OK。但是,这不影响排查和分析。 既然这次是明确要排查HTTP 400报错,所以我们直接点开这些HTTP事务: ![图片](https://static001.geekbang.org/resource/image/9e/b0/9eaa90c8d33c5b4f73b600ba4530a1b0.jpg?wh=1820x440) 可见,这里有200 OK这样的正常响应,也有400 Bad Request这样的异常响应。 我们找一个请求,Follow TCP Stream来看一下详细情况。比如,我们选中23号报文,此时主界面也自动跳转到了这个报文的位置。我们选中它,右单击后选择Follow -> TCP Stream: ![图片](https://static001.geekbang.org/resource/image/14/0d/14564a4652fcb3ea42639ede9e36920d.jpg?wh=1648x1230) 我们来看一下整个TCP流: ![图片](https://static001.geekbang.org/resource/image/61/5f/61fe4d82d3fc9e02137e8f3e572d0c5f.jpg?wh=1646x1238) 在Wireshark里,HTTP请求是红色字体,而HTTP响应是蓝色字体。显然,紧随在请求之后就是响应了,而蓝色字的第一行就是HTTP/1.1 400 Bad Request。这就是我们要排查的问题。 然后我们需要搞清楚问题的定义了:HTTP 400到底是什么? ### 究竟什么是HTTP 400? 要回答这个问题,最准确的办法,还是**阅读RFC**,看看标准里面到底怎么说。HTTP的RFC有过好几版,1999年6月的[RFC2616](https://datatracker.ietf.org/doc/html/rfc2616)确定了HTTP的大部分规范,而后在[7230](https://datatracker.ietf.org/doc/html/rfc7230)、[7231](https://datatracker.ietf.org/doc/html/rfc7231)、[7232](https://datatracker.ietf.org/doc/html/rfc7232)等RFC中做了更新和细化。RFC2616是这样定义400 Bad Request的: ```plain 400 Bad Request The request could not be understood by the server due to malformed syntax. The client SHOULD NOT repeat the request without modifications. ``` 也就是:这个请求因为语法错误而无法被服务端理解。客户端不可以不做修改就重复同样的请求。 此外,RFC2616里还定义了几种必须返回400的情况,比如: ```plain A client MUST include a Host header field in all HTTP/1.1 request messages . If the requested URI does not include an Internet host name for the service being requested, then the Host header field MUST be given with an empty value. An HTTP/1.1 proxy MUST ensure that any request message it forwards does contain an appropriate Host header field that identifies the service being requested by the proxy. All Internet-based HTTP/1.1 servers MUST respond with a 400 (Bad Request) status code to any HTTP/1.1 request message which lacks a Host header field. ``` 其他还有好几种情况,就不一一罗列了。 那么显然,400 Bad Request的语义,就是让服务端告诉客户端:**你发过来的请求不合规,我无法理解,所以我用400来告诉你这一点**。 但是,我们也不可能去穷举所有可能出现的不合规类型。那么在这个案例里面,究竟是哪里出了问题呢? ### 寻找突破口 有时候,我们做排查工作,需要一点灵感,也需要一点耐心。对着这个页面,如果你对HTTP协议并不是很熟悉,那么很难直接用肉眼就“看出”问题来。 那我们来玩个游戏怎么样:“大家来找茬”。你应该已经明白我的意思了,我们要做的是:**对比分析**。我们只需要把一个正常和一个异常的响应报文放在一起比较,也许就能找到原因了。 正巧,这次客户做的测试里,也有成功的请求。比如这个抓包文件里的HTTP 200 OK。那么,我们就借助这样的一个200 OK的TCP流,来对比分析下。 说到这里,你可能已经想起我们在[第5讲](https://time.geekbang.org/column/article/481042)的时候,也用过这种对比分析的方法。当时是排查一个乱序引起应用层故障的问题,我们对比了客户端抓包文件和服务端抓包文件这两个文件,而它们代表的是同一个TCP流,我们也因此找到了问题的关键,也就是防火墙引发了报文乱序的现象。 ![](https://static001.geekbang.org/resource/image/c0/47/c0a13a14a7ab884e0439c78c124f0d47.jpg?wh=2000x290) 当前的案例跟第5讲的案例就有所不同了,这次比较的是同一个抓包文件里的两个不同的TCP流,也可以说是两个不同的应用层事务。这两个事务,一个成功,一个失败。 ![](https://static001.geekbang.org/resource/image/a4/f3/a4324897b7f11c9285ff1807fcef6ff3.jpg?wh=2000x287) 我们还是需要一个大一点的显示屏,把HTTP 200的报文找到后,Follow TCP Stream,随后的弹窗里就展示了这次成功的应用层消息的细节;然后选取HTTP 400的报文,也同样做一遍。然后我们把两个窗口挪到齐平的位置。 好,我们的对比开始了: ![图片](https://static001.geekbang.org/resource/image/b6/59/b6d491f6b8a5085163650129a9dc1559.jpg?wh=1588x570) 你能找到几个“茬”呢?因为这是两次不同的事务,所以请求和回复的字符肯定也十分不同,所以我们应该集中在**格式**上,而不是字符。 你可能首先注意到了两次的HTTP方法不同:左边是PUT,右边是POST。 这是否说明,问题就是服务端不支持PUT方法导致了HTTP 400呢?这个很容易排除。因为,如果真的是服务端对PUT的处理有问题,那么其他客户还怎么使用PUT呢?所以,即使这个问题跟PUT还是有点关系的话,我们也要转换一下问题描述,变成:**为什么这个客户端发送的PUT请求会引起HTTP 400?** 然后,你可能会发现,左边的PUT请求里有Authorization头部,而右边POST请求里没有这个头部。 左边这个Authorization请求的头部格式是这样的: * 一开始是 `PUT /123456 HTTP/1.1`,然后换行; * 接着是 `Authorization: UCloud` 这样一个头部,然后换行; * 然后是 `abc@def.com:blahblah` 这种形式,看起来是一个邮箱地址后接冒号,然后是一串编码过的字符串。 你是不是觉得这部分的格式有点问题?这其实也是一个知识点了:HTTP Authorization头部的格式。 ### Authorization头部 我们看看[RFC2616](https://datatracker.ietf.org/doc/html/rfc2616#section-14.8)里,对Authorization头部是怎么规定的: ```plain 14.8 Authorization A user agent that wishes to authenticate itself with a server-- usually, but not necessarily, after receiving a 401 response--does so by including an Authorization request-header field with the request. The Authorization field value consists of credentials containing the authentication information of the user agent for the realm of the resource being requested. Authorization = "Authorization" ":" credentials ``` 简单来说,它也跟其他的HTTP头部的规定一样,也是 `key:value` 的形式。语法格式是这样: ```plain Authorization: ``` > 补充:如果要了解关于这个头部的更多细节,还可以参考Mozilla Developer Network关于这个头部的更多的[详细介绍](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Authorization)。 这里的 ``,比较常见的是Basic和Digest。如果是Basic类型,那么它的格式是: ```plain Authorization: Basic ``` 这里的credentials,可以是 `username@site.com:hashedPassword` 这种形式。 而我们在抓包里看到的是什么格式呢? ```plain Authorization: UCloud ucloudabcdef.yu@testtest.com144731865200013974915:gABCDEFGQSLLsdyOjIlo21fap6o= #这里是一个空行 ``` 这个 `Authorization: UCloud` 后面多了一个换行,这就已经是一个问题了。 更严重的是,在第二行的后面,有两次回车,或者说两次CRLF,而这个问题更加严重。说到这里,我们就需要复习一下HTTP报文格式的知识了。 ### HTTP报文分隔 跟IP、TCP类似,HTTP也分为头部(headers)和载荷(body或者payload)。 ![](https://static001.geekbang.org/resource/image/7c/ef/7c8f99a2ee15a21da8a1d60da1c6eeef.jpg?wh=2000x966) 既然分成了两个部分,那么显然,接收者需要知道header和payload的分界线,要不然就会导致信息解读错误,这是致命的。 **在IP协议里**,IP header是用一个Total Length字段,表示了包含IP头部在内的整个IP报文的长度。那怎么区分IP头部和载荷呢?IP头部还有一个字段是Header Length,表示了头部自身的长度。这样两个Length值的差,就是IP载荷的大小了。 **在TCP协议里**,TCP header里的Data offset,表示了TCP载荷开始的位置(也是TCP头部截止的位置),也就相应地可以计算出TCP头部的长度。那么TCP载荷长度是怎么来的呢?我们用一个简单的减法就好了: ```plain TCP payload Length = IP Total Length - IP Header Length - TCP Header Length ``` 这些头部长度的关系,我用了一张示意图来概括,供你参考: ![](https://static001.geekbang.org/resource/image/58/d3/588f8a3ec1f6b5e9ed30d77a546112d3.jpg?wh=2000x812) **而在HTTP里**,载荷的长度一般也是由一个HTTP header(这里指的是某一个头部项,而不是整个HTTP头部),也就是Content-length来表示的。假设你有一次PUT或者POST请求,比如上传一个文件,那么这个文件的大小,就会被你的HTTP客户端程序(无论是curl还是Chrome等)获取到,并设置为Content-Length头部的值,然后把这个header封装到整体的HTTP请求报文中去。 ![图片](https://static001.geekbang.org/resource/image/be/2a/be79d3c36ae164284yycc2cef58cf42a.jpg?wh=1274x151) 既然HTTP报文内容,分成了头部(headers)和载荷(Payload或者body)两部分,那么这两者的分界线在哪呢? **HTTP规定,头部和载荷的分界线是两次CRLF。** ```plain A request message from a client to a server includes, within the first line of that message, the method to be applied to the resource, the identifier of the resource, and the protocol version in use. Request = Request-Line ; Section 5.1 *(( general-header ; Section 4.5 | request-header ; Section 5.3 | entity-header ) CRLF) ; Section 7.1 CRLF [ message-body ] ; Section 4.3 ``` 也就是在最后一个header之后,需要有两个CRLF,这就是头部和载荷之间的分割线。之后就是载荷(message body)的开始了。 那么,前面引发HTTP 400的PUT请求,其Authorization后面也出现了两个CRLF,这就会被认为是headers的结束,payload的开始。但实际上,后面跟的又是剩余的HTTP头部项,在最后一个头部之后,又是两个CRLF。所以这对于Web服务端来说就懵了:“你这说的可不是人话啊!我只能表示我不理解。” ![图片](https://static001.geekbang.org/resource/image/41/bb/41ebb0e77beb6219a4acdee855f947bb.jpg?wh=1562x1152) ### 定位不合规处 原来如此,这次的**400 Bad Request的根因,是客户发送的HTTP PUT请求的格式出现了问题**。它违背了HTTP/1.1(RFC2616)的规定,在Authorization头部后面,错误地添加了两次回车(CRLF)。这样就导致服务端认为,后续的数据都属于payload,也就导致服务器无法正常读取这个请求,只能用HTTP 400来反馈这种状况了。 既然咱们的课程叫“网络排查案例课”,那么这次案例的根因,跟网络有没有关系呢? 我觉得要看你怎么定义“网络”。 如果是传统和狭义上的网络,只包含交换机、路由器、防火墙、负载均衡等环节,那么这里并没有什么问题。没什么重传,也不丢包,更不影响应用消息本身。 如果是广义的网络,那就包含了至少以下几个领域: * 对应用层协议的理解; * 对传输层和应用层两者协同的理解; * 对操作系统的网络部分的理解。 在这个案例里,我们依托于**对应用层协议的理解**,找到了网络行为以外的根因。这个根因虽然可能根源是开发方面的问题,但无论是开发、运维或者SRE,在处理这种问题的时候,如果能具备比较全面的知识,从而推导出根因,那么无论是对组织效率的提升,还是个人能力的提升,是不是都更有意义呢? ## 实验 现在,我们也来做几个简便的小实验,模拟出HTTP 400 Bad Request这样的响应。 ### 实验1:对HTTP发送不合规的请求 如果我们直接用高级语言来调用HTTP库,可能反而不容易做到这种“非法”请求。因为这些库的设计目的之一,就是要尽量避免人工的编码错误以及提升开发效率,我们想借助它去构造非法请求,恐怕不太容易。 当然,如果你熟悉Python的话,可能会想到用Scapy库等工具来实现。但这个步骤就稍多了点。 其实,我们也可以用最简单的方法,就是直接用 **telnet命令**。我们在[第2讲](https://time.geekbang.org/column/article/478189)里,用视频的形式介绍了如何一边用telnet模拟发送HTTP请求,一边用tcpdump的-X参数,展示抓取的报文里面的文本细节。 那么这里,我们也用类似的方法,只要手动执行下面的命令,就可以向目标站点发送一个不合规的请求: ```plain $ telnet www.baidu.com 80 Trying 180.101.49.12... Connected to www.a.shifen.com. Escape character is '^]'. GET / HTTP/1.1 Authorization #这里是一次回车 #这里是又一次回车 HTTP/1.1 400 Bad Request Connection closed by foreign host. ``` 也就是telnet目标站点的80端口,在提示符下输入: ```plain GET / HTTP/1.1 ``` 然后回车,再输入: ```plain Authorization ``` 注意这里不要输入更多内容,直接**回车两次**。这时,两次回车被对端Web服务器收到后,它是这么解读的: * 这是一个GET /的HTTP/1.1版本的请求。 * 有一个Authorization头部,但是这个头部并没有值。 * 两次回车就表示这次请求发送结束。 由于请求不合规,目标站点立刻回复了HTTP 400 Bad Request。 而如果我们在输入Authorization时,后面加上“: Basic”,会收到HTTP 500。这是因为服务端认为Authorization: Basic这个格式本身是正确的,只是后面缺少了真正的凭据(Credential),所以报告了HTTP 500。 所以,两者的区别就是: * **Authorization后面直接回车**,就表示它并没有带上 ``,所以属于不合规,应该回复HTTP 400。 * **Authorization: Basic后直接回车**,它的Authorization头部有 ``,但是没有带上有效的凭据,应该回复HTTP 500。 ### 实验2:对HTTPS发送不合规的请求 前面实验的是HTTP站点,我们用telnet发送明文请求比较直观。而要是对方站点是HTTPS的话,如果还是用telnet,会遇到TLS握手,这一关就过不去了。那么该怎么办呢? 其实,我们可以用**openssl命令**。执行`openssl s_client -connect 站点名:443`,就可以跟对端站点建立TLS握手。比如像下面这样: ```plain $ openssl s_client -connect www.baidu.com:443 CONNECTED(00000006) depth=2 C = BE, O = GlobalSign nv-sa, OU = Root CA, CN = GlobalSign Root CA verify return:1 depth=1 C = BE, O = GlobalSign nv-sa, CN = GlobalSign Organization Validation CA - SHA256 - G2 verify return:1 depth=0 C = CN, ST = beijing, L = beijing, OU = service operation department, O = "Beijing Baidu Netcom Science Technology Co., Ltd", CN = baidu.com verify return:1 --- ...... ``` 另外还有一点,我们这个时候怎么发送HTTP请求呢?不少人会在这里卡住。其实,openssl也是一个交互式的命令,跟telnet一样,直接键入HTTP请求就好了! ```plain --- GET / HTTP/1.1 Authorization #这里是一次回车 #这里是又一次回车 HTTP/1.1 400 Bad Request closed ``` 这样一来,也可以得到跟telnet 80一样的响应。 其实,**网络协议就是这样,是一种“方言”,互相要用对方听得懂的方式对话。**如果语法出现了问题,我们的自然语言就是“不明白你的意思,你说啥”。在HTTP这个“方言”里,就是用HTTP 400表达了同样的意思。 ## 小结 这节课,我们通过一个服务器回复HTTP 400的案例,学习了这种对HTTP返回码进行排查的方法。 使用这种方法的前提,还是需要你对HTTP协议本身有比较深入的掌握,然后结合对HTTP语义的理解,分析出根因。而熟悉HTTP协议的方法,就是熟读RFC2616,以及2014年6月的更新RFC(7230, 7231, 7232, 7233, 7234, 7235)。 具体的方法,我们可以借鉴这样的方式: * 我们可以把错误的报文跟成功的报文放一起,进行**对比分析**。这样会比较快地发现两者之间的差别,从而更快地定位到根因。 * 我们也可以通过telnet和openssl,分别**模拟复现HTTP和HTTPS的**请求,重放给服务端,观察其是否也返回同样的报错。 * 对比协议规范和报文中抓取到的实际行为,找到不符合规范之处,很可能这就是根因。 同时,我们也回顾了不少HTTP协议的知识,包括: * HTTP的各种版本的知识点:**HTTP/2和HTTP/3的语义跟HTTP/1.x是一致的**,不同的是HTTP/2和HTTP/3在传输效率方面,采用了更加先进的方案。 * Authorization头部的知识点:它的格式为 `Authorization: `,如果缺少了某一部分,就可能引发服务端报HTTP 400或者500。 * HTTP报文的知识点:**两次回车(两个CRLF)是分隔HTTP头部和载荷的分隔符**。 * HTTP返回码的知识点:HTTP 400 Bad Request在语义上表示的是**请求不符合HTTP规范**的情况,各种不合规的请求都可能导致服务端回复HTTP 400。 最后,我们通过两个小实验,学习了用简单的方式模拟HTTP请求的方法。如果服务端是HTTP,我们用telnet;如果服务端是HTTPS,就用openssl。 ## 思考题 给你留两道思考题: * 在HTTP请求里,我们用Content-Length表示了HTTP载荷,或者说HTTP body的长度,那有时候无法提前计算出这种长度,HTTP是如何表示这种“动态”的长度呢? * HTTP请求的动词加URL部分,比如GET /abc,它是属于headers,还是属于body,或者哪种都不属于,是独立的呢? 你可以在留言区说说你的想法和思考,我们一起交流。另外也欢迎你把今天的内容分享给更多的朋友。