eBPF와 bpftrace 완벽 가이드: 애플리케이션 성능 분석 및 병목점 찾기

초보자부터 전문가까지, eBPF와 bpftrace를 활용해 리눅스 애플리케이션의 성능 병목을 실시간으로 분석하고 해결하는 방법을 단계별로 배워보세요. 설치부터 실전 예제, 문제 해결까지 모든 것을 다룹니다.
인프라코디

현대의 복잡한 분산 시스템 환경에서 애플리케이션 성능 저하의 원인을 찾는 것은 마치 '유령을 쫓는' 것과 같습니다. 수많은 마이크로서비스와 인프라 계층 속에서 병목 지점을 정확히 식별하기란 여간 어려운 일이 아닙니다. top, iostat 같은 전통적인 도구들은 시스템의 전반적인 상태를 파악하는 데 유용하지만, 실시간으로 발생하는 미세한 문제나 프로덕션 환경에 영향을 주지 않고 깊이 있는 분석을 수행하기에는 한계가 명확합니다.

바로 이 지점에서 리눅스 커널의 혁신적인 기술, eBPF(extended Berkeley Packet Filter)가 등장합니다. eBPF는 단순히 도구가 아니라, 커널 코드를 직접 수정하거나 위험한 커널 모듈을 로드하지 않고도 안전하게 샌드박스 프로그램을 커널 내에서 실행할 수 있게 해주는 '커널 가상 머신'과 같습니다. 이 기술은 안전성성능이라는 두 가지 핵심 가치를 기반으로 합니다.

  1. 안전성 (Safety): eBPF 프로그램은 커널에 로드되기 전 '검증기(Verifier)'에 의해 무한 루프, 메모리 침범, 시스템을 불안정하게 만들 수 있는 모든 위험한 코드가 있는지 철저히 검사받습니다. 이 검증 과정을 통과해야만 실행이 허가되므로 프로덕션 환경에서도 안심하고 사용할 수 있습니다.
  2. 성능 (Performance): 검증을 통과한 eBPF 바이트코드는 JIT(Just-In-Time) 컴파일러를 통해 네이티브 머신 코드로 변환되어 거의 성능 저하 없이 실행됩니다. 이는 strace와 같이 시스템 콜을 가로챌 때 발생하는 막대한 오버헤드와는 차원이 다른 효율성을 보장합니다.

하지만 eBPF의 강력한 기능을 직접 사용하려면 복잡한 C 코드를 작성하고 컴파일해야 하는 등 진입 장벽이 높았습니다. 이때 bpftrace가 등장하며 모든 것을 바꾸었습니다. bpftraceawkDTrace에서 영감을 받은 고수준 스크립트 언어로, 단 몇 줄의 간단한 코드로 eBPF의 모든 강력함을 활용할 수 있게 해주는 마법 같은 도구입니다. 복잡한 컴파일과 로딩 과정은 bpftrace가 내부적으로 LLVM과 BCC(BPF Compiler Collection) 툴킷을 통해 모두 처리해 줍니다.

이러한 패러다임의 전환은 과거의 '사후 분석(Post-mortem)'에서 벗어나, 시스템에 거의 영향을 주지 않으면서 '지금, 여기에서' 무슨 일이 벌어지고 있는지 실시간으로 관찰하는 시대를 열었습니다. 더 이상 커널 전문가가 아니더라도, 시스템 관리자나 애플리케이션 개발자 누구나 커널 수준의 깊이 있는 분석을 수행할 수 있게 된 것입니다.

빠른 답변 (TL;DR): 이 튜토리얼의 목표

이 튜토리얼은 eBPFbpftrace를 설치하고 활용하여 리눅스 시스템과 애플리케이션을 실시간으로 안전하게 추적하는 방법을 안내합니다. 이 가이드를 끝까지 따라오시면, 디스크 I/O 병목 현상을 진단하고, CPU 스케줄러 지연 시간을 분석하며, 특정 애플리케이션 함수의 성능을 정밀하게 측정하는 실전 기술을 습득하게 될 것입니다.

사전 준비: 트레이싱 환경 구축하기

본격적인 튜토리얼을 시작하기 전에, 원활한 실습을 위해 다음 환경이 준비되어 있는지 확인해 주십시오.

  • 리눅스 시스템: 최신 기능을 원활하게 사용하기 위해 비교적 현대적인 리눅스 배포판을 권장합니다. (예: Ubuntu 20.04+, RHEL/CentOS 8+, Fedora 32+ 등)
  • 커널 버전: eBPF의 주요 기능들은 리눅스 커널 4.x 버전대에서 점진적으로 추가되었습니다. 폭넓은 기능을 문제없이 사용하려면 커널 4.9 이상을 사용하는 것이 좋습니다. 터미널에서 uname -r 명령어로 현재 커널 버전을 확인할 수 있습니다.
  • 관리자 권한: bpftrace는 커널에 프로그램을 로드하고 실행해야 하므로 root 또는 sudo 권한이 반드시 필요합니다. 이는 시스템 보안을 위한 필수적인 조치입니다.
  • 필수 패키지: bpftrace와 현재 실행 중인 커널에 맞는 kernel-headers 패키지가 필요합니다. 이 패키지들은 다음 단계에서 자세히 설치할 것입니다.

bpftrace 설치 및 환경 검증

모든 준비가 끝났다면, 이제 bpftrace를 설치하고 정상적으로 동작하는지 확인할 차례입니다. 이 단계는 간단하지만, 가장 흔한 문제가 발생하는 구간이기도 하니 집중해서 따라와 주시기 바랍니다.

bpftrace 패키지 설치

사용 중인 리눅스 배포판에 맞는 명령어를 터미널에 입력하여 bpftrace를 설치합니다.

Debian / Ubuntu 기반 시스템:

sudo apt-get update
sudo apt-get install -y bpftrace

RHEL / CentOS / Fedora 기반 시스템:

sudo dnf install -y bpftrace

가장 중요한 단계: 커널 헤더 설치

제 경험상, bpftrace 사용 시 겪는 문제의 90%는 바로 이 커널 헤더와 관련이 있습니다. bpftrace는 스크립트를 실행하는 시점에 현재 실행 중인 커널에 맞춰 즉석에서 eBPF 프로그램을 컴파일합니다. 이때 컴파일러는 task_struct와 같은 커널 내부 데이터 구조체의 정확한 정의를 알아야 하는데, 이 정보를 담고 있는 것이 바로 커널 헤더 파일입니다.

따라서, 현재 실행 중인 커널과 버전이 정확히 일치하는 헤더 파일을 설치해야 합니다. uname -r 명령어는 현재 커널 버전을 출력해주므로, 이를 활용하면 아주 쉽게 설치할 수 있습니다.

Debian / Ubuntu 기반 시스템:

sudo apt-get install -y linux-headers-$(uname -r)

RHEL / CentOS / Fedora 기반 시스템:

sudo dnf install -y kernel-devel-$(uname -r)

이 단계가 왜 그토록 중요한지 이해하는 것은 bpftrace의 동작 원리를 이해하는 첫걸음입니다. 커널 헤더가 없다는 것은 eBPF 프로그램을 만들 '설계도'가 없는 것과 같으며, 이는 bpftrace가 동적으로 여러분의 시스템에 맞는 맞춤형 추적 도구를 만들어내는 핵심적인 과정이기 때문입니다.

설치 검증

이제 모든 것이 올바르게 설정되었는지 간단한 명령어로 확인해 보겠습니다.

먼저, 가장 기본적인 "Hello World"를 실행해 봅니다.

sudo bpftrace -e 'BEGIN { printf("bpftrace is working!\n"); }'

다음과 같은 출력이 보이면 bpftrace 자체는 성공적으로 설치된 것입니다.

실행 결과
Attaching 1 probe...
bpftrace is working!
^C

(Ctrl+C를 눌러 종료합니다.)

다음으로, 실제로 커널에 프로브를 부착할 수 있는지 확인합니다. 아래 명령어는 1초마다 시스템에서 발생하는 모든 시스템 콜(syscall)의 개수를 세어 출력합니다.

sudo bpftrace -e 'tracepoint:raw_syscalls:sys_enter { @ = count(); } interval:s:1 { print(@); clear(@); }'

다음과 유사하게 매초 숫자가 출력된다면, 여러분의 bpftrace 환경은 완벽하게 준비된 것입니다.

실행 결과
Attaching 2 probes...
@: 15432
@: 17890
@: 12345
^C

bpftrace 기초 다지기: 문법부터 핵심 개념까지

bpftrace의 강력함은 간결한 문법에 있습니다. 몇 가지 핵심 개념만 이해하면 복잡한 시스템 동작도 손쉽게 분석할 수 있습니다.

bpftrace 원-라이너(One-Liner) 해부하기

bpftrace 스크립트는 대부분 다음 구조를 따릅니다.

probe[,probe,...] /filter/ { action }

  • 프로브 (Probe): 무엇을 추적할지 정의하는 이벤트 지점입니다. (예: kprobe:vfs_read - vfs_read 커널 함수가 호출될 때)
  • 필터 (Filter): 언제 추적할지 결정하는 조건문입니다. (예: /pid == 1234/ - 프로세스 ID가 1234일 때만)
  • 액션 (Action): 이벤트가 발생하고 필터 조건을 만족했을 때 무엇을 할지 정의하는 코드 블록입니다. (예: { @reads = count(); } - @reads라는 맵의 카운트를 1 증가시킴)

추적 가능한 이벤트 찾기: 프로브 목록 확인

시스템에서 어떤 이벤트를 추적할 수 있는지 궁금하다면 -l 옵션을 사용해 검색할 수 있습니다. 와일드카드(*)를 사용하면 더욱 편리합니다.

예를 들어, 시스템 콜(syscall)과 관련된 모든 진입(enter) 트레이스포인트를 찾아보겠습니다.

sudo bpftrace -l 'tracepoint:syscalls:sys_enter_*'

수많은 목록이 출력될 것이며, 이를 통해 open, read, write 등 우리가 추적할 수 있는 다양한 이벤트 지점을 확인할 수 있습니다.

첫 번째 트레이스: 파일 열기 감시

가장 직관적인 예제로 시스템에서 어떤 파일이 열리는지 실시간으로 감시해 보겠습니다.

sudo bpftrace -e 'tracepoint:syscalls:sys_enter_openat { printf("Opening file: %s by %s (PID: %d)\n", str(args.filename), comm, pid); }'
  • tracepoint:syscalls:sys_enter_openat: openat 시스템 콜이 호출될 때마다 이벤트를 발생시키는 프로브입니다.
  • printf(...): C언어와 유사한 형식으로 문자열을 출력합니다.
  • str(args.filename): openat 시스템 콜의 인자 중 하나인 filename은 메모리 주소(포인터)입니다. str() 함수는 이 포인터가 가리키는 실제 문자열을 가져옵니다.
  • comm, pid: 현재 프로세스의 이름과 ID를 나타내는 bpftrace 내장 변수입니다.

데이터 집계의 핵심: BPF 맵(Map)

bpftrace 스크립트는 이벤트가 발생할 때마다 실행되고 사라지는 일회성 프로그램입니다. 그렇다면 여러 이벤트에 걸쳐 데이터를 집계하거나 상태를 저장하려면 어떻게 해야 할까요? 바로 이때 BPF 맵을 사용합니다.

BPF 맵은 커널과 사용자 공간 사이에 데이터를 안전하게 공유하기 위한 핵심적인 다리 역할을 하는 키-값(key-value) 저장소입니다. bpftrace에서는 @ 기호로 시작하는 변수가 바로 BPF 맵입니다.

가장 간단한 예로, 어떤 프로세스가 시스템 콜을 가장 많이 호출하는지 집계해 보겠습니다.

sudo bpftrace -e 'tracepoint:raw_syscalls:sys_enter { @[comm] = count(); }'

이 명령어를 실행하고 몇 초 뒤 Ctrl+C로 종료하면, 각 프로세스 이름(comm)을 키로, 시스템 콜 호출 횟수를 값으로 하는 맵이 정렬되어 출력됩니다. 이처럼 맵은 커널에서 발생한 수많은 이벤트를 의미 있는 통계 데이터로 변환하는 핵심 도구입니다.

프로브 유형 설명 및 주요 사용 사례
tracepoint 커널 개발자들이 안정성을 보장하며 심어놓은 정적 추적 지점입니다. 커널 버전이 바뀌어도 거의 변경되지 않아 가장 안정적입니다. 시스템 콜, 스케줄링, 디스크 I/O 등 일반적인 이벤트를 추적할 때 우선적으로 사용하세요.
kprobe/kretprobe 커널 내의 거의 모든 함수 시작(kprobe)과 반환(kretprobe) 지점을 동적으로 추적합니다. 매우 강력하지만 커널 업데이트 시 함수 이름이나 구조가 바뀌면 스크립트가 동작하지 않을 수 있습니다. 특정 커널 함수의 동작을 분석할 때 사용합니다.
uprobe/uretprobe kprobe와 유사하지만, 사용자 공간의 애플리케이션이나 라이브러리 함수를 추적합니다. 내 애플리케이션의 특정 함수 성능을 측정하거나 디버깅할 때 사용합니다.
usdt User-level Statically Defined Tracing. 애플리케이션 개발자가 코드에 직접 심어놓은 정적 프로브입니다. uprobe보다 안정적이고 풍부한 정보를 제공하지만, 애플리케이션이 USDT 프로브를 포함하여 빌드되어야 합니다. (예: MySQL, Node.js, Python)
interval/profile 시간 기반으로 이벤트를 발생시킵니다. interval은 지정된 시간 간격마다, profile은 지정된 빈도(Hz)로 모든 CPU에서 이벤트를 발생시킵니다. 주기적인 데이터 출력이나 CPU 사용률 프로파일링에 사용합니다.
항목 설명
pid, tid, uid, gid 프로세스 ID, 스레드 ID, 사용자 ID, 그룹 ID
comm 현재 프로세스의 이름 (최대 16바이트)
nsecs 부팅 후 현재까지의 시간 (나노초 단위 타임스탬프)
cpu 현재 코드가 실행 중인 CPU 코어 번호
arg0, arg1,... kprobe/uprobe에서 함수에 전달된 인수 (레지스터 기반)
retval kretprobe/uretprobe에서 함수의 반환 값
args tracepoint에서 이벤트에 포함된 인자들의 구조체
count() 맵의 값을 1씩 증가시키는 집계 함수
sum(값) 맵의 값에 지정된 '값'을 누적하여 더하는 함수
hist(값) 값을 로그 스케일(2의 거듭제곱) 분포로 표현하는 히스토그램을 생성
lhist(값, 최소, 최대, 단계) 값을 선형 분포로 표현하는 히스토그램을 생성
str(포인터) 메모리 주소(포인터)가 가리키는 C 스타일 문자열을 반환
ntop(...) 네트워크 주소 체계를 사람이 읽을 수 있는 IP 주소 문자열로 변환
delete(@맵[키]) 맵에서 특정 키-값 쌍을 삭제

시스템 전반의 병목 지점 찾기 (커널 프로브 활용)

이제 기본기를 익혔으니, 시스템 성능에 큰 영향을 미치는 주요 영역들을 분석하는 실전 예제들을 살펴보겠습니다. 여기서 핵심은 '시작' 이벤트와 '종료' 이벤트를 연결하여 지연 시간(latency)을 측정하는 패턴을 이해하는 것입니다.

사용 사례: 디스크 I/O가 문제일까?

애플리케이션이 느려질 때 가장 먼저 의심하는 곳 중 하나가 바로 디스크 I/O입니다. bpftrace를 사용하면 각 I/O 요청이 디스크에 전달되어 완료되기까지 얼마나 걸리는지 정밀하게 측정할 수 있습니다.

  • 목표: 블록 디바이스(HDD, SSD 등)의 I/O 처리 지연 시간을 마이크로초 단위로 측정하고 히스토그램으로 시각화합니다.
  • 스크립트:
    sudo bpftrace -e '
    tracepoint:block:block_rq_issue { 
        @start[args.dev, args.sector] = nsecs; 
    } 
    
    tracepoint:block:block_rq_complete /@start[args.dev, args.sector]/ { 
        @latency_us = hist((nsecs - @start[args.dev, args.sector]) / 1000); 
        delete(@start[args.dev, args.sector]); 
    }'
  • 스크립트 해설:
    1. tracepoint:block:block_rq_issue: 커널이 디스크에 I/O 요청을 '발행(issue)'하는 시점에 첫 번째 프로브가 실행됩니다.
    2. @start[args.dev, args.sector] = nsecs;: @start 맵에 현재 시간을 저장합니다. 이때 키(key)로 디바이스 번호(args.dev)와 섹터 위치(args.sector)를 함께 사용합니다. 이는 동시에 발생하는 수많은 I/O 요청들을 서로 구분하기 위한 고유 식별자 역할을 합니다.
    3. tracepoint:block:block_rq_complete: 해당 I/O 요청이 '완료(complete)'되면 두 번째 프로브가 실행됩니다.
    4. /@start[...]/: 이 필터는 @start 맵에 해당 요청에 대한 시작 타임스탬프가 기록된 경우에만 액션 블록을 실행하도록 합니다.
    5. @latency_us = hist(...): 현재 시간(nsecs)에서 시작 시간을 빼서 지연 시간을 계산하고, 1000으로 나누어 마이크로초(us) 단위로 변환한 뒤, 그 결과를 @latency_us 히스토그램 맵에 기록합니다.
    6. delete(...): 측정이 끝난 항목은 맵에서 삭제하여 메모리를 절약합니다.
  • 결과 해석: Ctrl+C로 종료하면 다음과 같은 히스토그램이 출력됩니다. 이를 통해 I/O 요청 대부분이 몇 마이크로초 내에 처리되는지, 혹은 특정 구간에 지연이 몰려 있는지 한눈에 파악할 수 있습니다. 만약 분포가 오른쪽(높은 숫자)으로 길게 꼬리를 문다면 디스크 성능 병목을 의심해볼 수 있습니다.
    실행 결과
    @latency_us:
    [4, 8) 10 |@@@@@ |
    [8, 16) 20 |@@@@@@@@@@ |
    [16, 32) 15 |@@@@@@@ |
    [32, 64) 8 |@@@@ |
    [64, 128) 42 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@|
    [128, 256) 5 |@@ |

사용 사례: 프로세스들이 CPU를 기다리고 있는가?

CPU 사용률이 높지 않은데도 시스템이 느리게 느껴진다면, 프로세스들이 CPU를 사용하고 싶지만 다른 프로세스에 밀려 대기하는 '스케줄러 지연'이 원인일 수 있습니다.

  • 목표: 특정 프로세스가 실행 가능한 상태(runnable)가 된 후, 실제로 CPU를 할당받기까지 대기하는 시간을 측정합니다.
  • 스크립트:
    sudo bpftrace -e '
    tracepoint:sched:sched_wakeup { 
        @start[args.pid] = nsecs; 
    } 
    
    tracepoint:sched:sched_switch /@start[args.pid]/ { 
        @latency_us[args.prev_comm] = hist((nsecs - @start[args.pid]) / 1000);
        delete(@start[args.pid]);
    }'
  • 스크립트 해설:
    1. tracepoint:sched:sched_wakeup: 특정 프로세스(args.pid)가 잠든 상태에서 깨어나 실행 대기열(run queue)에 들어가는 시점에 첫 번째 프로브가 실행됩니다. 이때 시작 시간을 기록합니다.
    2. tracepoint:sched:sched_switch: 스케줄러가 CPU를 할당할 프로세스를 교체할 때 두 번째 프로브가 실행됩니다.
    3. /@start[args.pid]/: 만약 현재 CPU를 할당받는 프로세스(args.pidsched_switch에서 다음에 실행될 프로세스를 의미)가 이전에 깨어난 기록이 있다면, 필터를 통과합니다.
    4. @latency_us[args.prev_comm] = hist(...): '깨어난 시간'부터 '실행 시작 시간'까지의 차이를 계산하여 'CPU를 양보한 프로세스'(args.prev_comm)별로 히스토그램에 기록합니다. (여기서는 어떤 프로세스가 CPU를 점유하고 있을 때 지연이 발생하는지 보기 위해 prev_comm을 사용)
  • 결과 해석: 이 스크립트의 결과는 어떤 프로세스들이 실행될 때 다른 프로세스들의 대기 시간이 길어지는지 보여줍니다. 특정 프로세스(예: kworker, systemd)의 히스토그램이 높은 지연 시간을 보인다면, 해당 프로세스가 CPU를 과도하게 점유하여 다른 애플리케이션의 반응성을 떨어뜨리고 있을 가능성을 시사합니다.

사용 사례: 네트워크에서는 무슨 일이?

bpftrace는 네트워크 스택 깊숙한 곳까지 들여다볼 수 있습니다. 커널 함수를 직접 추적하여 새로운 TCP 연결 정보를 실시간으로 확인해 보겠습니다.

  • 목표: 시스템에서 발생하는 새로운 IPv4 TCP 연결의 출발지 및 목적지 IP와 포트를 추적합니다.
  • 스크립트:
    sudo bpftrace -e '
    #include <net/sock.h>
    #include <linux/socket.h>
    
    kprobe:tcp_v4_connect { 
        $sk = (struct sock *)arg0; 
        $saddr = $sk->__sk_common.skc_rcv_saddr;
        $daddr = $sk->__sk_common.skc_daddr;
        $dport = $sk->__sk_common.skc_dport;
        
        printf("New TCPv4 conn: %s:%u -> %s:%u\n",
            ntop(AF_INET, $saddr), $sk->__sk_common.skc_num,
            ntop(AF_INET, $daddr), bswap($dport));
    }'
  • 스크립트 해설:
    1. #include <net/sock.h>: tcp_v4_connect 함수의 인자인 struct sock 구조체 정의를 사용하기 위해 커널 헤더를 포함시킵니다.
    2. kprobe:tcp_v4_connect: TCPv4 연결을 시도하는 커널 함수인 tcp_v4_connect가 호출될 때 프로브를 실행합니다.
    3. $sk = (struct sock *)arg0;: 함수의 첫 번째 인자(arg0)를 struct sock 포인터로 형 변환(casting)하여 변수 $sk에 저장합니다.
    4. $sk->__sk_common...: 형 변환된 구조체 변수를 통해 출발지/목적지 주소(skc_rcv_saddr, skc_daddr), 출발지 포트(skc_num), 목적지 포트(skc_dport) 등 상세 정보에 접근합니다.
    5. ntop(AF_INET,...): 32비트 정수 형태의 IP 주소를 우리가 읽을 수 있는 192.168.1.10과 같은 문자열로 변환합니다.
    6. bswap($dport): 네트워크 바이트 순서(big-endian)로 저장된 포트 번호를 호스트 바이트 순서(little-endian)로 변환하여 올바르게 표시합니다.

이 고급 예제는 bpftrace가 단순한 이벤트 카운팅을 넘어, 커널 내부의 복잡한 데이터 구조에 직접 접근하여 매우 상세한 정보를 추출할 수 있음을 보여줍니다.

내 애플리케이션 코드 프로파일링 (사용자 공간 프로브)

시스템 전반의 문제를 파악했다면, 이제 시선을 애플리케이션 내부로 돌릴 차례입니다. uprobe를 사용하면 시스템 콜이나 커널 이벤트를 넘어, 우리가 작성한 코드의 특정 함수가 어떻게 동작하는지 직접 분석할 수 있습니다. 이는 시스템 수준의 '증상'과 애플리케이션 수준의 '원인'을 연결하는 결정적인 다리입니다.

추적 가능한 함수 찾기

uprobe를 사용하려면 먼저 애플리케이션 바이너리 파일 내에서 추적하려는 함수의 정확한 이름(심볼)을 알아야 합니다. 바이너리가 스트립(stripped)되지 않았다면 nm이나 objdump 같은 도구로 심볼 테이블을 확인할 수 있습니다.

# 'T'는 텍스트(코드) 섹션에 있는 전역 심볼을 의미합니다.
nm /path/to/my_app | grep 'T '

여기서 한 가지 중요한 팁은 C++이나 Rust 같은 언어는 컴파일 시 함수 이름을 고유하게 바꾸는 '네임 맹글링(Name Mangling)'을 사용한다는 것입니다. 이 경우 nm으로 찾은 맹글링된 이름 전체를 uprobe에 사용해야 합니다.

사용 사례: 특정 함수의 실행 시간 측정

애플리케이션의 process_request라는 함수가 성능 저하의 원인으로 의심된다고 가정해 봅시다. 이 함수의 실행 시간을 히스토그램으로 측정하여 가설을 검증할 수 있습니다.

  • 목표: /path/to/my_app이라는 애플리케이션의 process_request 함수가 한 번 실행되는 데 걸리는 시간을 측정합니다.
  • 스크립트:
    sudo bpftrace -e '
    uprobe:/path/to/my_app:process_request { 
        @start[tid] = nsecs; 
    } 
    
    uretprobe:/path/to/my_app:process_request /@start[tid]/ { 
        @latency_us[comm] = hist((nsecs - @start[tid]) / 1000); 
        delete(@start[tid]); 
    }'
  • 스크립트 해설:

    이 스크립트는 앞서 커널 함수 지연 시간을 측정했던 패턴과 정확히 동일합니다. 단지 kprobe/kretprobe 대신 uprobe/uretprobe를 사용하고, 프로브 경로에 커널 함수가 아닌 애플리케이션 바이너리 경로와 함수명을 지정했을 뿐입니다. 이처럼 bpftrace의 기본 원리는 커널과 사용자 공간 양쪽에서 일관되게 적용됩니다. 이 보편성이야말로 bpftrace를 강력하게 만드는 요인 중 하나입니다.

사용 사례: 함수에 어떤 인자가 전달되는지 엿보기

때로는 함수가 얼마나 오래 걸리는지보다, 어떤 값들이 전달될 때 문제가 발생하는지 아는 것이 더 중요할 수 있습니다. uprobe는 함수의 인자도 쉽게 확인할 수 있게 해줍니다.

  • 목표: process_user(int user_id, const char* user_name)라는 함수에 전달되는 user_iduser_name 값을 실시간으로 출력합니다.
  • 스크립트:
    sudo bpftrace -e '
    uprobe:/path/to/my_app:process_user {
        printf("Processing user ID: %d, Name: %s\n", arg0, str(arg1));
    }'
  • 스크립트 해설:

    uprobe에서는 함수에 전달된 인자를 arg0, arg1, arg2,... 와 같은 내장 변수로 접근할 수 있습니다. 이는 x86-64 아키텍처의 함수 호출 규약(calling convention)에 따라 첫 번째 인자는 %rdi 레지스터, 두 번째 인자는 %rsi 레지스터에 저장되는 원리를 이용한 것입니다. bpftrace가 이 복잡한 과정을 추상화해주므로 우리는 간단히 argN으로 값을 가져올 수 있습니다. user_name이 문자열 포인터이므로 str() 함수를 사용한 점에 유의하세요.

고급 사례: 도커 컨테이너 내부 애플리케이션 추적

실무에서는 많은 애플리케이션이 도커 컨테이너 안에서 실행됩니다. 컨테이너 내부의 애플리케이션을 어떻게 추적할 수 있을까요? 해답은 리눅스의 /proc 파일 시스템에 있습니다.

  • 목표: 도커 컨테이너 안에서 실행 중인 애플리케이션의 함수를 호스트 머신에서 직접 추적합니다.
  • 해결 과정:
    1. 먼저, 호스트에서 docker inspect 명령어로 추적하려는 컨테이너의 PID를 찾습니다.
      # <container_name_or_id>를 실제 컨테이너 이름이나 ID로 바꾸세요.
      CONTAINER_PID=$(docker inspect -f '{{.State.Pid}}' <container_name_or_id>)
      echo "Container PID on host: $CONTAINER_PID"
    2. 리눅스 커널은 각 프로세스가 보는 파일 시스템 루트를 /proc/<PID>/root/ 경로를 통해 노출합니다. 이를 이용하여 bpftrace가 컨테이너 내부의 파일 경로를 인식하도록 할 수 있습니다.
      # $CONTAINER_PID를 위에서 찾은 PID로 바꾸세요.
      # /usr/local/bin/my_app은 컨테이너 내부에서의 바이너리 경로입니다.
      sudo bpftrace -e '
      uprobe:/proc/'$CONTAINER_PID'/root/usr/local/bin/my_app:my_function {
          printf("Function my_function called inside container!\n");
      }'

이 기법은 컨테이너 환경의 투명성을 극대화하여, 컨테이너화된 애플리케이션의 성능 문제를 마치 일반 호스트 프로세스처럼 손쉽게 분석할 수 있게 해주는 매우 실용적이고 강력한 방법입니다.

문제 해결: 흔히 겪는 문제와 해결책

bpftrace는 매우 강력하지만, 처음 사용할 때는 몇 가지 흔한 문제에 부딪힐 수 있습니다. 이 섹션은 여러분의 시간을 절약해 줄 것입니다.

문제: Permission denied 또는 Operation not permitted 오류

원인: bpftrace는 커널의 가장 깊은 곳에 접근하여 이벤트를 추적합니다. 이러한 강력한 기능은 시스템 보안에 직접적인 영향을 줄 수 있으므로, 커널은 오직 관리자(root) 권한을 가진 사용자에게만 eBPF 프로그램 로드를 허용합니다.

해결책: 모든 bpftrace 명령어는 반드시 sudo를 사용하여 실행해야 합니다. 이것은 버그가 아니라 의도된 보안 기능입니다.

문제: Failed to find kernel headers 또는 Could not resolve BTF 오류

원인: 이것은 가장 흔하게 발생하는 문제입니다. bpftrace가 현재 실행 중인 커널 버전에 맞는 헤더 파일을 찾지 못했기 때문입니다. 커널을 업데이트한 후 재부팅하지 않은 경우에도 버전 불일치로 인해 이 문제가 발생할 수 있습니다.

해결책:

  1. 터미널에서 uname -r 명령어를 실행하여 현재 커널 버전을 정확히 확인합니다.
  2. 해당 버전에 맞는 헤더 패키지를 설치합니다. (예: sudo apt-get install linux-headers-$(uname -r))
  3. 커널 업데이트 후에는 반드시 시스템을 재부팅하여 새로운 커널로 부팅되었는지 확인한 후 bpftrace를 실행하세요.

문제: Unknown struct/union: 'task_struct' 오류

원인: 이 오류는 위 헤더 문제의 변형입니다. bpftrace 스크립트가 #include를 통해 커널 헤더를 포함하고 특정 구조체(예: task_struct)에 접근하려 하지만, bpftrace가 해당 구조체의 전체 정의를 찾지 못할 때 발생합니다. 헤더 파일의 경로가 잘못되었거나, 필요한 정의가 다른 헤더 파일에 있는데 포함시키지 않았을 경우에 주로 나타납니다.

해결책:

  • -I 옵션을 사용하여 커널 헤더 파일이 위치한 디렉토리를 명시적으로 지정해 줄 수 있습니다.
    sudo bpftrace -I /usr/src/linux-headers-$(uname -r)/include/... (스크립트)...
  • 근본적인 해결책 (미래): 최근 커널들은 BTF(BPF Type Format)라는 기능을 지원합니다. BTF는 필요한 타입 정보를 커널 자체에 내장하여 헤더 파일에 대한 의존성을 크게 줄여줍니다. 최신 배포판과 커널을 사용하면 이러한 종류의 문제가 점차 줄어들 것입니다.

자주 묻는 질문 (FAQ)

Q1: eBPF와 bpftrace를 프로덕션 환경에서 사용하는 것이 정말 안전한가요?

A: 네, 안전합니다. 이는 eBPF의 핵심 설계 원칙 중 하나입니다. 커널에 내장된 '검증기(Verifier)'가 eBPF 프로그램이 로드되기 전에 정적 분석을 수행하여 무한 루프, 허용되지 않은 메모리 접근, 커널을 불안정하게 만들 수 있는 모든 위험 요소를 사전에 차단합니다. 이 검증 과정을 통과한 코드만이 실행되므로 커널 패닉이나 시스템 손상을 일으킬 위험이 없습니다. 또한 성능 오버헤드 역시 프로덕션 환경에 미치는 영향을 최소화하도록 설계되었습니다.

Q2: bpftrace의 성능 오버헤드는 어느 정도인가요?

A: strace나 전체 디버거와 비교했을 때 오버헤드는 극히 낮습니다. 오버헤드는 주로 추적하는 이벤트의 빈도에 따라 결정됩니다. 이벤트 하나당 약간의 CPU 비용이 발생하므로, 초당 수백만 건과 같이 매우 빈번한 이벤트를 추적하면 부하가 눈에 띄게 증가할 수 있습니다. 하지만 대부분의 성능 분석 시나리오에서는 오버헤드가 무시할 수 있는 수준이며 프로덕션 환경에서 사용하기에 안전합니다.

Q3: 언제 bpftrace를 쓰고, 언제 BCC(Python/Go) 툴킷을 써야 하나요?

A: bpftrace는 대화형 탐색, 임시 분석, 그리고 간단한 스크립트를 작성할 때 가장 이상적입니다. 간결한 문법 덕분에 문제의 원인을 빠르게 파악하고 싶을 때 최고의 도구입니다. 반면, BCC 툴킷은 더 복잡하고 독립적인 도구를 만들거나, 정교한 사용자 인터페이스(예: TUI)가 필요하거나, 추적 로직을 Python, Go, C++로 작성된 더 큰 애플리케이션에 통합해야 할 때 사용합니다.

Q4: bpftrace로 모든 애플리케이션을 추적할 수 있나요? 심볼이 제거된(stripped) 바이너리는 어떻게 되나요?

A: bpftrace는 심볼 테이블이 존재하는 한 모든 애플리케이션의 사용자 공간 함수(uprobe)를 추적할 수 있습니다. 만약 바이너리가 '스트립'되어 심볼 테이블이 제거되었다면, 함수 이름으로 추적하는 것은 불가능합니다. 메모리 주소(오프셋)를 이용해 추적해야 하지만, 이는 매우 어렵고 바이너리가 조금만 바뀌어도 주소가 틀어지기 때문에 불안정합니다. 따라서 깊이 있는 분석을 위해서는 개발 또는 스테이징 환경에서 디버그 심볼이 포함된 바이너리나 스트립되지 않은 바이너리를 사용하는 것이 가장 좋습니다.

Q5: 상용 APM(Application Performance Monitoring) 도구와는 어떤 관계가 있나요?

A: eBPF는 현재 많은 최신 APM 및 관측 가능성(Observability) 도구들의 기반 기술로 사용되고 있습니다. 상용 APM 도구들은 eBPF를 기반으로 대시보드, 알림, 분산 추적 등 잘 다듬어진 통합 환경을 제공합니다. bpftrace는 이러한 APM 도구들의 엔진에 해당하는 강력한 원시 명령줄 도구로, 사용자에게 맞춤형 분석을 위한 최고의 유연성을 제공합니다. bpftrace를 배우는 것은 이들 상용 도구가 내부적으로 어떻게 동작하는지 이해하는 데 큰 도움이 됩니다.

결론: 관측 가능성을 향한 여정의 시작

이 튜토리얼을 통해 여러분은 eBPF라는 새로운 패러다임을 이해하고, bpftrace를 설치하여 사용하는 방법을 배웠으며, I/O, CPU, 애플리케이션 수준의 성능 문제를 진단하는 핵심적인 패턴을 익혔습니다. 이제 여러분은 시스템의 동작을 이전과는 비교할 수 없을 정도로 깊고 명확하게 들여다볼 수 있는 강력한 도구를 손에 쥐게 된 것입니다.

하지만 이것은 관측 가능성의 세계로 들어서는 첫걸음에 불과합니다. 여러분의 여정은 이제 막 시작되었습니다.

다음 단계 및 심화 학습

  • BCC 도구 모음 탐색하기: bpftrace와 함께 설치되는 BCC 툴킷에는 opensnoop(파일 열기 감시), execsnoop(프로세스 실행 감시), biolatency(I/O 지연 시간 분석) 등 특정 목적을 위해 미리 만들어진 수많은 유용한 도구들이 포함되어 있습니다. 보통 /usr/share/bcc/tools 디렉토리에서 찾아볼 수 있으니 꼭 한번 탐색해 보시길 권장합니다.
  • 모니터링 시스템과 통합하기: eBPF Exporter와 같은 도구를 사용하면 eBPF를 통해 수집한 데이터를 프로메테우스(Prometheus) 같은 모니터링 시스템으로 내보낼 수 있습니다. 이를 통해 장기적인 성능 추이를 관찰하고 알림을 설정하는 등 고급 모니터링 체계를 구축할 수 있습니다.
  • 전문가로 거듭나기: 이 분야의 진정한 전문가가 되고 싶다면, eBPF의 대가인 브렌던 그레그(Brendan Gregg)가 저술한 "BPF Performance Tools"라는 책을 필독서로 추천합니다. 이 책은 eBPF와 관련 도구들에 대한 가장 깊이 있고 포괄적인 내용을 담고 있습니다.
  • 커뮤니티에 기여하기: bpftracebcc에 익숙해졌다면, GitHub 저장소를 방문하여 이슈를 해결하거나 새로운 기능을 제안하며 오픈 소스 커뮤니티에 기여하는 것도 의미 있는 다음 단계가 될 수 있습니다.

eBPF는 우리가 복잡한 시스템을 이해하고 최적화하는 방식을 근본적으로 바꾸고 있습니다. 이 기술은 엔지니어들이 더 안정적이고 성능 좋은 소프트웨어를 만들 수 있도록 힘을 실어주는, 현대 시스템 엔지니어링의 가장 흥미로운 발전 중 하나입니다.

인프라코디
서버, 네트워크, 보안 등 IT 인프라 관리를 하는 시스템 엔지니어로 일하고 있으며, IT 기술 정보 및 일상 정보를 기록하는 블로그를 운영하고 있습니다. 글을 복사하거나 공유 시 게시하신 글에 출처를 남겨주세요.

- 블로그 : www.infracody.com

이 글이 유익했나요? 댓글로 소중한 의견을 남겨주시거나 커피 한 잔의 선물은 큰 힘이 됩니다.
댓글