在公司内部,我们的DDoS缓解团队有时被称为“丢包者”。其他团队在构建令人兴奋的产品来处理通过我们网络的流量,而我们却乐于发现丢弃这些流量的新方法。

‌‌CC BY-SA 2.0 图片作者Brian Evans

快速丢弃数据包对于抵御DDoS攻击非常重要。

丢掉发送到我们服务器的数据包,这和听上去一样简单,我们可以在很多层面上完成。每种技术都有其优缺点,在这篇博文里,我们将回顾到目前为止我们尝试过的所有技术。

试验台

为了说明这些方法的相对性能,我们将展示一些数字。这些基准是人为设定的,因此对这些数字我们要有所保留。我们将使用我们的一台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%,但它也大大恶化了我们测试中的标准偏差。启用turbo后,我们的数值偏差为±1.5%。如果关闭Turbo,这一数值则下降到可控的0.25%。

阶段1. 在应用程序中丢弃数据包

让我们先从将数据包传递到应用程序,并在用户空间代码中忽略这些数据包的想法开始。 对于测试设置,首先需要确保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([...])

我们准备了代码,运行一下:

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

这种设置允许内核从硬件接收队列中仅接收微不足道的175kpps,这是使用我们的简易mmwatch工具和通过ethtool来测量的:

$ mmwatch 'ethtool -S ext0|grep rx_2'
 rx2_packets: 174.0k/s

从技术上讲,硬件接收的速度是14Mpps(Mpacket/s,每秒百万包数),但是我们不可能将所有这些数据包都传递给一个RX队列,因为这个队列只由一个单核CPU处理内核工作。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%系统空间和2%用户空间的占用,而在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

阶段2. 击垮conntrack

我们特别设计了测试——通过选择随机源IP和端口——来对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。太棒了!

顺带一提。使用SO_BUSY_POLL可以让我们将数字提高到470k pps,但这是另一个话题了。

阶段3. 使用BPF在套接字上丢包

更进一步说,我们为什么要向用户空间应用程序交付数据包呢?尽管这种技术并不常见,但是我们可以使用setsockopt(SO_ATTACH_FILTER)将一个经典的BPF过滤器附加到SOCK_DGRAM套接字上,并对过滤器编程从而在内核空间中丢弃数据包。

查看这里的代码,运行一下:

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

使用BPF进行丢包(经典cBPF和扩展的eBPF都有相似的性能),我们大概达到了512kpps的性能。当这些数据包仍处于软件中断模式时,它们就被全部被丢弃在了BPF过滤器中,这节省了我们唤醒用户空间应用程序所需的CPU资源。

阶段4. 在路由后使用iptables丢包

下一步,我们可以通过添加如下的规则来简单地删除iptables防火墙输入链中的数据包:

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

需要注意的是我们之前已经通过-j NOTRACK禁用了conntrack,这两条规则使我们达到了608kpps的性能。

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不算差,但是我们可以做得更好!

阶段5. 在路由之前使用iptables丢包

我们甚至还有一种更快的技术,就是在数据包被路由之前丢弃它们。使用以下这个规则即可做到这一点:

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

这个方法可以使性能达到惊人的1.688mpps

这是一个相当大的性能飞跃,我不太理解为什么在路由前后丢包的性能差距这么大。要么是我们的路由层异常复杂,要么是我们的服务器配置有问题。

在任何情况下——通过iptables的raw列表进行丢包绝对是最快的方法。

阶段6. 使用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

阶段7. 利用tc ingress策略来丢包

一个有点令人惊讶的事实是,tc(traffic control,流量控制)ingress hook(入口钩子函数)甚至发生在PREROUTING之前。tc有可能并且确实可以做到根据一定的标准来选择并丢弃数据包。这里的语法难的夸张,所以建议使用这里的脚本进行设置。我们需要稍微复杂一点的tc匹配,命令行如下:

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的丢包能力。这实在太棒了!

但是,我们还可以更快……

阶段8. XDP_DROP

最后,我们的终极武器是XDP – eXpress数据路径。通过XDP,我们可以在网络驱动程序环境中运行eBPF代码。最重要的是,这发生在skbuff内存分配之前,可以提高速度。

通常,XDP项目包含两个部分:

  • eBPF代码加载到内核环境中
  • 用户态加载器,它可以将代码加载到正确的网卡上并进行管理

编写加载程序是相当困难的,所以我们可以使用iproute2的新特性,用这个简单的命令加载代码:

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

成功了!

此处提供了已加载的eBPF XDP程序的源代码。该程序解析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字节码。在此之后,我们可以加载和验证运行的XDP程序:

$ 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,我们可以在单核CPU上每秒丢弃1000万个数据包。

CC BY-SA 2.0 图片作者Andrew Filer

总结

我们针对IPv4和IPv6重复了这些试验,并绘制了以下图表:

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

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

对于应对DDoS,我们完全可以在应用程序中接收包并在用户空间中处理它们。适当调优的应用程序可以得到相当不错的性能。

对于有随机源IP和端口的攻击,禁用conntrack来换取速度也是值得的,但是要注意——在某些攻击情况下conntrack是很有用的。

在其他情况下,将Linux防火墙集成到DDoS缓解渠道中是有意义的。在这种情况下,请记住在“-t raw PREROUTING”层中进行缓解,因为在这一层比在“filter”表中快得多。

对于要求更高的工作负载,我们还有XDP。并且我的天,它实在太强大了。这里的图表与上面的相同,但是加上了XDP:

如果你想要复现这些数字,请点击这里,其中记录了所有内容

在Cloudflare,我们使用了……几乎所有的这些技术。一些用户空间技巧已经与我们的应用程序集成在一起。iptables层由我们的Gatebot DDoS通道管理。最后,我们正在用XDP替换我们专有的内核卸载解决方案。

想来帮我们丢弃更多的数据包吗?我们的许多岗位正在招聘中,包括丢包者,系统工程师等等!

特别感谢Jesper Dangaard Brouer为这项工作提供的帮助。