如何在一秒之内丢弃1000万个网络数据包?

偶然看到一篇cloudflare的博客How to drop 10 million packets per second,如何实现单核情况下一秒钟丢弃1000万个数据包,原文循序渐进,从最简单的用户态丢弃到使用非常新的技术XDP,逐步将单核丢包性能提升到10mpps,很有意思,网上也没有看到原文的中文版本,所以这里顺便翻译一下,看看cloudflare是如何处理类似的情况的。

在公司内部,我们的抗DDoS团队有时会被人们称作“数据包丢弃者”。当其他团队为流经我们网络的流量做了很多令人兴奋的聪明玩意时,我们也很享受探索如何丢弃这些流量的新方法。

能以最快速度丢掉网络包,对于抵抗DDoS攻击来说,是非常重要的。
丢掉发送到我们服务器的数据包,和听上去一样简单,可以在很多层面上进行。每个技术都有他的有点和缺陷,在这篇Blog里,我们会一起看一下我们到目前为止用到的技术。

试验台

为了说明方法的相对性能,我们将通过一些基准测试,这些测试是设计好的,可以得到一系列的数据。我们使用了一台Intel的服务器,这台机器有一块10Gbps网卡,机器的其他配置信息其实并不是很重要,因为这些测试的目标是为了显示出操作系统而不是硬件层面的限制。

我们的测试设置如下:

  • 我们传输大量小的UDP数据包,达到14Mpps(每秒数百万个数据包)。

  • 此流量指向目标服务器上的单个CPU。

  • 我们测量内核在该CPU上处理的数据包数量。

我们并没有尝试优化用户空间应用程序的速度,也没有尝试提升数据吞吐量 - 相反,我们尝试专门展示内核层的瓶颈。

生成的流量可以对conntrack施加最大压力 - 数据包使用随机源IP和端口字段。 tcpdump的结果如下:

$ tcpdump -ni vlan100 -c 10 -t udp and dst port 1234
IP 198.18.40.55.32059 > 198.18.0.12.1234: UDP, length 16
IP 198.18.51.16.30852 > 198.18.0.12.1234: UDP, length 16
IP 198.18.35.51.61823 > 198.18.0.12.1234: UDP, length 16
IP 198.18.44.42.30344 > 198.18.0.12.1234: UDP, length 16
IP 198.18.106.227.38592 > 198.18.0.12.1234: UDP, length 16
IP 198.18.48.67.19533 > 198.18.0.12.1234: UDP, length 16
IP 198.18.49.38.40566 > 198.18.0.12.1234: UDP, length 16
IP 198.18.50.73.22989 > 198.18.0.12.1234: UDP, length 16
IP 198.18.43.204.37895 > 198.18.0.12.1234: UDP, length 16
IP 198.18.104.128.1543 > 198.18.0.12.1234: UDP, length 16

在目标机器,我们将所有的流量都定向到网卡同一个RX队列上,也就是说所有的数据都只会被一个CPU核处理。我们通过硬件流转向实现这一目标:

ethtool -N ext0 flow-type udp4 dst-ip 198.18.0.12 dst-port 1234 action 2

基准测试通常也很困难,当我们在准备测试的过程中,我们发现如果系统中有活动的raw socket也会影响性能,事后看很明显,但是也很容易忽略类似的问题。所以在测试之前需要确认没有任何tcpdump进程在运行,可以通过下面的方式查看:

$ ss -A raw,packet_raw -l -p|cat
Netid  State      Recv-Q Send-Q Local Address:Port
p_raw  UNCONN     525157 0      *:vlan100          users:(("tcpdump",pid=23683,fd=3))

最后,我们要关闭Intel Turbo Boost特性:

echo 1 | sudo tee /sys/devices/system/cpu/intel_pstate/no_turbo

虽然Turbo Boost很好,而且可以提升至少20%的吞吐量,但是也会极大的影响测试结果的标准差,在开启状态下偏大达到了 ±1.5%,而关闭之后偏差下降到了0.25%。

第一阶段 在应用程序中丢弃包

让我们从将数据包传递到应用程序并在用户空间代码中忽略它们的想法开始。 对于测试设置,首先需要确保iptables不会影响性能:

iptables -I PREROUTING -t mangle -d 198.18.0.12 -p udp --dport 1234 -j ACCEPT
iptables -I PREROUTING -t raw -d 198.18.0.12 -p udp --dport 1234 -j ACCEPT
iptables -I INPUT -t filter -d 198.18.0.12 -p udp --dport 1234 -j ACCEPT

应用程序的代码就是一个简单的循环,获取数据,然后直接丢弃:

s = socket.socket(AF_INET, SOCK_DGRAM)
s.bind(("0.0.0.0", 1234))
while True:
    s.recvmmsg([...])

这里有准备好的C代码,运行结果:

$ ./dropping-packets/recvmmsg-loop
packets=171261 bytes=1940176

对于这个实现,我们利用ethtoolmmwatch工具可以实现从硬件队列中以175kpps的速度读取数据包。

硬件上看接收的速度是14Mpps,但是针对单核处理的RX队列,这些数据包已经无法处理了。可以通过mpstat工具确认:

$ watch 'mpstat -u -I SUM -P ALL 1 1|egrep -v Aver'
01:32:05 PM  CPU    %usr   %nice    %sys %iowait    %irq   %soft  %steal  %guest  %gnice   %idle
01:32:06 PM    0    0.00    0.00    0.00    2.94    0.00    3.92    0.00    0.00    0.00   93.14
01:32:06 PM    1    2.17    0.00   27.17    0.00    0.00    0.00    0.00    0.00    0.00   70.65
01:32:06 PM    2    0.00    0.00    0.00    0.00    0.00  100.00    0.00    0.00    0.00    0.00
01:32:06 PM    3    0.95    0.00    1.90    0.95    0.00    3.81    0.00    0.00    0.00   92.38

可以看到用户代码不是瓶颈,在CPU #1上有 27% sys + 2% userspace的占用,但是CPU #2被网络软中断(SOFTIRQ)占用了100%。

需要说明的是,使用recvmmsg(2)很重要,在Spectre漏洞被发现的现在,系统调用的成本变得更加高了,我们使用了4.14版本的内核,并开启了KPTI和Retpoline:

$ tail -n +1 /sys/devices/system/cpu/vulnerabilities/*
==> /sys/devices/system/cpu/vulnerabilities/meltdown <==
Mitigation: PTI

==> /sys/devices/system/cpu/vulnerabilities/spectre_v1 <==
Mitigation: __user pointer sanitization

==> /sys/devices/system/cpu/vulnerabilities/spectre_v2 <==
Mitigation: Full generic retpoline, IBPB, IBRS_FW

第二阶段 干掉conntrack

我们特别的设计了这个测试,用随机的原IP和端口,用来给conntrack层施加压力。这个可以通过查看conntrack数量的方式确认,在测试中,conntrack数量达到最大:

$ conntrack -C
2095202

$ sysctl net.netfilter.nf_conntrack_max
net.netfilter.nf_conntrack_max = 2097152

也能从dmesg中看到conntrack日志:

[4029612.456673] nf_conntrack: nf_conntrack: table full, dropping packet
[4029612.465787] nf_conntrack: nf_conntrack: table full, dropping packet
[4029617.175957] net_ratelimit: 5731 callbacks suppressed

为了加速我们的测试,把它关掉:

iptables -t raw -I PREROUTING -d 198.18.0.12 -p udp -m udp --dport 1234 -j NOTRACK

然后重新测试:

$ ./dropping-packets/recvmmsg-loop
packets=331008 bytes=5296128

程序性能里面提升到了333kpps,赞!

PS:通过SO_BUSY_POLL选项,我们可以将性能提升到470k pps,但是这个是另一个话题了。

第三阶段 利用BPF进行丢包操作

更进一步,为什么我们要在用户态进行丢包呢?虽然这个技术不常见,但是我们可以使用setsockopt(SO_ATTACH_FILTER)添加一个cBPF过滤器到一个socket上,让程序在内核态进行丢包操作。
这里是代码,运行一下:

$ ./bpf-drop
packets=0 bytes=0

使用BPF进行丢弃操作(cBPF和eBPF有相似的性能),我们大致达到了512kpps的性能。所有的包都在BPF过滤器中丢弃了,由于依然需要使用到软中断,所以只是省掉了唤醒用户态程序的CPU消耗。

第四阶段 使用iptables在路由阶段结束后丢弃

在下个阶段,我们可以简单的设置iptables INPUT规则来丢弃包:

iptables -I INPUT -d 198.18.0.12 -p udp --dport 1234 -j DROP

需要注意的是我们之前已经通过-j NOTRACK关闭了conntrack,这两条规则实现了608kbps的性能。
看下iptables的统计信息:

$ mmwatch 'iptables -L -v -n -x | head'

Chain INPUT (policy DROP 0 packets, 0 bytes)
    pkts      bytes target     prot opt in     out     source               destination
605.9k/s    26.7m/s DROP       udp  --  *      *       0.0.0.0/0            198.18.0.12          udp dpt:1234

600kpps不差了,但是我们能做到更好!

第五阶段 使用iptables在路由之前丢弃

有一个更快的方法,就是在包路由之前丢弃,可以通过下面的命令:

iptables -I PREROUTING -t raw -d 198.18.0.12 -p udp --dport 1234 -j DROP

这个方法的性能高达1.688pps。
这是非常明显的性能提升,我并不是特别明白多了一次路由差距这么大,要么是我们的路由层非常的复杂,或者在服务器的配置上有bug。
在任何情况下,通过iptables的raw表进行操作绝对是最快的方法。

第六阶段,使用nftables在CONNTRACK之前丢弃

iptables在现在已经有点过时了,更新的玩意是nftables,关于为什么nftables技术更优越,请参阅此视频。 由于许多原因,Nftables承诺比老旧的iptables更快,其中有一个说法是retpolines(没有间接跳跃的猜测)严重影响了iptables性能。

由于这篇文章不是关于比较nftables和iptables的速度,让我们尝试一下我能想到的最快的方法:

nft add table netdev filter
nft -- add chain netdev filter input { type filter hook ingress device vlan100 priority -500 \; policy accept \; }
nft add rule netdev filter input ip daddr 198.18.0.0/24 udp dport 1234 counter drop
nft add rule netdev filter input ip6 daddr fd00::/64 udp dport 1234 counter drop

相关的统计信息可以通过这个命令查看:

$ mmwatch 'nft --handle list chain netdev filter input'
table netdev filter {
    chain input {
        type filter hook ingress device vlan100 priority -500; policy accept;
        ip daddr 198.18.0.0/24 udp dport 1234 counter packets    1.6m/s bytes    69.6m/s drop # handle 2
        ip6 daddr fd00::/64 udp dport 1234 counter packets 0 bytes 0 drop # handle 3
    }
}

Nftables “ingress” Hook性能卡在了1.53mpps。 这比PREROUTING层中的iptables稍慢。 这令人费解 - 理论上”ingress”在PREROUTING之前发生,所以应该更快。
在我们的测试中nftables比iptables略慢,但不是很多。 Nftables仍然更好:P

第七阶段 利用tc的ingress策略丢包

有个比较令人震惊的事实是tc(traffic control)的ingress hook发生在PREROUTING之前。tc可以并且确实能做到根据一定的标准来选择并丢弃数据包,但是做法确实比较hacky,所以建议利用这个脚本进行设置,我们需要的是一个稍微复杂点的匹配,参考下面的命令:

tc qdisc add dev vlan100 ingress
tc filter add dev vlan100 parent ffff: prio 4 protocol ip u32 match ip protocol 17 0xff match ip dport 1234 0xffff match ip dst 198.18.0.0/24 flowid 1:1 action drop
tc filter add dev vlan100 parent ffff: protocol ipv6 u32 match ip6 dport 1234 0xffff match ip6 dst fd00::/64 flowid 1:1 action drop

可以验证:

$ mmwatch 'tc -s filter  show dev vlan100  ingress'
filter parent ffff: protocol ip pref 4 u32 
filter parent ffff: protocol ip pref 4 u32 fh 800: ht divisor 1 
filter parent ffff: protocol ip pref 4 u32 fh 800::800 order 2048 key ht 800 bkt 0 flowid 1:1  (rule hit   1.8m/s success   1.8m/s)
  match 00110000/00ff0000 at 8 (success   1.8m/s ) 
  match 000004d2/0000ffff at 20 (success   1.8m/s ) 
  match c612000c/ffffffff at 16 (success   1.8m/s ) 
        action order 1: gact action drop
         random type none pass val 0
         index 1 ref 1 bind 1 installed 1.0/s sec
        Action statistics:
        Sent    79.7m/s bytes   1.8m/s pkt (dropped   1.8m/s, overlimits 0 requeues 0) 
        backlog 0b 0p requeues 0

通过tc的ingress hook的u32匹配,可以让我们实现单核1.8mpps的丢包能力,这个很棒!

但是,我们可以更快一点…

第八阶段 XDP_DROP

最后,终极武器是XDP - eXpress Data Path。通过XDP,我们可以在网络驱动层运行eBPF代码。最重要的是,这个阶段发生在分配skbuff内存之前,可以获得超高的速度。

通常XDP项目包含两部分:

  • 被加载到内核的eBPB代码
  • 用户态的加载器,可以将代码加载到正确的网卡,并且控制他们

编写加载器很难,但是我们可以利用iproute2的这个新特性,用一个很简单的命令加载:

ip link set dev ext0 xdp obj xdp-drop-ebpf.o

搞定!
这个eBPF的代码在这里。这个程序解析IP数据包,然后寻找对应的特征:IP传输、UDP协议、对应的子网和端口

if (h_proto == htons(ETH_P_IP)) {
    if (iph->protocol == IPPROTO_UDP
        && (htonl(iph->daddr) & 0xFFFFFF00) == 0xC6120000 // 198.18.0.0/24
        && udph->dest == htons(1234)) {
        return XDP_DROP;
    }
}

XDP程序需要用现代的clang编译器编译成BPF字节码,完成之后可以加载并验证:

$ ip link show dev ext0
4: ext0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 xdp qdisc fq state UP mode DEFAULT group default qlen 1000
    link/ether 24:8a:07:8a:59:8e brd ff:ff:ff:ff:ff:ff
    prog/xdp id 5 tag aedc195cc0471f51 jited

然后通过ethtool -S查看网卡的统计信息:

$ mmwatch 'ethtool -S ext0|egrep "rx"|egrep -v ": 0"|egrep -v "cache|csum"'
     rx_out_of_buffer:     4.4m/s
     rx_xdp_drop:         10.1m/s
     rx2_xdp_drop:        10.1m/s

利用XDP,我们实现了在单核上,每秒钟丢弃1000万个包!

总结

我们在IPv4和IPv6上重复了实验,并画了这张图:

总的来说在我们现在的设置下,IPv6比v4要稍微慢一些,需要注意的是IPv6的包也稍微大了一些,所以这性能上的区别还是可以理解的。

Linux提供了很多过滤数据包的Hook,每个都有不同的性能和易用性。

对于应对DDoS的场景,在用户态的应用程序里处理这些数据包是合理的,通过调整应用程序,也可以获得不错的性能。
而对于有随机源IP和端口的攻击,关闭conntrack的特性来获得性能提升也是值得的,但是conntrack在某些攻击情况下,还是很有用的。
针对其他情况下,利用Linux的防火墙来作为抗DDoS的一部分还是很有意义的,在这种情况下要尽量利用”-t raw PREROUTING”这一层,因为这一层比”filter”表要快很多。
对于要求更高的工作负载,我们还有XDP,而且他很强大,下面是和上面相同的图表,但是加上了XDP:

如果需要重现这些数据,可以看看项目代码的README