한 번 컴파일해 어느 커널에서나 돈다
이 실습은 VM 에서 돕니다
우분투 24.04 VM 한 대입니다. clang 과 libbpf 를 설치하느라 처음 뜨는 데 2분 남짓
걸립니다. 커널 BTF(/sys/kernel/btf/vmlinux)가 켜져 있어서 CO-RE 를 그대로 쓸 수
있습니다.
목표
bpftrace 대신 libbpf 로 작은 도구를 처음부터 만듭니다. 커널 BTF 에서
vmlinux.h 를 뽑고, BPF_CORE_READ 로 task_struct 안을 읽는 BPF 프로그램을 쓰고,
bpftool gen skeleton 으로 만든 머리 파일을 써서 C 로더를 짓습니다. 로더는 프로그램을
올리고 붙이고 핀한 뒤 끝납니다. 마지막으로 이 프로그램이 커널의 어느 필드에 기대는지
min_core_btf 로 눈으로 확인합니다.
왜 중요한가
커널 구조체의 필드 위치는 판마다, 설정마다 다릅니다. 예전 도구(BCC)는 그래서 대상 서버에서 매번 커널 헤더를 받아 다시 컴파일했습니다. 서버마다 clang 과 헤더를 깔아야 했고 시작이 느렸습니다. CO-RE(Compile Once, Run Everywhere)는 컴파일할 때 "이 필드를 읽는다" 는 사실만 기록해 두고, 올리는 순간 libbpf 가 그 커널의 BTF 를 보고 위치를 고칩니다. Cilium·Falco·Tetragon 같은 현대 eBPF 도구가 바이너리 하나로 여러 배포판에서 도는 이유가 이것입니다. 이 구조를 알면 "이 커널에서만 안 붙는다" 는 문제를 조사할 수 있습니다.
단계
- 커널 BTF 에서
/root/kx/core/vmlinux.h를 뽑으세요. - exec 을 부모 프로세스(tgid) 별로 세는 BPF 프로그램
/root/kx/core/ppid.bpf.c를 쓰세요. /root/kx/core/ppid.bpf.o로 컴파일하세요. CO-RE 재배치 정보가 들어 있어야 합니다.- 스켈레톤 머리 파일
/root/kx/core/ppid.skel.h를 만드세요. - 로더
/root/kx/core/ppid.c를 쓰고/root/kx/core/ppid로 빌드하세요. - 로더를 실행해 링크와 맵을
/sys/fs/bpf/kx-core에 남기세요. - 이 프로그램이 기대는 커널 타입만 남긴 BTF 를
/root/kx/core/min.btf로 만들고 풀어/root/kx/core/07-min.txt에 저장하세요. - 정리를
/root/kx/core/08-report.md에 쓰세요.
참고
vmlinux.h를 쓰면<linux/…>헤더가 필요 없고, 따라서-I/usr/include/x86_64-linux-gnu도 필요 없습니다.BPF_CORE_READ(task, real_parent, tgid)는task->real_parent->tgid를 재배치할 수 있게 읽습니다. 그냥 화살표로 쓰면 검증기가 커널 메모리 직접 읽기를 거부합니다.- 스켈레톤의 이름은
bpftool gen skeleton <오브젝트> name <이름>으로 정합니다. 이름이ppid_bpf면 함수는ppid_bpf__open_and_load처럼 생깁니다. - 흔한 실수: 로더가 링크를 핀하지 않고 끝나는 것. 로더가 끝나는 순간 링크가 닫히고 프로그램이 떨어집니다. 맵만 남아 있어 겉보기에는 멀쩡합니다.
커널의 타입을 머리 파일로
bpftool btf dump file /sys/kernel/btf/vmlinux 를 C 형식으로 풀어 /root/kx/core/vmlinux.h 에 저장하세요.
format c 를 붙이면 C 머리 파일로, 붙이지 않으면 사람이 읽는 목록으로 나옵니다. 이 커널의 구조체 정의가 전부 들어 있어 수 MB 가 됩니다. struct task_struct { 가 들어 있는지 확인하세요.
BPF 프로그램을 쓴다
/root/kx/core/ppid.bpf.c 를 쓰세요. tracepoint/sched/sched_process_exec 에 붙는 함수 count_by_ppid 가 현재 태스크의 real_parent->tgid 를 BPF_CORE_READ 로 읽어, 해시 맵 ppid_count(키 __u32, 값 __u64)에서 그 키의 값을 1 늘려야 합니다. 머리 파일은 vmlinux.h 를 씁니다.
현재 태스크는 bpf_get_current_task() 로 얻습니다. 해시 맵은 키가 처음 나오면 칸이 없으니, 조회가 NULL 이면 1 을 넣고(bpf_map_update_elem) 아니면 늘립니다. 표본은 uid 로 세는 프로그램이니 키와 이름을 이 실습에 맞게 바꾸세요.
재배치 정보와 함께 컴파일한다
ppid.bpf.c 를 clang -O2 -g -target bpf 로 컴파일해 /root/kx/core/ppid.bpf.o 를 만드세요. 오브젝트에 .BTF 와 .BTF.ext 섹션이 모두 있어야 합니다.
.BTF.ext 에 CO-RE 재배치 기록(어느 명령이 어느 구조체의 어느 필드를 읽는지)이 들어갑니다. -g 가 없으면 생기지 않습니다. readelf -S 로 확인하세요.
스켈레톤을 만든다
bpftool gen skeleton 으로 ppid.bpf.o 에서 이름이 ppid_bpf 인 스켈레톤을 만들어 /root/kx/core/ppid.skel.h 에 저장하세요. 오브젝트를 다시 컴파일했다면 스켈레톤도 다시 만들어야 합니다.
스켈레톤은 오브젝트 파일을 통째로 머리 파일 안에 바이트 배열로 넣고, 맵과 프로그램에 이름으로 닿는 구조체와 __open_and_load·__attach·__destroy 함수를 만들어 줍니다. 오브젝트보다 오래된 스켈레톤은 옛 프로그램을 싣습니다.
로더를 짓는다
/root/kx/core/ppid.c 를 쓰고 -lbpf 로 링크해 /root/kx/core/ppid 를 만드세요. 로더는 스켈레톤으로 열고·올리고·붙인 뒤, 맵을 /sys/fs/bpf/kx-core/ppid_count 에, 링크를 /sys/fs/bpf/kx-core/link 에 핀하고 끝나야 합니다.
스켈레톤 구조체에는 maps.<맵 이름> 과 links.<프로그램 이름> 이 있습니다. 핀은 bpf_map__pin 과 bpf_link__pin 입니다. 링크를 핀하지 않으면 로더가 끝나는 순간 프로그램이 떨어집니다. 표본은 이름과 경로가 다른 로더입니다.
로더를 돌리고 숫자를 본다
/root/kx/core/ppid 를 실행해 링크와 맵이 /sys/fs/bpf/kx-core 에 남게 하세요. 로더는 끝나도 됩니다. 채점기는 셸 하나가 /bin/true 를 두 번 실행하게 한 뒤 그 셸의 PID 를 키로 맵을 조회해 2 이상인지 봅니다.
이미 핀이 있으면 로더의 핀이 실패합니다. 다시 돌리려면 /sys/fs/bpf/kx-core 의 두 파일을 먼저 rm 하세요. 확인은 sh -c '/bin/true; /bin/true; echo $$' 로 PID 를 얻고 bpftool map dump pinned /sys/fs/bpf/kx-core/ppid_count 에서 그 키를 찾으면 됩니다.
이 프로그램이 기대는 필드만
bpftool gen min_core_btf 로 /sys/kernel/btf/vmlinux 에서 ppid.bpf.o 가 쓰는 타입만 남긴 /root/kx/core/min.btf 를 만들고, 그것을 C 형식으로 풀어 /root/kx/core/07-min.txt 에 저장하세요.
이 명령은 원래 BTF 가 없는 옛 커널에 실어 보낼 작은 BTF 를 만드는 데 씁니다. 결과를 풀어 보면 이 프로그램이 커널의 어느 구조체, 어느 필드에 기대는지가 그대로 보입니다. 6MB 가 수백 바이트로 줄어듭니다.
CO-RE 가 한 일
/root/kx/core/08-report.md 에 ## BTF 가 주는 것·## 재배치가 한 일·## BCC 와 무엇이 다른가 세 절을 쓰세요. 본문에 BPF_CORE_READ·.BTF.ext·real_parent 가 나오고, min.btf 의 크기(바이트)를 그대로 인용해야 합니다.
크기는 stat -c %s /root/kx/core/min.btf 로 읽습니다. 둘째 절에는 컴파일할 때 기록된 것(필드 이름)과 올릴 때 바뀌는 것(필드 위치)을 나눠 적습니다. 셋째 절에는 대상 서버에 무엇이 필요 없어졌는지를 적습니다.