深入理解 TCP 三次握手


深入理解 TCP 三次握手

导图

索引

[toc]

基本握手流程

图为三次握手基本流程:

  1. 客户端发送 同步序列号 SYN 至服务器

  2. 服务器接收到后回复确认 ACK,并附带自己的同步序列化 SYN

  3. 客户端确认服务器的消息回复 ACK

深入探讨细节

TCP 连接状态共有哪些?如何在 Linux 系统中查看 TCP 状态?

由图可知,TCP 状态共包含以下11个,其中连接状态有 9 个

  • CLOSED:连接处于关闭状态

  • LISTEN:端口处于监听状态

  • SYN_SENT:客户端发送第一个 SYN 同步序列号后,自动进入 SYN_SENT 状态

  • SYN_RECV:服务端收到来自客户端的SYN后,进入 SYN_RECV 状态

  • ESTABLISHED:C/S 两端三次握手完成,已同步双方序列号,连接成功建立后进入 ESTABLISHED 状态

  • FIN_WAIT_1:主动关闭方发送FIN报文后,表示不再发送数据,此后进入 FIN_WAIT_1 状态,等待被动方的ACK确认报文

  • CLOSE_WAIT:CLOSE_WAIT 顾名思义,等待进程调用 close 函数关闭连接。被动关闭方接收到 FIN 报文,自动回复 ACK 确认报文,表示确认知晓你方发送通道关闭。此时如果有未发送完成的数据会继续发送给主动关闭方,如果没有数据需要发送了,则可能将 ACK 确认报文以及 FIN 报文一同发送给主动关闭方

  • CLOSING:仅出现在某些特殊情况,例如双方同时发送FIN报文,随后在等待 ACK 报文的过程中,都等来了 对方的FIN 报文,在此情况,连接会进入到 CLOSING 状态,它替代了 FIN_WAIT2 状态,此时内核会自动回复 ACK 确认对方发送通道的关闭,同时等待对方确认己方FIN返回ACK报文,如果产生丢包或超时,也会适时重发 FIN 报文的情况下最终关闭

  • LAST_ACK:被动关闭方发送FIN后,等待主动方返回 ACK 来确认连接关闭

  • FIN_WAIT_2:主动关闭方收到被动方的 ACK 后进入 FIN_WAIT2 状态

  • TIMEWAIT:主动关闭方收到被动方发来的 FIN 报文时,主动方回复 ACK,表示确认对方的发送通道已经关闭,连接随之进入 TIME_WAIT 状态,等待 60 秒后关闭连接,进入最终 CLOSED 状态

可以通过 ss -s 命令获取系统连接状态汇总,不过由于命令返回的瞬时数据,无法呈现这一指标的趋势变化,所以通常需要结合监控系统来进行更全面的观察分析

$ ss -s    
Total: 346 (kernel 392)
TCP:   53 (estab 25, closed 12, orphaned 0, synrecv 0, timewait 2/0), ports 0

Transport Total     IP    IPv6
*	  392    -    -    
RAW	  0    0    0    
UDP	  6    4    2    
TCP	  41    15    26    
INET	  47    19    28    
FRAG	  0    0    0    

Grafana 看板

为什么是三次握手?不是两次、四次?

简单来说,主要有四个方面的原因:

  1. 三次握手才能保证双方具有接收发送的能力 😄
  2. 三次握手才可以阻止重复历史连接的初始化 😀
  3. 三次握手才可以同步双方的初始序列号​ 😘
  4. 三次握手才可以避免资源浪费(SYN+ACK)👏

原因一:三次握手才能保证双方具有接收发送的能力 😄

  1. 第一次握手,客户端发送SYN,如果报文成功发出,并被服务器端接收到。此时表明客户端发送功能OK,服务器端接受功能OK~

  2. 第二次握手,服务器端发送SYN+ACK,如果报文成功发出,并被客户端接收到,此时证明客户端发送功能OK,服务器端收发功能都OK~

  3. 第三次握手,客户端发送ACK,如果报文成功发出,并被服务器端接收到。此时证明客户端及服务器两端收发功能都OK~

原因二:三次握手才可以阻止重复历史连接的初始化 😀

RFC 793 所指出 TCP 连接使用三次握手的首要原因

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

简单翻译下,就是为了防止旧的重复连接初始化造成混乱。

那再思考下,什么叫旧的连接?以及为什么旧的连接会造成初始化混乱?

网络世界,错综复杂,数据包在无数网络线路中流转,可能会遇到各种各样的问题,有时先发送的数据包,会如我们所期待的那样,先到达目标主机。可有时并非如此,由于网络拥堵等乱七八糟的原因,会使得旧的数据包,先到达目标主机,那么这种情况下 TCP 三次握手是如何工作的呢?

试想下,客户端连续发送了多次 SYN 建立连接的报文,由于网络产生短时拥堵,导致其中两个 SYN 发生错序:

  • 一个 旧 SYN 报文 90最新的 SYN 100 报文早到达了服务端 😱
  • 服务器端本身无法辨别 SYN 新旧,所以自动回复 SYN + ACK(300,90+1)
  • 客户端收到后,根据自身的上下文,判断此为历史连接(序列号过期或超时)
  • 随后客户端发送 RST 报文给服务端,表示中止该连接(不排除有建立连接的可能性,但此类连接通常为无效连接,空占资源)

随后,待到服务端接收到有效 SYN 连接,才会开始进行正常的三次握手流程

原因三:三次握手才可以同步双方的初始序列号 😘

初始序列号 ISN,为什么我们需要让 C/S 同步序列号?序列号存在意义是什么呢 ?只有解开这些问题,我们才能更好的理解TCP 工作原理。

在TCP 协议中。通信双方都必须维护一个「序列号」(共两个序列号), 序列号是可靠传输的一个关键因素,它的主要作用包括以下几点:

  1. 接收方通过序列号,可以去除重复的数据(服务端接收到X数据报文,但回复ACK时确认报文丢失,客户端超时重传发送X数据报文,服务端通过序列号识别出X为已接收数据报文)、
  2. 接收方可以根据数据包的序列号按序接收
  3. 发送方可以标识发送出去的数据包中, 哪些是已经被对方收到的

原因四:三次握手才可以避免资源浪费(SYN+ACK)👏

上面已经已经解答了为什么握手不是两次,原因很简单,二次握手不能很好的处理的由于网络延迟导致的历史连接问题。那么既然不能是两次,那为什么不可以是四次呢?

这个问题,回答起来很简单!

四次握手其实也能够可靠的同步双方的初始化序号,但由于第二步和第三步可以优化成一步,那么我们何必多发送一次报文呢?所以这就是 TCP「三次握手」的原因。

为什么客户端和服务端的初始序列号 ISN 是不相同的?

  1. 为了通信双方能够根据序号,将不属于本连接的报文段丢弃
  2. 为了安全性,防止黑客伪造的相同序列号的 TCP 报文被对方接收

初始序列号 ISN 是如何生成的?

起始 ISN 是基于时钟生成的,每 4 毫秒 + 1,转一圈要 4.55 个小时。

RFC1948 中提出了一个较好的初始化序列号 ISN 随机生成算法。

ISN = M + F (localhost, localport, remotehost, remoteport)

  • M 是一个计时器,这个计时器每隔 4 毫秒加 1。
  • F 是一个 Hash 算法,根据源 IP、目的 IP、源端口、目的端口生成一个随机数值。要保证
    Hash 算法不能被外部轻易推算得出,用 MD5 算法是一个比较好的选择。

三次握手中有哪些队列?分别有什么作用?

  • SYN 半连接队列:SYN 半连接队列是用来维护未完成握手的连接信息,当这个队列溢出后,服务器将无法再建立新连接。
  • ACCEPT 全连接队列:服务器端接收到三次握手的最后一个ACK确认报文,此时C/S双方收发功能都正常且已同步序列号。内核会把连接从 SYN 半连接队列中移出,然后移入 accept 队列,等待进程调用 accept 函数时把连接取出来。如果进程不能及时地调用 accept 函数,就会造成 accept 队列溢出,最终导致建立好的 TCP 连接被丢弃。

当流量上升、节点负载增加、或遇到网络攻击时,这两个队列很有可能会导致无法与客户端建立连接,具体问题下面会详细分析和讨论

握手性能优化

客户端优化

SYN 报文重传次数

net.ipv4.tcp_syn_retries

客户端发送 SYN 开启了三次握手,等待服务器回复的 ACK 报文,如果客户端迟迟没有收到 ACK,客户端会重发 SYN,重试的次数由 tcp_syn_retries 参数控制,默认是 6 次:

net.ipv4.tcp_syn_retries = 6

等待超过 1 秒中未收到 ack,那么进入重试流程,按照参数配置,它会重试 6 次,这 6 次重试会以 2、4、8、16、32、64 秒为间隔进行重试,最后一次重试,系统会等待 64 秒,如果仍然没有收到来自客户端返回的 ACK,将会终止终止三次握手。

所以,总耗时是 1+2+4+8+16+32+64=127 秒,超过 2 分钟。

可以根据网络的稳定性,以及服务器的繁忙程度修改重试次数,比如内网中通讯时,就可以适当调低重试次
数,尽快把错误暴露给应用程序。

net.ipv4.tcp_syn_retries = 3

服务端优化

syn_backlog 半连接队列

三次握手的服务端优化,我们先看第一个常见容易发生问题的点 tcp_syn_backlog 半连接队列溢出,既然说到溢出,那么就不得不思考以下几个问题

  1. 什么是半连接队列溢出?
  2. 为什么半连接队列会溢出?
  3. 半连接队列溢出会导致什么问题?
  4. 如何分析当前系统是否存在溢出?
  5. 发生半连接队列溢出了,我该怎么办?

OK,这些我们一个个来讨论

什么是半连接队列溢出?半连接队列溢出会导致什么问题?

首先,我们知道,当服务器收到来自客户端的 SYN 报文后,会立刻回复 SYN+ACK 报文,以此确认客户端的序列号,并发送自己的序列号。

此时,对于这个还未真正完成建立的新连接(SYN_RCV),服务器会通过一个 SYN 半连接队列 来维护,而当半连接队列大量出现未完成握手的连接,数量超过 tcp_max_syn_backlog <默认 128> 后,队列发生溢出,服务器将无法再建立新连接。

现在,我们已经知道了问题 1、3 的答案

为什么半连接队列会溢出?

接着来看第二个问题,为什么半连接队列会溢出?

首先,在 低流量负载、网络质量好的情况下,半连接队列是基本不可能出现的队列溢出,反之,当流量增大 或 网络发生抖动时,服务端无法在短时间内向大量的客户端发送 sync/ack 报文,那么就会导致半连接队列会溢出

除此外,还有一种场景,那就是低流量、网络质量也不错时,也出现了半连接队列溢出,那么九成以上的概率说明系统遇到了 syn flood 洪水攻击

SYN Flood 洪水攻击的大致原理如下:

  1. 客户端构造大量随机源 IP 的 SYN 包,向服务端发送 TCP 连接建立请求
  2. 服务器收到包后,会向源 IP 发送 SYN+ACK 报文,并等待三次握手的最后一次 ACK 报文
  3. 当服务端超时未收到 客户端的 ack 后,会按照 tcp_synack_retries 参数配置的次数进行重试

毫无疑问,服务端的重试已没有意义的,就这样,大量的恶意的半连接占满 syn_backlog 队列,导致正常的连接无法建立,影响服务器对外提供服务

如何分析当前系统是否存在溢出?

答案是 netstat -s

如下所示,通过 netstat -s | grep "SYNs to LISTEN" 命令可以获得由于半连接队列已满,而引发的失败次数的统计结果

$ netstat -s|grep "SYNs to LISTEN"
    79704 SYNs to LISTEN sockets dropped

补充说明一下,还可以通过 /proc/net/netstat 获取半连接、全连接队列溢出数

$ netstat -s|egrep -i "socket(s)? (overflowed|dropped)"
    23789 times the listen queue of a socket overflowed
    23808 SYNs to LISTEN sockets dropped


$ cat /proc/net/netstat | awk '/TcpExt/ { print $20,$21}'
ListenOverflows ListenDrops
23789 23808

该值为 SYN 半连接队列溢出导致连接被丢弃的个数累加值,如果数值在持续增加,则说明系统存在半连接队列溢出的问题

发生半连接队列溢出了,我该怎么办?

两个思路

  • 调大队列,让队列能容纳更多的半连接
  • 减少 tcp_synack_retries 重试次数,避免网络资源浪费
  • 配置使用 syncookies,让队列溢出不再影响正常连接的建立

我们分别看下这两个思路如何实践

思路一:调大队列,让队列能容纳更多的半连接

半连接队列长度受 tcp_max_syn_backlogsomaxconn 参数控制,我们可以通过增大这两个参数调大队列,这里的 somaxconn 参数配置影响 半连接、全连接两个队列的长度上限,所以需要一并设置

具体方式如下:

# 临时设置
$ sysctl -w net.ipv4.tcp_max_syn_backlog=2048
$ sysctl -w net.core.somaxconn=2048
# 永久设置
$ vim /etc/sysctl.conf
net.ipv4.tcp_max_syn_backlog=2048
net.core.somaxconn=2048

思路二:减少 tcp_synack_retries 重试次数

如果服务器没有收到 ACK,就会一直重发 SYN+ACK 报文,与客户端重发 SYN 类似,它的重试会经历 1、2、4、8、16 秒,最后一次重试后等待 32 秒,若仍然没有收到 ACK,才会关闭连接,故共需要等待 63 秒

当网络繁忙、不稳定时,可以适当的调大重发次数,提高连接的成功率

如果遇到 SYN Flood,则可以调小重试次数,从而避免网络资源的浪费

通过调整 tcp_synack_retries 参数可以修改重发次数,默认值为 5,可以按需调整为 36

$ sysctl -w net.ipv4.tcp_synack_retries=3
$ vim /etc/sysctl.conf
net.ipv4.tcp_synack_retries=3

思路三:启用 syncookies 功能,让队列溢出不再影响正常连接的建立

SYN 半连接队列已满,并不代表一定非得丢弃客户端连接,我们还可以配置开启 syncookies 功能,这样就可以降低 半连接队列被打满 导致无法与客户端建立连接,甚至我们还可以做到不使用 SYN 队列的情况下成功建立连接,这取决 tcp_syncookies 参数的配置

首先,先看一下 syncookies 工作原理如下:

  1. 客户端发送 syn 报文
  2. 服务端接收到来自客户端的 syn 报文
  3. 服务器判断自身是否开启 tcp_syncookies 以及 syn_backlog 是否溢出决定后面的选择
    • tcp_syncookies 参数为 0,说明系统没有开启该功能,则丢弃该连接
    • tcp_syncookies 参数为 1,说明功能已配置开启,此时如果 syn_backlog 队列发生溢出,那么服务端计算生成一个 cookie 值,进入流程 4
    • tcp_syncookies 参数为 2,说明功能已配置开启,服务端不会判断 syn_backlog 队列是否发生溢出,直接计算生成一个 cookie 值,进入流程 4
  4. 将计算出的 cookie 值,放在即将发出的 SYN/ACK 报文中
  5. 客户端收到 SYN/ACK 报文,从中拿到 cookie,回复 ACK 报文时携带该 cookie
  6. 服务端从 ack 报文取出 cookie 进行校验,如果合法,就认为连接建立成功
  7. 服务器将连接直接放入 accept 队列

基于 syncookies 功能,我们可以在一定程度上解决 syn 洪水攻击带来的问题,不过 syncookies 仅用于应对 SYN 泛洪攻击,且使用这种方式建立的连接,许多 TCP 特性都无法使用,所以通常来说建议配置 1,即仅在队列满时再启用。

# 临时设置
$ sysctl -w net.ipv4.tcp_syncookies=1
# 永久设置
$ vim /etc/sysctl.conf
net.ipv4.tcp_syncookies=1

accept 全连接队列

按照同样的的流程,去思考以下几个问题

  1. 什么是全连接队列溢出?
  2. 为什么全连接队列会溢出?
  3. 全连接队列溢出会导致什么问题?
  4. 如何分析当前系统是否存在全连接队列溢出?
  5. 发生全连接队列溢出了,我该怎么办?
什么是全连接队列溢出?

在三次握手的最后一步,服务器收到客户端的 ACK,则代表连接建立成功。

什么是全连接队列溢出?

此时,内核会把连接从 SYN 半连接队列中移至 accept 队列,等待进程调用 accept 函数把连接取出来。如果进程不能及时地调用 accept 函数,就会造成 accept 队列溢出,最终导致建立好的 TCP 连接被丢弃。全连接队列溢出会导致什么问题?

这一点和半连接队列一致,队列满了就丢弃连接

为什么全连接队列会溢出?

导致 全连接队列溢出的原因一般都是因为进程调用 accept() 太慢了,大量连接堆积在队列中,没有被应用及时拿走,那么什么时候 accept() 会很慢呢,大致有几个原因

  1. CPU 繁忙:CPU 繁忙自然会导致 进程可用的时间片减少,对应的 accept 速度也就满了
  2. IO 繁忙:IO 繁忙就会导致出现大量 IO WAIT,大量 IO WAIT 也会占用时间片,影响进程去 accept 连接
如何分析当前系统是否存在全连接队列溢出?

上面提到过 netstat -s,这里的 overflowed 就是全连接队列溢出数的累加值

$ netstat -s|egrep -i "socket(s)? (overflowed|dropped)"
    23789 times the listen queue of a socket overflowed
    23808 SYNs to LISTEN sockets dropped


$ cat /proc/net/netstat | awk '/TcpExt/ { print $20,$21}'
ListenOverflows ListenDrops
23789 23808

由于这个值是累加值,很难看出趋势,所以一般会结合监控系统,在 Grafana 看板上观察分析

除了 netstat,还可以使用 ss -lnt

$ ss -lnt
State      Recv-Q Send-Q Local Address:Port                Peer Address:Port
LISTEN     129    128                *:80                             *:*

注意看 Recv-QSend-Q 两列,在不同状态下,它们的含义不同

  • LISTEN 状态
    • Send-Q 表示 accept 全连接队列最大限制大小,最大 128
    • Recv-Q 表示 accept 全连接队列当前已用大小,已用 129,说明已经发生了溢出
  • ESTABLISHED 状态
    • Send-Q:发送数据包的 buffer
    • Recv-Q:接受数据包的 buffer

通过上面两种方式,都可以分析我们系统当前是否存在溢出

发生全连接队列溢出了,我该怎么办?

在说处理全连接队列溢出思路之前,先补充说明一个点,丢弃连接只是 Linux 的默认行为

除此外,我们可以选择向客户端发送 RST 复位报文,通知客户端由于accept队列溢出连接建立失败,打开这一功能需要将 tcp_abort_on_overflow 参数设置为 1

net.ipv4.tcp_abort_on_overflow = 1

但是,通常情况下,应当把 tcp_abort_on_overflow 设置为 0,因为这样更有利于应对突发流量

为什么这么说,我给你举个例子:

当出现流量增大、服务端负载飙高时,虽然服务器短时间无法快速的从队列里 accept 连接,导致其他客户端发送过来的 ACK 被丢弃,但是,此时客户端的连接状态已经进入 ESTABLISHED

对于客户端而言,它就在建立好的连接上不停地发送请求,只要服务端没有为请求回复 ACK,请求就会被多次重发

这时,如果服务器上的进程只是短暂的繁忙,导致 accept 队列被堆满,那么当服务器过了繁忙期,accept 队列有空位时,服务器再次接收到来自客户端的重试请求报文,由于报文内含有 ACK,仍然会触发服务器端成功建立连接。

所以,tcp_abort_on_overflow 设为 0 可以提高连接建立的成功率,除非你非常肯定 accept 队列会长期溢出时,才能设置为 1 以尽快通知客户端。

OK,接下来,我们来看比较常用的处理思路,处理全连接队列溢出,主要就两个思路

  • 增加队列长度
  • 减少重试次数

先来看看如何调整 accept 队列的长度

首先,配置系统 accept 队列最大上限,net.core.somaxconn

$ sysctl -w net.core.somaxconn=2048
$ vim /etc/sysctl.conf
net.core.somaxconn=2048

修改之后,应用大概率是不会即时生效的,如下所示

通过 ss -ltnp|awk '{print $3,$NF}' 获取处于 Listen 状态的端口监听队列(全连接队列)

$ ss -ltnp|awk '{print $3,$NF}'
Send-Q Address:Port
128 users:(("alertmanager",4638,5))
128 users:(("alertmanager",4747,5))
128 users:(("redis-server",27587,4))
128 users:(("nginx",14433,6),("nginx",12003,6))
128 users:(("nginx",23374,6),("nginx",23373,6))
...

接下来,我们还需要调整的应用(服务、容器)进行设置

不同服务配置 backlog 的方式都不尽相同,以 Nginx 为例,配置服务(应用)listen 函数的 backlog 参数

server {
    listen       80 default backlog=2048;
    server_name  localhost;
    #access_log  logs/host.access.log  main;

    location / {
        root   /opt/nginx/html;
        index  index.html index.htm;
    }
}

更改参数配置后,需要重启服务,因为 SYN 半连接队列和 accept 队列都是在 listen() 时初始化的。

# 重启生效前队列长度
$ ss -ltnp|awk '{print $3,$NF}'|grep nginx
128 users:(("nginx",14433,6),("nginx",12003,6))

# 重启服务
$ ./nginx/sbin/nginx -s reload

# 监听队列由 128 变更为1024
$ ss -ltnp | awk '{print $3,$NF}'|grep nginx
2048 users:(("nginx",18903,6),("nginx",12003,6))

对于运行在容器环境的应用来说,我们需要在 docke run 启动时添加 --sysctl net.core.somaxconn=2048 为容器环境设置 全连接队列上限,然后在应用配置文件内再次设置 listen 上限

$ docker run -ti --sysctl net.core.somaxconn=2048 --rm ubuntu /bin/bash
root@9e850908ddb7:/# sysctl net.core.somaxconn
net.core.somaxconn = 2048

对于 Kubernetes 平台的 Pod 而言,可以使用 securityContext 设置 ,然后在应用配置文件内再次设置 listen 上限

apiVersion: v1
kind: Pod
metadata:
  name: sysctl-example
spec:
  securityContext:
    sysctls:
    - name: net.core.somaxconn
      value: "8096"

TFO 技术

TFO是什么?

TFO(TCP fast open) 是对计算机网络中(TCP)连接的一种简化握手手续的拓展,用于提高客户端服务端间连接的打开速度。

TFO(TCP fast open) 是 TCP 协议的实验性更新 experimental update,它允许服务器和客户端在连接建立握手阶段交换数据,从而使应用节省了一个 RTT 的时延。

但是 TFO 在某些环境存在一些问题,因此协议要求 TCP 实现必须默认禁止TFO,如果想要使用TFO功能,则需要在服务端程序显式配置启用。

TFO 解决了什么问题?

减少三次握手时间消耗,据 Google 统计,三次握手消耗时间占 HTTP 请求完成总时间 10% 到 30% 之间。

TFO 是如何实现的?

接下来,具体看下 TFO 工作流程,它把通讯分为两个阶段:

首先,在使用 TFO 之前,客户端需要通过一次普通三次握手来获取 FOC(Fast Open Cookie)

  • 1.客户端发送一个带有 Fast Open 选项的 SYN 包,同时携带一个空的 cookie 域来请求一个 cookie
  • 2.服务端使用秘钥生成一个 cookie,然后通过 SYN-ACK 包的 Fast Open 选项来返回给客户端
  • 3.客户端缓存这个 cookie 以备将来使用 TFO 建立连接的时候使用

拥有 TOC 执行 TFO

  • 1.客户端发送一个带有数据的 SYN 包,同时在 Fast Open 选项中携带之前通过正常连接获取的 cookie
  • 2.服务端使用密钥验证 TOC 有效性,如果验证通过,服务端会返回 SYN-ACK 报文,连接建立成功,然后服务端把接收到的数据传递给应用层。如果 TOC 是无效的,服务端会丢掉SYN包中的数据,同时返回一个SYN-ACK包来确认SYN包中的序列号
  • 3.如果 TOC 有效,在连接完成之前,服务端可以给客户端发送响应数据,携带的数据量受到 TCP 拥塞控制的限制(RFC5681)。
  • 4.客户端发送 ACK 包来确认服务端的 SYN 和数据,如果客户端 SYN 包中的数据没有被服务器确认,客户端会在这个ACK包中重传对应的数据
  • 5.剩下的连接处理就类似正常的 TCP 连接了,客户端一旦获取到 FOC,可以重复 Fast Open 直到 TOC 过期。
TFO 代码实战

配置服务端开启 tfo 功能

$ sysctl -w net.ipv4.tcp_fastopen=3

nginx 服务端

server {
    # 启用 fastopen,并限制尚未完成三方握手的连接队列的最大长度
    listen       8080 fastopen=500;
    server_name  localhost;
    access_log  logs/access.log  main;
    real_ip_header    X-Real-IP;
    location /{
        root  html;
    }
}

修改后重启应用配置

$ ./nginx -t
$ ./nginx -s reload

tcp_fast_open_client.py

#!/usr/bin/env python

import sys
import socket

TCP_FASTOPEN = 0x20000000

dest_name = sys.argv[1]
port = int(sys.argv[2])

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.sendto("GET / HTTP/1.0\r\n\r\n".encode(), TCP_FASTOPEN, (dest_name, port))

data = s.recv(1024)
s.close()

print(data.decode())
抓包实战
正常握手阶段

服务端:开始抓包

$ tcpdump tcp and port 8080 -w tfo_nginx_test.pcap

客户端:执行客户端测试脚本

# 首次执行
$ python3 head_client.py 123.206.25.230 8080
...

# 等待几秒后(尽量避免报文乱序),再次执行
$ python3 head_client.py 123.206.25.230 8080
...

下载抓包文件,导入 wireshark 进行分析,追踪第一个 tcp 流(首次正常三次握手)

由图可见,首次正常三次握手的 SYN 报文中,TCP OPTIONS 附加了 TOC 请求。

服务端返回SYN,ACK,同时生成并回传了长度12位TOC(1a078126fcc7ea1c)

客户端将 ACK 与 PSH 合并成一个报文,以此减少发送报文

服务端确认请求包,并返回响应头以及响应正文,最后客户端发送FIN断开连接。PS:不过不清楚这里为什么是两次就断开了?

快速打开阶段

客户端:可以看到,由于上次握手已经获取到 TOC,现在首个 SYN 包(1)就是发送 GET 请求(TCP OPTION 附带 TOC)

服务端:密钥验证 TOC 有效性,验证通过后回复SYN,ACK(2),连接建立成功,Nginx 进程处理处理客户端请求返回响应头(3)及正文(4)

最终断开连接,不过断开这里还是有点迷~ 正常的 客户端先 FIN、服务端回 ACK,结果客户端直接给了个 RST…

TFO 存在的问题
  • 并非所有设备/应用都支持

  • TFO的IPv4支持在3.6(客户端)和3.7(服务端)版本中被合并进Linux内核。IPv6服务器的TFO支持被合并进入3.16版本。

  • 数据包途径某些中间件(防火墙,NAT)可能会引起问题,因为这是对旧协议的相对较新的更改

  • 如果客户端需要使用 SYN 发送的数据很大(>~1400字节),则 TCP Fast open 不会优化任何内容

  • 第一个 SYN 数据包可能会重复,因此应用程序需要进行特殊处理。

    在 TFO 下随着 SYN 发送的数据有可能重复递交到应用层。例如在 IP 层不可靠传输的情况下,发送端的一个 SYN 包被传输成了两个 SYN 包,而在接收端,接收到第一个 SYN 包后,接收端把随 SYN 的数据传递到应用层。

    随后,客户端发起关闭 TCP 连接的操作,服务端由于不会进入 TIME_WAIT 保护状态,可能会收到延迟的重复包,此时可能导致将附带数据的 SYN 报文再次传向应用层。因此如果应用层不能忍受这种包重复,则不能开启 TFO 特性。

TFO 应用场景

TFO 的核心原理是当拥有 TOC 后,下次建立连接时,客户端在第一个 SYN 报文中携带请求数据,以此减少一个 RTT。可此时服务器的 SYN 报文还没有发给客户端,客户端能否与自己成功建立连接,服务器并不确定。

于是,此时服务器需要去假定连接已经建立成功,并把请求交付给进程去处理,所以换言之服务器必须提前就能够信任这个客户端。

看到这,基本就能猜出这技术的适用场景了:

  • 相对较新的客户端和服务器
  • 通过网络连接,少量或没有中间设备(至少是支持TFO的中间设备)
  • 不关注”用户追踪问题”(Cookie标识用户)
资料

文章作者: Da
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Da !
  目录