가시성(Observability)

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) 런북(필수 템플릿)

  1. 증상: 어떤 알람/지표가 어떤 임계치를 넘었는가
  2. 즉시 조치: 롤백/트래픽 전환/캐시 무효화/리밋 상향 여부
  3. 진단: 대시보드 링크(트레이스·로그·메트릭), 체크리스트
  4. 해결: 원인/패치/재발 방지(인덱스, 캐시, 타임아웃, 코드 수정)
  5. 기록: 포스트모템(원인, 영향, 복구 시간, 액션아이템)

9) 운영 팁 & 안티 패턴

  • 안티: 전수 샘플링 100% → 비용 폭증 / 반대로 1% 고정 샘플은 장애 때 맹목
  • 해결: Tail Sampling으로 느린/에러 요청은 항상 보존, 정상은 샘플 낮춤
  • 안티: 로그에 트레이스ID 미포함 → 원인 상관조인 불가
  • 해결: 로거에 현재 스팬 컨텍스트 주입(위 스니펫)
  • 안티: 지표 라벨 폭증(카디널리티 폭탄)
  • 해결: 사용자ID·세션ID 같은 고카디널리티 라벨 금지, 샘플링 또는 로그로 전환

10) 48시간 액션 플랜

  • OTel Collector 배포(위 yaml) + 헬스체크 연결
  • FastAPI에 OTel/Prometheus/구조화 로그 적용, /metrics 노출
  • Next.js instrumentation.ts + BFF→API traceparent 전파
  • RED/USE 최소 대시보드 1장 생성(Grafana)
  • 알람 규칙 2개(APISLOBurnFast/Slow) 등록 및 테스트
  • 로그/트레이스 상관 조회 PoC(로그에서 trace_id 클릭→Tempo 열기)

11) 2주 액션 플랜

  1. Tail Sampling 튜닝(정책: 5xx, 지연>p95, 특정 경로 상시)
  2. 비즈 KPI 메트릭 표준화(라벨: org/plan) & 대시보드
  3. Exemplar 연동(지연 히스토그램 ↔ 트레이스)
  4. DB/Redis 전용 대시보드 + 느린쿼리 알람
  5. 런북 저장소 정리 및 모든 알람에 runbook 주석 연결
  6. 월간 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로 갈게요.

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다