eBPF로 Linux 커널 동작 실시간 추적하기

왜 커널 추적을 배워야 하는가

시스템 엔지니어라면 이런 경험이 있을 것이다. 프로덕션 서버의 성능이 갑자기 떨어졌는데 도구로 원인을 찾을 수 없다. CPU 사용률, 메모리, 디스크 I/O는 정상인데 왜 응답 시간이 증가했을까? 또는 특정 애플리케이션이 파일을 어떻게 읽고 있는지 알고 싶지만, 애플리케이션을 수정하지 않고는 방법이 없다.

전통적으로 리눅스 커널 내부를 들여다보려면 매우 제한적인 선택지만 있었다. 커널을 재컴파일하거나, 커널 모듈을 작성해야 했고, 이는 시스템에 위험을 초래할 수 있었다. 하지만 지난 몇 년간 리눅스 커널에 탑재된 eBPF(extended Berkeley Packet Filter)라는 기술이 이 상황을 완전히 바꿔놨다.

eBPF는 커널을 재컴파일하지 않고도 커널 코드를 동적으로 삽입하고, 안전성을 보장하면서도 거의 네이티브 성능으로 실행할 수 있게 해준다. 이제 한 줄의 명령어로 시스템 이벤트를 실시간으로 추적하고, 복잡한 진단 도구를 직접 만들 수 있다.

eBPF의 핵심: 안전하게 커널에 코드를 삽입하다

eBPF를 이해하려면 먼저 그것이 어떻게 작동하는지 알아야 한다. eBPF 프로그램은 세 단계를 거쳐 실행된다: 컴파일, 검증, 그리고 실행이다.

1단계: 바이트코드로 컴파일

eBPF 프로그램은 제한된 C 언어로 작성된다. 이 코드는 clang/LLVM으로 eBPF 바이트코드로 컴파일되고, 커널로 로드된다. 왜 바이트코드를 거쳐야 할까? 바이트코드는 CPU 독립적이고 (ARM, x86, x86-64 모두 같은 코드), 커널이 안전성을 검증할 수 있기 때문이다.

2단계: Verifier가 안전성 확인

커널이 eBPF 프로그램을 받으면 Verifier가 정적 분석을 수행한다. 이 검증 단계에서 다음을 확인한다:

  • 무한 루프가 없는가? (반드시 bounded loop여야 함)
  • 정하지 않은 변수를 사용하지는 않는가?
  • 스택을 512바이트 이상 사용하지는 않는가?
  • 커널 메모리에 부정당한 접근을 시도하지는 않는가?

검증에 통과하지 못하면 프로그램은 로드되지 않는다. 이것이 사용자 코드 하나 때문에 커널 패닉이 발생하지 않는 이유다.

3단계: JIT 컴파일러로 네이티브 성능 달성

검증을 통과한 바이트코드는 JIT(Just-In-Time) 컴파일러로 대상 CPU의 네이티브 기계어로 변환된다. 이는 eBPF 프로그램이 거의 네이티브 C로 작성한 커널 모듈과 비슷한 성능을 낼 수 있다는 뜻이다.

핵심 차이점: 커널 모듈은 검증 없이 실행되므로 버그가 커널 패닉을 일으킬 수 있지만, eBPF는 Verifier를 통과한 것만 실행되므로 안전하다.

Hook 포인트: 커널 이벤트를 어디서 잡을 것인가

eBPF 프로그램이 실행되려면 “언제” 실행될지를 지정해야 한다. 이것을 hook 포인트라 부르며, 크게 세 가지가 있다.

kprobe: 동적 커널 함수 훅

kprobe는 거의 모든 커널 함수의 진입점이나 반환점에 동적으로 훅을 삽입한다. 이는 매우 강력해서 깊은 내부 함수까지 추적할 수 있지만, 한 가지 문제가 있다: 커널 내부 함수는 ABI 안정성이 보장되지 않는다. 즉, 커널이 업그레이드되면 함수 이름이 바뀌거나 시그니처가 변경되어 프로그램이 작동하지 않을 수 있다.

uprobe: 유저스페이스 함수 훅

uprobe를 사용하면 라이브러리나 애플리케이션의 함수에 훅을 걸 수 있다. 예를 들어, Python 인터프리터의 함수 호출을 추적하거나, 오픈소스 라이브러리의 내부 동작을 볼 수 있다. 이는 애플리케이션 레벨의 성능 분석에 매우 유용하다.

tracepoint: 커널 개발자가 명시한 안정 이벤트

tracepoint는 커널 개발자들이 “이 지점은 추적 가능하도록 노출하겠다”고 명시한 이벤트들이다. 예를 들어 sched:sched_switch (프로세스 컨텍스트 스위칭), tcp:tcp_retransmit_skb (TCP 재전송) 같은 것들이다. 이들은 ABI 안정성이 보장되므로 커널이 업그레이드되어도 안정적으로 작동한다. 프로덕션 환경에서는 tracepoint를 최우선으로 사용해야 한다.

Hook 포인트 선택 규칙: 먼저 tracepoint를 찾아본다 (가장 안정적). 없으면 uprobe를 고려한다 (애플리케이션 레벨). 마지막 수단으로 kprobe를 사용한다 (강력하지만 불안정).

BPF Maps: 커널과 유저스페이스 사이의 통신

eBPF 프로그램은 커널에서 실행되지만, 수집한 데이터를 어딘가로 보내야 한다. 바로 여기서 BPF Maps가 중요한 역할을 한다.

BPF Maps는 커널의 eBPF 프로그램과 유저스페이스 도구 사이의 키-값 저장소다. eBPF 프로그램은 이벤트 발생 시 맵에 데이터를 기록하고, 유저스페이스는 이 맵을 읽어 처리한다.

주요 맵 타입들:

  • Hash map: 임의 키-값 저장, 키를 기반으로 빠른 조회
  • Array map: 인덱스 기반 배열, 매우 빠른 접근
  • LRU map: 메모리 제한이 있을 때 사용, 가장 오래된 항목 자동 제거
  • Ring buffer: 고속 스트리밍에 최적화, 최신 트레이싱 도구에서 선호됨

간단한 예로, 파일을 열 때마다 PID별로 횟수를 세는 BPF_HASH 맵이 있다면, 유저스페이스는 이 맵을 주기적으로 읽어 “PID 1234는 파일을 1000번 열었다”는 통계를 수집할 수 있다.

도구: bpftrace vs BCC

eBPF로 커널을 추적하려면 두 가지 주요 도구 중 하나를 선택하게 된다: bpftrace와 BCC다. 각각 다른 상황에 맞다.

bpftrace: 빠른 진단을 위한 원라이너

bpftrace는 awk나 DTrace에서 영감을 받은 고수준 DSL(Domain-Specific Language)이다. 복잡한 프로그램을 작성할 필요 없이 한 줄 명령으로 커널 이벤트를 쿼리할 수 있다.

모든 exec() 시스템 콜을 추적해보자:

sudo bpftrace -e 'tracepoint:syscalls:sys_enter_execve { printf("%s -> %s\n", comm, str(args.filename)); }'

이 한 줄로 어떤 프로세스가 어떤 명령어를 실행했는지 실시간으로 볼 수 있다.

파일 열기를 추적하려면:

sudo bpftrace -e 'tracepoint:syscalls:sys_enter_openat { printf("%s %s\n", comm, str(args.filename)); }'

레이턴시를 측정할 수도 있다. execve() 시스템 콜의 실행 시간을 히스토그램으로 보자:

sudo bpftrace -e '
  tracepoint:syscalls:sys_enter_execve { @start[tid] = nsecs; }
  tracepoint:syscalls:sys_exit_execve  /@start[tid]/ {
    @latency_us = hist((nsecs - @start[tid]) / 1000);
    delete(@start[tid]);
  }
'

이렇게 bpftrace는 빠른 진단에 최적화되어 있다. 문제를 빠르게 파악해야 할 때 가장 강력한 도구다.

BCC: 복잡한 도구 개발을 위한 프레임워크

BCC(BPF Compiler Collection)는 복잡한 커스텀 트레이서를 개발할 때 사용한다. 커널 측 C 코드와 유저스페이스 Python/Lua 스크립트를 결합해 강력한 도구를 만들 수 있다.

BCC에 포함된 도구들:

  • execsnoop: 새 프로세스 생성 실시간 추적 (악성 프로세스 탐지)
  • biosnoop: 블록 I/O 레이턴시 측정 (디스크 성능 분석)
  • tcptracer: TCP 연결 추적
  • runqlat: CPU 런큐 레이턴시 히스토그램 (스케줄러 분석)
  • offcputime: 프로세스가 CPU 대기 중인 시간 분석

이 도구들은 별도 코드 작성 없이 바로 실행 가능하다:

sudo apt install bpfcc-tools linux-headers-$(uname -r)
sudo execsnoop-bpfcc

또한 BCC Python API를 사용해 자신만의 트레이서를 만들 수 있다.

실제 코드로 해보기

bpftrace로 TCP 전송 모니터링

8KB 이상을 전송하는 모든 tcp_sendmsg() 호출을 감지해보자:

sudo bpftrace -e 'kprobe:tcp_sendmsg /arg2 > 8192/ { printf("PID %d (%s): %d bytes\n", pid, comm, arg2); }'

/arg2 > 8192/ 조건은 두 번째 인자(발송 바이트 수)가 8KB를 초과하는 경우만 필터링한다. 이렇게 특정 조건에서만 트레이싱할 수 있어 성능 오버헤드를 줄일 수 있다.

BCC Python으로 커스텀 파일 추적기

이번엔 BCC Python API를 사용해 파일을 여는 프로세스를 추적하고, PID별로 집계하는 도구를 만들어보자:

#!/usr/bin/env python3
from bcc import BPF

prog = """
#include <uapi/linux/ptrace.h>
#include <linux/sched.h>

BPF_HASH(counts, u32, u64);

int trace_open(struct pt_regs *ctx, int dfd, const char __user *filename, int flags) {
    u32 pid = bpf_get_current_pid_tgid() >> 32;
    u64 *val, zero = 0;

    val = counts.lookup_or_try_init(&pid, &zero);
    if (val) (*val)++;

    char fname[256];
    bpf_probe_read_user_str(fname, sizeof(fname), filename);
    bpf_trace_printk("PID %d opened: %s\\n", pid, fname);
    return 0;
}
"""

b = BPF(text=prog)
try:
    b.attach_kprobe(event=b.get_syscall_fnname("openat"), fn_name="trace_open")
except Exception:
    b.attach_kprobe(event="do_sys_open", fn_name="trace_open")

print("파일 열기 이벤트 추적 중... Ctrl+C로 종료")
print(f"{'PID':<8} {'EVENT':<10}")

try:
    b.trace_print()
except KeyboardInterrupt:
    print("\n--- PID별 open() 호출 횟수 ---")
    for pid, count in b["counts"].items():
        print(f"  PID {pid.value}: {count.value} 회")

이 프로그램은 openat() 시스템 콜을 후킹해 파일명을 출력하고, 동시에 BPF_HASH 맵에서 PID별 횟수를 집계한다. 유저스페이스에서는 `bpf_trace_printk()` 출력을 실시간으로 읽고, 프로그램 종료 시 최종 통계를 출력한다.

핵심 함수: bpf_probe_read_user_str()은 유저스페이스 포인터를 안전하게 읽는 헬퍼다. 직접 역참조하면 데이터가 누락될 수 있으므로 반드시 이 함수를 사용해야 한다.

CO-RE와 BTF: 한 번 컴파일하고 모든 곳에서 실행하기

전통적인 BCC 방식의 문제점이 하나 있다. 실행할 때마다 커널 헤더가 필요하고, 대상 머신에서 LLVM으로 다시 컴파일해야 한다. 이는 배포와 운영을 어렵게 만든다.

CO-RE(Compile Once – Run Everywhere)는 이 문제를 해결한다. BTF(BPF Type Format, 커널 5.2+)를 활용하면 한 번 컴파일한 eBPF 바이너리를 다양한 커널 버전에서 재컴파일 없이 실행할 수 있다.

Cilium, Falco, Pixie 같은 현대 클라우드 네이티브 도구들이 모두 CO-RE를 채택하고 있다. 이는 컨테이너 환경에서 eBPF 도구를 배포하는 것을 훨씬 간단하게 만든다.

주의사항과 트러블슈팅

커널 버전 호환성

대부분의 현대 eBPF 기능은 Linux 5.8 이상을 요구한다. CO-RE와 BTF는 5.2 이상에서만 작동한다. CentOS 7이나 RHEL 7(커널 3.10)에서는 매우 제한적이며, BCC 재컴파일이 필요할 수도 있다.

버전 확인: uname -r로 현재 커널 버전을 확인하고, 필요한 기능이 지원되는지 확인해야 한다.

Verifier 거부 오류

eBPF 프로그램이 Verifier를 통과하지 못하면 로드되지 않는다. 일반적인 오류들:

  • 스택 초과: 512바이트 제한을 초과하면 "stack depth" 오류
  • 무한 루프: 상한이 없는 루프는 거부됨
  • 미초기화 변수: 모든 변수를 먼저 초기화해야 함

dmesg를 확인하면 더 자세한 verifier 로그를 볼 수 있다:

sudo dmesg | tail -50

kprobe의 ABI 불안정성

kprobe는 강력하지만 위험하다. 커널 버전이 올라가면 함수 이름이나 시그니처가 변경되어 프로그램이 attach에 실패할 수 있다. 가능하면 tracepoint를 사용하고, 부득이하게 kprobe를 사용할 땐 여러 함수 이름을 시도하는 fallback 로직이 필요하다.

권한 문제

eBPF 프로그램 로드에는 root 또는 CAP_BPF 권한이 필요하다. 일부 배포판에서는 보안상 이유로 비루트 실행을 막아놨다:

cat /proc/sys/kernel/unprivileged_bpf_disabled

이 값이 1이면 root만 eBPF를 사용할 수 있다. 이를 해제하면 CVE-2020-8835 같은 권한 상승 취약점에 노출될 수 있으므로 주의해야 한다.

유저스페이스 메모리 접근

eBPF는 페이지 폴트가 비활성화된 상태에서 실행된다. 유저스페이스 포인터를 직접 역참조하면 안 된다. 반드시 헬퍼 함수를 사용한다:

char buf[256];
bpf_probe_read_user_str(buf, sizeof(buf), user_ptr);  // 올바른 방법

BCC 도구의 런타임 의존성

BCC 기반 도구는 실행 시 LLVM과 커널 헤더가 설치되어 있어야 한다. 가벼운 배포(Alpine 같은 것)에서는 이것이 문제가 될 수 있다. CI/CD 환경에서는 libbpf + CO-RE 기반 도구나 사전 컴파일된 바이너리를 사용하는 것이 더 실용적이다.

마치며

eBPF는 Linux 성능 분석과 커널 추적의 판도를 바꿔놨다. 커널을 재컴파일하거나 모듈을 삽입할 필요 없이, 안전하고 빠르게 커널 내부를 들여다볼 수 있게 되었다.

입문자는 bpftrace로 시작해 한 줄 명령으로 커널 이벤트를 쿼리하는 것부터 배운다. 더 복잡한 도구가 필요하면 BCC Python API로 넘어간다. 그리고 프로덕션 환경에서는 Cilium이나 Falco 같은 검증된 도구를 사용하는 것이 권장된다.

eBPF를 마스터하면 "보이지 않던" 커널의 동작이 시각화되고, 성능 문제를 근본 원인부터 진단할 수 있다. 시스템 엔지니어에게 이것은 매우 강력한 무기가 된다.

참고 문서

답글 남기기