시작하며


1장에서 eBPF가 무엇이며 왜 중요한지에 대한 이론적 배경을 학습했다면, 2장에서는 실질적으로 eBPF 프로그램을 어떻게 작성하고 실행하는지 다룬다.

2장에서는 eBPF의 가장 기본적인 개발 도구인 BCC(BPF Compiler Collection)를 사용하여 아주 심플한 “Hello World” 프로그램을 만들고, 더 나아가 eBPF Map(해시 테이블, Perf/Ring Buffer)을 사용해 데이터를 저장하고 사용자 공간과 동적으로 통신하는 법을 배운다. 또한, 커널 내에서의 제한적인 리소스를 극복하기 위한 함수 인라이닝(Function Inlining)과 테일 콜(Tail Calls) 까지 깊이 있게 파헤친다.

eBPF 프로그램의 기본 아키텍처


eBPF 애플리케이션은 기본적으로 두 개의 영역으로 나뉘어 설계된다.

  1. 커널 공간(Kernel Space) 프로그램: 훅(Hook) 포인트에 부착(Attach)되어 실제로 이벤트를 감지하고 필터링 또는 수집을 수행하는 핵심 C 소스코드.
  2. 사용자 공간(User Space) 드라이버 프로그램: 커널 공간에 eBPF 바이트코드를 로드하고, 이벤트를 모니터링하며, 커널과의 인터페이스(eBPF Map 등)를 통해 데이터를 전달받아 최종 시각화나 비즈니스 로직을 처리하는 프로그램 (Go, Python, C, Rust 등으로 작성).

이러한 분리 구조 덕분에 무겁고 위험한 비즈니스 로직은 안전한 사용자 공간에서 돌리고, 커널 공간에서는 극도로 가볍고 안전한 가로채기 연산만 효율적으로 수행할 수 있다.

BCC를 활용한 Hello World 프로그램


eBPF 실습용 라즈베리파이 클러스터 구성 모습
예전 쿠버네티스 학습을 위해 구축해 두었다가 이번 eBPF 실습의 든든한 구원투수가 된 라즈베리파이 장비들

실은 이 실습 코드를 실행하기 전, 나름 바보 같은 해프닝이 있었다. 사용하고 있는 메인 개발 장비가 macOS(맥북)이다 보니, 처음에 아무 생각 없이 로컬 맥 환경에서 BCC 패키지를 실행해 보려 시도했던 것이다.

당연하게도 eBPF는 리눅스(Linux) 커널에 극도로 종속적인 최첨단 커널 전용 기술이기 때문에 맥 환경에서는 동작 자체가 불가능했다.

그렇다고 맥 위에 가상 머신(VM)을 띄우자니 하이퍼바이저를 올리고 우분투 이미지 구워서 환경 세팅과 빌드 툴체인을 처음부터 일일이 구성하려니 생각만 해도 너무 번거롭고 귀찮아졌다.

그 순간 번뜩 머릿속을 스쳐 지나간 녀석이 바로 예전에 *쿠버네티스(Kubernetes)를 한참 공부할 때 직접 자작 클러스터로 하드웨어를 한 땀 한 땀 구축해 놓았던 라즈베리파이(Raspberry Pi)*였다.

BCC(BPF Compiler Collection)는 eBPF를 가장 손쉽게 작성할 수 있게 돕는 프레임워크다. BCC를 사용하면 파이썬(Python) 드라이버 코드 내부에 커널용 C 코드를 인라인 문자열 형식으로 삽입하여 간편하게 실행할 수 있다.

아래는 새로운 프로세스가 생성(execve 시스템 콜 호출)될 때마다 디버그 메시지를 출력하는 Hello World 프로그램 예시다.

from bcc import BPF

# 1. 커널 내부에서 실행될 C 코드 (eBPF 프로그램)
program = r"""
int hello(void *ctx) {
    bpf_trace_printk("Hello World\\n");
    return 0;
}
"""

# 2. BCC를 사용하여 C 코드를 컴파일하고 로드
b = BPF(text=program)

# 3. execve 시스템 콜의 커널 kprobe 이벤트에 hello 함수를 부착
syscall = b.get_syscall_fnname("execve")
b.attach_kprobe(event=syscall, fn_name="hello")

# 4. trace_pipe 출력 읽기
b.trace_print()

동작 흐름 분석

  1. 컴파일 및 로드: 파이썬 드라이버가 시작되면 BCC는 내장된 Clang/LLVM 컴파일러를 호출하여 C 소스코드를 eBPF 바이트코드로 메모리 상에서 실시간 빌드한다.
  2. 검증기 통과: 빌드된 바이트코드는 sys_bpf 시스템 콜을 통해 커널에 전달되고 검증기(Verifier)의 유효성 테스트를 거친다.
  3. 이벤트 부착 (kprobe): 검증을 통과하면 attach_kprobe에 의해 지정된 시스템 콜(execve) 훅 포인트에 동적으로 기계어 번역(JIT) 후 적재된다.
  4. 트레이스 수집: 커널 내부 헬퍼 함수인 bpf_trace_printk()는 커널 디버그 파이프(/sys/kernel/debug/tracing/trace_pipe)에 로그를 기록하고, 파이썬 드라이버의 trace_print()가 이를 폴링하여 출력한다.

주의: bpf_trace_printk()는 가볍고 디버깅하기에 매우 직관적이지만, 전역 추적 채널을 공유하기 때문에 오버헤드가 크고 운영 환경(Production)에는 사용을 강력히 금한다.

eBPF Map: 사용자 공간과 커널 공간의 통신 채널


커널 공간에서 단순 프린트만 찍는 것은 한계가 명확하다. 수집된 데이터를 가공하거나, 통계를 내거나, 설정 정보를 커널에 동적으로 주입하기 위해서는 두 영역이 고속으로 통신할 수 있는 안전한 메모리 공간이 필요하다. 이를 eBPF Map(맵)이라고 부른다.

eBPF Map은 커널이 관리하는 특수한 Key-Value 형태의 공유 데이터 구조체다. 2장에서는 이를 전달하는 대표적인 방식으로 Hash Table MapPerf / Ring Buffer Map을 소개한다.

1. Hash Table Map

해시 테이블 맵은 키-값 쌍 형태로 데이터 상태를 안전하게 누적/관리할 수 있는 공간이다. 다음은 execve 시스템 콜이 일어날 때마다 호출을 유발한 사용자의 UID(User ID)별로 호출 누적 횟수를 카운팅하고, 이를 사용자 영역에서 주기적으로 읽어 출력하는 예제다.

from bcc import BPF
from time import sleep

program = r"""
BPF_HASH(counter_table); // eBPF 해시 맵 정의

int hello(void *ctx) {
   u64 uid;
   u64 counter = 0;
   u64 *p;

   // 현재 이벤트를 트리거한 프로세스의 UID 정보 획득
   uid = bpf_get_current_uid_gid() & 0xFFFFFFFF;

   // 맵(counter_table)에서 해당 UID 키로 저장된 기존 값 조회
   p = counter_table.lookup(&uid);
   if (p != 0) {
      counter = *p;
   }

   // 값 증가 및 맵 업데이트
   counter++;
   counter_table.update(&uid, &counter);
   return 0;
}
"""

b = BPF(text=program)
syscall = b.get_syscall_fnname("execve")
b.attach_kprobe(event=syscall, fn_name="hello")

print("Tracing execve() call counts by UID... Press Ctrl+C to stop.")

# 사용자 영역에서 주기적으로 eBPF Map을 폴링하여 데이터를 정밀 조회
while True:
  sleep(2)
  s = ""
  for key, val in b["counter_table"].items():
      s += f"User ID {key.value}: {val.value}\t"
  print(s)
💡 BCC의 마법: “이건 표준 C 코드가 아니다!”

위 커널 코드 중에서 맵을 조작하는 아래의 구조를 유심히 살펴보자.

p = counter_table.lookup(&uid);
counter_table.update(&uid, &counter);

구조체 내부에 메서드를 정의하여 호출하는 방식은 표준 C언어에서는 지원하지 않는 문법이다. (C++에서는 가능하지만 C에서는 불가능하다.)

이 코드가 정상적으로 컴파일되는 이유는 BCC의 프리프로세서 기능 덕분이다. BCC는 컴파일러로 넘기기 전에 이와 같은 편리한 매크로 형태의 ‘C-like’ 문법을 파싱하여, 커널이 완벽하게 이해할 수 있는 “진짜 C 표준 규격"의 eBPF 헬퍼 함수 호출 코드로 자동 재작성(Rewrite)해 준다.

실행 결과의 해석 (Terminal 1 & 2)

두 개의 터미널을 열고 테스트를 해보면 재미있는 통계를 볼 수 있다.

  • UID가 501인 사용자가 일반 ls를 치면 UID 501 카운트가 1씩 올라간다.
  • 만약 sudo ls를 실행하면, sudo 명령어 자체가 UID 501 하에서 실행되고, 그 내부에서 실제 ls는 root 권한(UID 0)으로 실행되므로 UID 501: +1, UID 0: +1이 동시에 집계되는 아키텍처적 인과관계를 정밀하게 관측할 수 있다.

2. Perf Buffer와 Ring Buffer Map

해시 테이블 맵은 키-값 단위의 상태 축적에는 훌륭하지만, 이벤트가 발생할 때마다 실시간으로 대량의 복잡한 이벤트를 통보받기 위해서는 매번 사용자 공간에서 전체 테이블을 무식하게 폴링(Polling)해야 하므로 성능 손실이 크다.

이러한 문제를 우아하게 해결하기 위해 리눅스 커널이 제공하는 순환 큐 형태의 자료구조인 링 버퍼(Ring Buffer) 개념을 활용한다.

링 버퍼(Ring Buffer)의 기본 동작 메커니즘

링 버퍼는 메모리 상에 논리적인 원형 링(Ring) 형태로 구성된 일종의 FIFO 버퍼다.

별도의 Write Pointer와 Read Pointer를 두고 순환하며 데이터를 소모하는 링 버퍼 메커니즘
  • 독립된 포인터: 데이터를 기록하는 Write Pointer와 데이터를 읽어가는 Read Pointer가 독립적으로 움직인다.
  • 데이터 흐름: 커널이 Write Pointer 위치에 메시지를 기록하고 포인터를 전진시키면, 사용자 공간 드라이버가 Read Pointer를 전진시키며 이벤트를 소비한다.
  • 오버플로우 처리: 만약 읽기 속도가 쓰기 속도를 따라잡지 못해 Write Pointer가 Read Pointer를 추월하려고 하면, 커널은 이벤트를 기록하지 않고 드롭(Drop)시킨 뒤 드롭 카운터를 올려 데이터 소실을 방지하고 상태를 경고한다.

이 방식을 사용하면 동기적 컨텍스트 스위칭(Synchronous Context Switching) 없이, 커널 레벨에서 비동기적으로 대량의 버퍼를 고속 스트리밍하여 오버헤드를 극도로 낮출 수 있다.

참고: 전통적으로 사용되던 Perf Buffer 외에도, 커널 5.8 이상부터는 메모리 오버헤드가 더 낮고 공유 효율이 높은 BPF Ring Buffer(BPF_RINGBUF_OUTPUT) 방식이 권장된다. (BCC의 Ring Buffer 예제는 4장에서 상세히 다뤄진다.)

Perf Buffer를 활용한 이벤트 스트리밍 예제

다음은 프로세스 ID(PID), 사용자 ID(UID), 실행된 명령어 이름(Command), 메시지를 하나의 C 구조체에 담아 BPF_PERF_OUTPUT 버퍼 맵을 통해 비동기로 스트리밍하는 예제다.

from bcc import BPF

program = r"""
BPF_PERF_OUTPUT(output); // perf_output 버퍼 맵 정의

// 사용자 영역과 주고받을 정교한 이벤트 데이터 구조체 정의
struct data_t {
    int pid;
    int uid;
    char command[16];
    char message[12];
};

int hello(void *ctx) {
    struct data_t data = {};
    char message[12] = "Hello World";

    // 커널 헬퍼 함수들을 사용해 컨텍스트 정보 수집
    data.pid = bpf_get_current_pid_tgid() >> 32; // 상위 32비트가 실제 PID
    data.uid = bpf_get_current_uid_gid() & 0xFFFFFFFF;
    bpf_get_current_comm(&data.command, sizeof(data.command));
    
    // 안전한 커널 메모리 복사를 보장하는 헬퍼 함수
    bpf_probe_read_kernel(&data.message, sizeof(data.message), message);

    // 수집된 대용량 구조체 데이터를 맵 버퍼로 실시간 제출(Submit)
    output.perf_submit(ctx, &data, sizeof(data));
    return 0;
}
"""

b = BPF(text=program)
syscall = b.get_syscall_fnname("execve")
b.attach_kprobe(event=syscall, fn_name="hello")

# 사용자 영역 파이썬 콜백 함수 정의
def print_event(cpu, data, size):
    # BCC가 원시 바이트 데이터를 Python 객체 구조체로 변환해 줌
    event = b["output"].event(data)
    print(f"[{event.pid}] UID:{event.uid} | Cmd:{event.command.decode()} | Msg:{event.message.decode()}")

# 콜백 등록 및 폴링 루프 실행
b["output"].open_perf_buffer(print_event)
print("Streaming events through Perf Buffer... Press Ctrl+C")
while True:
    try:
        b.perf_buffer_poll() # 무한 폴링 루프
    except KeyboardInterrupt:
        break
🛠 사용된 핵심 커널 헬퍼 함수 정리
  • bpf_get_current_pid_tgid(): 현재 실행 흐름의 PID와 TGID(Thread Group ID)를 64비트 값으로 돌려준다. eBPF 내부에서 실제 우리가 아는 프로세스 ID(PID)는 상위 32비트에 저장되어 있으므로 비트 시프트 연산(>> 32)을 통해 올바른 값을 추출해야 한다.
  • bpf_get_current_comm(): 현재 호출을 트리거한 실행 파일 명칭(문자열)을 안전하게 읽어온다. C언어 특성상 단순 대입(=)이 불가능하므로, 목적지 주소(&data.command)를 인자로 넘겨 직접 쓰도록 안전하게 설계되어 있다.
  • bpf_probe_read_kernel(): 안전하게 격리된 메모리 영역에서 다른 커널 공간 메모리를 복사해 온다. 커널 공간의 임의 주소에 잘못 접근해 커널 패닉이 나는 것을 원천 차단하는 대표적인 보안 보호 헬퍼다.

함수 호출(Function Calls)과 컴파일러 인라이닝


코드의 유지보수와 중복 제거를 위해 함수를 분리하는 것은 프로그래밍의 기초 상식(DRY 원칙)이다. 하지만 아주 오랫동안 eBPF 프로그램 안에서는 사용자 정의 함수 호출이 불가능했다. 오직 커널이 정의한 공식 Helper Function만 호출할 수 있었다.

이 제약을 우회하기 위해 전통적으로 사용된 기법이 바로 인라이닝(Inlining)이다.

static __always_inline void my_function(void *ctx, int val)

함수 인라이닝의 작동 원리와 장단점

  • 작동 원리: 컴파일러가 바이트코드를 빌드할 때, 호출부에서 점프(JUMP) 명령어를 통해 실제 함수 메모리 위치로 이동하게 만드는 대신, 해당 함수의 실제 인스트럭션 코드 뭉치를 호출한 자리에 통째로 복사해서 붙여넣는다.
  • 장점: 스택 프레임을 무식하게 증가시키지 않고 점프 오버헤드가 없으므로, 검증기(Verifier)가 실행 안전 흐름을 단선적으로 분석하기에 아주 안전하다.
  • 단점: 함수가 여러 군데서 호출될 경우 컴파일된 바이너리/바이트코드의 물리적 크기가 기하급수적으로 커지는 벌룬 오버헤드(Balloon Overhead)가 발생한다. 특히 eBPF 프로그램은 엄격한 명령어 개수 한계가 있으므로 치명적일 수 있다.

참고: 리눅스 커널 4.16 및 LLVM 6.0부터 제약이 풀려 네이티브 함수 호출(BPF-to-BPF Function Calls)이 기술적으로 가능해졌지만, 현재 BCC 프레임워크 수준에서는 이를 완벽히 동적으로 핸들링하기 어려워 여전히 BCC 개발 시에는 __always_inline 지시어의 사용이 권장된다.


테일 콜(Tail Calls): 커널 공간 내부의 무중단 프로그램 체이닝


eBPF 커널 실행 환경에는 매우 까다로운 제약 조건이 존재하는데, 바로 스택 메모리 크기가 최대 512바이트로 제한된다는 점이다. 512바이트는 복잡한 파싱 로직이나 중첩 루프를 돌리기에는 턱없이 부족한 공간이다.

이를 우회하고, 복잡한 로직을 모듈화하여 순차적으로 이어 달릴 수 있도록 지원하는 핵심 기술이 바로 테일 콜(Tail Calls)이다.

테일 콜(Tail Call)의 개념

테일 콜은 마치 리눅스 프로세스 생태계의 execve 시스템 콜과 유사하게 동작한다. 현재 실행 중인 eBPF 프로그램의 컨텍스트를 유지한 채, 다음에 실행할 eBPF 프로그램으로 제어권을 완전히 넘겨버리며, 호출한 부모 프로그램으로 다시 리턴(Return)하지 않는다. 즉, 스택 프레임을 한 층도 더 쌓지 않고 완전히 동일한 깊이에서 새 프로그램을 실행하므로, 스택 오버플로우가 원천적으로 불가능하다.

테일 콜은 다음과 같은 bpf_tail_call() 시스템 도구를 활용한다.

long bpf_tail_call(void *ctx, struct bpf_map *prog_array_map, u32 index)
  • prog_array_map: 다음에 실행될 수 있는 eBPF 프로그램들의 파일 디스크립터(FD) 주소 리스트를 담은 특수 맵인 BPF_MAP_TYPE_PROG_ARRAY다.
  • index: 해당 맵에서 몇 번째(Index) 등록된 프로그램을 가져와 즉시 실행할 것인지 지정한다.

BCC 테일 콜 예제: hello-tail.py

다음은 전체 시스템 콜 진입점(sys_enter raw tracepoint)에 부착하여 작동하되, 특정 시스템 콜 번호(Opcode)에 매칭되는 독립된 프로그램들이 존재할 경우 테일 콜 방식으로 점프하여 특화된 로직을 실행하고, 매칭되는 맵이 없을 경우 기본 로직을 실행하는 정교한 설계 예제다.

from bcc import BPF
import ctypes as ct

program = r"""
// 최대 300개의 eBPF 프로그램을 적재할 수 있는 PROG_ARRAY 맵 선언
BPF_PROG_ARRAY(syscall, 300);

// 전체 시스템 콜 훅에 걸릴 메인 프로그램
int hello(struct bpf_raw_tracepoint_args *ctx) {
    int opcode = ctx->args[1]; // 시스템 콜 번호(Opcode) 획득
    
    // 테일 콜 실행 (BCC는 이를 내부적으로 bpf_tail_call로 치환)
    syscall.call(ctx, opcode);
    
    // 만약 테일 콜에 성공하면 이 아래 코드는 실행되지 않고 제어권이 완전히 넘어감!
    // 테일 콜 실패 시(프로그램 미등록 등)에만 폴백으로 이 로직이 작동하게 됨
    bpf_trace_printk("Another syscall: %d\\n", opcode);
    return 0;
}

// execve 시스템 콜 전용 테일 콜 프로그램
int hello_execve(void *ctx) {
    bpf_trace_printk("Executing a program\\n");
    return 0;
}

// 타이머 관련 시스템 콜 전용 테일 콜 프로그램
int hello_timer(struct bpf_raw_tracepoint_args *ctx) {
    if (ctx->args[1] == 222) {
        bpf_trace_printk("Creating a timer\\n");
    } else {
        bpf_trace_printk("Other timer operation\\n");
    }
    return 0;
}

// 원치 않는 자주 발생하는 시스템 콜 트래픽을 필터링하기 위한 빈 테일 콜
int ignore_opcode(void *ctx) {
    return 0;
}
"""

b = BPF(text=program)
# Raw Tracepoint의 sys_enter 진입점에 메인 hello 부착
b.attach_raw_tracepoint(tp="sys_enter", fn_name="hello")

# 개별 서브 eBPF 프로그램들을 독자적으로 빌드하고 파일 디스크립터(FD) 추출
ignore_fn = b.load_func("ignore_opcode", BPF.RAW_TRACEPOINT)
exec_fn = b.load_func("hello_execve", BPF.RAW_TRACEPOINT)
timer_fn = b.load_func("hello_timer", BPF.RAW_TRACEPOINT)

# PROG_ARRAY 맵 가져오기
prog_array = b.get_table("syscall")

# 특정 시스템 콜 번호(Opcode) 키값에 매핑되는 FD 등록
prog_array[ct.c_int(59)] = ct.c_int(exec_fn.fd)   # 59번: execve (x86 기준)
prog_array[ct.c_int(222)] = ct.c_int(timer_fn.fd) # 222번: timer_create
prog_array[ct.c_int(226)] = ct.c_int(timer_fn.fd) # 226번: timer_delete

# 너무 과하게 호출되는 무의미한 시스템 콜(예: read/write 등)은 무음 필터 테일 콜로 연결
prog_array[ct.c_int(21)] = ct.c_int(ignore_fn.fd)
prog_array[ct.c_int(22)] = ct.c_int(ignore_fn.fd)

print("Tracing system-wide syscalls with Tail Calls... Press Ctrl+C")
b.trace_print()

테일 콜 기법을 활용하면 최대 33개까지 프로그램 체이닝이 가능하다. eBPF 프로그램 1개당 최대 인스트럭션 복잡도 제한이 100만 라인인 점을 고려하면, 33개의 프로그램을 체이닝하여 거의 극단적인 수준의 대형 커널 로직도 안전하게 스택 제한 없이 구현해 낼 수 있음을 의미한다.


느낀점 / 결론


눈으로 직접 확인하고 동작하는 2장의 실습 단계를 마주하며, 약간이나마 eBPF 로 할 수 있는 것이 무었인지 알 수 있었다.

실제 가동 중인 물리 기기의 OS 커널 레이어에서 발생하는 시스템 콜 이벤트를 내가 짠 eBPF 코드가 직접 가로채 가공하고 있다는 것이 원초적인 즐거움을 다시금 일깨워 주었다.

또한, 512바이트 스택 메모리 한계를 극복하기 위해 도입된 인라이닝테일 콜(Tail Call) 이 인상 깊었다. 호출 깊이를 늘려 스택을 낭비하는 대신, 메모리 상에서 실행 흐름(Context)을 통째로 갈아 끼워 무중단 점프로 이어달리는 테일 콜 설계는 엘릭서에서 꼬리 재귀 함수 최적화가 생각났다.

참고 문헌


  • Liz Rice, Learning eBPF, O’Reilly Media.
>> Home