解决 Cannot assign requested address

有一种底气,叫做你能行!有一种豪气,叫做你可以!有一种霸气,叫做你最棒!不要总是瞻前顾后,想做的事,就大胆地做。只有迈出脚下那一步,人生才会与众不同。

背景

问题场景:

容器内访问出现以下错误:

1
Failed to establish a new connection: [Errno 99] Cannot assign requested address

网上找了下原因,大致上是由于客户端频繁的连服务器,由于每次连接都在很短的时间内结束,导致很多的TIME_WAIT,以至于用光了可用的端 口号,所以新的连接没办法绑定端口,但是使用

1
lsof -i | grep -E "TIME_WAIT"

没有发现有残留,但是

1
lsof -i | grep -E "CLOSE_WAIT"

处于 close_wait 状态的连接却是很多,高达两万多条

通过下述命令也可以查看当前端口占用及分类

1
2
3
4
5
6
netstat -n | awk '/^tcp/ {++state[$NF]} END {for(key in state) print key,"\t",state[key]}'

CLOSE_WAIT 27352
SYN_SENT 4
ESTABLISHED 58
TIME_WAIT 4

先看下端口状态的大致说明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
CLOSED: 这个没什么好说的了,表示初始状态。

LISTEN: 这个也是非常容易理解的一个状态,表示服务器端的某个SOCKET处于监听状态,可以接受连接了。

SYN_RCVD: 这个状态表示接受到了SYN报文,在正常情况下,这个状态是服务器端的SOCKET在建立TCP连接时的三次握手会话过程中的一个中间状态,很短暂,基本上用netstat你是很难看到这种状态的,除非你特意写了一个客户端测试程序,故意将三次TCP握手过程中最后一个ACK报文不予发送。因此这种状态时,当收到客户端的ACK报文后,它会进入到ESTABLISHED状态。

SYN_SENT: 这个状态与SYN_RCVD遥想呼应,当客户端SOCKET执行CONNECT连接时,它首先发送SYN报文,因此也随即它会进入到了SYN_SENT状态,并等待服务端的发送三次握手中的第2个报文。SYN_SENT状态表示客户端已发送SYN报文。

ESTABLISHED:这个容易理解了,表示连接已经建立了。

FIN_WAIT_1: 这个状态要好好解释一下,其实FIN_WAIT_1和FIN_WAIT_2状态的真正含义都是表示等待对方的FIN报文。而这两种状态的区别是:FIN_WAIT_1状态实际上是当SOCKET在ESTABLISHED状态时,它想主动关闭连接,向对方发送了FIN报文,此时该SOCKET即进入到FIN_WAIT_1状态。而当对方回应ACK报文后,则进入到FIN_WAIT_2状态,当然在实际的正常情况下,无论对方何种情况下,都应该马上回应ACK报文,所以FIN_WAIT_1状态一般是比较难见到的,而FIN_WAIT_2状态还有时常常可以用netstat看到。

FIN_WAIT_2:上面已经详细解释了这种状态,实际上FIN_WAIT_2状态下的SOCKET,表示半连接,也即有一方要求close连接,但另外还告诉对方,我暂时还有点数据需要传送给你,稍后再关闭连接。

TIME_WAIT: 表示收到了对方的FIN报文,并发送出了ACK报文,就等2MSL后即可回到CLOSED可用状态了。如果FIN_WAIT_1状态下,收到了对方同时带FIN标志和ACK标志的报文时,可以直接进入到TIME_WAIT状态,而无须经过FIN_WAIT_2状态。

CLOSING: 这种状态比较特殊,实际情况中应该是很少见,属于一种比较罕见的例外状态。正常情况下,当你发送FIN报文后,按理来说是应该先收到(或同时收到)对方的ACK报文,再收到对方的FIN报文。但是CLOSING状态表示你发送FIN报文后,并没有收到对方的ACK报文,反而却也收到了对方的FIN报文。什么情况下会出现此种情况呢?其实细想一下,也不难得出结论:那就是如果双方几乎在同时close一个SOCKET的话,那么就出现了双方同时发送FIN报文的情况,也即会出现CLOSING状态,表示双方都正在关闭SOCKET连接。

CLOSE_WAIT: 这种状态的含义其实是表示在等待关闭。怎么理解呢?当对方close一个SOCKET后发送FIN报文给自己,你系统毫无疑问地会回应一个ACK报文给对方,此时则进入到CLOSE_WAIT状态。接下来呢,实际上你真正需要考虑的事情是察看你是否还有数据发送给对方,如果没有的话,那么你也就可以close这个SOCKET,发送FIN报文给对方,也即关闭连接。所以你在CLOSE_WAIT状态下,需要完成的事情是等待你去关闭连接。

LAST_ACK: 这个状态还是比较容易好理解的,它是被动关闭一方在发送FIN报文后,最后等待对方的ACK报文。当收到ACK报文后,也即可以进入到CLOSED可用状态了。

要详细了解端口状态,需要从TCP的状态转换说起。

TCP协议解析

报文格式

在这里插入图片描述

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
16 位源端口,16 位目的端口:
数据从何而来,去向何方。

32 位序号,32 位确认序号:
和 TCP 的 ACK 机制有关,发送端给数据进行编号,接收端收到数据后确认收到哪些编号的数据。

4 位报头长度:
表示 TCP 的首部占用多少个 4 字节。

6 个标志位:
SYN:简写为S,同步标志位,用于建立会话连接,同步序列号;
ACK:简写为.,确认标志位,对已接收的数据包进行确认;
FIN:简写为F,完成标志位,表示我已经没有数据要发送了,即将关闭连接;
PSH:简写为P,推送标志位,表示该数据包被对方接收后应立即交给上层应用,而不在缓冲区排队;
RST:简写为R,重置标志位,用于连接复位、拒绝错误和非法的数据包;
URG:简写为U,紧急标志位,表示数据包的紧急指针域有效,用来保证连接不被阻断,并督促中间设备尽快处理;

16 位窗口大小:
和 TCP 滑动窗口相关,在下文。

16 位校验和:
发送端填充,CRC 校验,接收端校验不通过,则认为数据有问题。此处的检验和不光包含TCP首部,也包含TCP数据部分。

16 位紧急指针:
标识哪部分数据是紧急数据。

上面是对tcp发送的报文做一个简单介绍,没兴趣的可以跳过

连接管理

先放个总图

img

建立连接,三次握手

三次握手

服务端:通过 socket,bind 和 listen 完成了被动套接字的准备工作,被动的意思就是等着别人来连接,然后调用 accept,就会阻塞在这里,等待客户端的连接来临;

客户端:通过调用 socket 和 connect 函数之后,也会阻塞。接下来的事情是由操作系统内核完成的,更具体一点的说,是操作系统内核网络协议栈在工作。

流程

  1. 客户端的协议栈向服务器端发送了 SYN 包,并告诉服务器端当前发送序列号 j,客户端进入 SYNC_SENT 状态;
  2. 服务器端的协议栈收到这个包之后,和客户端进行 ACK 应答,应答的值为 j+1,表示对 SYN 包 j 的确认,同时服务器也发送一个 SYN 包,告诉客户端当前我的发送序列号为 k,服务器端进入 SYNC_RCVD 状态;
  3. 客户端协议栈收到 ACK 之后,使得应用程序从 connect 调用返回,表示客户端到服务器端的单向连接建立成功,客户端的状态为 ESTABLISHED,同时客户端协议栈也会对服务器端的 SYN 包进行应答,应答数据为 k+1;
  4. 应答包到达服务器端后,服务器端协议栈使得 accept 阻塞调用返回,这个时候服务器端到客户端的单向连接也建立成功,服务器端也进入 ESTABLISHED 状态。

客户端状态变化

  • 客户端调用 socket() 后,进入 CLOSED 状态。
  • 客户端调用 connect(),发送 SYN 报文,进入 SYN_SENT 状态。
  • 客户端在收到刚刚 SYN 报文的 ACK 后,进入 ESTABLISHED 状态,从 connect() 返回。

服务端状态变化

  • 服务器调用 socket() 后,进入 CLOSED 状态。
  • 服务器调用 bind(), listen() 后进入 LISTEN 状态,等待客户端连接,阻塞在 accept()。
  • 收到 SYN 报文后进入 SYN_RCVD 状态,就将该连接放入内核等待队列中,返回 SYN + ACK 报文。
  • 在客户端收到后,发送 ACK 报文,服务器从 accept() 返回,进入 ESTABLISHED 状态。
  • 至此连接建立完成,客户端、服务端都进入了 已连接 状态,即 ESTABLISHED。

抓包数据

tcpdump -i eth0 -S

1
2
3
07:37:06.157597 IP 192.168.1.10.945 > 192.168.1.141.nfs: Flags [S], seq 2084759064, win 14600, options [mss 1460,sackOK,TS val 223408 ecr 0,nop,wscale 2], length 0
07:37:06.157660 IP 192.168.1.141.nfs > 192.168.1.10.945: Flags [S.], seq 2467243379, ack 2084759065, win 28960, options [mss 1460,sackOK,TS val 91684817 ecr 223408,nop,wscale 7], length 0
07:37:06.158889 IP 192.168.1.10.945 > 192.168.1.141.nfs: Flags [.], ack 2467243380, win 3650, options [nop,nop,TS val 223408 ecr 91684817], length 0
  • 客户端–>服务器: seq 2084759064,
  • 服务器–>客户端: seq 2467243379, ack 2084759065 //ack等于 客户端2084759064+1
  • 客户端–>服务器: ack 2467243380

断开连接,四次挥手

在这里插入图片描述

流程:

挥手请求可以是Client端,也可以是Server端发起的,我们假设是Client端发起(主机1为client)

  1. 第一次:Client端发起挥手请求,向Server端发送标志位是FIN报文段,设置序列号seq,此时,Client端进入FIN_WAIT_1状态,这表示Client端没有数据要发送给Server端了。
  2. 第二次:Server端收到了Client端发送的FIN报文段,向Client端返回一个标志位是ACK的报文段,ack设为seq加1,Client端进入FIN_WAIT_2状态,Server端告诉Client端,我确认并同意你的关闭请求。
  3. 第三次:Server端向Client端发送标志位是FIN的报文段,请求关闭连接,同时Client端进入LAST_ACK状态。
  4. 第四次:Client端收到Server端发送的FIN报文段,向Server端发送标志位是ACK的报文段,然后Client端进入TIME_WAIT状态。Server端收到Client端的ACK报文段以后,就关闭连接。此时,Client端等待2MSL的时间后依然没有收到回复,则证明Server端已正常关闭,那好,Client端也可以关闭连接了。

抓包分析

挥手包4条

说明:localhost.45788是客户端,localhost.ssh是服务器

1
2
3
4
23:13:14.956630 IP localhost.45788 > localhost.ssh: Flags [F.], seq 3349160669, ack 377690074, win 342, options [nop,nop,TS val 1927014999 ecr 1927003705], length 0
23:13:14.956738 IP localhost.ssh > localhost.45788: Flags [.], ack 3349160670, win 342, options [nop,nop,TS val 1927015000 ecr 1927014999], length 0
23:13:14.959316 IP localhost.ssh > localhost.45788: Flags [F.], seq 377690074, ack 3349160670, win 342, options [nop,nop,TS val 1927015002 ecr 1927014999], length 0
23:13:14.959328 IP localhost.45788 > localhost.ssh: Flags [.], ack 377690075, win 342, options [nop,nop,TS val 1927015002 ecr 1927015002], length 0

在这里插入图片描述

客户端、服务器状态变化

  • 客户端调用 close() ,发送一个FIN报文给服务器,客户端进入 FIN_WAIT_1 状态;
  • 服务器收到 FIN 报文,响应一个ACK,也进入CLOSE_WAIT状态。
  • 服务器 read() 获取到了EOF,则执行 close() ,发送FIN报文,此时进入 LAST_ACK 状态;
  • 客户端收到服务器关于FIN的ACK报文,进入FIN_WAIT_2状态;
  • 客户端收到服务器的FIN报文,进入 TIME_WAIT 状态,发送FIN的响应ACK给服务器;
  • 客户端会在 TIME_WAIT 状态等待2MSL,然后进入CLOSE状态;

挥手包3条

说明:192.168.1.10.32823是客户端,192.168.1.141.9090是服务器

1
2
3
11:34:53.378185 IP 192.168.1.10.32823 > 192.168.1.141.9090: Flags [F.], seq 187740589, ack 2824594749, win 3650, options [nop,nop,TS val 79322 ecr 92901547], length 0
11:34:53.378331 IP 192.168.1.141.9090 > 192.168.1.10.32823: Flags [F.], seq 2824594749, ack 187740590, win 227, options [nop,nop,TS val 92904590 ecr 79322], length 0
11:34:53.379595 IP 192.168.1.10.32823 > 192.168.1.141.9090: Flags [.], ack 2824594750, win 3650, options [nop,nop,TS val 79322 ecr 92904590], length 0

在这里插入图片描述

出现的原因:这是TCP的捎带ACK机制,服务器将FIN的ACK和自己的FIN包一起发给了客户端

问题思考

问题1:为什么客户端在TIME-WAIT状态必须等待2MSL的时间?

答:MSL最长报文段寿命Maximum Segment Lifetime,MSL=2

两个理由:1)保证A发送的最后一个ACK报文段能够到达B。2)防止“已失效的连接请求报文段”出现在本连接中。

  • 1)这个ACK报文段有可能丢失,使得处于LAST-ACK状态的B收不到对已发送的FIN+ACK报文段的确认,B超时重传FIN+ACK报文段,而A能在2MSL时间内收到这个重传的FIN+ACK报文段,接着A重传一次确认,重新启动2MSL计时器,最后A和B都进入到CLOSED状态,若A在TIME-WAIT状态不等待一段时间,而是发送完ACK报文段后立即释放连接,则无法收到B重传的FIN+ACK报文段,所以不会再发送一次确认报文段,则B无法正常进入到CLOSED状态。
  • 2)A在发送完最后一个ACK报文段后,再经过2MSL,就可以使本连接持续的时间内所产生的所有报文段都从网络中消失,使下一个新的连接中不会出现这种旧的连接请求报文段。

问题2:为什么连接的时候是三次握手,关闭的时候却是四次握手?

答:因为当Server端收到Client端的SYN连接请求报文后,可以直接发送SYN+ACK报文。其中ACK报文是用来应答的,SYN报文是用来同步的。但是关闭连接时,当Server端收到FIN报文时,很可能并不会立即关闭SOCKET,所以只能先回复一个ACK报文,告诉Client端,”你发的FIN报文我收到了”。只有等到我Server端所有的报文都发送完了,我才能发送FIN报文,因此不能一起发送。故需要四步握手。

问题3:为什么TIME_WAIT状态需要经过2MSL(最大报文段生存时间)才能返回到CLOSE状态?

答:虽然按道理,四个报文都发送完毕,我们可以直接进入CLOSE状态了,但是我们必须假象网络是不可靠的,有可以最后一个ACK丢失。所以TIME_WAIT状态就是用来重发可能丢失的ACK报文。

根据上面的知识,我们再看下两种状态,而且一般来说,如果服务器出了异常,百分之八九十都是下面两种情况:

  • 服务器保持了大量TIME_WAIT状态
  • 服务器保持了大量CLOSE_WAIT状态

因为linux分配给一个用户的文件句柄是有限的,而TIME_WAIT和CLOSE_WAIT两种状态如果一直被保持,那么意味着对应数目的通道就一直被占着,而我们上面的背景中问题就是因为出现太多的CLOSE_WAIT。

下面来讨论下这两种情况的处理方法,网上有很多资料把这两种情况的处理方法混为一谈,以为优化系统内核参数就可以解决问题,其实是不恰当的,优化系统内核参数解决TIME_WAIT可能很容易,但是应对CLOSE_WAIT的情况还是需要从程序本身出发。现在来分别说说这两种情况的处理方法

TIME_WAIT 状态

TIME_WAIT: 表示收到了对方的FIN报文,并发送出了ACK报文,就等2MSL后即可回到CLOSED可用状态了。如果FIN_WAIT_1状态下,收到了对方同时带FIN标志和ACK标志的报文时,可以直接进入到TIME_WAIT状态,而无须经过FIN_WAIT_2状态。

服务器保持了大量TIME_WAIT状态

这种情况比较常见,一些爬虫服务器或者WEB服务器(如果网管在安装的时候没有做内核参数优化的话)上经常会遇到这个问题,这个问题是怎么产生的呢?

TIME_WAIT是主动关闭连接的一方保持的状态,对于爬虫服务器来说他本身就是“客户端”,在完成一个爬取任务之后,他就会发起主动关闭连接,从而进入TIME_WAIT的状态,然后在保持这个状态2MSL(max segment lifetime)时间之后,彻底关闭回收资源。至于为啥要等,上面已经说过。

如何来解决这个问题

解决思路很简单,就是不要让处于TIME_WAIT的端口占满所有本地端口,导致没有新的本地端口用来创建新的客户端。

1. 别让客户端的速率太快

将客户端请求的速率降下来就可以避免端时间占用大量的端口,吞吐量限制就是470tps或者235tps,具体根据系统TIME_WAIT默认时长决定,如果考虑到其他服务正常运行这个值还要保守一些才行;此外还需要注意,如果客户端和服务端增加了一层NAT或者L7负载均衡,那么这个限制可能会在负载均衡器上面;

2. 客户端改成长连接的形式

长连接效率高又不会产生大量TIME_WAIT端口。目前对我们来说还是不太现实的,虽然HTTP支持长连接,但是CGI调用应该是不可能的了,除非用之前的介绍的方式将CGI的请求转换成HTTP服务来实现。对于一般socket直连的程序来说,短连接改成长连接就需要额外的封装来标识完整请求在整个字节流中的起始位置,需要做一些额外的工作;

3. SO_LINGER选项

通常我们关闭socket的时候,即使该连接的缓冲区有数据要发送,close调用也会立即返回,TCP本身会尝试发送这些未发送出去的数据,只不过应用程序不知道也无法知道是否发送成功过了。如果我们将套接字设置SO_LINGER这个选项,并填写linger结构设置参数,就可以控制这种行为:
如果linger结构的l_onoff==0,则linger选项就被关闭,其行为就和默认的close相同;如果打开,那么具体行为依据另外一个成员l_linger的值来确定:如果l_linger!=0,则内核会将当前close调用挂起,直到数据都发送完毕,或者设置的逗留时间超时返回,前者调用会返回0并且正常进入TIME_WAIT状态,后者调用会返回EWOULDBLOCK,所有未发送出去的数据可能会丢失(此处可能会向对端发送一个RST而快速关闭连接);如果l_linger==0,则直接将缓冲区中未发送的数据丢弃,且向对等实体发送一个RST,自己不经过TIME_WAIT状态立即关闭连接。
我们都认为TIME_WAIT是TCP机制的正常组成部分,应用程序中不应该依赖设置l_linger=0这种机制避免TIME_WAIT。

4. 修改系统参数

  • 增加本地端口范围,修改net.ipv4.ip_local_port_range,虽然不能解决根本问题但情况可以得到一定的缓解;

  • 缩短TIME_WAIT的时间。这个时长在书中描述到RFC推荐是2min,而BSD实现通常是30s,也就说明这个值是可以减小的,尤其我们用在内网通信的环境,数据包甚至都流不出路由器,所以根本不需要设置那么长的TIME_WAIT。这个很多资料说不允许修改,因为是写死在内核中的;也有说可以修改netfilter.ip_conntrack_tcp_timeout_time_wait(新版本nf_conntrack_tcp_timeout_time_wait)的,他们依赖于加载nf_conntract_ipv4模块,不过我试了一下好像不起作用。

  • 像之前在项目中推荐的,做出如下调整:

1
2
3
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_timestamps=1
net.ipv4.tcp_tw_recycle=1

很多文献说这种设置是不安全的,所以在测试环境以外就别尝试了,因为这些选项还涉及到timestamp特性,我还不清楚什么回事,后面有时间再看什么吧。

我们在开发服务端的时候,通常都会设置SO_REUSEADDR这个选项。其实像上面描述到的,该选项也牵涉到侦听socket端口处于TIME_WAIT的情况,设置这个选项将允许处于TIME_WAIT的端口进行绑定

CLOSE_WAIT 状态

CLOSE_WAIT: 这种状态的含义其实是表示在等待关闭。怎么理解呢?当对方close一个SOCKET后发送FIN报文给自己,你系统毫无疑问地会回应一个ACK报文给对方,此时则进入到CLOSE_WAIT状态。接下来呢,实际上你真正需要考虑的事情是察看你是否还有数据发送给对方,如果没有的话,那么你也就可以close这个SOCKET,发送FIN报文给对方,也即关闭连接。所以你在CLOSE_WAIT状态下,需要完成的事情是等待你去关闭连接。

TIME_WAIT状态可以通过优化服务器参数得到解决,因为发生TIME_WAIT的情况是服务器自己可控的,要么就是对方连接的异常,要么就是自己没有迅速回收资源,总之不是由于自己程序错误导致的。

但是CLOSE_WAIT就不一样了,从上面的图可以看出来,如果一直保持在CLOSE_WAIT状态,那么只有一种情况,就是在对方关闭连接之后服务器程序自己没有进一步发出ack信号。换句话说,就是在对方连接关闭之后,程序里没有检测到,或者程序压根就忘记了这个时候需要关闭连接,于是这个资源就一直被程序占着。个人觉得这种情况,通过服务器内核参数也没办法解决,服务器对于程序抢占的资源没有主动回收的权利,除非终止程序运行。

所以如果将大量CLOSE_WAIT的解决办法总结为一句话那就是:查代码。因为问题出在服务器程序里头啊。

两种状态区别

场景说明:服务器A是一台爬虫服务器,它使用简单的HttpClient去请求资源服务器B上面的apache获取文件资源,正常情况下,如果请求成功,那么在抓取完资源后,服务器A会主动发出关闭连接的请求,这个时候就是主动关闭连接,服务器A的连接状态我们可以看到是TIME_WAIT。如果一旦发生异常呢?假设请求的资源服务器B上并不存在,那么这个时候就会由服务器B发出关闭连接的请求,服务器A就是被动的关闭了连接,如果服务器A被动关闭连接之后程序员忘了让HttpClient释放连接,那就会造成CLOSE_WAIT的状态了。

解决方案分析

解决思路-1

上面我们已经知道了连接处于 CLOSE_WAIT 状态是怎么回事了,一句话总结一下,就是 CLOSE_WAIT是被动关闭连接是形成的,即服务端主动断开了连接。那么问题来了,服务端为什么会主动断开连接,而不是客户端去处理?再有,我们知道,客户端断开连接会正常进行四次挥手的过程,那么服务端主动断开的话,会出现什么情况?

TCP 两端分别主动断开连接分析

正常退出情况

首先我们来看看一次服务器和客户端正常通信的情况。

这里写图片描述

当我们只打开服务端时,此时由于客户端没有向服务端发出连接请求,所以服务端此时处在LISTEN状态,这个状态称为监听状态,表示服务端在等待客户端的连接请求报文。

这里写图片描述

这次我们打开客户端,并且通过客户端向服务端发送数据,服务端页正常接收到了数据,此时服务端和客户端都处于ESTABLISHED状态,表示连接已经建立完成,即此时三次握手这个过程已经完毕,但是大家应该清楚,中间还有两次状态的切换客户端的SYN-SENT 以及 服务端的SYN-RCV.由于我们是手动监视,所以我们不能显示的看到这两个状态。

这里写图片描述

上图中,客户端在数据通信完成后想要结束掉与服务端的连接状态,我们直接通过按Ctrl + C 键向客户端发送了信号将客户端终止,此时,服务端应该也会向客户端发送ACK 字段的报文 相当于我们完成了四次挥手过程中的前两次挥手,通过指令的查看,我们发现结果确实也是那样,随后我们在Ctrl + C 掉服务端,完成了后两次挥手,整个通信过程也就完成了。

异常终止的情况

上面的正常过程中,我们是让客户端比服务端先退出,这样满足了四次挥手过程的要求;现在我们让服务器在客户端前面退出,我们来看看这个过程发生了什么。
由于我们研究的情况是连接的异常终止情况,所以三次握手状态我们就不关心了,这里我们这研究连接的异常终止情况。

这里写图片描述

上图中,我们让服务端先退出,然后我们用netstat观察端口的状态,此时我们发现四次挥手过程中服务器和客户端的状态颠倒了, 也就是说,服务端和客户端的进程那个先向对方发送FIN 字段报文,那么哪个就先进入FIN_WAIT2状态。

这里写图片描述

上图发生的原因是这样的,当服务器进程被终止时,会关闭其打开的所有文件描述符,此时就会向客户端发送一个FIN 的报文,客户端则响应一个ACK 报文,但是这样只完成了“四次挥手”的前两次挥手,也就是说这样只实现了半关闭,客户端仍然可以向服务器写入数据。
但是当客户端向服务器写入数据时,由于服务器端的套接字进程已经终止,此时连接的状态已经异常了,所以服务端进程不会向客户端发送ACK 报文,而是发送了一个RST 报文请求将处于异常状态的连接复位; 如果客户端此时还要向服务端发送数据,将诱发服务端TCP向客户端发送SIGPIPE信号,因为向接收到RST的套接口写数据都会收到此信号. 所以说,这就是为什么我们主动关闭服务端后,用客户端向服务端写数据,还必须是写两次后连接才会关闭的原因。

大家可以在自己的Linux系统实验一下,用tcpdump去抓包效果会更好。

解决思路-2

上面我们已经知道了当 server 端主动断开的时候,也会正常完成四次挥手的过程,但是需要客户端主动关闭,或者直接程序结束,由 language 的 gc 机制来回收资源,那么上面的问题就很明朗了,要么就是 client 没有进行回收,要么进程没结束。这个需要具体看下代码才能知道,下面看下对我的解决方案。

解决方案

由于我用的是 python 的 requests 库来进行 api 请求,所以简单说下两个方案:

  1. 客户端调用 close 强制回收;
  2. 客户端打开 Socket 的 keepalive 机制,通过类似心跳的功能解决。

方案一

调用 close 方案不用说,直接调用类似 request.Session().close() 即可;

方案二

修改 socket 的 keepalive 属性,在 python 的 requests 库中这个属性是默认没有配置的(在linux中默认值是0,即关闭),在该库中对 socket 的设置的默认值只有一个 (socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) ,这个是用来控制是否开启Nagle算法,该算法是为了提高较慢的广域网传输效率,减小小分组的报文个数,完整描述:

该算法要求一个TCP连接上最多只能有一个未被确认的小分组,在该小分组的确认到来之前,不能发送其他小分组。

这里的小分组指的是报文长度小于MSS(Max Segment Size)长度的分组(MSS是在TCP握手的时候在报文选项里面进行通告的大小,主要是用来限制另一端发送数据的长度,防止IP数据包被分段,提高效率,一般是链路层的传输最大传输单元大小减去IP首部与TCP首部大小)。

如果小分组的确认ACK一直没有回来,那么就可能会触发TCP超时重传的定时器。

下面是一个简单的示意图,开启了Nagle算法与没有开启:

img

这也是为什么 linux 的 /proc/sys/net/ipv4/tcp_keepalive_time 没有生效的原因,由于没有给socket带上keepalive的标签,linux在该socket异常以后并不会对其进行回收。

偏题了,回来一下,那么怎么修改 socket 值呢,具体代码不便贴上,给个思路:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def request():
adapter = TCPKeepAliveAdapter(
socket_options=[(socket.SOL_SOCKET, socket.SO_KEEPALIVE,1),
(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE,30),
(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL,3)])
s = requests.session()
s.mount("http://", adapter)
s.mount("https://", adapter)
return s

class TCPKeepAliveAdapter(requests.adapters.HTTPAdapter):
def __init__(self, *args, **kwargs):
self.socket_options = kwargs.pop("socket_options", None)
super(TCPKeepAliveAdapter, self).__init__(*args, **kwargs)

def init_poolmanager(self, *args, **kwargs):
if self.socket_options is not None:
kwargs["socket_options"] = self.socket_options
super(TCPKeepAliveAdapter,
self).init_poolmanager(*args, **kwargs)

通过实现 requests.adapters.HTTPAdapterinit_poolmanager 方法给 socket_options 赋值来修改 socket 的属性。

是否有人会感到疑惑,在python库requests给服务端发请求的时候,明明已经在Header设置了 'Connection': 'keep-alive'

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Session(SessionRedirectMixin):
def __init__(self):
self.headers = default_headers()
...
...

def default_headers():
"""
:rtype: requests.structures.CaseInsensitiveDict
"""
return CaseInsensitiveDict({
'User-Agent': default_user_agent(),
'Accept-Encoding': ', '.join(('gzip', 'deflate')),
'Accept': '*/*',
'Connection': 'keep-alive',
})

为什么没有生效呢?

HTTP keep-alive 和 TCP keepalive 原理和区别

不管是在OSI七层网络模型还是在TCP/IP五层网络模型中,TCP是传输层的一种协议,而HTTP是应用层的一种协议

在这里插入图片描述

HTTP中是keep-alive,TCP中是keepalive,HTTP中是带中划线的。大小写无所谓

TCP Keepalive

起源

双方建立交互的连接,但是并不是一直存在数据交互,有些连接会在数据交互完毕后,主动释放连接,而有些不会,那么在长时间无数据交互的时间段内,交互双方都有可能出现掉电、死机、异常重启等各种意外,当这些意外发生之后,这些TCP连接并未来得及正常释放,那么,连接的另一方并不知道对端的情况,它会一直维护这个连接,长时间的积累会导致非常多的半打开连接,造成端系统资源的消耗和浪费,为了解决这个问题,在传输层可以利用TCP的保活报文来实现。

存在的作用

  1. 探测连接的对端是否存活

在应用交互的过程中,可能存在以下几种情况:

(1) 客户端或服务端意外断电,死机,崩溃,重启。

(2) 中间网络已经中断,而客户端与服务器并不知道。

利用保活探测功能,可以探知这种对端的意外情况,从而保证在意外发生时,可以释放半打开的TCP连接。

  1. 防止中间设备因超时删除连接相关的连接表

中间设备如防火墙等,会为经过它的数据报文建立相关的连接信息表,并未其设置一个超时时间的定时器,如果超出预定时间,某连接无任何报文交互的话,中间设备会将该连接信息从表中删除,在删除后,再有应用报文过来时,中间设备将丢弃该报文,从而导致应用出现异常,这个交互的过程大致如下图所示:

img

这种情况在有防火墙的应用环境下非常常见,这会给某些长时间无数据交互但是又要长时间维持连接的应用(如数据库)带来很大的影响,为了解决这个问题,应用本身或TCP可以通过保活报文来维持中间设备中该连接的信息,(也可以在中间设备上开启长连接属性或调高连接表的释放时间来解决,但是,这个影响可能较大,有机会再针对这个做详细的描述,在此不多说)。

TCP保活的交互过程大致如下图所示:

img

TCP保活可能带来的问题

  1. 中间设备因大量保活连接,导致其连接表满

网关设备由于保活问题,导致其连接表满,无法新建连接(XX局网闸故障案例)或性能下降严重

  1. 正常连接被释放

当连接一端在发送保活探测报文时,中间网络正好由于各种异常(如链路中断、中间设备重启等)而无法将保活探测报文正确转发至对端时,可能会导致探测的一方释放本来正常的连接,但是这种可能情况发生的概率较小,另外,一般也可以增加保活探测报文发生的次数来减少这种情况发生的概率和影响。

HTTP Keep-alive

Httpd守护进程,一般都提供了keep-alive timeout时间设置参数。比如nginx的keepalive_timeout,和Apache的KeepAliveTimeout。

这个keepalive_timeout时间值意味着:一个http产生的tcp连接在传送完最后一个响应后,还需要hold住keepalive_timeout秒后,才开始关闭这个连接。

当httpd守护进程发送完一个响应后,理应马上主动关闭响应的tcp连接,设置keepalive_timeout后,httpd守护进程会想说:”再等等吧,看看浏览器还有没有请求过来”,这一等,便是keepalive_timeout时间。如果守护进程在这个等待的时间里,一直没有收到浏览器发过来http请求,则关闭这个http连接。

  1. 在没有设置keepalive_timeout情况下,一个socket资源从建立到真正释放需要经过的时间是:建立tcp连接+传送http请求+php脚本执行+传送http响应+关闭tcp连接。

  2. 设置了keepalive_timeout时间情况下,一个socket建立到释放需要的时间是多了keepalive_timeout时间。

区别

http keep-alive与tcp keep-alive,不是同一回事,意图不一样。http keep-alive是为了让tcp活的更久一点,以便在同一个连接上传送多个http,提高socket的效率。而tcp keep-alive是TCP的一种检测TCP连接状况的保险机制。tcp keep-alive保险定时器,支持三个系统内核配置参数:

echo 1800 > /proc/sys/net/ipv4/tcp_keepalive_time

echo 15 > /proc/sys/net/ipv4/tcp_keepalive_intvl

echo 5 > /proc/sys/net/ipv4/tcp_keepalive_probes

keepalive是TCP保鲜定时器,当网路两端建立了TCP连接之后,闲置idle(双方没有任何数据发送往来)了tcp_keepalive_time后,服务器内核就会尝试向客户端发送侦测包,来判断TCP连接状况(有可能客户端崩溃、强制关闭了应用、主机不可达等等)。如果没有收到对方的回答(ack包),则会在tcp_keepalive_intvl后再次尝试发送侦测包,直到收到对方的ack,如果一直没有收到对方的ack,一共会尝试tcp_keepalive_probes次,每次的间隔时间在这里分别是15s、30s、45s、60s、75s。如果尝试tcp_keepalive_probes,依然没有收到对方的ack包,则会丢弃该TCP连接。

TCP连接默认闲置时间是2小时,一般设置为30分钟足够了。也就是说,仅当nginx的keepalive_timeout值设置高于tcp_keepalive_time,并且距此tcp连接传输的最后一个http响应,经过了tcp_keepalive_time时间之后,操作系统才会发送侦测包来决定是否要丢弃这个TCP连接。一般不会出现这种情况,除非你需要这样做。

同时,还记得我们上面说的 TIME_WAIT 状态的连接吗,大量这样的连接可能会导致服务不可用,使用http keep-alive,可以减少服务端TIME_WAIT数量(因为由服务端httpd守护进程主动关闭连接)。道理很简单,相较而言,启用keep-alive,建立的tcp连接更少了,自然要被关闭的tcp连接也相应更少了。

下面再介绍个抓包工具

tcpdump 抓包工具

写不动了,介绍两篇文章吧

抓包工具tcpdump用法说明

最全的tcpdump使用详解


参考:

TCP协议解析、tcpdump抓包分析三次握手和四次挥手

TCP三次握手和四次挥手过程

端口状态 LISTENING、ESTABLISHED、TIME_WAIT及CLOSE_WAIT详解,以及三次握手,滑动窗口

服务器出现TIME_WAIT和CLOSE_WAIT的原因以及解决方法

TIME_WAIT和CLOSE_WAIT的区别

详解Socket编程—TCP_NODELAY选项

requests.adapters.HTTPAdapter.init_poolmanager某些字段含义

Tcp Keepalive和HTTP Keepalive详解

HTTP keep-alive和TCP keepalive测试试验

Apache 开启和关闭 Keep-Alive 解决访问长连接问题

TCP协议的KeepAlive机制

-------------本文结束 感谢您的阅读-------------
作者Magiceses
有问题请 留言 或者私信我的 微博
满分是10分的话,这篇文章你给几分