【动手实验】TCP 连接的建立与关闭抓包分析

本文是基于知识星球程序员踩坑案例分享中的作业进行的复现和总结,借此加深对 TCP 协议的理解, 原文参见TCP 连接的建立和关闭 ------ 强烈建议新手看看

实验环境

这里使用两台位于同一子网的腾讯云服务器,IP 分别是 node2(172.19.0.12)和 node3(172.19.0.15),内核版本均为 5.15.0-130-generic。

shell 复制代码
# node02
$ uname -a
Linux node2 5.15.0-130-generic #140-Ubuntu SMP Wed Dec 18 17:59:53 UTC 2024 x86_64 x86_64 x86_64 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: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 8500 qdisc mq state UP group default qlen 1000
    altname enp0s5
    altname ens5
    inet 172.19.0.12/20 metric 100 brd 172.19.15.255 scope global eth0
       valid_lft forever preferred_lft forever


# node03
$ uname -a
Linux node3 5.15.0-130-generic #140-Ubuntu SMP Wed Dec 18 17:59:53 UTC 2024 x86_64 x86_64 x86_64 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: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 8500 qdisc mq state UP group default qlen 1000
    altname enp0s5
    altname ens5
    inet 172.19.0.15/20 metric 100 brd 172.19.15.255 scope global eth0
       valid_lft forever preferred_lft forever

启动服务

首先我们使用 nc(netcat) 作为服务端,在 node2 监听 9527 端口:

# ubuntu @ node2 in ~ [10:40:58]
$ nc -k -l 172.19.0.12  9527

该命令表示在 IP 地址 172.19.0.12 的 9527 端口上持续监听(等待连接并接收数据)。参数含义如下:

  • -k 保持连接(Keep Listening),在客户端断开后继续监听端口。
  • -l 监听模式(Listen Mode),启动服务器等待连接。

启动成功后用 netstat 命令查看 socket 的连接状态:

$ 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 172.19.0.12:9527        0.0.0.0:*               LISTEN      13504/nc             off (0.00/0/0)

netstat 命令的各个参数含义如下:

  • -a 显示所有连接和监听的套接字。
  • -n 显示 IP 地址和端口号,不解析主机名。
  • -o 显示进程 ID(PID)和计时器信息。
  • -p 显示进程名称。

可以看到 9527 端口处于 LISTEN 状态,表示正在监听端口,等待连接请求。

连接建立

在客户端请求 node2 之前,我们先在 node2 开启抓包:

bash 复制代码
# ubuntu @ node2 in ~ [10:38:33]
$ sudo tcpdump -s0 -X -nn "tcp port 9527" -w tcp.pcap --print
tcpdump: listening on eth0, link-type EN10MB (Ethernet), snapshot length 262144 bytes

命令各个参数含义为:

  • -s0 捕获完整数据包(默认 -s 只抓取前 68/96 字节),0 代表不截断。
  • -X 以十六进制(hex)+ ASCII 格式打印数据包内容。
  • -nn 不解析主机名和端口(-n 不解析 IP,-nn 也不解析端口)。
  • "tcp port 9527" 仅捕获 TCP 端口 9527 的流量。
  • -w tcp.pcap 将捕获的数据包写入 tcp.pcap 文件(可用 wireshark 或 tcpdump -r tcp.pcap 查看)。
  • --print 同时在终端打印数据包内容(类似 -X,但 --print 仅在 -w 选项启用时生效)。

接下来我们在 node3 上使用 nc 连接 node2 的 9527 端口:

# ubuntu @ node3 in ~ [10:41:48]
$ nc 172.19.0.12 9527

然后分别在 node2 和 node3 上使用 netstat 命令查看 socket 的连接状态:

bash 复制代码
# node2
$ 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 172.19.0.12:9527        0.0.0.0:*               LISTEN      13504/nc             off (0.00/0/0)
tcp        0      0 172.19.0.12:9527        172.19.0.15:48868       ESTABLISHED 13504/nc             off (0.00/0/0)


# node3
$ 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 172.19.0.15:48868       172.19.0.12:9527        ESTABLISHED 17255/nc             off (0.00/0/0)

可以看到 node2 和 node3 中都有一条端口为 9527 处于 ESTABLISHED 状态的连接,表示连接已建立。 tcpdump 命令也会输出三次握手的数据包详情。

$ sudo tcpdump -s0 -X -nn "tcp port 9527" -w tcp.pcap --print
tcpdump: listening on eth0, link-type EN10MB (Ethernet), snapshot length 262144 bytes
10:54:13.960797 IP 172.19.0.15.48868 > 172.19.0.12.9527: Flags [S], seq 2713301685, win 59220, options [mss 8460,sackOK,TS val 2002430584 ecr 0,nop,wscale 7], length 0
	0x0000:  4500 003c 3a31 4000 4006 a849 ac13 000f  E..<:1@.@..I....
	0x0010:  ac13 000c bee4 2537 a1b9 b2b5 0000 0000  ......%7........
	0x0020:  a002 e754 92b3 0000 0204 210c 0402 080a  ...T......!.....
	0x0030:  775a aa78 0000 0000 0103 0307            wZ.x........
10:54:13.960874 IP 172.19.0.12.9527 > 172.19.0.15.48868: Flags [S.], seq 3309498602, ack 2713301686, win 59136, options [mss 8460,sackOK,TS val 556655863 ecr 2002430584,nop,wscale 7], length 0
	0x0000:  4500 003c 0000 4000 4006 e27a ac13 000c  E..<..@.@..z....
	0x0010:  ac13 000f 2537 bee4 c542 f0ea a1b9 b2b6  ....%7...B......
	0x0020:  a012 e700 5870 0000 0204 210c 0402 080a  ....Xp....!.....
	0x0030:  212d e4f7 775a aa78 0103 0307            !-..wZ.x....
10:54:13.961020 IP 172.19.0.15.48868 > 172.19.0.12.9527: Flags [.], ack 1, win 463, options [nop,nop,TS val 2002430584 ecr 556655863], length 0
	0x0000:  4500 0034 3a32 4000 4006 a850 ac13 000f  E..4:2@.@..P....
	0x0010:  ac13 000c bee4 2537 a1b9 b2b6 c542 f0eb  ......%7.....B..
	0x0020:  8010 01cf 05fa 0000 0101 080a 775a aa78  ............wZ.x
	0x0030:  212d e4f7                                !-..

三次握手抓包 & TCP 协议头解析

我们将抓包文件拖入 Wireshark 中来分析三次握手的过程。

首先回顾下 TCP 协议头格式:

图片来自 TCP/IP Reference

像序列号、端口信息、FLAG 等字段都比较熟悉了,我们这里重点看下 Options 的各个字段,完整的 Option 字段可以参考 Transmission Control Protocol (TCP) Parameters,这里我们只关注包中出现的最常见的几个字段:

  • MSS(Maximum Segment Size) 该字段只能在 SYN 包中,用来告知对方自己可以接收的最大数据包,这里指的是 TCP 包中 data 的大小,不包含 TCP 头数据。RFC 6691 中规定了 MSS 的值为 MTU 减去 IP 固定头大小(20 字节)和 TCP 固定头大小(20字节),不包含任何 Option 字段。从 ip -4 addr 命令中可以看到网卡的 MTU 大小为 8500,因此 MSS 大小为 8500 - 20 - 20 = 8460,和抓包中显示的 MSS 大小一致。

    ubuntu @ node3 in ~ [10:41:48]

    $ ip -4 addr
    ...
    2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 8500 qdisc mq state UP group default qlen 1000
    ...

  • SACK(Selective Acknowledgment) 选择性确认。在 RFC 2018 确定的机制,必须在握手时确认是否支持。TCP 最开始是按顺序响应的,比如有 1、2、3、4 共 4 个包,如果 2 没有收到,那即使 3、4 收到了也不会响应 ACK,这可能导致客户端不断重传 3、4 号包,对网络造成不必要的负载。SACK 解决了这一问题,可以让服务端响应 3、4 包,客户端只需要重传 2 号包就可以了。

图片来自 TCP/IP Guide

在 Linux 内核中,使用 net.ipv4.tcp_sack 参数来控制是否开启 SACK ,默认开启。

bash 复制代码
$ sysctl net.ipv4.tcp_sack
net.ipv4.tcp_sack = 1
  • TS(Timestamp) 时间戳标记。内核用来计算 RTT(Round-Trip Time),即数据包从发送端到接收端的时间。在内核中可以使用 net.ipv4.tcp_timestamps 参数来控制是否开启该选项。
bash 复制代码
$ sysctl net.ipv4.tcp_timestamps
net.ipv4.tcp_timestamps = 1
  • NOP(No Operation) NOP 一般用来占位对齐,因为 TCP 头大小必须是 4 字节的倍数。因此当 TCP 固定头 + Option 字段长度不为 4 字节的倍数时,一般会填充 NOP 字段。

  • WScale(Window Scale) 窗口缩放因子。TCP 的 window 窗口字段大小是 16bit,其最大值为 65536 ,也就是说 TCP 包能传输的最大数据为 65536 byte / 1024 = 64KB。在硬件设备和网络如此发达的今天,这个窗口大小显然有点太小了,为此 RFC 7323 中提出了 WScale 选项,用来扩展 window 字段的大小。

    WScale Option 中有 shift.count 值,顾名思义就是移位数,表示 2 的多少次方,虽然 shift.count 占了 1 个字节,但 RFC 规定只能使用后 4 位,其最大值为 1110,也就是 14。结合最大 window 值为 64KB,在 WScale 的帮助下,最大窗口大小可以达到 64KB * (2^14) = 1048576KB = 1GB。

在我们的抓包中,可以看到 WScale 选项的值为 7,因此 window * (2^7) 才是真正的 window 大小。

需要注意的是,WScale 只会在携带这个选项的包之后生效,因此发送第一个 SYN 包时是没有生效的,在第三次握手时该选项才生效,可以看到 window 值为 463,而计算后的 window 值为 463 * (2^7) = 463 * 128 = 59264,和 Wireshark 中显示的 window 值一致。

在 Linux 内核中,可以通过 net.ipv4.tcp_window_scaling 参数来控制是否开启 WScale 选项。

bash 复制代码
$ sysctl net.ipv4.tcp_window_scaling
net.ipv4.tcp_window_scaling = 1

SYN-SENT 状态抓包

前文抓包我们看到的是 LISTEN 和 ESTABLISHED 状态的 socket,除了这两种状态,连接建立时客户端、服务端还会分别经历 SYN-SENT 和 SYN-RECV 状态。

图片来自 TCP/IP Guide

这里通过 iptables 拦截握手包来看下 SYN-SENT 和 SYN-RECV 状态的 socket,首先在 node2 上使用 iptables 规则,将访问 9527 的端口包丢弃掉,命令如下:

bash 复制代码
sudo iptables -A INPUT -p tcp --dport 9527 -j DROP

然后在 node3 再次执行 nc 命令连接服务,这次带上参数 -w 3600,表示连接超时时间为 3600 秒,命令如下:

bash 复制代码
nc -w 3600 172.19.0.12 9527

请求发出后,tcpdump 抓包会打印 SYN 包和后续的重传包,用 Wireshark 打开抓包文件:

可以看到 SYN 包一共有 6 次重传,共传了 7 个包。Linux 的 SYN 最大重传次数是由内核参数 net.ipv4.tcp_syn_retries 控制的,默认值为 6。

bash 复制代码
# ubuntu @ node3 in ~ [16:26:35] C:130
$ sysctl net.ipv4.tcp_syn_retries
net.ipv4.tcp_syn_retries = 6

重传的超时 RTO 时间初始值通常在 1s 左右,按照指数级增长,因此重传时间间隔大约为 1s、2s、4s、8s、16s、32s。从抓包中也可以看到,在 1.02,3.03,7.726s,15.15,31.58,65.11s 发生了重传,因此默认情况下,一个 TCP 连接建立的超时时间会大于 64s。

在重传期间,查看客户端的 netstat 信息可以看到 SYN-SENT 状态的 socket,表示连接正在等待 SYN 包的响应。

# ubuntu @ node3 in ~ [16:26:35] C:130
$ while true; do sudo netstat -anpo | grep 9527; sleep 1; done
tcp        0      1 172.19.0.15:44004       172.19.0.12:9527        SYN_SENT    3066236/nc           on (0.77/0/0)
tcp        0      1 172.19.0.15:44004       172.19.0.12:9527        SYN_SENT    3066236/nc           on (1.78/1/0)
tcp        0      1 172.19.0.15:44004       172.19.0.12:9527        SYN_SENT    3066236/nc           on (0.76/1/0)
tcp        0      1 172.19.0.15:44004       172.19.0.12:9527        SYN_SENT    3066236/nc           on (3.76/2/0)
tcp        0      1 172.19.0.15:44004       172.19.0.12:9527        SYN_SENT    3066236/nc           on (2.74/2/0)
tcp        0      1 172.19.0.15:44004       172.19.0.12:9527        SYN_SENT    3066236/nc           on (1.72/2/0)
tcp        0      1 172.19.0.15:44004       172.19.0.12:9527        SYN_SENT    3066236/nc           on (0.70/2/0)
tcp        0      1 172.19.0.15:44004       172.19.0.12:9527        SYN_SENT    3066236/nc           on (7.88/3/0)
tcp        0      1 172.19.0.15:44004       172.19.0.12:9527        SYN_SENT    3066236/nc           on (6.86/3/0)
tcp        0      1 172.19.0.15:44004       172.19.0.12:9527        SYN_SENT    3066236/nc           on (5.84/3/0)
tcp        0      1 172.19.0.15:44004       172.19.0.12:9527        SYN_SENT    3066236/nc           on (4.82/3/0)
tcp        0      1 172.19.0.15:44004       172.19.0.12:9527        SYN_SENT    3066236/nc           on (3.80/3/0)
tcp        0      1 172.19.0.15:44004       172.19.0.12:9527        SYN_SENT    3066236/nc           on (2.78/3/0)
tcp        0      1 172.19.0.15:44004       172.19.0.12:9527        SYN_SENT    3066236/nc           on (1.76/3/0)
tcp        0      1 172.19.0.15:44004       172.19.0.12:9527        SYN_SENT    3066236/nc           on (0.75/3/0)
tcp        0      1 172.19.0.15:44004       172.19.0.12:9527        SYN_SENT    3066236/nc           on (15.92/4/0)
tcp        0      1 172.19.0.15:44004       172.19.0.12:9527        SYN_SENT    3066236/nc           on (14.90/4/0)
tcp        0      1 172.19.0.15:44004       172.19.0.12:9527        SYN_SENT    3066236/nc           on (13.88/4/0)
tcp        0      1 172.19.0.15:44004       172.19.0.12:9527        SYN_SENT    3066236/nc           on (12.86/4/0)
tcp        0      1 172.19.0.15:44004       172.19.0.12:9527        SYN_SENT    3066236/nc           on (11.84/4/0)
tcp        0      1 172.19.0.15:44004       172.19.0.12:9527        SYN_SENT    3066236/nc           on (10.83/4/0)
tcp        0      1 172.19.0.15:44004       172.19.0.12:9527        SYN_SENT    3066236/nc           on (9.81/4/0)
tcp        0      1 172.19.0.15:44004       172.19.0.12:9527        SYN_SENT    3066236/nc           on (8.79/4/0)
tcp        0      1 172.19.0.15:44004       172.19.0.12:9527        SYN_SENT    3066236/nc           on (7.77/4/0)
tcp        0      1 172.19.0.15:44004       172.19.0.12:9527        SYN_SENT    3066236/nc           on (6.75/4/0)
tcp        0      1 172.19.0.15:44004       172.19.0.12:9527        SYN_SENT    3066236/nc           on (5.73/4/0)
tcp        0      1 172.19.0.15:44004       172.19.0.12:9527        SYN_SENT    3066236/nc           on (4.71/4/0)
tcp        0      1 172.19.0.15:44004       172.19.0.12:9527        SYN_SENT    3066236/nc           on (3.70/4/0)
tcp        0      1 172.19.0.15:44004       172.19.0.12:9527        SYN_SENT    3066236/nc           on (2.68/4/0)
tcp        0      1 172.19.0.15:44004       172.19.0.12:9527        SYN_SENT    3066236/nc           on (1.65/4/0)
tcp        0      1 172.19.0.15:44004       172.19.0.12:9527        SYN_SENT    3066236/nc           on (0.64/4/0)
tcp        0      1 172.19.0.15:44004       172.19.0.12:9527        SYN_SENT    3066236/nc           on (31.74/5/0)

最后一列是 Timer 计时器,格式为 timer(a/b/c),timer取值有四种

  • on: 超时计时器
  • off: 没有计时器
  • keepalive: keepalive 计时器
  • timewait: TIME_WAIT 计时器

对于超时计时器,a 表示当前计时器剩余时间,b 表示当前计时器重传次数,c 表示已发送的保活探测次数,比如命令中一行时 (1.72/2/0),1.72 表示在等 1.72 秒进行重传,2 表示已经重传了两次。

node2 使用 iptables 屏蔽了所有 9527 端口的包,因此不会有任何 socket 信息。

SYN-RECV 状态抓包

我们在修改下 node3 的 iptables 规则,将源端口为 9527 的包丢弃掉,命令如下:

bash 复制代码
# --sport 9527 表示源端口为 9527 的包被匹配,也就是 node2 发来的 ACK 包会被拦截
sudo iptables -A INPUT -p tcp --sport 9527 -j DROP

为了避免 SYN 重传,这里使用 namp 命令执行访问,命令如下:

bash 复制代码
sudo nmap -sS 172.19.0.12 -p 9527

node02 抓包如下:

可以看到 SYN-ACK 重传了 5 次,这是由内核参数 net.ipv4.tcp_synack_retries 控制的,默认值为 5,重传时间也是从 1s 开始逐渐翻倍,成指数级增长。

bash 复制代码
$ sysctl net.ipv4.tcp_synack_retries
net.ipv4.tcp_synack_retries = 5

重传过程中,查看 node2 的 netstat 信息可以看到 SYN-RECV 状态的 socket,表示连接正在等待 SYN-ACK 包的响应。

bash 复制代码
$ $ while true; do sudo netstat -anpo | grep SYN_RECV; sleep 1; done
tcp        0      0 172.19.0.12:9527        172.19.0.15:48803       SYN_RECV    -                    on (1.24/1/0)
tcp        0      0 172.19.0.12:9527        172.19.0.15:48803       SYN_RECV    -                    on (0.22/1/0)
tcp        0      0 172.19.0.12:9527        172.19.0.15:48803       SYN_RECV    -                    on (3.22/2/0)
tcp        0      0 172.19.0.12:9527        172.19.0.15:48803       SYN_RECV    -                    on (2.20/2/0)
tcp        0      0 172.19.0.12:9527        172.19.0.15:48803       SYN_RECV    -                    on (1.18/2/0)
tcp        0      0 172.19.0.12:9527        172.19.0.15:48803       SYN_RECV    -                    on (0.16/2/0)
tcp        0      0 172.19.0.12:9527        172.19.0.15:48803       SYN_RECV    -                    on (7.34/3/0)
tcp        0      0 172.19.0.12:9527        172.19.0.15:48803       SYN_RECV    -                    on (6.32/3/0)

SYN Flood 攻击

上面实验可以看到在 SYN-ACK 包重传期间,始终有 socket 在占用服务器的资源。如果有恶意攻击者不断发送 SYN 包,同时拒绝接收 SYN-ACK 或者故意不响应 ACK,服务器就会有大量处于 SYN-RECV 状态的连接消耗资源。这里的原理是在三次握手时,Linux 内核维护了半连接队列(SYN Queue)和全连接队列(Accept Queue),大量 SYN-RECV 状态的 socket 会占满 SYN Queue 队列,导致服务器无法正常处理新的 SYN 包,这就是 SYN Flood 攻击。

Linux 内核提供了 net.ipv4.tcp_syncookies 参数来应对 SYN Flood 攻击,当该参数开启时,如果队列已满,内核会计算一个 Cookies 值作为 SYN-ACK 包的序列号返回,客户端收到后会在 ACK 中使用 Cookie+1 作为序列号进行响应,服务端只有在检查 ACK 包的序列号正确后才会建立连接。这样如果有 SYN Flood 攻击,服务端每次都只计算 cookie 进行响应,不会真的占用半连接队列,从而达到服务拒绝的目的。

关于半连接队列全连接队列的更详细介绍可以以参考笔者的另一篇文章 【动手实验】TCP半连接队列、全连接队列实战分析,这里不在赘述。

PS:原实验用了 nc 验证 SYN Queue 的队列长度,但笔者在做实验时发现 nc 的 SYN-Queue 默认长度为 1,无法复现实验中的效果。

在 ChatGPT 帮助下了解到,对于网络 socket 来说,nc 在调用 listen 时,默认的 backlog 长度为 1,因此无法复现实验中的效果。查看 nc 的源码也可以验证这一点。因此如果要做类似的实验,最好用其他工具,比如 Python、Go 等语言做服务端。

c 复制代码
// 源码地址
// https://github.com/openbsd/src/blob/d800967ee04b1c92ceefa78494d0ff66606a806d/usr.bin/nc/netcat.c#L1072

/*
 * local_listen()
 * Returns a socket listening on a local port, binds to specified source
 * address. Returns -1 on failure.
 */
int
local_listen(const char *host, const char *port, struct addrinfo hints)
{
	// 代码省略

	if (!uflag && s != -1) {
    // 调用 listen 时,默认的 backlog 长度为 1
		if (listen(s, 1) == -1)
			err(1, "listen");
	}
 // 代码省略

	return s;
}

为什么需要三次握手?

实验完成了这里多扯一句三次握手的目的,网络上的资料大部分都会提到三次握手的目的是客户端、服务端同步序列号、窗口大小、MSS、SACK 等信息,其实这部分在前两次握手就已经完成了。三次握手最重要的原因在 RFC 里写的很清楚,主要是为了是防止历史的重复连接初始化造成的混乱问题,防止使用 TCP 协议通信的双方建立了错误的连接。

The principal reason for the three-way handshake is to prevent old duplicate connection initiations from causing confusion.

RFC9293

TCP 是半开、全双工通信的,通信双方要互相建立连接才行,所谓的三次握手本质上是完成四步操作:

  • 客户端请求建立连接;服务端响应确认。
  • 服务端请求建立连接,客户端响应确认。

我们将主动请求建立连接的一方称为发起方,被动建立连接的一方称为接收方。连接建立时,发起方的 SYN 请求和接收方的 ACK 响应是必不可少的,但除此之外,发起方必须要确认接收方的的响应是否正确,因此 TCP 引入了三次握手和 RST 机制来完成这一确认操作:

  • 如果接收方响应正确。则接收方发送 ACK 消息,完成正常的第三次握手。
  • 如果接收方响应错误,则接收方发送 RST 消息中断连接。

无论发送方返回 ACK 还是 RST 消息,都至少需要一次发起方到接收方的通信,这才是三次握手最重要的目的。

下面是 RFC9293 中的例子:

图片来自:为什么 TCP 建立连接需要三次握手

客户端发送第一次 SYN 后响应超时,又发送了一次 SYN,然而服务端响应了首次的 SYN,客户端收到 ACK 后检查到序列号不对,此时返回 RST 包中断连接,然后重新执行三次握手过程。

连接关闭

分析完了 TCP 连接建立的过程,我们再来分析下 TCP 连接关闭的过程。我们继续使用 nc 作为工具,首先启动服务端和客户端。

bash 复制代码
# node2 使用 nc 启动服务端
$ nc -k -l 172.19.0.12  9527

# node3 使用 nc 启动客户端
$ nc 172.19.0.12 9527

完成后查看服务端和客户端的状态信息:

bash 复制代码
# node2 服务端
$ ss -atnp | grep -E "Recv-Q|9527"
State  Recv-Q Send-Q Local Address:Port    Peer Address:Port Process
LISTEN 0      1        172.19.0.12:9527         0.0.0.0:*     users:(("nc",pid=147133,fd=3))
ESTAB  0      0        172.19.0.12:9527     172.19.0.15:42526 users:(("nc",pid=147133,fd=4))

# node3 客户端
$ ss -atnp | grep -E "Recv-Q|9527"
State  Recv-Q Send-Q Local Address:Port    Peer Address:Port Process
ESTAB  0      0        172.19.0.15:42526    172.19.0.12:9527  users:(("nc",pid=149072,fd=3))

正常关闭

我们首先在 node2 执行抓包,然后在客户端按照 ctrl+c 关闭连接,然后执行 netstat 命令查看服务端的状态信息:

bash 复制代码
# node2 抓包
$ sudo tcpdump -s0 -X -nn "tcp port 9527" -w tcp-handshake-03.pcap --print
tcpdump: listening on eth0, link-type EN10MB (Ethernet), snapshot length 262144 bytes

# node2 服务端
Proto Recv-Q Send-Q Local Address           Foreign Address         State       PID/Program name     Timer
tcp        0      0 172.19.0.12:9527        0.0.0.0:*               LISTEN      147133/nc            off (0.00/0/0)
tcp        0      0 172.19.0.12:9527        172.19.0.15:41492       ESTABLISHED 147133/nc            off (0.00/0/0)

# node3 客户端
$ 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 172.19.0.15:41492       172.19.0.12:9527        TIME_WAIT   -                    timewait (58.92/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 172.19.0.15:41492       172.19.0.12:9527        TIME_WAIT   -                    timewait (47.42/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 172.19.0.15:41492       172.19.0.12:9527        TIME_WAIT   -                    timewait (36.56/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 172.19.0.15:41492       172.19.0.12:9527        TIME_WAIT   -                    timewait (24.22/0/0)

抓包结果如图:

我们来简要分析下上述过程:

  1. 连接断开的很快,从抓包结果可以看出耗时大约 0.0019s,因此服务端执行 netstat 已经查不到连接了。

  2. 四次握手只有 3 个包,因为服务端没有数据需要处理,所以在对客户端的 FIN 进行 ACK 时,把 FIN 也捎带上了。

  3. 客户端收到了服务端的 FIN 并发送了 ACK 后进入 TIME_WAIT 状态,从 netstat 输出结果看有一个 60s 的timewait 定时器正在执行。

关于 TIME_WAIT 我们来做进一步的分析。

TIME_WAIT 状态处理

TIME_WAIT 主要是为了解决两个问题:

  1. 防止前一个连接的延迟发送的 Segment 被使用相同四元组的连接接收。

我们看下面图中的例子,第一个连接服务端发送的 SEQ=3 因为某些原因丢失,服务端执行了重传后客户端接收并断开了连接进入 TIME_WAIT 状态。此时如果 TIME_WAIT 时间过短,很快又和服务端建立了另一个使用相同四元组的连接,而此时之前丢失的 SEQ=3 包又发送来了,造成 TCP 状态的紊乱。

图片来源:Coping with the TCP TIME-WAIT state on busy Linux servers

  1. 确保远端已经关闭连接

当被动关闭的一方发送 FIN 后会进入 LAST_ACK 状态等待对端的 ACK。如果没有 TIME_WAIT 状态,服务端处于 LAST_ACK 状态时,客户端可能会使用相同的四元组来新建连接,因为新的连接会使用新的序列号,与之前的不匹配,服务端会认为新连接错误,从而返回 RST 包中止连接。

图片来源:Coping with the TCP TIME-WAIT state on busy Linux servers

TIME_WAIT 状态的 socket 本身也会带来问题,主要是端口占用,可能导致服务器无法建立新的连接。TIME_WAIT 状态只会在主动断开连接的一方出现,在收到对方的 FIN 包后进入该状态。Linux 默认的 MSL(Maximum Segment Lifetime, 最大报文生存时间) 为 30s,因此默认的 TIME_WAIT 时间为 2* MSL = 60s。在这期间端口是一直被占用的,服务器是根据四元组来识别 socket 的,因此在这 1 分钟内,服务器不能在建立相同的连接。

Linux 开放给应用使用的端口大约在 3 万个左右,受 sysctl net.ipv4.ip_local_port_range 的影响。假设我们可以使用全部的可用接口,在大量连接执行正常断开的流程下,我们只能支持每秒 500 条连接建立。但实际情况是服务端程序往往只会监听若干固定端口,并且收到的流量可能是通过几台 LoadBalancer 转发过来的,因此实际能支持的四元组数量是有限的。

bash 复制代码
$ sysctl net.ipv4.ip_local_port_range
net.ipv4.ip_local_port_range = 32768	60999

Linux 内核提供了以下参数来影响 TIME_WAIT 状态的处理:

  1. net.ipv4.ip_local_port_range:可以通过该参数来扩大可用端口范围,让服务器可以创建更多的连接。

  2. net.ipv4.tcp_timestampsRFC 7323 引入的时间戳机制。其定义了一个 Timestamp 的 option,包含两个值:value,发送方当前的时间戳;echo,对端响应的最新时间戳。

  1. net.ipv4.tcp_tw_recycle:开启后,如果某个远端发来的包的时间戳,小于上次发过来的时间戳,会将这些包丢掉。开启后理论上是可以解决上面提到的第一个问题的,旧的包发来时被发现其 timestamp 小于新连接发来的 timestamp,会被丢掉。但理想很丰满,现实很骨感,该参数要求 timestamp 必须是单调递增的。这在 LB/NAT 环境下是无法得到保证的,因为无法共享时间戳时钟。在 4.12 版本之后该配置已经被移除,因此在生产环境中,任何情况下都不在建议开启这个选项。

  2. net.ipv4.tcp_tw_reuse: 将处于 TIME_WAIT 状态的 socket 用于主动建立新的 socket 连接,其允许内核复用超过 1s 的 TIME_WAT socket 被复用(仅适用于主动建立连接,被动建立连接的一方这个选项没啥用)。

  3. net.ipv4.tcp_max_tw_buckets:内核允许的状态为 TIME_WAIT 的最大连接数。超过该数字后,新的 TIME_WAIT 会被立即销毁。

bash 复制代码
$ sysctl net.ipv4.tcp_timestamps net.ipv4.tcp_tw_reuse net.ipv4.tcp_max_tw_buckets net.ipv4.tcp_tw_recycle
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 = 4096
sysctl: cannot stat /proc/sys/net/ipv4/tcp_tw_recycle: No such file or directory

下面做实验来看下上述参数的效果:

python 复制代码
# 服务端 启动服务
$ nc -k -l 172.19.0.12  9527

# 客户端程序
# 不断打开并关闭连接
import socket

def 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('172.19.0.12', 9527, 70000)

先将 tcp_max_tw_buckets 调到 100 万,执行程序,结果如下,最终客户端报了 Cannot assign requested address 表示没有地址可用,客户端有

28232 个 TIME_WAIT 状态的 socket,与 ip_local_port_range 的计算范围一致。

sh 复制代码
$ sysctl net.ipv4.ip_local_port_range
net.ipv4.ip_local_port_range = 32768	60999

$ sudo netstat -anpo | grep 9527 | grep timewait | wc -l
28232

$ python3 client.py
Failed to connect: [Errno 99] Cannot assign requested address

现在我们将 net.ipv4.tcp_tw_reuse 设置为全局有效后再次执行程序,可以看到 TIME_WAIT 状态的数量会稳定在一万多条,客户端没有报错,执行完成后正常退出。

bash 复制代码
# 修改客户端 tcp_tw_reuse
sudo sysctl -w net.ipv4.tcp_tw_reuse=1

$ sudo netstat -anpo | grep 9527 | grep timewait | wc -l
10967

$ sudo netstat -anpo | grep 9527 | grep timewait | wc -l
13921

$ sudo netstat -anpo | grep 9527 | grep timewait | wc -l
14070

$ sudo netstat -anpo | grep 9527 | grep timewait | wc -l
14070

我们将 tcp_tw_reuse 关闭并调整 tcp_max_tw_buckets 为 5000 重复实验:

bash 复制代码
$ sudo sysctl -w net.ipv4.tcp_tw_reuse=2 net.ipv4.tcp_max_tw_buckets=5000

再次执行客户端程序后会发现 TIME_WAIT 状态的 socket 不会超过 5000,超过阈值后的 socket 会被清理并统计,可以通过 netstat -s 命令查看:

bash 复制代码
$ sudo netstat -anpo | grep 9527 | grep timewait | wc -l
4996

$ netstat -s | grep TCPTimeWaitOverflow
    TCPTimeWaitOverflow: 65008

这里我们总结下 TIME_WAIT 的处理:

  1. TIME_WAIT 是为了保证通信的可靠性而存在的,这也是为什么 Linux 内核不支持修改 60s 限制的原因。

The TIME_WAIT state is our friend and is there to help us (i.e., to let old duplicate segments expire in the network). Instead of trying to avoid the state, we should understand it. -- 《Unix programming》

  1. 在服务端,永远不要开启 net.ipv4.tcp_tw_recycle,新的内核版本已废弃;旧的版本在 LB/NAT 环境下会将正常包丢弃,导致问题。

  2. net.ipv4.tcp_tw_reuse 仅对主动断开和发起的一方有效,可以理解为只对客户端有效,服务端大部分都是被动建立连接,因此对其意义不大。

  3. 客户端还可以设置 0 延迟关闭的方式,此时会发送 RST 直接终止连接,不走正常的断开流程,也就不会进入 TIME_WAIT 状态,对于探活类应用非常拥有。但服务端永远不要设置,否则客户端会收到 connnection reset by peer 的错误。

  4. 服务端尽量不要主动断开连接,将 TIME_WAIT 留在客户端,不然会耗费更多的资源,并且调优方式有限。

  5. 如果可以尽量使用长连接的方式。

上面分析了正常关闭的流程,下面我们再来看下各个状态的情况。

FIN_WAIT_1 状态

我们使用 iptalbes 拦截第一个 FIN 包,然后看下服务端和客户端的状态信息:

shell 复制代码
# node2
$ nc -k -l 172.19.0.12  9527

# node3
$ nc 172.19.0.12 9527

# 在 node2设置规则,将目标端口为 9527 的 FIN 包丢弃
sudo iptables -A INPUT -p tcp --dport 9527 --tcp-flags FIN FIN -j DROP

# 在 node2 开启抓包
$ sudo tcpdump -s0 -X -nn "tcp port 9527" -w tcp-handshake-FIN1-01.pcap --print

# 在两台服务器执行命令,查看 socket 状态
$ while true; do sudo netstat -anpo | grep 9527; sleep 1; done

命令都执行后,我们在 node3 按下 ctrl+c 关闭连接,查看 node3 的链接可以看到进入了 FIN_WAIT1 状态。node2 因为 FIN 包被丢弃,所以还是 ESTABLISHED 状态。

bash 复制代码
$ while true; do sudo netstat -anpo | grep 9527; sleep 1; done
tcp        0      0 172.19.0.15:53072       172.19.0.12:9527        ESTABLISHED 546891/nc            off (0.00/0/0)

tcp        0      0 172.19.0.15:53072       172.19.0.12:9527        ESTABLISHED 546891/nc            off (0.00/0/0)
tcp        0      1 172.19.0.15:53072       172.19.0.12:9527        FIN_WAIT1   -                    on (0.36/1/0)
tcp        0      1 172.19.0.15:53072       172.19.0.12:9527        FIN_WAIT1   -                    on (0.17/2/0)
tcp        0      1 172.19.0.15:53072       172.19.0.12:9527        FIN_WAIT1   -                    on (0.80/3/0)
tcp        0      1 172.19.0.15:53072       172.19.0.12:9527        FIN_WAIT1   -                    on (3.08/4/0)
...
tcp        0      1 172.19.0.15:53072       172.19.0.12:9527        FIN_WAIT1   -                    on (0.35/7/0)
tcp        0      1 172.19.0.15:53072       172.19.0.12:9527        FIN_WAIT1   -                    on (0.00/7/0)
tcp        0      1 172.19.0.15:53072       172.19.0.12:9527        FIN_WAIT1   -                    on (51.56/8/0)
tcp        0      1 172.19.0.15:53072       172.19.0.12:9527        FIN_WAIT1   -                    on (50.54/8/0)
tcp        0      1 172.19.0.15:53072       172.19.0.12:9527        FIN_WAIT1   -                    on (49.52/8/0)
...
tcp        0      1 172.19.0.15:53072       172.19.0.12:9527        FIN_WAIT1   -                    on (0.61/8/0)
tcp        0      1 172.19.0.15:53072       172.19.0.12:9527        FIN_WAIT1   -                    on (0.00/8/0)

node3 使用 ss -s 命令统计,可以看到有 1 个 orphaned 状态的 socket。

bash 复制代码
$ ss -s
Total: 192
TCP:   10 (estab 6, closed 0, orphaned 1, timewait 0)

Transport Total     IP        IPv6
RAW	  1         0         1
UDP	  6         4         2
TCP	  10        9         1
INET	  17        13        4
FRAG	  0         0         0

下面是抓包结果

可以看到 FIN 包重传了 8 次,一共发了 9 个包。这个的重传次数是由内核参数 net.ipv4.tcp_orphan_retries 控制的,该参数会控制连接关闭时所有的超时重传次数,默认为 0。其计算逻辑是:

  • 如果为 0,则重传 8 次
  • 如果不为 0,则重传次数为该参数的值。可以将该值调小来减少重传次数,提高性能。

当超时重传次数达到上限后,内核将连接关闭并清除定时器。

C 复制代码
//源码地址:https://elixir.bootlin.com/linux/v5.15.130/source/net/ipv4/tcp_timer.c#L139
// 如果 net.ipv4.tcp_orphan_retries 是 0,则重传次数为 8。
/**
 *  tcp_orphan_retries() - Returns maximal number of retries on an orphaned socket
 *  @sk:    Pointer to the current socket.
 *  @alive: bool, socket alive state
 */
static int tcp_orphan_retries(struct sock *sk, bool alive)
{
	int retries = READ_ONCE(sock_net(sk)->ipv4.sysctl_tcp_orphan_retries); /* May be zero. */

	/* We know from an ICMP that something is wrong. */
	if (sk->sk_err_soft && !alive)
		retries = 0;

	/* However, if socket sent something recently, select some safe
	 * number of retries. 8 corresponds to >100 seconds with minimal
	 * RTO of 200msec. */
	if (retries == 0 && alive)
		retries = 8;
	return retries;
}


// 源码地址: https://elixir.bootlin.com/linux/v5.15.130/source/net/ipv4/tcp.c#L4450
// 将 state 设置为 TCP_CLOSE 状态,并清除发送定时器
void tcp_done(struct sock *sk)
{
	struct request_sock *req;

	/* We might be called with a new socket, after
	 * inet_csk_prepare_forced_close() has been called
	 * so we can not use lockdep_sock_is_held(sk)
	 */
	req = rcu_dereference_protected(tcp_sk(sk)->fastopen_rsk, 1);

	if (sk->sk_state == TCP_SYN_SENT || sk->sk_state == TCP_SYN_RECV)
		TCP_INC_STATS(sock_net(sk), TCP_MIB_ATTEMPTFAILS);

	tcp_set_state(sk, TCP_CLOSE);
	tcp_clear_xmit_timers(sk);
	if (req)
		reqsk_fastopen_remove(sk, req, false);

	WRITE_ONCE(sk->sk_shutdown, SHUTDOWN_MASK);

	if (!sock_flag(sk, SOCK_DEAD))
		sk->sk_state_change(sk);
	else
		inet_csk_destroy_sock(sk);
}

这里的控制参数是 tcp_orphan_retries,使用 orphan 代表而不是像连接建立时的参数tcp_syn_retriestcp_synack_retries,明确按包类型区分。原因是 Linux 将执行关闭 的 socket 视为 orphan(孤儿)socket,处于 FIN-WAIT-1、FIN-WAIT-2、LAST-ACK、CLOSING 状态的 socket 都可能属于 orphan socket。

内核还有一个参数 net.ipv4.tcp_max_orphans 用来控制 orphan socket 的最大数量。当该状态的 socket 数量超过阈值后,Linux 内核将不会走正常的四次挥手流程,而是直接发送 RST 信息终止连接。

关于 orphan socket 的详细讨论可以参考笔者另一篇实验 TCP orphan socket 的产生与消亡,这里不再赘述。

FIN_WAIT_2 状态

我们将 node2 的iptables 清理后,在重启服务端和客户端,然后在 node3 添加 iptables 拦截 node2 发来的 FIN 包。

bash 复制代码
# node2 清理 iptables
sudo iptables -F

# node2 重启服务端和客户端
$ nc -k -l 172.19.0.12  9527

# node3
$ nc 172.19.0.12 9527

# 在 node3 设置规则,将源端口为 9527 的 FIN 包丢弃
sudo iptables -A INPUT -p tcp --sport 9527 --tcp-flags FIN FIN -j DROP

# node2 开启抓包
$ sudo tcpdump -s0 -X -nn "tcp port 9527" -w tcp-handshake-FIN2-01.pcap --print

# 在两台服务器执行命令,查看 socket 状态
$ while true; do sudo netstat -anpo | grep 9527; sleep 1; done

执行上述命令后,在 node3 按下 ctrl+c 关闭连接,可以看到 node2 服务端进入 LAST_ACK 状态,node3 客户端进入 FIN_WAIT2 状态。

bash 复制代码
# node2 服务端
tcp        0      0 172.19.0.12:9527        172.19.0.15:55942       ESTABLISHED 579852/nc            off (0.00/0/0)
tcp        0      0 172.19.0.12:9527        0.0.0.0:*               LISTEN      579852/nc            off (0.00/0/0)
tcp        0      0 172.19.0.12:9527        172.19.0.15:55942       ESTABLISHED 579852/nc            off (0.00/0/0)
tcp        0      0 172.19.0.12:9527        0.0.0.0:*               LISTEN      579852/nc            off (0.00/0/0)
tcp        0      1 172.19.0.12:9527        172.19.0.15:55942       LAST_ACK    -                    on (0.18/1/0)
tcp        0      1 172.19.0.12:9527        172.19.0.15:55942       LAST_ACK    -                    on (0.00/2/0)
tcp        0      1 172.19.0.12:9527        172.19.0.15:55942       LAST_ACK    -                    on (0.62/3/0)
tcp        0      1 172.19.0.12:9527        172.19.0.15:55942       LAST_ACK    -                    on (0.27/6/0)
tcp        0      1 172.19.0.12:9527        172.19.0.15:55942       LAST_ACK    -                    on (25.62/7/0)


tcp        0      1 172.19.0.12:9527        172.19.0.15:55942       LAST_ACK    -                    on (1.32/8/0)
tcp        0      1 172.19.0.12:9527        172.19.0.15:55942       LAST_ACK    -                    on (0.30/8/0)

tcp        0      1 172.19.0.12:9527        172.19.0.15:55942       LAST_ACK    -                    on (0.00/8/0)
tcp        0      0 172.19.0.12:9527        0.0.0.0:*               LISTEN      579852/nc            off (0.00/0/0)
tcp        0      0 172.19.0.12:9527        0.0.0.0:*               LISTEN      579852/nc            off (0.00/0/0)
tcp        0      0 172.19.0.12:9527        0.0.0.0:*               LISTEN      579852/nc            off (0.00/0/0)
tcp        0      0 172.19.0.12:9527        0.0.0.0:*               LISTEN      579852/nc            off (0.00/0/0)
tcp        0      0 172.19.0.12:9527        0.0.0.0:*               LISTEN      579852/nc            off (0.00/0/0)

# node3 客户端
tcp        0      0 172.19.0.15:55942       172.19.0.12:9527        ESTABLISHED 582121/nc            off (0.00/0/0)
tcp        0      0 172.19.0.15:55942       172.19.0.12:9527        ESTABLISHED 582121/nc            off (0.00/0/0)
tcp        0      0 172.19.0.15:55942       172.19.0.12:9527        ESTABLISHED 582121/nc            off (0.00/0/0)
tcp        0      1 172.19.0.15:55942       172.19.0.12:9527        FIN_WAIT1   -                    on (0.14/0/0)
tcp        0      0 172.19.0.15:55942       172.19.0.12:9527        FIN_WAIT2   -                    timewait (59.12/0/0)
tcp        0      0 172.19.0.15:55942       172.19.0.12:9527        FIN_WAIT2   -                    timewait (58.11/0/0)
tcp        0      0 172.19.0.15:55942       172.19.0.12:9527        FIN_WAIT2   -                    timewait (57.09/0/0)
tcp        0      0 172.19.0.15:55942       172.19.0.12:9527        FIN_WAIT2   -                    timewait (56.07/0/0)
tcp        0      0 172.19.0.15:55942       172.19.0.12:9527        FIN_WAIT2   -                    timewait (55.05/0/0)
tcp        0      0 172.19.0.15:55942       172.19.0.12:9527        FIN_WAIT2   -                    timewait (54.03/0/0)
tcp        0      0 172.19.0.15:55942       172.19.0.12:9527        FIN_WAIT2   -                    timewait (53.01/0/0)
tcp        0      0 172.19.0.15:55942       172.19.0.12:9527        FIN_WAIT2   -                    timewait (52.00/0/0)
tcp        0      0 172.19.0.15:55942       172.19.0.12:9527        FIN_WAIT2   -                    timewait (50.98/0/0)
tcp        0      0 172.19.0.15:55942       172.19.0.12:9527        FIN_WAIT2   -                    timewait (49.96/0/0)
tcp        0      0 172.19.0.15:55942       172.19.0.12:9527        FIN_WAIT2   -                    timewait (48.94/0/0)
tcp        0      0 172.19.0.15:55942       172.19.0.12:9527        FIN_WAIT2   -                    timewait (47.92/0/0)
tcp        0      0 172.19.0.15:55942       172.19.0.12:9527        FIN_WAIT2   -                    timewait (46.90/0/0)
tcp        0      0 172.19.0.15:55942       172.19.0.12:9527        FIN_WAIT2   -                    timewait (45.88/0/0)
tcp        0      0 172.19.0.15:55942       172.19.0.12:9527        FIN_WAIT2   -                    timewait (44.86/0/0)
tcp        0      0 172.19.0.15:55942       172.19.0.12:9527        FIN_WAIT2   -                    timewait (43.84/0/0)
tcp        0      0 172.19.0.15:55942       172.19.0.12:9527        FIN_WAIT2   -                    timewait (42.82/0/0)
tcp        0      0 172.19.0.15:55942       172.19.0.12:9527        FIN_WAIT2   -                    timewait (41.81/0/0)
tcp        0      0 172.19.0.15:55942       172.19.0.12:9527        FIN_WAIT2   -                    timewait (40.79/0/0)
tcp        0      0 172.19.0.15:55942       172.19.0.12:9527        FIN_WAIT2   -                    timewait (39.77/0/0)
tcp        0      0 172.19.0.15:55942       172.19.0.12:9527        FIN_WAIT2   -                    timewait (38.75/0/0)
tcp        0      0 172.19.0.15:55942       172.19.0.12:9527        FIN_WAIT2   -                    timewait (37.73/0/0)
tcp        0      0 172.19.0.15:55942       172.19.0.12:9527        FIN_WAIT2   -                    timewait (36.71/0/0)
tcp        0      0 172.19.0.15:55942       172.19.0.12:9527        FIN_WAIT2   -                    timewait (35.69/0/0)
tcp        0      0 172.19.0.15:55942       172.19.0.12:9527        FIN_WAIT2   -                    timewait (34.67/0/0)
tcp        0      0 172.19.0.15:55942       172.19.0.12:9527        FIN_WAIT2   -                    timewait (33.65/0/0)
tcp        0      0 172.19.0.15:55942       172.19.0.12:9527        FIN_WAIT2   -                    timewait (32.63/0/0)
tcp        0      0 172.19.0.15:55942       172.19.0.12:9527        FIN_WAIT2   -                    timewait (31.61/0/0)
tcp        0      0 172.19.0.15:55942       172.19.0.12:9527        FIN_WAIT2   -                    timewait (30.59/0/0)
tcp        0      0 172.19.0.15:55942       172.19.0.12:9527        FIN_WAIT2   -                    timewait (29.57/0/0)
tcp        0      0 172.19.0.15:55942       172.19.0.12:9527        FIN_WAIT2   -                    timewait (28.56/0/0)
tcp        0      0 172.19.0.15:55942       172.19.0.12:9527        FIN_WAIT2   -                    timewait (27.54/0/0)
tcp        0      0 172.19.0.15:55942       172.19.0.12:9527        FIN_WAIT2   -                    timewait (26.52/0/0)
tcp        0      0 172.19.0.15:55942       172.19.0.12:9527        FIN_WAIT2   -                    timewait (25.50/0/0)
tcp        0      0 172.19.0.15:55942       172.19.0.12:9527        FIN_WAIT2   -                    timewait (24.48/0/0)
tcp        0      0 172.19.0.15:55942       172.19.0.12:9527        FIN_WAIT2   -                    timewait (23.46/0/0)
tcp        0      0 172.19.0.15:55942       172.19.0.12:9527        FIN_WAIT2   -                    timewait (22.44/0/0)
tcp        0      0 172.19.0.15:55942       172.19.0.12:9527        FIN_WAIT2   -                    timewait (21.42/0/0)
tcp        0      0 172.19.0.15:55942       172.19.0.12:9527        FIN_WAIT2   -                    timewait (20.40/0/0)
tcp        0      0 172.19.0.15:55942       172.19.0.12:9527        FIN_WAIT2   -                    timewait (19.38/0/0)
tcp        0      0 172.19.0.15:55942       172.19.0.12:9527        FIN_WAIT2   -                    timewait (18.36/0/0)
tcp        0      0 172.19.0.15:55942       172.19.0.12:9527        FIN_WAIT2   -                    timewait (17.34/0/0)
tcp        0      0 172.19.0.15:55942       172.19.0.12:9527        FIN_WAIT2   -                    timewait (16.33/0/0)
tcp        0      0 172.19.0.15:55942       172.19.0.12:9527        FIN_WAIT2   -                    timewait (15.31/0/0)
tcp        0      0 172.19.0.15:55942       172.19.0.12:9527        FIN_WAIT2   -                    timewait (14.29/0/0)
tcp        0      0 172.19.0.15:55942       172.19.0.12:9527        FIN_WAIT2   -                    timewait (13.27/0/0)
tcp        0      0 172.19.0.15:55942       172.19.0.12:9527        FIN_WAIT2   -                    timewait (12.25/0/0)
tcp        0      0 172.19.0.15:55942       172.19.0.12:9527        FIN_WAIT2   -                    timewait (11.23/0/0)
tcp        0      0 172.19.0.15:55942       172.19.0.12:9527        FIN_WAIT2   -                    timewait (10.21/0/0)
tcp        0      0 172.19.0.15:55942       172.19.0.12:9527        FIN_WAIT2   -                    timewait (9.19/0/0)
tcp        0      0 172.19.0.15:55942       172.19.0.12:9527        FIN_WAIT2   -                    timewait (8.18/0/0)
tcp        0      0 172.19.0.15:55942       172.19.0.12:9527        FIN_WAIT2   -                    timewait (7.16/0/0)
tcp        0      0 172.19.0.15:55942       172.19.0.12:9527        FIN_WAIT2   -                    timewait (6.14/0/0)
tcp        0      0 172.19.0.15:55942       172.19.0.12:9527        FIN_WAIT2   -                    timewait (5.12/0/0)
tcp        0      0 172.19.0.15:55942       172.19.0.12:9527        FIN_WAIT2   -                    timewait (4.10/0/0)
tcp        0      0 172.19.0.15:55942       172.19.0.12:9527        FIN_WAIT2   -                    timewait (3.08/0/0)
tcp        0      0 172.19.0.15:55942       172.19.0.12:9527        FIN_WAIT2   -                    timewait (2.06/0/0)
tcp        0      0 172.19.0.15:55942       172.19.0.12:9527        FIN_WAIT2   -                    timewait (1.04/0/0)
tcp        0      0 172.19.0.15:55942       172.19.0.12:9527        FIN_WAIT2   -                    timewait (0.02/0/0)
tcp        0      0 172.19.0.15:55942       172.19.0.12:9527        FIN_WAIT2   -                    timewait (0.00/0/0)

下面是抓包结果:

我们来简单分析下:

  1. 服务端一直处于 LAST_ACK 状态,说明 FIN 包已发送,但一直没有收到客户端的 ACK 包。
  2. 客户端一直处于 FIN_WAIT2 状态,说明客户端已经收到了服务端的 ACK 包,但迟迟没收到服务端的 FIN 包。说明我们的 iptables 拦截生效了。
  3. 客户端进入 FIN_WAIT2 状态后,有一个 60s 的 timewait 计时器在运行。这是由内核参数 net.ipv4.tcp_fin_timeout 控制的,默认是 60s。超过后会自动关闭连接,不会进入 TIME_WAIT 状态。

连接保活

TCP 通信需要建立连接,这里的连接并不是真的在通信双方之间有一个通路,而是双方各自维护一个 TCB 来管理状态数据。在这种情况下,如果一方挂了并且没有数据传输,那另一方是感知不到的,其连接可能会一直存在,造成不必要的资源浪费。

为了解决这个问题 TCP 也设计了保活机制,内核有三个参数与该机制有关:

bash 复制代码
$ 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:最后一次数据发送到发送第一个探活包的间隔时长,默认为 7200s。也就是说如果超过了 7200s 没有发送数据,TCP 就会发送一个探活包。
  • net.ipv4.tcp_keepalive_probes:允许探活包没有回应的最大次数,默认为 9。也就是说如果发送了 9 次探活包后依然没有得到响应,那么 TCP 就会考虑连接已经失效,会通知应用层中断连接。
  • net.ipv4.tcp_keepalive_intvl:在第一个探测包发送后,在没有数据传输的情况下,每个探测包的发送频率,默认 75s。即每 75s 发送一个探测包。

我们来做实验验证一下,这里先将上述参数的值调小一些,方便我们观察实验结果。我们将首个探测包的发送时间改为最后一次发送数据 10s 后,并且探测包的时间间隔为 5s,超过 5 次后就断开连接。

客户端先发送一次数据,然后休眠 30 s,在发送一次数据,然后休眠 200s。

bash 复制代码
$ sudo sysctl -w net.ipv4.tcp_keepalive_time=10 net.ipv4.tcp_keepalive_intvl=5 net.ipv4.tcp_keepalive_probes=5
net.ipv4.tcp_keepalive_time = 10
net.ipv4.tcp_keepalive_intvl = 5
net.ipv4.tcp_keepalive_probes = 5
  • 服务端代码
python 复制代码
import socket
import time
import os

# 创建服务器端用于测试
def start_server():
    server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    server_sock.bind(('172.19.0.15', 8888))
    server_sock.listen(1)
    print("Server listening on 172.19.0.15:8888...")

    conn, addr = server_sock.accept()
    print(f"Connection from {addr}")

    while True:
        data = conn.recv(1024)
        if not data:
            break
        print(f"Received: {data.decode('utf-8')}")


if __name__ == "__main__":
    start_server()
  • 客户端代码
Python 复制代码
import socket
import time
import os


# 客户端代码,启用 TCP 保活
def start_client():
    # 创建 socket
    client_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

    # 启用 SO_KEEPALIVE
    client_sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)


    # 连接到服务器
    client_sock.connect(('172.19.0.15', 8888))
    print("Connected to server")

    # 发送第一次 "hello world"
    client_sock.send("hello world".encode('utf-8'))
    print("Sent first 'hello world'")

    # 休眠 28 秒
    time.sleep(28)

    # 发送第二次 "hello world"
    client_sock.send("hello world".encode('utf-8'))
    print("Sent second 'hello world'")

    # 休眠 200 秒
    print("Sleeping for 200 seconds...")
    time.sleep(200)

    # 关闭连接
    client_sock.close()
    print("Connection closed")


if __name__ == "__main__":
    # 启动客户端
    start_client()

启动程序,完成第二次数据传输中用 iptables 将 ACK 包连接来伪造探活失败的场景,

bash 复制代码
# 服务端执行
$ sudo iptables -A INPUT -p tcp --dport 8888  -j DROP

socket 状态抓包结果如下:

sh 复制代码
$ sudo netstat -anpo | grep -E "Recv-Q|8888"
Proto Recv-Q Send-Q Local Address           Foreign Address         State       PID/Program name     Timer
tcp        0      0 172.19.0.12:38224       172.19.0.15:8888        ESTABLISHED 3276657/python3      keepalive (0.12/0/4)

# ubuntu @ node2 in ~/labs/syn-queue-lab [12:38:09]
$ sudo netstat -anpo | grep -E "Recv-Q|8888"
Proto Recv-Q Send-Q Local Address           Foreign Address         State       PID/Program name     Timer
tcp        0      0 172.19.0.12:38224       172.19.0.15:8888        ESTABLISHED 3276657/python3      keepalive (4.13/0/5)
  • 在第 10s 客户端发送了探活包,其数据长度为 0,这是第一次休眠时的探活检测。
  • 在第 28s 客户端发送了第二次数据,之后从第 38s 开始,每 10s 发送一次探活包。由此可以知道探活包的 ACK 也被视作正常的数据收发,探活检测会根据 net.ipv4.tcp_keepalive_time的值来确定。
  • 从第 68s 开始,我们在服务端设置了 iptables 规则拦截探活包,之后开始每隔 5s 发送一次探活包,这里开始受 net.ipv4.tcp_keepalive_intvl 参数的控制。
  • 连续 5 个探活包没有收到响应后,客户端发送了 RST 包中断连接,说明 net.ipv4.tcp_keepalive_intvl = 5 的改动已经生效。

PS:最开始使用的是 Golang 程序,但发现修改系统设置并不生效,探活包的发送时间间隔一直是 15s。

bash 复制代码
$ sudo netstat -anpo | grep -E "Recv-Q|8888"
Proto Recv-Q Send-Q Local Address           Foreign Address         State       PID/Program name     Timer
tcp        0      0 172.19.0.12:46706       172.19.0.15:8888        ESTABLISHED 3270763/client       keepalive (0.12/0/0)

# ubuntu @ node2 in ~/labs/syn-queue-lab [12:25:07]
$ sudo netstat -anpo | grep -E "Recv-Q|8888"
Proto Recv-Q Send-Q Local Address           Foreign Address         State       PID/Program name     Timer
tcp        0      0 172.19.0.12:46706       172.19.0.15:8888        ESTABLISHED 3270763/client       keepalive (14.77/0/0)

经过调查后发现原因是 Golang 的 net net.DialTimeout 创建连接时默认设置了 15s,如果想修改必须获取到 TCPConn 对象后自行修改。

Go 复制代码
// 源码地址:https://github.com/golang/go/blob/bc5f4a555e933e6861d12edba4c2d87ef6caf8e6/src/net/dial.go#L19

const (
	// defaultTCPKeepAliveIdle is a default constant value for TCP_KEEPIDLE.
	// See go.dev/issue/31510 for details.
	defaultTCPKeepAliveIdle = 15 * time.Second

	// defaultTCPKeepAliveInterval is a default constant value for TCP_KEEPINTVL.
	// It is the same as defaultTCPKeepAliveIdle, see go.dev/issue/31510 for details.
	defaultTCPKeepAliveInterval = 15 * time.Second

	// defaultTCPKeepAliveCount is a default constant value for TCP_KEEPCNT.
	defaultTCPKeepAliveCount = 9

	// For the moment, MultiPath TCP is used by default with listeners, if
	// available, but not with dialers.
	// See go.dev/issue/56539
	defaultMPTCPEnabledListen = true
	defaultMPTCPEnabledDial   = false
)

总结

作为程序员,虽然接触到的网络知识基本逃不过 RFC1180: A TCP/IP Tutorial 的范畴,但这确实是最让人头大的知识点之一。作为将《TCP/IP Guide》、《TCP/IP 详解(英文版)》以及主要 RFC 都读过的踩坑者,只能无奈的感慨,光读这些是资料顶多可以让我们勉强了解,但要想在实际工作中对遇到的问题手到擒来,还远远不够。

网络知识的学习至少涉及到三方面内容:RFC 定义的协议原理、操作系统的具体实现、命令工具的使用。而每一部分学习起来都不容易,RFC 理论的枯燥,操作系统不同版本实现机制的繁杂,命令工具各种参数的琐碎,都让人望而却步。最好的方式就是做实验,将三者统一起来,通过动手实验,尤其是做生产级别的故障排查类实验,可以帮助我们熟悉工具的使用,验证系统的实现,并通过实验结果加深对理论的理解,做到全面而深刻的学习。

相关推荐
网络设计ensp4 分钟前
企业网设计
网络·智能路由器
鸭梨山大。12 分钟前
linux命令-iptables与firewalld 命令详解
linux·运维·网络
半夏知半秋17 分钟前
linux下的网络抓包(tcpdump)介绍
linux·运维·服务器·网络·笔记·学习·tcpdump
fatsheep洋43 分钟前
全网最详解答OSPF基础
网络·ospf
zzy20887402711 小时前
网络初级复习作业
网络
渗透测试老鸟-九青1 小时前
我与红队:一场网络安全实战的较量与成长
运维·服务器·网络·经验分享·安全·web安全·代码审计
黑风风2 小时前
详解了解websocket协议
网络·websocket·网络协议
黑客-秋凌2 小时前
网络安全基础与应用习题 网络安全基础答案
网络·安全·web安全
2022计科一班唐文2 小时前
靶场练习ing
网络·渗透
Albert XUU2 小时前
nettrace rtt分析器
linux·运维·网络·网络协议·网络安全·腾讯云·运维开发