8

eBPF Up & Running: Overview

 3 years ago
source link: https://kernel.taobao.org/2020/07/eBPF-Up-and-Running-Overview/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

Jul 9, 2020 • 荣旸

eBPF Up & Running: Overview

eBPF 技术是近几年 Linux 社区一颗闪亮的明星,如果你还没有开始接触并编写过 eBPF 程序,那么不要错过,接下来的一系列文章非常适合你。

eBPF Up & Running 系列文章目前主要包括下面三篇:

  • Overview 总览,你的第一个 eBPF 程序将会在这里诞生;
  • Tracing 跟踪,eBPF 如何在 tracing 场景大展身手;
  • Network 网络,如何使用 eBPF 增强内核网络;

接下来会随着 eBPF 的发展和大家的反响情况,不断丰富扩展更多的文章。废话不多说,让我们开始接下来的干货时间。

在写第一行代码之前,首先让我们了解一下 eBPF 技术前世今生。

回到 1992 年,eBPF 的前身 BPF (Berkeley Packet Filter),现在被成为 cBPF (classic BPF) ,主要用于 tcpdump 和 seccomp,场景无外乎是一种灵活的 DSL + 虚拟机帮助用户筛选数据。用户的过滤条件会被编译为 cBPF 程序,程序由预先定义的指令组成,这些指令在内核中解释运行,并通过 JIT 加速执行速度,每当数据经过时,会触发程序执行并判断是否满足筛选条件。

时间很快来到了 2011 年,eBPF 在这一年诞生了。经过社区的不断迭代和完善,eBPF 在各个方面得到增强:

  1. 定义了全新的指令架构,复用了 cBPF 的 opcode 格式和命令,并引入了大量的寄存器;
  2. LLVM 后端支持 BPF target,可以将 C 程序编译为 eBPF 字节码并在内核中运行;
  3. 引入了 bpf.h 等头文件的支持和丰富的 helpers 函数,简化了 eBPF 程序的编写;

eBPF 是如何在内核中的执行的?eBPF Internal: Instructions and Runtime 文章中有更详细的介绍,下图展示了 eBPF 程序的大体执行流程。

image.png

其实 eBPF 本身其实远没有想象中的复杂,我们可以把内核想象成一个庞大而复杂的电路,如果电路出现了异常,我们可以通过使用万用表测量电路,如果当前电路不能满足需求,但又不能推倒重新设计,我们需要串入其他元件。eBPF 之于电路,既是万用表,也是各种功能元件。再回到内核中的 eBPF,内核预先在各个关键路径埋设了 eBPF 程序入口,用户可以编写不同类型的 eBPF 程序,将 eBPF 程序 attach 在内核中不同路径中执行。

示例:流量协议统计

搭建脚手架

在编写第一个程序之前,我们需要准备一个顺手的开发环境。内核源码仓库 samples/bpf 目录下包含了数十个典型的 eBPF 示例程序,除了可供学习和参考之外,我们可以将程序代码放在这个目录,无需自己额外花时间编写和调试 Makefile。

eBPF 程序本质上是一种字节码,我们只需将编写的代码编译成 eBPF 字节码,即可在内核的 eBPF 虚拟机中运行。理论上我们可以使用各种现有的语言编写 eBPF 程序,只要确保这门语言具有对应的 LLVM 前端,帮助我们将其翻译为 LLVM IR 中间代码,并通过 LLVM 最终编译为 eBPF 字节码。通常情况下,推荐使用 C 进行编写,当前内核提供了完备的 C library。因此我们需要确保 clang 已经正确配置好,如果已经按照上面步骤正确配置了脚手架,我们无需再配置 clang 和 LLVM。

对于大部分场景下 eBPF 编程模型,eBPF 程序不是单独出现,而是由用户态控制平面 control plane 和内核态数据平面 data plane 两部分组成,经典的数据面和控制面分离。

接下来我们将以 samples/bpf/{xdp1_kern.c,xdp1_user.c} 程序为例,这个示例不只是简单的打印,它可以统计网卡的数据报文的协议分布数量。

首先是内核态的 eBPF 代码:xdp1_kern.c,主要功能是从数据报文中解析并判断协议类型,统计数据报文中不同协议的数量分布情况。

/* 其他省略,完整源码请参考 samples/bpf/xdp1_kern.c */

/* 定义数据结构存放统计信息 */
struct bpf_map_def SEC("maps") rxcnt = {
        .type = BPF_MAP_TYPE_PERCPU_ARRAY,
        .key_size = sizeof(u32),
        .value_size = sizeof(long),
        .max_entries = 256,
};

SEC("xdp1")
/* eBPF XDP 类型程序的特定函数签名,ctx 包含数据包的上下文信息 */
int xdp_prog1(struct xdp_md *ctx)
{
        /* 报文结束位置 */
        void *data_end = (void *)(long)ctx->data_end;
        /* 报文开始位置 */
        void *data = (void *)(long)ctx->data;
        struct ethhdr *eth = data;
        long *value;
        u16 h_proto;
        u64 nh_off;
        u32 ipproto;

        nh_off = sizeof(*eth);
        if (data + nh_off > data_end)
		        /* 对于非法报文直接返回 XDP_DROP 并丢弃 */
                return XDP_DROP;

        h_proto = eth->h_proto;

        /* 接下来会根据数据报文依次解析协议类型 */
        if (h_proto == htons(ETH_P_8021Q) || h_proto == htons(ETH_P_8021AD)) {
                struct vlan_hdr *vhdr;

                vhdr = data + nh_off;
                nh_off += sizeof(struct vlan_hdr);
		        /* 判断数据包是否合法,否则直接丢弃 */
                if (data + nh_off > data_end)
                        return XDP_DROP;
                h_proto = vhdr->h_vlan_encapsulated_proto;
        }
        if (h_proto == htons(ETH_P_8021Q) || h_proto == htons(ETH_P_8021AD)) {
                struct vlan_hdr *vhdr;

                vhdr = data + nh_off;
                nh_off += sizeof(struct vlan_hdr);
		        /* 判断数据包是否合法,否则直接丢弃 */
                if (data + nh_off > data_end)
                        return XDP_DROP;
                h_proto = vhdr->h_vlan_encapsulated_proto;
        }

        if (h_proto == htons(ETH_P_IP))
		        /* 从 payload 中解析 Ipv4 protocol */
                ipproto = parse_ipv4(data, nh_off, data_end);
        else if (h_proto == htons(ETH_P_IPV6))
		        /* 从 payload 中解析 Ipv6 protocol */
                ipproto = parse_ipv6(data, nh_off, data_end);
        else
                ipproto = 0;
  
        /* 统计不同协议的访问次数 */
        value = bpf_map_lookup_elem(&rxcnt, &ipproto);
        if (value)
                *value += 1;

        /* 返回 XDP_PASS 传递报文至下层处理逻辑 */
        return XDP_PASS;
}

接下来是用户态代码:xdp1_user.c,主要功能是载入 eBPF 程序到内核中,并从内核共享的 map 中获得统计信息并展示。

/* 其他省略,完整源码请参考 samples/bpf/xdp1_kern.c */

static int ifindex;
static __u32 xdp_flags;

static void int_exit(int sig)
{
        /* 将 XDP 与网卡解绑 */
        bpf_set_link_xdp_fd(ifindex, -1, xdp_flags);
        exit(0);
}

static void poll_stats(int map_fd, int interval)
{
        unsigned int nr_cpus = bpf_num_possible_cpus();
        const unsigned int nr_keys = 256;
        __u64 values[nr_cpus], prev[nr_keys][nr_cpus];
        __u32 key;
        int i;

        memset(prev, 0, sizeof(prev));

        while (1) {
                sleep(interval);

                for (key = 0; key < nr_keys; key++) {
                        __u64 sum = 0;
                        /* 从 map 中获取统计信息 */
                        assert(bpf_map_lookup_elem(map_fd, &key, values) == 0);
                        for (i = 0; i < nr_cpus; i++)
                                sum += (values[i] - prev[key][i]);
                        if (sum)
                                printf("proto %u: %10llu pkt/s\n",
                                       key, sum / interval);
                        memcpy(prev[key], values, sizeof(values));
                }
        }
}

int main(int argc, char **argv)
{
        struct rlimit r = {RLIM_INFINITY, RLIM_INFINITY};
        struct bpf_prog_load_attr prog_load_attr = {
		       /* 指定程序类型,这里为 XDP */
                .prog_type      = BPF_PROG_TYPE_XDP,
        };
        const char *optstr = "SN";
        int prog_fd, map_fd, opt;
        struct bpf_object *obj;
        struct bpf_map *map;
        char filename[256];

        while ((opt = getopt(argc, argv, optstr)) != -1) {
                switch (opt) {
                case 'S':
					    /* XDP 两种工作模式,第三篇文章会详细展开 */
                        xdp_flags |= XDP_FLAGS_SKB_MODE;
                        break;
                case 'N':
                        /* XDP 两种工作模式,第三篇文章会详细展开 */
                        xdp_flags |= XDP_FLAGS_DRV_MODE;
                        break;
                default:
                        usage(basename(argv[0]));
                        return 1;
                }
        }

        /* 省略不重要代码 */
  
        snprintf(filename, sizeof(filename), "%s_kern.o", argv[0]);
        prog_load_attr.file = filename;

        /* 载入 eBPF 字节码至内核 */
        if (bpf_prog_load_xattr(&prog_load_attr, &obj, &prog_fd))
                return 1;

        map = bpf_map__next(NULL, obj);
        if (!map) {
                printf("finding a map in obj file failed\n");
                return 1;
        }
        /* 获得 eBPF 中定义的数据结构,用以获取统计信息 */
        map_fd = bpf_map__fd(map);

        /* 省略不重要代码 */

        /* 设置 XDP 与网卡的绑定,eBPF 字节码将会在指定网卡收包路径执行  */
        if (bpf_set_link_xdp_fd(ifindex, prog_fd, xdp_flags) < 0) {
                printf("link set xdp fd failed\n");
                return 1;
        }

        /* 定时从 map fd 中轮询统计数据 */
        poll_stats(map_fd, 2);

        return 0;
}

在 Linux 源码仓库根目录执行 make samples/bpf/,编译完成后会在 samples/bpf 目录下会得到用户态可执行文件 xdp1 和 eBPF 字节码文件 xdp1_kern.o,直接在当前目录直接执行即可。如果是自己编写的代码,可以将文件放入 samples/bpf 目录下,并在 Makefile 中加入这对应文件的编译配置。

如果遇到了任何环境或者编译的问题,可以参考内核文档 samples/bpf/README.rst

通过上面的示例程序之后,想必大家已经对如何编写一个 eBPF 程序有了初步的印象。以刚才编写的程序为例,我们可以发现 eBPF 程序包含三个基础的概念:程序类型、数据结构和 helpers。

eBPF 的程序类型 (program type),决定了 eBPF 程序如何载入内核,以及在内核哪些路径执行 eBPF 程序。例如需要 kprobe 跟踪函数执行,则需要在载入时指定 eBPF 程序的类型为 BPF_PROG_TYPE_KPROBE 。对于 XDP 则需要指定为 BPF_PROG_TYPE_XDP

以 XDP 类型 eBPF 程序为例,用户通过 netlink 调用 dev_change_xdp_fd 为指定 dev 设置 eBPF 程序,例如 veth 网卡 XDP 的支持在 4.14 内核引入,通过 veth_xdp_set 为网卡绑定 eBPF 字节码,当 NAPI poll 执行时于每个 XDP frame 依次调用 veth_xdp_rcv_one,并执行对应的 eBPF 程序,根据 eBPF 程序的返回结果决定丢弃、通过或者重定向报文。

全量的程序类型定义在 include/uapi/linux/bpf.h,如需了解详细的程序类型请参考:BCC 文档

对于任何程序而言,都离不开各种各样的数据结构。eBPF 提供了各种常用的数据结构,从而实现内核内部数据的组织,以及用户态和内核态的通信;

当前 eBPF 定义了26中基础的数据结构,涵盖了 hash、stack、array 和 ringbuf。以 hash 为例,可以指定 key、leaf 的数据类型以及大小,同时提供了相应的 helpers 操作函数 BPF_FUNC_map_lookup_elem()BPF_FUNC_map_push_elem() 等。

全量的数据结构定义在 include/uapi/linux/bpf.h,如需了解详细的数据结构列表请参考:BCC 文档

Helpers

如同其他语言生态会提供丰富的 library,eBPF 也包含了各种常用的 helpers 函数,例如打印输出 BPF_FUNC_trace_printk 等;

eBPF 定义的 helpers 函数不仅为了简化复杂的 eBPF 操作,同时也会将部分危险的操作封装成安全的 helpers 函数,对于内核数据结构的访问和操作需要借助 helper 完成,确保了 eBPF 程序的安全性。

全量的 helpers 定义在 include/uapi/linux/bpf.h,详细的 helps 列表请参考:BCC 文档

快速上手的应用

学习并掌握一门技术的最好方式是付诸于实践。现在你的脑海中可能已经迸发出各种各样的想法,迫不及待编写 eBPF 程序验证和实践。为了更方便的去验证和实践,除了上面提到的基于 samples/bpf 示例程序之外,我们还可以基于下面一些社区已有的可供我们快速上手的应用。

BCC 是一个包含丰富的内核跟踪分析的 eBPF 工具集,用户也可以基于 BCC 创建自己的 eBPF 工具。当前 BCC 工具提供了 Python / Lua 和 Go 语言的 binding,用户可以使用这三种语言编写自己的 eBPF 工具。BCC 提供一个非常友好的 tutorial 可供大家快速上手。其中 iovisor/gobpf 库,可以通过 Go 生态将 eBPF 与云原生、k8s 或各种运维工具相结合。

bpftrace

如果使用过 systemtap 动态跟踪分析内核,那么 bpftrace 是一个很好的替代方案。bpftrace 提供了一种类 awk 和 C 的语言,使用 bpftrace 语言编写各种跟踪和分析脚本,并编译成 eBPF 字节码与内核交互,从而实现动态跟踪 Linux 内核。使用文档可以参考:使用 bpftrace 分析内核,和 The bpftrace One-Liner Tutorial

ubpf / generic-ebpf

假如对于 eBPF 字节码和虚拟机非常感兴趣,ubpf 提供了一个用户态实现的虚拟机,包含了解释运行和 JIT 特性。不仅帮助我们更好的理解 eBPF 虚拟机的实现,而且可以将 ubpf 嵌入到应用中,以执行编写好的 eBPF 程序,从而实现 Lua 或 WASM 的功能。generic-ebpf 则更进一步,提供了更完善的运行时机制和库函数,并将 eBPF 作为一种通用的字节码嵌入到交换机等硬件中并运用在生产环境。

eBPF 为内核提供了更多的可能性,社区仍在不断拓宽 eBPF 的场景,通过这篇文章我们了解了如何编写 eBPF 程序,接下来几篇文章将会为大家带来 eBPF 在跟踪和网络方面的特性和应用。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK