eBPF (bpf2go) - Tracepoint를 이용한 트레이스 (BPF_PROG_TYPE_TRACEPOINT)
앞서 살펴본 kprobe/kproberet, fentry/fexit와는 달리 tracepoint는,
Using the Linux Kernel Tracepoints
; https://www.kernel.org/doc/html/v4.19/trace/tracepoints.html
Program type BPF_PROG_TYPE_TRACEPOINT
; https://docs.ebpf.io/linux/program-type/BPF_PROG_TYPE_TRACEPOINT/
리눅스 커널에서 미리 준비된 이벤트에 연결하는 방식으로 동작합니다. 관련해서 찾아보면, 다음과 같이 커널 드라이버 개발자도 자신만의 tracepoint를 만들어 사용할 수 있는 것 같습니다.
Using the Linux Kernel Tracepoints
; https://docs.kernel.org/trace/tracepoints.html
제가 아직 저 분야에 대해서는 알지 못해 100% 확실하게 말할 수는 없지만, 아마도 윈도우 환경의 개발자에게 비유하자면
ETL(Event Tracing for Windows)과 비슷한 개념이 아닐까 싶습니다.
그런 의미에서 볼 때, tracepoint는 커널에서 미리 제공하는 기능에 한해서만 사용할 수 있을 텐데요, 가능한 목록은 bpftrace를 이용해 구할 수 있습니다.
$ sudo bpftrace -l "tracepoint:*"
...[생략]...
tracepoint:syscalls:sys_enter_accept
tracepoint:syscalls:sys_enter_accept4
tracepoint:syscalls:sys_enter_access
tracepoint:syscalls:sys_enter_acct
tracepoint:syscalls:sys_enter_add_key
tracepoint:syscalls:sys_enter_adjtimex
tracepoint:syscalls:sys_enter_alarm
tracepoint:syscalls:sys_enter_arch_prctl
tracepoint:syscalls:sys_enter_bind
tracepoint:syscalls:sys_enter_bpf
tracepoint:syscalls:sys_enter_brk
tracepoint:syscalls:sys_enter_capget
...[생략]...
tracepoint:syscalls:sys_enter_vhangup
tracepoint:syscalls:sys_enter_vmsplice
tracepoint:syscalls:sys_enter_wait4
tracepoint:syscalls:sys_enter_waitid
tracepoint:syscalls:sys_enter_write
tracepoint:syscalls:sys_enter_writev
tracepoint:syscalls:sys_exit_accept
tracepoint:syscalls:sys_exit_accept4
tracepoint:syscalls:sys_exit_access
...[생략]...
tracepoint:syscalls:sys_exit_wait4
tracepoint:syscalls:sys_exit_waitid
tracepoint:syscalls:sys_exit_write
tracepoint:syscalls:sys_exit_writev
...[생략]...
또는 /sys/kernel/debug/tracing/events 디렉터리를 살펴봐도 됩니다.
// events 중 syscalls 범주의 목록 확인
$ sudo ls /sys/kernel/debug/tracing/events/syscalls
enable sys_enter_recvmmsg sys_exit_ioperm
filter sys_enter_recvmsg sys_exit_io_pgetevents
sys_enter_accept sys_enter_remap_file_pages sys_exit_iopl
sys_enter_accept4 sys_enter_removexattr sys_exit_ioprio_get
sys_enter_access sys_enter_rename sys_exit_ioprio_set
...[생략]...
sys_enter_pwritev sys_exit_getuid sys_exit_ustat
sys_enter_pwritev2 sys_exit_getxattr sys_exit_utime
sys_enter_quotactl sys_exit_init_module sys_exit_utimensat
sys_enter_quotactl_fd sys_exit_inotify_add_watch sys_exit_utimes
sys_enter_read sys_exit_inotify_init sys_exit_vfork
sys_enter_readahead sys_exit_inotify_init1 sys_exit_vhangup
sys_enter_readlink sys_exit_inotify_rm_watch sys_exit_vmsplice
sys_enter_readlinkat sys_exit_io_cancel sys_exit_wait4
sys_enter_readv sys_exit_ioctl sys_exit_waitid
sys_enter_reboot sys_exit_io_destroy sys_exit_write
sys_enter_recvfrom sys_exit_io_getevents sys_exit_writev
tracepoint는 커널의 함수를 후킹하는 형식이 아니어서 인자를 해당 함수에 대응하는 유형이 아닌, tracepoint 개발 형식에 맞게 구성돼 있습니다. 이벤트를 제공하는 측에서는 TRACE_EVENT() 매크로를 사용하게 되는데,
Using the TRACE_EVENT() macro (Part 1)
; https://lwn.net/Articles/379903/
#undef TRACE_SYSTEM
#define TRACE_SYSTEM sched
#if !defined(_TRACE_SCHED_H) || defined(TRACE_HEADER_MULTI_READ)
#define _TRACE_SCHED_H
TRACE_EVENT(name, proto, args, struct, assign, print)
- name - the name of the tracepoint to be created.
- prototype - the prototype for the tracepoint callbacks
- args - the arguments that match the prototype.
- struct - the structure that a tracer could use (but is not required to) to store the data passed into the tracepoint.
- assign - the C-like way to assign the data to the structure.
- print - the way to output the structure in human readable ASCII format.
이런 식으로 트레이스 시스템마다 정의된 구조체 형식을 다음의 경로에서 확인할 수 있습니다.
[포맷 경로 형식]
/sys/kernel/debug/tracing/events/[TRACE_SYSTEM]/[name]/format
// 예를 들어, syscalls:sys_enter_connect의 경우,
$ sudo cat /sys/kernel/debug/tracing/events/syscalls/sys_enter_connect/format
name: sys_enter_connect
ID: 2407
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:int __syscall_nr; offset:8; size:4; signed:1;
field:int fd; offset:16; size:8; signed:0;
field:struct sockaddr * uservaddr; offset:24; size:8; signed:0;
field:int addrlen; offset:32; size:8; signed:0;
print fmt: "fd: 0x%08lx, uservaddr: 0x%08lx, addrlen: 0x%08lx", ((unsigned long)(REC->fd)), ((unsigned long)(REC->uservaddr)), ((unsigned long)(REC->addrlen))
이러한 포맷 구조는 vmlinux.h에서도 확인할 수 있습니다.
$ cat vmlinux.h | grep -A 5 "struct trace_entry {"
struct trace_entry {
short unsigned int type;
unsigned char flags;
unsigned char preempt_count;
int pid;
};
$ cat vmlinux.h | grep -A 5 "struct trace_event_raw_sys_enter"
struct trace_event_raw_sys_enter {
struct trace_entry ent;
long int id;
long unsigned int args[6];
char __data[0];
};
TRACE_EVENT로 정의된 필드와 vmlinux의 구조체를 대응시키면 대략 이렇게 나오는데요,
type (2바이트) == common_type
flags (1바이트) == common_flags
preempt_count (1바이트) == common_preempt_count
pid (4바이트) == common_pid
id (8바이트) == __syscall_nr (4바이트) + 패딩(4바이트)
args[6] (48바이트) != fd(8바이트) + uservaddr 포인터(8바이트) + addrlen(8바이트), 총 24바이트
args[0] == fd
args[1] == uservaddr
args[2] == addrlen
__data[0]
따라서 sys_enter_connect로 연결하는 소켓의 fd(file descriptor)를 출력하고 싶다면 이런 식으로 코딩할 수 있습니다.
//go:build ignore
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
#include <bpf/bpf_core_read.h>
SEC("tracepoint/syscalls/sys_enter_connect")
int sys_enter_connect(struct trace_event_raw_sys_enter *ctx) {
long unsigned int fd = BPF_CORE_READ(ctx, args[0]);
bpf_printk("sys_enter_connect - fd == %d\n", fd);
return 0;
}
char __license[] SEC("license") = "GPL";
하는 김에 sys_enter_exit도 맞춰 볼까요? ^^
$ sudo cat /sys/kernel/debug/tracing/events/syscalls/sys_enter_exit/format
name: sys_enter_exit
ID: 217
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:int __syscall_nr; offset:8; size:4; signed:1;
field:int error_code; offset:16; size:8; signed:0;
print fmt: "error_code: 0x%08lx", ((unsigned long)(REC->error_code))
$ grep -A 5 "struct trace_event_raw_sys_exit" vmlinux.h
struct trace_event_raw_sys_exit {
struct trace_entry ent;
long int id;
long int ret;
char __data[0];
};
위의 필드에서 중요한 것은 error_code이므로, sys_exit_connect 함수는 이렇게 구현할 수 있습니다.
SEC("tracepoint/syscalls/sys_exit_connect")
int sys_exit_connect(struct trace_event_raw_sys_exit* ctx) {
long unsigned int error_code = BPF_CORE_READ(ctx, ret);
bpf_printk("sys_exit_connect - error_code == %d\n", error_code);
return 0;
}
대충 이 정도로 eBPF 작업은 마무리하고, 이제 저 코드를
bpf2go를 거쳐 .o, .go 파일을 생성합니다.
//go:generate go run github.com/cilium/ebpf/cmd/bpf2go -target amd64 ebpf_basic basic.c
자, 그럼 이렇게 해서 eBPF 코드를 처리했으니, 남은 작업은 go 언어에서 (내부적으로 libbpf를 거쳐) 로드하고 attach하면 되는데요, 이때 사용할 메서드는 Program Section을 참조합니다.
Program Sections
; https://ebpf-go.dev/concepts/section-naming/#program-sections
tracepoint의 경우 ProgramType == TracePoint이므로 다음과 같이 코딩을 완료합니다.
func main() {
requirePrerequisites()
var bpfObj ebpf_basicObjects
opts := ebpf.CollectionOptions{
Programs: ebpf.ProgramOptions{},
}
err := loadEbpf_basicObjects(&bpfObj, &opts) // bpf2go로 자동 생성된 함수
if err != nil {
fmt.Printf("load: objs == null, %v\n", err)
return
}
defer func(bpfObj *ebpf_basicObjects) {
fmt.Printf("bpfObj.defer\n")
_ = bpfObj.Close()
}(&bpfObj)
fmt.Printf("loaded: %v\n", bpfObj)
{
tp, err := link.Tracepoint("syscalls", "sys_enter_connect", bpfObj.ebpf_basicPrograms.SysEnterConnect, nil)
if err != nil {
fmt.Printf("tp == null, %v\n", err)
return
}
defer func(kp link.Link) {
fmt.Printf("link.sys_enter_connect.defer\n")
_ = kp.Close()
}(tp)
fmt.Printf("link.sys_enter_connect: %v\n", tp)
}
{
tp, err := link.Tracepoint("syscalls", "sys_exit_connect", bpfObj.ebpf_basicPrograms.SysExitConnect, nil)
if err != nil {
fmt.Printf("tp == null, %v\n", err)
return
}
defer func(kp link.Link) {
fmt.Printf("link.sys_exit_connect.defer\n")
_ = kp.Close()
}(tp)
fmt.Printf("link.sys_exit_connect: %v\n", tp)
}
fmt.Println("Press any key to exit...")
input := bufio.NewScanner(os.Stdin)
input.Scan()
}
끝입니다, 이제 실행하고 /sys/kernel/debug/tracing/trace_pipe를 확인하면,
$ sudo cat /sys/kernel/debug/tracing/trace_pipe | grep curl
curl-86726 [006] ...21 35884.543056: bpf_trace_printk: sys_enter_connect - fd == 7
curl-86726 [006] ...21 35884.543131: bpf_trace_printk: sys_enter_connect - fd == 7
curl-86726 [006] ...21 35884.543529: bpf_trace_printk: sys_enter_connect - fd == 7
curl-86725 [004] ...21 35884.605325: bpf_trace_printk: sys_enter_connect - fd == 5
curl-87028 [001] ...21 35941.231991: bpf_trace_printk: sys_enter_connect - fd == 7
curl-87028 [001] ...21 35941.232055: bpf_trace_printk: sys_exit_connect - error_code == -2
curl-87028 [001] ...21 35941.232066: bpf_trace_printk: sys_enter_connect - fd == 7
curl-87028 [001] ...21 35941.232071: bpf_trace_printk: sys_exit_connect - error_code == -2
curl-87028 [001] ...21 35941.232309: bpf_trace_printk: sys_enter_connect - fd == 7
curl-87028 [001] ...21 35941.232363: bpf_trace_printk: sys_exit_connect - error_code == 0
curl-87027 [007] ...21 35941.472088: bpf_trace_printk: sys_enter_connect - fd == 5
curl-87027 [007] ...21 35941.472181: bpf_trace_printk: sys_exit_connect - error_code == -115
curl을 실행할 때마다 저렇게 socket connect가 발생하는 것을 볼 수 있습니다.
참고로, tracepoint의 Section 명은 별도로 "tp"로 지정할 수 있습니다. 그래서 본문의 eBPF 소스코드를 다음과 같이 지정해도 무방합니다.
SEC("tp/syscalls/sys_enter_connect")
int sys_enter_connect(struct trace_event_raw_sys_enter *ctx) {
// ...[생략]...
}
SEC("tp/syscalls/sys_exit_connect")
int sys_exit_connect(struct trace_event_raw_sys_exit* ctx) {
// ...[생략]...
}
[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]