본문 바로가기
Operating System

eBPF로 실행 중인 애플리케이션 추적하는 방법 (bpftrace, bcc)

아주라·2026년 8월 27일·조회 3

운영 중인 애플리케이션에서 원인을 알 수 없는 지연이 생기면 제일 먼저 나오는 말이 "로그 좀 더 찍고 재배포하자"다. 그런데 재현이 안 되는 문제일수록 재배포 순간에 증상이 사라진다. 예전에 쿠버네티스 환경에서 Pixie를 설치해본 글(Kubernetes 모니터링 툴인 Pixie 설치 후기)을 쓰면서 "에이전트를 코드에 넣지 않았는데 HTTP 요청이 다 보인다"는 점이 인상적이었는데, 그 밑바닥이 eBPF다. 이번에는 쿠버네티스가 아니라 그냥 리눅스 서버에서, 이미 떠 있는 프로세스 하나를 상대로 같은 일을 해본다.

결론부터 말하자면, eBPF를 쓰면 애플리케이션을 다시 빌드하거나 재시작하지 않고도 그 프로세스가 어떤 파일을 열고, 어떤 라이브러리 함수를 몇 번 호출하며 얼마나 걸리는지, TLS로 암호화되기 직전의 평문이 무엇인지까지 볼 수 있다. 도구는 두 가지면 충분하다. 원라이너로 즉석 질문을 던질 때는 bpftrace, 이미 만들어진 관측 시나리오를 쓸 때는 bcc 도구 모음이다. 다만 uprobe는 공짜가 아니라서 뜨거운 함수에 붙이면 애플리케이션이 느려질 수 있고, Go나 Java 같은 자체 런타임을 가진 언어는 주의사항이 따로 있다.

아래에서는 eBPF 프로브 종류와 설치를 먼저 정리하고, 애플리케이션을 들여다보는 네 가지 각도(syscall 경계, 라이브러리 함수, 런타임 USDT, 애플리케이션 자체 함수)를 명령과 출력으로 차례로 살펴본다. 마지막에 런타임별 함정과 오버헤드 관리를 정리한다.

1. eBPF와 프로브 종류

eBPF는 커널 안에서 실행되는 샌드박스 프로그램을 만드는 기술이다. 공식 설명은 "운영체제 커널 같은 특권 컨텍스트에서 샌드박스 프로그램을 실행하는 기술"이고, 커널 소스를 고치거나 커널 모듈을 올리지 않고 그렇게 한다. 프로그램을 로드하기 전에 검증기(verifier)가 통과 여부를 판정한다. 검증기는 프로그램이 반드시 종료하는지(무한 루프 금지), 초기화되지 않은 변수나 범위를 벗어난 메모리를 건드리지 않는지를 확인한다.

애플리케이션 추적에 쓰는 부착점은 네 가지다.

  • tracepoint: 커널에 미리 심어둔 정적 트레이스 지점이다. 예를 들어 tracepoint:syscalls:sys_enter_openat은 openat 시스템콜 진입 지점이다. 커널 버전이 바뀌어도 이름과 인자가 유지되는 편이라 안정적이다.
  • kprobe / kretprobe: 커널 함수의 진입과 반환에 동적으로 붙인다. 시스템콜 내부 동작까지 파고들 때 쓴다.
  • uprobe / uretprobe: 사용자 공간 바이너리나 공유 라이브러리의 함수 진입과 반환에 붙인다. 애플리케이션 추적의 핵심이다.
  • usdt: 애플리케이션이나 런타임이 소스에 미리 심어둔 정적 트레이스포인트다. JVM이나 특정 빌드 옵션으로 만든 Python 같은 런타임이 제공한다.

여기서 uprobe는 개념이 단순하다. 지정한 주소의 명령을 트랩으로 바꿔치기해서, 그 함수가 호출될 때마다 커널로 제어가 넘어오고 eBPF 프로그램이 실행된다. 애플리케이션 입장에서는 자기가 계측당하는지 알지 못한다. 재시작이 필요 없는 이유가 이것이다.

2. 준비물과 설치

필요한 것은 세 가지다.

  • 권한: 보통 root로 실행한다. CAP_BPF, CAP_PERFMON 같은 능력만 부여해 쓰는 것도 가능하지만 커널 버전, perf_event_paranoid 설정, 프로브 종류에 따라 추가 권한이 필요할 수 있다.
  • 커널: bcc 공식 문서의 최소 요건은 리눅스 커널 4.1 이상(CONFIG_BPF=y, CONFIG_BPF_SYSCALL=y, CONFIG_BPF_JIT=y)이지만, 이 글에서 쓰는 bpftrace 최신 문법, BTF, tracepoint 인자 접근, uprobe 오프셋 지정은 훨씬 새 커널을 전제한다. bpftrace 공식 문서가 권하는 커널 버전을 확인하고, 실무에서는 5.x 이상 최근 배포판 기본 커널을 기준으로 잡는다.
  • BTF: CONFIG_DEBUG_INFO_BTF=y로 빌드된 커널이면 커널 헤더 없이도 커널 구조체를 해석할 수 있다.
$ uname -r
6.8.0-51-generic

$ ls -l /sys/kernel/btf/vmlinux
-r--r--r-- 1 root root 5921174 Aug 20 09:12 /sys/kernel/btf/vmlinux

$ grep CONFIG_DEBUG_INFO_BTF /boot/config-$(uname -r)
CONFIG_DEBUG_INFO_BTF=y
CONFIG_DEBUG_INFO_BTF_MODULES=y

/sys/kernel/btf/vmlinux가 없으면 BTF가 없는 커널이다. 이 경우 bcc 도구 상당수는 linux-headers 패키지를 요구한다.

설치는 배포판 패키지로 끝난다.

# Ubuntu / Debian
$ sudo apt install -y bpftrace bpfcc-tools linux-headers-$(uname -r)

# RHEL / Rocky / AlmaLinux / Fedora
$ sudo dnf install -y bpftrace bcc-tools

여기서 한 번 걸리기 쉬운 지점이 도구 이름이다. 배포판마다 bcc 도구의 설치 위치와 이름이 다르다.

  • Ubuntu/Debian: /sbin(또는 /usr/sbin)에 -bpfcc 접미사가 붙는다. opensnoop-bpfcc, funclatency-bpfcc 형태다. 단 ucalls, ustat, uflow 같은 u* 도구는 접미사 없이 설치되므로 ls /usr/sbin/*bpfcc* /usr/sbin/u*로 실제 이름을 확인한다.
  • RHEL 계열: /usr/share/bcc/tools/ 아래에 접미사 없이 들어간다. PATH에 없으므로 전체 경로로 실행한다.
$ bpftrace --version
bpftrace v0.20.2

버전 번호는 배포판 저장소 상태에 따라 다르다. bpftrace 공식 문서도 문법이 버전별로 달라지므로 문서를 볼 때 bpftrace --version을 먼저 확인하라고 안내한다. 이 글의 예시는 0.20 계열(Ubuntu 24.04 저장소 버전)에서 동작하는 문법이고, 0.22 이후 달라지는 부분은 해당 절에서 따로 적는다.

3. syscall 경계에서 프로세스 관찰하기

애플리케이션 내부 함수부터 파고들 필요는 없다. 프로세스가 외부와 접촉하는 지점, 즉 시스템콜만 봐도 상당수 문제의 실마리가 보인다. 대상 PID를 먼저 잡는다.

$ pgrep -a -f myapp
2841 /opt/myapp/bin/myapp --config /etc/myapp.yaml

어떤 파일을 여는지 보기

opensnoop은 open 계열 시스템콜을 잡아 파일 경로와 결과를 출력한다. 설정 파일을 엉뚱한 경로에서 찾는 문제, 인증서 경로 오타 같은 것이 바로 보인다.

$ sudo opensnoop-bpfcc -p 2841
PID    COMM               FD ERR PATH
2841   myapp               7   0 /etc/myapp.yaml
2841   myapp              -1   2 /opt/myapp/conf/local.yaml
2841   myapp               9   0 /etc/ssl/certs/ca-certificates.crt
^C

두 번째 줄의 ERR 2는 ENOENT다. 애플리케이션이 없는 파일을 조용히 넘어가고 있다는 뜻이고, 로그에는 남지 않는 경우가 많다.

bpftrace 원라이너로도 같은 일을 한다. tracepoint 인자는 args 구조체에 점 표기로 접근한다.

$ sudo bpftrace -e 'tracepoint:syscalls:sys_enter_openat /pid == 2841/ { printf("%s %s\n", comm, str(args.filename)); }'
Attaching 1 probe...
myapp /etc/myapp.yaml
myapp /opt/myapp/conf/local.yaml
^C

0.19 이전 bpftrace는 args->filename 형태를 썼고, 이 표기는 지금도 호환용 별칭으로 동작한다. 반대로 args.filename을 0.19 이전 버전에 붙이면 파싱 에러가 나므로, 옛 서버에서 에러가 나면 이 차이일 가능성이 크다. 어떤 필드가 있는지는 -lv로 확인한다.

$ sudo bpftrace -lv 'tracepoint:syscalls:sys_enter_openat'
tracepoint:syscalls:sys_enter_openat
    int __syscall_nr
    int dfd
    const char * filename
    int flags
    umode_t mode

자식 프로세스와 네트워크 연결 보기

애플리케이션이 셸을 호출하거나 외부 바이너리를 실행하는지는 execsnoop으로 본다.

$ sudo execsnoop-bpfcc
PCOMM            PID    PPID   RET ARGS
sh               3112   2841     0 /bin/sh -c /usr/bin/convert /tmp/in.png /tmp/out.png
convert          3113   3112     0 /usr/bin/convert /tmp/in.png /tmp/out.png

아웃바운드 연결은 tcpconnect, 인바운드 수락은 tcpaccept다. 애플리케이션이 어디로 나가는지 방화벽 로그를 뒤지지 않고 바로 확인한다.

$ sudo tcpconnect-bpfcc -P 443
PID    COMM         IP SADDR            DADDR            DPORT
2841   myapp        4  10.0.2.15        142.250.196.132  443
2841   myapp        4  10.0.2.15        52.219.60.10     443

4. uprobe로 라이브러리 함수 호출과 지연 보기

여기서부터가 uprobe의 영역이다. 우선 어떤 심볼에 붙일 수 있는지 목록을 뽑는다. 라이브러리 경로는 배포판과 아키텍처에 따라 다르므로 ldd로 실제 경로를 먼저 확인하는 편이 정확하다.

$ ldd /opt/myapp/bin/myapp | grep -E 'libc|libssl'
        libssl.so.3 => /lib/x86_64-linux-gnu/libssl.so.3 (0x00007f2a1c000000)
        libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f2a1bc00000)

$ sudo bpftrace -l 'uprobe:/lib/x86_64-linux-gnu/libssl.so.3:SSL_*'
uprobe:/lib/x86_64-linux-gnu/libssl.so.3:SSL_read
uprobe:/lib/x86_64-linux-gnu/libssl.so.3:SSL_read_ex
uprobe:/lib/x86_64-linux-gnu/libssl.so.3:SSL_write
uprobe:/lib/x86_64-linux-gnu/libssl.so.3:SSL_write_ex
...

호출 횟수를 먼저 센다

지연을 재기 전에 호출 빈도부터 확인해야 한다. 초당 수십만 번 불리는 함수에 uretprobe까지 붙이면 애플리케이션 응답이 눈에 띄게 느려질 수 있다. bcc funccount는 진입만 세므로 상대적으로 가볍다.

$ sudo funccount-bpfcc -p 2841 -d 5 'c:malloc'
Tracing 1 functions for "c:malloc"... Hit Ctrl-C to end.

FUNC                                    COUNT
malloc                                  38412
Detaching...

패턴도 쓸 수 있다. -r을 주면 정규식으로 해석한다.

$ sudo funccount-bpfcc -p 2841 -d 5 -r 'c:(read|write)$'

COUNT 값은 워크로드에 따라 완전히 달라지므로 절대값보다 자릿수를 본다. 호출 횟수가 자릿수 단위로 커질수록 프로브 비용도 그만큼 커진다고 보고, 많이 불리는 함수라면 대상을 더 좁히거나 반환 프로브를 빼는 식으로 줄인다. 어느 정도가 감당 가능한지는 코어 수, 프로브 핸들러가 하는 일, 애플리케이션의 지연 여유에 따라 다르므로 실제 대상에서 짧게 재보고 정한다.

지연 히스토그램

bcc funclatency는 진입과 반환 시각의 차이를 히스토그램으로 뽑는다. -u는 마이크로초, -m은 밀리초 단위다.

$ sudo funclatency-bpfcc -p 2841 -u c:malloc
Tracing 1 functions for "c:malloc"... Hit Ctrl-C to end.
^C
     usecs               : count     distribution
         0 -> 1          : 24518    |****************************************|
         2 -> 3          : 1902     |***                                     |
         4 -> 7          : 233      |                                        |
         8 -> 15         : 41       |                                        |
        16 -> 31         : 7        |*                                       |
Detaching...

위 숫자는 예시이며 실제 분포는 환경과 부하에 따라 다르다. 여기서 중요한 것은 값이 아니라 모양이다. 봉우리가 두 개로 갈라지면(bimodal) 빠른 경로와 느린 경로가 따로 있다는 신호이고, 락 대기나 캐시 미스를 의심할 근거가 된다.

같은 측정을 bpftrace로 직접 짜면 조건을 마음대로 붙일 수 있다. 진입에서 시각을 저장하고 반환에서 차이를 히스토그램에 넣는 방식이다.

$ sudo bpftrace -e '
uprobe:/lib/x86_64-linux-gnu/libc.so.6:malloc /pid == 2841/ {
    @start[tid] = nsecs;
}
uretprobe:/lib/x86_64-linux-gnu/libc.so.6:malloc /pid == 2841 && @start[tid]/ {
    @us = hist((nsecs - @start[tid]) / 1000);
    delete(@start[tid]);
}'
Attaching 2 probes...
^C

@us:
[0]                18904 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@|
[1]                 3311 |@@@@@@                                  |
[2, 4)               507 |@                                       |
[4, 8)                62 |                                        |
# 앞의 funclatency와는 별개 시점의 예시 값이다. 버킷 표기도 두 도구가 다르다.

두 가지를 짚어둔다. 첫째, @start[tid] 조건을 필터에 넣지 않으면 추적을 시작한 시점에 이미 함수 안에 들어가 있던 스레드가 반환할 때 nsecs - 0이 계산되어 말도 안 되는 값이 히스토그램에 섞인다. 둘째, 맵 항목 삭제 문법이 버전에 따라 다르다. 위에서 쓴 delete(@start[tid])는 0.21 계열까지 쓰던 문법이고, 0.22.0부터는 맵과 키를 따로 넘기는 delete(@start, tid)가 정식이고, 옛 형태는 폐기 예정으로 표시됐지만 여전히 지원된다. 어느 쪽이든 동작은 하니 bpftrace --version에 맞춰 고르면 된다.

맵을 지우지 않으면 스레드가 많은 애플리케이션에서 맵이 계속 커진다. 반환 프로브에서 반드시 지운다.

5. TLS 평문 들여다보기

이 부분이 eBPF 추적에서 가장 실용적이다. tcpdump로 잡으면 TLS 암호문만 보이지만, uprobe는 암호화 직전과 복호화 직후 지점에 붙는다. OpenSSL에서 그 지점이 SSL_writeSSL_read다.

bcc에 이 원리를 구현한 sslsniff가 들어 있다. 공식 설명대로 OpenSSL, GnuTLS, NSS의 write/send, read/recv 함수를 추적한다.

$ sudo sslsniff-bpfcc -c curl
FUNC         TIME(s)            COMM             PID     LEN
WRITE/SEND   0.000000000        curl             12915   74
----- DATA -----
GET / HTTP/1.1
Host: example.com
User-Agent: curl/8.5.0
Accept: */*

----- END DATA -----

자주 쓰는 옵션은 다음과 같다.

  • -p PID: 특정 프로세스만 추적한다. 운영 환경에서는 사실상 필수다.
  • -c COMM: 프로세스 이름으로 거른다. 위 예시의 curl이 그것이다.
  • -u UID: 사용자 ID로 거른다.
  • --hexdump: 바이너리 프로토콜을 볼 때 16진 덤프로 출력한다.
  • -l(--latency): 함수 지연을 함께 출력한다. --handshake는 여기에 덧붙여 TLS 핸드셰이크 시간을 재는 옵션으로, -l이 켜져 있을 때만 동작한다.
  • --extra-lib lib_type:lib_path: 기본 세 가지 외의 SSL 라이브러리를 추가한다. 형식은 openssl:/path/libssl.so.3처럼 라이브러리 종류와 경로를 콜론으로 잇는다. 컨테이너 안의 라이브러리를 잡을 때 쓴다.

bpftrace 원라이너로 최소 형태만 만들면 이렇다. SSL_write(SSL *ssl, const void *buf, int num)이므로 버퍼는 arg1, 길이는 arg2다. str()은 길이를 두 번째 인자로 받는다.

$ sudo bpftrace -e '
uprobe:/lib/x86_64-linux-gnu/libssl.so.3:SSL_write /pid == 2841/ {
    printf("--- %s (%d bytes) ---\n%s\n", comm, arg2, str(arg1, arg2));
}'

여기서 흔히 만나는 문제 두 가지가 있다. 하나는 출력이 잘리는 것이다. 우선 str(ptr, len)은 지정한 길이 중 마지막 1바이트를 NUL 종료용으로 쓰기 때문에 실제로는 len-1바이트만 복사되고, 페이로드 중간에 NUL이 있으면 거기서 끊긴다. bpftrace의 문자열 버퍼 크기는 설정값 max_strlen으로 정해지는데 0.20 계열 기본값은 64바이트라 긴 HTTP 바디는 앞부분만 나온다. 환경변수 BPFTRACE_MAX_STRLEN(스크립트 안에서는 config = { max_strlen = N })으로 늘릴 수 있지만, 0.20 계열에서는 이 버퍼가 512바이트짜리 BPF 스택에 잡히기 때문에 공식 man 기준 실질적으로 200바이트 안팎까지만 키울 수 있다. 문자열을 스택 밖으로 옮긴 0.21.0 이후에는 이 제약이 없고(기본값이 1024바이트로 늘어난 것은 0.23.0부터) 값이 클수록 메모리와 CPU만 더 쓴다. 어느 쪽이든 긴 페이로드를 제대로 보려면 자체 원라이너 대신 sslsniff를 쓰는 쪽이 낫다.

다른 하나는 심볼을 못 찾는 경우다. Go 표준 라이브러리의 crypto/tls나 Java의 JSSE처럼 OpenSSL을 쓰지 않는 구현은 libssl.so에 붙는 프로브로 잡히지 않는다. 애플리케이션이 실제로 어떤 라이브러리를 링크했는지 lddlsof -p PID | grep ssl로 먼저 확인한다.

말할 필요도 없지만, 이 기능은 남의 세션 데이터를 그대로 노출한다. 운영 환경에서는 대상 PID를 한정하고, 필요한 시간만 돌리고, 출력물을 남기지 않는 것을 원칙으로 삼는다.

6. USDT로 런타임 이벤트 보기

USDT(User-level Statically Defined Tracing)는 애플리케이션이나 런타임이 소스 안에 미리 심어둔 트레이스 지점이다. uprobe와 달리 심볼 이름에 의존하지 않고, 개발자가 의미를 부여해 둔 이벤트라 해석이 쉽다. GC 시작, 객체 할당, 메서드 진입 같은 것들이다.

바이너리에 USDT 프로브가 있는지는 bpftrace -l로 확인한다.

$ sudo bpftrace -l 'usdt:/usr/lib/jvm/java-21-openjdk-amd64/lib/server/libjvm.so:*'
usdt:/usr/lib/jvm/java-21-openjdk-amd64/lib/server/libjvm.so:hotspot:gc__begin
usdt:/usr/lib/jvm/java-21-openjdk-amd64/lib/server/libjvm.so:hotspot:gc__end
usdt:/usr/lib/jvm/java-21-openjdk-amd64/lib/server/libjvm.so:hotspot:thread__start
...

출력이 비어 있으면 그 빌드에 USDT가 없는 것이다. 런타임별 조건은 단정할 수 없고 빌드 옵션에 달려 있다.

  • Java(HotSpot): 배포판 OpenJDK 상당수가 GC, 스레드 같은 기본 USDT 프로브를 포함한다. 메서드 진입/반환, 객체 할당, 모니터 같은 세밀한 프로브는 별도 플래그로 켜야 하는데, 예전에 쓰던 -XX:+ExtendedDTraceProbes는 JDK 19에서 폐기 예정이 되고 JDK 21에서 제거됐다. JDK 21 이후에 이 옵션을 붙이면 알 수 없는 옵션이라며 JVM이 기동하지 않으므로, -XX:+DTraceMethodProbes, -XX:+DTraceAllocProbes, -XX:+DTraceMonitorProbes처럼 개별 플래그를 쓴다. 이 플래그들은 DTrace/SystemTap 지원(--enable-dtrace)을 켜고 빌드한 JDK에만 존재하므로, 그렇지 않은 배포 JDK에서는 역시 알 수 없는 옵션으로 기동이 실패할 수 있다. java -XX:+PrintFlagsFinal -version | grep DTrace로 먼저 확인한다. 어느 쪽이든 성능 부담이 크므로 운영 상시 적용 대상이 아니다.
  • Python: --with-dtrace로 빌드한 경우에만 python:function__entry 같은 프로브가 생긴다. 배포판 패키지는 포함 여부가 갈린다.
  • Node.js: 과거에는 빌드 옵션으로 DTrace/USDT 프로브를 넣을 수 있었지만 v19.0.0에서 이 지원이 제거됐다. 현재 지원되는 Node 버전에는 켤 수 있는 USDT가 없으므로, Node 프로세스는 libc나 libssl에 uprobe를 붙이거나 애플리케이션 자체 계측(OpenTelemetry 등)으로 접근한다.

bcc에는 USDT를 활용한 런타임 전용 도구가 있다. ucalls(메서드 호출 횟수와 지연), uflow(메서드 진입/반환 추적), ustat(런타임 이벤트 통계), ugc(GC 이벤트), uthreads(스레드 생성) 등이고, 언어별 심볼릭 링크로 javacalls, pythonflow 같은 이름도 제공한다. 도구별 지원 언어와 요구 조건은 각 도구의 _example.txt 문서를 확인한다.

# Ubuntu/Debian: u* 도구는 예외적으로 -bpfcc 접미사가 없다(/usr/sbin/ucalls, ustat ...). 언어별 변형만 javacalls-bpfcc처럼 접미사가 붙는다.
$ sudo ustat -h
$ sudo ucalls -h
$ sudo javacalls-bpfcc -h

7. 애플리케이션 자체 함수에 uprobe 붙이기

라이브러리가 아니라 우리가 만든 함수 자체를 추적하려면 조건이 하나 있다. 바이너리에 심볼이 남아 있어야 한다. 릴리스 빌드에서 strip 했다면 함수 이름으로는 붙일 수 없다.

$ file /opt/myapp/bin/myapp
/opt/myapp/bin/myapp: ELF 64-bit LSB pie executable, x86-64, dynamically linked, not stripped

$ nm -D /opt/myapp/bin/myapp | grep ' T ' | head
0000000000002310 T handle_request
00000000000024a0 T parse_header
00000000000026f0 T send_response

nm -D는 동적 심볼 테이블을, objdump -T도 같은 정보를 보여준다. 정적 함수까지 보려면 nm을 옵션 없이 쓰거나 별도 디버그 심볼 패키지가 필요하다. 심볼이 확인되면 라이브러리와 똑같이 붙인다.

$ sudo bpftrace -e '
uprobe:/opt/myapp/bin/myapp:handle_request { @start[tid] = nsecs; }
uretprobe:/opt/myapp/bin/myapp:handle_request /@start[tid]/ {
    @ms = hist((nsecs - @start[tid]) / 1000000);
    delete(@start[tid]);
}' -p 2841

-p로 대상을 지정하면 해당 프로세스에만 프로브가 붙는다. 같은 바이너리로 뜬 프로세스가 여러 개인 서버에서는 필수다.

C, C++, Rust, Go처럼 네이티브로 컴파일하는 언어는 이 방식이 통한다. C++은 이름 맹글링 때문에 _ZN7Handler6handleEv 같은 심볼이 나오므로 nm -D --demangle로 원래 이름을 확인한 뒤 맹글링된 이름을 프로브에 적는다.

8. 런타임별 함정

Go: uretprobe로 프로세스가 죽을 수 있다

이건 성능 문제가 아니라 안정성 문제라 따로 강조한다. bpftrace 공식 언어 레퍼런스는 uretprobe에 대해 이렇게 적는다. "uretprobe가 동작하려면 커널이 사용자 공간 함수 진입 시 특별한 헬퍼를 실행해 스택의 반환 주소를 덮어쓴다. 이는 Golang처럼 자체 런타임을 가진 언어에서 문제를 일으킬 수 있다."

Go 런타임은 goroutine 스택을 실행 중에 늘리고 줄인다. 커널이 덮어쓴 반환 주소가 이 과정과 어긋나면 프로세스가 크래시할 수 있다. 그래서 Go 바이너리에는 다음 원칙을 지킨다.

  • 진입만 잡을 때는 uprobe를 쓴다. 이건 문제되지 않는다.
  • 반환을 잡아야 하면 uretprobe 대신 함수의 각 ret 명령 위치에 uprobe를 붙인다. bpftrace는 uprobe:binary:func+offset(함수 시작 기준 오프셋) 형태를 지원하므로 이쪽이 안전하다. uprobe:binary:offset처럼 절대 오프셋을 쓸 때는 커널 uprobe 문서대로 objdump가 보여주는 가상 주소에서 로드 베이스(Go 기본 비-PIE 빌드는 0x400000)를 뺀 파일 내 오프셋을 넣어야 한다. 주소를 그대로 넣으면 명령 경계가 아닌 곳에 트랩이 박혀 프로세스가 죽을 수 있다.
  • 운영 프로세스에 시험 삼아 uretprobe를 붙여보는 일은 하지 않는다. 테스트 환경에서 먼저 확인한다.
$ objdump -d --no-show-raw-insn /opt/mygoapp/bin/app | grep -A 200 'main.handleRequest>:' | grep -E 'handleRequest>:|ret'
00000000004a1200 <main.handleRequest>:
  4a12f7:       ret
# 함수 시작 0x4a1200, ret 0x4a12f7 -> 함수 기준 오프셋 0xf7
$ sudo bpftrace -e 'uprobe:/opt/mygoapp/bin/app:main.handleRequest+0xf7 { @ret = count(); }'
# 절대 오프셋으로 쓰려면 0x4a12f7 - 0x400000 = 0xa12f7 (주소, 베이스는 예시 값)

Java: JIT 코드에는 심볼이 없다

JVM은 바이트코드를 실행 중에 기계어로 컴파일하므로, JIT된 메서드는 ELF 심볼 테이블에 존재하지 않는다. ustack을 찍으면 이런 모양이 나온다.

@[
    0x7f8a2c0184e1
    0x7f8a2c011a3c
    0x7f8a2c0119b8
]: 42

주소를 메서드 이름으로 바꾸려면 보조 수단이 필요하다. perf-map-agent로 JVM이 /tmp/perf-PID.map 심볼 맵을 쓰게 하는 방법, -XX:+PreserveFramePointer로 스택 워킹이 가능하게 하는 방법이 알려져 있다. 두 옵션 모두 부담이 따르므로 상시 적용 대상은 아니고, 필요한 조사 기간에만 켠다.

인터프리터 언어: 사용자 함수 이름이 바로 보이지 않는다

Python이나 Ruby 애플리케이션에 uprobe를 붙이면 잡히는 것은 인터프리터 자신의 C 함수다. 사용자가 작성한 함수 이름을 얻으려면 인터프리터 내부 구조체에서 프레임 정보를 읽어내야 하고, 이걸 대신 해주는 것이 앞서 언급한 USDT 프로브와 bcc의 u* 도구다. USDT 지원 없이 빌드된 인터프리터에서는 이 방법을 쓸 수 없다.

9. 오버헤드와 안전한 운영 원칙

uprobe는 명령 트랩 방식이라 호출마다 커널 진입 비용이 붙는다. 초당 수십만 번 호출되는 함수에 붙이면 애플리케이션 응답이 눈에 띄게 느려질 수 있다. 정확한 비율은 함수 호출 빈도, CPU, 커널 버전에 따라 달라지므로 미리 예측하지 말고 실제 대상에서 재는 것이 맞다.

운영 환경에 붙일 때 지키는 순서는 다음과 같다.

  1. funccount로 대상 함수의 호출 빈도부터 확인한다. 자릿수를 보고 붙일지 말지 정한다.
  2. -p PID로 대상을 한 프로세스로 좁힌다. 시스템 전체 프로브는 조사 범위가 넓을 때만 쓴다.
  3. -d(bcc)나 interval, 수동 Ctrl-C로 짧게 끊는다. 실행 시간은 보려는 이벤트가 얼마나 자주 일어나는지에 맞춰 정한다. 자주 불리는 함수라면 몇 초로도 패턴이 보이고, 드문 이벤트라면 그만큼 길게 잡되 재현 시점을 노린다.
  4. 진입만으로 답이 나오면 반환 프로브를 붙이지 않는다. uretprobe는 비용도 위험도 진입 프로브보다 크다.
  5. 붙이기 전에 스테이징이나 동일 빌드의 테스트 프로세스에서 한 번 돌려본다. 특히 Go 바이너리.

안전 측면에서는 커널 모듈보다 유리하다. 검증기가 무한 루프와 불법 메모리 접근을 로드 시점에 거부하므로 잘못된 프로그램이 커널을 패닉시키는 경로가 막혀 있다. 다만 공식 문서도 명시하듯 검증기는 안전성 도구이지 보안 도구가 아니다. 프로그램이 무엇을 하는지는 검사하지 않는다. TLS 평문을 읽을 수 있다는 사실 자체가, eBPF 실행 권한을 아무에게나 주면 안 되는 이유다.

10. 쿠버네티스로 넘어가면

지금까지 한 일을 클러스터 전체에 손으로 하기는 어렵다. 파드는 계속 뜨고 죽고, 컨테이너마다 라이브러리 경로가 다르다. 그래서 같은 원리를 자동화한 도구들이 있다.

  • Pixie: 노드마다 에이전트를 띄워 HTTP, gRPC, MySQL, PostgreSQL 같은 프로토콜을 자동 파싱한다. 애플리케이션 계측 코드가 필요 없다. 설치 과정은 예전에 정리한 글에 남겨두었다.
  • Grafana Beyla: eBPF 자동 계측으로 서비스 지연과 요청 수를 OpenTelemetry, Prometheus 형식으로 내보낸다. 2025년에 OpenTelemetry 프로젝트에 기부되어 상류 개발은 OpenTelemetry eBPF Instrumentation(OBI)으로 이어지고 있다.
  • Cilium Tetragon: 프로세스 실행, 파일 접근, 네트워크 이벤트를 정책 기반으로 관측하고 차단까지 한다. 보안 관측 쪽에 초점이 있다.
  • Inspektor Gadget: eBPF 프로그램을 "gadget"이라는 OCI 이미지로 패키징해 쿠버네티스 리소스 단위로 실행하는 프레임워크와 도구 모음이다. 초기에는 bcc 도구를 감싼 형태였지만 지금은 자체 gadget 체계로 바뀌었다. 파드 이름으로 파일 열기나 네트워크 이벤트를 추적하는 식이다.

이 도구들이 하는 일도 결국 uprobe, tracepoint, kprobe를 붙이는 것이다. 원리를 손으로 한 번 해보면 자동화 도구가 무엇을 보고 무엇을 못 보는지 판단이 선다.

11. 정리

실행 중인 애플리케이션을 재시작 없이 들여다보는 순서를 요약하면 이렇다. 먼저 syscall 경계(opensnoop, execsnoop, tcpconnect)에서 프로세스가 무엇을 만지는지 확인하고, 범위가 좁혀지면 funccount로 호출 빈도를 잰 뒤 funclatency나 bpftrace 원라이너로 지연 분포를 본다. 암호화 구간이 의심되면 sslsniff로 평문을 확인하고, 런타임 내부 이벤트가 필요하면 USDT 프로브 존재 여부를 bpftrace -l로 먼저 확인한다.

도구 선택 기준은 단순하다. 즉석에서 새로운 질문을 던져야 하면 bpftrace 원라이너, 이미 정형화된 관측이면 bcc 도구다. 그리고 어느 쪽이든 대상 PID를 한정하고 짧게 돌린다는 원칙은 같다.

자주 묻는 질문

eBPF로 추적하려면 애플리케이션을 재시작해야 하나?

재시작하지 않아도 된다. uprobe는 실행 중인 프로세스의 메모리에 매핑된 함수 주소에 트랩을 걸어 동작하므로, 이미 떠 있는 PID에 그대로 붙는다. 코드 수정이나 재빌드, 에이전트 주입도 필요 없다. 다만 애플리케이션 자체 함수에 붙이려면 바이너리에 심볼이 남아 있어야 하고(strip된 바이너리는 이름으로 붙일 수 없음), USDT 프로브를 쓰려면 해당 런타임이 USDT 지원 옵션으로 빌드되어 있어야 한다.

bpftrace와 bcc 중 무엇을 써야 하나?

즉석에서 새 질문을 만들어야 하면 bpftrace를 쓴다. 원라이너 한 줄로 조건, 필터, 집계를 자유롭게 조합할 수 있다. 반대로 파일 열기 추적, 함수 지연 히스토그램, TLS 평문 확인처럼 이미 정형화된 관측은 bcc 도구(opensnoop, funclatency, sslsniff 등)가 옵션과 출력 형식까지 정리되어 있어 빠르다. 두 도구는 같은 커널 기능을 쓰므로 한쪽만 골라야 하는 관계가 아니다. 둘 다 설치해두고 상황에 맞춰 쓴다.

Go로 만든 프로그램에 uretprobe를 붙여도 되나?

권장하지 않는다. bpftrace 공식 문서는 uretprobe가 동작하기 위해 커널이 사용자 공간 스택의 반환 주소를 덮어쓰며, 이것이 Go처럼 자체 런타임을 가진 언어에서 문제를 일으킬 수 있다고 명시한다. Go 런타임이 goroutine 스택을 실행 중에 재조정하기 때문이다. 진입만 필요하면 uprobe는 문제없이 쓸 수 있고, 반환값이 필요하면 objdump로 함수의 ret 명령 오프셋을 찾아 uprobe:binary:offset 형태로 붙이는 방법을 쓴다.

sslsniff로 TLS 평문이 보이는 이유는 무엇인가?

TLS 암호화는 애플리케이션이 SSL_write를 호출한 뒤 라이브러리 내부에서 일어난다. sslsniff는 그 함수의 진입 지점에 uprobe를 붙여 인자로 넘어온 버퍼를 읽으므로, 아직 암호화되지 않은 평문을 그대로 본다. 읽기는 SSL_read 반환 시점에 복호화가 끝난 버퍼를 읽는 방식이다. 다만 OpenSSL, GnuTLS, NSS를 쓰는 애플리케이션에만 해당하며, Go의 crypto/tls나 Java JSSE처럼 자체 구현을 쓰는 경우에는 libssl 프로브로 잡히지 않는다.

운영 서버에 붙였다가 애플리케이션이 느려지지 않나?

느려질 수 있다. uprobe는 호출마다 커널 진입 비용이 발생하므로, 초당 수십만 번 불리는 함수에 붙이면 체감할 정도의 지연이 생길 수 있다. 그래서 funccount로 호출 빈도를 먼저 재고, -p PID로 대상을 한 프로세스로 좁히고, 실행 시간을 제한해 짧게 끊는 순서를 지킨다(funccount, funclatency는 -d 초 옵션이지만 sslsniff의 -d는 디버그 모드처럼 도구마다 뜻이 다르니 -h로 확인한다. bpftrace는 Ctrl-C나 interval 프로브의 exit()로 끝낸다). 반환값이 필요 없으면 uretprobe를 붙이지 않는 것만으로도 부담이 줄어든다. 커널 패닉 위험은 검증기가 무한 루프와 불법 메모리 접근을 차단하므로 커널 모듈보다 낮다.

관련 글

댓글 0

로그인 후 댓글을 남길 수 있습니다.

아직 댓글이 없습니다.