服务端不回应客户端的syn握手,连接建立失败原因排查
背景
测试环境有一个后台服务,部署在内网服务器A上(无外网地址),给app提供接口。app访问这个后台服务时,ip地址是公网地址,那这个请求是如何到达我们的内网服务器A呢,这块我咨询了网络同事,我画了简图如下:
请求会直接打到防火墙上,防火墙对请求先做了DNAT转换(将目的地址转换为后台服务器的地址192.168.1.3),另外,为了确保后台服务处理完请求后,能正常返回响应,所以,防火墙还做了SNAT转换(将源地址转换为防火墙的内网地址192.168.1.2)。
其实我之前测试过,不做SNAT也可以正常回包,但我只是一个开发,网工这块并不了解,所以网络同事肯定是有自己的其他考虑,总之,外网发来的进入后台服务器的报文,其源ip都变成了防火墙的ip。
简单而言,这也是一个典型的NAT环境。
再来说,我们遇到啥问题。我们这次变更,在192.168.1.3这个服务器上,加了个openresty(nginx增强版本),由openresty承接请求,然后反向代理到后台服务。
结果,测试同事反馈,app发出去的一些包,在三次握手的第一次握手就失败了。
当时,是在后台服务的机器上抓包,发现:app侧,好几个请求发了syn,但是后端没有回应,然后一直重传syn,重传n次后放弃。
我一想,这难道是本次引入的openresty组件的问题?这要是上线了还得了,赶紧查查。
排查过程
先是自己在本地开发环境试了好久,app被我玩得死去活来,并没有复现问题。
由于本周测试同事休假了,然后,我自己在测试环境又玩了好久,还是没有复现问题,但是之前,测试同事复现了好些次,我当时也在场。怎么,这次我自己就复现不出来呢?
复现不出来就在网上随便逛逛,然后找到一些文章,说了一些可能的原因,至于是哪个原因,那得执行命令来确诊。
// 检查指标,看看有没有因为时间戳丢弃syn包的情况
netstat -s |egrep -e SYNs -e "time stamp"
我一看,果然和网上说的对得上:tcp_tw_recycle参数在nat环境下,触发了linux的paws机制,导致丢包。
这个paws机制在nat环境下丢包,开启的前提是,服务器上打开了如下参数:
[root@VM-0-6-centos ~]# sysctl -a |egrep -e tcp_tw_recycle -e tcp_timestamps
net.ipv4.tcp_timestamps = 1
net.ipv4.tcp_tw_recycle = 1
此时,我大概感觉就是这么个事情了,然后了解了这个问题场景后,果然在本地复现了,复现后就是照着改,然后问题就解决了。
解决不代表结束,我们得详细了解下这个机制,这个机制启动的参数中有一个net.ipv4.tcp_tw_recycle
,这个参数,乍一看,是回收time_wait状态的socket,而在网上搜索linux time_wait
时,出来的第一页的答案,很多都会跟你说,改下面的参数,改了就好了:
vi /etc/sysctl.conf
编辑文件,加入以下内容:
net.ipv4.tcp_syncookies = 1
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_tw_recycle = 1
net.ipv4.tcp_fin_timeout = 30
改了有什么后果呢,不知道。这就在某些场景下埋下了隐患。要讲清楚这个问题,还得先了解下这个time_wait。
time_wait
time_wait是什么
请看状态变迁图:
大家知道四次挥手关闭连接吧,其中,首先发起挥手的一方(或者叫:首先发起关闭连接的一方),在经历FIN_WAIT_1/CLOSING/FIN_WAIT_2等路径后,最终会进入time_wait状态。
其实,此时已经完成了4次挥手,为什么连接不直接进入关闭状态呢,为啥还要等到2MSL后,才能进入关闭状态呢?
既然tcp协议组设计了这么一个状态,自然是为了解决某些问题。在讲它能解决的问题之前,我们先简单实践下,看看什么情况下会出现该状态。
在什么地方出现该状态
在我们传统的cs模型里,app/web网页是客户端,后端是服务端,那么,一定是app端/web网页发起主动关闭吗,不见得。后端也可以主动发起挥手。
按照我们上面的说法,主动关闭方最终进入time_wait状态。
看下面的例子,我本地telnet 10.80.121.114 9900
(9900是一个nginx进程在监听)后,在dos框里随便输,这时就会导致后端主动关闭连接。
此时,在后端就会出现time_wait:
[root@xxx-access ~]# netstat -ntp|grep 9900
tcp 0 0 10.80.121.114:9900 10.0.235.78:14966 TIME_WAIT -
所以,因为后端发起挥手,所以后端进入time_wait。
另外,测试了下本机发起http短连接的场景,此时也是由后端主动发起挥手的,因此,也是后端进入time_wait。
GET / HTTP/1.1
Connection: close
User-Agent: PostmanRuntime/7.32.3
Accept: */*
Host: 10.80.121.114:9900
Accept-Encoding: gzip, deflate, br
time_wait的危害
发起挥手的一端,进入time_wait后,会在该状态下持续一段时间,协议规定这个时间等于2MSL,这个时间还是比较长的,以分钟为单位。
在这个时间段内,不能出现重复的四元组。大家看如下的例子。
我在服务器上去telnet 百度,正常来说,我可以打开n个和百度服务器之间的tcp连接。但每个连接都需要耗费一个本地的端口。如果我们在耗尽本地可用的端口后,会出现什么事情呢?
我们先设置本机只运行使用两个端口:
echo "61000 61001" > /proc/sys/net/ipv4/ip_local_port_range
首先,打开一个shell,执行:
## 110.242.68.4是www.baidu.com后的某个ip
[root@VM-0-6-centos ~]# telnet 110.242.68.4 443
Trying 110.242.68.4...
Connected to 110.242.68.4.
Escape character is '^]'.
查看状态:
[root@VM-0-6-centos ~]# netstat -ntp|grep 443
tcp 0 0 10.0.0.6:61000 110.242.68.4:443 ESTABLISHED 987835/telnet
再打开一个shell,进行同样动作后,查看:
[root@VM-0-6-centos ~]# netstat -ntp|grep 443
tcp 0 0 10.0.0.6:61000 110.242.68.4:443 ESTABLISHED 987835/telnet
tcp 0 0 10.0.0.6:61001 110.242.68.4:443 ESTABLISHED 987986/telnet
此时,本机的61000/61001端口都已被使用,此时,本机已经没有端口可用了,再执行telnet:
[root@VM-0-6-centos ~]# telnet 110.242.68.4 443
Trying 110.242.68.4...
telnet: connect to address 110.242.68.4: Cannot assign requested address
这里,我们模拟的不是time_wait状态的socket,而是established状态,但结果是一样的,因为,socket四元组不能完全一致,在服务端ip+端口+本地ip已经确定的情况下,唯一可以发生变化的就是本地端口,但是本地端口已经全被占用,因此,新的连接就无法建立。
但是,只要我们四元组其他部分可以改变,就还是可以建立连接,比如我们对www.baidu.com后的另一个ip来连接,就可以连上了:
[root@VM-0-6-centos ~]# telnet 110.242.68.3 443
Trying 110.242.68.3...
Connected to 110.242.68.3.
Escape character is '^]'.
此时状态:
[root@VM-0-6-centos ~]# netstat -ntp|grep 443
tcp 0 0 10.0.0.6:61000 110.242.68.3:443 ESTABLISHED 990953/telnet
tcp 0 0 10.0.0.6:61000 110.242.68.4:443 ESTABLISHED 990886/telnet
tcp 0 0 10.0.0.6:61001 110.242.68.4:443 ESTABLISHED 990927/telnet
这里,简单总结下,time_wait出现在主动关闭端,如果该端短时间内和对端建立了大量连接,然后又主动关闭,就会导致该端的大量端口被占用(由于端口号最大为65535,除去1-1024这些著名端口,可用的就是64000多个,也就是说短时间内,该端和对端最多建立6w多个连接再关闭,就会把这些端口全耗尽);此时,该端再想和对端建立连接,就会失败。
除了这部分的危害,其余的会额外占用内存、cpu之类的,基本不是什么太大的事情(除非在某些嵌入式设备上,我工作反正不涉及这块)。
出现大量time_wait的场景
再背一遍:只有主动关闭方才会进入time_wait。
1、外网访问我方服务
典型场景,服务提供给外网访问,且,我方服务端主动关闭连接。此时的四元组:
本端:localip + 服务端口,对端:用户外网ip + 随机端口。
但此时,由于对端ip和端口都是用户真实ip+端口,虽然出现大量time_wait,但因为四元组不重复,此时,不会导致用户连接不能建立的问题。
2、防火墙/lvs等访问业务接入层
此时,防火墙或者lvs的ip作为客户端,访问后台业务接入层nginx等。此时:
本端:防火墙 ip + 本地端口,对端:nginx + 固定端口,此时,对端ip端口固定,本端ip固定,可变的唯有本地端口,如果此时是本端主动关闭,本端就会出现大量time_wait,影响到和接入层的新连接建立。
3、我方服务接入层,访问后端真实服务
典型场景,nginx机器(本端)将请求反向代理给后端,且本端主动关闭连接。此时的四元组:
本端:nginx ip + 本地端口,对端:后端机器ip + 固定端口,此时,对端的ip和端口是固定的,本端ip固定,如果和后端发生大量短连接,就可能导致本地端口耗尽,无法建立新的连接。
4、我方后端服务,访问依赖的服务、中间件、db、第三方服务等
该场景下,如果也是我方主动关闭连接,陷入time_wait的话。此时的四元组:
本端:后端服务ip + 本地端口,对端:中间件、db等ip + 固定端口,此时,面临的是和上面2/3类似的问题。
怎么解决大量time_wait的问题
增加四元组的方式
先看看,到底需不需要解决,如上面的第一种场景,如果只考虑新连接不能建立的问题,那么,是不需要解决time_wait过多的问题;
2/3/4,理论上需要解决,如果真的有这么大的量,导致新连接无法建立的话。但是,解决的办法,很多,不考虑系统内核参数的话,只需要保证四元组不重复即可。
就像我们上面那个telnet百度的实验一样,百度有多个ip,在ip1:443上耗尽了本地端口,那可以换到百度的ip2上。
大家可以参考下面这个文章,写得很好:https://vincent.bernat.ch/en/blog/2014-tcp-time-wait-state-linux
The solution is more quadruplets.5 This can be done in several ways (in the order of difficulty to setup):
- use more client ports by setting
net.ipv4.ip_local_port_range
to a wider range;- use more server ports by asking the web server to listen to several additional ports (81, 82, 83, …);
- use more client IP by configuring additional IP on the load balancer and use them in a round-robin fashion;6 or
- use more server IP by configuring additional IP on the web server.
翻译下就是:
解决办法就是拥有更多的四元组即可。
- 更多的本地端口可供使用,通过net.ipv4.ip_local_port_range,但最大也就65535
- 更多的服务端端口,如服务端可以监听81/82/83,多个端口都可以处理请求
- 更多的客户端ip
- 更多的服务端ip
复用time_wait(仅适用连接发起方)
上面我们说,time_wait会导致新连接无法建立。但是,如果打开了参数:net.ipv4.tcp_tw_reuse = 1
,新连接建立的时候,就可以在time_wait状态下已持续超过1s的那些socket中选一个来用。
我们怎么知道哪些socket在time_wait状态已经持续超过1s了呢,那就依赖另一个参数:
net.ipv4.tcp_timestamps = 1 (默认就是1)
这个参数是默认打开的,它会给socket关联上一个时间戳。
这块具体的,还是参考文章吧:
https://vincent.bernat.ch/en/blog/2014-tcp-time-wait-state-linux
总之,这是一个推荐的方案,适用于time_wait过多,新连接建立不起来的问题。但是,注意,这个只适用于发起连接的一方,不适用于接收连接的一方。
回收time_wait(适用于连接发起方、连接接收方)
终于来到了恶名昭彰的参数:net.ipv4.tcp_tw_recycle
. 它也是依托于net.ipv4.tcp_timestamps
参数才能生效。
这个参数,首先会加快time_wait状态socket的回收,如果你在服务器上执行netstat,经常看不到time_wait状态的socket,那就很可能是打开了这个参数。
其次,它还有个副作用,它会启用一个叫做PAWS(PAWS : Protection Against Wrapping Sequence)的机制。
这个机制就是解决sequence回绕的问题,比如,本端和对端建立了一个连接,此时开始发送请求,假设seq为1,数据包长度100. 但这个包在路由给对端的时候,可能进入了某个异常的路由器,被阻塞了,迟迟未能到达对端。
这时,本端就会重传这个seq=1的包,本次,走了一条比较快的路由,到达对端了。
接下来,我们又和对端进行了很多交互,seq来到了最大值附近,最大为2*32次方 - 1。此时,我们关闭本次连接。
接下来,我们又建立了一个新的连接(正巧,四元组和刚关闭的这个一致),由于seq已经最大,发生了回绕,变成了从头开始,此时,我们又发了一个seq为1,数据包长度200的包给对端;而此时,之前上一轮那个走了歧路的包,意外到达对端了,此时,对端就会认为第一轮那个包是ok的,反而把我们本轮的包给丢了。
我找了个网图(侵删):
为了解决这个问题,就引入了时间戳机制,每个包中都带了自己本地生成的一个时间戳,而且,这个时间戳就是本地生成的,但是是递增的,比如,下面的第一个包,时间戳为96913730,第二包为:96913734
引入这个时间戳机制后,就可以解决上面的seq回绕问题了,因为每次收到这种带时间戳的包时,服务端都会维护下我方的最新时间戳,当收到在第一轮中误入歧途的包时,由于其时间戳比较小(比较老),比当前服务端维护的时间戳小,就会认定这个包有问题,直接丢弃。
但是,服务端(对端)是针对我方ip维护了一个时间戳,回到开头的例子,我方ip在通过防火墙以后,访问到后端服务时,后端服务看到的ip是防火墙的ip;那么,后端服务器就只会维护一个防火墙的最新时间戳,这是有问题的。
比如我们两个人各自用app访问服务,此时,各自本地生成的时间戳是不一致的,假设A生成的时间戳较大,此时,服务端维护的时间戳就是A生成的,接到B生成的时间戳较小的包时,就会直接丢弃。
比如,下面的第807包,时间戳为12亿左右:
而到了808包,时间戳到了2亿,这就会导致错乱:
在这期间,服务端的netstat统计可以看到,很多被拒绝的syn:
补充下:
在处理三次握手的第一次握手时,协议栈相关代码中根据时间戳丢弃syn的逻辑:
最终的解决办法
我是直接关闭了net.ipv4.tcp_tw_recycle
参数,关闭后,再测试多手机同时使用app,已经没有拒绝syn的指标继续增长的情况了。
net.ipv4.tcp_tw_recycle在新版本被删除
由于其在nat环境存在的巨大问题,基本就只剩下很少场景可以用了。后来linux 4.1内核又上了一个特性,导致这个参数彻底失效,然后在4.2版本被删除。
参考资料
https://vincent.bernat.ch/en/blog/2014-tcp-time-wait-state-linux
https://zhuanlan.zhihu.com/p/356087235
https://elixir.bootlin.com/linux/v3.10/source/net/ipv4/tcp_ipv4.c#L203
https://stackoverflow.com/questions/6426253/tcp-tw-reuse-vs-tcp-tw-recycle-which-to-use-or-both
https://blog.csdn.net/qq_25046827/article/details/131839126
https://www.ietf.org/rfc/rfc1323.txt
https://github.com/y123456yz/Reading-and-comprehense-linux-Kernel-network-protocol-stack