eBPF 실전 (2) — BPF 맵과 링 버퍼: per-CPU, LRU, 유실, 맵 고정

eBPF 프로그램은 이벤트 하나를 처리하고 끝나는 짧은 함수라서, 이벤트 사이에 상태를 유지하거나 결과를 유저 공간에 넘기려면 BPF 맵이 필요하다. 문제는 맵 종류가 30가지가 넘고, 잘못 고르면 카운트가 조용히 유실되거나 이벤트가 버려진다는 점이다. 에러 메시지도 없이 숫자만 틀리게 나오므로 원인을 찾기도 어렵다.

이 글에서는 자주 쓰는 맵 종류를 정리하고, 여러 CPU가 같은 값을 갱신할 때의 경쟁, 해시와 LRU 해시의 차이, 링 버퍼 유실, 맵 고정(pinning)을 각각 실험으로 확인한다.

이 시리즈의 다른 글

자주 쓰는 맵 종류

타입용도특징
HASHPID·이름 등 임의 키 집계가득 차면 새 키 삽입이 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/iterBPF 없음 대비
BPF 없음—311.4—
HASH + *v += 145,421건575.7+264
HASH + __sync_fetch_and_add0500.9+190
PERCPU_ARRAY + *v += 10339.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를 비교하고 각각의 오버헤드를 측정한다.

참고

답글 남기기