Advanced Debugging & Observability

eBPF 기반 리눅스 커널 메모리 누수(Memory Leak) 및 좀비 프로세스(Zombie Process) 추적 가이드

임베디드 친구 2026. 7. 3. 21:17
반응형

내용 요약

현상: 장시간 구동 시 메모리가 지속적으로 감소하고 [defunct] 상태의 좀비 프로세스가 쌓여 PID 자원이 고갈됨.
원인: 메모리 할당 후 해제(free/kfree)가 누락되는 메모리 누수와, 종료된 자식 프로세스의 수거(waitpid)가 누락되는 좀비 프로세스 누적.
해결: eBPF 기반의 kprobe/uprobe를 활용해 메모리 할당·해제 이벤트를 실시간으로 추적 및 대조하여 누수와 고립 리소스를 탐지.

리눅스 시스템 메모리 고갈(Memory Exhaustion) 및 좀비 프로세스 누적 증상

리눅스 기반 임베디드 장치 및 서버 환경을 장기 운용할 때, 물리 메모리(RAM)의 여유 공간이 서서히 줄어들다가 결국 커널의 OOM Killer (Out of Memory Killer)가 동작하여 데몬을 강제 종료하는 현상이 발생합니다.

글로벌 개발 환경에서 주로 보고되는 주요 증상과 검색 키워드는 다음과 같습니다:

  • Kernel Memory Leak & Slab Leak: top 또는 /proc/meminfo 상에서 SUnreclaim (비가해성 슬랩 메모리) 수치가 지속적으로 우상향합니다.
  • PID Range Exhaustion: 새로운 프로세스나 스레드를 생성하려고 할 때 fork: Cannot allocate memory 또는 Resource temporarily unavailable 에러가 발생합니다.
  • Zombie Process Accumulation: ps ef나 top 명령어 결과 창에 [defunct] 마킹이 된 유령 프로세스들이 수백 개 이상 제거되지 않고 유지됩니다.

이러한 현상은 전통적인 디버깅 툴인 valgrind나 strace를 운영 환경(Production)에 적용하기에는 CPU 및 I/O 오버헤드가 너무 커 시스템이 중단되는 2차 장애를 유발하기에, 무중단 레이턴시와 안전성이 보장된 인-커널(In-Kernel) 추적 솔루션이 필수적입니다.

동적 메모리 할당(Slab Allocation) 미해제 및 자식 태스크 고립의 메커니즘 원인 분석

시스템 메모리 누수와 좀비 프로세스는 커널의 리소스 수명 주기 관리(Resource Lifecycle Management) 실패 때문입니다.

  • 커널 슬랩 메모리 누수 (Kernel Slab Leak): 리눅스 커널은 Slab Allocator를 통해 고정 크기의 메모리 객체를 관리합니다. kmalloc() 또는 kmem_cache_alloc()이 호출되면 커널 내부 가상 메모리 영역의 페이지가 할당되고, 대응하는 Task Struct 또는 드라이버 컨텍스트에 매핑됩니다. 만약 드라이버 에러 패스(Error Path)나 조건문 분기에서 kfree() 호출이 유실되면, 해당 슬랩 객체는 시스템이 리부팅되기 전까지 메모리에 남아 반환되지 않는 Memory Leak 상태가 됩니다.
  • 좀비 프로세스 (Zombie Process) 생성 원인: 프로세스가 exit() 시스템 콜을 호출하여 종료되면, 커널은 해당 프로세스가 사용하던 페이지 테이블, 파일 디스크립터 등 대부분의 리소스를 해제합니다. 그러나 커널은 부모 프로세스가 자식의 종료 상태(Exit Status)를 확인할 수 있도록 최 최소한의 정보인 task_struct와 PID를 커널 메모리에 남겨둡니다. 이 상태를 EXIT_ZOMBIE라고 합니다. 부모 프로세스가 wait() 또는 waitpid()를 호출하여 이 값을 읽어가야 커널이 완전히 내부 구조체를 해제(release_task())하는데, 부모 프로세스의 시그널 핸들러 버그나 블로킹 현상으로 인해 이 프로세스가 누락되면 시스템에 PID 자원을 점유한 채 Zombie Process로 방치됩니다.

메모리 오염 및 리소스 누수를 유발하는 불량 코드 예시 (Bad Case)

아래 예시는 에러 핸들러 진입 시 할당된 커널 메모리를 반환하지 않고, 자식 프로세스를 생성한 후 적절한 정리 작업을 누락하여 두 가지 치명적인 누수를 동시에 유발하는 전형적인 버그 코드입니다.

#include <linux/module.h>
#include <linux/slab.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>

/* Bad Case 1: Kernel Memory Leak due to unhandled error path */
void *buggy_kernel_alloc(size_t size, int condition) {
    void *ptr = kmalloc(size, GFP_KERNEL);
    if (!ptr) {
        return NULL;
    }

    if (condition < 0) {
        /* ERROR: Exiting function without calling kfree(ptr) */
        return NULL; 
    }

    return ptr;
}

/* Bad Case 2: Zombie Process generation in User-space application */
void buggy_zombie_creator(void) {
    pid_t pid = fork();

    if (pid < 0) {
        return;
    } else if (pid == 0) {
        /* Child process exits immediately */
        _exit(0);
    } else {
        /* Parent process sleeps indefinitely or performs infinite work */
        /* ERROR: Missing wait() or waitpid() to reap the child process */
        while (1) {
            sleep(1);
        }
    }
}

eBPF 기반 실시간 할당/해제 추적 및 리소스 복원 C 코드 (Good Case)

eBPF를 활용해 커널 메모리 할당 이벤트와 프로세스 종료 이벤트를 가로채 누수를 모니터링하는 인-커널 가상 머신 기반의 안전한 방어적 코드 구조입니다. (BCC 스타일로 구현되어 검증기를 통과하도록 설계되었습니다.)

#include <uapi/linux/ptrace.h>
#include <linux/sched.h>

/* Structure to store allocation info */
struct alloc_info {
    u64 size;
    u64 timestamp;
};

/* eBPF Hash Maps to track memory allocations and process statuses */
BPF_HASH(alloc_map, u64, struct alloc_info);    /* Key: Allocated Address */
BPF_HASH(zombie_tracker, u32, u64);             /* Key: PID, Value: Timestamp */

/* 1. Track Memory Allocation via kprobe on kmalloc */
int kprobe__kmalloc(struct pt_regs *ctx, size_t size) {
    u64 pid_tgid = bpf_get_current_pid_tgid();

    /* In BCC, to get return value accurately, kretprobe is normally used. 
       This structure registers the allocation request event. */
    return 0;
}

/* 2. Capture Allocation Return Address via kretprobe */
int kretprobe__kmalloc(struct pt_regs *ctx) {
    u64 return_address = PT_REGS_RC(ctx);
    if (return_address == 0)
        return 0;

    struct alloc_info info = {};
    info.size = 1024; /* Dynamic tracking requires reading args or storing via TLS map */
    info.timestamp = bpf_ktime_get_ns();

    /* Save the address to the map */
    alloc_map.update(&return_address, &info);
    return 0;
}

/* 3. Track Memory Free via kprobe on kfree */
int kprobe__kfree(struct pt_regs *ctx, void *objp) {
    u64 address = (u64)objp;
    if (address == 0)
        return 0;

    /* Remove tracked allocation upon successful kfree */
    alloc_map.delete(&address);
    return 0;
}

/* 4. Track Process Termination to hunt Zombie Processes */
int kprobe__do_exit(struct pt_regs *ctx, long code) {
    u64 pid_tgid = bpf_get_current_pid_tgid();
    u32 pid = pid_tgid >> 32;
    u64 ts = bpf_ktime_get_ns();

    /* Record the time when process transitions into EXIT_ZOMBIE */
    zombie_tracker.update(&pid, &ts);
    return 0;
}

핵심 수정 포인트 (Key Implementation Details)

  • In-Kernel State Tracking: 할당된 메모리의 가상 주소를 BPF_HASH 맵의 키값(Key)으로 삼고, kfree가 호출될 때 해당 항목을 delete() 합니다. 최종적으로 맵에 남아있는 데이터가 바로 Memory Leak 범인입니다.
  • Low Overhead Event Pairing: 컨텍스트 스위칭을 유발하지 않고 커널 내부 가상 머신 레벨에서 맵 업데이트 및 삭제를 연산하므로 전체 시스템 성능에 미치는 영향이 0.1% 미만입니다.
  • Zombie Target Identification: do_exit 진입 시점에 PID 데이터를 수집하고, 유저 공간 제어 프로그램에서 waitpid 이벤트 바인딩과 교차 검증하여 해제되지 않는 태스크 구조체를 즉시 가시화합니다.

엔지니어를 위한 eBPF 리소스 추적 디버깅 및 트러블슈팅 가이드 (Debugging Tips)

운영 및 개발 환경에서 eBPF 누수 분석기가 오작동하거나 커널 자원을 정확하게 프로파일링하기 위한 핵심 실무 체크리스트입니다.

  • 커널 심볼 및 메모리 인라인 최적화 확인 (Kernel Symbols & Inline Functions): kmalloc과 같은 핵심 함수는 컴파일러 최적화에 의해 __kmem_cache_alloc_node 등 하위 레벨 함수로 인라인화(Inline Optimization)되어 커널 심볼 테이블에서 누락될 수 있습니다. 추적기가 동작하지 않는다면 /proc/kallsyms 파일에서 실제 노출된 하위 심볼을 확인하고 kprobe 타깃 주소를 보정하십시오.
  • uprobe 프레임 포인터 확보 (User-space Frame Pointers): 유저 공간 어플리케이션의 malloc 및 free를 추적하기 위해 uprobe를 배치할 때, 타깃 바이너리가 -fomit-frame-pointer 옵션으로 빌드되었다면 정확한 호출 스택(Call Stack) 복원이 불가능합니다. 디버깅 빌드 시 반드시 -fno-omit-frame-pointer 플래그를 활성화하여 컴파일하십시오.
  • eBPF Map 메모리 제한 및 클린업 연동 (Map Capacity Tuning): 할당 요청 빈도가 매우 높은 대규모 시스템의 경우, alloc_map의 최대 엔트리 크기(max_entries)가 너무 작으면 이벤트 유실이 발생하여 정상 메모리를 누수로 오진할 수 있습니다. 할당 맵의 크기를 타깃 워크로드에 맞춰 충분히 스케일링하고, 정기적으로 유저 레벨 디버깅 데몬에서 해제된 메모리를 폴링 및 가비지 컬렉션 처리를 수행해 주어야 합니다.
반응형