IT/모니터링

Pyroscope 2.0 + eBPF로 continuous profiling 시작하기

gfrog 2026. 5. 3. 12:44
반응형

왜 굳이 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

반응형