- Published on
Pyroscope로 Kubernetes 애플리케이션 Continuous Profiling 구축하기
- Authors
- Name
- 들어가며
- Continuous Profiling이란?
- Pyroscope 아키텍처
- 언어별 SDK 연동
- Flame Graph 분석
- Grafana 대시보드 연동
- 실전 성능 개선 사례
- 프로파일 비교 (Diff View)
- 정리
들어가며
Metrics, Logs, Traces에 이은 관측성의 네 번째 기둥이 Continuous Profiling입니다. CPU는 충분한데 레이턴시가 높다면? 메모리 사용량이 서서히 증가한다면? 전통적인 모니터링으로는 원인을 찾기 어렵습니다.
Pyroscope는 Grafana 생태계에 통합된 continuous profiling 도구로, 코드 레벨에서 성능 병목을 지속적으로 추적합니다.
Continuous Profiling이란?
기존 프로파일링은 "문제가 발생한 후" 수동으로 실행했지만, Continuous Profiling은 항상 켜져 있는 프로파일러입니다.
| 항목 | 전통적 프로파일링 | Continuous Profiling |
|---|---|---|
| 실행 시점 | 문제 발생 후 수동 | 상시 자동 수집 |
| 오버헤드 | 높음 (10-50%) | 낮음 (2-5%) |
| 히스토리 | 없음 | 시계열 저장 |
| 환경 | 개발/스테이징 | 프로덕션 |
| 비교 분석 | 어려움 | 시간대별 비교 가능 |
Pyroscope 아키텍처
Pyroscope는 두 가지 컴포넌트로 구성됩니다:
- Pyroscope Server: 프로파일 데이터를 수집, 저장, 쿼리
- Agent/SDK: 애플리케이션에서 프로파일 데이터를 수집하여 서버로 전송
Kubernetes에 Pyroscope 설치
# Helm으로 Pyroscope 설치
helm repo add grafana https://grafana.github.io/helm-charts
helm repo update
helm install pyroscope grafana/pyroscope \
--namespace monitoring \
--create-namespace \
--set pyroscope.extraArgs.storage.backend=filesystem \
--set persistence.enabled=true \
--set persistence.size=50Gi
Grafana Alloy로 eBPF 프로파일링
Grafana Alloy(구 Grafana Agent)를 사용하면 코드 수정 없이 eBPF로 프로파일을 수집할 수 있습니다.
# alloy-config.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: alloy-config
namespace: monitoring
data:
config.alloy: |
pyroscope.ebpf "instance" {
forward_to = [pyroscope.write.endpoint.receiver]
targets_only = false
default_target = {"service_name" = "unspecified"}
targets = discovery.kubernetes.pods.targets
}
discovery.kubernetes "pods" {
role = "pod"
}
pyroscope.write "endpoint" {
endpoint {
url = "http://pyroscope:4040"
}
}
# alloy-daemonset.yaml
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: alloy
namespace: monitoring
spec:
selector:
matchLabels:
app: alloy
template:
metadata:
labels:
app: alloy
spec:
hostPID: true # eBPF에 필수
containers:
- name: alloy
image: grafana/alloy:latest
args:
- run
- /etc/alloy/config.alloy
securityContext:
privileged: true
runAsUser: 0
volumeMounts:
- name: config
mountPath: /etc/alloy
- name: sys-kernel
mountPath: /sys/kernel
volumes:
- name: config
configMap:
name: alloy-config
- name: sys-kernel
hostPath:
path: /sys/kernel
언어별 SDK 연동
Python (FastAPI)
# pip install pyroscope-io
import pyroscope
pyroscope.configure(
application_name="order-service",
server_address="http://pyroscope:4040",
tags={
"region": "ap-northeast-2",
"env": "production",
},
)
from fastapi import FastAPI
app = FastAPI()
@app.get("/orders/{order_id}")
async def get_order(order_id: str):
# 이 함수의 CPU/메모리 사용이 자동으로 프로파일됨
order = await db.fetch_order(order_id)
return order
Go
package main
import (
"github.com/grafana/pyroscope-go"
"net/http"
)
func main() {
pyroscope.Start(pyroscope.Config{
ApplicationName: "api-gateway",
ServerAddress: "http://pyroscope:4040",
ProfileTypes: []pyroscope.ProfileType{
pyroscope.ProfileCPU,
pyroscope.ProfileAllocObjects,
pyroscope.ProfileAllocSpace,
pyroscope.ProfileInuseObjects,
pyroscope.ProfileInuseSpace,
pyroscope.ProfileGoroutines,
pyroscope.ProfileMutexCount,
pyroscope.ProfileMutexDuration,
pyroscope.ProfileBlockCount,
pyroscope.ProfileBlockDuration,
},
Tags: map[string]string{
"env": "production",
},
})
http.HandleFunc("/health", healthHandler)
http.ListenAndServe(":8080", nil)
}
Java (Spring Boot)
// build.gradle
// implementation 'io.pyroscope:agent:0.13.1'
// application.yml
// pyroscope:
// application-name: user-service
// server-address: http://pyroscope:4040
// format: jfr
import io.pyroscope.javaagent.PyroscopeAgent;
import io.pyroscope.javaagent.config.Config;
@SpringBootApplication
public class UserServiceApplication {
public static void main(String[] args) {
PyroscopeAgent.start(
new Config.Builder()
.setApplicationName("user-service")
.setServerAddress("http://pyroscope:4040")
.setProfilingEvent(EventType.ITIMER)
.setFormat(Format.JFR)
.build()
);
SpringApplication.run(UserServiceApplication.class, args);
}
}
Flame Graph 분석
Flame Graph 읽는 법
Flame Graph에서:
- X축: 샘플 비율 (넓을수록 해당 함수에서 더 많은 시간 소비)
- Y축: 호출 스택 깊이 (아래에서 위로)
- 색상: 무작위 (구분용)
# Pyroscope CLI로 프로파일 조회
pyroscope query \
--app-name order-service \
--from "now-1h" \
--until "now" \
--profile-type cpu
병목 진단 패턴
CPU 병목 예시:
main.handleRequest (40%)
└── db.QueryRow (35%)
└── net/http.(*conn).readRequest (30%)
└── crypto/tls.(*Conn).Read (25%)
해석: TLS 핸드셰이크가 CPU의 25%를 차지 → 커넥션 풀링으로 해결
메모리 누수 예시:
runtime.mallocgc (60%)
└── encoding/json.(*Decoder).Decode (45%)
└── main.processLargePayload (40%)
해석: JSON 디코딩에서 대량의 메모리 할당 → 스트리밍 파서로 전환
Grafana 대시보드 연동
# Grafana에 Pyroscope 데이터 소스 추가
# Settings > Data Sources > Add > Grafana Pyroscope
# URL: http://pyroscope:4040
유용한 Grafana 패널 설정
{
"targets": [
{
"datasource": { "type": "grafana-pyroscope-datasource" },
"profileTypeId": "process_cpu:cpu:nanoseconds:cpu:nanoseconds",
"labelSelector": "{service_name=\"order-service\"}",
"queryType": "profile"
}
]
}
Traces와 Profiling 연동
Tempo(분산 추적)와 Pyroscope를 연동하면, 느린 trace에서 바로 해당 시점의 프로파일을 확인할 수 있습니다.
# Tempo 설정에 Pyroscope 연동 추가
# tempo.yaml
overrides:
defaults:
profiles:
pyroscope:
url: http://pyroscope:4040
실전 성능 개선 사례
사례 1: N+1 쿼리 발견
# 프로파일에서 db.fetch_order가 80%의 CPU를 차지
# Flame Graph로 확인하면 같은 쿼리가 반복 호출
# Before: N+1
async def get_orders_with_items(user_id):
orders = await db.fetch_orders(user_id)
for order in orders:
order.items = await db.fetch_items(order.id) # N번 호출!
return orders
# After: JOIN으로 1번 조회
async def get_orders_with_items(user_id):
return await db.fetch_orders_with_items(user_id) # 1번 호출
사례 2: 메모리 누수 추적
// 프로파일에서 runtime.mallocgc가 지속적으로 증가
// inuse_space 프로파일로 확인
// Before: 버퍼를 매번 새로 할당
func processRequest(data []byte) {
buf := make([]byte, 1024*1024) // 매 요청마다 1MB 할당
// ...
}
// After: sync.Pool로 버퍼 재사용
var bufPool = sync.Pool{
New: func() interface{} {
buf := make([]byte, 1024*1024)
return &buf
},
}
func processRequest(data []byte) {
buf := bufPool.Get().(*[]byte)
defer bufPool.Put(buf)
// ...
}
프로파일 비교 (Diff View)
# 배포 전후 프로파일 비교
pyroscope diff \
--app-name order-service \
--left-from "2026-03-02T10:00:00Z" \
--left-until "2026-03-02T11:00:00Z" \
--right-from "2026-03-03T10:00:00Z" \
--right-until "2026-03-03T11:00:00Z" \
--profile-type cpu
Grafana에서도 Explore 패널에서 두 시간대의 프로파일을 오버레이하여 비교할 수 있습니다. 빨간색은 증가, 녹색은 감소를 나타냅니다.
정리
Continuous Profiling은 관측성의 마지막 퍼즐입니다:
- Metrics: "무엇이" 느린지 알려줌
- Logs: "무슨 일이" 일어났는지 알려줌
- Traces: "어디서" 느린지 알려줌
- Profiles: "왜" 느린지 알려줌 (코드 레벨)
Pyroscope + Grafana 조합으로 프로덕션 환경의 성능 문제를 코드 레벨까지 추적하세요.
✅ 퀴즈: Continuous Profiling 이해도 점검 (7문제)
Q1. Continuous Profiling이 전통적 프로파일링과 다른 점은?
항상 켜져 있어 프로덕션에서 지속적으로 수집하며, 오버헤드가 2-5%로 낮고, 시간대별 비교가 가능합니다.
Q2. eBPF 프로파일링의 장점은?
코드 수정 없이 커널 레벨에서 모든 프로세스의 프로파일을 수집할 수 있습니다.
Q3. Flame Graph에서 X축이 의미하는 것은?
해당 함수(와 하위 함수)에서 소비한 시간의 비율입니다. 넓을수록 더 많은 시간을 사용합니다.
Q4. Grafana Alloy DaemonSet에서 hostPID: true가 필요한 이유는?
eBPF가 호스트의 프로세스 정보에 접근하려면 호스트 PID 네임스페이스가 필요합니다.
Q5. 메모리 누수를 추적하는 데 적합한 프로파일 타입은?
inuse_space (현재 사용 중인 메모리) 프로파일로 시간에 따라 증가하는 할당을 추적합니다.
Q6. Diff View의 활용 사례는?
배포 전후의 프로파일을 비교하여 새 코드가 성능에 미친 영향을 확인합니다.
Q7. Traces와 Profiling을 연동하면 어떤 이점이 있나요?
느린 trace에서 해당 시점의 코드 레벨 프로파일로 바로 이동하여 병목 원인을 정확히 파악할 수 있습니다.