본문 바로가기
Infra2026년 8월 31일17분 읽기

eBPF 입문 — 커널을 다시 컴파일하지 않고 관측·보안·네트워킹을 확장하기

YS
김영삼
조회 17
eBPF 입문 — 커널을 다시 컴파일하지 않고 관측·보안·네트워킹을 확장하기

eBPF는 리눅스 커널 안에서 검증된 작은 프로그램을 안전하게 실행하는 기술이다. 커널을 다시 컴파일하지도, 위험한 모듈을 로드하지도 않고 관측·네트워킹·보안 기능을 붙일 수 있다.

10여 년 전만 해도 커널 동작을 바꾸려면 소스를 패치해 새 커널을 빌드하거나, 크래시 위험을 감수하고 커널 모듈을 밀어 넣어야 했다. eBPF는 그 사이에 "검증기"라는 안전장치를 두어, 사용자가 작성한 프로그램이 커널을 죽이지 않음을 로드 시점에 증명하게 만든다. 오늘날 Cilium, Falco, Pixie, Tetragon 같은 도구가 모두 이 위에 서 있다.

1992
고전 BPF(패킷 필터) 등장
3.15+
eBPF 커널 병합(2014)
~4096
검증기 명령어 상한(초기), 지금은 100만+
ns~µs
JIT 후 훅당 오버헤드 수준

eBPF란 무엇인가 — 커널 안의 안전한 VM

eBPF(extended Berkeley Packet Filter)는 이름의 유래(패킷 필터)와 달리 이제는 범용에 가까운 커널 내 실행 환경이다. 사용자가 제한된 명령어 집합으로 작성한 프로그램을 커널의 특정 이벤트(시스템콜 진입, 함수 호출, 패킷 수신 등)에 붙여 두면, 그 이벤트가 발생할 때마다 커널이 해당 프로그램을 실행한다. 핵심은 이 실행이 샌드박스 안에서 안전하게 이뤄진다는 점이다.

흔히 "커널을 위한 자바스크립트"라는 비유가 쓰인다. 브라우저가 임의의 웹 페이지 스크립트를 실행하면서도 브라우저 자체는 지키듯, 커널은 임의의 eBPF 프로그램을 실행하면서도 커널 자신의 안정성은 보장한다. 차이가 있다면 eBPF에는 가비지 컬렉터도, 무한 루프도 없다는 것이다. 프로그램은 반드시 유한한 시간 안에 종료해야 하며, 이를 로드 전에 정적으로 증명해야 한다.

이것이 커널 모듈과 결정적으로 다른 점이다. 커널 모듈은 커널과 동일한 주소 공간에서 무엇이든 할 수 있어 버그 하나가 곧 커널 패닉이다. 반면 eBPF 프로그램은 검증기를 통과하지 못하면 애초에 로드되지 않는다. "위험을 감수하고 넣는" 것이 아니라 "안전이 증명된 것만 넣는" 모델이다.

동작 원리 — 검증기·JIT·맵·헬퍼

eBPF의 생명주기는 네 개의 축으로 이해하면 깔끔하다. 사용자공간에서 프로그램을 바이트코드로 컴파일하고, 커널에 로드할 때 검증기(verifier)가 안전성을 증명하며, 통과하면 JIT 컴파일러가 네이티브 기계어로 바꾸고, 실행 중에는 맵(map)헬퍼(helper)로 커널·사용자공간과 소통한다.

검증기는 eBPF의 심장이다. 프로그램의 모든 가능한 실행 경로를 그래프로 펼쳐서 (1) 무한 루프가 없고, (2) 초기화되지 않은 메모리나 허용 범위를 벗어난 포인터에 접근하지 않으며, (3) 종료가 보장됨을 확인한다. 초기에는 명령어 4096개, 반복 없음이라는 엄격한 제한이 있었지만, 지금은 bounded loop를 허용하고 상한도 100만 명령어 수준으로 완화됐다. 그럼에도 검증기는 여전히 보수적이라 "사람이 보기엔 명백히 안전한" 코드도 거부하는 일이 잦다.

은 eBPF 프로그램이 상태를 저장하고, 프로그램끼리 또는 사용자공간과 데이터를 주고받는 유일한 통로다. 해시맵, 배열, per-CPU 배열, LRU 맵, ring buffer 등 종류가 다양하다. 예컨대 시스템콜 호출 횟수를 세는 카운터는 맵에 누적하고, 사용자공간 도구가 그 맵을 주기적으로 읽어 화면에 그린다. 헬퍼 함수는 eBPF 프로그램이 호출할 수 있도록 커널이 노출한 안전한 API 집합이다(현재 시각 조회, 맵 조회/갱신, 패킷 조작, perf 이벤트 전송 등). 임의의 커널 함수를 마음대로 부를 수 없고 이 화이트리스트만 쓸 수 있다는 점이 또 하나의 안전장치다.

참고검증기를 통과했다고 프로그램이 "논리적으로 옳다"는 뜻은 아니다. 검증기가 보장하는 것은 커널을 죽이지 않는다는 안전성이지, 당신이 세려던 값을 제대로 센다는 정확성이 아니다. 여전히 테스트는 당신 몫이다.

훅 포인트 — 어디에 붙일 수 있나

eBPF의 활용은 결국 "어떤 이벤트에 프로그램을 붙이느냐"로 결정된다. 대표적인 훅 포인트를 성격별로 나눠 보면 다음과 같다.

훅 포인트붙는 위치주 용도안정성
kprobe/kretprobe임의 커널 함수 진입/반환동적 커널 추적불안정(함수명 바뀌면 깨짐)
tracepoint커널이 공식 노출한 지점안정적 추적안정(ABI 보장)
uprobe사용자공간 함수앱/라이브러리 추적앱 심볼에 의존
XDPNIC 드라이버 최전선(스택 이전)초고속 패킷 처리·DDoS 차단안정
tc(traffic control)네트워크 스택 큐 지점패킷 정책·로드밸런싱안정
LSM(BPF LSM)보안 훅(권한 결정 지점)런타임 보안 강제안정(5.7+)

실무 원칙 하나: 가능하면 tracepoint를 쓰고, 없을 때만 kprobe로 내려가라. kprobe는 커널 내부 함수 이름에 직접 붙기 때문에 커널 버전이 바뀌면 함수가 사라지거나 시그니처가 달라져 프로그램이 조용히 깨진다. tracepoint는 커널이 "이건 유지하겠다"고 약속한 안정적 지점이라 이식성이 훨씬 좋다.

대표 활용 — 관측·네트워킹·보안

eBPF가 실제로 빛나는 세 영역을 보자. 공통점은 모두 "애플리케이션을 건드리지 않고" 커널 레벨에서 관찰·개입한다는 것이다.

관측(Observability). bpftrace는 awk 같은 한 줄짜리 DSL로 커널·앱을 즉석에서 추적한다. Pixie는 쿠버네티스 클러스터에 배포해 코드 수정 없이 서비스 간 HTTP/gRPC/DB 트래픽을 자동 계측한다. 전통적 APM이 앱에 에이전트나 SDK를 심어야 했던 것과 달리, eBPF 기반 관측은 커널이 이미 보는 것을 그대로 활용하므로 계측 코드 제로에 가깝다.

네트워킹. Cilium은 eBPF로 쿠버네티스 CNI를 구현한다. iptables 규칙 수천 개를 선형 순회하던 kube-proxy를 eBPF 맵 조회로 대체해 서비스 라우팅을 O(1)에 가깝게 만든다. XDP는 NIC 드라이버 최전선에서 패킷을 처리해, 커널 네트워크 스택에 진입하기도 전에 DDoS 패킷을 XDP_DROP으로 떨궈 버린다. L4 로드밸런서(예: Meta의 Katran 계열 접근)도 이 지점에서 초당 수백만 패킷을 넘긴다.

보안. Falco와 Tetragon은 시스템콜과 커널 이벤트를 실시간 감시해 "컨테이너 안에서 셸이 떴다", "예상치 못한 파일에 쓰기가 일어났다" 같은 행위를 탐지한다. BPF LSM 훅에 붙으면 단순 탐지를 넘어 차단까지 강제할 수 있다. 사이드카나 앱 수정 없이, 커널이 모든 프로세스를 한눈에 보는 위치라는 이점을 그대로 살린다.

실전 예시 — bpftrace 한 줄과 간단한 코드

백문이 불여일견이다. bpftrace 한 줄이면 커널 계측의 감을 바로 잡을 수 있다. 아래는 어떤 프로세스가 어떤 파일을 여는지 실시간으로 흘려보는 명령이다.

# openat 시스템콜을 추적: PID · 프로세스명 · 열린 파일 경로 출력
sudo bpftrace -e 'tracepoint:syscalls:sys_enter_openat { printf("%d %s %s\n", pid, comm, str(args->filename)); }'

# 프로세스별 read() 시스템콜 호출 횟수 집계 (Ctrl+C 시 히스토그램 출력)
sudo bpftrace -e 'tracepoint:syscalls:sys_enter_read { @[comm] = count(); }'

# 블록 I/O 지연시간 분포를 로그2 히스토그램으로
sudo bpftrace -e 'tracepoint:block:block_rq_issue { @start[args->dev] = nsecs; }'

조금 더 본격적인 프로그램은 C로 작성해 libbpf로 로드한다. 아래는 execve가 호출될 때마다 실행 파일명을 커널에서 사용자공간으로 보내는 최소 골격이다(핵심만 발췌).

// exec_trace.bpf.c — 커널 측 eBPF 프로그램
#include <vmlinux.h>
#include <bpf/bpf_helpers.h>

char LICENSE[] SEC("license") = "GPL";  // 다수 헬퍼는 GPL 심볼 요구

// 사용자공간으로 이벤트를 흘려보낼 ring buffer 맵
struct {
    __uint(type, BPF_MAP_TYPE_RINGBUF);
    __uint(max_entries, 256 * 1024);
} events SEC(".maps");

SEC("tracepoint/syscalls/sys_enter_execve")
int handle_exec(struct trace_event_raw_sys_enter *ctx) {
    char *msg = bpf_ringbuf_reserve(&events, 128, 0);
    if (!msg) return 0;                  // 공간 없으면 조용히 포기
    // 첫 인자(파일 경로)를 안전하게 복사 — 직접 역참조는 검증기가 거부
    bpf_probe_read_user_str(msg, 128, (const char *)ctx->args[0]);
    bpf_ringbuf_submit(msg, 0);
    return 0;
}

여기서 눈여겨볼 대목은 포인터를 직접 *ptr로 읽지 않고 bpf_probe_read_user_str 헬퍼로 복사한다는 점이다. 검증기는 사용자공간 포인터의 직접 역참조를 절대 허용하지 않는다. 이런 제약을 몸에 익히는 것이 eBPF 학습의 절반이다.

CO-RE와 BTF — "한 번 컴파일, 어디서나 실행"

초기 eBPF의 가장 큰 골칫거리는 이식성이었다. 커널 구조체의 필드 오프셋은 커널 버전·설정마다 다르다. 그래서 예전 bcc 방식은 대상 머신마다 커널 헤더를 깔고 배포 시점에 LLVM/Clang으로 현장 컴파일을 해야 했다. 무겁고 취약했다.

CO-RE(Compile Once, Run Everywhere)가 이를 해결했다. 커널이 자신의 타입 정보를 BTF(BPF Type Format)로 내장해 노출하면(/sys/kernel/btf/vmlinux), 컴파일러는 "이 필드의 오프셋"을 상수로 박지 않고 재배치(relocation) 지시로 남겨 둔다. 로드 시점에 libbpf가 실제 커널의 BTF를 보고 오프셋을 채워 넣는다. 덕분에 개발 머신에서 한 번 빌드한 .o 하나를 여러 커널 버전에 그대로 배포할 수 있다. 오늘날 실무 eBPF 프로젝트의 사실상 표준 경로다.

참고CO-RE를 쓰려면 대상 커널이 CONFIG_DEBUG_INFO_BTF=y로 빌드돼 있어야 한다. 최신 배포판(우분투 20.10+, RHEL 8.2+ 등)은 대부분 켜져 있지만, 오래된 커스텀 커널에서는 BTF가 없어 CO-RE가 동작하지 않을 수 있다.

사용자공간 도구 — 무엇으로 시작할까

eBPF를 다루는 도구는 추상화 수준으로 나뉜다. 목적에 맞게 고르면 된다.

  • bpftrace — 즉석 조사·프로토타이핑에 최적. 한 줄로 끝나는 문제라면 여기서 멈춰라. 프로덕션 상시 배포용은 아니다.
  • bcc — 파이썬 프런트엔드 + C 커널 코드. 성숙한 도구 모음(execsnoop, biolatency 등)을 제공하지만 런타임 컴파일 의존성이 무겁다.
  • libbpf + CO-RE — 프로덕션급 독립 실행 바이너리를 만드는 현대적 표준 경로. C로 쓰고 정적 링크해 배포한다.
  • 고수준 프레임워크 — Go의 cilium/ebpf, Rust의 Aya 등. 앱 안에 eBPF를 임베드하고 싶을 때.

추천 학습 순서는 명확하다. bpftrace로 감을 잡고 → bcc 도구들의 소스를 읽어 패턴을 익히고 → libbpf+CO-RE로 첫 프로덕션 프로그램을 써 보는 것이다.

한계와 주의점 — 그리고 언제 과한가

eBPF는 강력하지만 만능은 아니다. 냉정하게 짚어야 할 제약이 있다.

커널 버전 의존성. 기능마다 최소 커널 버전이 다르다. BPF LSM은 5.7+, ring buffer는 5.8+, 일부 헬퍼는 더 최신을 요구한다. 낡은 커널(예: 4.x 장기 지원 배포판)을 운영한다면 쓸 수 있는 기능이 크게 제한된다.

검증기의 벽. 논리적으로 안전한 코드도 검증기가 증명하지 못하면 거부한다. 복잡한 루프, 큰 스택(512바이트 상한), 까다로운 포인터 연산에서 자주 막힌다. "왜 verifier가 이걸 거부하지?"와 씨름하는 시간이 eBPF 개발의 상당 부분을 차지한다. 오류 메시지가 불친절하기로도 악명 높다.

권한. eBPF 프로그램 로드에는 보통 CAP_BPF(+ 훅에 따라 CAP_PERFMON, CAP_NET_ADMIN) 또는 사실상 root가 필요하다. 이는 곧 eBPF 자체가 커널 공격 표면이 될 수 있다는 뜻이기도 하다. 신뢰할 수 없는 사용자에게 eBPF 로드 권한을 주면 안 된다.

주의"멋있어 보여서" eBPF를 꺼내는 것은 흔한 실수다. 애플리케이션 로그 한 줄, 표준 프로파일러(perf), 기존 APM으로 답이 나오는 문제라면 굳이 커널 프로그램을 작성·검증·배포하는 비용을 감수할 이유가 없다. eBPF는 앱을 건드릴 수 없거나, 커널/네트워크 레벨의 가시성이 정말 필요하거나, 초당 수백만 이벤트 규모일 때 값어치를 한다.

정리하면, eBPF는 "커널을 다시 컴파일하지 않고 커널을 확장한다"는 오래된 꿈을 안전한 방식으로 실현한 기술이다. 관측·네트워킹·보안의 현대적 인프라 상당수가 이미 그 위에 서 있다. 다만 도구가 강력할수록 "이 문제에 정말 이게 필요한가"를 먼저 묻는 절제가 중요하다.

자주 묻는 질문

eBPF를 쓰려면 커널 모듈을 만들 줄 알아야 하나요?

아닙니다. 오히려 그 반대가 eBPF의 존재 이유입니다. 커널 모듈은 커널을 크래시시킬 수 있어 위험하지만, eBPF 프로그램은 검증기를 통과해야만 로드되므로 커널 안정성이 보장됩니다. 게다가 bpftrace 한 줄이면 커널 코드를 전혀 몰라도 유용한 추적을 시작할 수 있습니다. 본격적인 프로그램도 C(또는 Go/Rust 프레임워크)로 사용자공간에서 작성해 로드하는 방식이라, 전통적 커널 모듈 개발과는 경험이 다릅니다.

eBPF는 성능에 얼마나 부담을 주나요?

JIT 컴파일된 후에는 훅당 오버헤드가 대개 나노초에서 마이크로초 수준으로 매우 낮습니다. 그래서 프로덕션 상시 관측에 쓸 수 있는 것입니다. 다만 이벤트 빈도가 극단적으로 높은 지점(예: 초당 수백만 번 호출되는 함수)에 무거운 프로그램을 붙이면 누적 비용이 커질 수 있으니, 필터링을 커널 측에서 최대한 일찍 수행하고 사용자공간으로는 요약된 데이터만 넘기는 설계가 중요합니다.

kprobe와 tracepoint 중 무엇을 써야 하나요?

가능하면 tracepoint를 우선하세요. tracepoint는 커널이 안정적 인터페이스로 유지하겠다고 약속한 지점이라 커널 버전이 바뀌어도 잘 깨지지 않습니다. 반면 kprobe는 임의의 커널 내부 함수에 붙기 때문에, 그 함수가 리팩터링되거나 인라인되면 프로그램이 조용히 동작을 멈춥니다. 원하는 정보를 노출하는 tracepoint가 없을 때에 한해 kprobe로 내려가는 것이 실무 원칙입니다.

CO-RE가 없으면 어떤 문제가 생기나요?

CO-RE 이전 방식(bcc 계열)은 프로그램을 배포하는 각 머신에서 그 커널의 헤더를 기반으로 현장 컴파일을 해야 했습니다. 즉 대상 노드마다 LLVM/Clang과 커널 헤더가 필요했고, 시작이 느리고 이식성이 나빴습니다. CO-RE는 커널의 BTF 정보를 이용해 오프셋을 로드 시점에 재배치하므로, 개발 머신에서 한 번 빌드한 단일 바이너리를 여러 커널에 그대로 배포할 수 있습니다. 대상 커널에 BTF가 활성화돼 있어야 한다는 전제만 지키면 됩니다.

eBPF는 리눅스 전용인가요?

eBPF의 뿌리와 성숙도는 압도적으로 리눅스에 있습니다. 다만 Microsoft가 주도하는 eBPF for Windows 프로젝트가 XDP·소켓 훅 등 일부를 윈도우로 이식하고 있어, 완전한 리눅스 전용은 아니게 되어 가는 중입니다. 실무에서는 여전히 "eBPF = 리눅스 커널 기술"로 이해하고 접근하는 편이 정확합니다.

언제 eBPF를 쓰지 말아야 하나요?

기존 도구로 충분할 때입니다. 애플리케이션 로그, 표준 프로파일러(perf), 성숙한 APM/트레이싱 SDK로 답이 나오는 문제에 eBPF를 꺼내면 개발·검증·커널 버전 호환·권한 관리라는 비용만 늘어납니다. eBPF는 앱을 수정할 수 없거나, 커널·네트워크 레벨의 깊은 가시성이 꼭 필요하거나, 극단적 처리량에서 오버헤드를 최소화해야 할 때 그 복잡성을 정당화합니다. "할 수 있다"와 "해야 한다"는 다릅니다.

댓글 0

아직 댓글이 없습니다.
Ctrl+Enter로 등록