eBPF 실전 (6) — 페이지 폴트·slab·메모리 누수·OOM 관측하기

메모리 문제는 대부분 사후에 발견된다. OOM으로 프로세스가 죽은 뒤 dmesg 한 줄이 남고, 그 전까지 누가 얼마나 잡아먹고 있었는지는 알 수 없다. /proc/meminfo나 /proc/slabinfo는 지금 이 순간의 스냅샷이라 어떤 작업이 어떤 캐시를 늘렸는지 연결해주지 못하고, 응답이 느려질 때 그게 major 폴트 때문인지도 vmstat의 합계만으로는 판단하기 어렵다.

이 글에서는 메모리 경로 네 곳에 BPF를 붙인다. 페이지 폴트를 minor/major로 나눠 처리 시간까지 재고, slab 캐시별 할당/해제를 집계하고, 유저 공간의 미해제 malloc을 추적하고, OOM 킬을 그 순간에 잡아낸다. 모든 결과는 rusage·/proc/slabinfo·memory.events와 대조해 확인한다.

이 시리즈의 다른 글

페이지 폴트: minor와 major

handle_mm_fault()에 fentry/fexit를 걸면 폴트 한 건의 처리 시간과 결과를 함께 볼 수 있다. 반환값의 VM_FAULT_MAJOR 비트가 디스크 I/O가 필요했는지를 알려준다.

#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
#include <bpf/bpf_core_read.h>

char LICENSE[] SEC("license") = "GPL";

#define VM_FAULT_MAJOR	0x000004
#define VM_FAULT_RETRY	0x000400
#define FAULT_FLAG_TRIED	(1 << 5)
#define MAX_SLOTS	24

const volatile __u32 target_tgid = 0;

struct key_t {
	char comm[16];
};

struct val_t {
	__u64 minor;
	__u64 major;
	__u64 retry;
};

struct {
	__uint(type, BPF_MAP_TYPE_HASH);
	__uint(max_entries, 4096);
	__type(key, struct key_t);
	__type(value, struct val_t);
} counts SEC(".maps");

/* 폴트 처리에 들어간 시각 (pid별) */
struct {
	__uint(type, BPF_MAP_TYPE_HASH);
	__uint(max_entries, 8192);
	__type(key, __u32);
	__type(value, __u64);
} start SEC(".maps");

__u64 major_hist[MAX_SLOTS];	/* major 폴트 처리 시간 (us) */
__u64 minor_hist[MAX_SLOTS];

static __always_inline bool wanted(void)
{
	return !target_tgid || (bpf_get_current_pid_tgid() >> 32) == target_tgid;
}

SEC("fentry/handle_mm_fault")
int BPF_PROG(fault_enter, struct vm_area_struct *vma, unsigned long address,
	     unsigned int flags, struct pt_regs *regs)
{
	__u32 pid = (__u32)bpf_get_current_pid_tgid();
	__u64 ts = bpf_ktime_get_ns();

	if (!wanted())
		return 0;
	bpf_map_update_elem(&start, &pid, &ts, BPF_ANY);
	return 0;
}

SEC("fexit/handle_mm_fault")
int BPF_PROG(fault_exit, struct vm_area_struct *vma, unsigned long address,
	     unsigned int flags, struct pt_regs *regs, int ret)
{
	__u32 pid = (__u32)bpf_get_current_pid_tgid();
	struct key_t key = {};
	struct val_t zero = {}, *val;
	__u64 *tsp, delta, us, slot = 0;
	bool major = ret & VM_FAULT_MAJOR;

	tsp = bpf_map_lookup_elem(&start, &pid);
	if (!tsp)
		return 0;
	delta = bpf_ktime_get_ns() - *tsp;
	bpf_map_delete_elem(&start, &pid);

	bpf_get_current_comm(&key.comm, sizeof(key.comm));
	val = bpf_map_lookup_elem(&counts, &key);
	if (!val) {
		bpf_map_update_elem(&counts, &key, &zero, BPF_NOEXIST);
		val = bpf_map_lookup_elem(&counts, &key);
		if (!val)
			return 0;
	}
	if (major)			/* I/O가 필요한 폴트. RETRY가 같이 실려 온다 */
		__sync_fetch_and_add(&val->major, 1);
	else if (flags & FAULT_FLAG_TRIED)	/* 재시도로 다시 들어온 완료 호출 */
		__sync_fetch_and_add(&val->retry, 1);
	else
		__sync_fetch_and_add(&val->minor, 1);

	us = delta / 1000;
	while (us > 1 && slot < MAX_SLOTS - 1) {
		us >>= 1;
		slot++;
	}
	if (!major && (flags & FAULT_FLAG_TRIED))	/* 완료 호출은 시간에서 뺀다 */
		return 0;
	if (major)
		__sync_fetch_and_add(&major_hist[slot], 1);
	else
		__sync_fetch_and_add(&minor_hist[slot], 1);
	return 0;
}

익명 메모리 256MB와 파일 매핑 64MB를 각각 페이지마다 읽는 프로그램을 만들고, 캐시를 비운 뒤 돌렸다.

/* faultgen anon|file <파일> : minor/major 폴트를 일부러 일으키고 rusage로 센다 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <sys/resource.h>

int main(int argc, char **argv)
{
	const char *mode = argc > 1 ? argv[1] : "anon";
	size_t len = 256 * 1024 * 1024;
	char *p;
	volatile char sink = 0;
	struct rusage ru;

	if (!strcmp(mode, "file")) {
		int fd = open(argv[2], O_RDONLY);
		struct stat st;

		if (fd < 0) { perror("open"); return 1; }
		fstat(fd, &st);
		len = st.st_size;
		p = mmap(NULL, len, PROT_READ, MAP_PRIVATE, fd, 0);
		if (p == MAP_FAILED) { perror("mmap"); return 1; }
		madvise(p, len, MADV_RANDOM);	/* readahead로 major가 묻히지 않게 */
	} else {
		p = mmap(NULL, len, PROT_READ | PROT_WRITE,
			 MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
		if (p == MAP_FAILED) { perror("mmap"); return 1; }
	}

	for (size_t off = 0; off < len; off += 4096)
		sink += p[off];

	getrusage(RUSAGE_SELF, &ru);
	printf("%s: %zu 페이지 접근  rusage minflt=%ld majflt=%ld\n",
	       mode, len / 4096, ru.ru_minflt, ru.ru_majflt);
	return 0;
}
$ sync; echo 3 > /proc/sys/vm/drop_caches
$ sudo ./faults 25 &
$ ./faultgen anon
anon: 65536 페이지 접근  rusage minflt=65609 majflt=0
$ ./faultgen file /tmp/big.bin
file: 16384 페이지 접근  rusage minflt=73 majflt=16384

COMM                  MINOR      MAJOR      TRIED
faultgen              65663      16384      16384
bash                     97          0          0

BPF가 센 major 16384건이 rusage의 majflt와 정확히 일치한다. TRIED 열이 같은 16384인 것이 핵심인데, major 폴트 한 건마다 handle_mm_fault()가 두 번 불리기 때문이다.

호출flags반환값하는 일
1번째—VM_FAULT_MAJOR|VM_FAULT_RETRYI/O를 시작하며 mmap_lock을 놓는다
2번째FAULT_FLAG_TRIED0읽어온 페이지로 매핑을 완성한다

이 구분을 넣지 않으면 두 번째 호출이 minor로 잡혀 minor 수가 major 수만큼 부풀려진다. 실제로 처음 만든 버전은 minor를 82041건으로 셌다(정답은 65663건).

minor 폴트 처리 시간 (usecs) (65766건)
       0 -> 1        : 65633    |****************************************
       2 -> 3        : 86       |
       4 -> 7        : 25       |
       8 -> 15       : 14       |

major 폴트 처리 시간 (usecs) (16384건)
     128 -> 255      : 1866     |*****
     256 -> 511      : 14245    |****************************************
     512 -> 1023     : 201      |
    1024 -> 2047     : 33       |
    2048 -> 4095     : 6        |
    4096 -> 8191     : 12       |
    8192 -> 16383    : 17       |

minor는 1us 미만에 끝나지만 major는 256~511us에 몰려 있다. 같은 “페이지 폴트”라도 두 자릿수 이상 차이가 나므로, 합계만 보는 vmstat으로는 이 차이를 알 수 없다.

slab 캐시별 할당 추적

kmem_cache_alloc/kmem_cache_free tracepoint에 붙어 캐시 이름별로 집계한다. ftrace로 같은 이벤트를 봐도 call_site와 ptr만 나오고 캐시 이름은 없는데, tp_btf로 붙으면 struct kmem_cache *를 그대로 받아 s->name을 읽을 수 있다.

#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
#include <bpf/bpf_core_read.h>

char LICENSE[] SEC("license") = "GPL";

struct key_t {
	char cache[32];
};

struct val_t {
	__u64 allocs;
	__u64 frees;
	__u64 bytes;		/* 할당된 누적 바이트 */
};

struct {
	__uint(type, BPF_MAP_TYPE_HASH);
	__uint(max_entries, 2048);
	__type(key, struct key_t);
	__type(value, struct val_t);
} stats SEC(".maps");

static __always_inline struct val_t *slot(struct kmem_cache *s)
{
	struct key_t key = {};
	struct val_t zero = {}, *v;
	const char *name = BPF_CORE_READ(s, name);

	bpf_probe_read_kernel_str(key.cache, sizeof(key.cache), name);
	v = bpf_map_lookup_elem(&stats, &key);
	if (v)
		return v;
	bpf_map_update_elem(&stats, &key, &zero, BPF_NOEXIST);
	return bpf_map_lookup_elem(&stats, &key);
}

SEC("tp_btf/kmem_cache_alloc")
int BPF_PROG(on_alloc, unsigned long call_site, const void *ptr,
	     struct kmem_cache *s, gfp_t gfp_flags, int node)
{
	struct val_t *v = slot(s);

	if (v) {
		__sync_fetch_and_add(&v->allocs, 1);
		__sync_fetch_and_add(&v->bytes, BPF_CORE_READ(s, size));
	}
	return 0;
}

SEC("tp_btf/kmem_cache_free")
int BPF_PROG(on_free, unsigned long call_site, const void *ptr, struct kmem_cache *s)
{
	struct val_t *v = slot(s);

	if (v)
		__sync_fetch_and_add(&v->frees, 1);
	return 0;
}

dentry 캐시를 비운 직후와, 같은 트리를 한 번 더 훑을 때를 비교했다.

### B1. 캐시를 비운 뒤 첫 find
CACHE                        ALLOCS      FREES        NET       NET_KB
extent_status                 11973       5841       6132          239
dentry                         5896          1       5895         1105
shared_policy_node             5873          0       5873          275
ext4_inode_cache               5872          0       5872         6789
maple_node                      102         32         70           17
dentry active_objs: 6860 -> 12841 (+5981)

### B2. 같은 트리를 바로 다시 find
CACHE                        ALLOCS      FREES        NET       NET_KB
mbcache                           2          0          2            0
jbd2_journal_head                 2          0          2            0
dentry                            3          2          1            0
dentry active_objs: 12841 -> 12841 (+0)

BPF가 센 dentry 순증 5895건이 /proc/slabinfo의 active_objs 증가분 5981과 맞고, 두 번째 find는 dentry를 3건만 할당했다. 캐시가 살아 있으면 같은 작업이 slab을 전혀 건드리지 않는다는 뜻이다.

유저 공간 메모리 누수 찾기

libc의 malloc에 uprobe/uretprobe를, free에 uprobe를 걸고 주소별로 짝을 맞춘다. 짝이 맞지 않고 남은 것이 미해제 할당이다.

#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>

char LICENSE[] SEC("license") = "GPL";

const volatile __u32 target_tgid = 0;

/* malloc 진입 시 요청 크기를 스레드별로 기억한다 */
struct {
	__uint(type, BPF_MAP_TYPE_HASH);
	__uint(max_entries, 8192);
	__type(key, __u32);
	__type(value, __u64);
} sizes SEC(".maps");

/* 아직 free되지 않은 할당: 주소 -> 크기 */
struct {
	__uint(type, BPF_MAP_TYPE_HASH);
	__uint(max_entries, 1000000);
	__type(key, __u64);
	__type(value, __u64);
} allocs SEC(".maps");

/* 크기별 미해제 건수 */
struct {
	__uint(type, BPF_MAP_TYPE_HASH);
	__uint(max_entries, 4096);
	__type(key, __u64);
	__type(value, __s64);
} by_size SEC(".maps");

__u64 nr_malloc, nr_free, nr_untracked;

static __always_inline bool wanted(void)
{
	return !target_tgid || (bpf_get_current_pid_tgid() >> 32) == target_tgid;
}

static __always_inline void bump(__u64 size, __s64 delta)
{
	__s64 zero = 0, *cnt;

	cnt = bpf_map_lookup_elem(&by_size, &size);
	if (!cnt) {
		bpf_map_update_elem(&by_size, &size, &zero, BPF_NOEXIST);
		cnt = bpf_map_lookup_elem(&by_size, &size);
		if (!cnt)
			return;
	}
	__sync_fetch_and_add(cnt, delta);
}

SEC("uprobe")
int BPF_UPROBE(malloc_enter, size_t size)
{
	__u32 tid = (__u32)bpf_get_current_pid_tgid();
	__u64 sz = size;

	if (!wanted())
		return 0;
	bpf_map_update_elem(&sizes, &tid, &sz, BPF_ANY);
	return 0;
}

SEC("uretprobe")
int BPF_URETPROBE(malloc_exit, void *ret)
{
	__u32 tid = (__u32)bpf_get_current_pid_tgid();
	__u64 addr = (__u64)ret, *szp;

	szp = bpf_map_lookup_elem(&sizes, &tid);
	if (!szp || !addr)
		return 0;
	bpf_map_update_elem(&allocs, &addr, szp, BPF_ANY);
	bump(*szp, 1);
	__sync_fetch_and_add(&nr_malloc, 1);
	bpf_map_delete_elem(&sizes, &tid);
	return 0;
}

SEC("uprobe")
int BPF_UPROBE(free_enter, void *ptr)
{
	__u64 addr = (__u64)ptr, *szp;

	if (!wanted() || !addr)
		return 0;
	szp = bpf_map_lookup_elem(&allocs, &addr);
	if (!szp) {			/* 추적 시작 전에 할당된 것 */
		__sync_fetch_and_add(&nr_untracked, 1);
		return 0;
	}
	bump(*szp, -1);
	bpf_map_delete_elem(&allocs, &addr);
	__sync_fetch_and_add(&nr_free, 1);
	return 0;
}

심볼 이름으로 붙이려면 bpf_program__attach_uprobe_opts()에 func_name을 넘겨야 한다. 오프셋 인자에 0을 주는 옛 API를 쓰면 엉뚱하게 ELF 진입점에 붙는다.

LIBBPF_OPTS(bpf_uprobe_opts, m_in,  .func_name = "malloc", .retprobe = false);
LIBBPF_OPTS(bpf_uprobe_opts, m_out, .func_name = "malloc", .retprobe = true);
LIBBPF_OPTS(bpf_uprobe_opts, f_in,  .func_name = "free",   .retprobe = false);

bpf_program__attach_uprobe_opts(skel->progs.malloc_enter, -1, libc, 0, &m_in);
bpf_program__attach_uprobe_opts(skel->progs.malloc_exit,  -1, libc, 0, &m_out);
bpf_program__attach_uprobe_opts(skel->progs.free_enter,   -1, libc, 0, &f_in);

4096바이트만 누수시키고 나머지 크기는 정상적으로 해제하는 프로그램으로 확인했다.

/* leaky <초>: 4096바이트 블록만 누수시키면서 다른 크기는 정상적으로 해제한다.
 * sink에 대입해 두지 않으면 GCC가 malloc/free 쌍을 통째로 제거한다. */
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <stdio.h>

static void * volatile sink;

int main(int argc, char **argv)
{
	int sec = argc > 1 ? atoi(argv[1]) : 5;
	long leaked = 0;

	alarm(sec + 2);
	for (int i = 0; i < sec * 1000; i++) {
		void *keep = malloc(4096);	/* 일부러 free하지 않는다 */
		void *tmp1 = malloc(128);
		void *tmp2 = malloc(65536);

		sink = keep; sink = tmp1; sink = tmp2;
		memset(keep, 1, 4096);
		memset(tmp1, 2, 128);
		memset(tmp2, 3, 65536);
		free(tmp1);
		free(tmp2);
		leaked += 4096;
		usleep(1000);
	}
	printf("leaky: %ld KB 누수\n", leaked / 1024);
	return 0;
}
$ ./leaky 6 & sudo ./memleak 7 $!
malloc=2997 free=1997 추적밖_free=0
        SIZE 미해제건수  미해제KB
        4096        999         3996
         128          1            0
미해제 합계: 3996 KB

2997번의 malloc 중 1997번만 해제됐고, 남은 999건이 전부 4096바이트로 한 크기에 몰렸다. 128바이트 1건은 측정 시점에 아직 해제 전이던 것이다.

OOM 킬을 그 순간에 잡기

oom/mark_victim tracepoint는 희생자 태스크를 그대로 넘겨준다. 여기에 oom_kill_process() 진입을 붙여 한도와 cgroup 여부까지 함께 담는다.

#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
#include <bpf/bpf_core_read.h>

char LICENSE[] SEC("license") = "GPL";

struct event {
	__u32 pid;
	__u32 killer_pid;
	__u64 rss_pages;
	__u64 totalpages;
	__u8  cgroup_oom;	/* 1이면 cgroup 한도, 0이면 시스템 전역 */
	char  comm[16];
	char  killer[16];
};

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

/* out_of_memory()에 들어온 판단 근거를 스레드별로 기억해 둔다 */
struct {
	__uint(type, BPF_MAP_TYPE_HASH);
	__uint(max_entries, 256);
	__type(key, __u32);
	__type(value, struct event);
} pending SEC(".maps");

/* out_of_memory() 진입 시점에는 oc->totalpages가 아직 0이다.
 * 희생자를 고른 뒤 호출되는 oom_kill_process()에서 읽어야 값이 차 있다. */
SEC("fentry/oom_kill_process")
int BPF_PROG(on_ooc, struct oom_control *oc, const char *message)
{
	__u32 tid = (__u32)bpf_get_current_pid_tgid();
	struct event e = {};

	e.totalpages = BPF_CORE_READ(oc, totalpages);
	e.cgroup_oom = BPF_CORE_READ(oc, memcg) != NULL;
	bpf_map_update_elem(&pending, &tid, &e, BPF_ANY);
	return 0;
}

SEC("tp_btf/mark_victim")
int BPF_PROG(on_victim, struct task_struct *task, uid_t uid)
{
	__u32 tid = (__u32)bpf_get_current_pid_tgid();
	struct event *p, *e;
	struct mm_struct *mm;
	__s64 anon = 0;

	e = bpf_ringbuf_reserve(&events, sizeof(*e), 0);
	if (!e)
		return 0;
	p = bpf_map_lookup_elem(&pending, &tid);
	e->totalpages = p ? p->totalpages : 0;
	e->cgroup_oom = p ? p->cgroup_oom : 0;
	e->pid = BPF_CORE_READ(task, pid);
	/* 6.2부터 rss_stat은 percpu_counter 배열이라 BPF_CORE_READ 체인으로 못 뚫는다 */
	mm = BPF_CORE_READ(task, mm);
	if (mm)
		bpf_core_read(&anon, sizeof(anon), &mm->rss_stat[MM_ANONPAGES].count);
	e->rss_pages = anon > 0 ? anon : 0;
	bpf_probe_read_kernel_str(e->comm, sizeof(e->comm), BPF_CORE_READ(task, comm));
	e->killer_pid = bpf_get_current_pid_tgid() >> 32;
	bpf_get_current_comm(&e->killer, sizeof(e->killer));
	bpf_ringbuf_submit(e, 0);
	bpf_map_delete_elem(&pending, &tid);
	return 0;
}

64MB로 제한한 cgroup에 메모리를 계속 잡는 프로세스를 넣어 실제로 죽였다.

mkdir -p /sys/fs/cgroup/oomtest
echo 64M > /sys/fs/cgroup/oomtest/memory.max
echo 0 > /sys/fs/cgroup/oomtest/memory.swap.max

./oomwatch 25 &
sleep 2
./hog > /tmp/hog.txt 2>&1 &
echo $! > /sys/fs/cgroup/oomtest/cgroup.procs
OOM 감시 시작 (25초)
cgroup.procs: 11889
OOM kill: pid=11889 comm=hog  anon_rss=63MB  한도=64MB  범위=cgroup  트리거=hog(11889)
hog 마지막 출력: 60MB

--- memory.events
max 38
oom 1
oom_kill 1

--- dmesg
Memory cgroup out of memory: Killed process 11889 (hog) total-vm:68284kB,
  anon-rss:65152kB, file-rss:1536kB, shmem-rss:0kB, UID:0 pgtables:184kB

BPF가 읽은 anon_rss=63MB가 커널이 dmesg에 남긴 anon-rss:65152kB와 같은 값이고, memory.events의 oom_kill=1과도 맞는다. 프로세스가 죽기 전 상태를 로그를 뒤지지 않고 그 자리에서 얻은 셈이다.

주의사항

  • handle_mm_fault()는 major 폴트 한 건에 두 번 호출된다. FAULT_FLAG_TRIED가 붙은 두 번째 호출을 빼지 않으면 minor 수가 major 수만큼 부풀려진다.
  • out_of_memory() 진입 시점에는 oc->totalpages가 아직 0이다. 처음에 여기에 fentry를 걸었다가 한도가 0MB로 찍혔고, 희생자를 고른 뒤 불리는 oom_kill_process()로 옮겨서야 값이 찼다.
  • 커널 6.2부터 mm->rss_stat은 struct percpu_counter[4]라 BPF_CORE_READ() 체인으로 배열 인덱스를 못 뚫는다. mm을 먼저 읽고 bpf_core_read(&v, sizeof(v), &mm->rss_stat[MM_ANONPAGES].count)로 따로 읽어야 한다.
  • kmem_cache_alloc의 ftrace 포맷에는 캐시 이름이 없다(call_site·ptr·bytes_req만 있다). 이름이 필요하면 tp_btf로 붙어 struct kmem_cache *에서 직접 읽어야 한다.
  • libc의 malloc에 건 uprobe는 그 라이브러리를 쓰는 모든 프로세스에 걸린다. 위 도구가 target_tgid 필터를 가진 이유이며, 필터 없이 붙이면 시스템 전체가 느려진다.
  • 추적을 시작하기 전에 할당된 메모리는 free만 보이므로 미해제 집계가 음수로 기울 수 있다. nr_untracked로 그 건수를 따로 세어 두면 결과를 해석할 때 도움이 된다.
  • GCC는 결과가 밖으로 새지 않는 malloc/free 쌍을 통째로 지운다. 테스트 프로그램의 임시 할당이 추적에 하나도 잡히지 않아 한참 헤맸는데, 포인터를 volatile 전역에 대입하고 나서야 정상적으로 잡혔다.

마무리

페이지 폴트·slab·유저 공간 할당·OOM을 각각 하나의 작은 BPF 프로그램으로 관측했고, 네 결과 모두 커널이 따로 제공하는 숫자와 대조해 맞는 것을 확인했다. 특히 major 폴트의 이중 호출과 oc->totalpages의 타이밍은 교차 검증을 하지 않았다면 그대로 틀린 값을 믿을 뻔한 경우였다.

다음 편에서는 네트워크로 옮겨가 XDP와 tc에 BPF를 붙여 패킷을 직접 처리한다.

참고

답글 남기기