TCP

TCP

TCP 头

三次握手

三次握手恰好可以保证 Client 和 Server 对自己的发送、接受能力做了一次确认

四次挥手

TIME_WAIT 状态的设计

为什么不直接进入 CLOSED 状态,而是中间有一个 TIME_WAIT 状态 ?

4元组 (客户端 IP、客户端 Port、服务器 IP、服务器 Port),无法区分出新连接还是老连接,这会导致老连接上的数据包会串到新的连接上来。

TCP/IP 定义了一个值 MSL,表示数据包在网络上最长逗留时间,默认是 120s。连接保持在 TIME_WAIT 状态,再等待 2 × MSL 时间就可以进入 CLOSED 状态了,防止串数据。

为什么是两倍的 MSL ?

因为上图第 4 次发送的数据包,服务器是否收到是不确定的。而如果服务器没有收到,那么服务器会重新发送第三次的数据包。所以整个的时间,最长是两个 MSL。

为何服务器收到第 4 个 ACK 之后,立刻进入 CLOSED 状态,而不是也进入 TIME_WAIT 状态 ?

没有必要,因为客户端处于 TIME_WAIT 状态,意味着四元组代表的这个连接要到 2 × MSL 时间之后才能重新启用,服务器即使想要立即连接也无法实现。

大量 TIME_WAIT 状态

如果存在大量的 TIME_WAIT,往往是因为短连接太多,不断的创建连接,然后释放连接,从而导致很多连接在这个状态,可能会导致无法发起新的连接。解决的方式往往是:

  • 打开 tcp_tw_recycletcp_timestamps 选项;
  • 打开 tcp_tw_reusetcp_timestamps 选项;
  • 程序中使用 SO_LINGER,应用强制使用 rst 关闭。

起始序列号 ISN

ISN 为什么不从 1 开始

client 第一次尝试建立连接: client 发送 1,2,3 数据包给 server,我们假设这个时候 3 这个数据包丢失了。紧接着 client 第二次尝试建立连接: client 发送 1,2 给 server,主观上并没有想发送 3 这个数据包,但是第一次的 3 数据包可能又会回来发送给 server,因此造成数据错误)。

起始序列号 ISN 如何计算

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

TCP 初始化序列号不能设置为一个固定值,因为这样容易被攻击者猜出后续序列号,从而遭到攻击。 RFC1948 中提出了一个较好的初始化序列号 ISN 随机生成算法。

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

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

特点

滑动窗口

顺序问题、丢包问题、流量控制都是通过滑动窗口来解决的

发送端维持如下数据结构:

接收端维持如下数据结构:

丢包算法

某个包发送出去,没有收到 ACK,那么应该怎么办?

一种方法就是超时重试,也即对每一个发送了,但是没有 ACK 的包,都有设一个定时器,超过了一定的时间,就重新尝试。但是这个超时的时间如何评估呢?这个时间不宜过短,时间必须大于往返时间 RTT,否则会引起不必要的重传。也不宜过长,这样超时时间变长,访问就变慢了。

估计往返时间,需要 TCP 通过采样 RTT 的时间,然后进行加权平均,算出一个值,而且这个值还是要不断变化的,因为网络状况不断地变化。除了采样 RTT,还要采样 RTT 的波动范围,计算出一个估计的超时时间。由于重传时间是不断变化的,我们称为自适应重传算法(Adaptive Retransmission Algorithm)

每当遇到一次超时重传的时候,都会将下一次超时时间间隔设为先前值的两倍。两次超时,就说明网络环境差,不宜频繁反复发送。

超时触发重传存在的问题是,超时周期可能相对较长。那是不是可以有更快的方式呢?

有一个可以快速重传的机制,当接收方收到一个序号大于下一个所期望的报文段时,就会检测到数据流中的一个间隔,于是它就会发送冗余的 ACK,仍然 ACK 的是期望接收的报文段。而当客户端收到三个冗余的 ACK 后,就会在定时器过期之前,重传丢失的报文段

例如,接收方发现 6 收到了,8 也收到了,但是 7 还没来,那肯定是丢了,于是发送 6 的 ACK,要求下一个是 7。接下来,收到后续的包,仍然发送 6 的 ACK,要求下一个是 7。当客户端收到 3 个重复 ACK,就会发现 7 的确丢了,不等超时,马上重发。

还有一种方式称为 Selective Acknowledgment (SACK)。这种方式需要在 TCP 头里加一个 SACK 的东西,可以将缓存的地图发送给发送方。例如可以发送 ACK6、SACK8、SACK9,有了地图,发送方一下子就能看出来是 7 丢了。

流量控制

在对于包的确认中,同时会携带一个窗口的大小。如果接收方实在处理的太慢,导致缓存中没有空间了,可以通过确认信息修改窗口的大小,甚至可以设置为 0,则发送方将暂时停止发送。

如果这样的话,发送方会定时发送窗口探测数据包,看是否有机会调整窗口的大小。当接收方比较慢的时候,要防止低能窗口综合症,别空出一个字节来就赶快告诉发送方,然后马上又填满了,可以当窗口太小的时候,不更新窗口,直到达到一定大小,或者缓冲区一半为空,才更新窗口。

这就是我们常说的流量控制。

拥塞控制

TCP 的拥塞控制主要来避免两种现象,包丢失超时重传。一旦出现了这些现象就说明,发送速度太快了,要慢一点。但是一开始我怎么知道速度多快呢,我怎么知道应该把窗口调整到多大呢?

一条 TCP 连接开始,cwnd 设置为一个报文段,一次只能发送一个;当收到这一个确认的时候,cwnd 加一,于是一次能够发送两个;当这两个的确认到来的时候,每个确认 cwnd 加一,两个确认 cwnd 加二,于是一次能够发送四个;当这四个的确认到来的时候,每个确认 cwnd 加一,四个确认 cwnd 加四,于是一次能够发送八个。可以看出这是指数性的增长

涨到什么时候是个头呢?有一个值 ssthresh65535 个字节,当超过这个值的时候,就要小心一点了,不能倒这么快了,可能快满了,再慢下来。

每收到一个确认后,cwnd 增加 1/cwnd,我们接着上面的过程来,一次发送八个,当八个确认到来的时候,每个确认增加 1/8,八个确认一共 cwnd 增加 1,于是一次能够发送九个,变成了线性增长

但是线性增长还是增长,还是越来越多,直到有一天,水满则溢,出现了拥塞,这时候一般就会一下子降低倒水的速度,等待溢出的水慢慢渗下去。

拥塞的一种表现形式是丢包,需要超时重传,这个时候,将 sshresh 设为 cwnd/2,将 cwnd 设为 1,重新开始慢启动。这真是一旦超时重传,马上回到解放前。但是这种方式太激进了,将一个高速的传输速度一下子停了下来,会造成网络卡顿。

前面我们讲过快速重传算法。当接收端发现丢了一个中间包的时候,发送三次前一个包的 ACK,于是发送端就会快速地重传,不必等待超时再重传。TCP 认为这种情况不严重,因为大部分没丢,只丢了一小部分,cwnd 减半为 cwnd/2,然后 sshthresh = cwnd,当三个包返回的时候,cwnd = sshthresh + 3,也就是没有一夜回到解放前,而是还在比较高的值,呈线性增长。

就像前面说的一样,正是这种知进退,使得时延很重要的情况下,反而降低了速度。但是如果你仔细想一下,TCP 的拥塞控制主要来避免的两个现象都是有问题的。

  • 第一个问题是丢包并不代表着通道满了,也可能是管子本来就漏水。例如公网上带宽不满也会丢包,这个时候就认为拥塞了,退缩了,其实是不对的。
  • 第二个问题是 TCP 的拥塞控制要等到将中间设备都填充满了,才发生丢包,从而降低速度,这时候已经晚了。其实 TCP 只要填满管道就可以了,不应该接着填,直到连缓存也填满。

为了优化这两个问题,后来有了 TCP BBR 拥塞算法。它企图找到一个平衡点,就是通过不断地加快发送速度,将管道填满,但是不要填满中间设备的缓存,因为这样时延会增加,在这个平衡点可以很好的达到高带宽和低时延的平衡。

异常情况如何响应

出现如下异常情况,TCP 会如何响应。

Server 端口没有打开

内核给 Client 响应一个 RST

Server 所在的机器都没有开机

Client 等待 6s 重发一个 SYN,无响应则等待 24s 再发一个,若总共等待 75s 之后,则触发 ETIMEDOUT。通过 setConnectTimeout() 来改善这个等待时间,使其可以在合理的毫秒以内返回。

Server 宕机

客户端持续重传,并且 read 调用会阻塞,一段时间后 TIMEOUT,使用 setReadTimeout() 来改善这个阻塞时间。

Server 宕机又重启

收到了一个这个连接上不该有的,所以响应一个 RST

TCP close() vs shutdown()

  • 调用 close() 函数会把读和写两个方向的,同时关闭
  • 调用 shutdown() 可以只关闭一半的 TCP 连接。

什么时候发送 RST 响应

当客户端收到 Connection Reset,往往是收到了 TCP 的 RST 消息,RST 消息一般在下面的情况下发送:

  • 试图连接一个未被监听的服务端;
  • 对方处于 TIME_WAIT 状态,或者连接已经关闭处于 CLOSED 状态,或者重新监听 seq num 不匹配;
  • 发起连接时超时,重传超时,keepalive 超时
  • 在程序中使用 SO_LINGER,关闭连接时,放弃缓存中的数据,给对方发送 RST

Linux 服务器最多能支撑多少个 TCP 连接?

Client 最大 TCP 连接

Client每次发起tcp连接请求时,除非绑定端口,通常会让系统选取一个空闲的本地端口(local port),该端口是独占的,不能和其他tcp连接共享。tcp端口的数据类型是unsigned short,因此本地端口个数最大只有65536,端口0有特殊含义,不能使用,这样可用端口最多只有65535,所以在全部作为client端的情况下,最大tcp连接数为65535,这些连接可以连到不同的server ip。

Server 端最大 TCP 连接

TCP 连接四元组是源IP地址、源端口、目的IP地址和目的端口。任意一个元素发生了改变,那么就代表的是一条完全不同的连接了。拿我的Nginx举例,它的端口是固定使用80。另外我的IP也是固定的,这样目的IP地址、目的端口都是固定的。剩下源IP地址、源端口是可变的。所以理论上我的Nginx上最多可以建立**2的32次方(ip数)×2的16次方(port数)**个连接。这是两百多万亿的一个大数字!!

(1) 每维持一条 TCP 连接,都需要创建一个文件对象

进程每打开一个文件(linux下一切皆文件,包括socket),都会消耗一定的内存资源。如果有不怀好心的人启动一个进程来无限的创建和打开新的文件,会让服务器崩溃。所以linux系统出于安全角度的考虑,在多个位置都限制了可打开的文件描述符的数量,包括系统级、用户级、进程级。这三个限制的含义和修改方式如下:

  • 系统级:当前系统可打开的最大数量,通过 fs.file-max 参数可修改
  • 用户级:指定用户可打开的最大数量,修改 /etc/security/limits.conf
  • 进程级:单个进程可打开的最大数量,通过 fs.nr_open 参数可修改

(2) 每一条连接,都需要 file, socket 内核对象,占用内存,一个空 TCP 连接就得消耗 3.3KB 左右

4GB 内存维持 100W 条空的长连接是没有问题的。

(3) 发送数据,就需要为 TCP 内核对象开启接受缓冲区,会增加内存开销

接收缓存区大小是可以配置的,通过 sysctl 命令就可以查看。

$ sysctl -a | grep rmem
net.ipv4.tcp_rmem = 4096 87380 8388608
net.core.rmem_default = 212992
net.core.rmem_max = 8388608

其中在 tcp_rmem 中的第一个值是为你们的TCP连接所需分配的最少字节数。该值默认是4K,最大的话8MB之多。也就是说你们有数据发送的时候我需要至少为对应的socket再分配4K内存,甚至可能更大。

TCP分配发送缓存区的大小受参数net.ipv4.tcp_wmem配置影响。

$ sysctl -a | grep wmem
net.ipv4.tcp_wmem = 4096 65536 8388608
net.core.wmem_default = 212992
net.core.wmem_max = 8388608

net.ipv4.tcp_wmem中的第一个值是发送缓存区的最小值,默认也是4K。当然了如果数据很大的话,该缓存区实际分配的也会比默认值大。

查看当前活动的 TCP 连接数

$ ss -n | grep ESTAB | wc -l  
1000024

半连接全连接队列

TCP 是否丢包

请参考Linux 丢包那些事

攻击

Dos 攻击

DoS:是Denial of Service的简称,即拒绝服务,造成DoS的攻击行为被称为DoS攻击,其目的是使计算机或网络无法提供正常的服务。最常见的DoS攻击有计算机网络带宽攻击和连通性攻击。

  • SYN FLOOD
  • 利用 RST 位来实现。假设现在有一个合法用户(1.1.1.1)已经同服务器建立了正常的连接,攻击者构造攻击的TCP数据,伪装自己的IP为1.1.1.1,并向服务器发送一个带有RST位的TCP数据段。服务器接收到这样的数据后,认为从1.1.1.1发送的连接有错误,就会清空缓冲区中建立好的连接。这时,如果合法用户1.1.1.1再发送合法数据,服务器就已经没有这样的连接了,该用户就必须从新开始建立连接。攻击时,伪造大量的IP地址,向目标发送RST数据,使服务器不对合法用户服务。

DDos 攻击

DDoS即Distributed Denial of Service,DDoS攻击即是分布式DoS攻击。攻击者借助客户/服务器技术,控制并利用多个傀儡机(肉鸡),对一个或多个攻击目标发起DDoS攻击,从而成倍地提高DoS攻击的威力。

DDOS 只不过是一个概称,其下有各种攻击方式,比如“CC攻击、SYN攻击、NTP攻击、TCP攻击、DNS攻击等等”,现在DDOS发展变得越来越可怕,NTP攻击渐渐成为主流了,这意味着可以将每秒的攻击流量放大几百倍,比如每秒1G的SYN碎片攻击换成NTP放大攻击,就成为了200G或者更多。

  • CC 攻击。CC 攻击是目前应用层攻击的主要手段之一,借助代理服务器生成指向目标系统的合法请求,实现伪装和 DDoS。CC 攻击模拟多个正常用户不停地访问如论坛这些需要大量数据操作的页面,造成服务器资源的浪费,CPU长时间处于100%,永远都有处理不完的请求,网络拥塞,正常访问被中止。这种攻击技术性含量高,见不到真实源IP,见不到特别大的异常流量,但服务器就是无法进行正常连接。

CC攻击中最重要的方式之一HTTP Flood,不仅会直接导致被攻击的Web前端响应缓慢,对承载的业务造成致命的影响,还可能会引起连锁反应,间接攻击到后端的Java等业务层逻辑以及更后端的数据库服务。

  • DNS 攻击

  • NTP 攻击

NTP是标准的基于UDP协议传输的网络时间同步协议,由于UDP协议的无连接性,方便伪造源地址。攻击者使用特殊的数据包,也就是IP地址指向作为反射器的服务器,源IP地址被伪造成攻击目标的IP,反射器接收到数据包时就被骗了,会将响应数据发送给被攻击目标,耗尽目标网络的带宽资源。一般的NTP服务器都有很大的带宽,攻击者可能只需要1Mbps的上传带宽欺骗NTP服务器,就可给目标服务器带来几百上千Mbps的攻击流量。

  • ICMP Flood

ICMP(Internet控制报文协议)用于在IP主机、路由器之间传递控制消息,控制消息是指网络通不通、主机是否可达、路由是否可用等网络本身的消息,虽然并不传输用户数据,但是对于用户数据的传递起着重要的作用。通过对目标系统发送海量数据包,就可以令目标主机瘫痪,如果大量发送就成了洪水攻击。

DRDoS 攻击

DRDoS 即 Distributed Reflection Denial of Service,DRDoS攻击即是分布式反射DoS攻击。DRDoS攻击与DoS攻击、DDoS攻击不同,它不是直接对攻击目标发起请求,而是通过IP欺骗,发送大量源IP地址为攻击目标IP地址的数据包给攻击主机(第三方),然后攻击主机对IP地址源(即攻击目标)做出大量回应,从而形成DoS攻击。攻击者往往会选择那些响应包远大于请求包的第三方服务来利用,以达到放大反射攻击的效果,这样的服务包括DNSNTPSSDPChargenMemcached等。

SYN Flood

攻击者大量发送这种伪造源地址的 SYN 请求,服务器端将会消耗非常多的资源来处理这种半连接,保存遍历会消耗非常多的CPU时间和内存,何况还要不断对这个列表中的IP进行SYN+ACK的重试。TCP是可靠协议,这时就会重传报文,默认重试次数为5次,重试的间隔时间从1s开始每次都番倍,分别为1s + 2s + 4s + 8s +16s = 31s,第5次发出后还要等32s才知道第5次也超时了,所以一共是31 + 32 = 63s。

也就是说一共假的syn报文,会占用TCP准备队列63s之久,而半连接队列默认为1024,系统默认不同,可 cat /proc/sys/net/ipv4/tcp_max_syn_backlog c查看。也就是说在没有任何防护的情况下,每秒发送200个伪造syn包,就足够撑爆半连接队列,从而使真正的连接无法建立,无法响应正常请求。

如何检测 Syn Flood 攻击 ?

首先使用 tcpdump 抓包:

$ tcpdump -i eth0 -n tcp port 80
09:15:48.287047 IP 192.168.0.2.27095 > 192.168.0.30: Flags [S], seq 1288268370, win 512, length 0
09:15:48.287050 IP 192.168.0.2.27131 > 192.168.0.30: Flags [S], seq 2084255254, win 512, length 0
...
  • -i 只抓取 eth0
  • -n 不解析协议名和主机名
  • tcp port 80 只抓取 tcp 协议端口号为 80 的网络帧

你能看到,此时,服务器端的 TCP 连接,会处于 SYN_RECEIVED 状态。这其实提示了我们,查看 TCP 半开连接的方法,关键在于 SYN_RECEIVED 状态的连接。我们可以使用 netstat ,来查看所有连接的状态,不过要注意,SYN_REVEIVED 的状态,通常被缩写为 SYN_RECV

$ netstat -n -p | grep SYN_REC
tcp        0      0 192.168.0.30:80          192.168.0.2:12503      SYN_RECV    -
tcp        0      0 192.168.0.30:80          192.168.0.2:13502      SYN_RECV    -
tcp        0      0 192.168.0.30:80          192.168.0.2:15256      SYN_RECV    -
tcp        0      0 192.168.0.30:80          192.168.0.2:18117      SYN_RECV    -
...

-n 表示不解析名字,-p 表示显示连接所属进程

从结果中,你可以发现大量 SYN_RECV 状态的连接,并且源 IP 地址为 192.168.0.2

如何抵抗 Syn Flood ?

可以使用 iptables 过滤来自 192.168.0.2 的包:

$ iptables -I INPUT -s 192.168.0.2 -p tcp -j REJECT

如果源 IP 不固定,那么可以限制 syn 的并发数:

# 限制 syn 并发数为每秒 1 次
$ iptables -A INPUT -p tcp --syn -m limit --limit 1/s -j ACCEPT

# 限制单个 IP 在 60 秒新建立的连接数为 10
$ iptables -I INPUT -p tcp --dport 80 --syn -m recent --name SYN_FLOOD --update --seconds 60 --hitcount 10 -j REJECT

多台机器同时发送 syn flood

如果是多台机器同时发送 SYN Flood,这种方法可能就直接无效了,因为数据量特别大,刚才那种数据量小。因为你很可能无法 SSH 登录(SSH 也是基于 TCP 的)到机器上去,更别提执行上述所有的排查命令。

比如,SYN Flood 会导致 SYN_RECV 状态的连接急剧增大。在上面的 netstat 命令中,你也可以看到 190 多个处于半开状态的连接。

不过,半开状态的连接数是有限制的,执行下面的命令,你就可以看到,默认的半连接容量只有 256:

$ sysctl net.ipv4.tcp_max_syn_backlog
net.ipv4.tcp_max_syn_backlog = 256

换句话说, SYN 包数再稍微增大一些,就不能 SSH 登录机器了。 所以,你还应该增大半连接的容量,比如,你可以用下面的命令,将其增大为 1024:

$ sysctl -w net.ipv4.tcp_max_syn_backlog=1024
net.ipv4.tcp_max_syn_backlog = 1024

另外,连接每个 SYN_RECV 时,如果失败的话,内核还会自动重试,并且默认的重试次数是 5 次。你可以执行下面的命令,将其减小为 1 次:

$ sysctl -w net.ipv4.tcp_synack_retries=1
net.ipv4.tcp_synack_retries = 1

TCP SYN Cookies

除此之外,TCP SYN Cookies 也是一种专门防御 SYN Flood 攻击的方法。SYN Cookies 基于连接信息(包括源地址、源端口、目的地址、目的端口等)以及一个加密种子(如系统启动时间),计算出一个哈希值(SHA1),这个哈希值称为 cookie。

然后,这个 cookie 就被用作序列号,来应答 SYN + ACK 包,并释放连接状态。当客户端发送完三次握手的最后一次 ACK 后,服务器就会再次计算这个哈希值,确认是上次返回的 SYN + ACK 的返回包,才会进入 TCP 的连接状态。

因而,开启 SYN Cookies 后,就不需要维护半开连接状态了,进而也就没有了半连接数的限制。

$ sysctl -w net.ipv4.tcp_syncookies=1
net.ipv4.tcp_syncookies = 1

注意,上述 sysctl 命令修改的配置都是临时的,重启后这些配置就会丢失。所以,为了保证配置持久化,你还应该把这些配置,写入 /etc/sysctl.conf 文件中。比如:

$ cat /etc/sysctl.conf
net.ipv4.tcp_syncookies = 1
net.ipv4.tcp_synack_retries = 1
net.ipv4.tcp_max_syn_backlog = 1024

不过要记得,写入 /etc/sysctl.conf 的配置,需要执行 sysctl -p 命令后,才会动态生效。

参考