OpenTelemetry/로그/메트릭/트레이싱 & SLO
TL;DR (체크리스트)
- 프레임: RED(요청·오류·지연) + USE(자원 이용·포화·에러) 대시보드 고정
- 수집 경로: 앱(OTel SDK/자동계측) → OTel Collector →
 트레이스(Tempo/Jaeger) · 메트릭(Prometheus/PMM) · 로그(Loki/ELK)
- 상관관계: 로그에 trace_id/span_id넣고, 메트릭 히스토그램에 exemplar로 트레이스 연결
- 샘플링: ParentBased + Tail Sampling(느림/5xx/에러 태그는 항상 보존)
- SLO: 핵심 API 99.9%, p95 지연 목표, 에러버짓 소모율(버른레이트) 알람 2단계
1) 무엇을 볼 것인가: RED + USE + 비즈니스 KPI
- RED(웹/API):
- http_requests_total,- http_request_duration_seconds{le=}p50/p95/p99
- http_requests_total{status=~"5.."}비율(에러율)
 
- USE(인프라): CPU/메모리/디스크 I/O·대기, DB 커넥션/슬로우쿼리, Redis 메모리·히트율
- 비즈 KPI: 가입/구매/게시 수 등 카운터·퍼널 전환율
2) SLO와 알람(버른레이트)
예: 가용성 99.9%/30일 → 에러버짓 0.1%.
다단계 알람(권장):
- 단기: 5m & 1h 윈도우에서 버른레이트 > 14.4 (강 알람, 즉시 조치)
- 중기: 1h & 6h 윈도우에서 버른레이트 > 6 (주의 알람, 근본원인 분석)
PromQL 예(라벨/메트릭 이름은 환경에 맞게 조정):
# 에러율 (5xx / 전체)
sum(rate(http_requests_total{status=~"5.."}[5m]))
/
sum(rate(http_requests_total[5m]))
버른레이트는 위 에러율을 SLO 허용오류율(예: 0.001)로 나눈 값.
알람 규칙(개념):
# 단기
( 에러율_5m / 0.001 ) > 14.4
and
( 에러율_1h / 0.001 ) > 14.4
3) OpenTelemetry Collector(핵심 파이프라인)
collector.yaml (요지):
receivers:
  otlp:
    protocols: { http: {}, grpc: {} }
processors:
  memory_limiter: { check_interval: 1s, limit_percentage: 80 }
  batch: { timeout: 2s, send_batch_size: 4096 }
  attributes:
    actions:
      - key: deployment.env
        value: prod
        action: upsert
  tailsampling:
    decision_wait: 2s
    policies:
      - name: errors
        type: status_code
        status_code: { status_codes: [ERROR] }
      - name: latency
        type: latency
        latency: { threshold_ms: 500 }    # p95 대상 항상 보존
exporters:
  otlphttp/traces: { endpoint: http://tempo:4318, tls: { insecure: true } }
  prometheus: { endpoint: "0.0.0.0:9464" } # OTel metrics → Prometheus 스크레이프
  loki: { endpoint: http://loki:3100/loki/api/v1/push }
extensions: { health_check: {} }
service:
  extensions: [health_check]
  pipelines:
    traces: { receivers: [otlp], processors: [memory_limiter,batch,tailsampling], exporters: [otlphttp/traces] }
    metrics:{ receivers: [otlp], processors: [memory_limiter,batch], exporters: [prometheus] }
    logs:   { receivers: [otlp], processors: [memory_limiter,batch], exporters: [loki] }
4) FastAPI 자동계측 + 상관관계(Trace ↔ Log ↔ Metric)
pip install opentelemetry-sdk opentelemetry-instrumentation-fastapi \
            opentelemetry-instrumentation-httpx opentelemetry-instrumentation-sqlalchemy \
            opentelemetry-exporter-otlp prometheus-fastapi-instrumentator structlog
# app/otel.py
import os
from opentelemetry import trace
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace.export import BatchSpanProcessor
def setup_otel(app, engine):
    provider = TracerProvider(resource=Resource.create({"service.name":"api","service.version":"1.0.0"}))
    provider.add_span_processor(BatchSpanProcessor(
        OTLPSpanExporter(endpoint=os.getenv("OTLP_GRPC","http://otel-collector:4317"), insecure=True)
    ))
    trace.set_tracer_provider(provider)
    FastAPIInstrumentor.instrument_app(app, excluded_urls="/healthz,/metrics")
    HTTPXClientInstrumentor().instrument()
    SQLAlchemyInstrumentor().instrument(engine=engine.sync_engine)
# app/metrics.py
from prometheus_fastapi_instrumentator import Instrumentator
def setup_metrics(app):
    Instrumentator().instrument(app).expose(app, endpoint="/metrics")
로그에 trace_id 넣기(구조화 로그):
# app/log.py
import logging, json, sys
from opentelemetry import trace
class JsonWithTrace(logging.Formatter):
    def format(self, record):
        span = trace.get_current_span()
        ctx = span.get_span_context() if span else None
        base = {
          "level": record.levelname,
          "msg": record.getMessage(),
          "logger": record.name,
          "trace_id": f"{ctx.trace_id:032x}" if ctx and ctx.is_valid else None,
          "span_id": f"{ctx.span_id:016x}" if ctx and ctx.is_valid else None,
        }
        return json.dumps(base, ensure_ascii=False)
h = logging.StreamHandler(sys.stdout); h.setFormatter(JsonWithTrace())
root = logging.getLogger(); root.handlers=[h]; root.setLevel(logging.INFO)
비즈니스 메트릭(예: 주문 생성 카운터):
from prometheus_client import Counter
orders_created_total = Counter("orders_created_total","Number of created orders", ["org"])
def on_order_created(org_id:str):
    orders_created_total.labels(org=org_id).inc()
5) Next.js(BFF) 트레이스/전파
// instrumentation.ts
import { registerOTel } from '@vercel/otel'
export function register(){ registerOTel({ serviceName: 'web' }) }
BFF → API 호출 시 Trace Context 전파:
// app/api/_lib/fetch.ts
export async function apiFetch(input: string, init: RequestInit = {}) {
  const headers = new Headers(init.headers);
  // Next.js는 서버에서 traceparent를 보유 — 없다면 생성
  headers.set('traceparent', (init as any)?.traceparent ?? crypto.randomUUID());
  return fetch(`${process.env.API}${input}`, { ...init, headers });
}
6) 대시보드 권장(패널 구성)
A. API RED
- RPS(1m rate), 에러율(5xx 비율), p50/p95/p99 지연(엔드포인트 Top N)
- 5xx Top 엔드포인트 테이블(최근 1h)
B. DB/캐시 USE
- Postgres: 커넥션 사용률, 슬로우 쿼리 수, statement_timeout히트 수
- Redis: 메모리/키 수, 히트율, 락 경합(락 키 길이 상위)
C. 트레이스 헷맵
- 서비스 맵(API↔DB↔외부HTTP), 지연 상위 스팬 Top N
- 에러 스팬 샘플 목록(클릭→원본 트레이스)
D. 비즈 KPI
- 가입/주문/업로드 카운터, 퍼널 전환율(주요 단계 대비 비율)
7) 알람 규칙(예시)
# alerts.yml (PrometheusRule)
groups:
- name: api-slo
  rules:
  - alert: APISLOBurnFast
    expr: (sum(rate(http_requests_total{status=~"5.."}[5m])) / sum(rate(http_requests_total[5m]))) / 0.001 > 14.4
      and (sum(rate(http_requests_total{status=~"5.."}[1h])) / sum(rate(http_requests_total[1h]))) / 0.001 > 14.4
    for: 2m
    labels: { severity: critical }
    annotations: { summary: "SLO burn fast (5m & 1h)", runbook: "https://runbooks/api-slo" }
  - alert: APISLOBurnSlow
    expr: (sum(rate(http_requests_total{status=~"5.."}[1h])) / sum(rate(http_requests_total[1h]))) / 0.001 > 6
      and (sum(rate(http_requests_total{status=~"5.."}[6h])) / sum(rate(http_requests_total[6h]))) / 0.001 > 6
    for: 10m
    labels: { severity: warning }
    annotations: { summary: "SLO burn slow (1h & 6h)", runbook: "https://runbooks/api-slo" }
8) 런북(필수 템플릿)
- 증상: 어떤 알람/지표가 어떤 임계치를 넘었는가
- 즉시 조치: 롤백/트래픽 전환/캐시 무효화/리밋 상향 여부
- 진단: 대시보드 링크(트레이스·로그·메트릭), 체크리스트
- 해결: 원인/패치/재발 방지(인덱스, 캐시, 타임아웃, 코드 수정)
- 기록: 포스트모템(원인, 영향, 복구 시간, 액션아이템)
9) 운영 팁 & 안티 패턴
- 안티: 전수 샘플링 100% → 비용 폭증 / 반대로 1% 고정 샘플은 장애 때 맹목
- 해결: Tail Sampling으로 느린/에러 요청은 항상 보존, 정상은 샘플 낮춤
- 안티: 로그에 트레이스ID 미포함 → 원인 상관조인 불가
- 해결: 로거에 현재 스팬 컨텍스트 주입(위 스니펫)
- 안티: 지표 라벨 폭증(카디널리티 폭탄)
- 해결: 사용자ID·세션ID 같은 고카디널리티 라벨 금지, 샘플링 또는 로그로 전환
10) 48시간 액션 플랜
- OTel Collector 배포(위 yaml) + 헬스체크 연결
- FastAPI에 OTel/Prometheus/구조화 로그 적용, /metrics노출
- Next.js instrumentation.ts+ BFF→APItraceparent전파
- RED/USE 최소 대시보드 1장 생성(Grafana)
- 알람 규칙 2개(APISLOBurnFast/Slow) 등록 및 테스트
- 로그/트레이스 상관 조회 PoC(로그에서 trace_id 클릭→Tempo 열기)
11) 2주 액션 플랜
- Tail Sampling 튜닝(정책: 5xx, 지연>p95, 특정 경로 상시)
- 비즈 KPI 메트릭 표준화(라벨: org/plan) & 대시보드
- Exemplar 연동(지연 히스토그램 ↔ 트레이스)
- DB/Redis 전용 대시보드 + 느린쿼리 알람
- 런북 저장소 정리 및 모든 알람에 runbook주석 연결
- 월간 SLO 리포트 자동생성(버짓 소모·주요 장애·근본 원인)
12) “복붙” 모음
A. FastAPI 부팅 조립
app = FastAPI()
setup_metrics(app)
setup_otel(app, engine)   # traces
# log 초기화는 import 시 실행됨 (JsonWithTrace)
B. p95 패널용 PromQL
histogram_quantile(0.95, sum by (le) (rate(http_request_duration_seconds_bucket[5m])))
C. 응답 헤더에 traceid 노출(디버그용)
@app.middleware("http")
async def add_trace_header(req, call_next):
    from opentelemetry import trace
    resp = await call_next(req)
    ctx = trace.get_current_span().get_span_context()
    if ctx.is_valid: resp.headers["X-Trace-Id"] = f"{ctx.trace_id:032x}"
    return resp
마무리
관측성은 “보는 순간 의심되는 곳으로 바로 점프”가 전부입니다.
RED/USE 대시보드 + Tail Sampling + 상관 가능한 로그만 제대로 깔아도, 장애 대응 속도가 한 단계 올라갑니다.
다음은 9편. CI/CD & IaC — GitHub Actions · Docker · Terraform · Helm · GitOps로 갈게요.
답글 남기기