Pyroscope 2.0 + eBPF로 continuous profiling 시작하기

왜 굳이 eBPF인가
Pyroscope 2.0이 정식 릴리즈되면서 우리 팀도 한 번 손을 대봤다. 결론부터 말하면 — eBPF 기반으로 깔면 코드 한 줄 안 건드리고 P99 튀는 핫스팟을 잡을 수 있다. 다만 처음 깔 때 알아둬야 할 함정이 몇 개 있어서 정리한다.
이 글은 EKS 1.31 클러스터(노드 약 60대) 기준이고, Grafana Alloy로 eBPF 프로파일러를 띄우는 방식을 기준으로 쓴다. Java/Go/Python 워크로드가 섞여 있는 환경이다.
기존 SDK 방식으로 깔아도 되긴 한다. Java면 async-profiler, Go면 pprof endpoint, Python이면 pyspy를 사이드카로... 근데 워크로드 수가 100개 넘어가면 이걸 다 일일이 깔고 유지하는 게 일이다. 우리 팀에서는 새 서비스가 한 주에 한두 개씩 배포되는데, 매번 프로파일러 설정을 PR로 받아주는 게 영 비효율적이었다.
eBPF는 노드 단위로 한 번만 깔면 그 위에 뜨는 모든 프로세스를 자동으로 프로파일링한다. 추가로 하는 일이 거의 없다. 오버헤드는 우리 측정으로 노드 CPU 1~2% 정도. 99Hz 샘플링 기준이다.
대신 단점도 있다. 라인 단위 프로파일링은 안 된다(주소→심볼 매핑까지만). 그리고 Java 같은 JIT 언어는 추가로 perf-map이 필요하다. 거기는 뒤에서 다룬다.
아키텍처 한 장 요약
[노드 N대]
└─ Alloy DaemonSet (eBPF 프로그램 부착)
│ (gRPC/HTTP)
▼
[Pyroscope server (object storage 기반)]
│
▼
[Grafana → Profiles datasource]
Pyroscope 2.0의 큰 변화는 write-path replication을 없애고 모든 프로파일이 오브젝트 스토리지에 한 번만 쓰인다는 점이다. 그리고 read path가 stateless라 querier가 부하에 따라 오토스케일된다. 이게 실제로 의미가 있냐면 — 우리 같이 쿼리 빈도가 들쑥날쑥한 팀(평소엔 거의 안 보다가 인시던트 터지면 몰려서 본다) 입장에서는 비용이 꽤 줄어든다. 자세한 수치는 좀 더 운영해봐야 알 것 같다.
1. Pyroscope 서버 띄우기
Helm으로 micro-services 모드로 깐다. 단일 바이너리(monolithic) 모드도 있는데, S3 같은 외부 오브젝트 스토리지 쓸 거면 그냥 처음부터 micro-services로 가는 게 마음 편하다.
# pyroscope-values.yaml
pyroscope:
structuredConfig:
storage:
backend: s3
s3:
bucket_name: "our-pyroscope-profiles"
region: "ap-northeast-2"
limits:
ingestion_rate_mb: 16
ingestion_burst_size_mb: 64
max_profile_size_bytes: 10485760
ingester:
replicaCount: 3
persistence:
enabled: true
size: 50Gi
querier:
replicaCount: 2
distributor:
replicaCount: 2
minio:
enabled: false # S3 쓸 거니까 끈다
배포:
helm install pyroscope grafana/pyroscope \
-n profiling --create-namespace \
-f pyroscope-values.yaml
S3 권한은 IRSA로 붙인다. ingester, distributor, querier 세 ServiceAccount에 똑같은 IAM Role을 부여하면 된다.
2. Alloy로 eBPF 프로파일러 깔기
여기가 진짜다. Alloy를 DaemonSet으로 깔고 pyroscope.ebpf 컴포넌트로 노드 위 프로세스를 자동 발견한다.
discovery.kubernetes "pods" {
role = "pod"
}
discovery.relabel "pods" {
targets = discovery.kubernetes.pods.targets
rule {
source_labels = ["__meta_kubernetes_namespace"]
action = "replace"
target_label = "namespace"
}
rule {
source_labels = ["__meta_kubernetes_pod_name"]
action = "replace"
target_label = "pod"
}
rule {
source_labels = ["__meta_kubernetes_pod_container_name"]
action = "replace"
target_label = "container"
}
// 같은 노드 위 컨테이너만 타겟팅
rule {
source_labels = ["__meta_kubernetes_pod_node_name"]
regex = env("HOSTNAME")
action = "keep"
}
}
pyroscope.ebpf "default" {
forward_to = [pyroscope.write.default.receiver]
targets = discovery.relabel.pods.output
collect_interval = "15s"
sample_rate = 97
collect_user_profile = true
collect_kernel_profile = true
}
pyroscope.write "default" {
endpoint {
url = "http://pyroscope-distributor.profiling:4040"
}
external_labels = {
"cluster" = "prod-eks-apne2",
}
}
여기서 잠깐 — sample_rate = 97. 일부러 100을 안 썼다. 다른 perf 기반 도구들(Linux perf 자체나 BCC tools 등)이 100Hz로 잡혀 있는 경우가 많아서, 같은 주기에 샘플링이 겹치면 통계적으로 편향이 생긴다. 99나 97 같은 소수를 쓰는 게 관행이다. 사실 별거 아닌데 모르고 100으로 두면 데이터가 미묘하게 어긋난다.
DaemonSet 띄울 때 주의사항:
hostPID: true # 다른 프로세스 보려면 필수
securityContext:
privileged: true # eBPF 프로그램 부착 권한
runAsUser: 0
volumeMounts:
- name: cgroup
mountPath: /sys/fs/cgroup
readOnly: true
volumes:
- name: cgroup
hostPath:
path: /sys/fs/cgroup
privileged: true가 마음에 안 들면 capabilities로 좁힐 수도 있다. BPF, PERFMON, SYS_RESOURCE, SYS_PTRACE만 주면 동작은 한다. 다만 우리 팀은 노드 자체가 격리된 운영 클러스터라 그냥 privileged로 깠다. 보안팀과 사전 합의는 필수.
3. 첫 프로파일 보기
Grafana에 Profiles 데이터소스를 추가하고 (URL은 http://pyroscope-querier.profiling:4040) Explore에서 본다. 처음 보면 열에서 깜짝 놀라는데, 일단 노드 단위로 모든 프로세스가 다 잡혀 있다. CRI-O, kubelet, containerd 같은 시스템 프로세스까지 다 보인다.
서비스 단위로 보고 싶으면 service_name 라벨로 필터하면 된다. Alloy의 pyroscope.ebpf가 컨테이너 라벨을 자동으로 따와서 붙여준다.
4. Java 프로파일링 — perf-map 함정
여기가 진짜 함정이다. eBPF는 커널/유저 스택을 잡아오긴 하는데, JIT 코드는 주소만 보고 함수 이름을 못 풀어낸다. 그래서 처음 깔면 Java 워크로드는 전부 [unknown]으로 나온다. 멘탈이 살짝 나갔다.
해결은 perf-map-agent를 JVM에 붙이거나, JDK 17+면 -XX:+PreserveFramePointer -XX:+UnlockDiagnosticVMOptions -XX:+DebugNonSafepoints를 켜고 async-profiler가 /tmp/perf-<PID>.map을 쓰게 하면 된다. Alloy의 eBPF 프로파일러가 이 파일을 자동으로 읽어서 심볼을 풀어준다.
JVM 옵션:
-XX:+UnlockDiagnosticVMOptions
-XX:+DebugNonSafepoints
-XX:+PreserveFramePointer
그리고 컨테이너 안에서 perf-map 파일이 노드의 /tmp/perf-<PID>.map에 보여야 한다(PID namespace 때문에). Alloy가 이걸 자동으로 처리하긴 하는데, 컨테이너 내부에서 /proc/self/root/tmp/perf-<PID>.map로 보이게 설정이 잘 됐는지 확인해야 한다. 이거 빠뜨리면 한참 디버깅한다.
Go 바이너리는 별 설정 없이 잘 풀린다. Python은 py-spy 방식의 인터프리터 추적이 별도로 있는데, 우리 팀은 Python 워크로드가 적어서 아직 안 깔았다.
5. 비용 통제 — ingestion_rate 꼭 걸어라
처음 깔 때 한 번 데인 게, ingestion_rate 제한을 안 걸어두면 노드 60대에서 들어오는 프로파일이 분당 수 GB씩 쌓인다. S3 비용이 무서워진다.
테넌트별로 ingestion_rate를 걸고, 더 거친 컨트롤로는 sample_rate를 낮추면 된다. 우리는 운영 환경은 97Hz, 스테이징은 49Hz로 차등을 뒀다. 인시던트 디버깅용으론 이걸로도 충분하다.
오브젝트 스토리지 비용 자체는 생각보다 안 나오는데(압축률이 좋다) — 우리 측정으로 60노드 클러스터 일주일 프로파일이 약 220GB. 한 달이면 1TB 좀 안 되는 정도. 라이프사이클로 30일 후 Glacier로 보내면 쿼리는 거의 안 하지만 보관용으로는 충분하다.
6. 알람은 어떻게 거는가
이게 좀 애매한 부분이다. Pyroscope 자체에는 PromQL 같은 알람용 쿼리 언어가 없다. 대신 profilecli CLI로 특정 함수의 CPU 점유율을 시계열로 뽑아서 Prometheus의 remote_write로 밀어넣을 수는 있다.
근데 이게 좀 번거로워서, 우리 팀은 "주간 회고에서 top 10 hotspot 함수 뽑아서 보는" 정도로만 쓰고 있다. 실시간 알람으로 쓰기엔 아직 도구 체인이 부족하다고 본다. 더 나은 방법 있으면 알려주시라.
마치며
eBPF 기반 continuous profiling은 한 번 깔아두면 인시던트 디버깅 때 진짜 빛을 본다. 지난주에 결제 서비스 P99가 갑자기 200ms씩 튀는 일이 있었는데, Pyroscope 그래프 한 번 보고 특정 정규식 컴파일이 핫패스에 들어간 걸 5분 만에 잡아냈다. 평소에 안 보다가 필요할 때 쓰는 도구로는 가성비가 좋다.
Pyroscope 2.0의 stateless querier 특성이 우리 같은 패턴(평소 안 보다가 몰려서 본다)에 잘 맞는다는 게 두 달 운영하면서 느낀 점이다. 아직 비용 데이터를 더 모아봐야 정확한 비교가 되겠지만.
다음 글에서는 Pyroscope를 OpenTelemetry traces랑 묶어서 distributed profiling으로 쓰는 패턴을 다뤄보려고 한다. OTel SIG에서 profiling signal이 stable로 올라가고 있어서 이쪽도 재밌을 것 같다.
참고
Pyroscope 2.0 release blog
Setup eBPF Profiling on Kubernetes