- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 들어가며
- eBPF가 커널을 바꾼 방식
- 전체 아키텍처 한눈에 보기
- 프로그램 타입 지도
- 맵: 커널과 유저 공간의 다리
- Verifier: 커널의 문지기
- 첫 프로그램 실습: libbpf + CO-RE
- bpftool: eBPF의 스위스 아미 나이프
- 언어와 프레임워크 선택
- 커널 버전과 기능 매트릭스
- 디버깅 팁
- 프로덕션 고려사항
- 학습 로드맵
- 함정과 안티패턴
- 마치며
- 참고 자료
들어가며
운영 중인 서버에서 "특정 프로세스가 어떤 파일을 열고 있는지 실시간으로 보고 싶다"는 요구가 생겼다고 가정해 보겠습니다. 전통적인 방법은 두 가지였습니다. 커널 모듈을 직접 작성해서 올리거나, strace 같은 ptrace 기반 도구로 프로세스를 붙잡아 관찰하는 것입니다. 전자는 커널 패닉의 위험을 감수해야 하고, 후자는 대상 프로세스를 수십 배 느리게 만듭니다. 프로덕션에서는 둘 다 쉽게 선택할 수 없는 방법입니다.
eBPF(extended Berkeley Packet Filter)는 이 딜레마를 근본적으로 해결했습니다. 커널을 재컴파일하거나 모듈을 로드하지 않고도, 검증된 안전한 프로그램을 커널 내부의 거의 모든 지점에 부착해 실행할 수 있습니다. 오늘날 Cilium 같은 CNI, Katran 같은 로드밸런서, Falco와 Tetragon 같은 보안 도구, 그리고 수많은 관측성 도구가 모두 eBPF 위에서 동작합니다.
이 글에서는 eBPF의 핵심 구성 요소인 프로그램, 맵, Verifier를 차례로 살펴보고, libbpf와 CO-RE를 이용한 첫 프로그램을 처음부터 끝까지 작성해 보겠습니다. 이후 관측성 편과 보안 편으로 이어지는 시리즈의 첫 번째 글입니다.
eBPF가 커널을 바꾼 방식
커널 모듈의 시대와 그 한계
리눅스 커널의 동작을 확장하는 전통적인 방법은 커널 모듈(LKM)이었습니다. 커널 모듈은 강력하지만 치명적인 단점이 있습니다.
| 항목 | 커널 모듈 (LKM) | eBPF 프로그램 |
|---|---|---|
| 안전성 | 버그 하나로 커널 패닉 가능 | Verifier가 로드 전에 안전성 검증 |
| 격리 | 커널 전체 메모리 접근 가능 | 헬퍼 함수를 통한 제한된 접근만 허용 |
| 호환성 | 커널 버전마다 재컴파일 필요 | CO-RE로 단일 바이너리가 여러 커널에서 동작 |
| 배포 | 모듈 서명, 정책 이슈 | 시스템 콜 하나로 로드, 권한으로 통제 |
| 종료 보장 | 무한 루프 가능 | 프로그램 종료가 정적으로 증명되어야 로드됨 |
| 학습 비용 | 커널 내부 API 전반 이해 필요 | 제한된 헬퍼 API와 C 부분집합 |
eBPF의 핵심 아이디어는 "커널 안에서 돌릴 코드를 커널이 미리 검증한다"입니다. 자바스크립트가 브라우저라는 샌드박스 안에서 웹을 프로그래머블하게 만들었듯이, eBPF는 커널을 프로그래머블하게 만들었습니다. 실제로 eBPF를 두고 "커널의 자바스크립트"라는 비유가 자주 쓰입니다.
cBPF에서 eBPF로
원래 BPF는 1992년 tcpdump의 패킷 필터링을 위해 설계된 작은 가상 머신(classic BPF, cBPF)이었습니다. 2014년 커널 3.18에서 확장된 eBPF가 도입되면서 다음이 달라졌습니다.
- 레지스터가 2개에서 11개(R0~R10)로 늘고 64비트로 확장
- 맵(map)이라는 영속적 자료구조 도입으로 커널-유저공간 데이터 공유 가능
- 헬퍼 함수 호출 지원으로 커널 기능에 안전하게 접근
- JIT 컴파일러로 네이티브 코드 수준의 성능 확보
- 네트워킹을 넘어 트레이싱, 보안 등 범용 실행 엔진으로 확장
전체 아키텍처 한눈에 보기
eBPF 프로그램이 작성되어 커널에서 실행되기까지의 여정을 그림으로 보면 다음과 같습니다.
유저 공간(User space)
+--------------------------------------------------------------+
| C 소스 (.bpf.c) |
| | clang -target bpf -g (BTF 포함) |
| v |
| ELF 오브젝트 (.bpf.o) |
| | |
| v |
| 로더 (libbpf / cilium-ebpf / aya / bpftool) |
| | CO-RE 재배치 적용, bpf() 시스템 콜 |
+-----|--------------------------------------------------------+
v
커널 공간(Kernel space)
+--------------------------------------------------------------+
| Verifier ──> 안전성 검증 (포인터, 경계, 종료 보장) |
| | 통과 |
| v |
| JIT 컴파일러 ──> 네이티브 기계어로 변환 |
| | |
| v |
| 훅(hook)에 부착 |
| ├── kprobe / kretprobe (커널 함수 진입/리턴) |
| ├── tracepoint (안정적 커널 이벤트) |
| ├── XDP (NIC 드라이버 수신 직후) |
| ├── tc (트래픽 컨트롤 ingress/egress) |
| ├── LSM (보안 훅) |
| └── cgroup (소켓/시스템 콜 제어) |
| |
| 맵(Maps) <────> 유저 공간과 데이터 공유 |
+--------------------------------------------------------------+
핵심 흐름을 요약하면 이렇습니다. 제한된 C로 작성한 코드를 clang이 BPF 바이트코드로 컴파일하고, 로더가 bpf() 시스템 콜로 커널에 전달하면, Verifier가 안전성을 검증한 뒤 JIT가 네이티브 코드로 변환하여 지정된 훅에 부착합니다. 프로그램과 유저 공간은 맵을 통해 데이터를 주고받습니다.
프로그램 타입 지도
eBPF 프로그램은 부착되는 위치(훅)에 따라 타입이 나뉘고, 타입마다 받을 수 있는 컨텍스트와 사용할 수 있는 헬퍼가 다릅니다. 실무에서 자주 만나는 타입을 정리하면 다음과 같습니다.
| 프로그램 타입 | 부착 지점 | 주 용도 | 특징 |
|---|---|---|---|
| kprobe / kretprobe | 임의의 커널 함수 진입/리턴 | 트레이싱, 디버깅 | 유연하지만 커널 버전에 따라 함수가 바뀔 수 있음 |
| tracepoint | 커널에 미리 정의된 정적 이벤트 | 안정적인 트레이싱 | ABI가 비교적 안정적, 권장 시작점 |
| fentry / fexit | 커널 함수 진입/리턴 (BTF 기반) | 고성능 트레이싱 | kprobe보다 오버헤드 낮음, 커널 5.5 이상 |
| XDP | NIC 드라이버 수신 경로 최전단 | DDoS 방어, 로드밸런싱 | sk_buff 할당 전이라 매우 빠름 |
| tc (clsact) | 트래픽 컨트롤 ingress/egress | 패킷 조작, 정책 | 송신 방향 처리 가능, sk_buff 접근 |
| LSM (BPF LSM) | 리눅스 보안 모듈 훅 | 런타임 보안 정책 | 동작 차단 가능, 커널 5.7 이상 |
| cgroup 계열 | cgroup 단위 소켓/시스템 콜 | 컨테이너별 네트워크 정책 | 컨테이너 환경과 궁합이 좋음 |
| uprobe / USDT | 유저 공간 함수/정적 트레이스 포인트 | 애플리케이션 트레이싱 | 라이브러리 함수 추적 등 |
| perf_event | 타이머/PMU 이벤트 | CPU 프로파일링 | 샘플링 기반 분석 |
선택 기준을 간단히 정리하면 이렇습니다. 안정성이 중요하면 tracepoint, 커널 내부의 세밀한 지점이 필요하면 kprobe나 fentry, 패킷을 가장 빠르게 처리해야 하면 XDP, 정책 차단이 목적이면 LSM을 우선 검토합니다.
맵: 커널과 유저 공간의 다리
맵은 eBPF 프로그램이 상태를 저장하고 유저 공간과 데이터를 교환하는 키-값 자료구조입니다. 어떤 맵을 쓰느냐가 프로그램의 성능과 구조를 좌우합니다.
| 맵 타입 | 구조 | 대표 용도 |
|---|---|---|
| BPF_MAP_TYPE_HASH | 해시 테이블 | PID별 통계, 연결 추적 등 임의 키 조회 |
| BPF_MAP_TYPE_ARRAY | 고정 크기 배열 | 설정값, 인덱스 기반 카운터 |
| BPF_MAP_TYPE_PERCPU_HASH | CPU별 해시 | 락 경합 없는 고빈도 카운팅 |
| BPF_MAP_TYPE_PERCPU_ARRAY | CPU별 배열 | 히스토그램 버킷, 핫패스 통계 |
| BPF_MAP_TYPE_LRU_HASH | LRU 해시 | 가득 차면 오래된 항목 자동 제거 |
| BPF_MAP_TYPE_RINGBUF | 링 버퍼 (MPSC) | 커널→유저 이벤트 스트리밍 (5.8 이상, 권장) |
| BPF_MAP_TYPE_PERF_EVENT_ARRAY | CPU별 perf 버퍼 | 구형 커널에서의 이벤트 전달 |
| BPF_MAP_TYPE_LPM_TRIE | 최장 접두사 매칭 | IP CIDR 매칭 |
| BPF_MAP_TYPE_PROG_ARRAY | 프로그램 배열 | tail call로 프로그램 체이닝 |
| BPF_MAP_TYPE_SK_STORAGE | 소켓 로컬 저장소 | 소켓별 메타데이터 |
실무 팁 두 가지를 강조하고 싶습니다.
- 이벤트 전달에는 ringbuf를 우선 사용합니다. perf buffer와 달리 CPU 간 이벤트 순서가 보장되고 메모리 효율이 좋으며, 유저 공간 API도 단순합니다.
- 핫패스의 카운터는 percpu 맵으로 만듭니다. 일반 해시 맵을 여러 CPU가 동시에 갱신하면 원자적 연산 비용이 커지는데, percpu 맵은 CPU별로 독립된 슬롯을 쓰므로 경합이 없습니다. 합산은 유저 공간에서 읽을 때 수행합니다.
맵 정의는 최신 libbpf 스타일에서는 BTF 기반 섹션으로 선언합니다.
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, 10240);
__type(key, __u32); /* PID */
__type(value, __u64); /* 호출 횟수 */
} call_count SEC(".maps");
Verifier: 커널의 문지기
Verifier는 eBPF의 안전성을 책임지는 핵심 컴포넌트입니다. 프로그램이 로드될 때 모든 실행 경로를 정적으로 분석해서 다음을 검증합니다.
- 프로그램이 반드시 종료하는가 (무한 루프 금지)
- 모든 메모리 접근이 검증된 범위 안에 있는가
- 초기화되지 않은 레지스터를 읽지 않는가
- 해당 프로그램 타입에 허용된 헬퍼만 호출하는가
- 포인터 연산이 안전한 범위를 벗어나지 않는가
경계 검사: Verifier가 보는 세상
Verifier는 각 레지스터의 가능한 값 범위를 추적합니다. 패킷 데이터를 읽는 XDP 프로그램에서 전형적인 패턴은 다음과 같습니다.
SEC("xdp")
int xdp_prog(struct xdp_md *ctx)
{
void *data = (void *)(long)ctx->data;
void *data_end = (void *)(long)ctx->data_end;
struct ethhdr *eth = data;
/* 이 경계 검사가 없으면 Verifier가 로드를 거부한다 */
if ((void *)(eth + 1) > data_end)
return XDP_PASS;
/* 검사를 통과한 뒤에야 eth->h_proto 접근이 허용된다 */
if (eth->h_proto == bpf_htons(ETH_P_IP))
return XDP_DROP;
return XDP_PASS;
}
if 문으로 경계를 검사하는 순간, Verifier는 해당 분기 안에서 포인터의 범위가 안전하다는 사실을 "알게" 됩니다. 컴파일러가 이 검사를 최적화로 제거하거나 순서를 바꾸면 검증이 실패할 수 있어서, 경계 검사 코드는 단순하고 직접적으로 작성하는 것이 좋습니다.
루프 제약
초기 eBPF는 루프를 아예 허용하지 않았습니다. 현재는 단계적으로 완화되었습니다.
| 방법 | 커널 버전 | 설명 |
|---|---|---|
| pragma unroll | 전 버전 | 컴파일 타임에 루프를 펼침, 횟수 고정 필요 |
| bounded loop | 5.3 이상 | Verifier가 종료를 증명할 수 있는 유계 루프 허용 |
| bpf_loop 헬퍼 | 5.17 이상 | 콜백 기반 루프, 큰 반복 횟수에 유리 |
| open-coded iterator | 6.4 이상 | bpf_for 매크로 등 이터레이터 기반 반복 |
흔한 검증 실패와 해결법
| 에러 메시지 (요지) | 원인 | 해결 |
|---|---|---|
| invalid mem access | 경계 검사 없이 포인터 역참조 | 접근 전 명시적 경계 검사 추가 |
| unbounded loop detected | 종료 증명 불가능한 루프 | 반복 횟수에 상한 부여, bpf_loop 사용 |
| BPF program is too large | 명령어 수 한도 초과 | 로직 분할, tail call, 커널 5.2 이상에서는 한도가 백만 개로 완화 |
| stack limit exceeded | 스택 512바이트 초과 | 큰 구조체는 percpu array를 스크래치 공간으로 사용 |
| R1 type=ctx expected=fp | 컨텍스트 포인터 오용 | 컨텍스트 필드는 정해진 방식으로만 접근 |
| helper call is not allowed | 프로그램 타입에 금지된 헬퍼 | 해당 타입에서 허용되는 헬퍼 확인 |
Verifier 로그는 길고 난해하기로 유명하지만, 실패 지점 직전의 레지스터 상태 덤프를 따라가면 대부분 원인을 찾을 수 있습니다. libbpf 로더에서 verbose 로그를 켜는 방법은 디버깅 절에서 다룹니다.
첫 프로그램 실습: libbpf + CO-RE
이제 실제로 동작하는 프로그램을 만들어 보겠습니다. 목표는 "시스템 전체에서 실행되는 모든 프로세스(execve)를 추적해서 PID, 부모 PID, 명령어 이름을 출력"하는 미니 execsnoop입니다.
CO-RE와 vmlinux.h, BTF란
과거 bcc 방식은 대상 머신에 clang과 커널 헤더를 설치하고 런타임에 컴파일했습니다. CO-RE(Compile Once, Run Everywhere)는 이 문제를 해결합니다.
- BTF(BPF Type Format): 커널이 자기 자신의 타입 정보를 컴팩트하게 내장한 포맷입니다. /sys/kernel/btf/vmlinux 파일이 있으면 BTF가 활성화된 커널입니다.
- vmlinux.h: 커널 BTF에서 생성한, 모든 커널 타입 정의가 들어 있는 단일 헤더입니다. 커널 헤더 패키지 없이도 커널 구조체를 참조할 수 있게 해 줍니다.
- CO-RE 재배치: 컴파일 시점에 구조체 필드 접근을 "재배치 정보"로 기록해 두고, 로드 시점에 libbpf가 실행 중인 커널의 BTF와 대조해 필드 오프셋을 보정합니다. 덕분에 한 번 빌드한 바이너리가 필드 배치가 다른 여러 커널에서 그대로 동작합니다.
vmlinux.h는 다음 명령으로 생성합니다.
bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h
커널 사이드 코드 (execsnoop.bpf.c)
// SPDX-License-Identifier: GPL-2.0
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_core_read.h>
#define TASK_COMM_LEN 16
struct event {
__u32 pid;
__u32 ppid;
char comm[TASK_COMM_LEN];
};
/* 커널 -> 유저 공간 이벤트 전달용 링 버퍼 */
struct {
__uint(type, BPF_MAP_TYPE_RINGBUF);
__uint(max_entries, 256 * 1024);
} events SEC(".maps");
SEC("tracepoint/syscalls/sys_enter_execve")
int handle_execve(struct trace_event_raw_sys_enter *ctx)
{
struct event *e;
struct task_struct *task;
/* 링 버퍼에서 이벤트 공간 예약 */
e = bpf_ringbuf_reserve(&events, sizeof(*e), 0);
if (!e)
return 0;
e->pid = bpf_get_current_pid_tgid() >> 32;
/* CO-RE 매크로로 task_struct에서 부모 PID 읽기 */
task = (struct task_struct *)bpf_get_current_task();
e->ppid = BPF_CORE_READ(task, real_parent, tgid);
bpf_get_current_comm(&e->comm, sizeof(e->comm));
bpf_ringbuf_submit(e, 0);
return 0;
}
char LICENSE[] SEC("license") = "GPL";
포인트를 짚어 보겠습니다.
- SEC 매크로는 ELF 섹션 이름으로 프로그램 타입과 부착 지점을 선언합니다.
- BPF_CORE_READ는 CO-RE 재배치가 적용되는 안전한 멤버 접근 매크로입니다. 중첩 포인터 체인(task → real_parent → tgid)을 한 번에 따라갑니다.
- 라이선스 선언은 필수입니다. GPL 호환 라이선스가 아니면 일부 헬퍼를 사용할 수 없습니다.
유저 사이드 로더 (execsnoop.c)
빌드 과정에서 bpftool이 .bpf.o로부터 스켈레톤 헤더(execsnoop.skel.h)를 생성해 줍니다. 스켈레톤은 로드/부착/해제를 타입 안전한 함수로 감싸 줍니다.
// SPDX-License-Identifier: GPL-2.0
#include <stdio.h>
#include <signal.h>
#include <bpf/libbpf.h>
#include "execsnoop.skel.h"
struct event {
__u32 pid;
__u32 ppid;
char comm[16];
};
static volatile sig_atomic_t exiting = 0;
static void sig_handler(int sig) { exiting = 1; }
static int handle_event(void *ctx, void *data, size_t len)
{
const struct event *e = data;
printf("%-8u %-8u %-16s\n", e->pid, e->ppid, e->comm);
return 0;
}
int main(void)
{
struct execsnoop_bpf *skel;
struct ring_buffer *rb;
int err;
signal(SIGINT, sig_handler);
signal(SIGTERM, sig_handler);
skel = execsnoop_bpf__open_and_load();
if (!skel) {
fprintf(stderr, "BPF 스켈레톤 로드 실패\n");
return 1;
}
err = execsnoop_bpf__attach(skel);
if (err) {
fprintf(stderr, "BPF 프로그램 부착 실패: %d\n", err);
goto cleanup;
}
rb = ring_buffer__new(bpf_map__fd(skel->maps.events),
handle_event, NULL, NULL);
if (!rb) {
err = -1;
goto cleanup;
}
printf("%-8s %-8s %-16s\n", "PID", "PPID", "COMM");
while (!exiting) {
err = ring_buffer__poll(rb, 100 /* ms */);
if (err == -EINTR) { err = 0; break; }
if (err < 0) break;
}
cleanup:
ring_buffer__free(rb);
execsnoop_bpf__destroy(skel);
return err < 0 ? 1 : 0;
}
빌드와 실행
# 1. vmlinux.h 생성
bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h
# 2. 커널 사이드 컴파일 (-g 필수: BTF/CO-RE 정보 생성)
clang -O2 -g -target bpf -D__TARGET_ARCH_x86 \
-c execsnoop.bpf.c -o execsnoop.bpf.o
# 3. 스켈레톤 헤더 생성
bpftool gen skeleton execsnoop.bpf.o > execsnoop.skel.h
# 4. 유저 사이드 컴파일 및 링크
clang -O2 -o execsnoop execsnoop.c -lbpf -lelf -lz
# 5. 실행 (root 또는 적절한 capability 필요)
sudo ./execsnoop
다른 터미널에서 ls나 date를 실행하면 PID, PPID, 명령어 이름이 즉시 출력되는 것을 확인할 수 있습니다. 이 작은 프로그램이 시스템 전체의 execve를 거의 0에 가까운 오버헤드로 관찰하고 있다는 점이 eBPF의 힘입니다.
bpftool: eBPF의 스위스 아미 나이프
bpftool은 커널 트리에서 관리되는 공식 CLI로, 로드된 프로그램과 맵을 조사하는 데 필수입니다.
# 로드된 모든 프로그램 나열
sudo bpftool prog show
# 특정 프로그램의 JIT 결과 디스어셈블
sudo bpftool prog dump jited id 42
# 변환된(verifier 통과 후) 바이트코드 확인
sudo bpftool prog dump xlated id 42
# 맵 나열과 내용 덤프
sudo bpftool map show
sudo bpftool map dump id 17
# 시스템의 eBPF 기능 지원 현황 조사
sudo bpftool feature probe
# 네트워크에 부착된 프로그램 확인 (XDP, tc)
sudo bpftool net show
# 프로그램을 bpffs에 핀(pin)하여 로더 종료 후에도 유지
sudo bpftool prog pin id 42 /sys/fs/bpf/myprog
특히 feature probe는 "이 커널에서 어떤 프로그램 타입, 맵 타입, 헬퍼가 지원되는가"를 한 번에 보여 주기 때문에, 새 서버에서 작업을 시작할 때 가장 먼저 실행할 가치가 있습니다.
언어와 프레임워크 선택
eBPF 자체는 커널 기술이고, 어떤 언어로 개발할지는 별개의 선택입니다.
| 프레임워크 | 언어 | 장점 | 단점 | 적합한 경우 |
|---|---|---|---|---|
| libbpf + C | C | 커널 트리 공식, 기능 최신, CO-RE 표준 | C의 생산성, 메모리 관리 부담 | 시스템 도구, 최고 성능과 최신 기능 |
| cilium/ebpf | Go | Go 생태계 통합, 순수 Go 로더 | 커널 사이드는 여전히 C | k8s 도구, Go 기반 에이전트 |
| aya | Rust | 커널/유저 모두 Rust, 메모리 안전 | 생태계가 상대적으로 젊음 | Rust 팀, 안전성 중시 프로젝트 |
| bcc | Python + C | 풍부한 예제, 빠른 프로토타이핑 | 런타임 컴파일, 무거운 의존성 | 학습, 일회성 분석 |
| bpftrace | 전용 DSL | 원라이너로 즉시 분석 | 복잡한 로직 한계 | 운영 중 즉석 트러블슈팅 |
추천 경로는 명확합니다. 운영 분석은 bpftrace로 시작하고, 제품화할 도구는 libbpf(C)나 cilium/ebpf(Go), aya(Rust)로 작성합니다. bcc의 런타임 컴파일 방식은 CO-RE가 보편화된 지금은 신규 프로젝트에 권장되지 않습니다.
커널 버전과 기능 매트릭스
배포 대상 커널이 무엇을 지원하는지는 설계 단계에서 가장 먼저 확인할 사항입니다. 주요 이정표를 정리하면 다음과 같습니다 (제가 확실히 아는 범위 기준이며, 정확한 지원 여부는 bpftool feature probe로 확인하는 것이 안전합니다).
| 커널 버전 | 주요 기능 |
|---|---|
| 3.18 | eBPF 시스템 콜 도입 |
| 4.1 ~ 4.7 | kprobe, tc, tracepoint 부착 지원 확대 |
| 4.8 | XDP 도입 |
| 4.18 | BTF 도입 시작 |
| 5.2 | 명령어 수 한도 1백만으로 완화, 글로벌 변수 지원 |
| 5.3 | 유계 루프(bounded loop) 허용 |
| 5.5 | fentry / fexit (BTF 기반 트램펄린) |
| 5.7 | BPF LSM, struct_ops |
| 5.8 | ring buffer 맵, CAP_BPF 권한 분리 |
| 5.10 | sleepable BPF 프로그램 |
| 5.17 | bpf_loop 헬퍼 |
| 6.x | open-coded iterator, kfunc 확대, arena 등 지속 확장 |
실무 기준으로는 RHEL 9, Ubuntu 22.04 LTS 이상이면 ringbuf, CO-RE, fentry까지 무리 없이 사용할 수 있습니다. 2026년 현재 주류 배포판 커널(5.14 이상)에서는 이 글의 예제가 모두 동작합니다.
디버깅 팁
- Verifier 로그를 자세히 봅니다. libbpf 환경 변수 또는 코드로 verbose 레벨을 올릴 수 있습니다.
/* 로드 전에 디버그 출력 활성화 */
libbpf_set_print(libbpf_print_fn); /* LIBBPF_DEBUG 레벨까지 출력 */
- bpf_printk로 커널 사이드에서 printf 디버깅을 합니다. 출력은 trace_pipe에서 읽습니다.
bpf_printk("pid=%d comm=%s", pid, comm);
sudo cat /sys/kernel/debug/tracing/trace_pipe
- 컴파일러 최적화가 경계 검사를 제거하는 문제는 volatile 사용이나 barrier_var 매크로로 우회합니다.
- xlated 덤프와 소스를 대조합니다. bpftool prog dump xlated 출력에서 Verifier가 본 실제 명령을 확인하면 "내가 쓴 코드"와 "Verifier가 검증한 코드"의 차이를 발견할 수 있습니다.
- 작게 시작해서 점진적으로 키웁니다. 한 번에 큰 프로그램을 통과시키려 하지 말고, 최소 버전을 먼저 로드해 본 뒤 로직을 추가합니다.
프로덕션 고려사항
오버헤드
eBPF는 가볍지만 공짜는 아닙니다. 비용의 대부분은 훅 자체의 호출 빈도에 비례합니다.
- 초당 수백만 번 호출되는 함수(예: 스케줄러 핫패스)에 kprobe를 걸면 누적 오버헤드가 눈에 띌 수 있습니다. fentry가 kprobe보다 저렴하므로 가능하면 fentry를 사용합니다.
- 맵 갱신 비용을 줄이려면 percpu 맵과 집계-후-전달 패턴(커널에서 집계하고 유저 공간은 주기적으로만 읽기)을 사용합니다.
- 이벤트가 폭주할 수 있는 지점에서는 커널 사이드에서 샘플링하거나 필터링해서 ringbuf로 보내는 양 자체를 줄입니다.
권한: CAP_BPF와 그 친구들
커널 5.8부터 eBPF 권한이 CAP_SYS_ADMIN에서 분리되었습니다.
| 권한 | 허용 범위 |
|---|---|
| CAP_BPF | bpf() 시스템 콜 기본 사용 (맵 생성, 프로그램 로드 일부) |
| CAP_PERFMON | 트레이싱 계열 프로그램 부착, 커널 메모리 읽기 |
| CAP_NET_ADMIN | XDP, tc 등 네트워크 프로그램 부착 |
| CAP_SYS_ADMIN | 위 전부 포함하는 기존 방식 (지양) |
관측 에이전트를 배포할 때는 CAP_BPF + CAP_PERFMON 조합으로 최소 권한을 구성하고, 네트워크 프로그램이 필요할 때만 CAP_NET_ADMIN을 추가하는 것이 모범 사례입니다. 또한 unprivileged BPF는 보안상 대부분의 배포판에서 비활성화되어 있으며(sysctl kernel.unprivileged_bpf_disabled), 그대로 두는 것을 권장합니다.
수명 주기 관리
- 프로그램과 맵은 파일 디스크립터 참조가 사라지면 해제됩니다. 로더 프로세스와 독립적으로 유지하려면 bpffs에 핀해야 합니다.
- 커널 업그레이드 시 CO-RE 덕분에 대부분 재빌드가 불필요하지만, kprobe 대상 함수가 사라지거나 이름이 바뀔 수 있으므로 부착 실패에 대한 폴백 로직을 에이전트에 넣어 두는 것이 안전합니다.
학습 로드맵
- 1단계 — 사용자로 시작: bpftrace 원라이너와 bcc 도구(execsnoop, opensnoop, biolatency)를 운영 장비 분석에 활용하며 감을 익힙니다.
- 2단계 — 읽기: ebpf.io의 What is eBPF 문서와 커널 BPF 문서를 읽고, 프로그램 타입과 맵의 전체 그림을 잡습니다.
- 3단계 — 첫 프로그램: 이 글의 execsnoop처럼 tracepoint + ringbuf 조합으로 libbpf 프로그램을 작성합니다. libbpf-bootstrap 저장소의 템플릿이 훌륭한 출발점입니다.
- 4단계 — 영역 확장: 관심 분야에 따라 XDP(네트워킹), uprobe/USDT(애플리케이션), LSM(보안)으로 확장합니다.
- 5단계 — 프로덕션화: CO-RE 호환성 매트릭스, 권한 설계, 오버헤드 측정, CI에서의 다중 커널 테스트까지 갖춥니다.
함정과 안티패턴
- 안티패턴 1: 불안정한 커널 내부 함수에 kprobe 의존. 커널 마이너 업데이트로 함수가 인라인되거나 이름이 바뀌면 도구가 조용히 깨집니다. tracepoint나 fentry + BTF 존재 확인으로 방어합니다.
- 안티패턴 2: 맵 크기 부족 방치. 해시 맵이 가득 차면 업데이트가 실패하고 데이터가 유실됩니다. LRU 맵을 쓰거나 실패 카운터를 별도로 둡니다.
- 안티패턴 3: ringbuf 소비 지연. 유저 공간 컨슈머가 느리면 reserve가 실패하며 이벤트가 사라집니다. 드롭 카운터를 만들어 관측해야 합니다.
- 안티패턴 4: 검증 통과만을 목표로 한 코드 뒤틀기. Verifier를 "속이는" 패턴은 커널/컴파일러 버전이 바뀌면 깨집니다. 명시적 경계 검사와 단순한 제어 흐름이 정답입니다.
- 안티패턴 5: 라이선스 누락. GPL 선언이 없으면 다수의 헬퍼가 막힙니다. 의도적으로 비GPL을 선택할 때는 사용 가능한 헬퍼 목록을 미리 확인합니다.
마치며
eBPF는 "커널을 고치지 않고 커널의 동작을 바꾼다"는, 십 년 전이라면 불가능하다고 여겨졌을 일을 일상으로 만들었습니다. 핵심은 세 가지였습니다. 훅에 부착되는 프로그램, 데이터를 잇는 맵, 그리고 안전을 보증하는 Verifier입니다. 이 세 가지의 관계를 이해하면 어떤 eBPF 도구를 만나도 내부 동작을 추론할 수 있습니다.
다음 글에서는 이 기초 위에서 bpftrace와 BCC 도구로 실제 시스템의 블랙박스를 여는 관측성 실전을 다루고, 그다음 글에서는 Tetragon과 Falco, BPF LSM으로 런타임 보안을 구축하는 방법을 살펴보겠습니다.
참고 자료
- ebpf.io — What is eBPF?
- 리눅스 커널 BPF 공식 문서
- BPF Verifier 문서 (kernel.org)
- BTF (BPF Type Format) 문서 (kernel.org)
- libbpf 저장소 (GitHub)
- libbpf-bootstrap — libbpf 예제 템플릿
- BPF CO-RE 레퍼런스 가이드 (Andrii Nakryiko)
- bpftool 문서 (kernel.org)
- cilium/ebpf — Go eBPF 라이브러리
- aya — Rust eBPF 라이브러리
- BCC (BPF Compiler Collection)
- Brendan Gregg — eBPF 트레이싱 자료 모음