[Linux Kernel] ftrace event tracing — 필터·트리거·히스토그램으로 좁혀 보기

커널이 제공하는 트레이스 이벤트는 Ubuntu 24.04 기준 1789개다. 어떤 이벤트를 봐야 할지 모르겠다고 events/enable에 1을 써버리면 1초 만에 수십만 건이 링 버퍼에서 밀려나 사라지고, 정작 보려던 이벤트도 그 안에 묻힌다. ftrace의 이벤트 트레이싱은 필터(filter)·트리거(trigger)·히스토그램(hist)을 커널 안에서 적용해, 사용자 공간으로 넘어오기 전에 기록 대상을 좁힐 수 있다. 이 글에서는 이 세 가지와 set_event_pid, instance 버퍼 분리까지 실제 출력과 함께 정리한다.

테스트 환경은 Ubuntu 24.04 / 커널 7.0.0-28-generic(6코어)이고, tracefs는 /sys/kernel/tracing에 마운트되어 있다. 아래 명령은 모두 root로 이 디렉터리에서 실행한다.

이벤트를 전부 켜면 벌어지는 일

$ cd /sys/kernel/tracing
$ dd if=/dev/zero of=/tmp/test.img bs=1M count=1500 conv=fsync &   # 디스크 부하
$ echo 1 > events/enable        # 이벤트 1789개 전부
$ echo > trace; echo 1 > tracing_on; sleep 1; echo 0 > tracing_on
$ echo 0 > events/enable

$ wc -l < trace
36241
$ grep -H ^overrun per_cpu/cpu*/stats
per_cpu/cpu0/stats:overrun: 0
per_cpu/cpu1/stats:overrun: 0
per_cpu/cpu2/stats:overrun: 0
per_cpu/cpu3/stats:overrun: 0
per_cpu/cpu4/stats:overrun: 0
per_cpu/cpu5/stats:overrun: 847388

overrun은 버퍼가 가득 차서 덮어써진, 즉 영영 못 보게 된 이벤트 수다. 1초 동안 3만 6천 건을 건지는 대신 84만 건을 버린 셈이라, 무엇을 켤지 좁히는 작업이 먼저다.

이벤트가 가진 필드 확인하기

필터 표현식에는 각 이벤트가 기록하는 필드 이름을 그대로 쓴다. 어떤 필드가 있는지는 이벤트 디렉터리의 format 파일에 있다.

$ grep -E "^\s+field" events/block/block_rq_issue/format
	field:unsigned short common_type;	offset:0;	size:2;	signed:0;
	field:unsigned char common_flags;	offset:2;	size:1;	signed:0;
	field:unsigned char common_preempt_count;	offset:3;	size:1;	signed:0;
	field:int common_pid;	offset:4;	size:4;	signed:1;
	field:dev_t dev;	offset:8;	size:4;	signed:0;
	field:sector_t sector;	offset:16;	size:8;	signed:0;
	field:unsigned int nr_sector;	offset:24;	size:4;	signed:0;
	field:unsigned int bytes;	offset:28;	size:4;	signed:0;
	field:unsigned short ioprio;	offset:32;	size:2;	signed:0;
	field:char rwbs[10];	offset:34;	size:10;	signed:0;
	field:char comm[16];	offset:44;	size:16;	signed:0;
	field:__data_loc char[] cmd;	offset:60;	size:4;	signed:0;

common_으로 시작하는 네 개는 모든 이벤트가 공통으로 갖고, 나머지가 이 이벤트 고유 필드다. 필터에서 쓸 수 있는 연산자는 다음과 같다.

연산자대상
==숫자, 문자열comm == "dd"
!=숫자, 문자열comm != "kworker/0:1H"
< <= > >=숫자bytes >= 262144
&숫자(비트 검사)flags & 0x40
~문자열 glob__filename_val ~ "*passwd*"
&& ||조건 결합comm == "dd" && bytes >= 262144

filter로 이벤트 좁히기

이벤트 디렉터리의 filter 파일에 표현식을 쓰면, 조건에 맞지 않는 이벤트는 버퍼에 기록되지 않는다. 시스템 전체에서 /etc/passwd를 여는 프로세스만 잡아보면 이렇다.

$ ls -R /usr/lib >/dev/null 2>&1 &                      # openat 노이즈 부하
$ echo 'syscalls:sys_enter_openat' > set_event

$ echo 0 > events/syscalls/sys_enter_openat/filter      # 필터 없이
$ echo > trace; echo 1 > tracing_on
$ for i in 1 2 3; do getent passwd root >/dev/null; id -u nobody >/dev/null; sleep 0.2; done
$ echo 0 > tracing_on; grep -c "sys_openat(" trace
6855

$ echo '__filename_val ~ "*passwd*"' > events/syscalls/sys_enter_openat/filter
$ echo > trace; echo 1 > tracing_on
$ for i in 1 2 3; do getent passwd root >/dev/null; id -u nobody >/dev/null; sleep 0.2; done
$ echo 0 > tracing_on; grep -c "sys_openat(" trace
9
$ grep "sys_openat(" trace | head -4
          getent-7379    [001] .....   880.156642: sys_openat(dfd: 4294967196, filename: 138258862109472 "/etc/passwd", flags: O_RDONLY|O_CLOEXEC)
              id-7380    [003] .....   880.159581: sys_openat(dfd: 4294967196, filename: 123995588195104 "/etc/passwd", flags: O_RDONLY|O_CLOEXEC)
              id-7380    [003] .....   880.159591: sys_openat(dfd: 4294967196, filename: 123995588195104 "/etc/passwd", flags: O_RDONLY|O_CLOEXEC)
          getent-7382    [003] .....   880.930340: sys_openat(dfd: 4294967196, filename: 135933393171232 "/etc/passwd", flags: O_RDONLY|O_CLOEXEC)

같은 1초 부하에서 6855건이 9건으로 줄었다. filename 필드는 유저 공간 포인터라 값 자체로는 쓸모가 없고, 커널이 문자열로 복사해둔 __filename_val에 glob을 걸어야 한다 — 이 필드가 없는 커널도 있으니 format으로 먼저 확인한다.

표현식이 틀리면 write가 EINVAL로 실패하는데, 이유는 error_log에 남는다.

$ echo 'filename_val ~ "*passwd*"' > events/syscalls/sys_enter_openat/filter
bash: line 27: echo: write error: Invalid argument
$ tail -4 error_log
[  883.012292] event filter parse error: error: Field not found
  Command: filename_val ~ "*passwd*"
                        ^

캐럿이 문제가 된 위치를 가리킨다. 필터를 걸었는데 이벤트가 하나도 안 잡힐 때 가장 먼저 볼 파일이다.

특정 프로세스만 추적하기

syscall 이벤트에는 comm 필드가 없어 필터로 프로세스를 고를 수 없다. 대신 set_event_pid에 PID를 쓴다.

$ bash -c 'while :; do cat /etc/hostname >/dev/null; sleep 0.05; done' & TARGET=$!
$ ls -R /usr/lib >/dev/null 2>&1 &     # 추적 대상이 아닌 노이즈
$ echo $TARGET > set_event_pid
$ echo 'syscalls:sys_enter_openat' > set_event

$ cat options/event-fork
0
$ grep -c 'sys_openat(' trace          # 1초 추적 결과
0

$ echo 1 > options/event-fork          # 자식까지 따라가기
$ grep -c 'sys_openat(' trace
252
$ grep 'sys_openat(' trace | awk '{print $1}' | sort | uniq -c | sort -rn | head -3
     16 sleep-7663
     16 sleep-7661
     16 sleep-7659

셸 자신은 openat을 거의 하지 않고 실제 작업은 cat/sleep 자식이 하므로, options/event-fork를 켜지 않으면 0건이 나온다. 이 옵션을 켜면 이후 fork되는 자식 PID가 set_event_pid에 자동으로 추가된다.

조건이 맞을 때만 동작하는 트리거

필터가 "기록할지"를 정한다면, 트리거는 "이벤트가 발생했을 때 무엇을 할지"를 정한다. 이벤트 디렉터리의 trigger 파일에 쓴다.

트리거동작
traceon / traceoff트레이싱 전체를 켜거나 끈다
snapshot그 시점의 버퍼를 스냅샷 버퍼로 복사한다
stacktrace해당 지점의 커널 콜스택을 기록한다
enable_event / disable_event다른 이벤트를 켜거나 끈다
hist필드 값을 키로 히스토그램을 누적한다

모든 트리거는 if <필터식>을 붙여 조건을 걸 수 있고, :1처럼 발동 횟수도 제한할 수 있다. 먼저 /etc/shadow가 열리는 순간 버퍼를 얼려 직전 상황을 보존해 본다.

$ echo 'syscalls:sys_enter_openat sched:sched_process_exec' > set_event
$ echo 'traceoff if __filename_val ~ "*/shadow"' > events/syscalls/sys_enter_openat/trigger
$ cat events/syscalls/sys_enter_openat/trigger
traceoff:unlimited if __filename_val ~ "*/shadow"

$ echo 1 > tracing_on
$ getent shadow root >/dev/null      # 트리거 조건 발생
$ cat tracing_on
0
$ tail -5 trace
          getent-7674    [005] .....   905.720775: sys_openat(dfd: 4294967196, filename: 133921041982351 "/etc/ld.so.cache", flags: O_RDONLY|O_CLOEXEC)
          getent-7674    [005] .....   905.720789: sys_openat(dfd: 4294967196, filename: 133921041752384 "/lib/x86_64-linux-gnu/libc.so.6", flags: O_RDONLY|O_CLOEXEC)
          getent-7674    [005] .....   905.720956: sys_openat(dfd: 4294967196, filename: 133921039534256 "/usr/lib/locale/locale-archive", flags: O_RDONLY|O_CLOEXEC)
          getent-7674    [005] .....   905.721005: sys_openat(dfd: 4294967196, filename: 133921039510930 "/etc/nsswitch.conf", flags: O_RDONLY|O_CLOEXEC)
          getent-7674    [005] .....   905.721029: sys_openat(dfd: 4294967196, filename: 133921039512391 "/etc/shadow", flags: O_RDONLY|O_CLOEXEC)

조건이 걸린 이벤트가 버퍼의 마지막 줄로 남고 그 앞이 통째로 보존되므로, "문제가 터지기 직전에 무슨 일이 있었는지"를 그대로 읽을 수 있다.

stacktrace는 조건을 만족한 이벤트의 커널 콜스택을 남긴다. 1MB 이상 크기로 발행된 블록 요청의 경로를 한 번만 찍어보면 이렇다.

$ echo 'block:block_rq_issue' > set_event
$ echo 'stacktrace:1 if bytes >= 1048576' > events/block/block_rq_issue/trigger
$ dd if=/dev/zero of=/tmp/test.img bs=4M count=30 conv=fsync
$ sed -n "/<stack trace>/,+10p" trace
              dd-7844    [001] .....   930.130344: <stack trace>
 => do_trace_event_raw_event_block_rq
 => trace_event_raw_event_block_rq
 => blk_mq_start_request
 => scsi_queue_rq
 => blk_mq_dispatch_rq_list
 => __blk_mq_do_dispatch_sched
 => __blk_mq_sched_dispatch_requests
 => blk_mq_sched_dispatch_requests
 => blk_mq_run_hw_queue
 => blk_mq_dispatch_list

스택 상단 두 프레임은 트레이스 이벤트 자신이고, 그 아래부터가 실제 I/O 제출 경로다.

hist 트리거로 커널 안에서 집계하기

이벤트를 전부 사용자 공간으로 내보내 awk로 합산하는 대신, hist 트리거를 쓰면 커널이 해시 테이블에 직접 누적한다. 프로세스별 블록 쓰기량 집계는 한 줄이면 된다.

$ echo 'hist:keys=comm:vals=bytes:sort=bytes.descending' > events/block/block_rq_issue/trigger
$ echo 'block:block_rq_issue' > set_event
$ echo 1 > tracing_on        # dd 2개 + find|md5sum 부하를 4초간
$ cat events/block/block_rq_issue/hist
# event histogram
#
# trigger info: hist:keys=comm:vals=hitcount,bytes:sort=bytes.descending:size=2048 [active]
#

{ comm: dd                                                 } hitcount:       1588  bytes:  345829376
{ comm: kworker/1:2H                                       } hitcount:         25  bytes:   25387008
{ comm: kworker/2:2H                                       } hitcount:         57  bytes:   21254144
{ comm: kworker/3:0H                                       } hitcount:         85  bytes:   12963840
{ comm: kworker/5:1H                                       } hitcount:         13  bytes:    8437760
{ comm: md5sum                                             } hitcount:          4  bytes:     172032
{ comm: kworker/u24:0                                      } hitcount:          2  bytes:     151552
{ comm: jbd2/sda2-8                                        } hitcount:          1  bytes:      90112
{ comm: kworker/u24:5                                      } hitcount:          2  bytes:         16
{ comm: kworker/0:1H                                       } hitcount:          1  bytes:          0

Totals:
    Hits: 1778
    Entries: 10
    Dropped: 0

버퍼에는 아무것도 안 쌓이고 집계 결과만 hist 파일에 남는다. kworker/*H가 상위에 보이는 것은 페이지 캐시에 쌓인 쓰기를 커널 워커가 대신 발행하기 때문이다.

키는 콤마로 여러 개를 줄 수 있고, 필드 뒤에 .log2를 붙이면 값을 2의 거듭제곱 구간으로 묶는다.

$ echo 'hist:keys=comm,bytes.log2:sort=hitcount.descending' > events/block/block_rq_issue/trigger
$ head -12 events/block/block_rq_issue/hist
# event histogram
#
# trigger info: hist:keys=comm,bytes.log2:vals=hitcount:sort=hitcount.descending:size=2048 [active]
#

{ comm: dd                                                , bytes: ~ 2^12 } hitcount:       6000
{ comm: dd                                                , bytes: ~ 2^22 } hitcount:         48
{ comm: kworker/u24:0                                     , bytes: ~ 2^3  } hitcount:          2
{ comm: kworker/3:0H                                      , bytes: ~ 2^22 } hitcount:          2
{ comm: kworker/0:1H                                      , bytes: ~ 2^0  } hitcount:          1
{ comm: kworker/1:2H                                      , bytes: ~ 2^0  } hitcount:          1
{ comm: kworker/1:2H                                      , bytes: ~ 2^12 } hitcount:          1

같은 dd라도 oflag=direct로 4KB씩 쓴 6000건(2^12)과 페이지 캐시를 거쳐 4MB로 병합된 48건(2^22)이 분리돼 보인다.

synthetic 이벤트로 I/O 지연시간 재기

두 이벤트의 시간 차를 커널 안에서 계산하려면 synthetic 이벤트를 하나 만들고, 시작 이벤트에 타임스탬프를 저장했다가 끝 이벤트에서 onmatch로 이어 붙인다. 블록 요청의 발행→완료 지연시간을 히스토그램으로 뽑는 전체 절차는 네 줄이다.

$ echo 'io_lat u64 lat; u32 nr_sector' >> synthetic_events
$ echo 'hist:keys=sector:ts0=common_timestamp.usecs' > events/block/block_rq_issue/trigger
$ echo 'hist:keys=sector:lat=common_timestamp.usecs-$ts0:onmatch(block.block_rq_issue).trace(io_lat,$lat,nr_sector)' > events/block/block_rq_complete/trigger
$ echo 'hist:keys=lat.log2:sort=lat' > events/synthetic/io_lat/trigger

$ dd if=/dev/zero of=/tmp/test.img bs=64k count=1500 oflag=direct
$ cat events/synthetic/io_lat/hist
# event histogram
#
# trigger info: hist:keys=lat.log2:vals=hitcount:sort=lat.log2:size=2048 [active]
#

{ lat: ~ 2^0  } hitcount:       1212
{ lat: ~ 2^6  } hitcount:          1
{ lat: ~ 2^7  } hitcount:          2
{ lat: ~ 2^8  } hitcount:        209
{ lat: ~ 2^9  } hitcount:         14
{ lat: ~ 2^10 } hitcount:         21
{ lat: ~ 2^11 } hitcount:          8
{ lat: ~ 2^12 } hitcount:         17
{ lat: ~ 2^13 } hitcount:          6
{ lat: ~ 2^15 } hitcount:          3
{ lat: ~ 2^16 } hitcount:          7

Totals:
    Hits: 1500
    Entries: 11
    Dropped: 0

단위는 마이크로초라 대부분(1212건)은 2µs 미만에 끝났지만, 2^16 구간 즉 65ms를 넘긴 요청도 7건 있다. 이런 꼬리는 평균값만 보면 절대 안 보인다.

instance로 버퍼 분리하기

최상위 버퍼는 하나뿐이라 다른 도구나 다른 작업과 설정이 충돌한다. instances/ 아래에 디렉터리를 만들면 이벤트 설정과 버퍼를 통째로 따로 갖는 트레이싱 공간이 생긴다.

$ echo 'sched:sched_process_exec' > set_event      # 최상위 버퍼는 다른 용도로 사용 중
$ mkdir instances/diskwatch
$ echo 'comm == "dd"' > instances/diskwatch/events/block/block_rq_issue/filter
$ echo 'block:block_rq_issue' > instances/diskwatch/set_event
$ echo 1 > instances/diskwatch/tracing_on
$ dd if=/dev/zero of=/tmp/test.img bs=64k count=2000 oflag=direct

$ grep -c block_rq_issue instances/diskwatch/trace
2000
$ grep -c block_rq_issue trace
0
$ grep -c sched_process_exec trace
2
$ rmdir instances/diskwatch                        # 버퍼까지 함께 해제

instance 쪽에만 2000건이 쌓이고 최상위 버퍼는 원래 보던 sched_process_exec만 유지한다. 버퍼 크기(buffer_size_kb)나 트레이서도 instance마다 따로 잡을 수 있다.

주의사항

  • 필터는 이벤트 발생 자체를 막지 못한다. 이벤트 핸들러가 실행되고 인자를 채운 뒤에 기록 여부만 결정하므로, 켜 둔 이벤트 수 자체를 줄이는 것이 오버헤드를 줄이는 유일한 방법이다.
  • 트리거와 필터는 이벤트를 꺼도 남는다. 지우려면 !를 앞에 붙여 등록할 때 쓴 표현식 그대로 다시 써야 한다(예: echo '!hist:keys=comm:vals=bytes:sort=bytes.descending' > .../trigger).
  • hist 해시 테이블은 기본 2048 엔트리다. 키 종류가 그보다 많으면 새 키가 버려지므로, 결과를 믿기 전에 Dropped 값을 확인하고 size=로 늘린다.
  • trace_pipe는 읽는 순간 버퍼에서 이벤트를 소비한다. 같은 내용을 두 번 봐야 하면 trace를 쓴다.
  • 설정은 tracing_on을 0으로 둔 상태에서 하고, 마지막에 echo > trace로 버퍼를 비운 뒤 켠다. 설정 중에 들어온 이벤트가 결과에 섞이지 않는다.

테이블이 넘칠 때의 동작은 size=를 작게 줘서 바로 확인할 수 있다.

$ echo 'hist:keys=sector:size=128' > events/block/block_rq_issue/trigger
$ dd if=/dev/zero of=/tmp/test.img bs=64k count=800 oflag=direct
$ tail -4 events/block/block_rq_issue/hist
Totals:
    Hits: 128
    Entries: 128
    Dropped: 672

섹터 번호처럼 키가 계속 새로 생기는 필드는 800건 중 672건이 통째로 버려졌다. 이런 필드는 키가 아니라 vals이나 .log2 구간으로 쓰는 게 맞다.

마무리

이벤트 트레이싱의 실전 요령은 결국 "커널 안에서 최대한 줄여서 내보내기"다. 켤 이벤트를 고르고(set_event), 필드로 거르고(filter), 조건이 맞는 순간에만 반응하고(trigger), 가능하면 집계까지 커널에 맡긴다(hist). 여기까지 하면 별도 도구 설치 없이 tracefs만으로 "누가 이 파일을 여는가", "어느 프로세스가 디스크를 쓰는가", "I/O 지연시간 분포가 어떤가" 같은 질문에 답할 수 있다.

참고

답글 남기기