슬랩 할당 백트레이스 추적: alloc_calls에서 alloc_traces로

슬랩 캐시가 계속 커질 때 /proc/slabinfo는 어느 캐시에 오브젝트가 몇 개 있는지만 알려주고, 그 오브젝트를 누가 할당했는지는 알려주지 않는다. SLUB에는 오브젝트마다 마지막 할당·해제 호출 스택을 기록하는 기능이 있어서 이 질문에 답할 수 있는데, 커널 ABI 문서와 오래된 자료는 하나같이 /sys/kernel/slab/<cache>/alloc_calls를 읽으라고 안내한다. 정작 요즘 커널에서 그 파일을 찾으면 없다. 이 글에서는 alloc_calls가 어디로 옮겨갔는지, 어떻게 켜서 읽는지, 그리고 kmalloc 캐시에서 호출자를 찾을 때 걸리는 함정을 Ubuntu 24.04 / 커널 6.8 환경에서 직접 확인한 결과로 정리한다.

alloc_calls는 어디로 갔나

$ ls /sys/kernel/slab/kmalloc-1k/ | tr '\n' ' '
aliases align cache_dma cpu_partial cpu_slabs ctor destroy_by_rcu hwcache_align min_partial
objects object_size objects_partial objs_per_slab order partial poison reclaim_account
red_zone remote_node_defrag_ratio sanity_checks shrink skip_kfence slabs slabs_cpu_partial
slab_size store_user total_objects trace usersize validate

$ ls /sys/kernel/slab/kmalloc-1k/alloc_calls
ls: cannot access '/sys/kernel/slab/kmalloc-1k/alloc_calls': No such file or directory

alloc_callsfree_calls도 없다. sysfs는 파일 하나당 PAGE_SIZE까지만 출력할 수 있고 “한 파일에 한 값” 규칙에도 어긋나서, 두 파일은 5.14에서 debugfs로 옮겨지면서 alloc_traces/free_traces로 이름까지 바뀌었다.

커널변화커밋
~5.13/sys/kernel/slab/<cache>/alloc_calls, free_calls (2.6.22부터)
5.14debugfs로 이동 + 이름 변경 → /sys/kernel/debug/slab/<cache>/alloc_traces, free_traces64dd68497be5
5.19stackdepot 사용. 호출자 한 줄이 아니라 전체 콜 스택을 저장5cf909c553e9
6.1kmalloc 캐시에 한해 요청 크기와 실제 오브젝트 크기의 차이를 waste=로 표시6edf2576a6cc
6.9부팅 파라미터 이름이 slab_debug=로 변경(slub_debug=는 별칭으로 유지)

파일이 생기는 조건

debugfs 아래 캐시 디렉토리는 모든 캐시에 대해 만들어지지 않는다. slab_debugfs_init()SLAB_STORE_USER 플래그가 붙은 캐시만 등록한다.

$ sudo ls /sys/kernel/debug/slab/
$

부팅 파라미터 없이 올라온 시스템에서는 출력이 비어 있다. 아래 조건이 모두 갖춰져야 캐시 디렉토리가 생긴다.

조건확인 방법비고
CONFIG_SLUB_DEBUG=ygrep CONFIG_SLUB_DEBUG= /boot/config-$(uname -r)대부분의 배포판 커널에서 기본 y
CONFIG_STACKDEPOT=y동일없으면 호출자 한 줄만 나오고 스택은 안 나옴
debugfs 마운트mount | grep debugfs파일 권한이 0400이라 root만 읽을 수 있음
캐시에 SLAB_STORE_USERcat /sys/kernel/slab/<cache>/store_user아래 세 가지 방법 중 하나로 켬
켜는 방법적용 범위재부팅
slub_debug=U,<캐시> 부팅 파라미터지정한 기존 캐시필요
CONFIG_SLUB_DEBUG_ON=y모든 캐시(FZPU 전부)커널 재빌드
kmem_cache_create(..., SLAB_STORE_USER, ...)내가 만든 캐시불필요

디버그 플래그는 캐시가 만들어지는 시점에 확정되기 때문에, 이미 부팅된 시스템에서 기존 캐시에 store_user를 나중에 켜는 방법은 없다. 반대로 내 모듈이 직접 만드는 캐시라면 재부팅 없이 바로 쓸 수 있다.

재부팅 없이 확인하기: SLAB_STORE_USER 캐시

호출 지점 두 곳에서 같은 캐시를 쓰는 모듈을 만들어, 32개는 계속 보유하고 8개는 할당·해제 후 다시 할당하도록 했다.

#include <linux/module.h>
#include <linux/slab.h>

#define NR_META    32
#define NR_SESSION 8

struct demo_obj {
	unsigned long id;
	char payload[120];
};

static struct kmem_cache *demo_cache;
static struct demo_obj *meta[NR_META];
static struct demo_obj *session[NR_SESSION];

static noinline void alloc_metadata(void)
{
	int i;

	for (i = 0; i < NR_META; i++)
		meta[i] = kmem_cache_zalloc(demo_cache, GFP_KERNEL);
}

static noinline void alloc_session(void)
{
	int i;

	for (i = 0; i < NR_SESSION; i++)
		session[i] = kmem_cache_zalloc(demo_cache, GFP_KERNEL);
}

static noinline void free_session(void)
{
	int i;

	for (i = 0; i < NR_SESSION; i++)
		kmem_cache_free(demo_cache, session[i]);
}

static int __init demo_init(void)
{
	demo_cache = kmem_cache_create("demo_slab", sizeof(struct demo_obj),
				       0, SLAB_STORE_USER, NULL);
	if (!demo_cache)
		return -ENOMEM;

	alloc_metadata();	/* 32개 할당 후 계속 보유 */
	alloc_session();	/* 8개 할당 */
	free_session();		/* 위 8개 해제 */
	alloc_session();	/* 방금 해제한 자리를 재사용 */
	return 0;
}

static void __exit demo_exit(void)
{
	int i;

	for (i = 0; i < NR_META; i++)
		kmem_cache_free(demo_cache, meta[i]);
	free_session();
	kmem_cache_destroy(demo_cache);
}

module_init(demo_init);
module_exit(demo_exit);
MODULE_LICENSE("GPL");
obj-m := slab_trace_demo.o kmalloc_leak.o
KDIR := /lib/modules/$(shell uname -r)/build

all:
	$(MAKE) -C $(KDIR) M=$(PWD) modules
clean:
	$(MAKE) -C $(KDIR) M=$(PWD) clean
$ make
$ sudo insmod slab_trace_demo.ko
$ ls /sys/kernel/debug/slab/
demo_slab
$ sudo cat /sys/kernel/debug/slab/demo_slab/alloc_traces
     32 alloc_metadata+0x2b/0x60 [slab_trace_demo] age=5/5/5 pid=2341 cpus=2
        alloc_metadata+0x2b/0x60 [slab_trace_demo]
        demo_init+0x35/0xff0 [slab_trace_demo]
        do_one_initcall+0x5e/0x340
        do_init_module+0x97/0x290
        load_module+0xb5f/0xca0
        init_module_from_file+0x96/0x100
        idempotent_init_module+0x11c/0x310
        __x64_sys_finit_module+0x64/0xd0
        x64_sys_call+0x2019/0x25a0
        do_syscall_64+0x87/0x180
        entry_SYSCALL_64_after_hwframe+0x78/0x80

      8 alloc_session+0x2b/0x60 [slab_trace_demo] age=5/5/5 pid=2341 cpus=2
        alloc_session+0x2b/0x60 [slab_trace_demo]
        demo_init+0x44/0xff0 [slab_trace_demo]
        do_one_initcall+0x5e/0x340
        ...

호출 지점별로 살아있는 오브젝트 수가 그대로 집계된다. 같은 캐시라도 alloc_metadata()가 32개, alloc_session()이 8개를 잡고 있다는 것이 바로 보인다.

$ sudo cat /sys/kernel/debug/slab/demo_slab/free_traces
     32 <not-available> age=4295310043 pid=0 cpus=0
      8 free_session+0x2d/0x50 [slab_trace_demo] age=7/7/7 pid=2341 cpus=2
        kmem_cache_free+0x3cf/0x480
        free_session+0x2d/0x50 [slab_trace_demo]
        demo_init+0x3f/0xff0 [slab_trace_demo]
        ...

free_traces는 “해제된 오브젝트 목록”이 아니라 지금 할당되어 있는 오브젝트의 직전 해제 기록이다. 커널이 슬랩을 훑을 때(process_slab()) free 비트가 꺼진 오브젝트만 집계하기 때문으로, 한 번도 재사용된 적 없는 32개는 <not-available>로 나오고 해제 후 재할당된 8개만 free_session()을 가리킨다. use-after-free 조사에서 “이 오브젝트가 직전에 어디서 free됐나”를 볼 때 쓰는 정보다.

출력 필드 읽기

필드의미
맨 앞 숫자같은 스택으로 할당되어 현재 살아있는 오브젝트 수
심볼+0x2b/0x60기록된 호출자 주소. 아래 들여쓴 줄들이 stackdepot에 저장된 전체 스택
waste=20736/324(총 낭비/오브젝트당 낭비) 바이트. kmalloc 캐시에서 요청 크기와 실제 크기 차이
age=min/avg/max할당 후 경과 시간(jiffies). CONFIG_HZ=1000이면 ms와 같음. 값이 하나면 전부 동일
pid=1-905할당한 프로세스 PID 범위. 값이 하나면 단일 PID
cpus=, nodes=할당이 일어난 CPU/NUMA 노드 목록. 각각 2개 이상일 때만 출력

age4295310043처럼 비정상적으로 크면 트랙이 비어 있는(when=0) 항목이다. 위 <not-available> 줄이 그 경우다.

kmalloc 캐시에서 호출자 찾기

실전에서 궁금한 쪽은 보통 kmalloc-*다. 700바이트를 64번 할당하고 놓아주지 않는 모듈로 재현했다.

#include <linux/module.h>
#include <linux/slab.h>

#define NR_LEAK 64

static void *leaked[NR_LEAK];

static noinline void leak_config_blob(void)
{
	int i;

	for (i = 0; i < NR_LEAK; i++)
		leaked[i] = kmalloc(700, GFP_KERNEL);	/* kmalloc-1k에서 할당 */
}

static int __init leak_init(void)
{
	leak_config_blob();
	return 0;
}

static void __exit leak_exit(void)
{
	int i;

	for (i = 0; i < NR_LEAK; i++)
		kfree(leaked[i]);
}

module_init(leak_init);
module_exit(leak_exit);
MODULE_LICENSE("GPL");

700바이트면 kmalloc-1k이므로 slub_debug=U,kmalloc-1k로 부팅하고 모듈을 올렸는데, alloc_tracesleak_config_blob이 나오지 않았다. 모듈 적재 전후로 /proc/slabinfo를 비교해 보면 이유가 드러난다.

$ sudo awk 'NR>2{print $1, $2}' /proc/slabinfo | sort > /tmp/before
$ sudo insmod kmalloc_leak.ko
$ sudo awk 'NR>2{print $1, $2}' /proc/slabinfo | sort > /tmp/after
$ join /tmp/before /tmp/after | awk '$2!=$3 {print $1, $2, "->", $3}'
kmalloc-1k 94 -> 96
kmalloc-rnd-12-1k 130 -> 208
maple_node 1320 -> 1541
task_struct 240 -> 246
vma_lock 6396 -> 6528

할당은 kmalloc-1k이 아니라 kmalloc-rnd-12-1k로 갔다. 6.6에서 들어온 CONFIG_RANDOM_KMALLOC_CACHES가 kmalloc 캐시를 크기별로 16벌씩 복제해두고, 호출 지점 주소의 해시로 그중 하나를 고르기 때문이다. 어느 번호로 갈지는 부팅할 때마다 달라지므로(아래 재부팅 후에는 같은 호출 지점이 rnd-13으로 옮겨갔다) 캐시 이름을 고정해 지정할 수 없고, 대신 glob을 쓴다.

# /etc/default/grub
GRUB_CMDLINE_LINUX_DEFAULT="slub_debug=U,kmalloc-*"

$ sudo insmod kmalloc_leak.ko
$ sudo grep -l leak_config_blob /sys/kernel/debug/slab/*/alloc_traces
/sys/kernel/debug/slab/kmalloc-rnd-13-1k/alloc_traces
$ sudo cat /sys/kernel/debug/slab/kmalloc-rnd-13-1k/alloc_traces
     64 leak_config_blob+0x5d/0x90 [kmalloc_leak] waste=20736/324 age=19198/19198/19198 pid=1387 cpus=2
        leak_config_blob+0x5d/0x90 [kmalloc_leak]
        leak_init+0xe/0xff0 [kmalloc_leak]
        do_one_initcall+0x5e/0x340
        do_init_module+0x97/0x290
        load_module+0xb5f/0xca0
        ...

64개가 그대로 잡히고, waste=20736/324로 1024바이트 오브젝트에 700바이트만 담아 개당 324바이트씩 총 20KB를 버리고 있다는 것까지 나온다. 이름 매칭은 * 앞부분만 prefix로 비교하므로 kmalloc-*kmalloc-rnd-*kmalloc-cg-*까지 모두 포함한다.

낭비 기준으로 전체 kmalloc 캐시를 한 번에 줄 세우면 크기 클래스를 잘못 고른 할당 지점이 바로 드러난다.

$ sudo awk '/waste=/ {match($0, /waste=[0-9]+/); print substr($0, RSTART+6, RLENGTH-6), $1, $2}' \
      /sys/kernel/debug/slab/kmalloc-*/alloc_traces | sort -rn | head -5
201432 109 mem_cgroup_alloc+0x31/0x550
190944 204 bpf_prog_alloc_no_stats+0xc7/0x2b0
124696 109 mem_cgroup_alloc+0x226/0x550
87296 176 kvmalloc_node+0x24/0x100
54936 109 alloc_shrinker_info+0xf5/0x1f0

첫 컬럼이 총 낭비 바이트, 둘째가 오브젝트 수다. mem_cgroup_alloc() 한 곳만으로 109개 오브젝트에서 약 197KB가 버려지고 있다.

주의사항

  • store_user를 켜면 오브젝트마다 struct track이 두 개(할당/해제) 붙는다. 실측으로 커스텀 캐시는 object_size 128 → slab_size 192, kmalloc-1k은 1024 → 2048로 늘었다. kmalloc-* 전체에 거는 것은 재현 시험용으로만 쓰고 운영에는 캐시를 좁혀서 건다.
  • 디버그 플래그가 붙은 캐시는 다른 캐시와 병합되지 않는다(aliases가 0이 된다). 추적에는 유리하지만, 병합이 사라지면서 슬랩 개수와 메모리 사용량 자체가 평소와 달라지므로 이 상태의 slabinfo 수치를 평상시 기준으로 삼으면 안 된다.
  • stackdepot에는 심볼이 아니라 주소가 저장되고 읽는 시점에 심볼로 변환된다. 모듈을 언로드했다가 다른 모듈을 올리면 예전 스택이 엉뚱한 모듈 심볼로 보일 수 있다(실제로 proto_registerleak_config_blob에서 호출된 것처럼 표시되는 항목이 관찰됐다).
  • Documentation/ABI/testing/sysfs-kernel-slab은 지금도 alloc_calls를 sysfs 항목으로 기술하고 있다. 파일이 옮겨진 지 오래됐으니 문서만 보고 sysfs를 뒤지지 말 것.
  • 커널 6.9부터 부팅 파라미터 이름이 slab_debug=다. 예전 이름 slub_debug=도 별칭으로 남아 있어 위 예시는 두 이름 모두 동작한다.

마무리

alloc_traces는 kmemleak처럼 별도 스캐너를 돌리지 않고도 “지금 이 캐시를 붙잡고 있는 코드”를 호출 스택 단위로 집계해 준다. 켜는 비용이 오브젝트당 수십 바이트로 작지 않으니 상시 활성화보다는, 슬랩 사용량이 이상한 캐시를 slabinfo로 먼저 좁힌 뒤 그 캐시에만 slab_debug=U를 걸고 재부팅하는 순서가 현실적이다.

참고

답글 남기기