libbpf và CO-RE: Tự Viết Một Công Cụ eBPF

K
Kai··4 min read

bpftrace (Part II) tuyệt cho câu hỏi nhanh, nhưng nó là công cụ ad-hoc: gõ một dòng, xem, thoát. Khi cần một công cụ thật — phân phối được, chạy thường trú, tích hợp vào hệ thống khác — ta viết chương trình eBPF bằng C với libbpfCO-RE (Bài 5). Đây cũng là cách bcc-libbpf, Cilium, Tetragon được viết. Part III dựng đúng kiểu đó, bắt đầu bằng một execsnoop hoàn chỉnh.

Hai phía của một ứng dụng libbpf

Một công cụ eBPF có hai nửa, biên dịch riêng:

   exec.bpf.c  ──clang──► exec.bpf.o ──bpftool gen skeleton──► exec.skel.h
   (chạy TRONG nhân)                                                 │ #include
                                                                     ▼
   exec.c  ────────────────clang + link libbpf───────────────►  execsnoop
   (chạy Ở userspace: nạp, attach, đọc sự kiện)
  • Phía nhân (exec.bpf.c): chương trình eBPF gắn vào tracepoint exec, đẩy sự kiện ra một ring buffer.
  • Skeleton (exec.skel.h): bpftool gen skeleton nhúng exec.bpf.o vào một header C kèm hàm open/load/attach và con trỏ tới từng map/prog — userspace không phải lục lọi ELF thủ công.
  • Phía userspace (exec.c): dùng skeleton + libbpf để nạp chương trình, gắn, rồi đọc ring buffer in ra.

Cả hai chia chung một struct sự kiện trong exec.h.

Phía nhân: đẩy sự kiện qua ring buffer

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

SEC("tracepoint/sched/sched_process_exec")
int handle_exec(struct trace_event_raw_sched_process_exec *ctx)
{
    struct event *e = bpf_ringbuf_reserve(&events, sizeof(*e), 0);   // xin chỗ
    if (!e) return 0;
    e->pid = bpf_get_current_pid_tgid() >> 32;
    struct task_struct *task = (struct task_struct *)bpf_get_current_task();
    e->ppid = BPF_CORE_READ(task, real_parent, tgid);               // CO-RE (Bài 5)
    bpf_get_current_comm(&e->comm, sizeof(e->comm));
    unsigned off = ctx->__data_loc_filename & 0xFFFF;               // field động của tracepoint
    bpf_probe_read_kernel_str(&e->filename, sizeof(e->filename), (void *)ctx + off);
    bpf_ringbuf_submit(e, 0);                                       // gửi
    return 0;
}

Ring buffer (BPF_MAP_TYPE_RINGBUF) là cách hiện đại đẩy sự kiện từ nhân sang userspace: chương trình reserve một chỗ, ghi dữ liệu, submit — userspace đọc theo dòng. Khác map đếm ở Bài 3 (userspace poll giá trị), ring buffer đẩy từng sự kiện có thứ tự, hiệu quả. ppid đọc bằng BPF_CORE_READ (relocate theo BTF — Bài 5), filename lấy từ field động của tracepoint.

Phía userspace: skeleton + libbpf

Sinh skeleton rồi viết loader:

#include <bpf/libbpf.h>
#include "exec.skel.h"

static int on_event(void *ctx, void *data, size_t sz)
{
    struct event *e = data;
    printf("%-16s pid=%-7u ppid=%-7u %s\n", e->comm, e->pid, e->ppid, e->filename);
    return 0;
}

int main(void)
{
    setvbuf(stdout, NULL, _IOLBF, 0);                       // (xem cái bẫy bên dưới)
    struct exec_bpf *skel = exec_bpf__open_and_load();      // nạp + verifier + CO-RE relocate
    exec_bpf__attach(skel);                                 // gắn vào tracepoint
    struct ring_buffer *rb =
        ring_buffer__new(bpf_map__fd(skel->maps.events), on_event, NULL, NULL);
    while (!stop)
        ring_buffer__poll(rb, 100);                     // chờ + gọi on_event mỗi sự kiện
    exec_bpf__destroy(skel);
}

exec_bpf__open_and_load() (do skeleton sinh) làm trọn việc Bài 1–2 hé lộ: nạp bytecode, chạy CO-RE relocation theo BTF của kernel này, đẩy qua verifier, JIT. ring_buffer__new + poll đăng ký callback chạy mỗi khi nhân submit một sự kiện.

Build cả chuỗi

clang -O2 -g -target bpf -I. -c exec.bpf.c -o exec.bpf.o   # 1. C -> bytecode eBPF
bpftool gen skeleton exec.bpf.o > exec.skel.h              # 2. sinh skeleton (40k dòng)
clang exec.c -o execsnoop -lbpf -lelf -lz                  # 3. loader, link libbpf

Ba bước: biên dịch phía nhân sang bytecode, sinh skeleton, biên dịch loader liên kết libbpf. Chạy:

sudo ./execsnoop
COMM             PID         PPID         FILENAME
iptables         pid=367016  ppid=300744  /usr/sbin/iptables
ip6tables        pid=367017  ppid=300744  /usr/sbin/ip6tables
iptables         pid=367018  ppid=213711  /usr/sbin/iptables
iptables         pid=367019  ppid=213711  /usr/sbin/iptables

Mỗi lần bất kỳ tiến trình nào trên máy exec, một dòng hiện ra — qua ring buffer, không phải poll. Trên cụm này thấy ngay cilium-agent (ppid 213711) liên tục exec iptables/ip6tables để cập nhật rule. Đây là công cụ thật, chạy thường trú, đóng gói được — không phải one-liner.

Một cái bẫy thật: buffer stdout

Lần chạy đầu tiên không in gì cả. Lý do: printf ghi vào pipe bị block-buffered, và khi timeout giết tiến trình, buffer chưa kịp flush nên mất sạch. Sửa bằng setvbuf(stdout, NULL, _IOLBF, 0) (line-buffered) ở đầu main — mỗi dòng flush ngay. Đây là một lỗi C thường gặp, không liên quan eBPF, nhưng đáng nhớ khi viết công cụ tracing chạy lâu.

🧹 Dọn dẹp

execsnoop tự gỡ chương trình khi thoát (exec_bpf__destroy); node về 140 chương trình. Mã nguồn đầy đủ (exec.bpf.c, exec.c, exec.h, Makefile) ở github.com/nghiadaulau/ebpf-from-scratch, thư mục 09-libbpf-core.

Tổng kết

Khi cần một công cụ eBPF thật thay vì one-liner, ta viết bằng C với libbpf + CO-RE. Một ứng dụng có hai nửa: phía nhân (exec.bpf.c) gắn tracepoint exec và đẩy sự kiện qua ring buffer (reserve/submit — đẩy có thứ tự, khác poll map ở Bài 3), dùng BPF_CORE_READ lấy ppid (CO-RE, Bài 5); phía userspace (exec.c) dùng skeleton do bpftool gen skeleton sinh để open_and_load (gồm verifier + CO-RE relocate + JIT) rồi ring_buffer__poll đọc sự kiện. Chuỗi build: clang → .bpf.o → skeleton → loader link libbpf. Kết quả execsnoop stream mọi lần exec với pid/ppid/comm/filename — thấy cilium-agent liên tục gọi iptables. Và một bài học C: nhớ setvbuf line-buffered, không thì output kẹt trong buffer.

Bài 10 viết lại đúng công cụ này nhưng nạp từ Go với thư viện cilium/ebpf — cách hệ sinh thái Go (kể cả chính Cilium) build ứng dụng eBPF, và là loader phổ biến nhất trong thế giới Kubernetes.

Related Posts