커널 동작을 들여다보려면 예전에는 커널 모듈을 만들거나 커널을 다시 빌드해야 했다. 모듈 버그 하나가 곧 커널 패닉이고, 커널 버전이 바뀔 때마다 다시 컴파일해야 한다. eBPF는 검증된 작은 프로그램을 실행 중인 커널에 붙이는 방식으로 이 문제를 푼다. 지금은 관측(tracing)뿐 아니라 스케줄러(sched_ext), 네트워크(XDP), 보안(BPF LSM)까지 커널의 주요 서브시스템을 확장하는 표준 수단이 됐다.
이 시리즈는 libbpf와 CO-RE로 eBPF 프로그램을 직접 작성하고, 테스트 VM에서 실행한 결과로 동작을 검증한다. 첫 편인 이 글에서는 개발 환경을 갖추고, 첫 프로그램을 빌드·로드한 뒤 bpftool로 커널 안의 모습을 확인한다. 이어서 CO-RE로 같은 바이너리가 다른 커널에서 도는 것과 verifier가 코드를 거부하는 사례를 살펴본다.
이 시리즈의 다른 글
- eBPF 실전 (2) — BPF 맵과 링 버퍼: per-CPU, LRU, 유실, 맵 고정
- eBPF 실전 (3) — kprobe·fentry·tracepoint·uprobe 훅 오버헤드 비교
- eBPF 실전 (4) — 런큐 지연과 컨텍스트 스위치 관측하기
- eBPF 실전 (5) — sched_ext로 커널 스케줄러 직접 구현하기
- eBPF 실전 (6) — 페이지 폴트·slab·메모리 누수·OOM 관측하기
eBPF 프로그램이 커널에 들어가는 과정
개념 설명은 eBPF로 Linux 커널 동작 실시간 추적하기에서 다뤘으니, 여기서는 이 글에서 직접 거칠 단계만 정리한다.
| 단계 | 담당 | 이 글에서 확인하는 것 |
|---|---|---|
| 컴파일 | clang -target bpf | C 소스 → BPF 바이트코드 오브젝트(.bpf.o) |
| 스켈레톤 생성 | bpftool gen skeleton | 오브젝트를 임베드한 C 헤더(.skel.h) |
| 로드·재배치 | libbpf | CO-RE 재배치로 구조체 오프셋을 대상 커널에 맞춤 |
| 검증 | 커널 verifier | 메모리 접근·루프 종료를 정적으로 증명, 실패하면 로드 거부 |
| JIT | 커널 | 바이트코드 → 네이티브 기계어 |
| 부착(attach) | libbpf | tracepoint 등 훅에 연결, 이벤트마다 실행 |
| 데이터 전달 | 맵, 링 버퍼 | 커널 ↔ 유저 공간 데이터 교환 |
개발 환경 준비
테스트 환경은 Ubuntu 24.04(커널 6.8.0-139-generic, 6코어 VM)다. clang, libbpf 헤더, 커널 버전에 맞는 bpftool을 설치한다.
sudo apt install clang llvm libbpf-dev libelf-dev zlib1g-dev make \
linux-tools-$(uname -r)vmlinux.h는 커널의 모든 타입 정의를 담은 헤더로, 실행 중인 커널의 BTF에서 뽑아낸다.
mkdir -p ~/ebpf && cd ~/ebpf
bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h
ls -lh vmlinux.h /sys/kernel/btf/vmlinux-r--r--r-- 1 root root 5.9M Sep 19 15:47 /sys/kernel/btf/vmlinux
-rw-rw-r-- 1 noble noble 3.1M Sep 19 16:12 vmlinux.h
커널 헤더 수십 개를 include하는 대신 이 파일 하나면 된다. /sys/kernel/btf/vmlinux가 없다면 커널이 CONFIG_DEBUG_INFO_BTF=y로 빌드되지 않은 것이다.
첫 번째 프로그램: execve 추적
execve() 시스템 콜이 호출될 때마다 프로세스 이름과 PID를 커널 트레이스 버퍼에 찍는다.
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
char LICENSE[] SEC("license") = "GPL";
SEC("tracepoint/syscalls/sys_enter_execve")
int handle_execve(void *ctx)
{
char comm[16];
bpf_get_current_comm(comm, sizeof(comm));
bpf_printk("execve by %s (pid %d)", comm, bpf_get_current_pid_tgid() >> 32);
return 0;
}SEC()로 지정한 섹션 이름이 곧 부착 위치다. libbpf가 tracepoint/syscalls/sys_enter_execve를 보고 알아서 해당 tracepoint에 연결한다.
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include "hello.skel.h"
static volatile int stop;
static void on_sig(int sig) { stop = 1; }
int main(void)
{
struct hello_bpf *skel;
skel = hello_bpf__open_and_load();
if (!skel) {
fprintf(stderr, "open_and_load failed\n");
return 1;
}
if (hello_bpf__attach(skel)) {
fprintf(stderr, "attach failed\n");
goto out;
}
signal(SIGINT, on_sig);
printf("attached. cat /sys/kernel/tracing/trace_pipe\n");
while (!stop)
sleep(1);
out:
hello_bpf__destroy(skel);
return 0;
}유저 공간 쪽은 스켈레톤이 만들어 준 hello_bpf__open_and_load(), __attach(), __destroy() 세 함수만 부르면 된다.
BPF_CLANG ?= clang
ARCH := $(shell uname -m | sed 's/x86_64/x86/;s/aarch64/arm64/')
APPS := hello execsnoop
.SECONDARY:
all: $(APPS)
%.bpf.o: %.bpf.c ../vmlinux.h
$(BPF_CLANG) -g -O2 -target bpf -D__TARGET_ARCH_$(ARCH) -I.. -c $< -o $@
%.skel.h: %.bpf.o
bpftool gen skeleton $< > $@
$(APPS): %: %.c %.skel.h
$(CC) -g -O2 -Wall $< -o $@ -lbpf -lelf -lz
clean:
rm -f *.o *.skel.h $(APPS)$ make hello
clang -g -O2 -target bpf -D__TARGET_ARCH_x86 -I.. -c hello.bpf.c -o hello.bpf.o
bpftool gen skeleton hello.bpf.o > hello.skel.h
cc -g -O2 -Wall hello.c -o hello -lbpf -lelf -lz
.SECONDARY:가 없으면 make가 중간 산출물인 .bpf.o를 지워 버려 나중에 bpftool로 살펴볼 수 없다. 이제 root로 실행하고, 다른 터미널에서 PATH를 일부러 틀리게 준 명령을 실행한다.
# 터미널 1
sudo ./hello
# 터미널 2
sudo cat /sys/kernel/tracing/trace_pipe
# 터미널 3
env PATH=/nope1:/nope2:/usr/bin date bash-2370 [004] ...21 1358.366328: bpf_trace_printk: execve by env (pid 2370)
bash-2370 [004] ...21 1358.366455: bpf_trace_printk: execve by env (pid 2370)
bash-2370 [004] ...21 1358.366460: bpf_trace_printk: execve by env (pid 2370)
env가 /nope1/date, /nope2/date, /usr/bin/date 순서로 시도해서 세 번 찍혔다. sys_enter_execve는 시스템 콜 진입 시점이라 실패한 호출도 잡힌다.
bpftool로 커널 안의 프로그램 보기
로더가 떠 있는 동안 bpftool로 커널에 올라간 프로그램과 맵을 확인한다. 아래는 다음 절의 execsnoop을 띄워 둔 상태다.
$ sudo bpftool prog show name handle_exec
77: tracepoint name handle_exec tag a9c049e1d6dbbe60 gpl
loaded_at 2026-09-19T15:51:28+0000 uid 0
xlated 384B jited 233B memlock 4096B map_ids 16
btf_id 126
$ sudo bpftool map show name events
16: ringbuf name events flags 0x0
key 0B value 0B max_entries 262144 memlock 275864B
$ sudo bpftool link show | grep -A2 tracepoint
tracepoint sched_process_exec
xlated는 verifier를 통과한 뒤의 바이트코드 크기, jited는 JIT가 만든 x86 기계어 크기다. 변환된 바이트코드는 소스 줄과 함께 볼 수 있다.
$ sudo bpftool prog dump xlated id 77 | head -20
int handle_exec(struct trace_event_raw_sched_process_exec * ctx):
; int handle_exec(struct trace_event_raw_sched_process_exec *ctx)
0: (bf) r6 = r1
; struct task_struct *task = (struct task_struct *)bpf_get_current_task();
1: (85) call bpf_get_current_task#-130816
2: (bf) r8 = r0
; unsigned int off = ctx->__data_loc_filename & 0xFFFF;
3: (61) r9 = *(u32 *)(r6 +8)
; e = bpf_ringbuf_reserve(&events, sizeof(*e), 0);
4: (18) r1 = map[id:16]
6: (b7) r2 = 92
7: (b7) r3 = 0
8: (85) call bpf_ringbuf_reserve#352720
...
15: (b7) r1 = 2504
16: (0f) r8 += r1
15번 명령의 2504가 task_struct 안에서 real_parent 필드의 오프셋이다. 이 숫자가 커널마다 어떻게 바뀌는지는 CO-RE 절에서 확인한다.
링 버퍼로 이벤트를 유저 공간에 전달하기
bpf_printk()는 디버깅용이라 모든 출력이 시스템 전역 트레이스 버퍼 하나로 섞인다. 실제 도구는 구조체 단위로 이벤트를 링 버퍼에 담아 유저 공간으로 보낸다.
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_core_read.h>
#include <bpf/bpf_tracing.h>
char LICENSE[] SEC("license") = "GPL";
struct event {
u32 pid;
u32 ppid;
u32 uid;
char comm[16];
char filename[64];
};
struct {
__uint(type, BPF_MAP_TYPE_RINGBUF);
__uint(max_entries, 256 * 1024);
} events SEC(".maps");
SEC("tp/sched/sched_process_exec")
int handle_exec(struct trace_event_raw_sched_process_exec *ctx)
{
struct task_struct *task = (struct task_struct *)bpf_get_current_task();
unsigned int off = ctx->__data_loc_filename & 0xFFFF;
struct event *e;
e = bpf_ringbuf_reserve(&events, sizeof(*e), 0);
if (!e)
return 0;
e->pid = bpf_get_current_pid_tgid() >> 32;
e->ppid = BPF_CORE_READ(task, real_parent, tgid);
e->uid = bpf_get_current_uid_gid();
bpf_get_current_comm(e->comm, sizeof(e->comm));
bpf_probe_read_str(e->filename, sizeof(e->filename), (void *)ctx + off);
bpf_ringbuf_submit(e, 0);
return 0;
}BPF_CORE_READ(task, real_parent, tgid)는 task->real_parent->tgid를 안전하게 읽으면서 각 필드 접근을 CO-RE 재배치 대상으로 기록한다. 이번에는 exec가 성공한 뒤에 발생하는 sched_process_exec tracepoint를 썼다.
#include <stdio.h>
#include <signal.h>
#include <bpf/libbpf.h>
#include "execsnoop.skel.h"
struct event {
unsigned int pid, ppid, uid;
char comm[16];
char filename[64];
};
static volatile int stop;
static void on_sig(int sig) { stop = 1; }
static int on_event(void *ctx, void *data, size_t len)
{
const struct event *e = data;
printf("%-7u %-7u %-5u %-16s %s\n", e->pid, e->ppid, e->uid, e->comm, e->filename);
return 0;
}
int main(void)
{
struct execsnoop_bpf *skel;
struct ring_buffer *rb = NULL;
skel = execsnoop_bpf__open_and_load();
if (!skel)
return 1;
if (execsnoop_bpf__attach(skel))
goto out;
rb = ring_buffer__new(bpf_map__fd(skel->maps.events), on_event, NULL, NULL);
if (!rb)
goto out;
signal(SIGINT, on_sig);
printf("%-7s %-7s %-5s %-16s %s\n", "PID", "PPID", "UID", "COMM", "FILENAME");
while (!stop)
ring_buffer__poll(rb, 100);
out:
ring_buffer__free(rb);
execsnoop_bpf__destroy(skel);
return 0;
}$ sudo ./execsnoop
PID PPID UID COMM FILENAME
2368 2361 0 sudo /usr/bin/sudo
2369 2368 1000 bash /usr/bin/bash
2370 2369 1000 env /usr/bin/env
2370 2369 1000 date /usr/bin/date
2371 2361 0 sleep /usr/bin/sleep
같은 명령인데 env의 실패한 두 번의 시도는 보이지 않는다. PID 2370이 env에서 date로 바뀐 것은 exec가 PID를 유지한 채 프로그램만 교체하기 때문이다.
CO-RE: 한 번 컴파일해서 다른 커널에서 실행하기
task_struct의 필드 위치는 커널 버전과 설정마다 다르다. CO-RE(Compile Once – Run Everywhere)는 컴파일 시점의 오프셋을 기록해 두고 로드할 때 대상 커널의 BTF를 보고 고쳐 쓰며, 그 과정이 libbpf 디버그 로그에 남는다.
$ uname -r
6.8.0-139-generic
$ sudo bpftool -d prog load execsnoop.bpf.o /sys/fs/bpf/es 2>&1 | grep -iE "core|relo"
libbpf: sec 'tp/sched/sched_process_exec': found 3 CO-RE relocations
libbpf: CO-RE relocating [23] struct task_struct: found target candidate [82] struct task_struct in [vmlinux]
libbpf: prog 'handle_exec': relo #1: <byte_off> [23] struct task_struct.real_parent (0:96 @ offset 2504)
libbpf: prog 'handle_exec': relo #1: matching candidate #0 <byte_off> [82] struct task_struct.real_parent (0:96 @ offset 2504)
libbpf: prog 'handle_exec': relo #1: patched insn #15 (ALU/ALU64) imm 2504 -> 2504
libbpf: prog 'handle_exec': relo #2: patched insn #22 (ALU/ALU64) imm 2492 -> 2492
vmlinux.h를 이 커널에서 뽑았으니 2504 → 2504로 값이 그대로다. 같은 execsnoop.bpf.o와 execsnoop 바이너리를 다시 컴파일하지 않고 defconfig 기반으로 직접 빌드한 6.12 커널(QEMU)에서 실행했다.
$ uname -r
6.12.0
$ bpftool -d prog load execsnoop.bpf.o /sys/fs/bpf/es 2>&1 | grep -E "relo #[12]: patched"
libbpf: prog 'handle_exec': relo #1: patched insn #15 (ALU/ALU64) imm 2504 -> 1680
libbpf: prog 'handle_exec': relo #2: patched insn #22 (ALU/ALU64) imm 2492 -> 1668
$ ./execsnoop
shell pid 104
PID PPID UID COMM FILENAME
104 98 0 exe /proc/self/exe
105 104 0 bpftool /bin/bpftool
104 98 0 bpftool /bin/bpftool
real_parent 오프셋이 1680으로 고쳐졌고, 자식 프로세스의 PPID가 셸 PID(104)와 정확히 일치한다(첫 줄은 busybox 셸이 /proc/self/exe를 다시 exec한 것). 6.8 커널의 오프셋은 BTF에서 직접 확인할 수 있다.
$ bpftool btf dump file /sys/kernel/btf/vmlinux format raw | grep -A200 "STRUCT 'task_struct' size" \
| grep -E "task_struct. size|'real_parent'|'tgid'"
[82] STRUCT 'task_struct' size=13760 vlen=273
'tgid' type_id=40 bits_offset=19936
'real_parent' type_id=83 bits_offset=20032
비트 오프셋 20032를 8로 나누면 2504, 19936은 2492다.
verifier가 거부하는 코드
verifier는 로드 시점에 모든 실행 경로를 따라가며 메모리 접근이 안전하고 프로그램이 반드시 끝난다는 것을 증명한다. 흔히 걸리는 세 가지 실수를 한 파일에 #ifdef로 나눠 넣었다.
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
char LICENSE[] SEC("license") = "GPL";
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, 1024);
__type(key, u32);
__type(value, u64);
} counts SEC(".maps");
u64 table[16];
#ifdef BUG_NULL
SEC("tp/syscalls/sys_enter_openat")
int bug_null(void *ctx)
{
u32 pid = bpf_get_current_pid_tgid() >> 32;
u64 *val = bpf_map_lookup_elem(&counts, &pid);
*val += 1; /* NULL 검사 없음 */
return 0;
}
#endif
#ifdef BUG_BOUNDS
SEC("tp/syscalls/sys_enter_openat")
int bug_bounds(void *ctx)
{
u32 idx = bpf_get_prandom_u32();
table[idx] += 1; /* idx 범위 검사 없음 */
return 0;
}
#endif
#ifdef BUG_LOOP
SEC("tp/syscalls/sys_enter_openat")
int bug_loop(void *ctx)
{
u32 n = bpf_get_prandom_u32();
u64 sum = 0;
for (u32 i = 0; i < n; i++) /* 상한이 런타임 값 */
sum += table[i & 15];
table[0] = sum;
return 0;
}
#endif
#ifdef FIXED
SEC("tp/syscalls/sys_enter_openat")
int fixed(void *ctx)
{
u32 pid = bpf_get_current_pid_tgid() >> 32;
u32 idx = bpf_get_prandom_u32();
u64 one = 1, *val;
val = bpf_map_lookup_elem(&counts, &pid);
if (val)
__sync_fetch_and_add(val, 1);
else
bpf_map_update_elem(&counts, &pid, &one, BPF_NOEXIST);
table[idx & 15] += 1; /* 0~15로 제한 */
u64 sum = 0;
for (u32 i = 0; i < 64 && i < idx; i++)
sum += table[i & 15];
table[0] += sum;
return 0;
}
#endiffor d in BUG_NULL BUG_BOUNDS BUG_LOOP FIXED; do
clang -g -O2 -target bpf -D$d -I.. -c verif.bpf.c -o $d.o
sudo bpftool prog load $d.o /sys/fs/bpf/verif_$d
done맵 조회 결과의 NULL 검사 누락
; u64 *val = bpf_map_lookup_elem(&counts, &pid);
5: (18) r1 = 0xffff8e0083094000 ; R1_w=map_ptr(map=counts,ks=4,vs=8)
7: (85) call bpf_map_lookup_elem#1 ; R0=map_value_or_null(id=1,map=counts,ks=4,vs=8)
; *val += 1; /* NULL 검사 없음 */
8: (79) r1 = *(u64 *)(r0 +0)
R0 invalid mem access 'map_value_or_null'
processed 8 insns (limit 1000000) max_states_per_insn 0 total_states 1 peak_states 1 mark_read 1
bpf_map_lookup_elem()의 반환 타입이 map_value_or_null로 추적되고 있어서, NULL 검사 없이 역참조하는 순간 거부된다.
범위를 모르는 배열 인덱스
; table[idx] += 1; /* idx 범위 검사 없음 */
3: (67) r0 <<= 3 ; R0_w=scalar(smin=0,smax=umax=0x7fffffff8,...)
4: (18) r1 = 0xffffcf94c04dc000 ; R1_w=map_value(map=BUG_BOUN.bss,ks=4,vs=128)
6: (0f) r1 += r0
7: (79) r2 = *(u64 *)(r1 +0)
R1 unbounded memory access, make sure to bounds check any such access
전역 배열 table은 .bss 맵(128바이트)으로 만들어지는데, 인덱스의 최댓값이 0x7fffffff8이라 범위를 벗어날 수 있다고 판단한다.
상한이 정해지지 않은 루프
12: (0f) r4 += r2 ; R2_w=24 R4_w=map_value(map=BUG_LOOP.bss,ks=4,vs=128,off=24)
13: (79) r2 = *(u64 *)(r4 +0)
BPF program is too large. Processed 1000001 insn
processed 1000001 insns (limit 1000000) max_states_per_insn 4 total_states 9526 peak_states 9526 mark_read 2
5.3부터 유한 루프는 허용되지만, verifier는 루프를 실제로 펼쳐 가며 검증하므로 상한이 32비트 난수면 명령 100만 개 한도를 넘는다. 처음에는 루프 본문을 sum += i로 썼다가 clang이 루프를 n*(n-1)/2 계산으로 바꿔 버려 그대로 통과했고, 그래서 메모리를 읽도록 바꿨다.
수정한 버전
$ sudo bpftool prog load FIXED.o /sys/fs/bpf/fixed autoattach
$ sudo bpftool map dump name counts | head -7
[{
"key": 17341,
"value": 17
},{
"key": 17350,
"value": 17
},{
| 오류 메시지 | 원인 | 수정 |
|---|---|---|
invalid mem access 'map_value_or_null' | 맵 조회 결과 역참조 전 NULL 검사 없음 | if (val) 분기 추가 |
unbounded memory access | 인덱스 범위를 verifier가 알 수 없음 | idx & 15 또는 if (idx < 16) |
BPF program is too large | 루프 상한이 런타임 값 | 상수 상한 추가(i < 64 && i < idx), 또는 bpf_loop() |
주의사항
| 항목 | 내용 |
|---|---|
| 권한 | 일반 사용자로 실행하면 Couldn't load trivial BPF program으로 실패한다. Ubuntu는 kernel.unprivileged_bpf_disabled = 2가 기본값이라 root 또는 CAP_BPF(+ 트레이싱은 CAP_PERFMON)가 필요하다. |
bpf_printk() | 출력이 전역 trace_pipe 하나로 모이고 느리다. 디버깅용으로만 쓰고 실제 데이터는 맵이나 링 버퍼로 보낸다. |
| 라이선스 | bpf_probe_read_str() 등 상당수 헬퍼는 GPL 호환 라이선스 프로그램에서만 호출할 수 있다. LICENSE 섹션을 빼먹으면 로드 단계에서 거부된다. |
| CO-RE 전제 조건 | 대상 커널에 BTF(/sys/kernel/btf/vmlinux)가 있어야 재배치가 된다. 필드가 아예 없는 커널을 지원하려면 bpf_core_field_exists()로 분기한다. |
| tracepoint 선택 | sys_enter_*는 실패한 호출까지 잡고, sched_process_exec는 성공한 exec만 잡는다. 같은 “exec 추적”이라도 결과가 다르다. |
| JIT 역어셈블 | Ubuntu 패키지의 bpftool은 역어셈블러 없이 빌드돼 prog dump jited가 No JIT disassembly support로 끝난다. xlated는 정상 동작한다. |
마무리
clang으로 BPF 오브젝트를 만들고, 스켈레톤으로 로드·부착하고, bpftool로 커널 안의 상태를 확인하는 흐름이 이 시리즈 전체의 기본 틀이다. CO-RE 덕분에 6.8에서 빌드한 바이너리가 6.12에서 그대로 돌았고, verifier 로그만 읽을 줄 알면 거부된 이유를 대부분 바로 찾을 수 있다. 다음 편에서는 커널과 유저 공간 사이의 통로인 BPF 맵과 링 버퍼를 종류별로 비교한다.