BPF 아키텍처
BPF 아키텍처 (BPF Architecture)
이 가이드는 Cilium이 사용하는 BPF 기술의 심층 아키텍처를 설명해요. 명령어 집합, 헬퍼 함수, 맵, 객체 고정, tail call, BPF to BPF 호출, JIT, 하드닝, 오프로드, sysctl을 다룹니다.
출처: BPF Architecture
본문
BPF는 명령어 집합만 제공하는 것이 아니라, 그 주변의 추가 인프라도 함께 제공해요. 여기에는 효율적인 키/값 저장소 역할을 하는 맵, 커널 기능과 상호작용하고 활용하는 헬퍼 함수, 다른 BPF 프로그램을 호출하는 tail call, 보안 하드닝 기본 요소(primitives), 객체(맵, 프로그램)를 고정(pinning)하기 위한 의사 파일 시스템, 그리고 예를 들어 네트워크 카드로 BPF를 오프로드할 수 있게 하는 인프라가 포함돼요.
LLVM은 BPF 백엔드를 제공하므로, clang 같은 도구로 C를 BPF 오브젝트 파일로 컴파일한 뒤 커널에 로드할 수 있어요. BPF는 리눅스 커널과 깊이 연결되어 있으며, 네이티브 커널 성능을 희생하지 않으면서 완전한 프로그래밍 가능성을 제공해요.
마지막으로 BPF를 사용하는 커널 서브시스템들도 BPF 인프라의 일부예요. 이 문서 전반에서 다루는 두 주요 서브시스템은 BPF 프로그램을 부착할 수 있는 tc와 XDP예요. XDP BPF 프로그램은 가장 이른 네트워킹 드라이버 단계에서 부착되며, 패킷 수신 시 BPF 프로그램 실행을 트리거해요. 정의상 이는 최상의 패킷 처리 성능을 달성하는데, 패킷이 소프트웨어에서 더 일찍 처리될 수 없기 때문이에요. 그러나 처리가 네트워킹 스택에서 너무 이르게 일어나므로 스택이 아직 패킷에서 메타데이터를 추출하지 않았어요. 반면 tc BPF 프로그램은 커널 스택에서 더 나중에 실행되므로 더 많은 메타데이터와 핵심 커널 기능에 접근할 수 있어요. tc와 XDP 프로그램 외에도 tracing(예: kprobes, uprobes, tracepoints 통해) 등 다양한 커널 서브시스템이 BPF를 사용해요.
이어지는 하위 섹션은 BPF 아키텍처의 개별 측면에 대한 자세한 내용을 제공해요.
명령어 집합 (Instruction Set)
BPF는 범용 RISC 명령어 집합으로, 원래 C의 하위 집합으로 프로그램을 작성해 컴파일러 백엔드(예: LLVM)를 통해 BPF 명령어로 컴파일해 커널이 이후 커널 내 JIT 컴파일러로 네이티브 opcode에 매핑해 커널 내 최적 실행 성능을 내도록 설계됐어요.
이 명령어를 커널로 밀어 넣는 장점은 다음과 같아요:
- 커널/사용자 공간 경계를 넘지 않고 커널을 프로그래밍 가능하게 만듦. 예를 들어 Cilium의 경우처럼 네트워킹 관련 BPF 프로그램은 패킷을 사용자 공간으로 보냈다가 다시 커널로 가져오지 않고도 유연한 컨테이너 정책, 로드밸런싱 등을 구현할 수 있어요. BPF 프로그램과 커널/사용자 공간 사이의 상태는 필요할 때마다 여전히 맵을 통해 공유할 수 있어요.
- 프로그래밍 가능한 데이터패스의 유연성 덕분에, 프로그램이 해결하는 유스 케이스에 필요하지 않은 기능을 컴파일 시점에 빼내 성능을 크게 최적화할 수 있어요. 예를 들어 컨테이너가 IPv4를 요구하지 않으면 BPF 프로그램을 IPv6만 처리하도록 빌드해 fast-path의 리소스를 절약할 수 있어요.
- 네트워킹(예: tc와 XDP)의 경우 커널, 시스템 서비스, 컨테이너를 재시작하거나 트래픽을 중단하지 않고 BPF 프로그램을 원자적으로(atomically) 업데이트할 수 있어요. 또한 BPF 맵을 통해 업데이트 전반에 걸쳐 프로그램 상태를 유지할 수도 있어요.
- BPF는 사용자 공간에 안정적인 ABI를 제공하며, 서드파티 커널 모듈을 요구하지 않아요. BPF는 어디에나 포함되어 배포되는 리눅스 커널의 핵심 부분이며, 기존 BPF 프로그램이 더 새로운 커널 버전에서 계속 실행되도록 보장해요. 이 보장은 커널이 사용자 공간 애플리케이션에 대해 시스템 콜로 제공하는 보장과 같아요. 게다가 BPF 프로그램은 여러 아키텍처에서 이식 가능해요.
- BPF 프로그램은 커널과 함께 작동하며, 기존 커널 인프라(예: 드라이버, netdevice, 터널, 프로토콜 스택, 소켓)와 도구(예: iproute2), 그리고 커널이 제공하는 안전 보장을 활용해요. 커널 모듈과 달리 BPF 프로그램은 커널을 크래시시키지 못하고 항상 종료되도록 하는 등 커널 내 verifier를 통해 검증돼요. 예를 들어 XDP 프로그램은 기존 커널 내 드라이버를 재사용하고, 패킷 프레임을 담은 제공된 DMA 버퍼에서 작동하여 다른 모델처럼 드라이버나 전체를 사용자 공간에 노출하지 않아요. 또한 XDP 프로그램은 기존 스택을 우회하지 않고 재사용해요. BPF는 특정 유스 케이스를 해결하는 프로그램을 만들기 위한 커널 기능 간의 범용 "글루 코드(glue code)"로 볼 수 있어요.
커널 안에서 BPF 프로그램의 실행은 항상 이벤트 기반이에요! 예:
- 수신 경로에 BPF 프로그램이 부착된 네트워킹 장치는 패킷을 받으면 프로그램 실행을 트리거해요.
- BPF 프로그램이 부착된 kprobe가 있는 커널 주소는 해당 주소의 코드가 실행되면 트랩되어 계측을 위한 kprobe 콜백 함수를 호출하고, 이어서 부착된 BPF 프로그램의 실행을 트리거해요.
BPF는 32비트 서브레지스터가 있는 11개의 64비트 레지스터, 프로그램 카운터, 512바이트 크기의 BPF 스택 공간으로 구성돼요. 레지스터 이름은 r0 - r10이에요. 운영 모드는 기본적으로 64비트이며, 32비트 서브레지스터는 특수한 ALU(산술 논리 장치) 연산으로만 접근할 수 있어요. 32비트 하위 서브레지스터는 기록될 때 64비트로 zero-extend돼요.
레지스터 r10은 읽기 전용인 유일한 레지스터이며, BPF 스택 공간에 접근하기 위한 프레임 포인터 주소를 담아요. 나머지 r0 - r9 레지스터는 범용이며 읽기/쓰기가 가능해요.
BPF 프로그램은 커널이(모듈이 아닌) 정의한 미리 정의된 헬퍼 함수를 호출할 수 있어요. BPF 호출 규약은 다음과 같이 정의돼요:
r0은 헬퍼 함수 호출의 반환 값을 담아요.r1-r5는 BPF 프로그램에서 커널 헬퍼 함수로의 인자를 담아요.r6-r9는 헬퍼 함수 호출 시 보존되는 callee-saved 레지스터예요.
BPF 호출 규약은 x86_64, arm64 및 기타 ABI에 직접 매핑될 만큼 일반적이므로, 모든 BPF 레지스터가 하드웨어 CPU 레지스터에 1:1로 매핑되어 JIT는 call 명령만 내면 되고 함수 인자를 배치하는 추가 move가 필요 없어요. 이 호출 규약은 성능 손실 없이 일반적인 호출 상황을 처리하도록 모델링됐어요. 6개 이상 인자를 가진 호출은 현재 지원되지 않아요. BPF 전용 커널의 헬퍼 함수(BPF_CALL_0() ~ BPF_CALL_5() 함수)는 이 규약을 염두에 두고 특별히 설계됐어요.
레지스터 r0은 BPF 프로그램의 종료 값도 담는 레지스터예요. 종료 값의 의미는 프로그램 유형에 따라 정의돼요. 또한 실행을 커널에 반환할 때 종료 값은 32비트 값으로 전달돼요.
레지스터 r1 - r5는 스크래치 레지스터로, 여러 헬퍼 함수 호출에 걸쳐 이 인자를 재사용해야 한다면 BPF 프로그램이 스택으로 spill하거나 callee-saved 레지스터로 옮겨야 해요. Spill이란 레지스터의 변수를 BPF 스택으로 옮기는 것을 의미해요. 변수를 BPF 스택에서 레지스터로 옮기는 역연산을 fill이라고 해요. spill/fill이 필요한 이유는 레지스터 수가 제한돼 있기 때문이에요.
BPF 프로그램의 실행에 들어가면 레지스터 r1이 처음에 프로그램의 컨텍스트를 담아요. 컨텍스트는 프로그램의 입력 인자예요(일반적인 C 프로그램의 argc/argv 쌍과 유사). BPF는 단일 컨텍스트에서만 작동하도록 제한되요. 컨텍스트는 프로그램 유형으로 정의되며, 예를 들어 네트워킹 프로그램은 네트워크 패킷(skb)의 커널 표현을 입력 인자로 가질 수 있어요.
BPF의 일반적인 연산은 64비트로, 64비트 아키텍처의 자연스러운 모델을 따라 포인터 산술을 수행하고 포인터를 전달하며 헬퍼 함수에 64비트 값을 전달하고 64비트 원자 연산을 허용해요.
프로그램당 최대 명령어 수는 4096개 BPF 명령어로 제한되며, 이는 설계상 모든 프로그램이 빠르게 종료된다는 뜻이에요. 5.1보다 새로운 커널에서는 이 제한이 100만 개 BPF 명령어로 완화됐어요. 명령어 집합에 전방·후방 점프가 모두 포함되어 있지만, 커널 내 BPF verifier가 루프를 금지해 종료가 항상 보장돼요. BPF 프로그램은 커널 내에서 실행되므로 verifier의 역할은 이것이 실행에 안전하고 시스템 안정성에 영향을 주지 않도록 하는 것이에요. 즉 명령어 집합 관점에서는 루프를 구현할 수 있지만 verifier가 제한해요. 그러나 한 BPF 프로그램이 다른 프로그램으로 점프하는 tail call이라는 개념도 있으며, 이 역시 최대 33개 중첩 호출 상한이 있고 보통 프로그램 로직의 일부를 단계별로 분리하는 데 사용돼요.
명령어 형식은 2-오퍼랜드 명령어로 모델링되어 JIT 단계에서 BPF 명령어를 네이티브 명령어로 매핑하는 데 도움을 줘요. 명령어 집합은 고정 크기로, 모든 명령어가 64비트 인코딩을 가져요. 현재 87개의 명령어가 구현됐고 인코딩은 필요할 때 집합을 더 확장할 수 있게 해줘요. 빅엔디언 머신에서 단일 64비트 명령어의 인코딩은 MSB(최상위 비트)에서 LSB(최하위 비트)로 op:8, dst_reg:4, src_reg:4, off:16, imm:32 비트 시퀀스로 정의돼요. off와 imm은 부호 있는 타입이에요. 인코딩은 커널 헤더의 일부이며 linux/bpf.h 헤더에 정의되고, 여기에는 linux/bpf_common.h도 포함돼요.
op는 실제 수행할 연산을 정의해요. op 인코딩 대부분은 cBPF에서 재사용됐어요. 연산은 레지스터 또는 즉시(immediate) 오퍼랜드를 기반으로 할 수 있어요. op 인코딩 자체는 어떤 모드를 사용할지 정보를 제공해요(BPF_X는 레지스터 기반 연산, BPF_K는 즉시 기반 연산 각각). 후자의 경우 대상 오퍼랜드는 항상 레지스터예요. dst_reg와 src_reg는 연산에 사용할 레지스터 오퍼랜드(예: r0 - r9)에 대한 추가 정보를 제공해요. off는 일부 명령어에서 스택이나 BPF에 사용 가능한 다른 버퍼(예: 맵 값, 패킷 데이터 등)를 주소 지정하거나 점프 명령어의 점프 대상 등의 상대 오프셋을 제공하는 데 사용돼요. imm은 상수/즉시 값을 담아요.
사용 가능한 op 명령어는 여러 명령어 클래스로 분류할 수 있어요. 이 클래스도 op 필드 안에 인코딩돼요. op 필드는 (MSB에서 LSB로) code:4, source:1, class:3으로 나뉘어요. class는 더 일반적인 명령어 클래스이고, code는 해당 클래스 내 특정 연산 코드를 나타내며, source는 소스 오퍼랜드가 레지스터인지 즉시 값인지 알려줘요. 가능한 명령어 클래스는 다음과 같아요:
BPF_LD,BPF_LDX: 둘 다 로드 연산용 클래스예요.BPF_LD는imm:32분할 때문에 두 명령어에 걸친 특수 명령어로 더블 워드를 로드하고, 패킷 데이터의 바이트/하프워드/워드 로드에 사용돼요. 후자는 주로 cBPF에서 BPF로의 변환을 효율적으로 유지하기 위해 가져온 것인데, 최적화된 JIT 코드가 있기 때문이에요. 네이티브 BPF에서는 이러한 패킷 로드 명령어가 오늘날 덜 관련돼요.BPF_LDX클래스는 메모리에서 바이트/하프워드/워드/더블워드 로드 명령어를 담아요. 여기서 메모리는 일반적이며 스택 메모리, 맵 값 데이터, 패킷 데이터 등이 될 수 있어요.BPF_ST,BPF_STX: 둘 다 저장 연산용 클래스예요.BPF_LDX와 유사하게BPF_STX는 저장 대응이며 레지스터에서 메모리로 데이터를 저장하는 데 사용돼요. 역시 스택 메모리, 맵 값, 패킷 데이터 등이 될 수 있어요.BPF_STX는 워드·더블워드 기반 원자 add 연산을 수행하는 특수 명령어도 담고 있어, 예를 들어 카운터에 사용할 수 있어요.BPF_ST클래스는BPF_STX와 유사하되 소스 오퍼랜드가 즉시 값이라는 점만 다르게 메모리로 데이터를 저장하는 명령어를 제공해요.BPF_ALU,BPF_ALU64: 둘 다 ALU 연산 클래스예요. 일반적으로BPF_ALU연산은 32비트 모드이고BPF_ALU64는 64비트 모드예요. 두 ALU 클래스 모두 레지스터 기반 소스 오퍼랜드와 즉시 기반 대응 연산이 있는 기본 연산을 가져요. 둘 모두 add (+), sub (-), and (&), or (|), left shift (<<), right shift (>>), xor (^), mul (*), div (/), mod (%), neg (~) 연산을 지원해요. 또한 두 클래스 모두 두 오퍼랜드 모드에서 특수 ALU 연산으로 mov (<X> := <Y>)가 추가됐어요.BPF_ALU64는 부호 있는 우측 시프트도 포함해요.BPF_ALU는 주어진 소스 레지스터에서 하프워드/워드/더블워드에 대한 엔디언 변환 명령어도 추가로 포함해요.BPF_JMP: 이 클래스는 점프 연산 전용이에요. 점프는 무조건적·조건적일 수 있어요. 무조건 점프는 프로그램 카운터를 앞으로 이동시켜 현재 명령어에 상대적으로 실행할 다음 명령어가off + 1이 되게 해요. 여기서off는 명령어에 인코딩된 상수 오프셋이에요.off가 부호 있으므로 루프를 만들지 않고 프로그램 범위 내에 있는 한 점프를 역방향으로도 수행할 수 있어요. 조건 점프는 레지스터 기반·즉시 기반 소스 오퍼랜드 모두에서 작동해요. 점프 연산의 조건이true면off + 1로 상대 점프를 수행하고, 그렇지 않으면 다음 명령어(0 + 1)를 수행해요. 이 fall-through 점프 로직은 cBPF와 달리 CPU 분기 예측 로직에 더 자연스럽게 맞아 더 나은 분기 예측을 가능하게 해요. 사용 가능한 조건은 jeq (==), jne (!=), jgt (>), jge (>=), jsgt (signed>), jsge (signed>=), jlt (<), jle (<=), jslt (signed<), jsle (signed<=), jset (DST & SRC이면 점프)예요. 그 외에도 이 클래스에는 세 가지 특수 점프 연산이 있어요: BPF 프로그램을 떠나r0의 현재 값을 반환 코드로 돌려주는 exit 명령어, 사용 가능한 BPF 헬퍼 함수 중 하나로 함수 호출을 내는 call 명령어, 그리고 다른 BPF 프로그램으로 점프하는 숨은 tail call 명령어예요.
리눅스 커널에는 BPF 명령어로 조립된 프로그램을 실행하는 BPF 인터프리터가 포함돼 있어요. cBPF 프로그램도 커널에서 투명하게 eBPF 프로그램으로 변환되는데, 아직 cBPF JIT가 포함되어 있고 eBPF JIT로 마이그레이션하지 않은 아키텍처는 예외예요.
현재 x86_64, arm64, ppc64, s390x, mips64, sparc64, arm 아키텍처에는 커널 내 eBPF JIT 컴파일러가 포함돼 있어요.
프로그램을 커널로 로드하거나 BPF 맵을 생성하는 등의 모든 BPF 처리는 중앙 bpf() 시스템 콜로 관리돼요. 이는 맵 항목 관리(lookup/update/delete)와 pinning을 통한 BPF 파일 시스템에서 프로그램·맵을 영구화하는 데도 사용돼요.
헬퍼 함수 (Helper Functions)
헬퍼 함수는 BPF 프로그램이 커널에서 정의한 함수 호출 집합을 참조해 데이터를 가져오거나 푸시할 수 있게 해주는 개념이에요. 사용 가능한 헬퍼 함수는 BPF 프로그램 유형마다 다를 수 있어요. 예를 들어 소켓에 부착된 BPF 프로그램은 tc 계층에 부착된 BPF 프로그램에 비해 헬퍼 하위 집합만 호출할 수 있어요. 경량 터널링용 캡슐화·역캡슐화 헬퍼는 하위 tc 계층에서만 사용할 수 있는 함수의 예이고, 사용자 공간으로 알림을 푸시하는 이벤트 출력 헬퍼는 tc와 XDP 프로그램 모두에서 사용할 수 있어요.
각 헬퍼 함수는 시스템 콜과 유사한 공통 함수 시그니처로 구현돼요. 시그니처는 다음과 같이 정의됩니다:
u64 fn(u64 r1, u64 r2, u64 r3, u64 r4, u64 r5)
이전 섹션에서 설명한 호출 규약이 모든 BPF 헬퍼 함수에 적용돼요.
커널은 헬퍼 함수를 시스템 콜과 유사한 매크로 BPF_CALL_0() ~ BPF_CALL_5()로 추상화해요. 다음 예시는 해당 맵 구현 콜백을 호출해 맵 요소를 업데이트하는 헬퍼 함수의 일부예요:
BPF_CALL_4(bpf_map_update_elem, struct bpf_map *, map, void *, key,
void *, value, u64, flags)
{
WARN_ON_ONCE(!rcu_read_lock_held());
return map->ops->map_update_elem(map, key, value, flags);
}
const struct bpf_func_proto bpf_map_update_elem_proto = {
.func = bpf_map_update_elem,
.gpl_only = false,
.ret_type = RET_INTEGER,
.arg1_type = ARG_CONST_MAP_PTR,
.arg2_type = ARG_PTR_TO_MAP_KEY,
.arg3_type = ARG_PTR_TO_MAP_VALUE,
.arg4_type = ARG_ANYTHING,
};
이 접근 방식에는 여러 장점이 있어요: cBPF는 불가능한 패킷 오프셋에서 데이터를 가져와 보조 헬퍼 함수를 호출하기 위해 로드 명령어를 오버로드했고, 각 cBPF JIT가 그러한 cBPF 확장에 대한 지원을 구현해야 했어요. eBPF의 경우 새로 추가된 각 헬퍼 함수가 투명하고 효율적으로 JIT 컴파일되는데, 레지스터 매핑이 BPF 레지스터 할당이 이미 기본 아키텍처의 호출 규약과 일치하게 이루어져 JIT 컴파일러가 call 명령만 내면 되기 때문이에요. 이는 새 헬퍼 기능으로 핵심 커널을 쉽게 확장할 수 있게 해줘요. 모든 BPF 헬퍼 함수는 핵심 커널의 일부이며 커널 모듈로 확장하거나 추가할 수 없어요.
앞서 언급한 함수 시그니처는 verifier가 타입 검사를 수행하게도 해줘요. 위 struct bpf_func_proto는 헬퍼에 대해 알아야 할 모든 필요한 정보를 verifier에 전달해, 헬퍼가 기대하는 타입이 BPF 프로그램의 분석된 레지스터의 현재 내용과 일치하는지 확인하게 해요.
인자 타입은 어떤 값이든 전달하는 것부터 BPF 스택 버퍼에 대한 포인터/크기 쌍처럼 제한된 내용까지 다양할 수 있으며, 헬퍼가 그 버퍼를 읽거나 써야 해요. 후자의 경우 verifier는 추가 검사(예: 버퍼가 이전에 초기화됐는지)도 수행할 수 있어요.
사용 가능한 BPF 헬퍼 함수 목록은 상당히 길고 계속 늘어나요. 예를 들어 이 글을 쓰는 시점에 tc BPF 프로그램은 38개의 다른 BPF 헬퍼 중에서 선택할 수 있어요. 커널의 struct bpf_verifier_ops는 특정 enum bpf_func_id를 주어진 BPF 프로그램 유형의 사용 가능한 헬퍼 중 하나로 매핑하는 get_func_proto 콜백 함수를 포함해요.
맵 (Maps)

맵은 커널 공간에 상주하는 효율적인 키/값 저장소예요. 여러 BPF 프로그램 호출 사이에 상태를 유지하기 위해 BPF 프로그램에서 접근할 수 있어요. 또한 사용자 공간의 파일 디스크립터를 통해 접근할 수 있고, 다른 BPF 프로그램이나 사용자 공간 애플리케이션과 자유롭게 공유할 수 있어요.
서로 맵을 공유하는 BPF 프로그램은 같은 프로그램 유형일 필요가 없어요. 예를 들어 tracing 프로그램이 네트워킹 프로그램과 맵을 공유할 수 있어요. 단일 BPF 프로그램은 현재 최대 64개의 서로 다른 맵에 직접 접근할 수 있어요.
맵 구현은 핵심 커널이 제공해요. 임의의 데이터를 읽고/쓸 수 있는 per-CPU 및 non-per-CPU 유형의 일반 맵이 있지만, 헬퍼 함수와 함께 사용되는 몇 가지 비일반(non-generic) 맵도 있어요.
현재 사용 가능한 일반 맵은 BPF_MAP_TYPE_HASH, BPF_MAP_TYPE_ARRAY, BPF_MAP_TYPE_PERCPU_HASH, BPF_MAP_TYPE_PERCPU_ARRAY, BPF_MAP_TYPE_LRU_HASH, BPF_MAP_TYPE_LRU_PERCPU_HASH, BPF_MAP_TYPE_LPM_TRIE예요. 이들은 모두 lookup, update, delete 연산을 수행하는 데 같은 공통 BPF 헬퍼 함수 세트를 사용하면서 서로 다른 시맨틱과 성능 특성을 가진 서로 다른 백엔드를 구현해요.
현재 커널에 있는 비일반 맵은 BPF_MAP_TYPE_PROG_ARRAY, BPF_MAP_TYPE_PERF_EVENT_ARRAY, BPF_MAP_TYPE_CGROUP_ARRAY, BPF_MAP_TYPE_STACK_TRACE, BPF_MAP_TYPE_ARRAY_OF_MAPS, BPF_MAP_TYPE_HASH_OF_MAPS예요. 예를 들어 BPF_MAP_TYPE_PROG_ARRAY는 다른 BPF 프로그램을 보관하는 배열 맵이고, BPF_MAP_TYPE_ARRAY_OF_MAPS와 BPF_MAP_TYPE_HASH_OF_MAPS는 다른 맵에 대한 포인터를 보관해 전체 BPF 맵을 런타임에 원자적으로 교체할 수 있게 해줘요. 이러한 유형의 맵은 BPF 프로그램 호출 사이에 추가(비데이터) 상태를 유지해야 하므로 단순히 BPF 헬퍼 함수로만 구현하기엔 부적합한 특정 문제를 해결해요.
객체 고정 (Object Pinning)

BPF 맵과 프로그램은 커널 리소스로 작동하며, 커널의 익명 inode가 뒷받침하는 파일 디스크립터로만 접근할 수 있어요. 장점과 함께 단점도 여러 가지 수반돼요:
사용자 공간 애플리케이션은 대부분의 파일 디스크립터 관련 API를 사용할 수 있고, Unix 도메인 소켓을 통한 파일 디스크립터 전달이 투명하게 작동하는 등의 장점이 있지만, 동시에 파일 디스크립터는 프로세스 수명으로 제한되어 맵 공유 같은 옵션을 수행하기가 상당히 번거로워요.
따라서 iproute2 같은 특정 유스 케이스에 여러 복잡성이 발생해요. tc나 XDP가 프로그램을 설정해 커널에 로드하고 결국 스스로 종료하는 경우예요. 이와 함께 사용자 공간 쪽에서 맵에 접근하는 것도 불가능해지는데, 맵이 데이터패스의 ingress와 egress 위치 사이에 공유되는 경우처럼 그게 유용할 수 있는데도 그렇지 않아요. 또한 서드파티 애플리케이션이 BPF 프로그램 런타임 중에 맵 내용을 모니터링하거나 업데이트하기를 원할 수도 있어요.
이 제한을 극복하기 위해 최소한의 커널 공간 BPF 파일 시스템이 구현됐고, 여기에 BPF 맵과 프로그램을 고정(pin)할 수 있어요. 이를 객체 고정(object pinning)이라고 해요. 이를 위해 BPF 시스템 콜에 이전에 고정된 객체를 고정(BPF_OBJ_PIN)하거나 검색(BPF_OBJ_GET)하는 두 가지 새 명령이 추가됐어요.
예를 들어 tc 같은 도구는 이 인프라를 사용해 ingress와 egress에서 맵을 공유해요. BPF 관련 파일 시스템은 단일(singleton)이 아니며, 여러 마운트 인스턴스, 하드·소프트 링크 등을 지원해요.
Tail Calls

BPF와 함께 사용할 수 있는 또 다른 개념은 tail call이에요. Tail call은 한 BPF 프로그램이 이전 프로그램으로 돌아오지 않고 다른 프로그램을 호출할 수 있게 하는 메커니즘으로 볼 수 있어요. 이러한 호출은 함수 호출과 달리 같은 스택 프레임을 재사용하는 롱 점프로 구현되어 오버헤드가 최소예요.
이러한 프로그램은 서로 독립적으로 검증되므로, 상태를 전달하려면 스크래치 버퍼로 per-CPU 맵을 쓰거나, tc 프로그램의 경우 cb[] 영역 같은 skb 필드를 사용해야 해요.
같은 유형의 프로그램만 tail call될 수 있고, JIT 컴파일 측면에서도 일치해야 하므로 JIT 컴파일된 프로그램끼리 또는 인터프리트된 프로그램끼리만 호출할 수 있으며 서로 섞을 수는 없어요.
Tail call 수행에는 두 구성 요소가 관여해요: 첫째는 프로그램 배열(BPF_MAP_TYPE_PROG_ARRAY)이라 불리는 특수 맵을 설정해야 하는데, 사용자 공간이 키/값으로 채울 수 있고 값은 tail call되는 BPF 프로그램의 파일 디스크립터예요. 둘째는 컨텍스트, 프로그램 배열 참조, lookup 키가 전달되는 bpf_tail_call() 헬퍼예요. 그러면 커널이 이 헬퍼 호출을 특수 BPF 명령어로 직접 인라인해요. 이러한 프로그램 배열은 현재 사용자 공간 쪽에서 쓰기 전용이에요.
커널은 전달된 파일 디스크립터에서 관련 BPF 프로그램을 찾아 주어진 맵 슬롯의 프로그램 포인터를 원자적으로 교체해요. 제공된 키에서 맵 항목을 찾지 못하면 커널은 "fall through"하여 bpf_tail_call() 뒤의 명령어들로 이전 프로그램의 실행을 계속해요. Tail call은 강력한 유틸리티인데, 예를 들어 네트워크 헤더 파싱을 tail call로 구조화할 수 있어요. 런타임 중 기능을 원자적으로 추가하거나 교체해 BPF 프로그램의 실행 동작을 바꿀 수 있어요.
BPF to BPF 호출 (BPF to BPF Calls)

BPF 헬퍼 호출과 BPF tail call 외에, BPF 핵심 인프라에 최근 추가된 기능은 BPF to BPF 호출이에요. 이 기능이 커널에 도입되기 전에는 일반적인 BPF C 프로그램이 헤더에 있는 재사용 가능한 코드를 always_inline으로 선언해야 했어요. 그래서 LLVM이 BPF 오브젝트 파일을 컴파일·생성할 때 이 모든 함수가 인라인되어 결과 오브젝트 파일에 여러 번 중복되어 코드 크기를 인위적으로 부풀렸어요:
#include <linux/bpf.h>
#ifndef __section
# define __section(NAME) \
__attribute__((section(NAME), used))
#endif
#ifndef __inline
# define __inline \
inline __attribute__((always_inline))
#endif
static __inline int foo(void)
{
return XDP_DROP;
}
__section("prog")
int xdp_drop(struct xdp_md *ctx)
{
return foo();
}
char __license[] __section("license") = "GPL";
이것이 필요했던 주된 이유는 BPF 프로그램 로더와 verifier, 인터프리터, JIT에 함수 호출 지원이 없었기 때문이에요. 리눅스 커널 4.16과 LLVM 6.0부터 이 제한이 풀렸고, BPF 프로그램은 더 이상 모든 곳에서 always_inline을 사용할 필요가 없어요. 따라서 앞서 보여준 BPF 예제 코드는 더 자연스럽게 다음과 같이 다시 쓸 수 있어요:
#include <linux/bpf.h>
#ifndef __section
# define __section(NAME) \
__attribute__((section(NAME), used))
#endif
static int foo(void)
{
return XDP_DROP;
}
__section("prog")
int xdp_drop(struct xdp_md *ctx)
{
return foo();
}
char __license[] __section("license") = "GPL";
x86_64, arm64 같은 주류 BPF JIT 컴파일러는 오늘날 BPF to BPF 호출을 지원하며, 다른 것들도 가까운 시일 내에 따를 예정이에요. BPF to BPF 호출은 생성된 BPF 코드 크기를 크게 줄여 CPU의 명령어 캐시에 더 친화적이 되므로 중요한 성능 최적화예요.
BPF 헬퍼 함수에서 알려진 호출 규약이 BPF to BPF 호출에도 그대로 적용돼요. 즉 r1부터 r5는 callee에 인자를 전달하는 데 사용되고 결과는 r0으로 반환돼요. r1r5는 스크래치 레지스터이고 r6r9는 호출 간 일반적인 방식으로 보존돼요. 최대 중첩 호출 수(허용된 호출 프레임 수)는 8이에요. 호출자는 callee에 포인터(예: 호출자의 스택 프레임에 대한)를 전달할 수 있지만 그 반대는 불가능해요.
BPF JIT 컴파일러는 각 함수 본문에 대해 별도의 이미지를 생성하고, 마지막 JIT 패스에서 이미지의 함수 호출 주소를 수정해요. 이를 통해 JIT가 BPF to BPF 호출을 기존 BPF 헬퍼 호출로 취급할 수 있어 JIT 변경이 최소화되는 것으로 입증됐어요.
커널 5.9까지는 BPF tail call과 BPF 서브프로그램이 서로 배타적이었어요. tail call을 사용한 BPF 프로그램은 프로그램 이미지 크기 축소와 더 빠른 로드 시간이라는 이점을 누릴 수 없었어요. 리눅스 커널 5.10이 마침내 사용자가 두 세계의 장점을 모두 누리고 BPF 서브프로그램을 tail call과 결합할 수 있게 했어요.
다만 이 개선에는 몇 가지 제한이 따르는 것은 상관없어요. 이 두 기능을 섞으면 커널 스택 오버플로가 발생할 수 있어요. 무엇이 일어날 수 있는지 알고 싶다면 bpf2bpf 호출과 tail call의 혼합을 보여주는 아래 그림을 참고하세요:

Tail call은 실제로 대상 프로그램으로 점프하기 전에 현재 스택 프레임만 풀어요(unwind). 위 예시처럼 sub-function 내부에서 tail call이 발생하면, 프로그램 실행이 func2에 있을 때 함수(func1)의 스택 프레임이 스택에 존재해요. 마지막 함수(func3)가 종료되면 이전의 모든 스택 프레임이 풀리고 제어가 BPF 프로그램 호출자에게 돌아가요.
커널은 이 기능 조합을 감지하는 추가 로직을 도입했어요. 전체 호출 체인에 걸쳐 서브프로그램당 256바이트까지 스택 크기 제한이 있어요(verifier가 bpf2bpf 호출을 감지하면 main 함수도 sub-function으로 취급된다는 점에 유의). 총체적으로 이 제한으로 BPF 프로그램의 호출 체인은 최대 8KB의 스택 공간을 소비할 수 있어요. 이 제한은 스택 프레임당 256바이트에 tail call 수 제한(33)을 곱한 값에서 나와요. 이것이 없다면 BPF 프로그램은 512바이트 스택 크기로 작동해 최대 tail call 수에 대해 총 16KB 크기가 되어 일부 아키텍처에서 스택이 넘칠 거예요.
한 가지 더 언급할 것은 이 기능 조합이 현재 x86-64 아키텍처에서만 지원된다는 점이에요.
JIT

64비트 x86_64, arm64, ppc64, s390x, mips64, sparc64 및 32비트 arm, x86_32 아키텍처 모두 커널 내 eBPF JIT 컴파일러가 포함돼 있고, 모두 기능적으로 동등하며 다음으로 활성화할 수 있어요:
# echo 1 > /proc/sys/net/core/bpf_jit_enable
32비트 mips, ppc, sparc 아키텍처는 현재 cBPF JIT 컴파일러가 있어요. 언급한 cBPF JIT가 있는 아키텍처와 BPF JIT 컴파일러가 전혀 없는 리눅스 커널이 지원하는 나머지 모든 아키텍처는 커널 내 인터프리터를 통해 eBPF 프로그램을 실행해야 해요.
커널 소스 트리에서 eBPF JIT 지원은 HAVE_EBPF_JIT를 grep해 쉽게 확인할 수 있어요:
# git grep HAVE_EBPF_JIT arch/
arch/arm/Kconfig: select HAVE_EBPF_JIT if !CPU_ENDIAN_BE32
arch/arm64/Kconfig: select HAVE_EBPF_JIT
arch/powerpc/Kconfig: select HAVE_EBPF_JIT if PPC64
arch/mips/Kconfig: select HAVE_EBPF_JIT if (64BIT && !CPU_MICROMIPS)
arch/s390/Kconfig: select HAVE_EBPF_JIT if PACK_STACK && HAVE_MARCH_Z196_FEATURES
arch/sparc/Kconfig: select HAVE_EBPF_JIT if SPARC64
arch/x86/Kconfig: select HAVE_EBPF_JIT if X86_64
JIT 컴파일러는 인터프리터에 비해 명령어당 비용을 줄여 BPF 프로그램 실행을 크게 가속해요. 종종 명령어를 기본 아키텍처의 네이티브 명령어와 1:1로 매핑할 수 있어요. 이는 결과 실행 이미지 크기도 줄여 CPU에 더 명령어 캐시 친화적이게 해요. 특히 x86 같은 CISC 명령어 집합의 경우 JIT는 주어진 명령어에 대해 가능한 한 가장 짧은 opcode를 내보내 프로그램 변환에 필요한 총 크기를 줄이도록 최적화돼요.
하드닝 (Hardening)
BPF는 프로그램 수명 동안 잠재적 손상으로부터 코드를 보호하기 위해 전체 BPF 인터프리터 이미지(struct bpf_prog)와 JIT 컴파일 이미지(struct bpf_binary_header)를 커널에서 읽기 전용으로 잠가요. 예를 들어 일부 커널 버그로 인한 그 시점의 손상은 조용히 진행되도록 두지 않고 일반 보호 장애(general protection fault)를 일으켜 커널을 크래시시켜요.
이미지 메모리를 읽기 전용으로 설정하는 것을 지원하는 아키텍처는 다음으로 확인할 수 있어요:
$ git grep ARCH_HAS_SET_MEMORY | grep select
arch/arm/Kconfig: select ARCH_HAS_SET_MEMORY
arch/arm64/Kconfig: select ARCH_HAS_SET_MEMORY
arch/s390/Kconfig: select ARCH_HAS_SET_MEMORY
arch/x86/Kconfig: select ARCH_HAS_SET_MEMORY
CONFIG_ARCH_HAS_SET_MEMORY 옵션은 설정할 수 없으며, 덕분에 이 보호가 항상 내장돼요. 다른 아키텍처는 향후 따를 수도 있어요.
x86_64 JIT 컴파일러의 경우 CONFIG_RETPOLINE이 설정되면 tail call 사용으로 인한 간접 점프의 JITing이 retpoline으로 실현되는데, 이는 이 글을 쓰는 시점에 대부분의 현대 리눅스 배포판에서 기본값이에요.
/proc/sys/net/core/bpf_jit_harden이 1로 설정된 경우 비특권(privileged) 사용자에 대해 JIT 컴파일의 추가 하드닝 단계가 적용돼요. 이는 시스템에서 작업하는 신뢰할 수 없는 사용자의 경우 (잠재적) 공격 표면을 줄여 그들의 성능을 약간 희생해요. 프로그램 실행의 감소는 완전히 인터프리터로 전환하는 것보다 여전히 더 나은 성능을 내요.
현재 하드닝을 활성화하면 JIT spraying 공격(즉시 값을 네이티브 opcode로 주입)을 막기 위해 BPF 프로그램이 JIT 컴파일될 때 사용자가 제공한 모든 32비트·64비트 상수를 블라인드(blind) 처리해요. 이는 즉시 값이 실행 가능한 커널 메모리에 상주하기 때문에 문제가 되는데, 일부 커널 버그로 트리거될 수 있는 점프가 즉시 값의 시작점으로 점프해 이를 네이티브 명령어로 실행할 수 있기 때문이에요.
JIT 상수 블라인딩은 실제 명령어를 무작위화해 이를 방지해요. 즉 연산을 즉시 기반 소스 오퍼랜드에서 레지스터 기반으로 변환하고, 값의 실제 로드를 두 단계로 나눠 명령어를 다시 작성해요: 1) 블라인드된 즉시 값 rnd ^ imm을 레지스터로 로드, 2) 그 레지스터를 rnd와 xor해 원래 imm 즉시 값이 레지스터에 남아 실제 연산에 사용될 수 있게 해요. 예시는 로드 연산에 대해 제공됐지만 실제로는 모든 일반 연산이 블라인드돼요.
하드닝이 비활성화된 상태로 프로그램을 JITing한 예:
# echo 0 > /proc/sys/net/core/bpf_jit_harden
ffffffffa034f5e9 + <x>:
[...]
39: mov $0xa8909090,%eax
3e: mov $0xa8909090,%eax
43: mov $0xa8ff3148,%eax
48: mov $0xa89081b4,%eax
4d: mov $0xa8900bb0,%eax
52: mov $0xa810e0c1,%eax
57: mov $0xa8908eb4,%eax
5c: mov $0xa89020b0,%eax
[...]
하드닝이 활성화된 경우 비특권 사용자가 BPF를 통해 로드하면 같은 프로그램이 상수 블라인드됩니다:
# echo 1 > /proc/sys/net/core/bpf_jit_harden
ffffffffa034f1e5 + <x>:
[...]
39: mov $0xe1192563,%r10d
3f: xor $0x4989b5f3,%r10d
46: mov %r10d,%eax
49: mov $0xb8296d93,%r10d
4f: xor $0x10b9fd03,%r10d
56: mov %r10d,%eax
59: mov $0x8c381146,%r10d
5f: xor $0x24c7200e,%r10d
66: mov %r10d,%eax
69: mov $0xeb2a830e,%r10d
6f: xor $0x43ba02ba,%r10d
76: mov %r10d,%eax
79: mov $0xd9730af,%r10d
7f: xor $0xa5073b1f,%r10d
86: mov %r10d,%eax
89: mov $0x9a45662b,%r10d
8f: xor $0x325586ea,%r10d
96: mov %r10d,%eax
[...]
두 프로그램은 의미적으로 동일하며, 두 번째 프로그램의 역어셈블리에는 원래 즉시 값이 하나도 보이지 않을 뿐이에요.
동시에 하드닝은 특권 사용자에 대한 JIT kallsyms 노출도 비활성화해 JIT 이미지 주소가 /proc/kallsyms에 더 이상 노출되지 않게 해요.
또한 리눅스 커널은 CONFIG_BPF_JIT_ALWAYS_ON 옵션을 제공하는데, 이는 커널에서 전체 BPF 인터프리터를 제거하고 JIT 컴파일러를 영구적으로 활성화해요. 이는 Spectre v2 맥락의 완화책의 일부로 개발됐어요. 그래서 VM 기반 환경에서 사용하면 게스트 커널이 공격을 가할 때 호스트 커널의 BPF 인터프리터를 더 이상 재사용하지 않게 돼요. 컨테이너 기반 환경의 경우 CONFIG_BPF_JIT_ALWAYS_ON 구성 옵션은 선택사항이지만, 어차피 JIT가 활성화되어 있다면 인터프리터를 컴파일해서 빼 커널 복잡성을 줄이는 것이 좋을 수 있어요. 따라서 x86_64, arm64 같은 주류 아키텍처의 널리 사용되는 JIT에도 일반적으로 권장돼요.
마지막으로 커널은 /proc/sys/kernel/unprivileged_bpf_disabled sysctl 노브를 통해 비특권 사용자의 bpf(2) 시스템 콜 사용을 비활성화하는 옵션을 제공해요. 이것은 의도적으로 일회성 kill switch인데, 한번 1로 설정하면 새 커널 재부팅까지 0으로 재설정할 방법이 없어요. 설정되면 그 시점부터 초기 네임스페이스 밖의 CAP_SYS_ADMIN 특권 프로세스만 bpf(2) 시스템 콜을 사용할 수 있어요. 시작 시 Cilium도 이 노브를 1로 설정해요.
# echo 1 > /proc/sys/kernel/unprivileged_bpf_disabled
오프로드 (Offloads)

BPF의 네트워킹 프로그램, 특히 tc와 XDP는 NIC에서 직접 BPF 코드를 실행하기 위해 커널에 하드웨어로의 오프로드 인터페이스가 있어요.
현재 Netronome의 nfp 드라이버가 NIC에 대해 구현된 명령어 집합으로 BPF 명령어를 변환하는 JIT 컴파일러를 통해 BPF 오프로드를 지원해요. 여기에는 BPF 맵을 NIC로 오프로드하는 것도 포함되므로, 오프로드된 BPF 프로그램이 맵 lookup, update, delete를 수행할 수 있어요.
BPF sysctls
리눅스 커널은 이 섹션에서 다루는 BPF 관련 sysctl 몇 가지를 제공해요.
/proc/sys/net/core/bpf_jit_enable: BPF JIT 컴파일러를 활성화하거나 비활성화해요.
| 값 | 설명 |
|---|---|
| 0 | JIT를 비활성화하고 인터프리터만 사용 (커널 기본값) |
| 1 | JIT 컴파일러 활성화 |
| 2 | JIT를 활성화하고 디버깅 추적을 커널 로그로 출력 |
이후 섹션에서 설명하듯, JIT 컴파일러가 디버깅 모드(옵션 2)로 설정되면 bpf_jit_disasm 도구로 디버깅 추적을 처리할 수 있어요.
/proc/sys/net/core/bpf_jit_harden: BPF JIT 하드닝을 활성화하거나 비활성화해요. 하드닝 활성화는 성능을 희생하지만 BPF 프로그램의 즉시 값을 블라인드해 JIT spraying을 완화할 수 있어요. 인터프리터로 처리되는 프로그램의 경우 즉시 값 블라인딩은 필요하지 않거나 수행되지 않아요.
| 값 | 설명 |
|---|---|
| 0 | JIT 하드닝 비활성화 (커널 기본값) |
| 1 | 비특권 사용자에 대해서만 JIT 하드닝 활성화 |
| 2 | 모든 사용자에 대해 JIT 하드닝 활성화 |
/proc/sys/net/core/bpf_jit_kallsyms: JIT된 프로그램을 커널 심볼로/proc/kallsyms에 내보낼지 활성화·비활성화해요.perf도구와 함께 사용하거나 스택 추적 덤프에 사용되는 스택 언와인딩을 위해 이러한 주소를 커널에 인지시키는 데 사용할 수 있어요. 심볼 이름에는 BPF 프로그램 태그(bpf_prog_<tag>)가 포함돼요.bpf_jit_harden이 활성화되면 이 기능은 비활성화돼요.
| 값 | 설명 |
|---|---|
| 0 | JIT kallsyms 내보내기 비활성화 (커널 기본값) |
| 1 | 특권 사용자에 대해서만 JIT kallsyms 내보내기 활성화 |
/proc/sys/kernel/unprivileged_bpf_disabled:bpf(2)시스템 콜의 비특권 사용을 활성화하거나 비활성화해요. 리눅스 커널은 기본적으로bpf(2)의 비특권 사용이 활성화돼 있어요. 값을 1로 설정하면 다음 재부팅까지 비특권 사용이 영구적으로 비활성화되며, 애플리케이션이나 관리자도 값을 더 이상 재설정할 수 없어요. 값을 2로 설정할 수도 있는데, 이는 지금 당장 비특권 사용을 비활성화하면서 나중에 런타임에 0이나 1로 변경할 수 있음을 의미해요. 이 값은 Linux 5.13에서 추가됐어요. 커널 구성에서BPF_UNPRIV_DEFAULT_OFF가 활성화되면 이 노브는 0 대신 기본적으로 2가 돼요. 이 노브는 프로그램을 커널로 로드하기 위해bpf(2)시스템 콜을 사용하지 않는 seccomp나 전통적인 소켓 필터 같은 cBPF 프로그램에는 영향을 주지 않아요.
| 값 | 설명 |
|---|---|
| 0 | bpf 시스템 콜의 비특권 사용 활성화 (커널 기본값) |
| 1 | bpf 시스템 콜의 비특권 사용 비활성화 (재부팅까지) |
| 2 | bpf 시스템 콜의 비특권 사용 비활성화 (커널 구성에서 BPF_UNPRIV_DEFAULT_OFF가 활성화된 경우 기본값) |