上节我们介绍了网络包在互联网中的传递,本节将介绍网络包到达服务端的局域网,并且到达服务器后如何被接收和处理。
1.网络包到达服务端防火墙
一般而言,web服务器会放在服务器运营商的数据中心,该数据中心与运营商核心部分NOC通过高速线路直接相连,因此网络包在互联网中能够快速到达服务器。在网络包到达服务器所在的子网后,进入服务器之前,会先由数据中心的防火墙进行一波过滤。
· 防火墙原理
防火墙的本质是一台用于过滤不合法网络包的服务器或者路由器,过滤的原理是通过获取网络包的头部(IP头部、TCP头部和UDP头部)控制信息(如通信双方IP和端口号以及TCP控制位等)与防火墙设备所设定的过滤规则比对来过滤掉不符合要求的包。
这里的地址过滤也是我们在第二节没有介绍的路由器的另一功能。
下面举例说明,某公司有一批外网设备和内网设备,现在希望互联网中的设备能够访问该公司的外网设备,但禁止外网设备访问互联网。这种情况一般出现在服务器中感染了病毒,为了防止病毒扩散而阻止服务器访问互联网。
假设公司外网设备所在子网的网段是 192.0.2.0/24,那么我们可以指定如下规则:
这条规则还限定了TCP控制位,是因为不能把外网设备的应答包(ACK=1)给禁止,否则互联网的包就得不到应答包。
上面的例子是针对TCP传输,如果是UDP传输的话,那么想要允许A能访问B,但B不能访问A就无法轻易实现。原因是UDP是无连接的协议,UDP头部也没有控制位这一信息。想要允许A能访问B,B不能访问A就要允许B发出的应答包通过并且禁止B发出的请求包通过,可是我们无法得知一个B发出的UDP包是一个请求包还是一个应答包,要么只能允许所有B发出的包通过,要么阻止所有B发出的包通过。
对于内网设备和互联网之间的通信,我们知道内网如果想要跟互联网通信就必须要通过地址转换,而防火墙自然也有地址转换的功能。如果想要禁止某内网设备访问互联网,只需在防火墙或路由器中设定不对该内网设备进行地址转换即可(相反,想要内网能够访问互联网只需对该内网设备进行地址转换)。如果要允许内网设备访问互联网,禁止互联网访问内网,只需开启地址转换,并且在过滤功能上禁止互联网向内网设备的连接请求包通过即可。
路由器本身除了转发包这个基本功能外,也有地址转换和包过滤的功能,因此路由器本身可以作为简单的防火墙使用。但对于判断规则比较复杂的情况,就需要专门的防火墙硬件和软件。另外,服务器本身也可以安装防火墙软件如firewall等。
2.网络包达到负载均衡器或缓存服务器
当网络包通过防火墙之后,包会经过局域网内的路由器一次次的转发到达目标服务器,这一过程和包在客户端局域网中传输过程一样(如为得知下一跳IP地址而查询路由器中路由表,为得到下一跳的MAC地址而在局域网中广播,每转发一次都会更换一次发送方和接收方MAC地址等)。
在单机部署的情况下,包的接收方IP地址可能就是最终处理请求的服务器,但很多情况下为了提高服务的性能,往往会进行集群部署,通过负载均衡减轻服务器压力。此时包的接收方IP可能是用于转发请求的负载均衡的服务器,而真正处理请求的服务器则是集群中的某台机器。
·简单描述负载均衡部署方案
例如服务器端有3台处理请求的服务器A~C,IP地址分别是
A: 192.0.2.60
B: 192.0.2.70
C: 192.0.2.80
方案1:通过DNS对同一域名映射到不同IP从而分散请求
假如域名是www.lab.glasscom.com,我们需要在DNS服务器中注册3条www.lab.glasscom.com与服务器A~C的IP地址映射关系。如下所示
当客户端请求该域名对应的IP时,DNS服务器会以轮询的方式返回该域名的IP给客户端。例如:第一次客户端拿到192.0.2.60,第二次客户端拿到192.0.2.70,第三次客户端拿到192.0.2.80,第四次客户端又会拿到192.0.2.60。这里的客户端可能不是同一个客户端,可能是n个客户端,而注册了这3条记录的DNS服务器也可能不止一台而是n台DNS服务器。
通过这种方式可以将访问平均到每一台处理请求的服务器。
优点:部署简单,直接在运营商提供的后台添加DNS解析记录即可,由DNS服务器告知客户端应该访问哪台服务器。开发者无需过多部署。
缺点:
1. 一旦某一台服务器出现故障,DNS不会跳过这台故障服务器,依旧会返回这个IP给客户端。因为DNS服务器无法感知目标IP的服务器的应用程序出现故障。
2. 轮询对于一些需要记住请求状态的请求是不利的,例如用户登录时生成的session在A服务器,但下一次访问用户主页时访问到B服务器,这是服务器会告知用户未登录。可以通过使用redis存储代替文件存储的方式来存session。
方案2:使用负载均衡器分配访问
为了避免上面出现的问题,可以使用负载均衡器分配访问,负载均衡器本质上是一台安装了具有转发请求功能软件的服务器。例如在服务器中安装nginx,并通过在nginx配置中设定转发规则来实现,相比于DNS服务器只会简单的轮询IP,负载均衡服务器能够制定更多灵活的负载均衡规则。
假如有A~C这3台实际处理请求的服务器,以及D这台负载均衡服务器,相应网站的域名是www.lab.glasscom.com。
为了实现使用负载均衡器分配请求,我们首先要将D的IP与www.lab.glasscom.com的映射关系注册到DNS服务器。客户端会认为负载均衡器就是一台 Web服务器,并向其发送请求,将请求发送到D。D按它所设定的规则转发请求给A~C的其中一台。
负载均衡器可以定期采集Web服务器的CPU、内存使用率,并根据这些数据判断服务器的负载状况,也可以向Web服务器发送测试包,根据响应所需的时间来判断负载状况。如果检测到某台服务器不可用,那么负载均衡器就不再将请求发送给这台服务器;或者如果检测到某台服务器的负载过重,那么就优先将请求发送给其他服务器。
除了负载均衡,设立缓存服务器也可以减轻服务器压力,下面我们简单介绍一下缓存服务器的原理。
· 缓存服务器和内容分发网络
相比于负载均衡而言,负载均衡是将请求转发给web服务器处理,真实处理请求的还是web服务器;而如果使用缓存服务器,缓存服务器会代替web服务器处理请求将之前缓存好的内容返回给客户端,从而减轻web服务器负载。
下面我们看看缓存服务器是如何工作的:
1.需要先将缓存服务器的IP和相关服务或网站的域名注册到DNS服务器中。当客户端请求该域名的资源时,HTTP请求会发送到缓存服务器。这个过程中,客户端是与缓存服务器建立TCP连接,而不是直接与Web服务器建立连接。
2.HTTP请求消息到达缓存服务器之后,缓存服务器会根据HTTP消息的uri查询是否有该uri的缓存数据。
3.假设没有缓存数据,缓存服务器会将该请求转发给Web服务器(这个过程中缓存服务器也会生成与Web服务器通信的套接字,并建立连接),并且在HTTP头部添加一个Via字段,表示该消息经过缓存服务器转发(当然,有些缓存服务器也会不加)。
4.Web服务器收到请求后,判断是否有If-Modified-Since字段(如果有该字段表示缓存服务器有缓存数据)从而判断缓存服务器是否有缓存数据。当Web服务器知道缓存服务器没有缓存数据时,就会根据请求生成响应并返回给缓存服务器。
5.缓存服务器将响应数据缓存一份并且记录保存的时间,并将响应转发给客户端。下一次客户端将相同uri的请求发送过来的时候,缓存服务器就会直接命中缓存并返回响应,不会再次请求Web服务器。
下面我们看看命中缓存的情况:
6.如果下一次客户端的请求到达缓存服务器并且命中缓存时,缓存服务器会在客户端的HTTP消息上添加一个If-Modified-Since的头部(内容就是上次缓存数据的时间)并转发给Web服务器,该头部相当于告诉Web服务器这个数据是在If-Modified-Since这个时间点缓存的。
7. Web服务器根据If-Modified-Since字段记录的时间比对对应uri资源的最后更新时间判断数据是否更新。如果数据没有更新,Web服务器返回的响应消息会返回304 Not Modified表示数据没有变更,而且响应体中的数据为空(相当于只返回一个数据状态而不返回真实的页面数据)。
8.缓存服务器知道Web服务器没有更新资源后,会将缓存数据返回给客户端。假如上一步中Web服务器告知缓存服务器数据有更新,那么就会把更新的数据放到响应体中给缓存服务器重新缓存,缓存服务器也会记录下最新的资源更新时间作为下次If-Modified-Since字段的值。
缓存更新策略
被动更新
每次客户端的请求发送过来的时候,如果缓存服务器命中了缓存,它会通过转发有If-Modified-Since头部的HTTP消息询问Web服务器数据是否更新。通过这种方式可以让数据保持较高的实时性。但是也会一定程度的增加Web服务端的负载。
有时候为了缩短响应时间,缓存服务器会先把旧的缓存数据先返回给客户端,再转发更新询问的HTTP消息给Web服务器。这可以保证虽然本次请求客户端拿到的是旧数据,但下次客户端请求的时候拿到的是最新的数据。
缓存服务器主动更新(定时更新)
如果用户长时间不请求某一uri,该uri资源的缓存也能自动更新。可以在缓存服务器设定一个资源过期时间(比如15分钟或1天,甚至于对不同uri设定不同的过期时间)。即使用户长时间不访问该uri,只要uri的缓存过了有效期,缓存服务器也会主动向Web服务器发送请求获取最新的缓存数据。
目标服务器主动更新
当目标服务器资源发生变更时,目标服务器主动通知缓存服务器作出更改。但这点相对而言比较难实现,因为这需要对服务端应用程序作出改动,支持这种主动通知的行为和逻辑编写。
当然,缓存服务器会涉及到更多的细节,例如缓存服务器可能隶属网络服务运营商,因此该缓存服务器可能缓存多个域名的uri资源,此时该怎么转发;又例如缓存服务器不只一台,而是分布式集群化部署在全国各地,怎么保证客户端请求发送到离它最近的缓存服务器上呢?这些问题将在下面介绍内容分发网络时介绍。
· 内容分发网络(CDN)
内容分发网络(CDN)本质上就是分布式的缓存服务器,除了具有上述缓存服务器的功能之外,CDN能根据客户端所在地理位置,以及根据网络拥挤情况和节点负载情况将请求引导到最优最快的CDN节点上。
CDN需要为广大用户服务器,那么对于不同域名的请求CDN是如何进行转发的呢?对于使用过CDN的朋友们对此肯定不陌生。
首先我们需要在网络服务运营商后台将相关域名和IP信息注册到CDN节点。例如服务端用户拥有一个www.abc.com的域名,以及2台负载均衡的服务器IP分别为192.0.2.70,192.0.2.780。那么就需要将域名和IP的映射关系信息填写到运营商管理后台。运营商会将该信息注册到全国各地的CDN服务器中。
此外,还需要在DNS服务器中将www.abc.com这个域名映射到CDN服务器的IP,这样一来客户端询问该域名的IP地址时,DNS会返回CDN的IP而不是目标服务器的IP。这一步也是在运营商后台操作注册的,只不过注册的DNS记录是CNAME类型的记录,会将www.abc.com域名与CDN的CNAME信息绑定形成一条DNS记录,而CNAME与CDN节点真实IP的映射关系则已经在各个DNS服务器中有记录。
当客户端准备发出某个HTTP消息时,会先向DNS服务器请求对应域名的IP地址,DNS服务器会查询DNS映射关系表先根据域名找到对应的CDN节点的CNAME,再根据CNAME找到对应的某一台CDN节点的IP。
于是这个HTTP消息就会发往这台CDN节点,CDN节点解析HTTP消息头部中的Host字段,得知请求的域名为www.abc.com,根据域名得知源目标服务器的IP为192.0.2.70或192.0.2.80,解析uri查找对应的缓存数据如果缓存不存在,则CDN会将请求按一定规则转发给192.0.2.70或192.0.2.80中的其中一台服务器。因此CDN节点其实也有负载均衡的作用。
CDN其他流程则与上面介绍缓存服务器的工作流程一致。
CDN通过解析http消息头部的HOST字段找到要转发的目标服务器IP。
下一个问题是,网络服务商是如何让用户访问到最近的CDN节点的,或者说如何让DNS服务器返回给客户端最近的CDN节点的IP?
其实说来也不难,每一台CDN节点附近的路由器都会记录着CDN节点的路由信息,我们需要将各个CDN节点的路由表收集并同步到DNS服务器。
在客户端的DNS请求会先送达离客户端机器比较近的本地DNS服务器,并转发到存有CND服务器IP的DNS服务器。
DNS服务器根据客户端要请求的域名找到对应的CDN的CNAME记录,再根据CDN的CNAME和客户端所在本地DNS服务器的IP在它(DNS服务器)所搜集的路由表中进行查询(因为路由表中还记录了发送方IP与接收方IP的距离有多少跳),得到离客户端DNS服务器最近的CDN节点IP。
DNS会将找到的这个CDN节点IP返回给客户端DNS服务器再返回给客户端主机。
通过在DNS服务器(记录着CDN节点ip的DNS服务器)根据客户端DNS服务器IP查询路由表的方式查到最近的CDN服务器。当然这种方式会有一定的误差,因为客户端本地DNS服务器离客户端主机也有一定距离,但是这个误差范围比较小。
如下图所示
CDN和缓存服务器的优点主要是以下2点:能够通过缓存页面数据加速响应速度,以及减轻服务器负载(因为避免了每次请求都打在目标服务器上)。一些附带的优点如隐藏了目标主机的真实IP,使其不易受到攻击。
CDN的缺点在于,数据的实时更新可能有延迟造成的数据不一致,以及CDN无法缓存一些动态生成的数据如需要CGI程序生成的数据,而只能缓存一些静态文件如html/css/js/图片等(这个应该不算缺点,而是适用场景的问题,CDN就不适合缓存动态页面或动态数据)。
另外,网络包在到达CDN后才会到达服务器所在防火墙。
4.网络包到达服务器主机网卡和协议栈
网络包在服务端的接收会比客户端要复杂一些,因为服务端要与多个客户端通信并且接收多个客户端的请求。接下来我们介绍一下服务端在接收和处理网络包时的工作流程。
我们一般会将接收处理网络包的服务器程序分为两个模块:等待连接模块和负责与客户端通信的模块。如下所示:
1. 一开始,服务器程序的主线程委托操作系统进行socket()系统调用,创建套接字,此时系统调用会返回一个用来表示套接字的描述符A给应用程序。应用程序再委托操作系统调用bind()系统调用绑定某个指定的端口,再调用listen()将套接字的状态置为等待连接状态。最后调用accept()等待客户端的连接到来。在主线程运行着的就是等待连接的模块。这一步是发生在客户端发起连接和发送请求包之前的。
2. 当有客户端的连接到来时,accept()会为描述符A代表的套接字A复制一个副本套接字B,并将客户端的连接信息如客户端IP和端口以及序号等信息写入到套接字B中,并且返回代表套接字B的描述符B给服务器应用程序。然后应用程序被唤醒,得到描述符B的应用程序会创建一个子线程,在这个子线程中由套接字B与客户端套接字通信,而套接字A则继续等待其他客户端连接的到来。
如果有更多的客户端连接到来的话,主线程会为每一个客户端连接创建一个子线程并创建一个新的套接字副本用于与客户端通信,这样使得每个客户端与服务端的通信是独立且并发进行的。
3. 接下来,在子线程中,用于跟客户端通信的套接字会调用read方法(委托操作系统调用read()系统调用)等待接收客户端发送过来的消息。每一个套接字对应的都有一个发送缓冲区和接收缓冲区,一旦该套接字对应的接收缓冲区有数据(缓冲区的数据量到达一定程度或者数据在缓冲区有一段时间),read系统调用就从缓冲区读取数据并拷贝到应用程序的内存中。现在假设客户端暂时没发送请求网络包过来,因此客户端程序的该子线程是处于阻塞状态的。
PS:上面的过程有一个细节需要注意:那就是accept()接收到连接后会对等待连接模块的套接字复制一个副本套接字用于与客户端收发消息。这些副本套接字与等待连接的套接字会绑定和监听相同的端口。那么问题来了,我们知道系统会根据网络包TCP头部的接收方端口找到对应的套接字,并读取该套接字中的信息从而与客户端通信的。现在如果多个套接字对应一个端口号,就无法通过端口号定位到套接字,那么会出现客户端A发送的包找到了服务端上用于和客户端B通信的套接字的情况。
但其实在生成这个副本套接字的时候,协议栈还会将连接时得到的客户端IP和客户端端口记录到这个副本套接字。收发数据阶段,当客户端A的包到来时根据包头部的发送方IP和端口和接收方端口就能找到对应的副本套接字(具体来说,根据包头部的接收方IP找到具体某台服务器,根据接收方端口找到服务器上具体的某个应用程序[一个服务端运行了多个进程,每个与网络通信相关的进程都对应着一个独立的端口号,对于和网络无关的进程是不会占用端口号的。准确的来说,真正和端口号绑定的不是进程而是进程内创建的套接字],根据发送方ip和端口找到具体的那个副本套接字)。
上面这个表的每一行分别记录到一个套接字上。
负责连接的那个套接字则只记录了服务端的IP和端口,没有记录任何客户端的IP和端口,因此非连接的包(即数据收发阶段的包)是不会找到这个负责连接的套接字要求它来跟客户端通信的。
4.当客户端的消息发送过来并被服务端网卡接收后,网卡的PHY(MUA)模块会将网络包从电信号转为数字信息。网卡MAC模块进行FCS校验帧数据无误,检查MAC地址无误后,MAC模块会将帧数据保存到网卡的缓冲区。
5.在这个过程中,CPU不会一直监控网络包的到达,而是在执行其他任务,因此网卡会发出中断将网络包到达的事件通知给CPU。CPU暂停当前工作并切换任务去执行网卡驱动中的程序将包从网卡缓冲区取出,根据MAC头部的以太类型字段调用负责处理该协议的软件,这里如果以太类型是IP协议,则将包转交给TCP/IP协议栈。
6.当包转交给协议栈后,IP模块会检查包头部的接收方IP地址是不是发给自己的,如果不是则向发送方发送ICMP包通知这个错误。如果是给自己的包,再检查包是否被分片(这里是指路由器对IP包数据的分片,而不是TCP对HTTP消息的分片),如果是分片的包则暂时存放到内存,等所有所有分片到达后再组装还原为原始包。此时我们完成了包的接收。
7.检查IP头部的协议号字段将包转交给对应的模块,如协议号为06则转交给TCP模块,协议号为11转交给UDP模块。假设这个包是交给TCP模块处理。
8. TCP模块会根据TCP头部控制位来决定怎么处理这个包,当控制位SYN为1时,TCP会执行连接的操作,先根据接收方端口找到服务器上具有相同端口号且处于等待连接状态的套接字,如果没有找到指定套接字则向客户端返回错误通知的包。如果找到了等待连接的套接字,就为这个套接字复制一个副本,并将发送方IP和端口、序号初始值和窗口大小(接收缓冲区的大小)写入这个副本套接字中,并分配用于发送缓冲区和接收缓冲区(这两个缓冲区用于存放TCP或UDP不含头部数据)的内存空间。
然后再生成ACK号,服务器向客户端发送数据的序号初始值、窗口大小,用这些信息生成TCP头部,委托IP模块发送给客户端。
这个包到达客户端之后,客户端会返回表示接收确认的ACK号,当这个ACK号返回服务器后,连接操作就完成了。
接下来会进入数据收发阶段。
9.数据收发阶段,客户端的数据包到达服务端后,根据双方端口以及接收方IP找到服务端用于和该客户端通信的套接字,从这个套接字获取上一次保存的序号和上一次的数据长度,根据这个序号和长度计算出新序号与这次数据包的序号比对。如果一致说明没有包丢失,TCP模块会从包中提取出数据,存放到接收缓冲区。同时TCP模块生成应答的ACK包,这个包只有头部,没有数据,头部的ACK号根据本次包的序号和数据长度计算得出,应答包被委托给IP模块发送给客户端。
10. 接下来连接会断开,对于短连接而言(HTTP1.0),服务器是主动断开连接的一方。服务器程序调用socket库的close,TCP模块会生成一个FIN位为1的TCP头部委托IP模块发送给客户端。客户端收到这个包会返回一个ACK的应答包。等客户端接收到所有服务端发送的响应数据包后,客户端也会调用close发起关闭连接,发送一个FIN=1的包给服务器,服务器再返回ACK=1的包。在HTTP1.1中,客户端先发起断开连接。但无论哪种情况,主动发起断开连接的一方在断开连接后,套接字会经过一段时间再被删除。
5. web服务器程序解析请求消息作出响应
本小点(web服务器程序解析请求消息作出响应)的内容其实应该在上一小点中的步骤9中发生,即通信双方数据收发阶段发生的。
服务端调用socket库的read得到http消息并传递到应用程序的内存,应用程序会先解析http消息的请求行,根据URI和请求方法找到服务器上对应的静态资源或动态的生成响应消息。
如果请求的是静态资源,则URI会对应服务器磁盘中的某个文件,但是URI和服务器的文件路径是不会完全对应的,否则意味着磁盘上所有的文件都能被访问,这很危险。因此Web服务器所公开的目录不是磁盘实际目录,而是一个虚拟目录,而真实目录和这个虚拟目录会有一个映射关系,这个映射关系会配置在服务器程序的配置文件中,如nginx和apache的配置文件针对www.abc.com这个域名的请求会将虚拟目录的根目录(/)映射到真实目录的/var/www/abc目录下,这样所有uri就都只能请求该/var/www/abc目录下的文件。
如果请求的是一个程序而非静态资源(例如通过判断后缀为.cgi或.php得知请求的是动态资源),那么服务器不会直接返回这个程序文件,而是运行这个程序。接收http消息的web服务器软件(如nginx)可能无法直接运行uri指定的程序(如php文件),此时web服务器需要和php程序建立连接并转发请求给php程序运行(委托操作系统去运行这个php程序),这里的php程序我们称为cgi程序。cgi程序生成数据后会返回给web服务器再委托协议栈(应用程序调用socket库的write方法)返回给客户端。一般而言cgi程序不是在请求到来的时候才启动,而是会预先启动多个长驻系统的cgi程序等待请求的到来。
此外,web服务器还有过滤请求的功能,通过指定客户端的IP、域名以及某些uri的黑白名单方式过滤掉一些客户端的请求。
6. 浏览器接收响应并显示内容
这是整个网络请求传输的终点,web服务器发送的响应消息被分成多个包发送给客户端,客户端协议栈接收之后将包里面的响应数据组装起来交给客户端应用程序,也就是浏览器。
浏览器需要根据响应消息中的数据类型(响应头中的content-type)决定如何处理这个响应内容。还要根据content-encoding头部判断数据的编码方式以便解编码。
如果数据类型是html,则还会解析html中的标签,遇到如<script>,<img>和<link>这样的标签还会在对这些静态资源发起请求。
最终页面被渲染出来。
至此,从浏览器输入网址到页面响应的整个过程就完成了。