들어가며
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
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
"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
@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 조합으로 프로덕션 환경의 성능 문제를 코드 레벨까지 추적하세요.
**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에서 해당 시점의 코드 레벨 프로파일로 바로 이동하여 병목 원인을 정확히 파악할 수 있습니다.
현재 단락 (1/231)
Metrics, Logs, Traces에 이은 관측성의 네 번째 기둥이 **Continuous Profiling**입니다. CPU는 충분한데 레이턴시가 높다면? 메모리 사용량이 서...