[카테고리:] 미분류

  • 가시성(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로 갈게요.