시작하며


4장에서는 사용자 공간 애플리케이션이 bpf() 시스템 콜을 호출하여 커널에 BTF 정보를 전송하고, 맵을 생성하며, 프로그램을 로드하는 등 로우 레벨에서의 소통 방식을 자세히 다루었다.

이전 단계까지 우리의 예제들은 주로 BCC(BPF Compiler Collection) 프레임워크에 기반해 작성되었다. BCC는 사용자 공간 프로그램이 시작되는 시점에 내장된 Clang/LLVM 컴파일러를 통해 eBPF C 코드를 실시간으로 컴파일한다. 이 덕분에 우리는 실행 중인 커널 버전에 딱 맞는 바이트코드를 그 자리에서 얻을 수 있었다.

하지만 배포와 운영 관점에서 이 방식은 큰 장벽을 마주하게 된다. 만약 수천 대의 대상 서버에 eBPF 모니터링 도구를 배포해야 한다면, 모든 서버에 헤비한 컴파일러 도구(Clang/LLVM)와 실행 중인 커널 버전과 정확히 일치하는 커널 헤더 패키지(linux-headers-$(uname -r))를 일일이 설치해야 할까? 이는 자원 소모가 크고 의존성이 무거워 실무 환경에서는 채택하기 매우 부담스러운 일이다.

이 문제를 우아하게 해결하기 위해 리눅스 커널과 eBPF 에코시스템은 **CO-RE(Compile Once - Run Everywhere, 한 번 컴파일하여 어디서나 실행)**라는 혁신적인 이식성 기술을 도입했다.

이번 장에서는 CO-RE의 세 가지 핵심 기둥인 BTF, Libbpf, 그리고 재배치(Relocation) 메커니즘을 파헤쳐 보겠다.

BCC 방식의 한계와 이식성(Portability) 문제


먼저 왜 eBPF 프로그램이 다른 커널 버전에서 그대로 실행되기 어려운지, 즉 이식성 문제가 발생하는 근본적인 원인부터 짚고 넘어가야 한다.

리눅스 커널은 끊임없이 진화한다. 그 과정에서 커널이 사용하는 핵심 구조체(예: 프로세스 메타데이터를 담은 struct task_struct, 네트워크 소켓을 표현하는 struct sock 등)의 내부 필드 순서가 바뀌거나, 새로운 필드가 삽입되기도 하고, 기존 필드가 삭제되거나 이름이 변경되기도 한다.

예를 들어, 어떤 커널 버전 5.4에서는 struct task_struct 내부의 pid 필드가 구조체 시작점으로부터 1200바이트 떨어진 오프셋(Offset)에 위치해 있었다고 가정해 보자.

[커널 5.4의 task_struct 구조]
┌───────────────────────────┬───────────────┐
│ ...                       │ pid (offset)  │
├───────────────────────────┼───────────────┤
│ 0 ~ 1199 Bytes            │ 1200 ~ 1203   │
└───────────────────────────┴───────────────┘

하지만 커널이 5.15로 업그레이드되면서 pid 앞에 새로운 필드가 추가되어 pid 필드의 오프셋이 1240바이트로 밀려났다면 어떻게 될까?

[커널 5.15의 task_struct 구조]
┌───────────────────────────┬───────────────────┬───────────────┐
│ ...                       │ [신규 필드 추가]   │ pid (offset)  │
├───────────────────────────┼───────────────────┼───────────────┤
│ 0 ~ 1199 Bytes            │ 1200 ~ 1239 Bytes │ 1240 ~ 1243   │
└───────────────────────────┴───────────────────┴───────────────┘

만약 커널 5.4를 기준으로 *(u32 *)(task_pointer + 1200) 형태로 빌드된 고정 오프셋 기반의 eBPF 바이트코드를 커널 5.15에 그대로 로드한다면 다음과 같은 심각한 문제가 생긴다.

  1. 쓰레기 값 조회: 의도치 않은 신규 필드 영역의 데이터를 읽어 오작동을 유발한다.
  2. 검증기(Verifier) 통과 실패: 타입 안전성을 훼손하는 메모리 접근으로 판단되어 프로그램이 로드되지 못한다.

BCC는 이 문제를 **“대상 장비에서 직접 실시간 컴파일”**하는 방식으로 피해 갔다. 대상 장비에 설치된 헤더 파일을 읽어서 컴파일하므로 구조체의 오프셋이 자동으로 맞춰지기 때문이다. 하지만 이는 다음과 같은 비용을 수반한다.

  • 무거운 자원 소모: 컴파일러 구동을 위해 대상 서버의 CPU와 메모리를 순간적으로 크게 소모한다. 이는 임베디드 장비나 리소스가 빡빡한 컨테이너 환경에서 큰 낭비다.
  • 컴파일 의존성: 모든 노드에 GCC, Clang, 커널 개발용 패키지가 미리 설치되어 있어야 하므로 보안 패치와 프로비저닝 관리가 까다로워진다.
  • 시작 실패 가능성: 만약 특정 서버의 커널 헤더가 깨져 있거나 버전이 미세하게 다르다면 로더 실행 자체가 실패한다.

결국 이상적인 해결책은 컴파일은 개발 단계에서 단 한 번만 수행하여 기계어(ELF)로 배포하되, 실제 커널에 로드될 때 실시간으로 구조체 오프셋을 조절해 주는 시스템이었고, 이것이 바로 CO-RE이다.

CO-RE를 지탱하는 세 가지 축


CO-RE는 어느 하나의 기술로 구현된 것이 아니다. 컴파일러, 메타데이터, 그리고 유저 공간 로더 라이브러리가 유기적으로 협업하여 달성된다.

┌─────────────────────────────────────────────────────────────┐
│                       eBPF CO-RE                            │
├──────────────────────┬──────────────────────┬───────────────┤
│ Clang / LLVM         │ BTF                  │ Libbpf        │
│ (컴파일 단계)         │ (커널 메타데이터)    │ (로드 및 배치) │
├──────────────────────┼──────────────────────┼───────────────┤
│ 재배치 정보가 기록된 │ 컴팩트하게 압축된    │ 타겟 커널에서 │
│ ELF 바이트코드 생성  │ 커널 타입 그래프 제공│ 오프셋을 보정 │
└──────────────────────┴──────────────────────┴───────────────┘
  1. Clang/LLVM 컴파일러: C 코드를 컴파일할 때 구조체 오프셋을 기계 코드로 고정하지 않고, **“이 명령어는 특정 구조체의 특정 필드를 참조한다”**라는 일종의 ‘메모(재배치 정보)‘를 ELF 포맷 내의 전용 섹션(.BTF.BTF.ext)에 기록해 둔다.
  2. BTF (BPF Type Format): 리눅스 커널 내부의 모든 구조체, 멤버 필드, 데이터 타입, 함수 프로토타입 정보를 담고 있는 경량 메타데이터 규격이다. 커널이 스스로의 지도를 내부에 품고 있는 것과 같다.
  3. Libbpf: 사용자 공간에서 실행되는 로더 라이브러리다. ELF 파일에 담긴 컴파일 단계의 재배치 정보와 현재 실행 중인 커널의 실시간 BTF 정보를 대조하여, 로드하기 바로 직전에 바이트코드 속의 메모리 접근 오프셋을 수정(Patch)한 뒤 커널에 집어넣는다.

BTF (BPF Type Format)의 정체와 역할


BTF는 구조체 오프셋 보정의 핵심 데이터 소스다.

전통적으로 유닉스 계열 시스템에서는 디버깅을 위해 DWARF 포맷을 사용해 왔다. 하지만 DWARF는 정보량이 너무 방대하여 크기가 보통 수백 메가바이트에 달한다. 디버깅 전용 빌드가 아닌 이상, 일반적인 배포 커널에 DWARF 정보를 영구히 임베디드해 두는 것은 불가능했다.

BTF는 이러한 정보 전송 부담을 해결하기 위해 고안된 초경량 데이터 포맷이다. 중복되는 타입 이름을 트리거링 방식으로 제거하고, 문자열을 풀(Pool)로 관리하여 DWARF 대비 100배 이상의 압축률을 달성했다. 그 덕분에 현대 리눅스 커널은 스스로의 구조체 지도를 상시 탑재할 수 있게 되었다.

최신 배포판 커널(CONFIG_DEBUG_INFO_BTF=y 옵션 활성화)에서는 커널이 기동될 때 이 메타데이터를 /sys/kernel/btf/vmlinux 경로에 가상 파일 형태로 노출한다.

bpftool로 커널 지도 엿보기

bpftool을 사용하면 이 정밀한 구조 정보를 직접 터미널에 사람이 읽을 수 있는 형태로 덤프(Dump)할 수 있다.

# 실행 중인 커널의 BTF 데이터에서 특정 구조체(예: task_struct) 정보만 C 문법으로 덤프
bpftool btf dump file /sys/kernel/btf/vmlinux format c | grep -A 20 "struct task_struct {"

출력 결과는 아래와 같이 완벽한 C 언어 구조체 정의 형태로 쏟아진다.

struct task_struct {
    struct thread_info thread_info;
    unsigned int __state;
    void *stack;
    raw_spinlock_t alloc_lock;
    /* ... 수많은 필드들이 생략 없이 완전하게 표현된다 ... */
    int pid;
    int tgid;
};

BTF-backed Maps의 편의성

BTF가 제공하는 장점은 이식성에만 국한되지 않는다.

4장까지 우리가 원시 바이트를 관찰할 때는 bpftool map dump 명령의 결과물이 날것의 16진수(Hex dump)로 표시되어 눈으로 읽기 힘들었다. 하지만 생성한 맵에 BTF의 타입 정보를 연계해 두면 커널과 도구들이 맵 키와 값의 내부 필드가 무엇인지 인지하게 된다.

그 결과, 아래처럼 맵 조회가 JSON이나 구조화된 데이터 형태로 예쁘게 정렬된다.

{
  "key": {
    "uid": 1000
  },
  "value": {
    "message": "Hey developer!",
    "counter": 42
  }
}

vmlinux.h와 헤더 다이어트


CO-RE와 BTF 기술이 가져온 놀라운 사이드 이펙트 중 하나는 vmlinux.h 파일의 출현이다.

기존 eBPF 컴파일 방식에서는 타겟 구조체를 참조하기 위해 헤더 지옥에 갇히곤 했다. 프로세스 정보를 읽으려면 <linux/sched.h>가 필요하고, 네트워크를 다루려면 <linux/fs.h>, <linux/socket.h> 등이 필요하다. 문제는 이 헤더들을 한 번에 include 하려 하면 상호 참조 및 중복 정의 충돌(Header Collision)이 빈번하게 일어난다는 점이다.

하지만 이제 커널에 이미 압축 정리된 완벽한 구조체 정보가 실시간으로 존재하므로, bpftool을 사용해 실행 중인 시스템의 모든 커널 타입 정의를 단 하나의 C 헤더 파일로 축출해 낼 수 있다.

bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h

생성된 vmlinux.h는 수만 라인에 걸쳐 리눅스 커널의 거의 모든 구조체 정의를 일관되게 담고 있다.

// my_program.bpf.c
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>

SEC("kprobe/sys_clone")
int BPF_PROG(handle_clone, struct task_struct *task) {
    // linux/sched.h를 따로 임포트하지 않아도 task_struct를 완벽하게 사용 가능하다.
    u32 pid = task->pid; 
    return 0;
}

이제 무겁고 복잡한 시스템 헤더들을 추적할 필요 없이, 단 한 줄의 #include "vmlinux.h" 만으로 모든 준비가 끝나는 획기적인 ‘헤더 다이어트’가 가능해졌다.

CO-RE 작동 메커니즘: BPF 재배치(Relocation)


이식성의 본질인 재배치(Relocation) 흐름을 단계별로 파악해 보자.

만약 개발자의 빌드 머신에서 빌드한 ELF 바이트코드 파일이 있고, 이를 실제 운영 환경에 배포하여 로드하는 과정은 다음과 같다.

[개발용 빌드 머신]
1. bpf_program.c 작성 (vmlinux.h 기반)
2. Clang -g -O2 -target bpf 로 컴파일
   -> ELF 파일 내부에 바이트코드와 함께 '.BTF' 및 '.BTF.ext' (재배치 기록) 섹션 저장

                    [ ELF 패키지 배포 ] ──> 대상 운영 서버로 전송
[실제 운영 서버]                                   ▼
1. 사용자 공간 로더(Libbpf)가 ELF 파일 파싱
2. 서버 내부의 '/sys/kernel/btf/vmlinux' 지도 데이터 로드
3. 두 정보 대조 및 오프셋 보정 (BPF Relocation)
   - 예: "컴파일 시 pid 오프셋은 1200이었지만, 현재 커널의 pid 오프셋은 1240이군!"
   - 메모리 접근 기계어 명령어 내의 오프셋 상수를 1200 -> 1240으로 강제 패치
4. 보정 완료된 최종 바이트코드를 bpf() 시스템 콜로 로드

이 모든 보정 작업이 사용자 공간의 Libbpf 내에서 조용하고 신속하게 진행된다. 커널 내부는 어떠한 추가 컴파일 부담도 지지 않으며, 안전하게 가공된 깨끗한 정적 바이트코드만 검증 후 수용하게 된다.

CO-RE 코드 작성 패턴


그렇다면 CO-RE의 이점을 온전히 누리기 위해 eBPF C 코드를 작성할 때 어떤 패턴을 사용해야 할까?

1. 컴파일러 가이드 속성

vmlinux.h 파일 내부의 구조체 정의를 자세히 뜯어보면 모든 구조체 하단에 다음과 같은 속성이 부여되어 있는 것을 발견할 수 있다.

struct task_struct {
    // ...
} __attribute__((preserve_access_index));

preserve_access_index 속성은 Clang 컴파일러에게 **“이 구조체의 필드에 접근할 때는 고정 오프셋을 박지 말고, 나중에 바꿀 수 있도록 재배치 메타데이터(Relocation Record)를 생성해라”**라고 명령하는 지시어다. 이 속성 덕분에 우리는 별도의 특수 함수를 호출하지 않고 task->pid처럼 자연스럽게 멤버를 읽어도 알아서 CO-RE의 보호를 받게 된다.

2. 중첩 포인터 안전하게 읽기: BPF_CORE_READ()

하지만 안전하고 명시적으로 중첩된 커널 구조체의 데이터를 메모리 보호 오작동 없이 가져오기 위해선 Libbpf가 제공하는 CO-RE 헬퍼 매크로를 사용하는 것이 안전하다.

기존 BCC 코드에서는 프로세스의 부모 PID를 알기 위해 여러 단계를 수동으로 타고 들어갔다.

// 기존 BCC 방식 (컴파일러가 부착 지점에서 실시간 매핑)
struct task_struct *parent = task->real_parent;
u32 parent_pid = parent->pid;

CO-RE 환경에서는 체인 접근의 안전성을 더하고 중간 오프셋 조절을 일괄적으로 수행할 수 있도록 BPF_CORE_READ 매크로를 제공한다.

// CO-RE 전용 매크로 사용
u32 parent_pid = BPF_CORE_READ(task, real_parent, pid);

이 매크로는 내부적으로 구조체 필드 보정과 커널 메모리로부터 안전하게 값을 퍼 올리는 작업을 컴팩트하게 축약해 주는 도구다.

3. 런타임 조건부 대응: bpf_core_field_exists()

커널 버전에 따라 아예 어떤 필드가 구조체 안에서 사라지는 등 대대적인 구조 개편이 있을 수 있다. 이 경우 단순히 오프셋 조정만으로는 해결할 수 없는데, CO-RE는 조건부 컴파일 및 분기를 위한 런타임 탐지 매크로도 제공한다.

if (bpf_core_field_exists(task->new_fancy_field)) {
    // 새로운 커널 버전에 존재하는 신규 필드 사용
    u64 val = BPF_CORE_READ(task, new_fancy_field);
    ...
} else {
    // 구버전 커널 대응 대체 로직
    u32 val = BPF_CORE_READ(task, old_legacy_field);
    ...
}

이 기법을 사용하면 진정한 의미로 하나의 배포판 바이너리가 오래된 커널과 최신 커널 양쪽에서 예외 없이 안전하게 멀티 트래킹을 수행하도록 설계할 수 있다.

Libbpf와 BPF Skeletons


사용자 공간 로더 개발을 더 심플하게 바꾸어 준 패러다임이 바로 **BPF Skeleton(골격)**이다.

4장에서 보았듯, 수동으로 맵을 개방하고, 컴파일된 바이트코드를 읽고, 시스템 콜 속 구조체를 한 땀 한 땀 매칭해 로드하는 작업은 상당한 양의 상용구 코드(Boilerplate code) 작성을 요했다.

Libbpf 생태계는 이를 자동화하기 위해 bpftool을 사용해 eBPF 오브젝트 파일(.o)로부터 C 언어 뼈대 헤더를 컴파일 해내는 기능을 제공한다.

# eBPF 오브젝트로부터 사용자 공간용 Skeleton 생성
bpftool gen skeleton my_program.bpf.o > my_program.skel.h

이렇게 탄생한 스켈레톤 헤더(my_program.skel.h)는 eBPF 파일에 내장된 맵 정보, 전역 변수, 프로그램 포인터들을 고스란히 유저 공간의 구조체와 매핑해 주며, 다음과 같이 지극히 단순하고 명료한 사용자 공간 로더 작성을 도와준다.

// main.c (사용자 공간 로더 프로그램)
#include <stdio.h>
#include <unistd.h>
#include "my_program.skel.h"

int main() {
    struct my_program_bpf *skel;
    int err;

    // 1. 스켈레톤 객체 개방 (Open) 및 이식성 조율 준비
    skel = my_program_bpf__open();
    if (!skel) return 1;

    // 2. 오프셋 재배치 및 커널 로드 (Load)
    err = my_program_bpf__load(skel);
    if (err) goto cleanup;

    // 3. 이벤트 훅 부착 (Attach)
    err = my_program_bpf__attach(skel);
    if (err) goto cleanup;

    printf("eBPF 프로그램이 이식성을 보장받으며 안전하게 로드되었습니다!\n");

    // 주기적인 작업 수행 등...
    while (1) {
        sleep(1);
    }

cleanup:
    // 4. 자원 해제 및 언로드 (Destroy)
    my_program_bpf__destroy(skel);
    return 0;
}

몇 백 라인에 달하던 복잡한 리눅스 로우 레벨 파일 기술자(FD) 바인딩 및 이벤트 매핑 제어가 **open -> load -> attach -> destroy**라는 명료한 4단계 라이프사이클로 고도 수준으로 추상화되었다.

CO-RE 기반의 컴파일 및 배포 라이프사이클 요약


우리가 도달한 현대적 eBPF CO-RE 애플리케이션의 개발-배포의 거시적 흐름을 다이어그램으로 나타내면 다음과 같다.

  [ 개발 환경 (Build Server) ]
  ┌────────────────────────┐
  │   my_program.bpf.c     │  ── (vmlinux.h 와 함께 작성)
  └────────────────────────┘
               ▼ Clang Compiler (-target bpf)
  ┌────────────────────────┐
  │   my_program.bpf.o     │  ── (바이트코드 + BTF 재배치 정보 탑재)
  └────────────────────────┘
               ▼ bpftool gen skeleton
  ┌────────────────────────┐
  │   my_program.skel.h    │  ── (타입 세이프 로더용 C 스켈레톤 생성)
  └────────────────────────┘
               ▼ 빌드 및 사용자 공간 실행 파일과 링크
  ┌────────────────────────┐
  │     final_agent        │  ── (하나의 최종 패키지)
  └────────────────────────┘
               │ [ 네트워크를 통한 정적 배포 ]
               │ (타겟 서버에 컴파일러나 커널 헤더 설치 필요 없음!)
  [ 운영 환경 (Target Servers: 다양한 커널 버전) ]
  ┌─────────────────────────────────────────────────────────┐
  │   Server A (Kernel 5.4)   │   Server B (Kernel 5.15)    │
  ├───────────────────────────┼─────────────────────────────┤
  │ /sys/kernel/btf/vmlinux   │ /sys/kernel/btf/vmlinux     │
  │ (5.4 버전의 오프셋 맵)     │ (5.15 버전의 오프셋 맵)     │
  │                           │                             │
  │ ── Libbpf가 로드 직전     │ ── Libbpf가 로드 직전       │
  │    오프셋을 1200으로 보정 │    오프셋을 1240으로 보정   │
  └───────────────────────────┴─────────────────────────────┘

느낀점 / 결론


BCC 시절의 실시간 런타임 컴파일 방식은 혁신적이었지만 프로덕션 수준의 안정적이고 민첩한 대형 분산 시스템에 적용하기엔 의존성의 무게가 너무나도 버거웠다.

5장에서 탐구한 CO-RE는 컴파일러(Clang)의 똑똑한 메타데이터 배치 기술, 마이크로 단위의 구조 지도를 커널에 영구 보존하는 포맷(BTF), 그리고 배포 현장에서 신속하게 수술을 집도해 기계어를 맞춰 주는 스마트 로더(Libbpf)의 협업이 만들어 낸 소프트웨어 공학의 걸작에 가깝다.

이 장을 공부하며 가장 전율을 느꼈던 부분은 이식성을 얻기 위해 무언가 무거운 미들웨어를 얹거나 가상 머신 계층(VM)을 중첩해 두지 않았다는 점이다. 기존의 빌드 타임 오프셋을 ELF 파싱을 활용해 동적으로 “재패치"하고 커널이 검증하도록 만든 매우 미니멀하고 로우레벨다운 접근 방식이 감탄스럽다.

더불어, vmlinux.h와 BPF Skeleton 기술 덕분에 시스템 프로그래머의 생산성도 비약적으로 수직 상승하게 되었다. 복잡하고 지저분한 구조체 정합성 확인과 로딩 스크립트 작성에 시간과 노력을 낭비하지 않고, 알맹이가 되는 비즈니스 탐지 지표(eBPF 탐지 로직)와 모니터링 이벤트 생성 자체에만 집중할 수 있는 쾌적한 개발 생태계가 마침내 구축되었다고 평가할 수 있다.

이로써 eBPF는 단순히 커널을 엿보는 해커의 개인 장난감을 넘어, 진정으로 안전하고 신뢰할 수 있으며 이식 가능한 엔터프라이즈급 플랫폼 아키텍처로 완벽하게 자리를 잡았다.

참고 문헌


>> Home