原文地址:https://blog.cloudflare.com/how-to-drop-10-million-packets/
翻译水平有限,有不通顺的语句,请见谅。
原作者:Marek Majkowski
写于 06 Jul 2018

译者:驱蚊器喵#ΦωΦ


在团队内部,我们的 DDoS 缓解团队有时被称为”数据包丢弃者”。当其他的团队使用流经我们网络的流量,构建了很棒的产品来做一些智能的事情, 我们以找到丢弃这些数据包的新方法而感到快乐。

38464589350_d00908ee98_b.jpg
38464589350_d00908ee98_b.jpg

CC BY-SA 2.0 image by Brian Evans

在抵抗 DDoS 攻击中,能够快速的丢弃数据包显得非常重要。

丢弃攻击服务器的数据包,听起来很简单,可以在网络的多个层级做到。每种技术都有他的优点和局限。在这篇博文中,我们将回顾到目前我们尝试的所有技术。

验证阶段

我们将展示一些数字,来说明这些方法的相对功能。基准测试是用数字合成的,所以请对这些数字持保留态度。 我们将使用一台 Intel 服务器,配备 10Gbps 网卡。 硬件细节不太重要,因为基准测试是针对操作系统,而不是硬件的瓶颈。

我们的测试步骤如下:

  • 我们发送大量的小型UDP数据包,峰值达到14Mpps(每秒百万个数据包).

  • 流量是针对目标服务器的单个CPU.

  • 我们测量单个CPU的内核能够处理多少数据包.

我们没有设置用户空间应用程序的最大速度值,也没有设置数据包的最大吞吐量,相反,我们是想专门展示内核的瓶颈。
合成流量使用随机的源ip和端口,准备对 conntrack (连接追踪)施加最大的压力。tcpdump显示如下:

1
2
3
4
5
6
7
8
9
10
11
$ 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. 我们通过控制硬件流来实现:

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

基准测试总是很难进行的.当准备测试的时候,我们才了解到任何活跃的流数据包都会破坏性能表现.事后来看这是很明显的,但是容易被忽略。在进行测试之前,请记住要确保没有任何 tcpdump 进程在运行。以下展示了如何来检查,并且显示有个正在活跃的进程:

1
2
3
$ 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 功能:

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

因为 Turbo Boost 技术是不错的,提升了至少20%的吞吐量,也极大的恶化了我们测试中的标准误差. 当开启 Turbo Boost 的时候,会对数值有 ±1.5% 的误差。当关闭 Turbo Boost 后,误差降低到可控的 0.25%.

layers.JPG
layers.JPG

Step 1. 使用程序丢弃数据包

让我们开始将数据包传送到应用程序,并在用户空间的代码中忽略他们。为了测试,我们要确保iptables 不会影响性能:

1
2
3
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

程序代码是一个简单的循环,接收数据包然后在用户空间(userspace)马上丢弃掉:

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

使用我们准备好的代码, 运行如下:

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

这样的设置会允许内核从硬件进站队列接收仅 175kpps 的数据包, 这里我们用 ethtool 计算数据并用 mmwatch 工具展示:

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

硬件从网线接收到 14Mpps 的数据包,但是不可能将这些数据包传送到只有一个 CPU 核在进行内核处理的单RX队列中. mpstat 证明如下:

1
2
3
4
5
6
$ 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 漏洞爆发后的时间里, 系统调用变得珍贵且不可替代,我们使用开启了 KPTI (译者注:内核页表隔离,Meltdown的防御方案) 和 retpolines (译者注:避免 Spectre 攻击的修毕方案) 的 4.14版本内核:

1
2
3
4
5
6
7
8
9
$ 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

Step 2. 关闭 conntrack

我们特别定制的测试,选取随机的源IP和端口,向连接追踪(conntrack)层施加压力。这可以通过查看 conntrack 的条目数据来验证, 在本次测试中已经达到峰值:

1
2
3
4
5
$ conntrack -C
2095202

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

你还能在 dmesg 中看到 conntrack 在嘶吼:

1
2
3
[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

为了加速我们的测试,我们将 conntrack 禁用:

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

-j NOTRACK用于禁用 conntrack。
然后重新运行测试:

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

这样瞬间将应用程序的接收性能提升至了333kpps. 哇!不可思议!

PS. 使用 SO_BUSY_POLL 我们可以将数字提升到470k pps,但是这不是本文的重点。

Step 3. BPF drop on a socket

深入思考以下, 为什么我们要把数据包传送到用户空间的应用程序? 我们可以使用setsockopt(SO_ATTACH_FILTER),将传统的 BPF 过滤器附加到 SOCK_DGRAM 套接字上,并通过在过滤器上编写代码,在内核空间中丢弃数据包,虽然这种技术并不常见。

点击查看代码, 运行代码:

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

随着在 BPF (传统的BPF和升级版的eBPF,性能都差不多)中的丢弃数据包,我们大约处理了512kpps 数据. 所有的数据包都在 BPF 过滤器中被丢弃了,同时因为处于软件的中断模式中,这节约了唤醒用户空间程序所需要的 CPU 资源。

Step 4. 在路由之后使用 iptables 丢包

作为下一步,我们可以在 iptables 防火墙上简单的丢弃数据包,设置防火墙的 INPUT 规则链,添加以下规则:

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

记着,我们已经通过-j NOTRACK禁用了 conntrack. 在此前提下,这两条规则能让我们接收的数据包达到 608kpps.

在 iptables 计数器里的统计:

1
2
3
4
5
$ 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 不算差, 但是我们还能让它变得更好!

Step 5. 在 PREROUTING 使用 iptables 丢弃

一种更快的技术,是在数据包被路由之前丢弃. 添加规则如下:

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

这样使我们接收的数据包达到了惊人的1.688mpps.

这是一个非常重要的性能飞跃, 我不能完全理解。要么我们的路由层非常复杂,要么在我们的服务器配置中有 bug。

不管怎么说,”原始” 的iptables 表肯定是更快了.

Step 6. 在 CONNTRACK 之前使用 nftables 丢弃

如今,iptables 被认为是过时的工具. 它的替代品是 nftables. 看这个视频会从技术方面解释为什么 nftables 是更优秀的.
nftables 承诺比年迈的 iptables 更快,原因很多,其中有一个传闻,retpolines(又名:对于没有间接跳跃的猜测)对iptables的影响非常严重。

因为本文并不是讨论比较 nftables 和 iptables 的速度,我们来尝试我能想到的最快的丢弃方案:

1
2
3
4
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

我们可以使用这个命令查看计数器:

1
2
3
4
5
6
7
8
$ 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 “入口” 的 hook 吞量约为 1.53 mpps,比 PREROUTING 层中的 iptables 稍慢。 这令人费解 - 理论上”入口”发生在 PREROUTING 之前,所以应该更快。

在我们的测试中,nftables 比 iptables 略慢,但不是很多。 不过 nftables 仍然是更好的 :P

Step 7. 在流量控制入口的处理器中丢弃

一个有点令人惊讶的事实是,在 PREROUTING 之前甚至发生了 tc(traffic control,流量控制)入口的 hook。 tc 可以根据基本标准选择数据包,实际上是 - 丢弃 - 它们。 语法相当粗糙,因此建议使用此脚本进行设置。 我们需要一个更复杂的 tc 匹配,这里是命令:

1
2
3
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

我们可以这样验证:

1
2
3
4
5
6
7
8
9
10
11
12
13
$ 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

使用 u32 匹配的 tc 入口 hook 允许我们在单个CPU上丢弃1.8mpps。 这太棒了!

但是我们还能更快…

Step 8. XDP_DROP

最后, 终极武器是 XDP - eXpress Data Path. 使用 XDP 我们可以在网络驱动环境中运行 eBPF 代码 . 最重要的是,这在”skbuff”内存分配之前,可以得到很快的运行速度。

通常 XDP 项目有两个部分:

  • 加载到内核环境的 eBPF 代码
  • 用户空间加载器,它将代码加载到正确的网卡上并对其进行管理

编写加载器非常困难,所以我们可以使用iproute2的新功能,并使用这个简单的命令加载代码:

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

Tadam!

加载了 eBPF 的 XDP 程序的源代码可在此处获得。该程序解析 IP 数据包并查找所需的特征:IP传输,UDP 协议,所需的目标子网和目标端口:

1
2
3
4
5
6
7
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 程序需要使用可以发出 BPF 字节码的现代 clang 进行编译。 然后,我们可以加载并验证正在运行的 XDP 程序:

1
2
3
4
$ 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查看网卡统计中的数据:

1
2
3
4
$ 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 万个数据包。

225821241_ed5da2da91_o.jpg
225821241_ed5da2da91_o.jpg

CC BY-SA 2.0 image by Andrew Filer

总结

我们为 IPv4 和 IPv6 重复进行了实验,并准备了如下图表:

numbers-noxdp.png
numbers-noxdp.png

一般来说,在我们的设置中,IPv6 的性能略低。 请记住,IPv6 数据包略大,因此一些性能差异是不可避免的。

Linux 有许多可用于过滤数据包的 hook ,每种方式具有不同的性能和易用性特征。

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

对于具有随机/欺骗源 IP 的 DDoS 攻击,禁用 conntrack 来获得一些速度可能是值得的。 但要小心 - conntrack 防御有些攻击非常有帮助。

在其他情况下,将 Linux 防火墙集成到 DDoS 缓解管道中可能是有用的。在这种情况下,请记住将缓解措施放在 “-t raw PREROUTING” 层中,因为它比在”过滤”表中快得多。

对于要求更高的工作负载,我们始终使用 XDP。因为它很强大。以下是与上面相同的图表,但包括了 XDP 的统计数据:

numbers-xdp-1.png
numbers-xdp-1.png

如果您想复现这些数据,请查看我们记录了所有内容的说明文件

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

评论:
我们写了几篇有关优化 socket 吞吐量的文章:

https://blog.cloudflare.com/the-sad-state-of-linux-socket-balancing/
https://blog.cloudflare.com/how-to-achieve-low-latency/
https://blog.cloudflare.com/how-to-receive-a-million-packets/