关于webassembly:在-WebAssembly-中使用-CC-和-libbpf-编写-eBPF-程序

33次阅读

共计 11320 个字符,预计需要花费 29 分钟才能阅读完成。

作者:于桐,郑昱笙

eBPF(extended Berkeley Packet Filter)是一种高性能的内核虚拟机,能够运行在内核空间中,用来收集零碎和网络信息。随着计算机技术的一直倒退,eBPF 的性能日益弱小,进而被用来构建各种效率高效的在线诊断和跟踪零碎,以及平安的网络和服务网格。

WebAssembly(Wasm)最后是以浏览器平安沙盒为目标开发的,倒退到目前为止,WebAssembly 曾经成为一个用于云原生软件组件的高性能、跨平台和多语言软件沙箱环境,Wasm 轻量级容器也非常适合作为下一代无服务器平台运行时,或在边缘计算等资源受限的场景高效执行。

当初,借助 Wasm-bpf 编译工具链和运行时,咱们能够应用 Wasm 将 eBPF 程序编写为跨平台的模块,同时应用 C/C++ 或 Rust 来编写 Wasm 程序。通过在 WebAssembly 中应用 eBPF 程序,咱们不仅能让 Wasm 利用享受到 eBPF 的高性能和对系统接口的拜访能力,还能够让 eBPF 程序应用到 Wasm 的沙箱、灵活性、跨平台性、和动静加载,并且应用 Wasm 的 OCI 镜像来不便、快捷地散发和治理 eBPF 程序。联合这两种技术,咱们将会给 eBPF 和 Wasm 生态来一个全新的开发体验!

应用 Wasm-bpf 工具链在 Wasm 中编写、动静加载、散发运行 eBPF 程序

Wasm-bpf 是一个全新的开源我的项目:https://github.com/eunomia-bp…。它定义了一套 eBPF 相干零碎接口的形象,并提供了一套对应的开发工具链、库以及通用的 Wasm + eBPF 运行时实例。它能够提供和 libbpf-bootstrap 类似的开发体验,主动生成对应的 skeleton 头文件,以及用于在 Wasm 和 eBPF 之间无序列化通信的数据结构定义。你能够非常容易地应用任何语言,在任何平台上建设你本人的 Wasm-eBPF 运行时,应用雷同的工具链来构建利用。更具体的介绍,请参考咱们的上一篇博客:Wasm-bpf: 架起 Webassembly 和 eBPF 内核可编程的桥梁。

基于 Wasm,咱们能够应用多种语言构建 eBPF 利用,并以对立、轻量级的形式治理和公布。以咱们构建的示例利用 bootstrap.wasm 为例,大小仅为 ~90K,很容易通过网络散发,并能够在不到 100ms 的工夫外在另一台机器上动静部署、加载和运行,并且保留轻量级容器的隔离个性。运行时不须要内核头文件、LLVM、clang 等依赖,也不须要做任何耗费资源的重量级的编译工作。

本文将以 C/C++ 语言为例,探讨 C/C++ 编写 eBPF 程序并编译为 Wasm 模块。应用 Rust 语言编写 eBPF 程序并编译为 Wasm 模块的具体示例,将在下一篇文章中形容。

咱们在仓库中提供了几个示例程序,别离对应于可观测、网络、平安等多种场景。

应用 C/C++ 编写 eBPF 程序并编译为 Wasm

libbpf 是一个 C/C++ 的 eBPF 用户态加载和管制库,随着内核一起散发,简直曾经成为 eBPF 用户态事实上的 API 规范,libbpf 也反对 CO-RE(Compile Once – Run Everywhere) 的解决方案,即预编译的 bpf 代码能够在不同内核版本上失常工作,而无需为每个特定内核从新编译。咱们心愿尽可能的放弃与 libbpf 的用户态 API 以及行为统一,尽可能减少利用迁徙到 Wasm(如果需要的话)的老本。

libbpf-bootstrap 为生成基于 libbpf 的 bpf 程序提供了模板, 开发者能够很不便的应用该模板生成自定义的 bpf 程序。一般说来,在非 Wasm 沙箱的用户态空间,应用 libbpf-bootstrap 脚手架,能够疾速、轻松地应用 C/C++ 构建 BPF 应用程序。

编译、构建和运行 eBPF 程序(无论是采纳什么语言),通常蕴含以下几个步骤:

  • 编写内核态 eBPF 程序的代码,个别应用 C/C++ 或 Rust 语言
  • 应用 clang 编译器或者相干工具链编译 eBPF 程序(要实现跨内核版本移植的话,须要蕴含 BTF 信息)。
  • 在用户态的开发程序中,编写对应的加载、管制、挂载、数据处理逻辑;
  • 在理论运行的阶段,从用户态将 eBPF 程序加载进入内核,并理论执行。

bootstrap

bootstrap 是一个简略(但实用)的 BPF 应用程序的例子。它跟踪过程的启动(精确地说,是 exec() 系列的零碎调用)和退出,并发送对于文件名、PID 和 父 PID 的数据,以及退出状态和过程的持续时间。用 -d <min-duration-ms> 你能够指定要记录的过程的最小持续时间。

bootstrap 是在 libbpf-bootstrap 中,依据 BCC 软件包中的 libbpf-tools 的相似思维创立的,但它被设计成更独立的,并且有更简略的 Makefile 以简化用户的非凡需要。它演示了典型的 BPF 个性,蕴含应用多个 BPF 程序段进行单干,应用 BPF map 来保护状态,应用 BPF ring buffer 来发送数据到用户空间,以及应用全局变量来参数化应用程序行为。

以下是咱们应用 Wasm 编译运行 bootstrap 的一个输入示例:

$ sudo sudo ./wasm-bpf bootstrap.wasm -h
BPF bootstrap demo application.

It traces process start and exits and shows associated
information (filename, process duration, PID and PPID, etc).

USAGE: ./bootstrap [-d <min-duration-ms>] -v
$ sudo ./wasm-bpf bootstrap.wasm
TIME     EVENT COMM             PID     PPID    FILENAME/EXIT CODE
18:57:58 EXEC  sed              74911   74910   /usr/bin/sed
18:57:58 EXIT  sed              74911   74910   [0] (2ms)
18:57:58 EXIT  cat              74912   74910   [0] (0ms)
18:57:58 EXEC  cat              74913   74910   /usr/bin/cat
18:57:59 EXIT  cat              74913   74910   [0] (0ms)
18:57:59 EXEC  cat              74914   74910   /usr/bin/cat
18:57:59 EXIT  cat              74914   74910   [0] (0ms)
18:57:59 EXEC  cat              74915   74910   /usr/bin/cat
18:57:59 EXIT  cat              74915   74910   [0] (1ms)
18:57:59 EXEC  sleep            74916   74910   /usr/bin/sleep

咱们能够提供与 libbpf-bootstrap 开发类似的开发体验。只需运行 make 即可构建 wasm 二进制文件:

git clone https://github.com/eunomia-bpf/wasm-bpf --recursive
cd examples/bootstrap
make

编写内核态的 eBPF 程序

要构建一个残缺的 eBPF 程序,首先要编写内核态的 bpf 代码。通常应用 C 语言编写,并应用 clang 实现编译:

char LICENSE[] SEC("license") = "Dual BSD/GPL";

struct {__uint(type, BPF_MAP_TYPE_HASH);
    __uint(max_entries, 8192);
    __type(key, pid_t);
    __type(value, u64);
} exec_start SEC(".maps");

struct {__uint(type, BPF_MAP_TYPE_RINGBUF);
    __uint(max_entries, 256 * 1024);
} rb SEC(".maps");

const volatile unsigned long long min_duration_ns = 0;
const volatile int *name_ptr;

SEC("tp/sched/sched_process_exec")
int handle_exec(struct trace_event_raw_sched_process_exec *ctx)
{
    struct task_struct *task;
    unsigned fname_off;
    struct event *e;
    pid_t pid;
    u64 ts;
....

受篇幅所限,这里没有贴出残缺的代码。内核态代码的编写形式和其余基于 libbpf 的程序完全相同,一般来说会蕴含一些全局变量,通过 SEC 申明挂载点的 eBPF 函数,以及用于保留状态,或者在用户态和内核态之间互相通信的 map 对象(咱们还在进行另外一项工作:bcc to libbpf converter,等它实现后就能够以这种形式编译 BCC 格调的 eBPF 内核态程序)。在编写完 eBPF 程序之后,运行 make 会在 Makefile 调用 clang 和 llvm-strip 构建 BPF 程序,以剥离调试信息:

clang -g -O2 -target bpf -D__TARGET_ARCH_x86 -I../../third_party/vmlinux/x86/ -idirafter /usr/local/include -idirafter /usr/include -c bootstrap.bpf.c -o bootstrap.bpf.o
llvm-strip -g bootstrap.bpf.o # strip useless DWARF info

之后,咱们会提供一个为了 Wasm 专门实现的 bpftool,用于从 BPF 程序生成 C 头文件:

../../third_party/bpftool/src/bpftool gen skeleton -j bootstrap.bpf.o > bootstrap.skel.h

因为 eBPF 自身的所有 C 内存布局是和以后所在机器的指令集一样的,然而 wasm 是有一套确定的内存布局(比方以后所在机器是 64 位的,Wasm 虚拟机外面是 32 位的,C struct layout、指针宽度、大小端等等都可能不一样),为了确保 eBPF 程序能正确和 Wasm 之间进行互相通信,咱们须要定制一个专门的 bpftool 等工具,实现正确生成能够在 Wasm 中工作的用户态开发框架。

skel 蕴含一个 BPF 程序的 skeleton,用于操作 BPF 对象,并管制 BPF 程序的生命周期,例如:

    struct bootstrap_bpf {
        struct bpf_object_skeleton *skeleton;
        struct bpf_object *obj;
        struct {
            struct bpf_map *exec_start;
            struct bpf_map *rb;
            struct bpf_map *rodata;
        } maps;
        struct {
            struct bpf_program *handle_exec;
            struct bpf_program *handle_exit;
        } progs;
        struct bootstrap_bpf__rodata {unsigned long long min_duration_ns;} *rodata;
        struct bootstrap_bpf__bss {uint64_t /* pointer */ name_ptr;} *bss;
    };

咱们会将所有指针都将依据 eBPF 程序指标所在的指令集的指针大小转换为整数,例如,name_ptr。此外,填充字节将明确增加到构造体中以确保构造体布局与指标端雷同,例如应用 char __pad0[4];。咱们还会应用 static_assert 来确保构造体的内存长度和原先 BTF 信息中的类型长度雷同。

构建用户态的 Wasm 代码,并获取内核态数据

咱们默认应用 wasi-sdk 从 C/C++ 代码构建 wasm 二进制文件。您也能够应用 emcc 工具链来构建 wasm 二进制文件,命令应该是类似的。您能够运行以下命令来装置 wasi-sdk:

wget https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-17/wasi-sdk-17.0-linux.tar.gz
tar -zxf wasi-sdk-17.0-linux.tar.gz
sudo mkdir -p /opt/wasi-sdk/ && sudo mv wasi-sdk-17.0/* /opt/wasi-sdk/

而后运行 make 会在 Makefile 中应用 wasi-clang 编译 C 代码,生成 Wasm 字节码:

/opt/wasi-sdk/bin/clang -O2 --sysroot=/opt/wasi-sdk/share/wasi-sysroot -Wl,--allow-undefined -o bootstrap.wasm bootstrap.c

因为宿主机(或 eBPF 端)的 C 构造布局可能与指标(Wasm 端)的构造布局不同,因而您能够应用 ecc 和咱们的 wasm-bpftool 生成用户空间代码的 C 头文件:

ecc bootstrap.h --header-only
../../third_party/bpftool/src/bpftool btf dump file bootstrap.bpf.o format c -j > bootstrap.wasm.h

例如,原先内核态的头文件中构造体定义如下:

struct event {
    int pid;
    int ppid;
    unsigned exit_code;
    unsigned long long duration_ns;
    char comm[TASK_COMM_LEN];
    char filename[MAX_FILENAME_LEN];
    char exit_event;
};

咱们的工具会将其转换为:

struct event {
    int pid;
    int ppid;
    unsigned int exit_code;
    char __pad0[4];
    unsigned long long duration_ns;
    char comm[16];
    char filename[127];
    char exit_event;
} __attribute__((packed));
static_assert(sizeof(struct event) == 168, "Size of event is not 168");

留神:此过程和工具并不总是必须的,对于简略的利用,你能够手动实现。 对于内核态和 Wasm 利用都应用 C/C++ 语言的状况下,你能够手动编写所有事件构造体定义,应用 __attribute__((packed)) 防止填充字节,并在主机和 wasm 端之间转换所有指针为正确的整数。所有类型必须在 wasm 中定义与主机端雷同的大小和布局。

对于简单的程序,手动确认内存布局的正确是分艰难,因而咱们创立了 wasm 特定的 bpftool,用于从 BTF 信息中生成蕴含所有类型定义和正确构造体布局的 C 头文件,以便用户空间代码应用。能够通过相似的计划,一次性将 eBPF 程序中所有的构造体定义转换为 Wasm 端的内存布局,并确保大小端统一,即可正确拜访。

对于 Wasm 中不是由 C 语言进行开发的状况下,借助 Wasm 的组件模型,咱们还能够将这些 BTF 信息结构体定义作为 wit 类型申明输入,而后在用户空间代码中应用 wit-bindgen 工具一次性生成多种语言(如 C/C++/Rust/Go)的类型定义。这部分会在对于如何应用 Rust 在 Wasm 中编写 eBPF 程序的局部详细描述,咱们也会将这些步骤和工具链持续欠缺,以改良 Wasm-bpf 程序的编程体验。

咱们为 wasm 程序提供了一个仅蕴含头文件的 libbpf API 库,您能够在 libbpf-wasm.h(wasm-include/libbpf-wasm.h)中找到它,它蕴含了一部分 libbpf 罕用的用户态 API 和类型定义。Wasm 程序能够应用 libbpf API 操作 BPF 对象,例如:

/* Load and verify BPF application */
skel = bootstrap_bpf__open();
/* Parameterize BPF code with minimum duration parameter */
skel->rodata->min_duration_ns = env.min_duration_ms * 1000000ULL;
/* Load & verify BPF programs */
err = bootstrap_bpf__load(skel);
/* Attach tracepoints */
err = bootstrap_bpf__attach(skel);

rodata 局部用于存储 BPF 程序中的常量,这些值将在 bpftool gen skeleton 的时候由代码生成映射到 object 中正确的偏移量, 而后在 open 之后通过内存映射批改对应的值,因而不须要在 Wasm 中编译 libelf 库,运行时仍可动静加载和操作 BPF 对象。

Wasm 端的 C 代码与本地 libbpf 代码略有不同,但它能够从 eBPF 端提供大部分性能,例如,从环形缓冲区或 perf 缓冲区轮询,从 Wasm 端和 eBPF 端拜访映射,加载、附加和拆散 BPF 程序等。它能够反对大量的 eBPF 程序类型和映射,涵盖从跟踪、网络、平安等方面的大多数 eBPF 程序的应用场景。

因为 Wasm 端短少一些性能,例如 signal handler 还不反对(2023 年 2 月),原始的 C 代码有可能无奈间接编译为 wasm,您须要略微批改代码以使其工作。咱们将尽最大致力使 wasm 端的 libbpf API 与通常在用户空间运行的 libbpf API 尽可能类似,以便用户空间代码能够在将来间接编译为 wasm。咱们还将尽快提供更多语言绑定(Go 等)的 wasm 侧 eBPF 程序开发库。

能够在用户态程序中应用 polling API 获取内核态上传的数据。它将是 ring buffer 和 perf buffer 的一个封装,用户空间代码能够应用雷同的 API 从环形缓冲区或性能缓冲区中轮询事件,具体取决于 BPF 程序中指定的类型。例如,环形缓冲区轮询定义为 BPF_MAP_TYPE_RINGBUF

struct {__uint(type, BPF_MAP_TYPE_RINGBUF);
    __uint(max_entries, 256 * 1024);
} rb SEC(".maps");

你能够在用户态应用以下代码从 ring buffer 中轮询事件:

rb = bpf_buffer__open(skel->maps.rb, handle_event, NULL);
/* Process events */
printf("%-8s %-5s %-16s %-7s %-7s %s\n", "TIME", "EVENT", "COMM", "PID",
       "PPID", "FILENAME/EXIT CODE");
while (!exiting) {
    // poll buffer
    err = bpf_buffer__poll(rb, 100 /* timeout, ms */);

ring buffer polling 不须要序列化开销。bpf_buffer__poll API 将调用 handle_event 回调函数来解决环形缓冲区中的事件数据:


static int
handle_event(void *ctx, void *data, size_t data_sz)
{
    const struct event *e = data;
    ...
    if (e->exit_event) {printf("%-8s %-5s %-16s %-7d %-7d [%u]", ts, "EXIT", e->comm, e->pid,
               e->ppid, e->exit_code);
        if (e->duration_ns)
            printf("(%llums)", e->duration_ns / 1000000);
        printf("\n");
    }
    ...
    return 0;
}

运行时基于 libbpf CO-RE(Compile Once, Run Everywhere)API,用于将 bpf 对象加载到内核中,因而 wasm-bpf 程序不受它编译的内核版本的影响,能够在任何反对 BPF CO-RE 的内核版本上运行。

从用户态程序中拜访和更新 eBPF 程序的 map 数据

runqlat 是一个更简单的示例,这个程序通过直方图展现调度器运行队列提早,给咱们展示了工作等了多久能力运行。

$ sudo ./wasm-bpf runqlat.wasm -h
Summarize run queue (scheduler) latency as a histogram.

USAGE: runqlat [--help] [interval] [count]

EXAMPLES:
    runqlat         # summarize run queue latency as a histogram
    runqlat 1 10    # print 1 second summaries, 10 times
$ sudo ./wasm-bpf runqlat.wasm 1

Tracing run queue latency... Hit Ctrl-C to end.

     usecs               : count    distribution
         0 -> 1          : 72       |*****************************           |
         2 -> 3          : 93       |*************************************   |
         4 -> 7          : 98       |****************************************|
         8 -> 15         : 96       |*************************************** |
        16 -> 31         : 38       |***************                         |
        32 -> 63         : 4        |*                                       |
        64 -> 127        : 5        |**                                      |
       128 -> 255        : 6        |**                                      |
       256 -> 511        : 0        |                                        |
       512 -> 1023       : 0        |                                        |
      1024 -> 2047       : 0        |                                        |
      2048 -> 4095       : 1        |                                        |

runqlat 中应用 map API 来从用户态拜访内核里的 map 并间接读取数据,例如:

    while (!bpf_map_get_next_key(fd, &lookup_key, &next_key)) {err = bpf_map_lookup_elem(fd, &next_key, &hist);
        ...
        lookup_key = next_key;
    }
    lookup_key = -2;
    while (!bpf_map_get_next_key(fd, &lookup_key, &next_key)) {err = bpf_map_delete_elem(fd, &next_key);
        ...
        lookup_key = next_key;
    }

运行时 wasm 代码将会应用共享内存来拜访内核 map,内核态能够间接把数据拷贝到用户态 Wasm 虚拟机的堆栈中,而不须要面对用户态主机侧程序和 Wasm 运行时之间的额定拷贝开销。同样,对于 Wasm 虚拟机和内核态之间共享的类型定义,须要通过仔细检查以确保它们在 Wasm 和内核态中的类型是统一的。

能够应用 bpf_map_update_elem 在用户态程序内更新内核的 eBPF map,比方:

        cg_map_fd = bpf_map__fd(obj->maps.cgroup_map);
        cgfd = open(env.cgroupspath, O_RDONLY);
        if (cgfd < 0) {...}
        if (bpf_map_update_elem(cg_map_fd, &idx, &cgfd, BPF_ANY)) {...}

因而内核的 eBPF 程序能够从 Wasm 侧的程序获取配置,或者在运行的时候接管音讯。

更多的例子:socket filter 和 lsm

在仓库中,咱们还提供了更多的示例,例如应用 socket filter 监控和过滤数据包:

SEC("socket")
int socket_handler(struct __sk_buff *skb)
{
    struct so_event *e;
    __u8 verlen;
    __u16 proto;
    __u32 nhoff = ETH_HLEN;

    bpf_skb_load_bytes(skb, 12, &proto, 2);
    ...

    bpf_skb_load_bytes(skb, nhoff + 0, &verlen, 1);
    bpf_skb_load_bytes(skb, nhoff + ((verlen & 0xF) << 2), &(e->ports), 4);
    e->pkt_type = skb->pkt_type;
    e->ifindex = skb->ifindex;
    bpf_ringbuf_submit(e, 0);

    return skb->len;
}

Linux Security Modules(LSM)是一个基于钩子的框架,用于在 Linux 内核中实现安全策略和强制访问控制。直到现在,可能实现施行安全策略指标的形式只有两种抉择,配置现有的 LSM 模块(如 AppArmor、SELinux),或编写自定义内核模块。

Linux Kernel 5.7 引入了第三种形式:LSM eBPF。LSM BPF 容许开发人员编写自定义策略,而无需配置或加载内核模块。LSM BPF 程序在加载时被验证,而后在调用门路中,达到 LSM 钩子时被执行。例如,咱们能够在 Wasm 轻量级容器中,应用 lsm 限度文件系统操作:

// all lsm the hook point refer https://www.kernel.org/doc/html/v5.2/security/LSM.html
SEC("lsm/path_rmdir")
int path_rmdir(const struct path *dir, struct dentry *dentry) {char comm[16];
  bpf_get_current_comm(comm, sizeof(comm));
  unsigned char dir_name[] = "can_not_rm";
  unsigned char d_iname[32];
  bpf_probe_read_kernel(&d_iname[0], sizeof(d_iname),
                        &(dir->dentry->d_iname[0]));

  bpf_printk("comm %s try to rmdir %s", comm, d_iname);
  for (int i = 0;i<sizeof(dir_name);i++){if (d_iname[i]!=dir_name[i]){return 0;}
  }
  return -1;
}

总结

本以 C/C++ 语言为例,探讨了如何应用 C/C++ 编写 eBPF 程序并编译为 Wasm 模块。更残缺的代码,请参考咱们的 Github 仓库:https://github.com/eunomia-bp….

在下一篇文章中,咱们会探讨应用 Rust 编写 eBPF 程序并编译为 Wasm 模块,并应用 OCI 镜像公布、部署、治理 eBPF 程序,取得相似 Docker 的体验。

接下来,咱们也会持续欠缺在 Wasm 中应用多种语言开发和运行 eBPF 程序的体验,提供更欠缺的示例和用户态开发库 / 工具链,以及更具体的利用场景。

参考资料

  • wasm-bpf Github 开源地址:https://github.com/eunomia-bp…
  • 什么是 eBPF:https://ebpf.io/what-is-ebpf
  • WASI-eBPF: https://github.com/WebAssembl…
  • 龙蜥社区 eBPF 技术摸索 SIG https://openanolis.cn/sig/ebp…
  • eunomia-bpf 我的项目:https://github.com/eunomia-bp…
  • eunomia-bpf 我的项目龙蜥 Gitee 镜像:https://gitee.com/anolis/eunomia
  • Wasm-bpf: 架起 Webassembly 和 eBPF 内核可编程的桥梁:https://mp.weixin.qq.com/s/2I…
  • 当 WASM 遇见 eBPF:应用 WebAssembly 编写、散发、加载运行 eBPF 程序:https://zhuanlan.zhihu.com/p/…
  • 教你应用 eBPF LSM 热修复 Linux 内核破绽:https://www.bilibili.com/read…

正文完
 0