메모리 문제는 대부분 사후에 발견된다. OOM으로 프로세스가 죽은 뒤 dmesg 한 줄이 남고, 그 전까지 누가 얼마나 잡아먹고 있었는지는 알 수 없다. /proc/meminfo나 /proc/slabinfo는 지금 이 순간의 스냅샷이라 어떤 작업이 어떤 캐시를 늘렸는지 연결해주지 못하고, 응답이 느려질 때 그게 major 폴트 때문인지도 vmstat의 합계만으로는 판단하기 어렵다.
이 글에서는 메모리 경로 네 곳에 BPF를 붙인다. 페이지 폴트를 minor/major로 나눠 처리 시간까지 재고, slab 캐시별 할당/해제를 집계하고, 유저 공간의 미해제 malloc을 추적하고, OOM 킬을 그 순간에 잡아낸다. 모든 결과는 rusage·/proc/slabinfo·memory.events와 대조해 확인한다.
이 시리즈의 다른 글
- eBPF 실전 (1) — libbpf와 CO-RE로 첫 커널 프로그램 작성하기
- eBPF 실전 (2) — BPF 맵과 링 버퍼: per-CPU, LRU, 유실, 맵 고정
- eBPF 실전 (3) — kprobe·fentry·tracepoint·uprobe 훅 오버헤드 비교
- eBPF 실전 (4) — 런큐 지연과 컨텍스트 스위치 관측하기
- eBPF 실전 (5) — sched_ext로 커널 스케줄러 직접 구현하기
페이지 폴트: 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_RETRY | I/O를 시작하며 mmap_lock을 놓는다 |
| 2번째 | FAULT_FLAG_TRIED | 0 | 읽어온 페이지로 매핑을 완성한다 |
이 구분을 넣지 않으면 두 번째 호출이 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.procsOOM 감시 시작 (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를 붙여 패킷을 직접 처리한다.