It’s a pleasure to pour cold water on the revellers, and you’ll thank me.
我在2016年写过一篇关于tcpdump对Linux网络协议栈性能影响的文章:
https://blog.csdn.net/dog250/article/details/52502623
大概的结论是 当skb的字段匹配项的filter数量非常大的时候,BPF过滤程序将严重影响收包性能。
当时我并没有展开说,部分原因是当时BPF尚未得到普遍关注,所以在就事论事之后,也就结束了。
最近,总有朋友向我咨询关于eBPF性能的问题,我也不知道该怎么回答,我觉得这个话题很适合写一篇文章来聊聊。
先说结论, 无论是cBPF还是eBPF,都不是性能问题的根源,如果出现了性能问题,那一定是你的BPF程序写得不好导致。
事实上, BPF只是在内核中的某些位置安置了一些虚拟机,它最大的妙处在于允许你用各种编程语言,各种编译器来编写符合该虚拟机规范的二进制代码 这是它和普通函数调用的最大的区别。
很多初学者总是误会这一点,他们总是以为运行BPF程序就是调用了一个函数,然而很容易就陷入了寻找调用线索的深渊。但其实并非如此。
你可以理解为BPF程序是被虚拟机解释执行的,然后作为一个优化手段,它可以JIT成本地语言,但首先,它必须是可以被解释执行的。
类似我们在x86机器上编写一个hello world然后将它编译成x86机器码,编写BPF程序唯一的区别就是要将它编译成符合BPF虚拟机规范的机器码,然后BPF机器码被BPF虚拟机解释执行或者被进一步JIT翻译成本地的机器码。
既然你可以编写低效的x86程序,你也可以轻松写出低效的BPF程序。
tcpdump所依赖的BPF程序就是一个低效的BPF程序,我用本文最开始引用的文章中的例子来说明。
我们执行下面的命令,导出tcpdump所灌入内核的BPF程序:
[root@localhost ~]# tcpdump src 1.1.1.1 or 2.2.2.2 or 3.3.3.3 -dd
{ 0x28, 0, 0, 0x0000000c },
{ 0x15, 0, 2, 0x00000800 },
{ 0x20, 0, 0, 0x0000001a },
{ 0x15, 6, 4, 0x01010101 },
{ 0x15, 1, 0, 0x00000806 },
{ 0x15, 0, 5, 0x00008035 },
{ 0x20, 0, 0, 0x0000001c },
{ 0x15, 2, 0, 0x01010101 }, # 匹配1.1.1.1
{ 0x15, 1, 0, 0x02020202 }, # 匹配2.2.2.2
{ 0x15, 0, 1, 0x03030303 }, # 匹配3.3.3.3
{ 0x6, 0, 0, 0x00040000 },
{ 0x6, 0, 0, 0x00000000 },
可以看到对于同一类型的多个匹配项,tcpdump最终会使用遍历的方式去匹配,这便是低效的根源。
我们来实际看一下效果,先给出pktgen打包脚本:
modprobe pktgen
echo rem_device_all >/proc/net/pktgen/kpktgend_0
echo max_before_softirq 1 >/proc/net/pktgen/kpktgend_0
echo add_device enp0s8 >/proc/net/pktgen/kpktgend_0
echo clone_skb 1000 >/proc/net/pktgen/enp0s8
echo pkt_size 550 >/proc/net/pktgen/enp0s8
echo src_mac 08:00:27:ce:25:7e >/proc/net/pktgen/enp0s8
echo flag IPSRC_RND >/proc/net/pktgen/enp0s8
echo src_min 192.168.56.101 >/proc/net/pktgen/enp0s8
echo src_max 192.168.56.255 >/proc/net/pktgen/enp0s8
echo dst 1.1.1.1 >/proc/net/pktgen/enp0s8
echo dst_mac 08:00:27:ff:26:e6 >/proc/net/pktgen/enp0s8
echo count 0 >/proc/net/pktgen/enp0s8
echo pkt_size 100 >/proc/net/pktgen/enp0s8
echo start >/proc/net/pktgen/pgctrl
运行它,然后在与其直连的对端设备上看性能:
[root@localhost src]# sar -n DEV 1
...
05时01分01秒 IFACE rxpck/s txpck/s rxkB/s txkB/s rxcmp/s txcmp/s rxmcst/s
05时01分02秒 enp0s3 0.00 0.00 0.00 0.00 0.00 0.00 0.00
05时01分02秒 enp0s8 486346.43 3.57 47494.65 2.06 0.00 0.00 0.00
05时01分02秒 enp0s9 0.00 0.00 0.00 0.00 0.00 0.00 0.00
05时01分02秒 lo 0.00 0.00 0.00 0.00 0.00 0.00 0.00
然后我们来个极端点的tcpdump程序:
#!/bin/bash
# tdump.sh
i=0
j=0
k=0
for ((k = 1; k < 2; k++)); do
src='tcpdump -i any src '
for ((i = 10; i < 40; i++)); do
for ((j = 30; j < 60; j++)); do
temp=$src" 192.$k.$i.$j or src"
src=$temp
done
done
src=$src" 192.168.11.11 -n"
$src
done
执行之,再看性能:
05时03分00秒 IFACE rxpck/s txpck/s rxkB/s txkB/s rxcmp/s txcmp/s rxmcst/s
05时03分01秒 enp0s3 0.00 0.00 0.00 0.00 0.00 0.00 0.00
05时03分01秒 enp0s8 93617.71 1.04 9142.32 0.60 0.00 0.00 0.00
05时03分01秒 enp0s9 0.00 0.00 0.00 0.00 0.00 0.00 0.00
05时03分01秒 lo 0.00 0.00 0.00 0.00 0.00 0.00 0.00
哈哈,这就是效果。
BPF程序遍历匹配所有这上千个IP地址,这是性能陡降的根源。但是这是BPF虚拟机的错吗?并不是,这只是BPF代码的错!正确的做法应该是至少采用二叉树匹配。
如果将链表转换成二叉树,这并不难。下面我给出一个顺序匹配源IP地址的BPF程序的例子:
// tcpdump src 192.168.56.96 or 192.168.56.97 or 192.168.56.98 or 192.168.56.99 or 192.168.56.100 or 192.168.56.101 or 192.168.56.102 -d
(000) ldh [12]
(001) jeq #0x800 jt 2 jf 4
(002) ld [26]
(003) jeq #0xc0a83860 jt 14 jf 8
(004) jeq #0x806 jt 6 jf 5
(005) jeq #0x8035 jt 6 jf 15
(006) ld [28]
(007) jeq #0xc0a83860 jt 14 jf 8
(008) jeq #0xc0a83861 jt 14 jf 9
(009) jeq #0xc0a83862 jt 14 jf 10
(010) jeq #0xc0a83863 jt 14 jf 11
(011) jeq #0xc0a83864 jt 14 jf 12
(012) jeq #0xc0a83865 jt 14 jf 13
(013) jeq #0xc0a83866 jt 14 jf 15
(014) ret #262144
(015) ret #0
显然,这是低效的。
正确的做法是二叉树匹配,如下面的样子:
#define OP_LDH (BPF_LD | BPF_H | BPF_ABS)
#define OP_LDW (BPF_LD | BPF_W | BPF_ABS)
#define OP_JEQ (BPF_JMP | BPF_JEQ | BPF_K)
#define OP_JGT (BPF_JMP | BPF_JGT | BPF_K)
#define OP_RET (BPF_RET | BPF_K)
static struct sock_filter bpfcode[] = {
{ OP_LDH, 0, 0, 12 },
{ OP_JEQ, 0, 16, ETH_P_IP },
{ OP_LDW, 0, 0, 26 },
{ OP_JGT, 7, 0, 0xc0a83863 }, // 根节点
{ OP_JEQ, 12, 0, 0xc0a83863 },
{ OP_JGT, 3, 0, 0xc0a83861 }, // 根左边
{ OP_JEQ, 10, 0, 0xc0a83861 },
{ OP_JGT, 1, 0, 0xc0a83860 },
{ OP_JEQ, 8, 9, 0xc0a83860 },
{ OP_JGT, 1, 0, 0xc0a83862 },
{ OP_JEQ, 6, 7, 0xc0a83862 },
{ OP_JGT, 3, 0, 0xc0a83865 }, // 根右边
{ OP_JEQ, 4, 0, 0xc0a83865 },
{ OP_JGT, 4, 0, 0xc0a83864 },
{ OP_JEQ, 2, 3, 0xc0a83864 },
{ OP_JGT, 2, 0, 0xc0a83866 },
{ OP_JEQ, 0, 1, 0xc0a83866 },
{ OP_RET, 0, 0, 0xffff },
{ OP_RET, 0, 0, 0 },
};
事先用匹配项IP地址构建二叉树,然后导出其中序遍历结果,就可以生成上面的BPF代码了,我们只要自己为tcpdump的socket注入上述类型的代码,性能就会提升。
然而,tcpdump并没有这么做。
tcpdump是没有这么做,抓包又不是仅此一家,除了tcpdump之外的几乎所有抓包程序都采用各种各样的优化措施,我这里只是用tcpdump举个例子来演示BPF程序是如何损害系统性能的。
实际上,为了防止用户写出低效甚至有bug的BPF程序甚至阻塞内核,BPF程序有一些附加规定,比如指令数不能超过某个阈值,不能有循环等,但即便如此,该低效的也低效。上面tcpdump的例子,我的指令数量一共也就1500多条。
eBPF程序没有什么不同,如果说cBPF是汇编语言,那eBPF就是高级语言了,除了增加了内置函数以支持内核调用以及MAP等数据结构,它和cBPF没有本质的不同,即, 你依然可以用eBPF写出低效或有bug的程序从而影响性能。
eBPF程序的写法亦存在诸多限制。
eBPF的Verifier构建了一个BPF虚拟机沙盒,该沙盒确保了隔离和安全,然而在安全之外,进出沙盒的时间却依然影响着系统的整体性能。换句话说,即便是对eBPF程序加以诸多的约束,你依然可以故意让eBPF程序拖慢你的系统。
近几年eBPF火得不得了,因为它给了那些希望扩展Linux内核的程序员足够灵活的自由度,但是这样真的好吗?
eBPF酷似一把瑞士军刀,小巧且强大,然而类比终究只是类比,我们同样可以将eBPF看作是正在扩散的癌细胞,小巧且危险。
如果无法确保约束规则被无条件遵守,那么规则约束的灵活性就可能是有害的。 在eBPF之前,我们已经见识过了。
Windows系统越用越慢,Android系统越用越慢,原因就在于这些系统为编程者提供了非常多的灵活性:
于是系统里便充满了程序被卸载了但依然存在的顽固文件占据着系统空间。
…
我目前未完待续的结论如下:
谨守口的,得保生命;大张嘴的,必致败亡。
浙江温州皮鞋湿,下雨进水不会胖。