IT/DevSecOps

Falco eBPF probe는 어떻게 syscall을 잡는가 — modern probe 내부 들여다보기

gfrog 2026. 7. 1. 06:17
SMALL

Falco를 클러스터에 깔아본 사람은 많지만, 이게 정확히 어떤 식으로 syscall을 잡아내는지 끝까지 따라가본 사람은 의외로 적다. 우리 팀에서도 한동안은 "그냥 eBPF로 커널 이벤트 본다더라" 수준의 이해로 운영했다. 그러다가 노드 일부에서 syscall drop이 튀기 시작했고, 원인을 디버깅하다 보니 결국 probe 내부 구조까지 파게 됐다. 이번 글은 그때 정리한 내용을 풀어서 쓴 것이다.

사실 내부적으로는 Falco가 모든 일을 직접 하지 않는다. 사용자 공간에서 룰을 평가하는 falco 데몬과, 커널 공간에서 syscall을 가로채는 probe가 분리돼 있다. 우리가 보통 Helm 차트 옵션에서 driver.kind: modern_ebpf라고 한 줄 적고 넘어가는 그 probe가, 실은 꽤 정교한 파이프라인이다.

probe가 syscall을 잡는 세 가지 경로

Falco 0.36 이후로 공식적으로 지원하는 driver는 세 가지다. 커널 모듈, 레거시 eBPF, 그리고 modern eBPF. 셋 다 결국 syscall enter/exit 이벤트를 잡는다는 점은 같다. 차이는 어디에 어떤 방식으로 훅을 거느냐다.

커널 모듈은 가장 오래된 방식이다. falco-driver-loader가 호스트 커널 헤더로 모듈을 빌드해서 insmod로 올린다. 성능은 제일 좋지만 커널 패닉 위험이 있고, 노드 커널 버전이 올라갈 때마다 재빌드 자동화를 신경 써야 한다. 우리 팀은 EKS AMI를 자주 갈아치우는데 이 부분이 운영 부담으로 작용해서 결국 eBPF로 옮겼다.

레거시 eBPF probe는 libbpf 없이 자체 로더로 BPF 프로그램을 올리는 구조다. kprobe와 raw tracepoint를 섞어 쓴다. 호환성은 넓은데, syscall 테이블에 의존하는 부분이 있어 커널 버전마다 미묘하게 동작이 갈리는 게 단점이다.

modern eBPF probe는 BTF(BPF Type Format)와 CO-RE(Compile Once, Run Everywhere)에 의존한다. 단일 바이너리가 커널 5.8 이상 어디서나 동작한다. 우리 팀은 노드가 전부 6.x 커널이라 이걸 선택했다. 그런데 정확히 BTF가 뭘 해주는지가 처음에 잘 와닿지 않았다.

CO-RE가 진짜로 해결하는 문제

eBPF 프로그램이 커널 구조체에 접근할 때, task_struct 같은 자료구조의 멤버 오프셋은 커널 빌드 옵션에 따라 다 다르다. 같은 5.15 커널이라도 Ubuntu와 RHEL의 task_struct 오프셋이 미묘하게 어긋나 있다. 옛날 방식이라면 호스트에서 커널 헤더로 직접 컴파일하거나, 사전에 빌드된 BTF 자료를 알아서 매칭해야 했다.

CO-RE는 이 문제를 컴파일 시점이 아니라 로드 시점에 해결한다. probe의 ELF 안에 "이 구조체의 이 필드를 읽고 싶다"는 relocation 정보가 박혀 있고, BPF 로더가 호스트 커널의 BTF 정보를 보고 실제 오프셋으로 다시 적어준다. 호스트에 BTF가 없으면? 이 경우 BTFHub에서 사전 추출한 BTF를 받아서 쓰는 폴백이 있다. 우리 환경에서는 /sys/kernel/btf/vmlinux가 다 노출돼 있어서 폴백을 탈 일이 없었다.

이 구조 덕분에 modern probe는 컨테이너 이미지에 단일 ELF 하나만 들어가도 충분하다. 노드마다 드라이버 매칭 안 돼서 빨개지는 일이 사라진다. 운영 입장에서는 이게 가장 큰 변화였다.

이벤트가 사용자 공간까지 흘러오는 길

커널에서 syscall이 발생하면 eBPF probe는 tp/raw_syscalls/sys_entersys_exit tracepoint에 걸린 프로그램을 실행한다. modern probe는 가능하면 raw tracepoint를 쓴다. 일반 tracepoint보다 오버헤드가 적기 때문이다. 그리고 syscall 인자나 반환값을 직렬화해서 perf buffer가 아닌 BPF ring buffer에 밀어 넣는다.

여기서 ring buffer로 바뀐 게 modern probe의 또 하나의 핵심이다. 레거시 probe가 쓰던 perf buffer는 CPU별로 분리된 버퍼라 메모리 사용이 크고, lost event를 직접 카운팅해야 했다. ring buffer(BPF_MAP_TYPE_RINGBUF)는 단일 공유 버퍼에 multi-producer 안전 큐로 동작한다. 같은 메모리에서 더 많은 이벤트를 다룰 수 있고, 사용자 공간에서 polling 효율도 좋다.

사용자 공간 쪽에서는 falco 프로세스가 libscap을 통해 ring buffer를 읽는다. 읽은 이벤트는 libsinsp에서 의미 단위로 가공된다. fd 추적, 프로세스 트리 추적, 컨테이너 컨텍스트 보강 같은 게 여기서 일어난다. 그 결과로 만들어진 enriched event가 룰 엔진으로 들어가서 evt.type=execve and proc.name in (curl, wget) 같은 패턴 매칭에 쓰인다.

[kernel]
  raw_syscalls/sys_enter → BPF prog → ring buffer
                                          │
[user space]                              ▼
  libscap (poll) → libsinsp (enrich) → falco rules engine → output

이 파이프라인을 머릿속에 한 번 그려두면, 드랍이 어디서 나는지 짚는 게 훨씬 쉬워진다.

우리 팀이 syscall drop을 디버깅한 방법

지난달에 일부 노드에서 n_drops_buffer가 분당 수천 건씩 튀는 걸 발견했다. 처음엔 노드가 syscall이 너무 많이 나오는 워크로드(로그 파이프라인 fluent-bit이 무거운 노드들)인 줄로만 알았는데, 그것만으로는 설명이 안 되는 패턴이었다.

원인 추적은 결국 두 갈래로 나뉘었다. 첫째, ring buffer 크기. 기본값이 8MB * CPU 수인데, 96vCPU 노드에서 이 크기가 부족해질 일이 별로 없다고 막연히 생각하고 있었다. 하지만 syscall 폭증 구간에서는 8MB도 빠듯했다. engine.ebpf.buf_size_preset 값을 한 단계 올렸더니 드랍이 절반으로 떨어졌다.

둘째, libsinsp의 enrichment가 병목이었다. ring buffer는 비어 있는데도 사용자 공간 쪽에서 처리가 밀려서 결과적으로 다음 사이클에서 드랍이 났던 것이다. 이쪽은 룰을 정리하면서 evt.type 필터를 좁힌 게 효과가 있었다. 모든 syscall을 다 보는 게 아니라, 실제 룰에 필요한 syscall만 enabling하면 probe 단에서부터 filter-out이 가능하다(base_syscalls.custom_set 옵션).

이 두 조치로 드랍은 거의 사라졌는데, 사실 더 나은 방법이 있을 수도 있다고 생각한다. probe 내부의 batching 옵션은 아직 충분히 안 만져봤다.

운영 입장에서 정리하면

modern eBPF probe가 "기본값"으로 좋은 선택인 건 맞다. 단일 이미지, BTF 기반 자동 적응, ring buffer로 메모리 효율 개선. 다만 syscall이 많은 노드에서는 buf_size 튜닝과 룰별 syscall set 최적화는 거의 필수에 가까웠다. 처음부터 메트릭(falco_n_drops_*)을 Prometheus로 긁어두는 걸 권장한다. 드랍이 나기 시작하면 어떤 종류의 드랍(buffer full, pf, bug)인지에 따라 처방이 다르기 때문이다.

내부 동작을 어느 정도 알고 나니, 룰을 새로 추가할 때 "이 룰이 어떤 syscall들을 추가로 켜게 만드는지"를 의식하게 됐다. 그 자체로도 운영 품질이 한 단계 올라간 느낌이 있다. 더 깊이 들어가면 pman(plugin manager) 영역의 user-space 플러그인까지 봐야 하는데, 이건 다음에 기회되면 따로 정리해보려고 한다.

BIG