一次HTTP请求完整旅程:从域名到响应
前言
一直在研究网络这一块,对Web端HTTP的请求与响应流程也有个大体的认识,本文目的是借着这个机会对自己的相关部分认知进行一次系统梳理,建立一个清晰框架体系的同时也留档便于后续巩固和不断完善。若您发现文章中有疏漏或理论缺陷,还请在评论区中不吝指出。
概览
这里以baidu.com为例,当我们在浏览器中输入相关的URL并敲下回车键时,其底层网络栈经过了从发出请求到接收到响应并经过浏览器渲染后回显这一过程:
1 | |
以上便是整个Web服务HTTP请求与响应的总体流程。整体可概括为:浏览器首先解析你的这串URL什么意思,从中提取出相关信息(比如域名),而后将提取出的域名信息使用系统调用发出DNS查询请求以此来获取远端IP地址(DNS查询并没有这么简单,这里只是通常情况)并建立TCP连接。在此基础上客户端与服务器进行TLS握手,协商加密参数并建立安全通道,之后才正式开始加密传输HTTP请求。
下面就配合curl -v来详细讲讲每个流程,同时在文章附录将附有一个实际全流程抓包作为参考。
URL 解析
https://www.baidu.com/index.html以该URL为例,浏览器在拿到该URL后,会提取出里面的www.baidu.com这个字段,也就是我们要访问的域名。同时提取出:使用的协议(https)、资源路径(/index.html)、端口号(默认443)。
这里额外讲一下端口:为什么URL里面未出现端口后,浏览器那边会自行“脑补”?这其实是URL的标准所带来的。
- 如果是HTTP协议,也就是说URL为
http://example.com,那么浏览器会自动推导为80端口,因为协议就是这么规定的:http <--> 80。 - 如果是HTTPS协议,URL为
https://example.com,浏览器自动推导为443端口,https <--> 443。
如果你访问的服务并不开在以上两个标准端口之上(也就是我们所说的非标端口),那么在URL里面必须显式带上端口号,比如:https://baidu.com:8443。国内443/80端口是需要备案的,这也是为什么我博客选择架在日本那边的服务器上。
DNS 解析
扯远了回来,浏览器从URL解析出域名后,并不会直接使用系统调用发起DNS查询请求!而是会逐级查缓存——通常浏览器会先检查自身的缓存,如果命中就直接使用;若未命中,向下检查本地Hosts以及Windows的系统DNS缓存。只有当这些缓存查询都没有命中时才会进行系统调用发送DNS查询请求。
Ps:标准查询请求是 UDP/53 口,以此对物理网卡进行抓包能够截获请求与响应。但是这只是通常情况,如果浏览器配置了安全DNS(DoH)那么并不会走UDP/53进行普通查询,而是浏览器直接自身发起一个HTTPS请求给DoH服务器,服务器将响应塞到HTTP的body里面通过加密通道进行传输,这个时候抓udp/53就抓不到了,要抓http以及tls handshake,通过TLS握手的SNI来识别,但是也仅限于识别,因为是加密传输看不到具体内容。
总之:浏览器那边提取出域名后,自己会主动去查浏览器dns缓存以及Windows系统的设置(Hosts、缓存),只有当这些都没有命中才会发包上公网请求DNS查询。而这个方式又有两种——一是通过udp/53进行普通查询;二是使用DoH通过加密传输HTTP进行查询。这两种查询方式最终都会使我们浏览器拿到一个具体的IP地址,进而转入下一步。

如上图所示,我们向百度发起访问请求时,根据域名解析出了百度的其中两个IP地址:103.235.47.188, 103.235.46.96。动手查一下这两个IP地址的归属地你会发现都来自于香港,这是百度部署在香港的服务器。之所以会这样是因为百度作为一家国际大厂,其拥有十分智能和全面的CDN系统。上述访问是以我在日本的VPS的视角进行访问的,被识别为海外IP,直接返回了百度对于海外IP处理的边缘节点。
以本机进行访问,其如下图所示:

返回了IP地址:180.101.51.73, 180.101.49.44。这两个IP都位于江苏南京,算是离我本机最近的位置了。同一域名在国内和海外被解析成了完全不同地域的IP。DNS系统正是通过这种方式,把用户调度到了离自己物理距离最近的服务器。
TCP 连接建立(三次握手)
找到IP后,接下来就是建立TCP连接了。
本机会立即发送一个SYN标志包并携带上随机序列号Seq过去请求连接,这也是第一次握手;随后,服务器侧若同意连接则回送一个SYN、ACK标志包,并且也带上自身关于本次会话的随机序列号Seq;最后,客户端回送一个ACK确认接收到了服务器的内容。至此,3次握手成功,连接正式建立(三次握手对应的详细机制与要点不属于本篇文章主题,因此本文只简要阐述连接过程,不深入讲解)。
1 | |
这里简单提一下:HTTP协议是应用层协议,而不是传输层协议,这一点一定要弄清楚。它不规定数据怎么传,数据可不可靠,它只规定了双方通信的格式(双方必须按照这个格式“说话”对方才能听懂)。因此必须要结合一个可靠的传输层协议来打底,这就是HTTP over TCP,不过没人会这么说,一般我们提到HTTP也就默认传输层为TCP。
当然,如果你有个人定制需求以及应用场景,完全也可以跑HTTP over UDP。还是那句话:HTTP不管你底层怎么传,只要数据是按照它的格式排布的就行。(那么有没有HTTP over UDP的范例呢?那自然是有的。当今的HTTP/3[底层为QUIC]本质就是传输层udp负责带数据,HTTP跑在这个基础之上。此处只做了解,不再展开更多)
TLS 握手
TCP连接建立后,基础信道便打通了,可以开始传输数据。但是在这里,http和https两个的区别就来了,这里在讲TLS握手之前先来简单讲讲http和https是怎么回事。
- HTTP:普通http协议,无验证也没有数据保护,信道上传输的是明文。既然是明文且没有完整性校验保护,那么也就意味着中间人能够拿到你通信的所有内容,且能够对其进行篡改。你在网站上输入的密码等信息是直接裸奔到服务器那边的。现在的网络环境下,对外开放的Web服务基本都不会使用纯HTTP了,你在浏览器上访问http服务时浏览器都会自动给你报警。
比如上面这个就是个典型的不安全网站,数据从你本机到akb48.jp那边从头到尾都是裸奔状态(直连情况下)。(话说开闭这么大个网站,子站全都走https了主站还留一个http在这,没看明白什么操作)
- HTTPS:
https = http + secure,也就是安全版本的http协议。现在大部分站点都是使用这个有安全加固的HTTPS。而这个secure安全套件就是TLS(Transport Layer Security,传输层安全协议),这玩意儿你可以理解为就是基于传输层上为数据传输提供加密、防篡改、身份认证以及完整性校验的一套安全系统。
综上也就是说:如果我们使用的是http,那么就没有TLS握手搭建加密通信信道这一个步骤,而是直接与对端进行HTTP明文交互。这里我们主要来看https,以下来看看TLS握手的流程:
1 | |
阶段一:客户端向服务器发送 ClientHello
ClientHello是一个结构性报文,用于我们客户端告诉服务器自己支持的TLS版本、密码学套件等,通常也携带密钥协商所需的参数(取决于TLS版本),同时还跟有一个拓展字段(也就是我们所说的SNI)。1
2
3
410:39:51.181645 * ALPN: curl offers http/1.1
10:39:51.181841 } [5 bytes data]
10:39:51.181921 * TLSv1.3 (OUT), TLS handshake, Client hello (1):
10:39:51.181963 } [512 bytes data]这里我们通过ALPN与服务器提前协商通信要用的HTTP协议(此处为了便于后续观察,我们在curl命令中强制了使用
HTTP/1.1,也就是传统的文本形式。如果不使用强制的话,这里通常会使用更加高效的HTTP/2。同时还要注意,这只是协商,不代表服务器已经同意使用HTTP/1.1)。而后通过
ClientHello把自己支持的加密套件、支持的TLS版本等等一股脑推给服务器。这里TLS handshake表示这是一个握手信息,类型为ClientHello。至于这里的TLS1.3并不是协商的最终结果(协商刚开始呢),只是curl日志中将其先标记为了1.3。阶段二:服务器返回握手信息 ServerHello 及其其它验证信息
服务器收到客户端的
ClientHello之后,会核实客户端的组件支持情况,并根据自身以及客户端实际情况从中选择一个套件进行使用。同时将自己这边的协商密钥以及服务器证书及相关身份验证信息包裹在ServerHello中发回给客户端。1
2
3
4
5
6
7
8
9
10
1110:39:51.200098 * CAfile: /etc/ssl/certs/ca-certificates.crt
10:39:51.200141 * CApath: /etc/ssl/certs
10:39:51.240214 { [5 bytes data]
10:39:51.240310 * TLSv1.3 (IN), TLS handshake, Server hello (2):
10:39:51.240358 { [106 bytes data]
10:39:51.240647 * TLSv1.2 (IN), TLS handshake, Certificate (11):
10:39:51.240686 { [4771 bytes data]
10:39:51.241667 * TLSv1.2 (IN), TLS handshake, Server key exchange (12):
10:39:51.241710 { [333 bytes data]
10:39:51.241890 * TLSv1.2 (IN), TLS handshake, Server finished (14):
10:39:51.241933 { [4 bytes data]CAfile和CApath这两个路径是我们本地客户端用于验证服务器证书真伪的文件,了解就行。这里两条日志的变化很有意思:
1
2TLSv1.3 (IN), TLS handshake, Server hello (2):{ [106 bytes data]
TLSv1.2 (IN), TLS handshake, Certificate (11):{ [4771 bytes data]TLSv1.3 (IN)IN表示这是客户端收到的由服务器发来的TLS消息,其类型为TLS handshake,消息结构为Server hello。前面之所以标TLSv1.3,并不是说这次就一定用1.3,而是此时协商结果还没出来——版本信息是带在ServerHello里的,而这条日志刚把ServerHello收进来,还没来得及解析。curl日志里的这个版本标记,实际取自底层TLS库(这里跑的是OpenSSL)当前记录的协议版本状态:客户端支持1.3时,解析前的状态就是TLS1.3。后续到服务器发送证书(
Certificate)消息时,协商结果已经拿到——使用TLS1.2版本,因此curl日志也将其改为了TLSv1.2。这里要注意,这条日志只代表客户端收到了证书,真正的证书链校验是客户端收到之后在本地做的(上面总的日志后面那句SSL certificate verify ok就是校验结果),跟这条消息本身没有直接关系。而且后续这段服务器发来的密钥步骤也验证了协商结果正是TLSv1.2(TLS1.3不是这样的):1
TLSv1.2 (IN), TLS handshake, Server key exchange (12):{ [333 bytes data]阶段三:服务器证书验证完毕,可以交换密钥
上述服务器发完证书和服务器的密钥后,我们客户端在本地进行验证。通过
CAfile和CApath这两个路径下的文件验证证书为真,并非其它中间人。随后我们客户端在本地生成一个用于本次通信的密钥协商参数(这里要强调一下,该参数只是作为双方协商共同密钥的一部分。双方交换的参数各自都要在双端用各自的私钥来参与计算得出一个共同的,这才是加密的依据,而这个依据是不会在信道上传输的),将这个参数发送给服务器。随后通告服务器——从现在开始将进行加密传输(因为服务器那边在我们客户端的密钥参数发过去后,也拥有足够的信息来计算出一个共同的密钥了)。
1
2
3
410:39:51.242191 * TLSv1.2 (OUT), TLS handshake, Client key exchange (16):
10:39:51.242230 } [70 bytes data]
10:39:51.242374 * TLSv1.2 (OUT), TLS change cipher, Change cipher spec (1):
10:39:51.242411 } [1 bytes data]阶段四:双方互发最终确认包,TLS握手完结
上面证书验证完了,密钥也交换完了,双方通过计算应该也得出一个共同的真正用于加密的密钥了。那么问题来了,怎么知道双方计算得对不对?怎么知道对方是否真正正确收到并处理我方发出的信息了?
这里我们客户端会用本次握手相关的握手信息(ClientHello、证书、交换的密钥参数等)计算出一个hash值,然后用计算出的密钥对该hash值加密后发送给服务器。
服务器收到后解密得到该hash值,若该值与服务器自己计算的相同,那么已经说明没问题了。但是服务器同样会将自己计算出的hash值加密后丢回给客户端(这也是为什么最后我们能在日志中看到一个IN),以便能让客户端确认服务器已经正确计算出了会话密钥。
1
2
3
410:39:51.242486 * TLSv1.2 (OUT), TLS handshake, Finished (20):
10:39:51.242527 } [16 bytes data]
10:39:51.294215 * TLSv1.2 (IN), TLS handshake, Finished (20):
10:39:51.294263 { [16 bytes data]Ps:这里服务器是手握两个hash的——一个来自于客户端数据解密,一个是自己计算的。虽然这两个值通常情况下在数学上是相等的,但是服务器回给客户端的一定是自己计算出来的,虽然值一样但是在物理含义上是不一样的。
整个握手流程完成后,开始打印本次握手的全局日志:
1 | |
SSL connection using TLSv1.2:可以看到本次握手协商使用了TLSv1.2而并不是常见的1.3版本
ECDHE-RSA-AES128-GCM-SHA256 / prime256v1 / RSASSA-PSS:这些是本次使用的加密算法以及加密套件
ALPN: server accepted http/1.1:服务器同意HTTP/1.1协议
Server certificate:xxxx:服务器证书的详细信息,此处不做展开
SSL certificate verify ok:证书验证通过
Level 0/1/2:服务器证书、中间人证书(CA)、根证书
using HTTP/1.x:收尾。接下来加密信道中数据全部走 HTTP/1.1
这里要提一点:本次握手流程是TLSv1.2版本,对于最新的1.3版本,部分流程会有差异,此处不详细展开。
HTTP 请求与响应
到现在,底层通信信道已经全部构筑完毕。此时我们客户端会向服务器发起一个HTTP请求头,该请求头作用是告诉服务器自己想要拉取什么资源,服务器据此来返回对应的数据流。
1 | |
GET / HTTP/1.1:请求行,告诉服务器要拉取根路径/,使用之前协商的HTTP/1.1协议。Host: www.baidu.com:请求站点域名。为什么不是IP?因为一个IP上面可能挂着几十个网站。服务器端只有根据域名作分流,根据IP可分不出。User-Agent: curl/8.5.0:访问站点的设备信息(用的什么操作系统、是PC还是手机、浏览器用的啥等等)。这玩意儿理论上可以不填,但是很多站点都有风控机制,没有UA或者UA不合规(比如示例中的curl/8.5.0等等)可能会被拦下来。Accept: */*:客户端接收数据类型。两个*号是通配符,中间的/号是分割符。前面是主类型,后面是子类型。前后两个通配表示接收所有数据类型(Json、图片、文本等等)。
这个明文请求包从应用层下放到传输层后被TLS加密成密文,随后封包成IP包通过物理网卡向对端发出。
对端站点收到请求后会向我方返回响应,数据交互正式开始:
1 | |
HTTP/1.1 200 OK:这个状态码想必不用说了,访问页面都想看到这个( ̄︶ ̄)↗ 。Cache-Control:缓存控制。百度由于是搜索引擎,跟站点常常有各种新闻,因此为了保证用户拿到最新的消息为本次链接打上了两个标签——no-cache、no-store。告诉可能的中间代理服务器里以及浏览器不要缓存页面数据。Content-Length: 2443:告诉我方接下来数据长度有2443个字节,客户端据此来进行数据接收。Content-Type: text/html:文本类型,浏览器应该使用HTML来渲染。Set-Cookie: BDORZ=27315; max-age=86400; domain=.baidu.com; path=/:发给你的赛博狗牌,对所有百度子域名都有效。你访问百度域名时浏览器会带上这个Cookie验明身份,便于厂商统计和风控管理。Connection #0 to host www.baidu.com left intact:长连接建立,也就是如果后续对百度这边还有资源请求,直接复用这条通道进行传输。
后续渲染操作
至此,跟网络传输低层相关的过程已经全部结束。浏览器从网络协议栈中拿到经过解密的HTTP数据流,按照HTML进行解析渲染。解析过程中若发现有图片引用等其它资源,会继续复用建立的长连接去获取更多的数据。最后经过一整套前端套件的渲染,屏幕上就显示出我们访问的Web页面了。
浏览器处理页面这一个过程不在我们的讨论范畴,此处就简略带过。
附录
这里我们使用PowerShell手动强制指定本机只支持HTTP/1.1以及TLSv1.2,并向QQ发起连接请求。
1 | |
DNS缓存未命中,走UDP/53进行常规查询:

通过DNS获取IP后,以下是我方主动发起SYN的TCP三次握手连接建立过程:

TCP连接建立后,我方发送ClientHello进行TLS握手(明文)。服务端回复ServerHello、Certificate、服务端协商公钥(这里可以看到由于服务端数据负载过大,因此被TCP切成了多个分包发出,在接收端重组)。

客户端验证服务器证书,验证通过后将自己的协商公钥交换给服务器,同时将自己这边计算完毕被会话密钥加密的hash校验数据传给对方(这里没看到单独的finish封包,但是该包内部有一条类型为22的Record,因此可以推测校验值一并附在该包之中并发给了服务端)
服务端同理也返回一个封包(第55包、内含下次握手票据(newsession)、服务器finish)。至此双方开始进入加密通信。

我方接收加密数据。这里HTTP的请求头这些全部被TLS加密了,这些数据在Wireshark中以Application Data标记。可以看到数据收完后我方向对端发送了一个Alert请求TLS断开。

TCP四次挥手断开连接。本次抓包比较特殊,服务器在发送FIN的同时确认了客户端的FIN,两个标志拼在一个包中发出,所以这里是一个特例,只抓到三个包。这种情况在HTTP通信中,双方都决定不再维持本端连线时会出现。

END
一次HTTP请求完整旅程:从域名到响应