lzh2nix / articles

用 issue 来管理个人博客
https://github.com/lzh2nix/articles
62 stars 13 forks source link

ebpf之路(2023版本) #169

Open lzh2nix opened 1 year ago

lzh2nix commented 1 year ago

开篇

之前也零零散散看过一些ebpf相关的知识, 就是没有深入的去学习, 2023 Q2开始计划每个Q在技术上只专注在一个点上(避免不聚焦导致最后一事无成). 花3个月深入的了解一门技术.

Q3计划专注在ebpf上, 整理了三个KR:

Content

lzh2nix commented 1 year ago

Learning eBPF Preface && CH1(2023.07.01)

在各种CNCF,eBPF相关的大会上你可能都会看到本书作者 Liz Rice 的演讲, 她是CNCF的 governing board & TOC emeritus chair, 也是ebpf背后isovalent公司的Chief Open Source Officer. 这么书写的相对也比较简单,适合作为对ebpf的一个入门书籍.

ebpf应该是最近几年内核里最火热的一个模块. 基本上每年都有基于ebpf的新项目产生(主要是和network, security, observability相关的).

BPF大事记:

  1. 1993 Steven McCane 和Van Jacobson 发表了 The BSD Packet Filter: A New Architecture for User-level Packet Capture, 实现了网络的抓包
  2. 2014 3.8 版本中第一次引入了ebpf
  3. 2015 kprobe 加入了内核
  4. 2016 Brendan Gregg做了很多基于ebpf的性能分析工具(superpower to linux) 同年 Cilium 宣布开搞使得ebpf 广为人知
  5. 2017 Facebook基于ebpf实现了4层的负载均衡Katran
  6. 2018 ebpf作为kernel的一个单独模块独立出来了
  7. 2020 允许ebpf attach到LSM(Linux Security Module)使得基于ebpf的各种安全项目成为可能

ebpf 应用变的这么广泛究其原因就是其简化了内核"开发", 在不修改linux内核的情况下也可以对内核做扩展.

Back To Top

lzh2nix commented 1 year ago

CH2(2023.7.4)

所有的编码练习都是以 "hello world" 开始,ebpf 也不例外. 书中例子都是python, 按照个人喜好这里选择golang, 主要有两个原因:

  1. 通过把书中的例子用golang实现一遍会有跟深都认识(提炼出属于自己的ebpf util function)
  2. 除了bcc之外其他项目都是使用golang来实现的, 使用golang更加的契合实际的项目需求

这里使用 cilium/ebpf 作为底层的库. ebpf 编程分为两部分, golang部分(用户层程序)和C部分(ebpf部分), 一个简单的例子如下, 每当发生execve的系统调用时 ebpf程序就打印出 "hello world":

#include "../headers/vmlinux-arm64.h"
#include "../headers/bpf/bpf_helpers.h"

char __license[] SEC("license") = "Dual MIT/GPL";

SEC("kprobe/sys_execve")
int kprobe_execve() {
  bpf_printk("hello world\n");
  return 0;
}

然后通过 $BPF_CLANG -cflags $BPF_CFLAGS bpf hello.c -- -I../headers 生成对于的ebpf字节码, go generate 生成对应的golang程序供上层使用(本例中只是加载ebpf程序到内核).

package main

import (
    "log"
    "os"
    "os/signal"
    "time"

    "github.com/cilium/ebpf/link"
    "github.com/cilium/ebpf/rlimit"
)

// $BPF_CLANG and $BPF_CFLAGS are set by the Makefile.
//go:generate go run github.com/cilium/ebpf/cmd/bpf2go -cc $BPF_CLANG -cflags $BPF_CFLAGS bpf hello.c -- -I../headers

const mapKey uint32 = 0

func main() {

    // Name of the kernel function to trace.
    fn := "sys_execve"

    // Allow the current process to lock memory for eBPF resources.
    if err := rlimit.RemovMemlock(); err != nil {
        log.Fatal(err)
    }

    // Load pre-compiled programs and maps into the kernel.
    objs := bpfObjects{}
    if err := loadBpfObjects(&objs, nil); err != nil {
        log.Fatalf("loading objects: %v", err)
    }
    defer objs.Close()

    // Open a Kprobe at the entry point of the kernel function and attach the
    // pre-compiled program. Each time the kernel function enters, the program
    // will increment the execution counter by 1. The read loop below polls this
    // map value once per second.
    kp, err := link.Kprobe(fn, objs.KprobeExecve, nil)
    if err != nil {
        log.Fatalf("opening kprobe: %s", err)
    }
    defer kp.Close()

    // Read loop reporting the total amount of times the kernel
    // function was entered, once per second.
    ticker := time.NewTicker(1 * time.Second)
    defer ticker.Stop()

    log.Println("Waiting for events..")
    sig := make(chan os.Signal, 1)
    signal.Notify(sig, os.Interrupt)
    <-sig
}

上面代码实现的效果书中python的效果一样, 当发生系统调用是打印hello world. Pasted image 20230707070941

Pasted image 20230707065604

上面程序中bpf程序直接打印到了 /sys/kernel/tracing/trace_pipe. 在实际应用中我们更多的希望这个hello程序和main.go 做一些交互. 这也就促成了各种map(详细列表可以参考 https://docs.kernel.org/bpf/maps.html)的产生(用户程序和ebpf程序沟通的桥梁). ebpf map的三个主要使用场景:

  1. 用户程序将配置信息下发给ebpf程序
  2. 多个ebpf之间map进行通信
  3. ebpf将结果写入到map中供上层应用程序使用

下面就以两个例子来看下ebp map怎么使用,其他类型的map在具体使用的时候查询手册即可.

BPF_MAP_TYPE_HASH

统计进程掉用 execve 的次数, 在bpf里向指定的map里写, 然后在userspace 读写入的值.

// +build ignore

#include "../headers/vmlinux-arm64.h"
#include "../headers/bpf/bpf_helpers.h"

char __license[] SEC("license") = "Dual MIT/GPL";

struct {
    __uint(type, BPF_MAP_TYPE_HASH);
    __type(key, __u32);
    __type(value, __u64);
    __uint(max_entries, 1024);
} counter_map SEC(".maps");

SEC("kprobe/sys_execve")
int kprobe_execve() {
    u32 pid;
    u64 initval = 1, *valp;
    pid = bpf_get_current_pid_tgid() >>32;
    valp = bpf_map_lookup_elem(&counter_map, &pid);
    if (!valp) {
        bpf_map_update_elem(&counter_map, &pid, &initval, BPF_ANY);
        return 0;
    }
    __sync_fetch_and_add(valp, 1);
    return 0;
}

golang 关键部分代码:

    go func() {
        for range ticker.C {
            var k uint32
            var v uint64
            iter := objs.CounterMap.Iterate()
            for iter.Next(&k, &v) {
                fmt.Printf("pid(%d) call %s %d times\n", k, fn, v)
            }
        }
    }()

BPF_MAP_TYPE_PERF_EVENT_ARRAY

在上一个例子中每次发生一次调用我们就先map[pid]++, 有另外一种方式就是直接向userspace发送一个raw event, 然后在userspace 对该event进行解析.

// +build ignore

#include "../headers/bpf/bpf_helpers.h"
#include "../headers/vmlinux-arm64.h"

char __license[] SEC("license") = "Dual MIT/GPL";

struct data_t {
  u32 pid;
  u32 uid;
  char command[16];
};
struct {
  __uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY);
} events SEC(".maps");

const struct data_t *unused __attribute__((unused));

SEC("kprobe/sys_execve")
int kprobe_execve(struct pt_regs *ctx) {
  struct data_t data = {};
  data.pid = bpf_get_current_pid_tgid() >> 32;
  data.uid = bpf_get_current_uid_gid() & 0xFFFFFFFF;
  bpf_get_current_comm(&data.command, sizeof(data.command));
  bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU, &data, sizeof(data));
  return 0;
}

用户层代码:

type data_t struct {
    Pid     uint32
    Uid     uint32
    Command [16]byte
}
    var event data_t
    for {
        record, err := rd.Read()
        if err != nil {
            if errors.Is(err, perf.ErrClosed) {
                return
            }
            log.Printf("reading from perf event reader: %s", err)
            continue
        }

        if record.LostSamples != 0 {
            log.Printf("perf event ring buffer full, dropped %d samples", record.LostSamples)
            continue
        }
        // Parse the perf event entry into a bpfEvent structure.
        if err := binary.Read(bytes.NewBuffer(record.RawSample), binary.LittleEndian, &event); err != nil {
            log.Printf("parsing perf event: %s", err)
            continue
        }
        log.Printf("event.pid = %d , uid = %d, cmd = %s\n", event.Pid, event.Uid, string(event.Command[:]))
    }

其他类型的map在后面使用时再做进一步的介绍:

Back To Top

lzh2nix commented 1 year ago

CH3 Anatomy of an eBPF program(2023.7.10)

这一章通过一个ebpf程序+bpftool来熟悉了一下ebpf程序的工作过程:

            c code ----->bytecode -------> machineCode

从编译到各种dump 观察, 有一种放到显微镜下观察ebpf程序的感觉.

clang 编译成bpf bytecode

hello.bpf.o: %.o: %.c
clang \
-target bpf \
-I/usr/include/$(shell uname -m)-linux-gnu \
-g \
-O2 -c $< -o $@

bpf程序的手动加载 bpftool prog load hello.ebpf.o /sys/fs/bpf/hello

查看已经加载的ebpf程序 bpftool prog list

epbf 使用方法可以参考 man

Usage: bpftool [OPTIONS] OBJECT { COMMAND | help }
       bpftool batch file FILE
       bpftool version

       OBJECT := { prog | map | link | cgroup | perf | net | feature | btf | gen | struct_ops | iter }
       OPTIONS := { {-j|--json} [{-p|--pretty}] | {-d|--debug} |
                    {-V|--version} }

或者下面这篇文章 https://qmonnet.github.io/whirl-offload/2021/09/23/bpftool-features-thread/

Back To Top

lzh2nix commented 1 year ago

CH4 The bpf() system call(2023.07.11)

用户层和内核交流还是走系统调用, ebpf也不例外. ebpf是走一个特有的系统的调用就要 bpf

       int bpf(int cmd, union bpf_attr *attr, unsigned int size);

ebpf程序的主要流程:

  1. 加载ebpf程序到内核
  2. attach 到指定的events
  3. map的读写

其实以上三部都需要通过bpf 程序来完成, 书中详细的trace 了一个ebpf程序来观察每一次系统调用, 我们这里也以前面的一个BPF_MAP_TYPE_HASH为例子:

struct {
    __uint(type, BPF_MAP_TYPE_HASH);
    __type(key, __u32);
    __type(value, __u64);
    __uint(max_entries, 1024);
} counter_map SEC(".maps");

SEC("kprobe/sys_execve")
int kprobe_execve() {
    u32 pid;
    u64 initval = 1, *valp;
    pid = bpf_get_current_pid_tgid() >>32;
    valp = bpf_map_lookup_elem(&counter_map, &pid);
    if (!valp) {
        bpf_map_update_elem(&counter_map, &pid, &initval, BPF_ANY);
        return 0;
    }
    __sync_fetch_and_add(valp, 1);
    return 0;
}

为例 (strace -f -e bpf counter)

  1. BTF相关加载
    bpf(BPF_BTF_LOAD, {btf="\237\353\1\0\30\0\0\0\0\0\0\0\20\0\0\0\20\0\0\0\1\0\0\0\0\0\0\0\0\0\0\1"..., btf_log_buf=NULL, btf_size=41, btf_log_size=0, btf_log_level=0}, 32) = 3
    bpf(BPF_BTF_LOAD, {btf="\237\353\1\0\30\0\0\0\0\0\0\0\30\0\0\0\30\0\0\0\3\0\0\0\1\0\0\0\0\0\0\f"..., btf_log_buf=NULL, btf_size=51, btf_log_size=0, btf_log_level=0}, 32) = 3
    bpf(BPF_BTF_LOAD, {btf="\237\353\1\0\30\0\0\0\0\0\0\0\30\0\0\0\30\0\0\0\3\0\0\0\1\0\0\0\1\0\0\f"..., btf_log_buf=NULL, btf_size=51, btf_log_size=0, btf_log_level=0}, 32) = 3
    bpf(BPF_BTF_LOAD, {btf="\237\353\1\0\30\0\0\0\0\0\0\08\0\0\08\0\0\0-\0\0\0\1\0\0\0\0\0\0\10"..., btf_log_buf=NULL, btf_size=125, btf_log_size=0, btf_log_level=0}, 32) = 3
  2. 创建counter_map
    bpf(BPF_MAP_CREATE, {map_type=BPF_MAP_TYPE_HASH, key_size=4, value_size=8, max_entries=1024, map_flags=0, inner_map_fd=0, map_name="counter_map", map_ifindex=0, btf_fd=3, btf_key_type_id=1, btf_value_type_id=2, btf_vmlinux_value_type_id=0, map_extra=0}, 72) = 4
  3. 加载程序
    [pid 32749] bpf(BPF_PROG_LOAD, {prog_type=BPF_PROG_TYPE_KPROBE, insn_cnt=23, insns=0x40000dc000, license="Dual MIT/GPL", log_level=0, log_size=0, log_buf=NULL, kern_version=KERNEL_VERSION(6, 3, 8), prog_flags=0, prog_name="kprobe_execve", prog_ifindex=0, expected_attach_type=BPF_CGROUP_INET_INGRESS, prog_btf_fd=3, func_info_rec_size=8, func_info=0x400001f900, func_info_cnt=1, line_info_rec_size=16, line_info=0x40000b8100, line_info_cnt=12, attach_btf_id=0, attach_prog_fd=0, fd_array=NULL}, 144) = 8
  4. 用户层访问map
    bpf(BPF_MAP_LOOKUP_ELEM, {map_fd=4, key=0x4000015008, value=0x4000015010, flags=BPF_ANY}, 32) = 0
    bpf(BPF_MAP_GET_NEXT_KEY, {map_fd=4, key=NULL, next_key=0x4000015008}, 24) = 0

Back To Top

lzh2nix commented 1 year ago

CH5 CO-RE, BTF, and libbpf(2023.07.14)

可以在上一章里我们已经看到了BTF(BPF Type Format), 主要目的是为了bpf程序的可移植性, 简单来说就是(compile once, run everyhere). 在目前的实例我们暂时还没有遇到需要访问内核数据结构的case, 不过ebpf作为用户程序和内核之间的桥梁, 访问内核的数据结构是必然的. 内核本身在不断的迭代, 不可能把所有内核的头文件都包进来, 然后不同的版本走不同的结构体. 这就出现了CO-RE项目.

针对这个问题BCC的解法是BCC包含了一套llvm的编译工具, 在实际用运行的时候根据机器实际情况, 先编译再运行. 但是这样会有一下的问题:

基于上面的几个痛点CO-RE 应运而生.

CO-RE的几个核心组件:

可以简单看一下他的工作原理, 首先我们在编译是引入了一个vmlinux.h 文件, 里面有具体结构体的定义(以file_system_type) 为例:

struct file_system_type {
        const char *name;
        int fs_flags;
        int (*init_fs_context)(struct fs_context *);
        const struct fs_parameter_spec *parameters;
        struct dentry * (*mount)(struct file_system_type *, int, const char *, void *);
        void (*kill_sb)(struct super_block *);
        struct module *owner;
        struct file_system_type *next;
        struct hlist_head fs_supers;
        struct lock_class_key s_lock_key;
        struct lock_class_key s_umount_key;
        struct lock_class_key s_vfs_rename_key;
        struct lock_class_key s_writers_key[3];
        struct lock_class_key i_lock_key;
        struct lock_class_key i_mutex_key;
        struct lock_class_key i_mutex_dir_key;
}

在编译bpf 程序的时候也会上对应变量名(类型debug信息), 然后在实际运行机器上通过内核的BTF找到变量在runtime的偏移量:

[189] STRUCT 'file_system_type' size=72 vlen=17
        'name' type_id=3 bits_offset=0
        'fs_flags' type_id=11 bits_offset=64
        'init_fs_context' type_id=1113 bits_offset=128
        'parameters' type_id=1115 bits_offset=192
        'mount' type_id=1117 bits_offset=256
        'kill_sb' type_id=1092 bits_offset=320
        'owner' type_id=207 bits_offset=384
        'next' type_id=952 bits_offset=448
        'fs_supers' type_id=174 bits_offset=512
        's_lock_key' type_id=201 bits_offset=576
        's_umount_key' type_id=201 bits_offset=576
        's_vfs_rename_key' type_id=201 bits_offset=576
        's_writers_key' type_id=1118 bits_offset=576
        'i_lock_key' type_id=201 bits_offset=576
        'i_mutex_key' type_id=201 bits_offset=576
        'invalidate_lock_key' type_id=201 bits_offset=576
        'i_mutex_dir_key' type_id=201 bits_offset=576

这样当我们访问file_system_type->i_mutex_dir_key的时候他就知道具体结构体中的偏移量.

函数也是一样, 在vmlinux中的定义:

typedef u64 (*btf_bpf_trace_printk)(char *, u32, u64, u64, u64);

在B TF中的定义:

[8188] TYPEDEF 'btf_bpf_trace_printk' type_id=8189
[8189] PTR '(anon)' type_id=8190
[8190] FUNC_PROTO '(anon)' ret_type_id=60 vlen=5
        '(anon)' type_id=16
        '(anon)' type_id=59
        '(anon)' type_id=60
        '(anon)' type_id=60
        '(anon)' type_id=60

具体参数类型通过type_id 不断的查找.

书中后面部分基本和上面golang的代码差不多就不展示, 了不过BPF_CORE_READ()这种宏确实好用, 不然指针必须一级一级的去拿,写起来太别扭.

Back To Top

lzh2nix commented 1 year ago

CH6 The eBPF verifier(2023.7.22)

verifier 的角色就是验证在加载你的ebpf程序的时候保证他是安全 ,防止对内核造成破坏.

其实就是检查你代码中的各种异常(通过eval而非executing的方式), 检查的内容和你使用工具检查python是一样的.

Back To Top

lzh2nix commented 1 year ago

CH7 eBPF Program and attachment Types(2023.8.30)

ebpf 相关的程序分为两类 tracing和networking

tracing 又有以下几类:

基本的几个example 都可以在cilium 里找到 https://github.com/cilium/ebpf/tree/main/examples.

写ebpf 代码的几个参考对象:

Back To Top