极客时间:eBPF核心技术与实战

时间:March 14, 2022 分类:

目录:

学习准备

eBPF发展历程及工作原理

eBPF技术是在BPF技术上扩展而来

在1992年的USENIX会议上,Steven McCanne和Van Jacobson发布的论文为BSD操作系统带来了革命性的包过滤机制BSD Packet Filter(简称为 BPF),比当时最先进的数据包过滤技术还快20倍

速度快得益于BPF的两大设计:

  1. 内核态引入虚拟机
  2. 用户态使用BPF字节码定义过滤的表达式,传递给内核,进而由虚拟机执行

包过滤可以直接在内核中进行,避免了向用户态的拷贝,提高了包过滤的性能

历史为

  1. 在BPF诞生5年后,Linux2.1.75首次引入了BPF
  2. 在Linux3.0添加了BPF即时编译器,优化了BPF指令运行的效率,但是整体还是在网络包过滤范畴
  3. 2014年为了研究软件定义网络,Alexei Starovoitov为BPF带来了eBPF,eBPF扩展了寄存器的数量,引入了BPF映射,在4.x内核将网络数据包扩展到内核函数、用户函数、跟踪点、性能事件和安全控制等

eBPF使BPF不再局限与网络栈,而是称为内核的顶级子系统

带来的有

  1. 跟踪和排错领域:BCC、bpftrace等
  2. 优化网络和安全:Cilium、Katran、Falco和最新版本的Calico等

BBC工具都是基于eBPF开发的

历史链路

eBPF工作原理

eBPF程序和常规线程不一样,启动就一直运行,而是需要事件进行触发

这些事件包括

  • 系统调用
  • 内核跟踪点
  • 内核函数和用户态函数的调用退出
  • 网络事件

借助内核态插桩(kprobe)和用户态插桩(uprobe)可以在内核和应用的任意位置插桩

不安全的eBPF不能被提交到内核虚拟机运行

eBPF执行过程

  1. 借助LLVM编写eBPF程序
  2. eBPF程序转换为BPF字节码
  3. BPF字节码通过bpf系统调用提交到内核
  4. 内核接收BPF字节码之前会进行校验
  5. 通过校验的BPF字节码由内核的即时编译器执行

流程图

常见的验证过程

  • 只有特权进程才可以执行bpf系统调用
  • BPF程序不能包含无限循环
  • BPF程序不能导致内核崩溃
  • BPF程序必须在有限时间内完成

BPF程序可以利用BPF映射(map)来进行存储,用户程序也通过映射与内核的BPF进行交互

示例性能观测中,BPF程序收集内核运行状态存储在映射中,用户程序再从映射中读出这些状态

eBPF程序需要编译、加载、验证和内核执行等过程,用户态借助BPF的映射获取eBPF程序运行状态

eBPF的局限

一些限制

  • eBPF程序必须被验证器校验通过后才能执行,且不能包含无法到达的指令
  • eBPF程序不能随意调用内核函数,只能调用在API中定义的辅助函数
  • eBPF程序栈空间只有512字节,更大存储需要依赖映射存储
  • 在5.2内核前,eBPF字节码只支持4096条指令,而5.2提高到100W
  • 在不同版本内核运行,可能需要调整源码进行编译

特性支持版本参考kernel version,为了稳定运行建议4.9,或者5以上版本

如何高效学习eBPF

  • 理解eBPF的基本原理
  • 掌握eBPF的编程接口
  • 通过实践把eBPF应用到真正的工作场景中

学习路径有

基础入门

开发并运行第一个eBPF程序

开发环境准备

内核需要开启

  • CONFIG_DEBUG_INFO_BTF=y
  • CONFIG_DEBUG_INFO=y

推荐使用默认开这些选项的发行版

  • Ubuntu 20.10+
  • Fedora 31+
  • RHEL 8.2+
  • Debian 11+
# 创建和启动Ubuntu 21.10虚拟机
vagrant box add ubuntu/impish64
vagrant init ubuntu/impish64
vagrant up

# 登录到虚拟机
vagrant ssh

vagrant下载地址

准备的开发工具

  • 将eBPF程序编译成字节码的LLVM
  • C语言程序编译工具make
  • 最流行的eBPF工具集BCC和它依赖的内核头文件
  • 与内核代码仓库实时同步的libbpf
  • 内核代码提供的eBPF程序管理工具bpftoo

安装命令

# For Ubuntu20.10+
sudo apt-get install -y  make clang llvm libelf-dev libbpf-dev bpfcc-tools libbpfcc-dev linux-tools-$(uname -r) linux-headers-$(uname -r)

# For RHEL8.2+
sudo yum install libbpf-devel make clang llvm elfutils-libelf-devel bpftool bcc-tools bcc-devel

BBC是一个BPF编译器集合,包含用于构建BPF程序的编程框架和库,并提供了大量可以直接使用的工具,提供了Python、C++等编程语言接口

示例跟踪打开文件的openat系统调用

用C开发一个eBPF程序

hello.c

int hello_world(void *ctx)
{
    bpf_trace_printk("Hello, World!");
    return 0;
}

这里是一个常用的辅助函数,输出的字符串到内核调试文件/sys/kernel/debug/tracing/trace_pipe,可以通过cat来查看

使用Python和BCC库开发一个用户态程序


#!/usr/bin/env python3
# 1) import bcc library
from bcc import BPF

# 2) load BPF program
b = BPF(src_file="hello.c")
# 3) attach kprobe
b.attach_kprobe(event="do_sys_openat2", fn_name="hello_world")
# 4) read and print /sys/kernel/debug/tracing/trace_pipe
b.trace_print()

整体的步骤为

  1. 导入BCC库的BPF模块
  2. 调用BPF()加载BPF源代码
  3. 将BPF程序挂载到内核探针kprobe,do_sys_openat2是系统调用openat在内核的实现
  4. 读取内核调试文件的内容打印到标准输出

在运行的时候,BCC会调用LLVM把BPF源代码编译为字节码,加载到内核中运行

执行eBPF程序

sudo python3 hello.py

在有文件打开之后能看到如下输出

b' cat-10656 [006] d... 2348.114455: bpf_trace_printk: Hello, World!'

输出的格式由/sys/kernel/debug/tracing/trace_options更改

默认输出的每个字段

  • cat-10656 表示进程的名字和PID
  • [006] 表示CPU编号
  • d… 表示一系列的选项
  • 2348.114455 表示时间戳
  • bpf_trace_printk 表示函数名

不过并不推荐通过内核文件输出日志,一方面有性能问题,另一方面很多eBPF程序都写入同一位置不好区分

使用map映射的方式输出eBPF的输出,BCC提供了一系列的库函数和辅助宏定义

eBPF程序

// 包含头文件
#include <uapi/linux/openat2.h>
#include <linux/sched.h>

// 定义数据结构
struct data_t {
  u32 pid;
  u64 ts;
  char comm[TASK_COMM_LEN];
  char fname[NAME_MAX];
};

// 定义性能事件映射
BPF_PERF_OUTPUT(events);


// 定义kprobe处理函数
int hello_world(struct pt_regs *ctx, int dfd, const char __user * filename, struct open_how *how)
{
  struct data_t data = { };

  // 获取PID和时间
  data.pid = bpf_get_current_pid_tgid();
  data.ts = bpf_ktime_get_ns();

  // 获取进程名
  if (bpf_get_current_comm(&data.comm, sizeof(data.comm)) == 0)
  {
    bpf_probe_read(&data.fname, sizeof(data.fname), (void *)filename);
  }

  // 提交性能事件
  events.perf_submit(ctx, &data, sizeof(data));
  return 0;
}
  • BPF_PERF_OUTPUT来定义一个Perf事件类型的BPF映射
  • bpf_get_current_pid_tgid 用于获取进程的TGID和PID,因为定义的data.pid数据类型为u32,所以高32位舍弃掉后就是进程的PID
  • bpf_ktime_get_ns 用于获取系统自启动以来的时间,单位是纳秒
  • bpf_get_current_comm 用于获取进程名,并把进程名复制到预定义的缓冲区中
  • bpf_probe_read 用于从指定指针处读取固定大小的数据,这里则用于读取进程打开的文件名

用户态程序

使用用户态的辅助程序open_perf_buffer()来获取BPF_PERF_OUTPUT对应的数据,需要一个回调函数用于处理从Perf事件类型的BPF映射中读取到数据

from bcc import BPF

# 1) load BPF program
b = BPF(src_file="trace-open.c")
b.attach_kprobe(event="do_sys_openat2", fn_name="hello_world")

# 2) print header
print("%-18s %-16s %-6s %-16s" % ("TIME(s)", "COMM", "PID", "FILE"))

# 3) define the callback for perf event
start = 0
def print_event(cpu, data, size):
    global start
    event = b["events"].event(data)
    if start == 0:
            start = event.ts
    time_s = (float(event.ts - start)) / 1000000000
    print("%-18.9f %-16s %-6d %-16s" % (time_s, event.comm, event.pid, event.fname))

# 4) loop with callback to print_event
b["events"].open_perf_buffer(print_event)
while 1:
    try:
        b.perf_buffer_poll()
    except KeyboardInterrupt:
        exit()

整体步骤

  1. 加载eBPF程序并挂载到内核探针
  2. 数据Header字符串
  3. print_event定义数据处理函数
  4. 定义event的perf映射,循环通过perf_buffer_poll读取映射内容,并执行回调的处理函数

输出信息

TIME(s)            COMM             PID    FILE
2.384485400        b'irqbalance'    991    b'/proc/interrupts'
2.384750400        b'irqbalance'    991    b'/proc/stat'
2.384838400        b'irqbalance'    991    b'/proc/irq/0/smp_affinity'

ebpf运行原理

系统虚拟化与ebpf的一些区别

  • 系统虚拟化基于x86或者arm64等通用指令集,具备完整的计算机功能
  • ebpf只提供了有限的指令集,只能完成一部分内核功能,并采用C调用约定提供辅助函数在C语言中调用

ebpf在内核中运行时主要由5个模块组成

  • ebpf辅助函数,用于ebpf程序与内核其他模块进行交互的函数
  • ebpf验证器,用于确保ebpf程序的安全,会将待执行指令创建为一个有向无环图,保证不包含不可达指令,模拟执行没有无效指令
  • 11个64位寄存器、一个程序计数器和一个512字节的栈组成的存储模块,R0寄存器用于存储函数调用和ebpf程序的返回值,意味着只能有一个返回值,R1~R5用于存储函数的参数,意味着参数不能超过5个,R10寄存器是一个只读寄存器,用于从栈中读取数据
  • 即时编译器,将ebpf字节码编译为本地机器指令
  • BPF映射,提供大块存储空间,可以被用户进程访问

bpftool可以查看运行的ebpf程序的状态

# sudo bpftool prog list
89: kprobe  name hello_world  tag 38dd440716c4900f  gpl
      loaded_at 2021-11-27T13:20:45+0000  uid 0
      xlated 104B  jited 70B  memlock 4096B
      btf_id 131
      pids python3(152027)
  • 89为ebpf程序编号
  • kprobe为程序类型
  • hello_world为程序名

有了ebpf编号,可以执行以下命令导出ebpf的指令

# sudo bpftool prog dump xlated id 89

int hello_world(void * ctx):
; int hello_world(void *ctx)
   0: (b7) r1 = 33                  /* ! */
; ({ char _fmt[] = "Hello, World!"; bpf_trace_printk_(_fmt, sizeof(_fmt)); });
   1: (6b) *(u16 *)(r10 -4) = r1
   2: (b7) r1 = 1684828783          /* dlro */
   3: (63) *(u32 *)(r10 -8) = r1
   4: (18) r1 = 0x57202c6f6c6c6548  /* W ,olleH */
   6: (7b) *(u64 *)(r10 -16) = r1
   7: (bf) r1 = r10
;
   8: (07) r1 += -16
; ({ char _fmt[] = "Hello, World!"; bpf_trace_printk_(_fmt, sizeof(_fmt)); });
   9: (b7) r2 = 14
  10: (85) call bpf_trace_printk#-61616
; return 0;
  11: (b7) r0 = 0
  12: (95) exit