手艺人舍bpftrace而取systemtap的代价和思考

上个礼拜我就想喷eBPF了,由于周末时间实在太紧,就准备拖延一周,但还是立了个flag,先发了个朋友圈:

ebpf就像牛皮藓一样,已经遍布在linux内核的各个角落,每个调用点都看上去很随意,毫无规划,让人觉得好像自己觉得哪里需要这么一个调用点并不很难…
但实际上如果你真的去尝试在某处加一个ebpf调用点时,就会觉得这件事和清除牛皮藓的过程非常类似,修改散落在各个目录的多个文件,还得重新编译,大概率失败,还要重新做一次,很难一次做干净,当你好不容易成功了,会有一种“不过如此”的嗟叹…
我曾将ebpf比做扩散的癌细胞,这个比喻没有给人密集恐惧的效果,所以我换成了牛皮癣。该存在ebpf调用点的地方一个也没有,没必要ebpf的地方到处都是,这些点还在持续增加,迄至5.11内核,ebpf已经有大三十个点了,依然在毫无规划地疯长着…
eBPF是个创新,但人们明显狂热过度了,ebpf增加调用点也过于随意,太业务导向了,损坏了内核的内聚性,远远比不上当初netfilter的五个hook点以及qdisc这种经过良好设计的机制,另外还有一个问题,netfilter的五个hook点上如果部署了ebpf点,其实就能解决大部分性能问题,然而直到现在都没有,感觉是社区矫枉过正了,真的彻底把netfilter当成了旧时代的象征,把马杀掉的同时,轮子也不要了
人们都像虫子一样在这里你争我抢,吃的都是良心,拉的全是思想。
终于到了周末,我终于还是不能说话,我甚至已经不知道该说些什么了。
昨天,我把自己血祭了,这种自我牺牲在古罗马共和国是一种美德,执政官会把自己献给神,以换取战争的胜利。
为了喷eBPF,在平时的工作和学习中,我积累了很多素材,eBPF的领地分为两个部分:

  • 网络协议栈功能
  • trace跟踪

在网络方面,我用牛皮癣的比喻来说明eBPF不断疯长的画面是多么糟糕。而在trace跟踪方面,我想拿性能和功能说事。
我本希望用这些素材来佐证自己的一些怪异的观点,昨天,我准备用我的实际工作来作为我最后一个素材,结果它恰恰验证了我的认知错误,它恰恰说明了eBPF作为trace跟踪工具是多么的好用!
我的故事是这样的。
在大流量的背景下,特别是如果你的代码使用类似Bonding,tun/tap,GRE,IPIP等虚拟网卡,排查skb在哪里被drop一直都是一个很麻烦的事情。即便是你已经知道了一个特定的五元组,这件事也不会因此变得简单。

抓包?抓包永远是第一步的操作,但也只是第一步,它只能告诉你skb收到了或者没有收到,如果没有收到,进一步就需要确认skb到底在哪里丢了。当然了,如果最后实在是定位不了,一般会把锅甩给运营商这个黑洞。

类似wireguard这种,其所有复杂的操作,包括加密,分发等逻辑在内均在wireguard虚拟网卡的xmit函数中完成,跟踪wg_xmit的细节除了要求你对wireguard的代码非常熟悉之外,还需要各种奇技淫巧的手艺。

以下面这个场景为例:

我要实锤skb在从1到11的哪一步被drop的,要怎么做?

这不正是stap的地盘吗?哦,我不能说stap,在有了bpftrace可供选择的时候,再说stap有点不正确,如果我坚持要用stap,会有一群人建议我用bpftrace,并说stap是多么的糟糕,过时,不友好。
那好,那就用bpftrace,下面的脚本可以对skb进行完美的全程trace:

#!/usr/local/bin/bpftrace
⁣
#include 
#include 
#include 
⁣
k:encrypt_packet
{
 
        $skb = (struct sk_buff *)arg0;
        // 这个skb的mark需要iptables来为特定的五元组标记上,但是encrypt_packet这里是可以使用mark的最后的地方。
        // 在encrypt_packet成功返回后,skb的几乎所有附属标记都会被reset,包括skb->mark。
        // 因此这里必须用另一个标记,以确保在encrypt_packet之后还能用此特征跟踪到特定的skb。
        // 由于bpftrace只能读不能写,这里我选择直接用skb的地址!
        if ($skb->mark == 1234) {
 
                printf("encrypt got %p\n", $skb);
                @addr = $skb;
        }
}//k:send4
k:udp_tunnel_xmit_skb
{
 
        $daddr = arg4;
        $saddr = arg3;
        $skb = (struct sk_buff *)arg2;
        // 这里除了match地址之外,是不是也要match一下其它字段呢?毕竟slub中的skb是可以重用的。
        // 如果mark 1234的skb在这个之前被drop & free了,它被重新alloc后依然会到这里,这就错了!
        // 然而由于流量可控,且我是一个函数一个函数trace,上述概率极低。手艺人不求完美!
        if ($skb == @addr) {
 
                printf("---- skb:%p daddr:%08x saddr:%08x \n", @addr, $daddr, $saddr);
        }
}
⁣
k:iptunnel_xmit
//k:dev_queue_xmit
//k:dev_hard_start_xmit
//k:dev_queue_xmit_nit
{
 
        $skb = (struct sk_buff *)arg2;
        // 从裸包中取外层协议头的内容。
        $udph = (struct udphdr *)($skb->head + $skb->transport_header);
        $sport = $udph->source;
        $dport = $udph->dest;
        if ($skb == @addr) {
 
                $port = (($sport & 0xff00) >>8) | (($sport & 0xff) << 8);
                $port2 = (($dport & 0xff00) >>8) | (($dport & 0xff) << 8);
                printf("sport:%d  dport:%d\n", $port, $port2);
                // trace结束,重置全局变量。
                @addr = (struct sk_buff *)0;
        }
}

啊哈,我觉得这是一个让人感觉很顺畅的脚本,skb在进入wg_xmit前打上mark,在wg_xmit的过程中清除skb的mark之前将其地址保存,此后跟踪该地址的skb。然而悲哀的是,skb顺利发送出去了,我一无所获,然而悲哀的是,内层的报文在对端wireguard的wg网卡上没有抓到。

去对端反着来一遍且OK?思路是一回事,落地是另一回事。

怎么才能在对端继续trace这个skb呢?

如果没有办法trace这个skb,你怎么区分这个报文是被中间网络设备drop了还是被对端wireguard接收过程drop了呢?由于发送端已经可以获取内层和外层的特定五元组,在接收端用外层五元组去match外层协议头当然是一个正确的思路,问题是如果外层隧道的五元组被大流量复用,你又将如何在skb解密前去匹配内层五元组,流量实在太大了,就像很多抓包由于流量大无法进行一样,你想要的信息几乎会被瞬间淹没!

我想知道的是,bpftrace怎么来做这件事。如果不能方便快捷地解决这个问题,我就有充分的理由使用旧时代的旧事物了。

问题是bpftrace不允许我修改skb啊!现在,我决定扔掉bpftrace,用stap来做正确的事。

我需要做的仅仅是,为特定的数据包打上一个标记,该标记必须在对端可以被识别。我决定使用无伤大雅的IP头TTL字段,使用stap完成这件事非常简单。顺便地,我将为skb打mark这件事也用stap来做,于是我也删掉了iptables规则:

#!/usr/local/bin/stap -g

%{
 
#include 
#include 
#include 
#include 

struct sk_buff *tmp = NULL;
%}

function getinfo(pskb:long)
%{
 
        struct sk_buff *skb = (struct sk_buff *)STAP_ARG_pskb;
        struct iphdr *hdr = ip_hdr(skb);
        const struct skb_shared_info *shinfo = skb_shinfo(skb);
        struct dst_entry *dst = skb_dst(skb);


        if (dst && !strcmp("wg0", dst->dev->name)) {
 
                struct tcphdr *tp = (struct tcphdr *)((char *)hdr + sizeof(struct iphdr));
                if (ntohs(tp->source) == 443 && ntohs(tp->dest) == 3663) {
 
                        STAP_PRINTF("sport:%d dport:%d addr:%p\n", ntohs(tp->source), ntohs(tp->dest), skb);
                        skb->mark = 1234;
                        tmp = skb;
                }
        }
%}

probe kernel.function("ip_forward")
{
 
        getinfo($skb)
}

function setttl(pskb:long)
%{
 
        struct sk_buff *skb = (struct sk_buff *)STAP_ARG_pskb;
        struct iphdr *hdr;

        if (skb == NULL)
                return;

        hdr = ip_hdr(skb);
        if (skb == tmp) {
 
                hdr->ttl = 120;
                tmp = NULL;
        }

%}

// 这个是在确认了skb顺利通过了wg server后,为了在wg client端跟踪所使用的。
// 由于数据包没有什么好的字段可利用来打标,就随手选了ttl,作为一个特殊值,不一定是120,180更好。。。
probe kernel.function("ip_local_out")
{
 
        setttl($skb)
}

如此一个脚本,在对端直接match外层五元组和TTL值就行了,我们只需要在匹配外层五元组的同时,匹配TTL值大于70的skb即可。bpftrace只能读不能写,为了让这件事成为可能,我只能用stap。

以上就是我的态度,我不是不接受bpftrace,我更不是不接受新事物,我只是想说不能在接受新事物的时候把旧事物一棍子打死!不能因为eBPF的流行就选择bpftrace而把stap丢进垃圾桶。



我正准备吐槽,然而我正准备喷bpftrace做不到某某事情的时候,我血祭了!

我明明可以在wireguard的数据接收端像数据发送端一样用bpftrace来trace这个特定的skb,毕竟我只是想知道它在哪里被drop了,这完全没有写操作的必要。然而我为了一种假装成格调的态度,死活非要用stap来完成这件事而放弃bpftrace。

虽然我自以为自己是stap的熟练工,但我却几乎都是在用-g的guru模式,不是因为我自信,而是因为我搞不清楚又记不住stap的语法。我几乎只会C和汇编,我几乎总是记不住任何其它语言的语法,包括Bash在内…

我在使用stap来trace内核或者模块的函数前,我总会看下它的参数解析情况:

stap -L 'module("wireguard").function("wg_allowedips_lookup_src")'⁣

很不幸,无法使用任何参数,原因未知。因此当我希望使用它的参数的时候,我只能裸取寄存器了,像下面这样:

...
probe module("wireguard").function("wg_allowedips_lookup_src")
{
 
        // 由于stap -L无法解析参数,只能用x86_64的调用规则直接取寄存器
        if (cmpskb(register("rsi"))) {
 
                a = 1;
        }
}

probe module("wireguard").function("wg_allowedips_lookup_src").return
{
 
        if (a == 1) {
 
                printf("peer returned::%p\n", register("rax"));
                a = 0;
        }
}...

既然都用stap了,为何不让事情简单一些呢?于是我就开始了自信满满的写操作。改skb内存,手工修改skb的data,以期望能bypass掉很多不必要的流程。



在我持续这么玩了大概几个小时后,大概就是开着飞机修引擎的感觉,我有些疲劳,大概在某个精确的时间点,crash or soft lockup,完美的完成了血祭!

这是一次线上作业,锅显然是我的。

这不能说明我的手艺不精湛,在这种硬着陆之前,我毕竟还放飞了几个小时呢,但这说明一个问题,bpftrace就是比stap好,至少安全。而这恰恰是我要反驳的观点,却被我证明了。
在稳定性方面,eBPF的两个不允许就够了:

  • eBPF不允许你写任何有潜在风险的代码。
  • eBPF不允许你写任何复杂的代码。

stap里你可以随便一个while(true)而把系统锁死,bpftrace中却不行。

处理类似的问题时,其实我是带有偏见的,我不喜欢使用工具特别是不喜欢使用新工具的原因背后更多的是因为我比较懒,我不喜欢面对和驾驭一大坨不相关的东西,比方说我明明知道有个dropwatch却没有使用,就是因为它太复杂了,还要去了解那么复杂的命令行,与其这样,还不如我直接stap probe kfree_skb然后dump_stack呢。
一开始我对stap也是抵触的,因为它也是足够复杂,我宁愿裸写ftrace函数,比方说手工把一个函数的头5个字节替换成call stub_handler这种。如今即便我对stap已经轻车熟路了,我依然还是坚持只写guru模式的脚本,我依然还是懒得去学习stap的语法。
使用工具提高效率那是针对熟悉这种工具的人来讲的,对于不熟悉该工具的来讲,比如我,花在学习这种工具的使用方法上的时间将让我延迟对真正问题的处理。
同样,磨刀不误砍柴工,工欲善其事必先利其器,我并不赞同这是普适的,这种话术是针对频繁解决同类问题的人来讲的,他们需要的是总结出一种范式,锤子能钉钉子,如果你需要频繁钉钉子,你当然需要买一把锤子,但如果你只需要钉一次钉子,随手拿起边上的一块砖或许比去买一把锤子更方便。
如果你每次玩的都是新花样,当然不需要工具了。

此外
“在路上”

的价值观在我针对工具的观点也起到了推波助澜的作用。我一向觉得自己是在路上的人,因此我讨厌任何累赘,我不回去携带,背负,记忆那些随处可以得到的东西。杭州往上海搬家,既然上海可以买到被子,我何必要把被子寄回来,直接扔掉不是更好吗?
在石器时代,人们就已经拥有了类别丰富的工具,但对于一个母系时代净身出户的男人而言,唯一可以带走并且他们自愿选择的工具就是弓箭,弹弓这种类似的远程攻击工具,可能就连刀,斧之类的,都属于累赘,抓住问题的本质本身,这就足够了。
浙江温州皮鞋湿,下雨进水不会胖!