ggaaooppeenngg

为什么计算机科学是无限的但生命是有限的

使用ebpf对oomkill进行探测

ebpf是内核当中一个非常重要的功能,简单来说就是以虚拟机的形式提供一种在内核中执行嵌入字节码的能力。
ebpf定义的指令集也非常简答,但是写起来比较麻烦所以作为工具提供了libbpf和bpf-tool。
用户可以通过写C的形式写ebpf程序嵌入到内核中,同时在用户态进行交互,用户态的程序没有语言限制。
ebpf特性的对应内核版本列表在,后面例子中的ringbuffer map是要内核版本在5.8以上。
ebpf的架构和工具链的一个比较完整的文档是cilium的一个文档

vmlinux.h是通过bpftool生成的一个虚拟头文件,用于访问内核数据结构。

libbpf 和 bpftool 都是在内核的代码仓库里面开发的。对应的目录分别是tools/lib/bpftools/bpf,帮助用户用c开发和调试ebpf程序。
相当于说bpftool是ebpf的调试和观测工具,libbpf是一提供给开发者的ebpf库。

bpf_herplers.h有一个section的定义SEC用来决定在ebpf的.o对象文件中的位置,以及一些功能。
比如SEC(kprobe/xxx)就代表修饰的程序会嵌入到内核函数的调用中。

1
2
3
4
5
6
7
8
9
10
11
12
13
/*
* Helper macro to place programs, maps, license in
* different sections in elf_bpf file. Section names
* are interpreted by libbpf depending on the context (BPF programs, BPF maps,
* extern variables, etc).
* To allow use of SEC() with externs (e.g., for extern .maps declarations),
* make sure __attribute__((unused)) doesn't trigger compilation warning.
*/
#define SEC(name) \
_Pragma("GCC diagnostic push") \
_Pragma("GCC diagnostic ignored \"-Wignored-attributes\"") \
__attribute__((section(name), used)) \
_Pragma("GCC diagnostic pop") \

libbpf.c 有对应的SEC的定义。

1
2
3
4
5
6
#define SEC_DEF(sec_pfx, ptype, ...) {                                      \
.sec = sec_pfx, \
.len = sizeof(sec_pfx) - 1, \
.prog_type = BPF_PROG_TYPE_##ptype, \
__VA_ARGS__ \
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
static const struct bpf_sec_def section_defs[] = {
BPF_PROG_SEC("socket", BPF_PROG_TYPE_SOCKET_FILTER),
BPF_EAPROG_SEC("sk_reuseport/migrate", BPF_PROG_TYPE_SK_REUSEPORT,
BPF_SK_REUSEPORT_SELECT_OR_MIGRATE),
BPF_EAPROG_SEC("sk_reuseport", BPF_PROG_TYPE_SK_REUSEPORT,
BPF_SK_REUSEPORT_SELECT),
SEC_DEF("kprobe/", KPROBE,
.attach_fn = attach_kprobe),
BPF_PROG_SEC("uprobe/", BPF_PROG_TYPE_KPROBE),
SEC_DEF("kretprobe/", KPROBE,
.attach_fn = attach_kprobe),
BPF_PROG_SEC("uretprobe/", BPF_PROG_TYPE_KPROBE),
BPF_PROG_SEC("classifier", BPF_PROG_TYPE_SCHED_CLS),
BPF_PROG_SEC("action", BPF_PROG_TYPE_SCHED_ACT),
SEC_DEF("tracepoint/", TRACEPOINT,
.attach_fn = attach_tp),
SEC_DEF("tp/", TRACEPOINT,
.attach_fn = attach_tp),
SEC_DEF("raw_tracepoint/", RAW_TRACEPOINT,
.attach_fn = attach_raw_tp),

kprobe的attach方式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
struct bpf_link *bpf_program__attach_kprobe(struct bpf_program *prog,
bool retprobe,
const char *func_name)
{
char errmsg[STRERR_BUFSIZE];
struct bpf_link *link;
int pfd, err;

pfd = perf_event_open_probe(false /* uprobe */, retprobe, func_name,
0 /* offset */, -1 /* pid */);
if (pfd < 0) {
pr_warn("prog '%s': failed to create %s '%s' perf event: %s\n",
prog->name, retprobe ? "kretprobe" : "kprobe", func_name,
libbpf_strerror_r(pfd, errmsg, sizeof(errmsg)));
return libbpf_err_ptr(pfd);
}
link = bpf_program__attach_perf_event(prog, pfd);
err = libbpf_get_error(link);
if (err) {
close(pfd);
pr_warn("prog '%s': failed to attach to %s '%s': %s\n",
prog->name, retprobe ? "kretprobe" : "kprobe", func_name,
libbpf_strerror_r(err, errmsg, sizeof(errmsg)));
return libbpf_err_ptr(err);
}
return link;
}

根据函数名用perf_event_open_probe注入program

内核对于oom的处理主要在mm/oom_kill.c中。
oom的触发就是在内存无法分配的时候,选择一个最“差”的进程发送kill信号。
oom_kill_process这个函数是主要入口,定义是static void oom_kill_process(struct oom_control *oc, const char *message)
其中oc->victim是要被杀掉的进程,如果有cgroup会把相同内存cgroup的进程都杀掉,所以如果要检测一个进程的oom可以通过检测这个函数的参数做到。

下面这段就是OOM的时候dmesg看到的信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

/* Get a reference to safely compare mm after task_unlock(victim) */
mm = victim->mm;
mmgrab(mm);

/* Raise event before sending signal: task reaper must see this */
count_vm_event(OOM_KILL);
memcg_memory_event_mm(mm, MEMCG_OOM_KILL);

/*
* We should send SIGKILL before granting access to memory reserves
* in order to prevent the OOM victim from depleting the memory
* reserves from the user space under its control.
*/
do_send_sig_info(SIGKILL, SEND_SIG_PRIV, victim, PIDTYPE_TGID);
mark_oom_victim(victim);
pr_err("%s: Killed process %d (%s) total-vm:%lukB, anon-rss:%lukB, file-rss:%lukB, shmem-rss:%lukB, UID:%u pgtables:%lukB oom_score_adj:%hd\n",
message, task_pid_nr(victim), victim->comm, K(mm->total_vm),
K(get_mm_counter(mm, MM_ANONPAGES)),
K(get_mm_counter(mm, MM_FILEPAGES)),
K(get_mm_counter(mm, MM_SHMEMPAGES)),
from_kuid(&init_user_ns, task_uid(victim)),
mm_pgtables_bytes(mm) >> 10, victim->signal->oom_score_adj);
task_unlock(victim);

对于内核函数的检测需要ebpf当中的kprobe的能力。
kprobe类似单步调试的能力,在函数入口插入一个breakpoint,然后通过trap让执行流转到注册的ebpf的程序。

oomkill的内核中的ebpf程序主要参考datadog的agent的实现。
框架程序主要参考cilium/ebpf当中的例子

PT_REGS_PARM1是一个宏可以帮助读取内核函数的参数,因为按照约定ebpf都是通过寄存器传参的,所以其实返回的是第1个参数对应的寄存器。bpf_probe_read是一个用于读取内存到ebpf程序中的辅助函数。
所以oomkill的ebpf实现的内核中的代码就比较简单,嵌入oom_kill_process的函数调用,或者要被结束的进程复制出进程的pid和command,然后通过类型为BPF_MAP_TYPE_RINGBUF的map发送event。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// +build ignore

#include "vmlinux.h"
#include "bpf_helpers.h"
#include "bpf_tracing.h"

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

struct event {
u32 pid;
u8 comm[80];
};

struct {
__uint(type, BPF_MAP_TYPE_RINGBUF);
__uint(max_entries, 1 << 24);
} events SEC(".maps");

// Force emitting struct event into the ELF.
const struct event *unused __attribute__((unused));

SEC("kprobe/oom_kill_process")
int kprobe_oom_kill_process(struct pt_regs *ctx) {
struct event *task_info;
struct oom_control *oc = (struct oom_control *)PT_REGS_PARM1(ctx);

task_info = bpf_ringbuf_reserve(&events, sizeof(struct event), 0);

if (!task_info) {
return 0;
}

struct task_struct *p;
bpf_probe_read(&p, sizeof(p), &oc->chosen);
bpf_probe_read(&task_info->pid, sizeof(task_info->pid), &p->pid);
bpf_probe_read(&task_info->comm, sizeof(task_info->comm), (void *)&p->comm);

bpf_ringbuf_submit(task_info, 0);

return 0;
}

用户态的程序也比较简单,把ebpf的对象文件attach以后读取ringbuffer别解析event。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
package main

import (
"bytes"
"encoding/binary"
"errors"
"fmt"
"log"
"os"
"os/signal"
"syscall"

"github.com/cilium/ebpf/link"
"github.com/cilium/ebpf/ringbuf"
"github.com/cilium/ebpf/rlimit"
"golang.org/x/sys/unix"
)

//go:generate go run github.com/cilium/ebpf/cmd/bpf2go -cc clang -type event -target amd64 bpf oom_kill_kernel.c

func main() {
// Name of the kernel function to trace.
fn := "oom_kill_process"

// Subscribe to signals for terminating the program.
stopper := make(chan os.Signal, 1)
signal.Notify(stopper, os.Interrupt, syscall.SIGTERM)

// Allow the current process to lock memory for eBPF resources.
if err := rlimit.RemoveMemlock(); 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()

fmt.Println("probe")
// 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 emit an event containing pid and command of the execved task.
kp, err := link.Kprobe(fn, objs.KprobeOomKillProcess, nil)
if err != nil {
log.Fatalf("opening kprobe: %s", err)
}
defer kp.Close()
fmt.Println("attach done")

// Open a ringbuf reader from userspace RINGBUF map described in the
// eBPF C program.
rd, err := ringbuf.NewReader(objs.Events)
if err != nil {
log.Fatalf("opening ringbuf reader: %s", err)
}
defer rd.Close()

// Close the reader when the process receives a signal, which will exit
// the read loop.
go func() {
<-stopper

if err := rd.Close(); err != nil {
log.Fatalf("closing ringbuf reader: %s", err)
}
}()

log.Println("Waiting for events..")

// bpfEvent is generated by bpf2go.
var event bpfEvent
for {
record, err := rd.Read()
if err != nil {
if errors.Is(err, ringbuf.ErrClosed) {
log.Println("Received signal, exiting..")
return
}
log.Printf("reading from reader: %s", err)
continue
}

// Parse the ringbuf event entry into a bpfEvent structure.
if err := binary.Read(bytes.NewBuffer(record.RawSample), binary.LittleEndian, &event); err != nil {
log.Printf("parsing ringbuf event: %s", err)
continue
}

log.Printf("pid: %d\tcomm: %s\n", event.Pid, unix.ByteSliceToString(event.Comm[:]))
}
}

使用一个python脚本进行测试OOM。

1
2
3
a="111111111111111"
while True:
a+=a

最后运行结果

1
2
3
4
probe
attach done
Waiting for events..
pid: 16541 comm: python

ebpf的能力还不止于此,业界做了很多流量控制,性能监控的实践,包括calico用ebpf的实现替代kube-proxy,waeve-scope 用ebpf记录tcp连接,cilium 也用eBPF,结合了tc和XDP。datadog-agent 也是使用了ebpf做一些监控,上面的oomkill的例子也是参考datadog的。ebpf能够让linux内核变得更动态更灵活。甚至有一种说法是eBPF让Linux内核正在变成微内核。