eBPF VM
eBPF에도 JAVA의 jvm같은 eBPF VM이 있다.
대략적인 전체 아키텍처를 claude가 다이어그램으로 그려줬다.

eBPF VM도 JVM과 유사한 샌드박스 실행 환경이지만 eBPF VM은 커널 내부에서 돌아간다는 차이점이 있다.
eBPF VM이란?
커널 안에 내장된 RISC 스타일의 가상 머신이다. JVM이 OS 위에서 바이트코드를 안전하게 실행하는 것 처럼 eBPF VM은 커널 위에서 사용자 프로그램을 안전하게 실행할 수 있도록 한다.
eBPF의 핵심 구성 요소
- eBPF 프로그램 실행 구조: 사용자 공간에서 작성된 코드가 커널 내부로 로드되어 실행되는 방식
- 사용자가 고수준 언어(C, Rust 등)로 작성한 eBPF 프로그램은 Clang/LLVM 컴파일러에 의해 eBPF 바이트코드로 컴파일된다. 그 후 커널 내의 eBPF VM이 이 바이트코드를 실행한다.
- 그렇기 때문에 시스템의 안정성을 해치지 않기 위해 Verifier(검증기)를 거쳐 안전한 코드인지 확인받아야 한다. 그리고 JIT(Just-In-Time) 컴파일러를 통해 네이티브 머신 코드로 변환되어 빠른 속도로 실행된다.
eBPF VM의 주요 구성 요소
eBPF VM 내부 아키텍처
- eBPF 레지스터 (Registers)
- eBPF 명령어 셋 (Instruction Set)
- BPF Map (데이터 저장소)
- Helper Functions (헬퍼 함수)
eBPF 레지스터 (Registers)
과거 클래식 BPF(cBPF)는 2개의 레지스터(A, X)만 사용했지만, eBPF로 확장하면서 현대 64비트 CPU 아키텍처(x86-64, ARM64 등)와 거의 1:1로 매핑되는 11개의 64비트 레지스터를 주로 활용하게 되었다.
레지스터 구조가 실제 하드웨어와 비슷하기 때문에 JIT 컴파일링 시 오버헤드 없이 네이티브 명령어로 쉽게 변환된다.
레지스터의 역할 (R0 ~ R10)
eBPF VM을 이해할 때 가장 중요한 개념은 각 레지스터의 정해진 역할이다.
- R0: 함수 호출의 반환값(Return value)과 eBPF 프로그램의 최종 종료 코드를 저장하는 레지스터
- R1 ~ R5: 함수 호출 시 인자(Arguments)를 전달하는 데 사용되는 레지스터
- R6 ~ R9: Callee-saved 레지스터 (함수 호출 후에도 값이 보존되어야 하는 레지스터)
- R10: 읽기 전용 스택 프레임 포인터 (Stack Frame Pointer)
예를 들어 BPF Helper 함수를 호출한다고 하면
bpf_trace_printk("Hello eBPF\\n", 11);
bpf_trace_printk() 함수를 호출하기 위해 R1에는 출력할 문자열의 주소, R2에는 문자열의 길이(11)가 들어간다.
함수 호출이 일어날 때 eBPF VM 내부에서는 다음과 같은 흐름이 나뉜다.
- eBPF 프로그램은 R1~R5에 커널 헬퍼 함수가 요구하는 인자를 담는다.
- eBPF 프로그램이 커널의 헬퍼 함수를 호출한다.
- 헬퍼 함수는 실행이 끝나면 반환값을 R0에 담아 전달한다.
- eBPF 프로그램은 R0 값을 확인한 뒤 기존 실행 흐름을 이어간다.
이 과정에서 1:1 하드웨어 매핑 덕분에 아주 빠른 속도로 데이터 전달이 일어난다.
eBPF 스택 공간 (Stack Frame)
Stack Frame 은 eBPF 프로그램이 실행될 때 사용하는 제한된 크기의 스택 영역이다.
eBPF VM은 보안과 성능을 위해 각 프로그램이 사용할 수 있는 스택의 크기를 512 바이트로 엄격하게 제한하고 있다.
스택 영역에는 일반적으로 다음과 같은 정보들이 저장된다.
- 함수의 지역 변수
- BPF Map 등에 전달할 임시 데이터 구조체
- 크기가 작은 문자열 버퍼
eBPF 스택은 주로 r10 레지스터를 기준으로 접근한다.
- r10: 현재 eBPF 프로그램의 스택 프레임 기준점을 가리키는 읽기 전용 레지스터
사용자는 r10 레지스터 값을 직접 수정할 수 없으며, 반드시 상대 주소 연산을 통해 스택에 접근해야 한다.
mov r1, r10
add r1, -8
stx [r1], r2
위 코드는 r10(프레임 포인터)에서 8바이트를 뺀 주소 공간에 r2 레지스터의 값을 저장하는 코드이다. 지역 변수나 포인터를 위한 공간을 확보하는 용도이다.
BPF Map (데이터 공유)
BPF Map 은 eBPF 프로그램의 실행이 끝난 뒤나 실행 중에 데이터를 외부와 주고받기 위한 저장소이다.
eBPF 프로그램은 커널 내부에서 실행되기 때문에, 일반적인 방법으로는 사용자 공간(User Space)의 프로그램과 데이터를 주고받기 어렵다. 이때 사용되는 것이 BPF Map 이다.
예를 들어 다음과 같은 코드가 있다고 하면
bpf_map_update_elem(&my_map, &key, &value, BPF_ANY);
bpf_map_update_elem 헬퍼 함수가 실행될 때, 커널에 위치한 BPF Map 공간에 key와 value가 저장된다.
그 후 시스템 콜을 통해 사용자 공간의 프로그램이 Map에 저장되어 있던 데이터를 꺼내서 읽어볼 수 있다.
즉, eBPF를 통한 데이터 수집과 공유는 대략 다음과 같은 흐름으로 진행된다.
- 커널 혹은 사용자 공간에서 BPF Map을 생성한다.
- eBPF 프로그램이 실행되면서 패킷 개수 등의 데이터를 Map에 업데이트한다.
- User Space 애플리케이션(예: Go, Python 프로그램)이 Map을 조회한다.
- 사용자에게 모니터링 대시보드나 통계 형식으로 출력해준다.
Verifier (검증기)
eBPF VM에서 가장 중요한 보안 장치 중 하나는 코드가 실행되기 전에 안전성을 검사하는 Verifier이다.
사용자가 작성한 코드를 커널 내부에서 실행하는 것은 자칫 잘못하면 커널 패닉이나 무한 루프를 유발할 수 있다.
eBPF Verifier는 다음과 같은 엄격한 규칙들을 코드 레벨에서 정적 분석한다.
- 무한 루프 금지 (DAG 분석, 최근 제한된 횟수의 Bounded Loop만 허용)
- 초기화되지 않은 레지스터 사용 금지
- 허가되지 않은 커널 메모리 접근 금지
예를 들어 배열을 접근할 때 범위를 벗어난(Out-of-bounds) 연산을 시도하면
ldx r2, [r1+1000]
Verifier는 이 코드가 안전하지 않다고 판단하여 커널 적재(Load)를 거부(Reject)한다.
이런 깐깐한 검증 절차가 있기 때문에 eBPF 프로그램은 커널 내부에서 실행되더라도 시스템에 치명적인 오류를 일으키지 않음이 수학적으로 보장된다.
JIT (Just-In-Time) Compiler
eBPF 바이트코드가 Verifier를 통과하면 JIT 컴파일러를 거치게 된다.
JIT 컴파일러도 eBPF의 성능을 결정짓는 핵심적인 요소이다. 가상 머신의 바이트코드를 그대로 인터프리팅하면 느리기 때문에, 호스트의 CPU가 직접 이해할 수 있는 네이티브 기계어로 즉시 번역한다.
x86-64 환경에서는 eBPF 명령어와 네이티브 명령어가 1:1에 가깝게 변환된다.
add r1, r2
위 코드는 JIT 컴파일러를 거치면 실제 하드웨어 명령어로 즉시 변환된다.
add rdi, rsi
즉, 프로그램 로드 시 한 번만 번역해두면, 이후 커널에서 이벤트가 발생할 때는 인터프리터 오버헤드 없이 네이티브 속도로 코드가 실행된다.
Helper Functions
eBPF VM은 임의의 커널 함수를 마음대로 호출하는 것을 제한한다.
대신, 커널이 명시적으로 제공해주는 안전한 API들만 호출할 수 있게 허용하는데 이를 Helper Functions(헬퍼 함수)라고 부른다.
대표적으로 다음과 같은 함수들이 있다.
- bpf_map_lookup_elem(): Map 데이터 조회
- bpf_trace_printk(): 커널 로그 버퍼에 메시지 기록
- bpf_get_current_pid_tgid(): 현재 실행 중인 프로세스의 PID 조회
예를 들어 패킷을 파싱하다가 시간을 기록해야 한다면
u64 ts = bpf_ktime_get_ns();
이처럼 헬퍼 함수는 eBPF 코드가 임의의 메모리를 훼손하지 않으면서도, 복잡한 커널 서브시스템의 기능(시간, 네트워크, 프로세스 정보 등)을 안전하게 사용할 수 있도록 돕는 역할을 한다.
Tail Call
Tail Call은 eBPF 프로그램 안에서 다른 eBPF 프로그램을 호출할 때 사용되는 특별한 최적화 기법이다.
Tail Call의 특징은 다음과 같다.
- 현재 eBPF 프로그램의 실행을 종료하고 다른 eBPF 프로그램으로 즉시 점프한다.
- 호출된 프로그램(Callee)이 종료되더라도 이전 프로그램(Caller)으로 되돌아오지 않는다.
- 현재의 스택 프레임(Stack Frame)을 덮어쓰므로 호출에 따른 스택 오버헤드가 없다.
예를 들어 다음과 같은 흐름으로 코드가 분기된다고 하면
bpf_tail_call(ctx, &my_prog_array, index);
이 함수가 성공적으로 실행되면, 현재 프로그램은 즉시 종료되고 my_prog_array Map에 지정된 index 번째 eBPF 프로그램이 이어서 실행된다.
eBPF는 단일 프로그램의 명령어 개수나 스택 크기(512바이트)에 제한이 있기 때문에, 하나의 거대한 프로그램을 짜기보다는 Tail Call로 여러 개의 작은 프로그램을 체인처럼 연결하여 복잡한 로직을 구현하는 데 적합하다.
BPF to BPF Call
BPF to BPF Call은 비교적 최근에 도입된 일반적인 함수 호출 기능이다.
기존에는 eBPF 내부에서 별도의 함수를 정의하면 컴파일러가 이를 모두 인라인(Inline)으로 펼쳐서 코드 크기가 기하급수적으로 커지는 문제가 있었다.
BPF to BPF Call의 특징은 다음과 같다.
- 일반적인 C 언어의 함수 호출처럼 Caller-Callee 관계를 갖는다.
- 함수 실행이 끝나면 원래 위치로 반환(Return)된다.
- 512바이트의 스택 공간을 Caller와 Callee가 나눠 쓴다.
예를 들어 다음과 같이 eBPF 프로그램 내부에서 자유롭게 함수를 정의하고 호출할 수 있다.
static int my_custom_add(int a, int b){
return a + b;
}
Caller 쪽에서는 일반적인 C 코드처럼 함수를 호출한다.
int result = my_custom_add(10, 20);
즉, BPF to BPF Call 기능은 코드의 재사용성을 높이고 eBPF 바이너리의 크기를 효율적으로 줄이는 역할을 한다.
eBPF Calling Convention
eBPF 가상 머신 내부에서도 함수나 헬퍼 함수를 호출할 때 인자와 반환값을 주고받는 규약(Calling Convention)이 정의되어 있다.
이 규약은 네이티브 아키텍처(x86-64 System V ABI 등)와 매우 유사하게 설계되었다.
정수형 및 포인터 인자는 다음 순서로 전달된다.
- r1 (첫 번째 인자)
- r2 (두 번째 인자)
- r3 (세 번째 인자)
- r4 (네 번째 인자)
- r5 (다섯 번째 인자)
반환값은 항상 r0 레지스터로 전달된다.
인자가 5개를 초과하는 경우, 일반적으로 여러 데이터를 하나의 구조체에 담아 그 포인터를 전달하는 방식으로 우회한다.
예를 들어 다음과 같은 헬퍼 함수 호출이 있다고 하면
bpf_perf_event_output(ctx, &map, flags, &data, size);
eBPF 어셈블리 레벨에서는 대략 다음과 같이 인자가 전달된다.
mov r1, r6 // ctx
mov r2, r7 // &map
mov r3, r8 // flags
mov r4, r9 // &data
mov r5, r10 // size
call bpf_perf_event_output
첫 번째부터 다섯 번째 인자까지 레지스터로 순차적으로 전달된다. 이는 하드웨어 레지스터에 가장 효율적으로 매핑되게 하기 위한 규약이다.
Caller-saved 와 Callee-saved Register
eBPF 규약은 레지스터 보존 규칙도 정의하고 있다.
함수나 헬퍼 함수를 호출할 때 레지스터 값이 바뀔 수 있기 때문에, 각 레지스터를 누가 책임지고 보존해야 하는지 명확히 해야 한다.
- Caller-saved Register: r0, r1 ~ r5 (함수 호출 시 값이 보장되지 않는 레지스터)
- Callee-saved Register: r6 ~ r9 (함수 호출 후에도 값이 보존되는 레지스터)
Caller-saved 레지스터(r1~r5)는 헬퍼 함수를 한 번 호출하고 나면 그 안의 값이 모두 오염되었다고 간주해야 한다.
따라서 함수 호출 후에도 이전 데이터를 다시 써야 한다면, 호출 전에 스택이나 Callee-saved 레지스터에 값을 미리 백업해 두어야 한다.
반면 Callee-saved 레지스터(r6~r9)는 헬퍼 함수를 호출하더라도 기존 데이터가 안전하게 유지된다.
만약 여러 번의 함수 호출 사이클에 걸쳐 계속 유지해야 하는 핵심 포인터가 있다면 r6에 저장해 두는 것이 유리하다.
mov r6, r1
call bpf_map_lookup_elem
mov r1, r6
이런 규칙이 있기 때문에 LLVM/Clang 컴파일러는 eBPF 코드를 컴파일할 때 레지스터 할당을 예측 가능하게 최적화할 수 있다.
eBPF VM 이 중요한 이유
eBPF VM은 단순히 커널 안에서 코드를 돌리는 기술을 넘어서, 현대 클라우드 네이티브 환경과 리눅스 시스템 생태계를 완전히 바꿔놓고 있다.
eBPF VM의 구조와 제약 사항을 알면 다음과 같은 원리를 파악할 수 있다.
- 커널을 다시 컴파일하거나 모듈을 로드하지 않고도 커널 기능을 확장할 수 있다.
- 시스템 콜 후킹, 네트워크 패킷 필터링 등을 커널 크래시(Kernel Crash)의 위험 없이 안전하게 수행할 수 있다.
- 메모리 사용 한계와 호출 규약을 이해하여, 효율적이고 통과율 높은 eBPF 코드를 짤 수 있다.
예를 들어 과거의 커널 모듈 방식에서는 포인터 실수 하나가 전체 서버를 다운시킬 수 있었다.
int *ptr = NULL;
*ptr = 10;
반면 eBPF VM 환경에서는 이런 코드를 짜더라도 Verifier 단계에서 원천 차단된다.
invalid mem access 'inv'
또한 기존에 패킷을 처리하기 위해 사용자 공간(User Space)까지 올려서 처리하던 것을, JIT 컴파일된 eBPF 코드가 커널 레벨에서 즉시 처리(XDP 등)하게 되면서 트래픽 처리 속도를 비약적으로 끌어올릴 수 있다.
패킷 수신 -> 커널 (eBPF) -> 즉각적인 패킷 드랍/포워딩
즉, eBPF VM의 아키텍처를 알면 이 기술이 왜 클라우드 네이티브의 네트워킹 및 보안 인프라를 지배하고 있는지 명확하게 분석할 수 있다.
정리
eBPF VM은 커널 내에서 안전하고 빠르게 사용자 코드를 실행하기 위한 RISC 스타일의 가상 머신이다.
eBPF 프로그램의 실행 과정에서는 다음과 같은 요소들이 정해진 역할을 수행한다.
- 시스템 붕괴를 막기 위해 바이트코드를 꼼꼼히 체크하는 Verifier
- 실행 속도 극대화를 위해 네이티브 코드로 변환하는 JIT Compiler
- 외부 시스템이나 다른 프로그램과 데이터를 주고받기 위한 BPF Map
- 커널의 기능을 안전하게 가져다 쓰기 위한 Helper Functions
- eBPF 자체의 Calling Convention 과 보존 레지스터 구조
과거에는 단순한 패킷 필터링(cBPF)에 불과했지만, 레지스터 개수를 늘리고 JIT 컴파일러와 Verifier를 도입함으로써 현재는 리눅스 커널을 안전하게 확장할 수 있는 범용 기술로 진화했다.
하지만 이 가상 머신은 무한한 자유를 주지 않으며, 512바이트의 스택 제한이나 루프 제한 같은 엄격한 규칙이 존재한다.
eBPF VM의 구조를 이해하면 C, Rust 코드가 어떻게 안전한 바이트코드로 변환되고 효율적으로 커널 자원을 제어하는지 이해할 수 있고, 나아가 Cilium, BCC 같은 현대적인 모니터링 및 네트워크 도구들의 근본적인 동작 원리를 파악하는 데 큰 도움이 된다.