eBPF 프로그램은 이벤트 하나를 처리하고 끝나는 짧은 함수라서, 이벤트 사이에 상태를 유지하거나 결과를 유저 공간에 넘기려면 BPF 맵이 필요하다. 문제는 맵 종류가 30가지가 넘고, 잘못 고르면 카운트가 조용히 유실되거나 이벤트가 버려진다는 점이다. 에러 메시지도 없이 숫자만 틀리게 나오므로 원인을 찾기도 어렵다.
이 글에서는 자주 쓰는 맵 종류를 정리하고, 여러 CPU가 같은 값을 갱신할 때의 경쟁, 해시와 LRU 해시의 차이, 링 버퍼 유실, 맵 고정(pinning)을 각각 실험으로 확인한다.
이 시리즈의 다른 글
- eBPF 실전 (1) — libbpf와 CO-RE로 첫 커널 프로그램 작성하기
- eBPF 실전 (3) — kprobe·fentry·tracepoint·uprobe 훅 오버헤드 비교
- eBPF 실전 (4) — 런큐 지연과 컨텍스트 스위치 관측하기
- eBPF 실전 (5) — sched_ext로 커널 스케줄러 직접 구현하기
- eBPF 실전 (6) — 페이지 폴트·slab·메모리 누수·OOM 관측하기
자주 쓰는 맵 종류
| 타입 | 용도 | 특징 |
|---|---|---|
HASH | PID·이름 등 임의 키 집계 | 가득 차면 새 키 삽입이 E2BIG으로 실패 |
ARRAY | 고정 인덱스 설정값·카운터 | 원소가 미리 할당되고 삭제 불가, 조회가 가장 빠름 |
PERCPU_HASH / PERCPU_ARRAY | 고빈도 카운터 | CPU마다 값이 따로 있어 경쟁이 없음, 읽을 때 합산 |
LRU_HASH | 커넥션·플로우 추적 | 가득 차면 오래된 항목을 밀어냄 |
RINGBUF | 이벤트 스트림 전달 | 모든 CPU가 공유하는 버퍼 하나, 순서 보장 |
PERF_EVENT_ARRAY | 이벤트 스트림(구형) | CPU별 버퍼, 5.8 이전 커널용 |
STACK_TRACE | 스택 수집 | 스택 ID로 중복 제거 |
C 전역 변수도 맵이다. .bss·.data·.rodata 섹션이 각각 원소 하나짜리 배열 맵으로 만들어지고, 스켈레톤이 skel->bss->변수 형태로 유저 공간에서 접근하게 해 준다.
여러 CPU가 같은 카운터를 갱신할 때
getppid()를 4개 스레드에서 200만 번씩 호출하고, 그 횟수를 BPF로 센다. mode에 따라 해시 맵의 값을 그냥 더하거나, 원자적으로 더하거나, per-CPU 배열에 더한다.
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
char LICENSE[] SEC("license") = "GPL";
const volatile int mode = 1; /* 로드 전에 유저 공간에서 설정 */
const volatile __u32 target_tgid = 0;
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, 1);
__type(key, u32);
__type(value, u64);
} hash_cnt SEC(".maps");
struct {
__uint(type, BPF_MAP_TYPE_PERCPU_ARRAY);
__uint(max_entries, 1);
__type(key, u32);
__type(value, u64);
} pcpu_cnt SEC(".maps");
SEC("tp/syscalls/sys_enter_getppid")
int count(void *ctx)
{
u32 key = 0;
u64 *v;
if ((bpf_get_current_pid_tgid() >> 32) != target_tgid)
return 0;
if (mode == 3) {
v = bpf_map_lookup_elem(&pcpu_cnt, &key);
if (v)
*v += 1; /* CPU마다 별도 슬롯 */
return 0;
}
v = bpf_map_lookup_elem(&hash_cnt, &key);
if (!v)
return 0;
if (mode == 1)
*v += 1; /* 비원자적 증가 */
else
__sync_fetch_and_add(v, 1); /* 원자적 증가 */
return 0;
}const volatile 전역 변수는 .rodata에 들어가고, 유저 공간이 로드 전에 값을 써 넣을 수 있다. target_tgid로 다른 프로세스의 getppid()를 걸러 낸다.
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <time.h>
#include <unistd.h>
#include <sys/syscall.h>
#include <bpf/libbpf.h>
#include <bpf/bpf.h>
#include "counter.skel.h"
#define ITERS 2000000
static void *worker(void *arg)
{
for (int i = 0; i < ITERS; i++)
syscall(SYS_getppid);
return NULL;
}
int main(int argc, char **argv)
{
int mode = argc > 1 ? atoi(argv[1]) : 1;
int nthr = argc > 2 ? atoi(argv[2]) : 4;
int ncpu = libbpf_num_possible_cpus();
struct counter_bpf *skel = NULL;
pthread_t th[64];
struct timespec t0, t1;
unsigned long long total = 0;
unsigned int key = 0;
if (mode > 0) {
skel = counter_bpf__open();
skel->rodata->mode = mode; /* const volatile 값 주입 */
skel->rodata->target_tgid = getpid();
if (counter_bpf__load(skel) || counter_bpf__attach(skel))
return 1;
unsigned long long zero = 0;
bpf_map__update_elem(skel->maps.hash_cnt, &key, sizeof(key), &zero, sizeof(zero), BPF_ANY);
}
clock_gettime(CLOCK_MONOTONIC, &t0);
for (int i = 0; i < nthr; i++)
pthread_create(&th[i], NULL, worker, NULL);
for (int i = 0; i < nthr; i++)
pthread_join(th[i], NULL);
clock_gettime(CLOCK_MONOTONIC, &t1);
if (mode == 3) {
unsigned long long vals[ncpu];
bpf_map__lookup_elem(skel->maps.pcpu_cnt, &key, sizeof(key), vals, sizeof(vals), 0);
for (int c = 0; c < ncpu; c++)
total += vals[c];
} else if (mode > 0) {
bpf_map__lookup_elem(skel->maps.hash_cnt, &key, sizeof(key), &total, sizeof(total), 0);
}
double sec = (t1.tv_sec - t0.tv_sec) + (t1.tv_nsec - t0.tv_nsec) / 1e9;
unsigned long long expect = (unsigned long long)nthr * ITERS;
printf("mode=%d threads=%d expected=%llu counted=%llu lost=%lld %.1f ns/iter\n",
mode, nthr, expect, total, mode ? (long long)(expect - total) : 0LL, sec * 1e9 / ITERS);
counter_bpf__destroy(skel);
return 0;
}per-CPU 맵을 조회하면 가능한 CPU 수만큼의 값 배열이 돌아오므로 유저 공간에서 합산한다.
$ sudo ./counter 0 4; sudo ./counter 1 4; sudo ./counter 2 4; sudo ./counter 3 4
mode=0 threads=4 expected=8000000 counted=0 lost=0 311.4 ns/iter
mode=1 threads=4 expected=8000000 counted=7954579 lost=45421 575.7 ns/iter
mode=2 threads=4 expected=8000000 counted=8000000 lost=0 500.9 ns/iter
mode=3 threads=4 expected=8000000 counted=8000000 lost=0 339.3 ns/iter
비원자적 증가(mode=1)는 4만 5천 건을 잃었고, 원자적 증가(mode=2)는 정확하지만 캐시 라인을 두고 CPU끼리 다퉈 느리다. per-CPU 배열(mode=3)은 정확하면서 BPF가 없을 때(mode=0)와 거의 같은 속도다.
$ for m in 0 1 2 3; do sudo ./counter $m 1; done
mode=0 threads=1 expected=2000000 counted=0 lost=0 192.8 ns/iter
mode=1 threads=1 expected=2000000 counted=2000000 lost=0 373.9 ns/iter
mode=2 threads=1 expected=2000000 counted=2000000 lost=0 391.7 ns/iter
mode=3 threads=1 expected=2000000 counted=2000000 lost=0 353.7 ns/iter
스레드가 하나면 경쟁이 없어 세 방식 모두 정확하다. 단일 스레드 테스트만으로는 mode=1의 버그를 찾을 수 없다는 뜻이다.
| 방식 | 4스레드 유실 | 4스레드 ns/iter | BPF 없음 대비 |
|---|---|---|---|
| BPF 없음 | — | 311.4 | — |
HASH + *v += 1 | 45,421건 | 575.7 | +264 |
HASH + __sync_fetch_and_add | 0 | 500.9 | +190 |
PERCPU_ARRAY + *v += 1 | 0 | 339.3 | +28 |
const volatile과 죽은 코드 제거
.rodata는 로드 시점에 얼어붙기(freeze) 때문에 verifier가 mode 값을 상수로 보고 쓰이지 않는 분기를 잘라낸다. mode=3으로 로드한 프로그램의 xlated 코드다.
$ sudo bpftool prog dump xlated name count
int count(void * ctx):
; int count(void *ctx)
0: (b7) r1 = 0
; u32 key = 0;
1: (63) *(u32 *)(r10 -4) = r1
; if ((bpf_get_current_pid_tgid() >> 32) != target_tgid)
2: (85) call bpf_get_current_pid_tgid#257216
; if ((bpf_get_current_pid_tgid() >> 32) != target_tgid)
3: (18) r1 = map[id:301][0]+4
5: (61) r1 = *(u32 *)(r1 +0)
; if ((bpf_get_current_pid_tgid() >> 32) != target_tgid)
6: (77) r0 >>= 32
; if ((bpf_get_current_pid_tgid() >> 32) != target_tgid)
7: (5d) if r0 != r1 goto pc+12
; if (mode == 3) {
8: (18) r1 = map[id:301][0]+0
10: (61) r1 = *(u32 *)(r1 +0)
; if (mode == 3) {
11: (bf) r2 = r10
; v = bpf_map_lookup_elem(&pcpu_cnt, &key);
12: (07) r2 += -4
13: (18) r1 = map[id:298]
15: (85) call percpu_array_map_lookup_elem#321568
; if (v)
16: (15) if r0 == 0x0 goto pc+3
;
17: (79) r1 = *(u64 *)(r0 +0)
18: (07) r1 += 1
19: (7b) *(u64 *)(r0 +0) = r1
; }
20: (b7) r0 = 0
21: (95) exit
mode == 3 비교 분기와 해시 맵 경로가 통째로 사라졌고, 오브젝트 파일의 명령 32개가 22개로 줄었다. bpf_map_lookup_elem() 호출도 맵 타입 전용 함수(percpu_array_map_lookup_elem)로 바뀌었다.
$ llvm-objdump -d counter.bpf.o | grep -cE "^ +[0-9]+:"
32
$ # 로드된 프로그램의 xlated 크기
mode=1 xlated 240B jited 139B
mode=2 xlated 208B jited 131B
mode=3 xlated 176B jited 111B
HASH와 LRU_HASH: 맵이 가득 찼을 때
최대 원소 4개짜리 해시와 LRU 해시에 키 1~8을 차례로 넣는다. BPF 프로그램 없이 맵만 정의해도 스켈레톤이 생성된다.
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
char LICENSE[] SEC("license") = "GPL";
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, 4);
__type(key, u32);
__type(value, u64);
} plain SEC(".maps");
struct {
__uint(type, BPF_MAP_TYPE_LRU_HASH);
__uint(max_entries, 4);
__type(key, u32);
__type(value, u64);
} lru SEC(".maps");#include <stdio.h>
#include <string.h>
#include <errno.h>
#include <bpf/libbpf.h>
#include "lru.skel.h"
static void dump(const char *name, struct bpf_map *m)
{
unsigned int key, next, *prev = NULL;
printf("%-6s keys:", name);
while (!bpf_map__get_next_key(m, prev, &next, sizeof(next))) {
printf(" %u", next);
key = next;
prev = &key;
}
printf("\n");
}
int main(void)
{
struct lru_bpf *skel = lru_bpf__open_and_load();
unsigned long long val = 0;
if (!skel)
return 1;
for (unsigned int k = 1; k <= 8; k++) {
int e1 = bpf_map__update_elem(skel->maps.plain, &k, sizeof(k), &val, sizeof(val), BPF_ANY);
int e2 = bpf_map__update_elem(skel->maps.lru, &k, sizeof(k), &val, sizeof(val), BPF_ANY);
printf("insert %u: hash=%-8s lru=%s\n", k, e1 ? strerror(-e1) : "ok", e2 ? strerror(-e2) : "ok");
}
dump("hash", skel->maps.plain);
dump("lru", skel->maps.lru);
lru_bpf__destroy(skel);
return 0;
}$ sudo ./lru
insert 1: hash=ok lru=ok
insert 2: hash=ok lru=ok
insert 3: hash=ok lru=ok
insert 4: hash=ok lru=ok
insert 5: hash=Argument list too long lru=ok
insert 6: hash=Argument list too long lru=ok
insert 7: hash=Argument list too long lru=ok
insert 8: hash=Argument list too long lru=ok
hash keys: 3 4 2 1
lru keys: 6 8 5 7
일반 해시는 5번째부터 E2BIG으로 거부하고, LRU는 삽입을 모두 받아들인 뒤 최근 4개만 남겼다. 종료된 프로세스의 키를 지우지 않는 도구라면 일반 해시는 언젠가 가득 차서 새 프로세스를 영영 못 세게 된다.
링 버퍼: 소비자가 따라가지 못할 때
생산자 스레드가 getppid()를 호출할 때마다 64바이트짜리 이벤트를 링 버퍼에 넣는다. 공간이 없어 bpf_ringbuf_reserve()가 실패하면 dropped를 올린다.
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
char LICENSE[] SEC("license") = "GPL";
struct event {
u32 pid;
u64 ts;
char pad[48];
};
struct {
__uint(type, BPF_MAP_TYPE_RINGBUF);
__uint(max_entries, 4096); /* 유저 공간에서 로드 전에 바꾼다 */
} rb SEC(".maps");
__u64 dropped = 0;
const volatile __u32 target_tgid = 0;
const volatile __u64 submit_flags = 0;
SEC("tp/syscalls/sys_enter_getppid")
int produce(void *ctx)
{
struct event *e;
if ((bpf_get_current_pid_tgid() >> 32) != target_tgid)
return 0;
e = bpf_ringbuf_reserve(&rb, sizeof(*e), 0);
if (!e) {
__sync_fetch_and_add(&dropped, 1); /* 공간 부족 */
return 0;
}
e->pid = bpf_get_current_pid_tgid();
e->ts = bpf_ktime_get_ns();
bpf_ringbuf_submit(e, submit_flags);
return 0;
}#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>
#include <sys/syscall.h>
#include <bpf/libbpf.h>
#include "ringdrop.skel.h"
#define EVENTS (delay_us ? 20000 : 200000)
static unsigned long long received;
static volatile int done;
static int delay_us;
static int on_event(void *ctx, void *data, size_t len) { received++; return 0; }
static void *producer(void *arg)
{
for (int i = 0; i < EVENTS; i++) {
syscall(SYS_getppid);
if (delay_us)
usleep(delay_us);
}
done = 1;
return NULL;
}
int main(int argc, char **argv)
{
unsigned int size = atoi(argv[1]);
int poll_ms = argc > 2 ? atoi(argv[2]) : 10;
int nowakeup = argc > 4 ? atoi(argv[4]) : 0;
delay_us = argc > 3 ? atoi(argv[3]) : 0;
struct ringdrop_bpf *skel = ringdrop_bpf__open();
pthread_t th;
bpf_map__set_max_entries(skel->maps.rb, size); /* 링 버퍼 크기 */
skel->rodata->target_tgid = getpid();
if (nowakeup)
skel->rodata->submit_flags = BPF_RB_NO_WAKEUP;
if (ringdrop_bpf__load(skel) || ringdrop_bpf__attach(skel))
return 1;
struct ring_buffer *rb = ring_buffer__new(bpf_map__fd(skel->maps.rb), on_event, NULL, NULL);
pthread_create(&th, NULL, producer, NULL);
while (!done) {
if (poll_ms == 0) {
ring_buffer__poll(rb, 100); /* 이벤트가 오면 바로 깨어남 */
continue;
}
usleep(poll_ms * 1000); /* 느린 소비자 흉내 */
ring_buffer__consume(rb);
}
pthread_join(th, NULL);
ring_buffer__consume(rb);
printf("ringbuf %7u B, poll %2d ms, delay %d us, nowakeup %d: received=%6llu dropped=%6llu\n",
size, poll_ms, delay_us, nowakeup, received, skel->bss->dropped);
ring_buffer__free(rb);
ringdrop_bpf__destroy(skel);
return 0;
}bpf_map__set_max_entries()로 링 버퍼 크기를 로드 전에 바꾼다. 인자는 버퍼 크기, 폴링 주기(0이면 이벤트가 오는 즉시 깨어남), 이벤트 사이 지연, BPF_RB_NO_WAKEUP 사용 여부다.
현실적인 이벤트 속도: 50µs 간격, 2만 건
$ sudo ./ringdrop 4096 0 50
ringbuf 4096 B, poll 0 ms, delay 50 us, nowakeup 0: received= 20000 dropped= 0
$ sudo ./ringdrop 4096 10 50
ringbuf 4096 B, poll 10 ms, delay 50 us, nowakeup 0: received= 18165 dropped= 1835
$ sudo ./ringdrop 65536 10 50
ringbuf 65536 B, poll 10 ms, delay 50 us, nowakeup 0: received= 20000 dropped= 0
4KB 버퍼에 이벤트 72바이트(헤더 8바이트 포함)면 56개밖에 안 들어간다. 10ms 폴링 사이에 쌓이는 이벤트를 다 담지 못해 유실됐고, 64KB로 늘리니 사라졌다.
버스트: 지연 없이 20만 건(약 14MB)
$ for i in 1 2 3; do sudo ./ringdrop 1048576 0; done
ringbuf 1048576 B, poll 0 ms, delay 0 us, nowakeup 0: received=196895 dropped= 3105
ringbuf 1048576 B, poll 0 ms, delay 0 us, nowakeup 0: received=155083 dropped= 44917
ringbuf 1048576 B, poll 0 ms, delay 0 us, nowakeup 0: received=200000 dropped= 0
$ for i in 1 2 3; do sudo ./ringdrop 16777216 0; done
ringbuf 16777216 B, poll 0 ms, delay 0 us, nowakeup 0: received=200000 dropped= 0
ringbuf 16777216 B, poll 0 ms, delay 0 us, nowakeup 0: received=200000 dropped= 0
ringbuf 16777216 B, poll 0 ms, delay 0 us, nowakeup 0: received=200000 dropped= 0
$ for i in 1 2 3; do sudo ./ringdrop 1048576 10; done
ringbuf 1048576 B, poll 10 ms, delay 0 us, nowakeup 0: received= 87839 dropped=112161
ringbuf 1048576 B, poll 10 ms, delay 0 us, nowakeup 0: received= 53442 dropped=146558
ringbuf 1048576 B, poll 10 ms, delay 0 us, nowakeup 0: received= 87871 dropped=112129
생산 속도(초당 약 300만 건)가 소비 속도를 넘으면 1MB 버퍼는 회차마다 유실량이 크게 흔들린다. 버스트 전체를 담을 수 있는 16MB에서만 안정적으로 0이 됐다.
| 상황 | 대응 |
|---|---|
| 이벤트 속도가 낮고 폴링이 느림 | 버퍼를 폴링 주기 동안 쌓이는 양보다 크게 |
| 짧은 버스트 | 버스트 전체를 담을 크기로, 또는 ring_buffer__poll()로 즉시 소비 |
| 지속적으로 생산 > 소비 | 버퍼로는 해결 불가. 커널 안에서 맵으로 집계하거나 샘플링해서 이벤트 수 자체를 줄인다 |
| 유실 여부 확인 | bpf_ringbuf_reserve() 실패를 전역 변수로 세서 항상 노출 |
맵 고정(pinning)으로 로더 수명과 분리하기
맵은 참조하는 fd가 모두 닫히면 사라진다. LIBBPF_PIN_BY_NAME을 지정하면 libbpf가 /sys/fs/bpf/에 맵을 고정하고, 다음 실행 때 같은 이름의 맵을 찾아 재사용한다.
#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, char[16]);
__type(value, u64);
__uint(pinning, LIBBPF_PIN_BY_NAME); /* /sys/fs/bpf/exec_count 에 고정 */
} exec_count SEC(".maps");
SEC("tp/sched/sched_process_exec")
int count_exec(void *ctx)
{
char comm[16] = {};
u64 one = 1, *v;
bpf_get_current_comm(comm, sizeof(comm));
v = bpf_map_lookup_elem(&exec_count, comm);
if (v)
__sync_fetch_and_add(v, 1);
else
bpf_map_update_elem(&exec_count, comm, &one, BPF_NOEXIST);
return 0;
}#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include "pin.skel.h"
int main(int argc, char **argv)
{
int sec = argc > 1 ? atoi(argv[1]) : 5;
struct pin_bpf *skel = pin_bpf__open_and_load(); /* 고정된 맵이 있으면 재사용 */
if (!skel || pin_bpf__attach(skel))
return 1;
sleep(sec);
pin_bpf__destroy(skel); /* 프로그램은 떼어지지만 맵은 남는다 */
return 0;
}$ sudo ./pin 3 & # 3초 동안 exec를 센다
$ for i in 1 2 3; do sudo -u noble date >/dev/null; done; wait
$ ls -l /sys/fs/bpf/
total 0
-rw------- 1 root root 0 Sep 19 16:01 exec_count
$ sudo bpftool map dump pinned /sys/fs/bpf/exec_count
"key": "date",
"value": 3
--
"key": "sudo",
"value": 3
로더가 종료된 뒤에도 맵과 값이 남아 있다. 한 번 더 실행하고 date를 두 번 호출하면 3에서 이어서 센다.
$ sudo ./pin 3 &
$ for i in 1 2; do sudo -u noble date >/dev/null; done; wait
$ sudo bpftool map lookup pinned /sys/fs/bpf/exec_count key 0x64 0x61 0x74 0x65 0 0 0 0 0 0 0 0 0 0 0 0
{
"key": "date",
"value": 5
}
$ sudo rm /sys/fs/bpf/exec_count
키가 char[16]이라 bpftool에는 16바이트를 모두 넘겨야 한다. BTF가 있으면 출력은 문자열로 보기 좋게 풀어 준다.
주의사항
| 항목 | 내용 |
|---|---|
| 비원자적 갱신 | *v += 1은 여러 CPU가 같은 해시 값을 동시에 갱신하면 유실된다. 공유 값은 __sync_fetch_and_add(), 고빈도 카운터는 per-CPU 맵을 쓴다. |
| per-CPU 값 크기 | 유저 공간 조회 버퍼는 libbpf_num_possible_cpus() × 값 크기(8바이트 단위 정렬)여야 한다. bpf_map__lookup_elem()은 크기가 다르면 -EINVAL로 거부하지만, 저수준 bpf_map_lookup_elem()은 검사 없이 버퍼 끝을 넘겨 쓴다. |
| 해시 맵 용량 | 가득 차면 삽입이 실패할 뿐 에러가 드러나지 않는다. 종료 이벤트에서 키를 지우거나 LRU를 쓴다. |
| LRU는 근사치 | 커널 문서가 밝히듯 LRU 퇴출은 근사 알고리즘이라 항상 가장 오래된 키가 빠진다는 보장은 없다. 반드시 남아야 하는 키가 있다면 일반 해시와 명시적 삭제를 쓴다. |
| 링 버퍼 크기 | 페이지 크기의 2의 거듭제곱이어야 한다. 이벤트마다 8바이트 헤더가 붙는다. |
| 고정한 맵 정리 | 고정한 맵은 로더가 끝나도 남아 메모리를 계속 쓴다. 맵 정의(키·값 크기)를 바꾸고 다시 실행하면 기존 맵과 맞지 않아 로드가 실패하므로 파일을 먼저 지운다. |
마무리
같은 카운터라도 맵 종류와 갱신 방식에 따라 유실과 오버헤드가 크게 달라졌다. 고빈도 집계는 per-CPU 맵, 수명이 긴 키는 LRU, 이벤트 스트림은 넉넉한 링 버퍼와 유실 카운터가 기본값이라고 보면 된다. 다음 편에서는 BPF 프로그램을 붙일 수 있는 훅인 kprobe, fentry, tracepoint, uprobe를 비교하고 각각의 오버헤드를 측정한다.