在公司内部,我们的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万个数据包。
总结
我们针对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为这项工作提供的帮助。