특정 프로세스가 어떤 시스템 콜을 얼마나 자주 호출하는지, 혹은 파일 하나를 여는 데 왜 갑자기 수십 밀리초가 걸리는지 알아내야 할 때 strace부터 떠올리기 쉽다. 하지만 strace는 매 시스템 콜마다 대상 프로세스를 멈추고 커널-유저 공간을 오가며 데이터를 복사하기 때문에 오버헤드가 커서, 트래픽이 몰리는 프로덕션 서버에 그대로 붙였다가는 문제를 재현하기도 전에 서비스가 느려질 수 있다. perf나 ftrace는 오버헤드가 훨씬 적지만 “이 조건을 만족할 때만 이 값을 기록하라” 같은 세밀한 로직을 짜기가 번거롭다. bpftrace는 이 둘의 중간 지점에 있는 도구다. eBPF 위에서 동작하는 고수준 트레이싱 언어로, awk와 비슷한 문법으로 커널/유저 공간 이벤트에 훅을 걸고 조건부 필터링과 집계까지 한 줄로 처리할 수 있으면서도 오버헤드는 낮게 유지한다. 이 글에서는 bpftrace의 핵심 개념과 실전에서 바로 쓸 수 있는 트레이싱 스크립트를 정리한다.
bpftrace 핵심 개념
bpftrace 스크립트는 probe / filter / { action } 형태의 규칙들로 구성된다. probe는 “언제 실행할지”, filter는 “어떤 조건에서만 실행할지”, action은 “무엇을 할지”를 정의한다. 자주 쓰는 probe 종류는 다음과 같다.
| probe 종류 | 문법 예시 | 용도 |
|---|---|---|
| kprobe / kretprobe | kprobe:vfs_read | 커널 함수 진입/반환 시점 후킹. 함수명이 바뀔 수 있어 커널 버전 간 이식성이 낮음 |
| tracepoint | tracepoint:syscalls:sys_enter_openat | 커널이 미리 정의해둔 안정적인 훅 포인트. kprobe보다 커널 버전 변화에 안전 |
| uprobe / uretprobe | uprobe:/bin/bash:readline | 유저 공간 바이너리의 함수 진입/반환 후킹 |
| profile | profile:hz:99 | 초당 N회 주기적으로 샘플링(CPU 프로파일링에 사용) |
| interval | interval:s:1 | 지정한 시간 간격마다 실행(집계 결과 주기적 출력에 사용) |
가능하면 kprobe보다 tracepoint를 우선 쓰는 편이 좋다. kprobe는 커널 내부 함수 이름과 시그니처에 직접 의존하므로 커널 마이너 버전만 바뀌어도 스크립트가 깨질 수 있지만, tracepoint는 커널이 안정적인 인터페이스로 제공하는 훅이라 버전 간 이식성이 훨씬 낫다.
집계에는 count(), sum(), hist() 같은 내장 map 함수를 쓴다. @변수명[키] 형태로 커널 안에 값을 저장해두고 스크립트가 끝날 때(또는 interval probe에서) 자동으로 출력한다.
설치와 실전 코드
Ubuntu/Debian 계열은 패키지로 바로 설치할 수 있다. BTF(BPF Type Format)를 지원하는 최신 커널(5.4 이상 권장)이면 별도로 커널 헤더를 맞출 필요 없이 바로 동작한다.
sudo apt update
sudo apt install -y bpftrace
sudo bpftrace --version
가장 간단한 예로, openat 시스템 콜을 호출하는 프로세스와 파일 경로를 실시간으로 찍어보자. tracepoint의 인자는 args로 접근하고, 문자열 포인터는 str()로 감싸야 사람이 읽을 수 있는 형태로 변환된다.
sudo bpftrace -e '
tracepoint:syscalls:sys_enter_openat
{
printf("%-16s %-6d %s\n", comm, pid, str(args.filename));
}'
여기서 comm과 pid는 어떤 probe에서든 바로 쓸 수 있는 내장 변수다. 다음은 시스템 전체에서 어떤 프로세스가 시스템 콜을 가장 많이 호출하는지 5초간 집계하는 스크립트다.
sudo timeout 5 bpftrace -e '
tracepoint:raw_syscalls:sys_enter
{
@[comm] = count();
}'
실행하면 5초 뒤 스크립트가 종료되면서 map에 쌓인 결과를 프로세스 이름별 호출 횟수로 출력한다.
Attaching 1 probe...
@[sshd]: 812
@[nginx]: 4031
@[postgres]: 15872
한 줄짜리 원라이너를 넘어, 특정 함수의 지연시간을 히스토그램으로 뽑아내려면 kprobe/kretprobe 쌍을 이용해 진입 시각과 반환 시각의 차이를 계산해야 한다. 이런 스크립트는 .bt 파일로 저장해 관리하는 편이 낫다.
#!/usr/bin/env bpftrace
// vfs_read_latency.bt — vfs_read() 호출 지연시간을 프로세스별 히스토그램으로 출력
kprobe:vfs_read
{
@start[tid] = nsecs;
}
kretprobe:vfs_read
/@start[tid]/
{
@latency_ns[comm] = hist(nsecs - @start[tid]);
delete(@start[tid]);
}
interval:s:10
{
exit();
}
/@start[tid]/는 필터(filter)로, 해당 스레드에 대한 시작 시각이 기록돼 있을 때만 kretprobe 블록을 실행한다. 스레드 진입 시점을 기록하지 않은 상태(예: 스크립트 시작 전에 이미 vfs_read 안에 들어가 있던 호출)에서 반환만 잡히는 경우를 걸러내기 위한 안전장치다. 실행 권한을 주고 10초간 돌리면 다음과 같은 히스토그램이 출력된다.
chmod +x vfs_read_latency.bt
sudo ./vfs_read_latency.bt
Attaching 3 probes...
@latency_ns[nginx]:
[256, 512) 3 |@ |
[512, 1K) 47 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@|
[1K, 2K) 12 |@@@@@@@@@@ |
[2K, 4K) 1 | |
@latency_ns[postgres]:
[512, 1K) 5 |@@@@ |
[1K, 2K) 61 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@|
[2K, 4K) 9 |@@@@@@ |
[8K, 16K) 2 |@ |
대부분의 호출은 1K~2K 나노초 구간에 몰려 있지만 8K~16K 구간처럼 꼬리(tail)에 튀는 값이 있다면, 그 시점의 다른 지표(디스크 큐 깊이, 락 경합 등)와 함께 봐야 원인을 좁힐 수 있다. 이런 식으로 특정 구간에만 필터를 걸어 더 깊이 파고드는 것이 strace로 매 호출을 다 찍어보는 것보다 훨씬 빠르게 병목 후보를 좁히는 방법이다.
주의사항
root 권한 또는 CAP_BPF/CAP_PERFMON이 필요하다: bpftrace는 커널에 eBPF 프로그램을 로드하므로 일반 사용자 권한으로는 실행할 수 없다. 컨테이너 안에서 실행하려면 호스트 커널에 접근할 수 있도록 특권 컨테이너로 띄우거나 필요한 capability를 추가해야 한다.
BTF가 없는 구형 커널은 별도 준비가 필요하다: BTF를 지원하지 않는 커널에서는 linux-headers-$(uname -r) 패키지를 설치해 CO-RE(Compile Once – Run Everywhere) 없이도 동작하도록 맞춰야 한다. /sys/kernel/btf/vmlinux 파일이 존재하는지로 BTF 지원 여부를 먼저 확인하는 것이 좋다.
kprobe는 커널 버전에 따라 깨질 수 있다: vfs_read 같은 내부 함수는 공식 ABI가 아니라서 커널 버전이 바뀌면 인라인化되거나 이름이 바뀔 수 있다. 가능하면 tracepoint:syscalls:*처럼 안정적인 tracepoint를 우선 쓰고, kprobe는 tracepoint가 없는 경우의 대안으로 남겨두는 편이 스크립트 수명을 늘리는 데 유리하다.
필터 없이 broad probe를 걸면 오버헤드가 커진다: tracepoint:raw_syscalls:sys_enter처럼 모든 프로세스의 모든 시스템 콜에 훅을 거는 probe는 트래픽이 많은 프로덕션 서버에서 체감될 정도의 부하를 만들 수 있다. /pid == 1234/처럼 대상 PID로 필터를 좁히거나, timeout으로 실행 시간을 제한해 영향을 최소화해야 한다.
map 크기는 무한정 늘어날 수 있다: @[comm] = count()처럼 키 공간이 넓은 map을 오래 돌리면 커널 메모리를 계속 소비한다. 장시간 실행하는 스크립트라면 interval probe에서 주기적으로 print() 후 clear()로 map을 비우는 패턴을 써야 한다.
마무리
bpftrace는 strace처럼 매 호출을 정지시키지 않으면서도 perf/ftrace보다 훨씬 유연하게 조건과 집계 로직을 짤 수 있는 도구다. tracepoint 위주로 스크립트를 짜고 필요한 만큼만 필터를 좁히면, 프로덕션 환경에서도 부담 없이 붙여 원인을 좁혀갈 수 있다. 원라이너로 감을 잡고, 반복적으로 쓰는 진단 로직은 .bt 스크립트로 저장해두면 다음번 장애 대응 때 바로 재사용할 수 있다.