原文标题:通过实验深入了解 TCP 连接的建立和关闭
原文作者:阿里云开发者
冷月清谈:
怜星夜思:
2、TCP 连接的关闭实验
原文内容
阿里妹导读
写在前面
通信媒介可能会丢失或者改变被传递的消息,在这类有损通信信道上提供可靠通信协议的问题已经被研究了很多年。处理差错的两种主要方法是纠错码和数据重传。后者又称之为自动重复请求(Automatic Repeat Request, ARQ),TCP 协议基于此方法设计。
本文不会完全阐述 TCP 协议的概念细节,更多的是以实验的方式揭示 TCP 在 Linux 内核协议栈上的实现细节。所以实践本文前需要先行阅读计算机网络基础以及 TCP/IP 协议相关的理论书籍。TCP 的原始规范是 RFC793[1],其中的一些错误在 RFC1122 [2]中被修正。拥塞控制(RFC5681、RFC3782、RFC3517、RFC3390、RFC3168)、重传超时(RFC6298、RFC5682、RFC4015)、连接管理(RFC5482)等特性在后续一系列的 RFC 文档中也进行了补充设计。
本文在阐述一些具体的行为和特性时也会提示相关的 RFC 文档。但是需要注意,具体系统的协议实现并非完全照搬 RFC 的每一句话,实践中在一些特定的场景下也会选择更有利于解决具体问题的方案。
实验环境
机器信息
两台虚拟机,IP 地址分别是 10.0.0.3(vm-1)和 10.0.0.4(vm-2):
$ uname -a Linux workspace-1 5.10.134-16.3.an8.aarch64 #1 SMP Tue Mar 26 18:49:57 CST 2024 aarch64 aarch64 aarch64 GNU/Linux $ ip -4 addr 1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000 inet 127.0.0.1/8 scope host lo valid_lft forever preferred_lft forever 2: enp0s5: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000 inet 10.0.0.3/24 brd 10.0.0.255 scope global dynamic noprefixroute enp0s5 valid_lft 1746sec preferred_lft 1746sec$ uname -a Linux workspace-2 5.10.134-16.3.an8.aarch64 #1 SMP Tue Mar 26 18:49:57 CST 2024 aarch64 aarch64 aarch64 GNU/Linux $ ip -4 addr 1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000 inet 127.0.0.1/8 scope host lo valid_lft forever preferred_lft forever 2: enp0s5: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq_codel state UP group default qlen 1000 inet 10.0.0.4/24 brd 10.0.0.255 scope global dynamic noprefixroute enp0s5 valid_lft 1784sec preferred_lft 1784sec
TCP 连接的建立
开启抓包
在 vm-1 上开启 tcpdump 抓包:
# vm-1 # 如果只输出到控制台而不需要保存包到文件的话,将 -w tcp.pcap --print 参数删除即可 $ sudo tcpdump -s0 -X -nn "tcp port 9527" -w tcp.pcap --print上面命令的 --print 参数在 tcpdump v4.99.0 版本才引入,用于 -w 写文件的同时在控制台也输出详情。如果实验环境的 tcpdump 版本过低,可以从源码编译安装,或者使用下面低版本 tcpdump 等效命令:
$ sudo tcpdump -s0 “tcp port 9527” -w - -U | tee tcp.pcap | tcpdump -r - -X -nn
创建连接
在 vm-1 上使用 nc 监听 TCP 9527 端口:
# vm-1 $ nc -k -l 10.0.0.3 9527这时候可以在另一个终端使用 netstat 命令查看这个监听套接字的情况:
# vm-1 $ sudo netstat -anpo | grep Recv-Q; sudo netstat -anpo | grep 9527 Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name Timer tcp 0 0 10.0.0.3:9527 0.0.0.0:* LISTEN 1704/nc off (0.00/0/0)在 vm-2 上打开一个终端,使用 nc 连接服务端:
# vm-2 $ nc 10.0.0.3 9527
# vm-1 $ sudo netstat -anpo | grep -E "Recv-Q|9527" Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name Timer tcp 0 0 10.0.0.3:9527 0.0.0.0:* LISTEN 1704/nc off (0.00/0/0) tcp 0 0 10.0.0.3:9527 10.0.0.4:52336 ESTABLISHED 1704/nc off (0.00/0/0)vm-2
$ sudo netstat -anpo | grep -E “Recv-Q|9527”
Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name Timer
tcp 0 0 10.0.0.4:52336 10.0.0.3:9527 ESTABLISHED 4678/nc off (0.00/0/0)
此时 tcpdump 也输出了三次握手包的详情:
# vm-1 $ sudo tcpdump -s0 -X -nn "tcp port 9527" -w tcp.pcap --print tcpdump: listening on enp0s5, link-type EN10MB (Ethernet), snapshot length 262144 bytes 13:15:40.768222 IP 10.0.0.4.33408 > 10.0.0.3.9527: Flags [S], seq 2741304532, win 64240, options [mss 1460,sackOK,TS val 3003747107 ecr 0,nop,wscale 7], length 0 0x0000: 4500 003c d3a4 4000 4006 5311 0a00 0004 E..<..@.@.S..... 0x0010: 0a00 0003 8280 2537 a364 fcd4 0000 0000 ......%7.d...... 0x0020: a002 faf0 b2ea 0000 0204 05b4 0402 080a ................ 0x0030: b309 8b23 0000 0000 0103 0307 ...#........ 13:15:40.768304 IP 10.0.0.3.9527 > 10.0.0.4.33408: Flags [S.], seq 1187094817, ack 2741304533, win 65160, options [mss 1460,sackOK,TS val 2489161299 ecr 3003747107,nop,wscale 7], length 0 0x0000: 4500 003c 0000 4000 4006 26b6 0a00 0003 E..<..@.@.&..... 0x0010: 0a00 0004 2537 8280 46c1 a121 a364 fcd5 ....%7..F..!.d.. 0x0020: a012 fe88 1435 0000 0204 05b4 0402 080a .....5.......... 0x0030: 945d 9653 b309 8b23 0103 0307 .].S...#.... 13:15:40.768647 IP 10.0.0.4.33408 > 10.0.0.3.9527: Flags [.], ack 1, win 502, options [nop,nop,TS val 3003747108 ecr 2489161299], length 0 0x0000: 4500 0034 d3a5 4000 4006 5318 0a00 0004 E..4..@.@.S..... 0x0010: 0a00 0003 8280 2537 a364 fcd5 46c1 a122 ......%7.d..F.." 0x0020: 8010 01f6 c80b 0000 0101 080a b309 8b24 ...............$ 0x0030: 945d 9653 .].S执行 tcpdump 的目录下也生成了 tcp.pacp 文件,可以用 wireshark 打开分析:
TCP 协议头解析
第一个选项(2)是告知对端自己的 MSS 值(RFC6691[3]), MSS 选项传输的头是 MTU 减去 IP 基本头(v4 20 字节,v6 40 字节)和 TCP 基本头(20字节)的值,不考虑扩展头。从 ifconfig 的输出能看出来 MTU 是 1500,减去 IPv4 的 20 字节和 TCP 的 20 字节恰好是 1460 字节。
$ ifconfig enp0s5: flags=4163<UP,BROADCAST,RUNNING,MULTICAST> mtu 1500 ...
第二个选项(4)是:
$ sysctl net.ipv4.tcp_sack net.ipv4.tcp_sack = 1
第三个选项(8)是时间戳标记(RFC1323[6])。内核使用 TCP 时间戳来更好地估算 TCP 连接中的往返时间 (RTTM, Round Trip Time Measurement),可以更准确的进行 TCP 窗口和缓冲区的计算。同时在高速网络中,可以使用该值更好的判断 sequence 的回绕情况以避免协议错误(PAWS,Protect Against Wrapped Sequences)。这个特性的开关可以用内核参数 net.ipv4.tcp_timestamps 控制:
$ sysctl net.ipv4.tcp_timestamps net.ipv4.tcp_timestamps = 1 # 0 表示TCP 时间戳被禁用,1 表示 TCP 时间戳被启用(默认),2 表示 TCP 时间戳被启用,但没有随机偏移
第四个选项(1)NOP 一般是占位对齐用的。因为 TCP 包头 offset 字段的值是以 4 字节为单位的,所以 TCP 头的大小也只能是 4 的倍数。
$ sysctl net.ipv4.tcp_rmem net.ipv4.tcp_wmem net.ipv4.tcp_rmem = 4096 131072 6291456 # 分别是最小值、默认值、最大值,虽然名字是 ipv4,但是同时影响 ipv4 和 ipv6 net.ipv4.tcp_wmem = 4096 16384 4194304
$ sysctl net.ipv4.tcp_window_scaling net.ipv4.tcp_window_scaling = 1
顺便提一下,RFC1122 规定了不能被理解的 TCP option 会被简单的忽略掉,那么在私有的环境里完全可以定义私有的 TCP option 以完成一些特殊的需求,即使这个包发送到不支持的设备上也只是功能不生效而不会认为包异常。举个例子,某些 LB/NAT 服务希望透传原始的连接地址给后端服务,就可以将相关的原始请求地址和端口打包在一个私有的 TCP option 中发送给后端。后端支持的话就可以从 TCP option 中解析出来,不支持的话也会忽略这个 TCP option 而不报错。
三次握手的状态变化
图片来源:
拦截 SYN 包
配置完成规则后在 vm-2 上使用 nc 连接。
注意要调大默认的超时时间:
# vm-2 $ nc -w 3600s 10.0.0.3 9527 # 超时时间 3600 秒
此时能观察到 vm-2 的协议栈因为 TCP 超时开始重传 SYN 包:
# vm-2 $ sudo tcpdump -s0 -nn "tcp port 9527" -w tcp.pcap --print tcpdump: listening on enp0s5, link-type EN10MB (Ethernet), snapshot length 262144 bytes 19:46:32.303016 IP 10.0.0.4.49728 > 10.0.0.3.9527: Flags [S], seq 2027654690, win 64240, options [mss 1460,sackOK,TS val 3027198638 ecr 0,nop,wscale 7], length 0 19:46:33.305107 IP 10.0.0.4.49728 > 10.0.0.3.9527: Flags [S], seq 2027654690, win 64240, options [mss 1460,sackOK,TS val 3027199640 ecr 0,nop,wscale 7], length 0 19:46:35.324485 IP 10.0.0.4.49728 > 10.0.0.3.9527: Flags [S], seq 2027654690, win 64240, options [mss 1460,sackOK,TS val 3027201660 ecr 0,nop,wscale 7], length 0 19:46:39.578790 IP 10.0.0.4.49728 > 10.0.0.3.9527: Flags [S], seq 2027654690, win 64240, options [mss 1460,sackOK,TS val 3027205914 ecr 0,nop,wscale 7], length 0 19:46:47.770402 IP 10.0.0.4.49728 > 10.0.0.3.9527: Flags [S], seq 2027654690, win 64240, options [mss 1460,sackOK,TS val 3027214106 ecr 0,nop,wscale 7], length 0 19:47:03.896674 IP 10.0.0.4.49728 > 10.0.0.3.9527: Flags [S], seq 2027654690, win 64240, options [mss 1460,sackOK,TS val 3027230232 ecr 0,nop,wscale 7], length 0 19:47:37.688850 IP 10.0.0.4.49728 > 10.0.0.3.9527: Flags [S], seq 2027654690, win 64240, options [mss 1460,sackOK,TS val 3027264024 ecr 0,nop,wscale 7], length 0最后客户端报错:
# vm-2 $ nc -w 3600s 10.0.0.3 9527 Ncat: Connection timed out.
可以看到,截止连接超时,SYN 包一共重传了 6 次(7 个包),第一次间隔 1s,第二次 2s,第三次 4s,等待时间逐渐翻倍。SYN 重传最大次数由内核参数 net.ipv4.tcp_syn_retries 决定:
# vm-2 $ sysctl net.ipv4.tcp_syn_retries net.ipv4.tcp_syn_retries = 6
在重传的过程中,可以在 vm-2 的 netstat 信息中看到处于 SYN_SENT 状态的套接字。
# vm-2
$ while true; do sudo netstat -anpo | grep 9527; sleep 1; done
tcp 0 1 10.0.0.4:49728 10.0.0.3:9527 SYN_SENT 44754/python3 on (0.78/0/0)
tcp 0 1 10.0.0.4:49728 10.0.0.3:9527 SYN_SENT 44754/python3 on (1.74/1/0)
tcp 0 1 10.0.0.4:49728 10.0.0.3:9527 SYN_SENT 44754/python3 on (0.70/1/0)
tcp 0 1 10.0.0.4:49728 10.0.0.3:9527 SYN_SENT 44754/python3 on (3.68/2/0)
最后一列 Timer 的格式为 timer(a/b/c)
timer 的取值有 off/on/keepalive/timewait 四种,分别表示 无定时器/重传定时器/keepalive 定时器/timewait 定时器
(a/b/c) 的含义为 (定时器的值/已经发送的重传次数/已发送的保活探测次数)。
拦截 SYN+ACK 包
# vm-2 $ sudo nmap -sS 10.0.0.3 -p 9527
$ sudo tcpdump -s0 -nn "tcp port 9527" -w tcp.pcap --print tcpdump: listening on enp0s5, link-type EN10MB (Ethernet), snapshot length 262144 bytes 20:33:14.312050 IP 10.0.0.4.61796 > 10.0.0.3.9527: Flags [S], seq 329704385, win 1024, options [mss 1460], length 0 20:33:14.312078 IP 10.0.0.3.9527 > 10.0.0.4.61796: Flags [S.], seq 3063951492, ack 329704386, win 64240, options [mss 1460], length 0 20:33:15.320577 IP 10.0.0.3.9527 > 10.0.0.4.61796: Flags [S.], seq 3063951492, ack 329704386, win 64240, options [mss 1460], length 0 20:33:17.334085 IP 10.0.0.3.9527 > 10.0.0.4.61796: Flags [S.], seq 3063951492, ack 329704386, win 64240, options [mss 1460], length 0 20:33:21.494471 IP 10.0.0.3.9527 > 10.0.0.4.61796: Flags [S.], seq 3063951492, ack 329704386, win 64240, options [mss 1460], length 0 20:33:29.687877 IP 10.0.0.3.9527 > 10.0.0.4.61796: Flags [S.], seq 3063951492, ack 329704386, win 64240, options [mss 1460], length 0 20:33:45.817673 IP 10.0.0.3.9527 > 10.0.0.4.61796: Flags [S.], seq 3063951492, ack 329704386, win 64240, options [mss 1460], length 0
可以看到,截止连接超时,SYN+ACK 包一共重传了 5 次(共 7 个包,第 1 个是 SYN,后 6 个 SYN+ACK),等待时间也是从 1s 开始逐渐翻倍。SYN+ACK 重传最大次数由内核参数 net.ipv4.tcp_synack_retries 决定:
$ sysctl net.ipv4.tcp_synack_retries net.ipv4.tcp_synack_retries = 5
在重传的过程中,可以在 vm-1 的 netstat 信息中看到处于 SYN_RECV 状态的套接字。
$ while true; do sudo netstat -anpo | grep SYN_RECV; sleep 1; done tcp 0 0 10.0.0.3:9527 10.0.0.4:61796 SYN_RECV - on (0.05/0/0) tcp 0 0 10.0.0.3:9527 10.0.0.4:61796 SYN_RECV - on (1.01/1/0) tcp 0 0 10.0.0.3:9527 10.0.0.4:61796 SYN_RECV - on (3.99/2/0) tcp 0 0 10.0.0.3:9527 10.0.0.4:61796 SYN_RECV - on (2.95/2/0) tcp 0 0 10.0.0.3:9527 10.0.0.4:61796 SYN_RECV - on (1.91/2/0)
处于 SYN_RECV 状态的套接字在内核的 SYN Queue 里等待第三次握手包 ACK 到达,但如果不断有 SYN 包发送,但是服务端回复的 SYN+ACK 包石沉大海的话。大量 SYN_RECV 状态的连接会快速消耗服务端的资源,这就是所谓的 SYN Flood 攻击。当服务端收到第三次 ACK 握手包的话,连接会变成 ESTABLISHED 状态,并从 SYN Queue (以下称半连接队列) 里移动到 Accept Queue (以下称全连接队列) 里,等待应用程序调用 accept()获取后移除。
图片来自:
-
net.ipv4.tcp_max_syn_backlog:控制收到 SYN 报文时,尚未完成三次握手因而还未建立的 TCP 连接请求可以被放到半连接队列中的最大数量(半连接队列长度)。
-
net.core.somaxconn: 已经完成三次握手,但是尚未被应用程序调用 accept() 获取的最大连接数量(全连接队列长度)。
-
net.ipv4.tcp_syncookies: SYN Cookie 机制,启用后当前连接收到 SYN 包后不进入半连接队列,而是根据时间戳,四元组信息计算出一个 Cookie 信息作为 ISN 返回给客户端,等收到了 ACK 包时从序号里反算出当时信息。配置为 0 时不启用;配置为 1 时当半连接队列满时启用;配置为 2 时无条件启用。
很多资料里提到的早先那个 roundup_pow_of_two 的计算方法在 v4.4 开始的一个提交[7]中修改了。
说不准你在实验的时候,更高版本内核的某些行为就和这篇文章里的实验结果不太一致了。这也很正常,要学会研究方法而不是记住数字。
# vm-1 $ sudo sysctl -w net.ipv4.tcp_syncookies=0 net.ipv4.tcp_max_syn_backlog=4 net.core.somaxconn=8 net.ipv4.tcp_syncookies = 0 net.ipv4.tcp_max_syn_backlog = 4 net.core.somaxconn = 8 # vm-2 $ while true; do sudo nmap -sS 10.0.0.3 -p 9527; done
$ sudo netstat -anpo | grep SYN_RECV | wc -l 4
可见半连接队列的长度被限制住了,后续发送的 SYN 包被直接丢弃了。netstat -s 可以看到相关的 SYN 包被丢弃的统计(累积值):
$ sudo netstat -s | grep -E "LISTEN|overflowed" 378 SYNs to LISTEN sockets dropped $ sudo netstat -s | grep -E "LISTEN|overflowed" 382 SYNs to LISTEN sockets dropped $ sudo netstat -s | grep -E "LISTEN|overflowed" 384 SYNs to LISTEN sockets dropped
import socket import timedef start_server(host, port, backlog):
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.bind((host, port))
server.listen(backlog)while True:
time.sleep(1)
if name == ‘main’:
start_server(‘10.0.0.3’, 9527, 8)
import socket import timedef connect_and_hold(host, port, count):
cli_list =
try:
for i in range(count):
cli = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
cli.connect((host, port))
cli_list.append(cli)
except Exception as e:
print(f"Failed to connect: {e}")while True:
time.sleep(1)
if name == ‘main’:
connect_and_hold(‘10.0.0.3’, 9527, 10)
$ sudo netstat -s | grep -E "LISTEN|overflow" 1 times the listen queue of a socket overflowed # 全连接队列 DROP 466 SYNs to LISTEN sockets dropped # 半连接队列 DROP
# vm-1 $ sudo netstat -anpo | grep -E "Recv-Q|9527" Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name Timer tcp 9 0 10.0.0.3:9527 0.0.0.0:* LISTEN 54108/python3 off (0.00/0/0) tcp 0 0 10.0.0.3:9527 10.0.0.4:49568 ESTABLISHED - off (0.00/0/0) tcp 0 0 10.0.0.3:9527 10.0.0.4:49580 ESTABLISHED - off (0.00/0/0) tcp 0 0 10.0.0.3:9527 10.0.0.4:49588 ESTABLISHED - off (0.00/0/0) tcp 0 0 10.0.0.3:9527 10.0.0.4:49566 ESTABLISHED - off (0.00/0/0) tcp 0 0 10.0.0.3:9527 10.0.0.4:49586 ESTABLISHED - off (0.00/0/0) tcp 0 0 10.0.0.3:9527 10.0.0.4:49594 ESTABLISHED - off (0.00/0/0) tcp 0 0 10.0.0.3:9527 10.0.0.4:49626 ESTABLISHED - off (0.00/0/0) tcp 0 0 10.0.0.3:9527 10.0.0.4:49598 ESTABLISHED - off (0.00/0/0) tcp 0 0 10.0.0.3:9527 10.0.0.4:49610 ESTABLISHED - off (0.00/0/0)vm-2
$ sudo netstat -anpo | grep -E “Recv-Q|9527”
Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name Timer
tcp 0 0 10.0.0.4:49610 10.0.0.3:9527 ESTABLISHED 48261/python3 off (0.00/0/0)
tcp 0 1 10.0.0.4:49628 10.0.0.3:9527 SYN_SENT 48261/python3 on (3.94/2/0)
tcp 0 0 10.0.0.4:49586 10.0.0.3:9527 ESTABLISHED 48261/python3 off (0.00/0/0)
tcp 0 0 10.0.0.4:49566 10.0.0.3:9527 ESTABLISHED 48261/python3 off (0.00/0/0)
tcp 0 0 10.0.0.4:49626 10.0.0.3:9527 ESTABLISHED 48261/python3 off (0.00/0/0)
tcp 0 0 10.0.0.4:49594 10.0.0.3:9527 ESTABLISHED 48261/python3 off (0.00/0/0)
tcp 0 0 10.0.0.4:49568 10.0.0.3:9527 ESTABLISHED 48261/python3 off (0.00/0/0)
tcp 0 0 10.0.0.4:49598 10.0.0.3:9527 ESTABLISHED 48261/python3 off (0.00/0/0)
tcp 0 0 10.0.0.4:49588 10.0.0.3:9527 ESTABLISHED 48261/python3 off (0.00/0/0)
tcp 0 0 10.0.0.4:49580 10.0.0.3:9527 ESTABLISHED 48261/python3 off (0.00/0/0)
可以观察到 vm-2 上第 10 个连接处于 SYN_SENT 状态,定时器显示其在进行 SYN 包的重传。从这个结果可以看出来,当全连接队列满的时候,半连接队列也是拒绝连接的。如果全连接队列不满的时候呢?修改上面的 conn.py 脚本里的连接数为 6,重新运行服务端和客户端程序,然后在 vm-2 上拦截 vm-1 的握手包,单独发一个 SYN 包过去试试:
# vm-2 $ sudo iptables -A INPUT -p tcp --sport 9527 -j DROP
$ sudo netstat -anpo | grep -E “Recv-Q|9527”
Proto Recv-Q Send-Q Local Address Foreign Address State
tcp 6 0 10.0.0.3:9527 0.0.0.0:* LISTEN 55439/python3 off (0.00/0/0)
tcp 0 0 10.0.0.3:9527 10.0.0.4:55484 ESTABLISHED - off (0.00/0/0)
tcp 0 0 10.0.0.3:9527 10.0.0.4:55520 ESTABLISHED - off (0.00/0/0)
tcp 0 0 10.0.0.3:9527 10.0.0.4:55504 ESTABLISHED - off (0.00/0/0)
tcp 0 0 10.0.0.3:9527 10.0.0.4:53019 SYN_RECV - on (3.18/2/0)
tcp 0 0 10.0.0.3:9527 10.0.0.4:55514 ESTABLISHED - off (0.00/0/0)
tcp 0 0 10.0.0.3:9527 10.0.0.4:55488 ESTABLISHED - off (0.00/0/0)
tcp 0 0 10.0.0.3:9527 10.0.0.4:55482 ESTABLISHED - off (0.00/0/0)
从这个结果中可以看到有一个 SYN_RECV 的连接,其成功进入了半连接队列。
-
net.ipv4.tcp_abort_on_overflow: 此值为 0 表示握手到第三步时全连接队列满时则扔掉客户端发过来的 ACK 包。但是客户端那边因为握手包已经发出,已经自动进入 ESTABLISHED 状态准备传输数据了。服务端丢弃了 ACK 包后这个链接还是处于 SYN_RECV 状态的(如果此时客户端发数据,服务端会直接丢弃。客户端就开始重传,此时的重传次数受内核的 net.ipv4.tcp_retries2 参数控制);此值为 1 则直接给客户端发送 RST 包直接断开连接。这里强调下,这个参数只在半连接队列往全连接队列移动时才有效。而全连接队列已经满的情况下,内核的默认行为只是丢弃新的 SYN 包(而且目前没有参数可以控制这个行为),这会导致客户端 SYN 不断重传。想想看为什么 Accept Queue 满了,客户端的第一次握手包只能直接丢弃而不能根据 tcp_abort_on_overflow 的配置决定行为?
其实很简单,当服务未启动的时候,端口未打开的情况下收到 SYN 包是要回复 RST 的,这表示端口不可达。如果 Accept Queue 满了也这么做的话,就无法区分这两种情况了。而服务端返回过 SYN+ACK,但是 Accept Queue 已经放不进去的时候选择静默丢弃或者直接回复 RST 断开不会造成协议通信上的歧义。
Recv-Q
Established: The count of bytes not copied by the user program connected to this socket.
Listening: Since Kernel 2.6.18 this column contains the current syn backlog.
Send-Q
Established: The count of bytes not acknowledged by the remote host.
Listening: Since Kernel 2.6.18 this column contains the maximum size of the syn backlog.
# vm-1 $ sudo ss -antl | grep -E "Recv-Q|9527" State Recv-Q Send-Q Local Address:Port Peer Address:PortProcess LISTEN 9 8 10.0.0.3:9527 0.0.0.0:*
# 先用 strace 看看 netstat 怎么打印的 $ strace netstat -ntl从输出结果看,这部分数据来自 /proc/net/tcp(省略无关行)
$ cat /proc/net/tcp
sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode
1: 0300000A:2537 00000000:0000 0A 00000000:00000009 00:00000000 00000000 1000 0 310710 10 0000000000000000 100 0 0 10 0看起来 /proc/net/tcp 里显示的 rx_queue 确实是 9,tx_queue 没有值
netstat 源码翻了下没什么价值,就是在解读 /proc/net/tcp 并打印而已,tx_queue 输出是 0 就显示 0 了,就是数据源的问题
用 strace 跟踪下 ss 看看
$ strace ss -ntl
输出一大堆,原来走的是 netlink 机制从内核额外获取的信息
翻了下内核的相关代码,netlink 确实返回了 sk->sk_ack_backlog 和 sk->sk_max_ack_backlog,所以 ss 的显示是对的
顺便翻下 /proc/net/tcp 的生成代码,在 get_tcp4_sock() 函数里:
static void get_tcp4_sock(struct sock *sk, struct seq_file *f, int i) { ... if (state == TCP_LISTEN) rx_queue = READ_ONCE(sk->sk_ack_backlog); else /* Because we don't lock the socket, * we might find a transient negative value. */ rx_queue = max_t(int, READ_ONCE(tp->rcv_nxt) - READ_ONCE(tp->copied_seq), 0); ...
}
TCP 连接的关闭
# vm-1 $ sudo netstat -anpo | grep Recv-Q; sudo netstat -anpo | grep 9527 Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name Timer tcp 0 0 10.0.0.3:9527 0.0.0.0:* LISTEN 1704/nc off (0.00/0/0)vm-2
$ sudo netstat -anpo | grep Recv-Q; sudo netstat -anpo | grep 9527
Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name Timer
tcp 0 0 10.0.0.4:52336 10.0.0.3:9527 TIME_WAIT - timewait (57.31/0/0)$ sudo netstat -anpo | grep Recv-Q; sudo netstat -anpo | grep 9527
Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name Timer
tcp 0 0 10.0.0.4:52336 10.0.0.3:9527 TIME_WAIT - timewait (54.04/0/0)
$ sudo netstat -anpo | grep Recv-Q; sudo netstat -anpo | grep 9527
Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name Timer
tcp 0 0 10.0.0.4:52336 10.0.0.3:9527 TIME_WAIT - timewait (52.92/0/0)
服务端上的连接已经消失,客户端(主动断连的一方)进入了 TIME_WAIT 状态,并且最后一列显示了 timewait 定时器的相关信息。
$ netstat -s | grep "passive connections rejected because of time stamp"
136964 passive connections rejected because of time stamp
$ sysctl net.ipv4.tcp_timestamps net.ipv4.tcp_tw_reuse net.ipv4.tcp_max_tw_buckets net.ipv4.tcp_timestamps = 1 net.ipv4.tcp_tw_reuse = 2 # 0 - disable, 1 - global enable, 2 - enable for loopback traffic only net.ipv4.tcp_max_tw_buckets = 8192
短连接和 TIME_WAIT 状态实验
# 先调大 net.ipv4.tcp_max_tw_buckets 的值 $ sudo sysctl -w net.ipv4.tcp_max_tw_buckets=1000000 net.ipv4.tcp_max_tw_buckets = 1000000关闭 net.ipv4.tcp_tw_reuse
$ sudo sysctl -w net.ipv4.tcp_tw_reuse=0
net.ipv4.tcp_tw_reuse = 0查看 net.ipv4.ip_local_port_range 配置
$ sysctl net.ipv4.ip_local_port_range
net.ipv4.ip_local_port_range = 32768 60999
import socketdef connect_and_immediately_disconnect(host, port, count):
try:
for i in range(count):
cli = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
cli.connect((host, port))
cli.close()
except Exception as e:
print(f"Failed to connect: {e}")
if name == ‘main’:
connect_and_immediately_disconnect(‘10.0.0.3’, 9527, 70000)
$ python3 ./conn.py
$ sudo netstat -anpo | grep 9527 | grep timewait | wc -l 28232
$ python3 ./conn.py
Failed to connect: [Errno 99] Cannot assign requested address
$ sudo sysctl -w net.ipv4.tcp_tw_reuse=1 net.ipv4.tcp_tw_reuse = 1
$ sudo netstat -anpo | grep 9527 | grep timewait | wc -l
14116
方案二:关闭 net.ipv4.tcp_tw_reuse(方案一),将 net.ipv4.tcp_max_tw_buckets 设置为 5000 重复上述实验,TIME_WAIT 的 socket 数量到 5000 便不再上涨:
$ sudo sysctl -w net.ipv4.tcp_tw_reuse=0 net.ipv4.tcp_tw_reuse = 0$ sudo sysctl -w net.ipv4.tcp_max_tw_buckets=5000
net.ipv4.tcp_max_tw_buckets = 5000
$ sudo netstat -anpo | grep 9527 | grep timewait | wc -l
5000
# 因为 conn.py 连接了 70000 次,超过 5000 后开始递增 TCPTimeWaitOverflow 错误,恰好 65000 次 $ netstat -s | grep TCPTimeWaitOverflow TCPTimeWaitOverflow: 65000
拦截第一个 FIN 包
# vm-2 $ while true; do sudo netstat -anpo | grep 9527; sleep 1; done tcp 0 1 10.0.0.4:43602 10.0.0.3:9527 FIN_WAIT1 - on (1.86/0/0) tcp 0 1 10.0.0.4:43602 10.0.0.3:9527 FIN_WAIT1 - on (0.75/0/0) ... tcp 0 1 10.0.0.4:43602 10.0.0.3:9527 FIN_WAIT1 - on (37.48/2/0) tcp 0 1 10.0.0.4:43602 10.0.0.3:9527 FIN_WAIT1 - on (36.46/2/0) ...
# vm-1 $ sudo tcpdump -s0 -nn "tcp port 9527" -w tcp.pcap --print tcpdump: listening on enp0s5, link-type EN10MB (Ethernet), snapshot length 262144 bytes 20:36:01.807300 IP 10.0.0.4.46622 > 10.0.0.3.9527: Flags [F.], seq 858866730, ack 476455863, win 502, options [nop,nop,TS val 3301094457 ecr 3783934485], length 0 20:36:02.014156 IP 10.0.0.4.46622 > 10.0.0.3.9527: Flags [F.], seq 0, ack 1, win 502, options [nop,nop,TS val 3301094663 ecr 3783934485], length 0 20:36:02.224638 IP 10.0.0.4.46622 > 10.0.0.3.9527: Flags [F.], seq 0, ack 1, win 502, options [nop,nop,TS val 3301094874 ecr 3783934485], length 0 20:36:02.646304 IP 10.0.0.4.46622 > 10.0.0.3.9527: Flags [F.], seq 0, ack 1, win 502, options [nop,nop,TS val 3301095295 ecr 3783934485], length 0 20:36:03.478598 IP 10.0.0.4.46622 > 10.0.0.3.9527: Flags [F.], seq 0, ack 1, win 502, options [nop,nop,TS val 3301096128 ecr 3783934485], length 0 20:36:05.142164 IP 10.0.0.4.46622 > 10.0.0.3.9527: Flags [F.], seq 0, ack 1, win 502, options [nop,nop,TS val 3301097791 ecr 3783934485], length 0 20:36:08.569241 IP 10.0.0.4.46622 > 10.0.0.3.9527: Flags [F.], seq 0, ack 1, win 502, options [nop,nop,TS val 3301101218 ecr 3783934485], length 0 20:36:15.222388 IP 10.0.0.4.46622 > 10.0.0.3.9527: Flags [F.], seq 0, ack 1, win 502, options [nop,nop,TS val 3301107871 ecr 3783934485], length 0 20:36:28.538659 IP 10.0.0.4.46622 > 10.0.0.3.9527: Flags [F.], seq 0, ack 1, win 502, options [nop,nop,TS val 3301121188 ecr 3783934485], length 0 20:36:55.159701 IP 10.0.0.4.46622 > 10.0.0.3.9527: Flags [F.], seq 0, ack 1, win 502, options [nop,nop,TS val 3301147809 ecr 3783934485], length 0
这个重传次数由内核参数 net.ipv4.tcp_orphan_retries 决定(连接关闭时所有的超时重传次数都受这个参数控制):
# vm-2 $ sysctl net.ipv4.tcp_max_orphans net.ipv4.tcp_max_orphans = 8192
拦截第二个 FIN 包
# vm-2 $ while true; do sudo netstat -anpo | grep 9527; sleep 1; done tcp 0 0 10.0.0.4:45954 10.0.0.3:9527 ESTABLISHED 57486/nc off (0.00/0/0) tcp 0 0 10.0.0.4:45954 10.0.0.3:9527 ESTABLISHED 57486/nc off (0.00/0/0) tcp 0 0 10.0.0.4:45954 10.0.0.3:9527 ESTABLISHED 57486/nc off (0.00/0/0) tcp 0 0 10.0.0.4:45954 10.0.0.3:9527 ESTABLISHED 57486/nc off (0.00/0/0) tcp 0 0 10.0.0.4:45954 10.0.0.3:9527 FIN_WAIT2 - timewait (59.64/0/0) tcp 0 0 10.0.0.4:45954 10.0.0.3:9527 FIN_WAIT2 - timewait (58.62/0/0) tcp 0 0 10.0.0.4:45954 10.0.0.3:9527 FIN_WAIT2 - timewait (57.59/0/0) tcp 0 0 10.0.0.4:45954 10.0.0.3:9527 FIN_WAIT2 - timewait (56.56/0/0) tcp 0 0 10.0.0.4:45954 10.0.0.3:9527 FIN_WAIT2 - timewait (55.53/0/0)vm-1
$ while true; do sudo netstat -anpo | grep 9527 | grep -v LISTEN; sleep 1; done
tcp 0 0 10.0.0.3:9527 10.0.0.4:45954 ESTABLISHED 67885/nc off (0.00/0/0)
tcp 0 0 10.0.0.3:9527 10.0.0.4:45954 ESTABLISHED 67885/nc off (0.00/0/0)
tcp 0 1 10.0.0.3:9527 10.0.0.4:45954 LAST_ACK - on (0.26/1/0)
tcp 0 1 10.0.0.3:9527 10.0.0.4:45954 LAST_ACK - on (0.05/2/0)
tcp 0 1 10.0.0.3:9527 10.0.0.4:45954 LAST_ACK - on (0.67/3/0)
tcp 0 1 10.0.0.3:9527 10.0.0.4:45954 LAST_ACK - on (2.94/4/0)
tcp 0 1 10.0.0.3:9527 10.0.0.4:45954 LAST_ACK - on (1.91/4/0)
tcp 0 1 10.0.0.3:9527 10.0.0.4:45954 LAST_ACK - on (0.88/4/0)
FIN_WAIT2 状态下,超时时间由 net.ipv4.tcp_fin_timeout 这个内核参数决定,超过了就会强制结束状态(不会进入 TIME_WAIT 状态,默认 60s)。
# vm-1 $ sudo sysctl net.ipv4.tcp_fin_timeout net.ipv4.tcp_fin_timeout = 60
注意上面 netstat 的定时器信息显示的是 timewait 字样。在 Linux 内核里,这个状态的一些处理逻辑也是类似 TIME_WAIT 状态的。
# vm-1 启动 python 的 server $ python3 server.pyvm-2
懒得改脚本了,直接 ipython 随手敲一下算了
$ ipython
Python 3.6.8 (default, Jan 29 2024, 15:29:06)
Type ‘copyright’, ‘credits’ or ‘license’ for more information
IPython 7.16.3 – An enhanced Interactive Python. Type ‘?’ for help.
In [1]: import socket
In [2]: c = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
In [3]: c.connect((‘10.0.0.3’, 9527))
In [4]: c.shutdown(socket.SHUT_WR)
# vm-1 $ sudo netstat -anpo | grep -E "Recv-Q|9527" Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name Timer tcp 1 0 10.0.0.3:9527 0.0.0.0:* LISTEN 23797/python3 off (0.00/0/0) tcp 1 0 10.0.0.3:9527 10.0.0.4:54788 CLOSE_WAIT - off (0.00/0/0)vm-2
$ sudo netstat -anpo | grep -E “Recv-Q|9527”
Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name Timer
tcp 0 0 10.0.0.4:54788 10.0.0.3:9527 FIN_WAIT2 7269/python3 off (0.00/0/0)
vm-2 是主动 FIN的发送方,转换到了FIN_WAIT_1状态后立即收到了 vm-1 的 ACK 就切换到了FIN_WAIT_2状态,而 vm-1 收到FIN并发出ACK后就进入了CLOSE_WAIT状态,这就是 TCP 的半关闭。shutdown()调用转换到FIN_WAIT2是豁免net.ipv4.tcp_fin_timeout超时的,所以上面netstat 看到的 Timer 是 off 状态。
连接保活
# vm-2 $ sysctl net.ipv4.tcp_keepalive_time net.ipv4.tcp_keepalive_probes net.ipv4.tcp_keepalive_intvl net.ipv4.tcp_keepalive_time = 7200 net.ipv4.tcp_keepalive_probes = 9 net.ipv4.tcp_keepalive_intvl = 75
-
net.ipv4.tcp_keepalive_time:在 TCP 保活打开的情况下,最后一次数据交换到 TCP 发送第一个保活探测包的间隔,即允许的持续空闲时长,或者说每次正常发送心跳的周期,默认值为 7200s
-
net.ipv4.tcp_keepalive_probes:在首包之后,没有回应情况下最大允许发送保活探测包的次数,到达此次数后直接放弃尝试,并关闭连接,默认值为 9
-
net.ipv4.tcp_keepalive_intvl:在首包探测之后,如果依旧是没有 TCP 数据包的情况下,继续发送保活探测包的发送频率,默认值为 75s
# vm-2 修改下默认值 $ sudo sysctl -w net.ipv4.tcp_keepalive_time=10 net.ipv4.tcp_keepalive_time = 10
# vm-2 sudo netstat -anpo | grep -E "Recv-Q|9527" Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name Timer tcp 0 0 10.0.0.4:50020 10.0.0.3:9527 ESTABLISHED 7417/python3 keepalive (9.56/0/0)
抓包的信息这里就不贴了,只需要知道 keepalive 包其实就是在 ack 之前已经 ack 过的 sequence 就行,有兴趣的话可以自己研究下。唯一需要注意的是一些 keepalive 机制“不生效”的情况,如果你完全依赖 keepalive 机制去保证网络异常情况下的异常连接断开的兜底时间的话,那你可能要失望了。因为这个过程中,大概率会有正常通信的 TCP 包没有收到而触发重传。一旦有重传包,那么 keepalive 就会认为是有数据发送的。此时重传的兜底时间和次数会干扰预设的 keepalive 兜底次数和时间,总的连接断开时间往往会超过预期。如果应用层协议无法做到心跳探活的话,RFC428 的 TCP USER_TIMEOUT 机制也许能帮到你,具体细节和一个生产环境的例子可以参考这篇文章《为什么 Lettuce 会带来更长的故障时间?》。
总结
参考链接:
[1]https://www.rfc-editor.org/rfc/rfc793
[2]https://www.rfc-editor.org/rfc/rfc1122
[3]https://www.rfc-editor.org/rfc/rfc6691
[4]https://www.rfc-editor.org/rfc/rfc2018
[5]https://www.rfc-editor.org/rfc/rfc2883
[6]https://www.rfc-editor.org/rfc/rfc1323
[7]https://github.com/torvalds/linux/commit/ef547f2ac16bd9d77a780a0e7c70857e69e8f23f
[8]https://github.com/torvalds/linux/commit/5ea8ea2cb7f1d0db15762c9b0bb9e7330425a071
[9]https://github.com/torvalds/linux/commit/47da8ee681d04e68ca1b1812c10e28162150d453
[10]https://github.com/torvalds/linux/commit/5ee3afba88f5a79d0bff07ddd87af45919259f91
[11]https://lore.kernel.org/lkml/20240509044323.247606-2-yf768672249@gmail.com/T/
[12]https://github.com/torvalds/linux/commit/a26552afe89438eefbe097512b3187f2c7e929fd
[13]https://github.com/torvalds/linux/commit/95a22caee396cef0bb2ca8fafdd82966a49367bb
[14]https://github.com/torvalds/linux/commit/28ee1b746f493b7c62347d714f58fbf4f70df4f0
[15]https://github.com/torvalds/linux/commit/d82bae12dc38d79a2b77473f5eb0612a3d69c55b
[16]https://github.com/torvalds/linux/commit/4396e46187ca5070219b81773c4e65088dac50cc
[17]https://help.aliyun.com/zh/alinux/user-guide/change-the-tcp-time-wait-timeout-period
[18]https://vincent.bernat.ch/en/blog/2014-tcp-time-wait-state-linux






