eBPF로 커널 레벨 관측하기: Cilium, Pixie, Parca, Tetragon과 bpftrace
이 글의 핵심
애플리케이션에 SDK나 사이드카를 심지 않고 커널 레벨에서 트래픽과 성능을 보고 싶을 때 eBPF 기반 도구가 선택지가 됩니다. 도구마다 푸는 문제가 달라 Cilium은 CNI와 L7 정책, Pixie는 자동 관측, Parca는 CPU 프로파일링을 맡으며, 오버헤드·보안·커널 버전 제약도 함께 따져야 합니다. 마지막에 단계별 도입 순서와 BTF 미지원, 비어 있는 플레임그래프 같은 트러블슈팅을 담았습니다.
eBPF가 관측 도구의 기반이 된 이유
eBPF(extended Berkeley Packet Filter)는 커널 소스를 고치거나 모듈을 로드하지 않고, 커널 안에서 검증된 작은 프로그램을 실행하는 리눅스 기술입니다. 패킷 필터에서 출발했지만 지금은 커널 함수, 시스템콜, 네트워크 스택, 유저 공간 함수의 거의 모든 지점에 프로그램을 붙일 수 있어 관측, 보안, 네트워킹 도구의 공통 기반이 되었습니다. 애플리케이션 입장에서는 SDK를 넣거나 사이드카를 붙이지 않아도 커널이 보는 모든 것을 볼 수 있다는 점이 핵심입니다.
이 글에서 다루는 도구는 역할이 서로 다릅니다. Cilium은 eBPF 기반 CNI로 Pod 네트워킹, 네트워크 정책, 로드밸런싱을 맡고, Hubble은 Cilium 위에서 네트워크 플로우를 보여 줍니다. Pixie는 계측 없이 애플리케이션 요청을 자동 수집하고, Parca는 상시 CPU 프로파일링을 합니다. Falco와 Tetragon은 런타임 보안 탐지와 차단을, bpftrace와 BCC는 운영자가 즉석에서 쓰는 트레이싱 도구를 담당합니다. 아래에서는 eBPF의 동작 원리를 짧게 정리한 뒤 각 도구의 사용법과 운영 시 주의점을 봅니다.
eBPF의 동작 구조
사용자 공간 커널 공간
───────────── ─────────────
eBPF 프로그램 (C/Rust) ┌──────────────────┐
│ │ Verifier │
▼ │ (안전성 검증) │
컴파일 → BPF 바이트코드 ──► │ JIT → 네이티브 │
│ │
│ kprobe/tracepoint│
│ XDP/tc/socket │
│ perf_event │
│ │ │
│ BPF Map ◄───────┼──► 사용자 공간이 읽기
└────────────────────┘
eBPF 프로그램은 C나 Rust로 작성해 BPF 바이트코드로 컴파일하고, bpf() 시스템콜로 커널에 로드합니다. 커널의 verifier는 프로그램이 유한한 시간 안에 끝나는지, 범위를 벗어난 메모리에 접근하지 않는지, 허용된 헬퍼 함수만 호출하는지를 검사하고, 통과한 프로그램만 JIT 컴파일해 네이티브 코드로 실행합니다. 커널 모듈과 달리 버그 있는 프로그램이 커널을 망가뜨리기 전에 로드 단계에서 거부되는 구조입니다.
프로그램은 붙는 위치에 따라 종류가 나뉩니다. XDP는 네트워크 드라이버에서 패킷이 커널 스택에 들어가기 전에, tc는 트래픽 제어 계층에서 실행됩니다. kprobe/kretprobe는 커널 함수의 진입과 반환에, uprobe는 유저 공간 함수에, tracepoint는 커널에 미리 정의된 안정적인 이벤트 지점에 붙습니다. perf_event 프로그램은 주기적인 샘플링에, LSM 프로그램은 보안 훅에 쓰입니다.
커널 쪽 프로그램과 사용자 공간 도구는 BPF Map으로 데이터를 주고받습니다. 해시, 배열, 링 버퍼, LRU, CPU별(per-CPU) 맵 등이 있으며, 커널에서 집계를 끝내고 결과만 사용자 공간으로 넘기기 때문에 이벤트마다 데이터를 복사하는 방식보다 오버헤드가 작습니다.
커널마다 내부 구조체의 레이아웃이 다르다는 문제는 CO-RE(Compile Once – Run Everywhere)가 해결합니다. 커널이 BTF(BPF Type Format)라는 타입 메타데이터를 제공하면, 로더(libbpf)가 로드 시점에 구조체 필드 오프셋을 실제 커널에 맞게 고쳐 주므로 한 번 빌드한 바이너리를 여러 커널 버전에서 쓸 수 있습니다.
Cilium: 네트워킹·보안·관측을 하나로
왜 Cilium인가
kube-proxy의 iptables 모드는 Service마다 규칙을 추가하고, 패킷이 규칙 목록을 순서대로 훑으므로 Service 수가 늘수록 규칙 갱신과 매칭 비용이 함께 커집니다. Cilium은 정책, Service 로드밸런싱, NAT를 eBPF 해시 맵 조회로 구현해 Service 수와 거의 무관한 비용으로 처리합니다.
Cilium이 제공하는 기능은 다음과 같습니다.
- CNI: Pod 네트워킹과 IPAM
- 네트워크 정책: 쿠버네티스 NetworkPolicy(L3/L4)에 더해 HTTP 경로·메서드, gRPC, Kafka 수준의 L7 정책
- kube-proxy 대체: Service 로드밸런싱을 eBPF로 처리해 iptables 규칙을 없앰
- 서비스 메시: Pod마다 사이드카를 두지 않고, L7 처리는 노드당 Envoy 프록시가 담당
- 암호화: WireGuard나 IPsec을 이용한 노드 간 투명 암호화, 그리고 SPIFFE/SPIRE 기반 상호 인증
- Hubble: 플로우 관측 CLI와 UI
- Tetragon: 같은 팀이 만든 별도 프로젝트로, 런타임 보안 관측과 차단
설치 (EKS 예시)
helm repo add cilium https://helm.cilium.io/
helm install cilium cilium/cilium --version 1.16.0 \
--namespace kube-system \
--set eni.enabled=true \
--set ipam.mode=eni \
--set egressMasqueradeInterfaces=eth+ \
--set routingMode=native \
--set kubeProxyReplacement=true \
--set k8sServiceHost=<EKS API 엔드포인트> \
--set k8sServicePort=443 \
--set hubble.relay.enabled=true \
--set hubble.ui.enabled=true \
--set hubble.metrics.enabled="{dns,drop,tcp,flow,port-distribution,icmp,http}"
EKS에서는 AWS VPC CNI와 마찬가지로 Pod가 VPC IP를 직접 받도록 ENI IPAM 모드를 씁니다. kubeProxyReplacement=true로 kube-proxy를 대체할 때는 Cilium이 kube-proxy 없이 API 서버에 접근해야 하므로 k8sServiceHost와 k8sServicePort를 반드시 지정합니다. 기존 aws-node(VPC CNI) DaemonSet과 kube-proxy를 정리하는 순서는 Cilium 공식 EKS 설치 문서를 따르고, 차트 버전은 문서에서 현재 지원되는 버전으로 고정합니다.
L7 네트워크 정책 예시
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: payment-api
namespace: prod
spec:
endpointSelector:
matchLabels: {app: payment}
ingress:
- fromEndpoints:
- matchLabels: {app: order}
toPorts:
- ports:
- {port: "8080", protocol: TCP}
rules:
http:
- method: "POST"
path: "/api/v1/charge"
- method: "GET"
path: "/api/v1/status"
이 정책을 적용하면 Order 서비스는 Payment의 8080 포트에 POST /api/v1/charge와 GET /api/v1/status만 호출할 수 있고, 다른 경로나 메서드는 403으로 거부됩니다. 쿠버네티스 표준 NetworkPolicy는 IP와 포트(L3/L4)까지만 다룰 수 있어 이런 경로 단위 제어는 표현할 수 없습니다. L7 규칙이 있는 트래픽은 노드의 Envoy 프록시를 거치므로, 모든 트래픽에 L7 정책을 거는 것보다 필요한 서비스에만 적용하는 편이 지연과 자원 면에서 유리합니다.
Hubble로 네트워크 플로우 관찰
cilium hubble port-forward &
hubble observe --follow --pod prod/order-7d8c9f
출력은 대략 다음과 같은 형태입니다(필드는 버전에 따라 다르며 일부 생략했습니다).
prod/order-7d8c9f:51234 -> prod/payment-3f2a1b:8080 http-request FORWARDED (HTTP/1.1 POST http://payment:8080/api/v1/charge)
prod/order-7d8c9f:51234 <- prod/payment-3f2a1b:8080 http-response FORWARDED (HTTP/1.1 200 12ms (POST http://payment:8080/api/v1/charge))
prod/order-7d8c9f:40112 -> prod/db-0:5432 to-endpoint FORWARDED (TCP Flags: SYN)
L3/L4 플로우는 기본으로 보이지만, HTTP 메서드나 응답 코드 같은 L7 정보는 해당 트래픽에 L7 정책이나 L7 가시성 설정이 있어 Envoy를 거칠 때만 기록됩니다. 외부 도메인 이름도 DNS 정책으로 DNS 가시성을 켜야 IP 대신 이름으로 표시됩니다. 애플리케이션을 고치지 않고 Pod 간 연결과 정책 거부(DROPPED)를 실시간으로 볼 수 있어, “왜 이 Pod가 저 Pod에 연결하지 못하는가”를 확인하는 데 특히 유용합니다.
Pixie: 계측 없는 애플리케이션 관측
어떤 문제를 푸는가
OpenTelemetry나 상용 APM은 대부분 애플리케이션에 SDK나 에이전트를 넣어야 합니다. Pixie는 노드마다 에이전트(PEM)를 띄우고 eBPF로 소켓 관련 시스템콜과 TLS 라이브러리 함수를 후킹해, 오가는 데이터를 프로토콜별로 파싱합니다. 그래서 HTTP, gRPC, MySQL, PostgreSQL, Redis, DNS 같은 트래픽을 애플리케이션 수정 없이 수집합니다. TLS 트래픽은 OpenSSL 같은 라이브러리의 암호화 전후 함수에 uprobe를 걸어 평문을 읽는 방식이라, 지원하지 않는 TLS 구현이나 정적 링크된 바이너리에서는 보이지 않을 수 있습니다.
설치
# Pixie CLI 설치
bash -c "$(curl -fsSL https://withpixie.ai/install.sh)"
# 현재 kubeconfig의 클러스터에 Vizier(Pixie 데이터 플레인) 배포
px deploy
배포가 끝나면 재배포나 코드 변경 없이 클러스터의 요청이 수집되기 시작합니다.
PxL 쿼리 언어
import px
df = px.DataFrame('http_events', start_time='-5m')
df = df[df.ctx['namespace'] == 'prod']
df.latency_ms = df.latency / 1e6
px.display(df[['req_path', 'resp_status', 'latency_ms']])
PxL은 Pandas와 비슷한 문법의 쿼리 언어입니다. 수집한 데이터는 클러스터 안의 Vizier가 노드 메모리에 보관하고 쿼리도 클러스터 안에서 실행하므로, 원시 요청 데이터가 외부로 나가지 않습니다. 대신 보관 기간은 메모리 한도에 따라 정해지는 짧은 구간이라 장기 분석 용도는 아닙니다.
새 배포 직후 에러율이 오르면 서비스별 뷰에서 어느 Pod가 5xx를 내는지 바로 확인하고, http_events로 경로별 지연 분포를 보며, mysql_events나 pgsql_events로 느린 쿼리를 실시간으로 찾고, dns_events로 실패하는 도메인 조회를 확인하는 식으로 씁니다. 오버헤드는 공식 문서상 낮은 한 자릿수 퍼센트 CPU 수준으로 안내되지만, 노드마다 데이터를 보관할 메모리를 예약하므로 도입 전에 노드 메모리 여유를 확인해야 합니다.
Parca: 지속 프로파일링
지속 프로파일링이란
perf나 pprof 같은 기존 프로파일러는 문제가 생긴 뒤 사람이 직접 실행합니다. 그런데 문제는 이미 지나간 경우가 많고, 운영 서버에서 무거운 프로파일러를 돌리기도 부담스럽습니다. 지속 프로파일링(continuous profiling)은 낮은 빈도로 항상 스택을 샘플링해 저장해 두고, 나중에 원하는 시간대와 서비스의 플레임그래프를 꺼내 보는 방식입니다. Google이 사내 전체 서버를 상시 프로파일링한 Google-Wide Profiling 논문이 이 접근을 널리 알렸고, 오픈소스로는 Polar Signals가 만든 Parca와 Grafana Pyroscope가 대표적입니다.
Parca Agent 배포
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: parca-agent
namespace: parca
spec:
selector:
matchLabels: {app: parca-agent}
template:
metadata:
labels: {app: parca-agent}
spec:
hostPID: true
containers:
- name: agent
image: ghcr.io/parca-dev/parca-agent:<버전>
args:
- --node=$(NODE_NAME)
- --remote-store-address=parca-server.parca:7070
- --remote-store-insecure
securityContext:
privileged: true
env:
- name: NODE_NAME
valueFrom: {fieldRef: {fieldPath: spec.nodeName}}
위 매니페스트는 구조를 보여 주기 위해 줄인 것입니다. 실제 배포에서는 공식 매니페스트나 Helm 차트를 쓰는 편이 안전한데, 에이전트가 /sys/kernel/debug, cgroup 파일시스템 같은 호스트 경로 마운트와 권한을 필요로 하기 때문입니다. 이미지 태그도 latest 대신 버전을 고정합니다. 에이전트는 노드의 모든 프로세스 CPU 스택을 eBPF perf_event 프로그램으로 주기적으로 샘플링하므로, 애플리케이션을 고치거나 다시 빌드할 필요가 없습니다.
활용
가장 흔한 용도는 배포 전후 플레임그래프를 비교해 새로 생긴 CPU 핫패스를 찾는 것입니다. CPU 사용률이 배포 후 올랐는데 어느 함수 때문인지 모를 때, 두 시간대의 프로파일 차이를 보면 늘어난 스택이 바로 드러납니다. 에이전트가 수집하는 것은 CPU 프로파일이며, 메모리 할당 프로파일이 필요하면 Go의 pprof 엔드포인트처럼 애플리케이션이 노출하는 프로파일을 Parca 서버가 스크레이프하도록 따로 설정합니다. 네이티브 언어(Go, Rust, C/C++) 외에 인터프리터나 JIT 언어의 심벌 해석 지원 범위는 에이전트 버전마다 다르므로 문서에서 확인합니다.
Falco와 Tetragon: 런타임 보안
eBPF는 보안 관측에도 쓰입니다. Falco는 시스템콜 이벤트를 규칙 엔진으로 평가해 “컨테이너 안에서 /etc/shadow를 읽는다”, “셸이 실행됐다” 같은 의심 행동을 실시간으로 탐지하고 알립니다. Tetragon은 kprobe, tracepoint, LSM 훅에 정책을 걸어 이벤트를 관측하고, 조건이 맞으면 커널 안에서 바로 프로세스를 종료하는 등의 대응까지 할 수 있습니다.
apiVersion: cilium.io/v1alpha1
kind: TracingPolicy
metadata:
name: block-curl-in-web
spec:
podSelector:
matchLabels: {app: web}
kprobes:
- call: "sys_execve"
syscall: true
args:
- index: 0
type: "string"
selectors:
- matchArgs:
- index: 0
operator: "Postfix"
values: ["/curl", "/wget"]
matchActions:
- action: Sigkill
이 정책은 app: web Pod에서 실행 파일 경로가 /curl이나 /wget으로 끝나는 execve가 호출되면, 커널의 kprobe 핸들러에서 해당 프로세스에 SIGKILL을 보냅니다. 사용자 공간 에이전트가 이벤트를 받아 판단한 뒤 대응하는 방식보다 지연이 짧아, 프로그램이 실행되기 전에 막을 수 있습니다. 다만 경로 이름 매칭은 바이너리 이름을 바꾸면 우회되므로, 이런 정책은 탐지와 다른 통제(읽기 전용 파일시스템, 최소 이미지)를 보완하는 수단으로 씁니다.
직접 쓰는 eBPF: bpftrace
운영 중인 서버에서 “어떤 프로세스가 파일을 많이 여는지”, “디스크 I/O 지연이 어떻게 분포하는지”를 바로 확인하고 싶을 때는 bpftrace를 씁니다.
# openat(2) 시스템콜 호출 횟수를 프로세스 이름별로 집계
sudo bpftrace -e 'tracepoint:syscalls:sys_enter_openat {
@[comm] = count();
}'
# 블록 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 = hist(nsecs - @start[args->dev, args->sector]);
delete(@start[args->dev, args->sector]);
}'
두 번째 스크립트는 요청을 장치와 섹터로 식별해 발행 시각을 맵에 저장하고, 완료 시 차이를 히스토그램에 넣습니다. 발행을 보지 못한 요청(스크립트 시작 전에 발행된 요청)이 엉뚱한 값을 만들지 않도록 완료 쪽에 조건(필터)을 둡니다. Ctrl+C로 종료하면 맵 내용이 출력됩니다. bpftrace는 awk처럼 한 줄 스크립트로 쓰는 트레이싱 언어이고, BCC 저장소에는 execsnoop, opensnoop, biolatency처럼 바로 쓸 수 있는 도구가 수십 개 들어 있습니다.
성능과 안전성 고려
오버헤드
eBPF 프로그램은 JIT 컴파일되어 네이티브 코드로 실행되지만, 비용이 없는 것은 아닙니다. 오버헤드는 프로그램이 붙은 지점이 얼마나 자주 호출되는지에 비례합니다. 초당 수십만 번 호출되는 커널 함수에 kprobe를 걸면, 한 번의 비용이 작아도 합계는 무시할 수 없습니다. 그래서 고빈도 지점은 tracepoint나 샘플링으로 대체하고, 이벤트를 하나하나 사용자 공간으로 보내기보다 맵에서 집계한 결과만 읽는 것이 원칙입니다. XDP는 커널이 패킷 버퍼(skb)를 만들기 전에 실행되므로 DDoS 차단처럼 대량의 패킷을 버리는 작업에 특히 효율적입니다.
보안
eBPF 프로그램을 로드하려면 원래 root가 필요했지만, 커널 5.8부터는 CAP_BPF와 CAP_PERFMON(네트워크 프로그램은 CAP_NET_ADMIN)으로 권한을 나눌 수 있습니다. 그래도 eBPF는 커널의 거의 모든 데이터를 읽을 수 있으므로, eBPF를 쓰는 에이전트는 노드 전체에 대한 권한을 가진 것과 같다고 보고 Cilium, Pixie, Parca, Falco처럼 검증된 구성 요소에만 허용합니다. 비특권 사용자의 eBPF 로드는 대부분의 배포판에서 kernel.unprivileged_bpf_disabled로 막혀 있으며 그대로 두는 것이 좋습니다. SELinux나 AppArmor 정책이 엄격한 노드에서는 에이전트가 BPF 파일시스템이나 debugfs에 접근하지 못해 기동에 실패할 수 있으므로 도입 전에 확인합니다.
커널 버전
도구마다 요구하는 최소 커널 버전이 다릅니다. Cilium 1.16은 5.4 이상(일부 기능은 5.10 이상)을 요구하고, Pixie는 4.14 이상을 요구하며, Parca Agent도 최근 커널을 전제로 합니다. 정확한 값은 버전마다 바뀌므로 각 도구의 시스템 요구 사항 문서를 확인하고, 새로 구성하는 클러스터라면 6.1, 6.6 같은 6.x LTS 커널을 쓰는 것이 무난합니다.
도입 순서
여러 도구를 한꺼번에 들이기보다 효과가 크고 위험이 낮은 것부터 하나씩 붙이는 편이 안전합니다. CNI 교체는 클러스터 네트워크 전체에 영향을 주므로, 새 클러스터라면 처음부터 Cilium으로 구성하고 기존 클러스터라면 Cilium의 마이그레이션 문서를 따라 노드 단위로 옮깁니다. Cilium이 자리 잡으면 Hubble을 켜는 것만으로 네트워크 가시성이 생깁니다. 그다음 Pixie를 기존 APM과 병행해 디버깅 용도로 쓰고, Parca로 상시 프로파일링을 시작하며, Falco나 Tetragon은 처음에는 탐지 전용으로 운영해 오탐을 줄인 뒤 차단 정책으로 넘어갑니다. bpftrace와 BCC 도구는 설치 부담이 거의 없으니 운영팀이 장애 대응 때 쓸 수 있도록 미리 익혀 두면 좋습니다.
트러블슈팅
libbpf: kernel doesn't support BTF
커널이 CONFIG_DEBUG_INFO_BTF=y로 빌드되지 않아 /sys/kernel/btf/vmlinux가 없을 때 나옵니다. CO-RE 기반 도구는 이 타입 정보로 커널 구조체 오프셋을 맞추므로, BTF가 없으면 로드할 수 없습니다. 주요 배포판은 5.8 무렵 이후 커널에서 기본으로 켜 두었으므로, 오래된 커널이나 직접 빌드한 커널을 쓰는 노드에서 주로 만납니다. 커널을 올릴 수 없다면 BTFHub 같은 외부 BTF 파일을 제공하는지 도구 문서를 확인합니다.
Cilium Pod가 init 컨테이너에서 실패
Cilium의 init 컨테이너는 BPF 파일시스템(/sys/fs/bpf)과 cgroup v2를 호스트에 마운트하고 커널 설정을 확인합니다. 노드 OS가 이 마운트를 허용하지 않거나, Pod Security Admission 같은 정책이 특권 컨테이너를 막는 경우 여기서 실패합니다. kubectl logs -n kube-system <cilium-pod> -c <init 컨테이너 이름>으로 어느 단계에서 실패했는지 먼저 보고, kube-system 네임스페이스의 보안 정책과 노드의 bpffs·cgroup 마운트 상태를 확인합니다.
Pixie 데이터 누락
특정 서비스의 요청이 보이지 않는다면 먼저 프로토콜과 TLS 라이브러리가 Pixie의 지원 대상인지 확인합니다. 트래픽이 많은 클러스터에서 오래된 데이터가 사라지는 것은 Vizier가 메모리 한도 안에서 오래된 데이터부터 버리기 때문이므로, 쿼리의 start_time 구간을 줄이거나 PEM 메모리 한도를 늘립니다.
Parca 플레임그래프가 비어 있거나 주소만 보임
스택을 걸으려면 프레임 포인터나 DWARF 언와인딩 정보가 필요하고, 함수 이름을 보려면 심벌이 필요합니다. Go 바이너리는 기본으로 심벌을 포함하지만 -ldflags="-s -w"로 빌드하면 빠집니다. C/C++/Rust는 배포 바이너리를 strip하는 경우가 많으므로, 디버그 정보를 별도로 보관해 Parca의 debuginfo 업로드 기능으로 서버에 올리거나 debuginfod 서버를 연결합니다.