TCP介绍
TCP是一种面向连接的单播协议,在发送数据前,通信双方必须在彼此间建立一条连接。所谓的“连接”,其实是客户端和服务器的内存里保存的一份关于对方的信息,如ip地址、端口号等。
TCP可以看成是一种字节流,它会处理IP层或以下的层的丢包、重复以及错误问题。在连接的建立过程中,双方需要交换一些连接的参数。这些参数可以放在TCP头部。
TCP提供了一种可靠、面向连接、字节流、传输层的服务,采用三次握手建立一个连接。采用4次挥手来关闭一个连接。
TCP三次握手
客户端和服务端通信前要进行连接,“3次握手”的作用就是双方都能明确自己和对方的收、发能力是正常的。
- 第一次握手:客户端发送网络包,服务端收到了。这样服务端就能得出结论:客户端的发送能力、服务端的接收能力是正常的。
- 第二次握手:服务端发包,客户端收到了。这样客户端就能得出结论:服务端的接收、发送能力,客户端的接收、发送能力是正常的。从客户端的视角来看,我接到了服务端发送过来的响应数据包,说明服务端接收到了我在第一次握手时发送的网络包,并且成功发送了响应数据包,这就说明,服务端的接收、发送能力正常。而另一方面,我收到了服务端的响应数据包,说明我第一次发送的网络包成功到达服务端,这样,我自己的发送和接收能力也是正常的。
- 第三次握手:客户端发包,服务端收到了。这样服务端就能得出结论:客户端的接收、发送能力,服务端的发送、接收能力是正常的。第一、二次握手后,服务端并不知道客户端的接收能力以及自己的发送能力是否正常。而在第三次握手时,服务端收到了客户端对第二次握手作的回应。从服务端的角度,我在第二次握手时的响应数据发送出去了,客户端接收到了。所以,我的发送能力是正常的。而客户端的接收能力也是正常的。
经历了上面的三次握手过程,客户端和服务端都确认了自己的接收、发送能力是正常的。之后就可以正常通信了。
TCP三次握手中的状态
在 TCP 三次握手的时候,Linux 内核会维护两个队列,分别是:
- 半连接队列,也称 SYN 队列;
- 全连接队列,也称 accepet 队列;
服务端收到客户端发起的 SYN 请求后,内核会把该连接存储到半连接队列,并向客户端响应 SYN+ACK,接着客户端会返回 ACK,服务端收到第三次握手的 ACK 后,内核会把连接从半连接队列移除,然后创建新的完全的连接,并将其添加到 accept 队列,等待进程调用 accept 函数时把连接取出来。
不管是半连接队列还是全连接队列,都有最大长度限制,超过限制时,内核会直接丢弃,或返回 RST 包。
半连接
很遗憾,我们没有命令可以查看系统的半连接队列数量。但是我们可以抓住 TCP 半连接的特点,就是服务端处于 SYN_RECV 状态的 TCP 连接,就是 TCP 半连接队列。使用如下命令计算当前 TCP 半连接队列长度:
- $ netstat |grep SYN_RECV |wc -l
- 1723
SYN_RECV 状态下,服务器必须建立一个 SYN 半连接队列来维护未完成的握手信息,当这个队列溢出后,服务器将无法再建立新连接。
如何模拟 TCP 半连接队列溢出场景?
模拟 TCP 半连接溢出场景不难,实际上就是对服务端一直发送 TCP SYN 包,但是不回第三次握手 ACK,这样就会使得服务端有大量的处于 SYN_RECV 状态的 TCP 连接。这其实也就是所谓的 SYN 洪泛、SYN 攻击、DDos 攻击。
实验环境:
- 客户端和服务端都是 CentOS Linux release 7.9.2009 (Core) ,Linux 内核版本 3.10.0-1160.15.2.el7.x86_64
- 服务端 IP 172.16.0.20,客户端 IP 172.16.0.157
- 服务端是 Nginx 服务,端口为 80
本次实验使用 hping3 工具模拟 SYN 攻击:
- $ hping3 -S -p 80 --flood 172.16.0.20
- HPING 172.16.0.20 (eth0 172.16.0.20): S set, 40 headers + 0 data bytes
- hping in flood mode, no replies will be shown
新连接建立失败的原因有很多,怎样获得由于队列已满而引发的失败次数呢?netstat -s 命令给出的统计结果中可以得到。
- $ netstat -s|grep "SYNs to LISTEN"
- 1541918 SYNs to LISTEN sockets dropped
这里给出的是队列溢出导致 SYN 被丢弃的个数。注意这是一个累计值,如果数值在持续增加,则应该调大 SYN 半连接队列。修改队列大小的方法,是设置 Linux 的 tcp_max_syn_backlog 参数:
- sysctl -w net.ipv4.tcp_max_syn_backlog = 1024
如果 SYN 半连接队列已满,只能丢弃连接吗?并不是这样,开启 syncookies 功能就可以在不使用 SYN 队列的情况下成功建立连接。syncookies 是这么做的:服务器根据当前状态计算出一个值,放在己方发出的 SYN+ACK 报文中发出,当客户端返回 ACK 报文时,取出该值验证,如果合法,就认为连接建立成功,如下图所示。
Linux 下怎样开启 syncookies 功能呢?修改 tcp_syncookies 参数即可,其中值为 0 时表示关闭该功能,2 表示无条件开启功能,而 1 则表示仅当 SYN 半连接队列放不下时,再启用它。由于 syncookie 仅用于应对 SYN 泛洪攻击(攻击者恶意构造大量的 SYN 报文发送给服务器,造成 SYN 半连接队列溢出,导致正常客户端的连接无法建立),这种方式建立的连接,许多 TCP 特性都无法使用。所以,应当把 tcp_syncookies 设置为 1,仅在队列满时再启用。
- sysctl -w net.ipv4.tcp_syncookies = 1
全连接
在服务端可以使用 ss 命令,来查看 TCP 全连接队列的情况:但需要注意的是 ss 命令获取的 Recv-Q/Send-Q 在「LISTEN 状态」和「非 LISTEN 状态」所表达的含义是不同的。从下面的内核代码可以看出区别:
- static void tcp_diag_get_info(struct sock *sk, struct inet_diag_msg *r,
- void *_info)
- {
- const struct tcp_sock *tp = tcp_sk(sk);
- struct tcp_info *info = _info;
- if (sk->sk_state == TCP_LISTEN) {
- r->idiag_rqueue = sk->sk_ack_backlog;
- r->idiag_wqueue = sk->sk_max_ack_backlog;
- } else {
- r->idiag_rqueue = tp->rcv_nxt - tp->copied_seq;
- r->idiag_wqueue = tp->write_seq - tp->snd_una;
- }
- if (info != NULL)
- tcp_get_info(sk, info);
- }
在「LISTEN 状态」时,Recv-Q/Send-Q 表示的含义如下:
- $ ss -ltnp
- LISTEN 0 1024 *:8081 *:* users:(("java",pid=5686,fd=310))
- Recv-Q:当前全连接队列的大小,也就是当前已完成三次握手并等待服务端 accept() 的 TCP 连接;
- Send-Q:当前全连接最大队列长度,上面的输出结果说明监听 8088 端口的 TCP 服务,最大全连接长度为 1024;
在「非 LISTEN 状态」时,Recv-Q/Send-Q 表示的含义如下:
- $ ss -tnp
- ESTAB 0 0 172.16.0.20:57672 172.16.0.20:2181 users:(("java",pid=5686,fd=292))
- Recv-Q:已收到但未被应用进程读取的字节数;
- Send-Q:已发送但未收到确认的字节数;
如何模拟 TCP 全连接队列溢出的场景?
实验环境:
- 客户端和服务端都是 CentOS Linux release 7.9.2009 (Core) ,Linux 内核版本 3.10.0-1160.15.2.el7.x86_64
- 服务端 IP 172.16.0.20,客户端 IP 172.16.0.157
- 服务端是 Nginx 服务,端口为 80
ab是apache bench命令的缩写。ab是apache自带的压力测试工具。ab非常实用,它不仅可以对apache服务器进行网站访问压力测试,也可以对或其它类型的服务器进行压力测试。比如nginx、tomcat、IIS等。ab的原理:ab命令会创建多个并发访问线程,模拟多个访问者同时对某一URL地址进行访问。它的测试目标是基于URL的,因此,它既可以用来测试apache的负载压力,也可以测试nginx、lighthttp、tomcat、IIS等其它Web服务器的压力。ab命令对发出负载的计算机要求很低,它既不会占用很高CPU,也不会占用很多内存。但却会给目标服务器造成巨大的负载,其原理类似CC攻击。自己测试使用也需要注意,否则一次上太多的负载。可能造成目标服务器资源耗完,严重时甚至导致死机。
TCP 全连接队列的最大值取决于 somaxconn 和 backlog 之间的最小值,也就是 min(somaxconn, backlog)。从下面的 Linux 内核代码可以得知:
- int __sys_listen(int fd, int backlog)
- {
- struct socket *sock;
- int err, fput_needed;
- int somaxconn;
- sock = sockfd_lookup_light(fd, &err, &fput_needed);
- if (sock) {
- somaxconn = sock_net(sock->sk)->core.sysctl_somaxconn;
- if ((unsigned int)backlog > somaxconn)
- backlog = somaxconn;
- err = security_socket_listen(sock, backlog);
- if (!err)
- err = sock->ops->listen(sock, backlog);
- fput_light(sock->file, fput_needed);
- }
- return err;
- }
- somaxconn 是 Linux 内核的参数,默认值是 128,可以通过 /proc/sys/net/core/somaxconn 来设置其值;我们设置为40000了。
- backlog 是 listen(int sockfd, int backlog) 函数中的 backlog 大小,Nginx 默认值是 511,可以通过修改配置文件设置其长度;
所以测试环境的 TCP 全连接队列最大值为 min(128, 511),也就是 511,可以执行 ss 命令查看:
- ss -tulnp|grep 80
- tcp LISTEN 0 511 *:80 *:* users:(("nginx",pid=22913,fd=6),("nginx",pid=22912,fd=6),("nginx",pid=22911,fd=6))
- tcp LISTEN 0 511 [::]:80 [::]:* users:(("nginx",pid=22913,fd=7),("nginx",pid=22912,fd=7),("nginx",pid=22911,fd=7))
客户端执行 ab 命令对服务端发起压力测试,并发 1 万个连接,发送10万个包:
- -n表示总的请求数为10000
- -c表示并发请求数为1000
- ab -c 10000 -n 100000 http://172.16.0.20:80/
- This is ApacheBench, Version 2.3 <$Revision: 1430300 $>
- Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
- Licensed to The Apache Software Foundation, http://www.apache.org/
- Benchmarking 172.16.0.20 (be patient)
- Completed 10000 requests
- Completed 20000 requests
- Completed 30000 requests
- Completed 40000 requests
- Completed 50000 requests
- Completed 60000 requests
- Completed 70000 requests
- Completed 80000 requests
- Completed 90000 requests
- Completed 100000 requests
- Finished 100000 requests
- Server Software: nginx/1.20.1
- Server Hostname: 172.16.0.20
- Server Port: 80
- Document Path: /
- Document Length: 4833 bytes
- Concurrency Level: 10000
- Time taken for tests: 2.698 seconds
- Complete requests: 100000
- Failed requests: 167336
- (Connect: 0, Receive: 0, Length: 84384, Exceptions: 82952)
- Write errors: 0
- Total transferred: 86399264 bytes
- HTML transferred: 82392984 bytes
- Requests per second: 37069.19 [#/sec] (mean)
- Time per request: 269.766 [ms] (mean)
- Time per request: 0.027 [ms] (mean, across all concurrent requests)
- Transfer rate: 31276.86 [Kbytes/sec] received
- Connection Times (ms)
- min mean[+/-sd] median max
- Connect: 0 129 151.5 106 1144
- Processing: 39 121 37.7 114 239
- Waiting: 0 23 51.8 0 159
- Total: 142 250 152.4 224 1346
- Percentage of the requests served within a certain time (ms)
- 50% 224
- 66% 227
- 75% 232
- 80% 236
- 90% 283
- 95% 299
- 98% 1216
- 99% 1228
- 100% 1346 (longest request)
其间共执行了两次 ss 命令,从上面的输出结果,可以发现当前 TCP 全连接队列上升到了 512 大小,超过了最大 TCP 全连接队列。
- ss -tulnp|grep 80
- tcp LISTEN 411 511 *:80 *:* users:(("nginx",pid=22913,fd=6),("nginx",pid=22912,fd=6),("nginx",pid=22911,fd=6))
- ss -tulnp|grep 80
- tcp LISTEN 512 511 *:80 *:* users:(("nginx",pid=22913,fd=6),("nginx",pid=22912,fd=6),("nginx",pid=22911,fd=6))
当超过了 TCP 最大全连接队列,服务端则会丢掉后续进来的 TCP 连接,丢掉的 TCP 连接的个数会被统计起来,我们可以使用 netstat -s 命令来查看:
- netstat -s|grep overflowed
- 1233972 times the listen queue of a socket overflowed
上面看到的 1233972 times ,表示全连接队列溢出的次数,注意这个是累计值。可以隔几秒钟执行下,如果这个数字一直在增加的话肯定全连接队列满了。
- netstat -s|grep overflowed
- 1292022 times the listen queue of a socket overflowed
从上面的模拟结果,可以得知,当服务端并发处理大量请求时,如果 TCP 全连接队列过小,就容易溢出。发生 TCP 全连接队列溢出的时候,后续的请求就会被丢弃,这样就会出现服务端请求数量上不去的现象。
Linux 有个参数可以指定当 TCP 全连接队列满了会使用什么策略来回应客户端
实际上,丢弃连接只是 Linux 的默认行为,我们还可以选择向客户端发送 RST 复位报文,告诉客户端连接已经建立失败。
- $ cat /proc/sys/net/ipv4/tcp_abort_on_overflow
- 0
tcp_abort_on_overflow 共有两个值分别是 0 和 1,其分别表示:
- 0 :如果全连接队列满了,那么 server 扔掉 client 发过来的 ack ;
- 1 :如果全连接队列满了,server 发送一个 reset 包给 client,表示废掉这个握手过程和这个连接;
如果要想知道客户端连接不上服务端,是不是服务端 TCP 全连接队列满的原因,那么可以把 tcp_abort_on_overflow 设置为 1,这时如果在客户端异常中可以看到很多 connection reset by peer 的错误,那么就可以证明是由于服务端 TCP 全连接队列溢出的问题。通常情况下,应当把 tcp_abort_on_overflow 设置为 0,因为这样更有利于应对突发流量。所以,tcp_abort_on_overflow 设为 0 可以提高连接建立的成功率,只有你非常肯定 TCP 全连接队列会长期溢出时,才能设置为 1 以尽快通知客户端。
- sysctl -w net.ipv4.tcp_abort_on_overflow = 0
如何增大 TCP 全连接队列呢?
根据上面提到的TCP 全连接队列的最大值取决于 somaxconn 和 backlog 之间的最小值,也就是 min(somaxconn, backlog)。我们现在调整somaxconn值:
- $ sysctl -w net.core.somaxconn=65535
调整nginx配置:
- server {
- listen 80 backlog=65535;
- ...
最后要重启 Nginx 服务,因为只有重新调用 listen() 函数 TCP 全连接队列才会重新初始化。服务端执行 ss 命令,查看 TCP 全连接队列大小:
- $ ss -tulntp|grep 80
- tcp LISTEN 0 65535 *:80 *:* users:(("nginx",pid=24212,fd=6),("nginx",pid=24211,fd=6),("nginx",pid=24210,fd=6))
从执行结果,可以发现 TCP 全连接最大值为 65535。
增大 TCP 全连接队列后,继续压测
- ab -c 10000 -n 100000 http://172.16.0.20:80/
- This is ApacheBench, Version 2.3 <$Revision: 1430300 $>
- Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
- Licensed to The Apache Software Foundation, http://www.apache.org/
- Benchmarking 172.16.0.20 (be patient)
- Completed 10000 requests
- Completed 20000 requests
- Completed 30000 requests
- Completed 40000 requests
- Completed 50000 requests
- Completed 60000 requests
- Completed 70000 requests
- Completed 80000 requests
- Completed 90000 requests
- Completed 100000 requests
- Finished 100000 requests
- Server Software: nginx/1.20.1
- Server Hostname: 172.16.0.20
- Server Port: 80
- Document Path: /
- Document Length: 4833 bytes
- Concurrency Level: 10000
- Time taken for tests: 2.844 seconds
- Complete requests: 100000
- Failed requests: 178364
- (Connect: 0, Receive: 0, Length: 89728, Exceptions: 88636)
- Write errors: 0
- Total transferred: 57592752 bytes
- HTML transferred: 54922212 bytes
- Requests per second: 35159.35 [#/sec] (mean)
- Time per request: 284.419 [ms] (mean)
- Time per request: 0.028 [ms] (mean, across all concurrent requests)
- Transfer rate: 19774.64 [Kbytes/sec] received
- Connection Times (ms)
- min mean[+/-sd] median max
- Connect: 0 130 18.3 130 172
- Processing: 45 142 40.1 138 281
- Waiting: 0 19 52.4 0 185
- Total: 159 272 31.2 272 390
- Percentage of the requests served within a certain time (ms)
- 50% 272
- 66% 274
- 75% 275
- 80% 276
- 90% 280
- 95% 358
- 98% 370
- 99% 375
- 100% 390 (longest request)
服务端执行 ss 命令,查看 TCP 全连接队列使用情况:
- $ ss -tulnp|grep 80
- tcp LISTEN 8 65535 *:80 *:* users:(("nginx",pid=24212,fd=6),("nginx",pid=24211,fd=6),("nginx",pid=24210,fd=6))
- $ ss -tulnp|grep 80
- tcp LISTEN 352 65535 *:80 *:* users:(("nginx",pid=24212,fd=6),("nginx",pid=24211,fd=6),("nginx",pid=24210,fd=6))
- $ ss -tulnp|grep 80
- tcp LISTEN 0 65535 *:80 *:* users:(("nginx",pid=24212,fd=6),("nginx",pid=24211,fd=6),("nginx",pid=24210,fd=6))
从上面的执行结果,可以发现全连接队列使用增长的很快,但是一直都没有超过最大值,所以就不会溢出,那么 netstat -s 就不会有 TCP 全连接队列溢出个数继续增加:
- $ netstat -s|grep overflowed
- 1540879 times the listen queue of a socket overflowed
- $ netstat -s|grep overflowed
- 1540879 times the listen queue of a socket overflowed
- $ netstat -s|grep overflowed
- 1540879 times the listen queue of a socket overflowed
- $ netstat -s|grep overflowed
- 1540879 times the listen queue of a socket overflowed
说明 TCP 全连接队列最大值从 512 增大到 65535 后,服务端抗住了 10 万连接并发请求,也没有发生全连接队列溢出的现象了。如果持续不断地有连接因为 TCP 全连接队列溢出被丢弃,就应该调大 backlog 以及 somaxconn 参数。
TCP四次挥手
- 第一次挥手:主动关闭方发送一个FIN,用来关闭主动方到被动关闭方的数据传送,也就是主动关闭方告诉被动关闭方:我已经不会再给你发数据了(当然,在fin包之前发送出去的数据,如果没有收到对应的ack确认报文,主动关闭方依然会重发这些数据),但此时主动关闭方还可以接受数据。
- 第二次挥手:被动关闭方收到FIN包后,发送一个ACK给对方,确认序号为收到序号+1(与SYN相同,一个FIN占用一个序号)。
- 第三次挥手:被动关闭方发送一个FIN,用来关闭被动关闭方到主动关闭方的数据传送,也就是告诉主动关闭方,我的数据也发送完了,不会再给你发数据了。
- 第四次挥手:主动关闭方收到FIN后,发送一个ACK给被动关闭方,确认序号为收到序号+1,至此,完成四次挥手。
互联网中往往服务器才是主动关闭连接的一方。这是因为,HTTP 消息是单向传输协议,服务器接收完请求才能生成响应,发送完响应后就会立刻关闭 TCP 连接,这样及时释放了资源,能够为更多的用户服务。
四次挥手的状态
我们来看断开连接的时候的状态时序图:
其实四次挥手只涉及两种报文:FIN 和 ACK。FIN 就是 Finish 结束连接的意思,谁发出 FIN 报文,就表示它将不再发送任何数据,关闭这一方向的传输通道。ACK 是 Acknowledge 确认的意思,它用来通知对方,你方的发送通道已经关闭。
- 当主动方关闭连接时,会发送 FIN 报文,此时主动方的连接状态由 ESTABLISHED 变为 FIN_WAIT1。
- 当被动方收到 FIN 报文后,内核自动回复 ACK 报文,连接状态由 ESTABLISHED 变为 CLOSE_WAIT,顾名思义,它在等待进程调用 close 函数关闭连接。
- 当主动方接收到这个 ACK 报文后,连接状态由 FIN_WAIT1 变为 FIN_WAIT2,主动方的发送通道就关闭了。
- 当被动方进入 CLOSE_WAIT 状态时,进程的 read 函数会返回 0,这样开发人员就会有针对性地调用 close 函数,进而触发内核发送 FIN 报文,此时被动方连接的状态变为 LAST_ACK。当主动方收到这个 FIN 报文时,内核会自动回复 ACK,同时连接的状态由 FIN_WAIT2 变为 TIME_WAIT,Linux 系统下等待的时间设为 2MSL,MSL 是 Maximum Segment Lifetime,报文最大生存时间,它是任何报文在网络上存在的最长时间,超过这个时间报文将被丢弃。TIME_WAIT 状态的连接才会彻底关闭。
当被动方收到 ACK 报文后,连接就会关闭。
主动方TIME_WAIT优化
大量处于 TIME_WAIT 状态的连接,它们会占用大量内存和端口资源。这时,我们可以优化与 TIME_WAIT 状态相关的内核选项,比如采取下面几种措施。
- 增大处于 TIME_WAIT 状态的连接数量 net.ipv4.tcp_max_tw_buckets ,并增大连接跟踪表的大小 net.netfilter.nf_conntrack_max。
- sysctl -w net.ipv4.tcp_max_tw_buckets=1048576
- sysctl -w net.netfilter.nf_conntrack_max=1048576
- 减小FIN_WAIT2状态的参数 net.ipv4.tcp_fin_timeout 的时间和减小TIME_WAIT 状态的参数net.netfilter.nf_conntrack_tcp_timeout_time_wait的时间 ,让系统尽快释放它们所占用的资源。
- sysctl -w net.ipv4.tcp_fin_timeout=15
- sysctl -w net.netfilter.nf_conntrack_tcp_timeout_time_wait=30
- 开启端口复用 net.ipv4.tcp_tw_reuse。这样,被 TIME_WAIT 状态占用的端口,还能用到新建的连接中。
- sysctl -w net.ipv4.tcp_tw_reuse=1
- 增大本地端口的范围 net.ipv4.ip_local_port_range 。这样就可以支持更多连接,提高整体的并发能力。
- sysctl -w net.ipv4.ip_local_port_range="1024 65535"
- 增加最大文件描述符的数量。你可以使用 fs.nr_open 和 fs.file-max ,分别增大进程和系统的最大文件描述符数;或在应用程序的 systemd 配置文件中,配置 LimitNOFILE ,设置应用程序的最大文件描述符数。
- sysctl -w fs.nr_open=1048576
- sysctl -w fs.file-max=1048576
巨人的肩膀
[1] 系统性能调优必知必会.陶辉.极客时间.
[2] TCP/IP详解卷二:实现
本文转载自微信公众号「 运维开发故事」,可以通过以下二维码关注。转载本文请联系 运维开发故事公众号。
【编辑推荐】