eBPF - libbpf CO-RE의 CONFIG_DEBUG_INFO_BTF 빌드 여부에 대한 의존성
libbpf가 지원하는 CO-RE(Compile Once, Run Everywhere)의 핵심은 BTF(Binary Type Format) 정보를 이용하는 것입니다. 바로 그런 특성으로 인해 커널의 CONFIG_DEBUG_INFO_BTF 빌드 또는 BTF 정보를 별도로 설치하는 작업이 필요한 것인데요.
물론 libbpf를 사용해도 BTF에 의존하지 않게 만들 수도 있습니다. 즉, CO-RE를 사용하지 않는 방향으로 코드를 작성하면 되는데, 어떤 차이점을 갖는지 한번 볼까요? ^^
예를 들어 아래의 eBPF 코드는,
//go:build ignore
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
#include <bpf/bpf_core_read.h>
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__type(key, uint32_t);
__type(value, uint32_t);
__uint(max_entries, 1);
} my_hash_map SEC(".maps");
volatile const __u32 const_u32 = 50;
volatile __u32 arg_u32 = 10;
SEC("socket") int const_example() {
return const_u32;
}
SEC("tracepoint/syscalls/sys_enter_close")
int sys_enter_close(struct trace_event_raw_sys_enter *ctx) {
bpf_printk("sys_enter_close called: %d", const_u32);
return 0;
}
char __license[] SEC("license") = "GPL";
Map도 있고, 전역 변수도 갖춰져 있는 제법 기본적인 구색은 갖추고 있는데
CONFIG_DEBUG_INFO_BTF 빌드가 아닌 환경에서도 잘 실행이 됩니다. 왜냐하면, 위의 eBPF 코드에서는 어떠한 BTF 정보도 참조하지 않고 있기 때문입니다.
하지만 이 상태에서, trace_event_raw_sys_enter의 인자 중에 있는 args를 읽으려는 코드를 넣는다면?
SEC("tracepoint/syscalls/sys_enter_close")
int sys_enter_close(struct trace_event_raw_sys_enter *ctx) {
__u64 fd = BPF_CORE_READ(ctx, args[0]);
bpf_printk("sys_enter_close called: %d", fd);
return 0;
}
또는, 이런 식으로 풀어서 작성해도,
__u64 fd = 0;
bpf_probe_read_kernel(&fd, sizeof(__u64), ctx->args);
이제는 CONFIG_DEBUG_INFO_BTF가 없는 환경이라면 (libbpf 방식의 bpf2go로 작성한) go 측에서 eBPF 모듈 로딩 시 이런 오류가 발생합니다.
program sys_enter_close: apply CO-RE relocations: load kernel spec: btf: not found
왜냐하면, vmlinux BTF에 포함된 trace_event_raw_sys_enter 정보에서 args를 참조하기 때문입니다.
$ grep -A 5 "struct trace_event_raw_sys_enter {" vmlinux.h
struct trace_event_raw_sys_enter {
struct trace_entry ent;
long int id;
long unsigned int args[6];
char __data[0];
};
자, 그럼 위의 상태에서 BTF를 참조하지 않는 방향으로 코드를 작성하면 어떨까요? 그렇다면 libbpf 방식 역시 CONFIG_DEBUG_INFO_BTF가 없는 환경에서도 잘 동작할 것입니다.
가령 위와 같은 trace_event_raw_sys_enter의 경우,
TRACE_EVENT 매크로에 따른 구조로 정의돼 있다고 설명했었는데요,
type (2바이트) == common_type
flags (1바이트) == common_flags
preempt_count (1바이트) == common_preempt_count
pid (4바이트) == common_pid
id (8바이트) == __syscall_nr (4바이트) + 패딩(4바이트)
args[6] (48바이트) != fd(8바이트) + uservaddr 포인터(8바이트) + addrlen(8바이트), 총 24바이트
args[0] == fd
args[1] == uservaddr
args[2] == addrlen
__data[0]
여기서 우리가 원하는 필드가 fd라면, 저 위치만 맞춰주는 구조체를 직접 정의해 사용하면 그만입니다.
struct trace_event_raw_sys_enter_close_stub {
__u64 unused1; // type (2바이트) + flags (1바이트) + preempt_count (1바이트) + pid (4바이트)
__u64 unused2; // id (8바이트)
__u64 fd; // args[0]번 위치
}
그다음, 이걸 가지고 eBPF 코드를 작성하면,
SEC("tracepoint/syscalls/sys_enter_close")
int sys_enter_close(void* ctx) {
struct trace_event_raw_sys_enter_close_stub close_arg = {};
if (bpf_probe_read(&close_arg, sizeof(close_arg), ctx) < 0) {
return 0;
}
__u64 fd = close_arg.fd;
bpf_printk("sys_enter_close called: %d", fd);
return 0;
}
저 코드는 CONFIG_DEBUG_INFO_BTF가 없는 환경에서도 잘 동작합니다. 차이점을 대충 아시겠죠? ^^
물론, trace_event_raw_sys_enter_close_stub과 같은 구조체를 대상 커널 구조체에 일치하는 형태로 만들면 CO-RE의 혜택이 없습니다. 다시 말해, 만약 향후 커널, 또는 다른 커널에서 "struct trace_event_raw_sys_enter"의 정의를 다음과 같이 바꾼다면,
struct trace_event_raw_sys_enter {
struct trace_entry ent;
long int id;
long int extension;
long unsigned int args[6];
char __data[0];
};
BTF 없이 만들었던 코드에서는 args[0]번 필드를 접근하지 못하고, 그 위치를 대신하고 있는 extension 값을 읽게 돼 결국 프로그램은 의도치 않은 동작을 하게 됩니다.
반면, vmlinux BTF에 의존해 만들었다면 args 필드를 참조할 때 eBPF가 적재되면서 자동으로 extension 필드를 건너 뛴 args를 참조하게 되는 CO-RE의 혜택을 받게 됩니다.
그러니까, 서로 장단점이 있는 것입니다. BTF 의존성 없이 만들면 보다 많은 상황에서 동작은 하겠지만 자칫 커널의 구조체가 바뀌었을 때는 오동작할 여지가 있습니다. 반면 BTF 의존성을 갖게 만들면 대상 운영체제의 BTF 설정은 필요하지만 대신 커널 구조체가 바뀌어도 자동으로 대응할 수 있습니다.
이런 것을 감안했을 때 현실적인 기준으로 보면, trace_event_raw_sys_enter와 같은 커널 구조체는 거의 바뀌지 않는다고 기대할 수 있으므로 BTF 의존성 없이 만들어도 나쁘지 않은 선택일 수 있습니다. 하지만, 이게 거의 불가능한 경우도 있는데요, 단적인 예로 task_struct를 건드리는 코드가 대표적입니다.
struct task_struct* current_task = (struct task_struct*)bpf_get_current_task();
struct task_struct* parent_task;
bpf_probe_read(&parent_task, sizeof(parent_task), &task->real_parent);
위의 경우라면, BTF 의존성을 갖는 경우 vmlinux.h에 정의된 task_struct 구조체를 참조하면서 빌드도 자연스럽고, 이후 CO-RE의 혜택으로 실행도 (
필드가 없어지지만 않는다면) 보장이 됩니다.
하지만, BTF 의존성을 없애려고 task_struct를 real_parent 필드까지만 정의한 구조체로 정의하려고 해도,
linux/include/linux/sched.h
; https://github.com/torvalds/linux/blob/master/include/linux/sched.h#L778
위의 task_struct 정의에서 보듯이 수많은 #ifdef CONFIG_... 정의에 따라 바뀔 수 있으므로 다양한 환경에 대응할 수 없습니다. 아마도, 특정 시스템을 타깃팅하지 않는 경우를 제외하고는 저것을 BTF 의존성 없이 만들 장점이 전혀 없을 텐데요, 즉, BTF 의존성을 없애려고 했다가 오히려 더 많은 의존성 문제를 낳게 될 수 있는 것입니다.
저렇게 보면, libbcc의 방식도 나쁘지 않은 선택일 수 있습니다. 그런 경우라면 task_struct의 필드 접근 코드를 대상 컴퓨터에서 eBPF 코드를 컴파일할 때 자동으로 맞춰서 바꿔주는 방식이기 때문에 오히려 BTF 의존성 없이 만들어야 하면서 범용성을 갖고 싶은 경우 고려할 수 있는 선택지 중의 하나가 됩니다.
실제로
libbcc 예제 코드에서는 task_struct에 대한 접근을 예사로 하는 코드를 종종 볼 수 있는 이유가 있던 것입니다.
정리해 보면, libbcc 또는 libbpf CO-RE를 선택하는 기준은 분명합니다. 대상 시스템에 BTF 의존성을 갖거나, clang/libbcc 의존성을 갖거나!
[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]