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.

384 lines
24 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 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: <auth-scheme> <authorization-parameters>
```
> 补充如果要了解关于这个头部的更多细节还可以参考Mozilla Developer Network关于这个头部的更多的[详细介绍](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Authorization)。
这里的 `<auth-scheme>`比较常见的是Basic和Digest。如果是Basic类型那么它的格式是
```plain
Authorization: Basic <credentials>
```
这里的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.1RFC2616的规定在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后面直接回车**,就表示它并没有带上 `<auth-scheme>`所以属于不合规应该回复HTTP 400。
* **Authorization: Basic后直接回车**它的Authorization头部有 `<auth-scheme>`但是没有带上有效的凭据应该回复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月的更新RFC7230, 7231, 7232, 7233, 7234, 7235
具体的方法,我们可以借鉴这样的方式:
* 我们可以把错误的报文跟成功的报文放一起,进行**对比分析**。这样会比较快地发现两者之间的差别,从而更快地定位到根因。
* 我们也可以通过telnet和openssl分别**模拟复现HTTP和HTTPS的**请求,重放给服务端,观察其是否也返回同样的报错。
* 对比协议规范和报文中抓取到的实际行为,找到不符合规范之处,很可能这就是根因。
同时我们也回顾了不少HTTP协议的知识包括
* HTTP的各种版本的知识点**HTTP/2和HTTP/3的语义跟HTTP/1.x是一致的**不同的是HTTP/2和HTTP/3在传输效率方面采用了更加先进的方案。
* Authorization头部的知识点它的格式为 `Authorization: <auth-scheme> <authorization-parameters>`如果缺少了某一部分就可能引发服务端报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或者哪种都不属于是独立的呢
你可以在留言区说说你的想法和思考,我们一起交流。另外也欢迎你把今天的内容分享给更多的朋友。