Prometheus 고급 가이드 — 메트릭 수집·PromQL·알림·SLO까지

이 글의 핵심

Prometheus에서 고부하 환경의 메트릭을 안정적으로 수집하고, PromQL·Recording Rule로 쿼리 비용을 줄이며, Alertmanager와 연동한 실전 알림·SLO 모니터링까지 한 번에 정리합니다.

들어가며 — 로그만으로는 장애를 잡을 수 없는 이유

예전에 몸담았던 팀은 모니터링 체계 없이 결제 API의 상태를 docker logs -f로 실시간으로 확인하는 방식에 의존하고 있었습니다. 장애 징후가 나타난 것은 점심 무렵이었지만, “어제 무엇을 배포했는지”부터 되짚어야 했고, 결국 새벽 3시가 되어서야 데이터베이스 커넥션 풀이 고갈된 것이 원인이라는 사실을 확인할 수 있었습니다. 그동안 로그에는 “connection refused”라는 메시지만 반복적으로 쌓여 있었을 뿐, 얼마나 많은 요청이 실패하고 있는지, 어느 시점부터 p99 지연이 악화되기 시작했는지는 전혀 파악할 수 없었습니다.

이 경험 이후로는 로그만으로는 장애 대응에 충분하지 않다는 것을 팀 내부 원칙으로 삼았습니다. 집계된 수치인 메트릭과 그에 기반한 알림이 먼저이고, 원인 분석을 위한 로그·트레이스는 그다음 단계입니다. Prometheus는 바로 이 첫 번째 단계, 즉 메트릭 수집과 알림 체계를 구축하는 데 매우 적합한 도구입니다. 이 글에서는 PromQL 활용법부터 Recording/Alert Rule 설계, Kubernetes·Consul 기반 서비스 디스커버리, Exporter와 커스텀 메트릭, Grafana 대시보드 구성, 고가용성(HA)과 Federation, SLO·에러 버짓 모니터링까지 실무에서 바로 적용할 수 있는 내용을 정리합니다.

Prometheus는 풀(pull) 방식으로 시계열 데이터를 수집하는 구조를 가지고 있어서, 팀이 기존에 어떤 모니터링 도구에 익숙한지에 따라 초기 학습 난이도가 다르게 느껴질 수 있습니다. 다만 한번 구조를 이해하고 나면 up, rate(), histogram_quantile 세 가지만으로도 기본적인 관측 체계의 절반은 갖출 수 있습니다. rate() 결과가 비어 있거나 그래프가 이상하게 보일 때—스크랩 간격과 [5m] 윈도 길이가 맞지 않거나, 파드를 교체하는 도중 서비스 디스커버리 갱신이 지연되는 경우—단순히 “Prometheus가 오작동하는 것 아닌가”라고 의심하기 쉽지만, 실제로는 대부분 타겟 설정·라벨·윈도 값 쪽에 원인이 있습니다.

PromQL 기초 다지기

rate()와 irate()의 차이

카운터 메트릭으로 초당 요청 수(RPS)를 확인할 때 rate()는 지정한 윈도 구간 안에서의 평균 초당 증가율을 계산합니다. 순간적인 스파이크는 다소 완화되어 보이지만, SLO나 대시보드 용도로는 이 특성이 오히려 적합합니다. 반면 irate()는 가장 최근 두 개의 샘플만 사용하기 때문에 값이 들쭉날쭉하게 나타나며, “지금 당장 무슨 일이 벌어지고 있는지”를 순간적으로 확인할 때 외에는 잘 사용하지 않는 것이 일반적입니다.

# HTTP 요청 초당 건수 (5분 윈도)
rate(http_requests_total{job="api"}[5m])

히스토그램과 분위수 쿼리

지연 시간은 대부분 히스토그램 타입 메트릭으로 수집하며, _bucket / _sum / _count 세 가지 시계열이 함께 노출되는 것이 기본 구조입니다. histogram_quantile 함수는 버킷 경계가 충분히 촘촘하게 설계되어 있어야 의미 있는 값을 계산할 수 있습니다. SLO 상한선(예: 1초, 2초)에 맞춰 버킷 경계를 미리 설계해 두지 않으면, 꼬리 지연(tail latency) 구간이 뭉개져서 실제로 느린 요청을 놓칠 수 있습니다.

# API 지연 p99 (job 기준, 환경에 맞게 좁혀 쓰기)
histogram_quantile(
  0.99,
  sum by (le, job) (
    rate(http_request_duration_seconds_bucket{job="api"}[5m])
  )
)

라벨 조인으로 여러 메트릭 결합하기

서로 다른 라벨 집합을 가진 시계열을 결합하려면 on(), ignoring()과 함께 group_left 같은 벡터 매칭 옵션을 사용합니다. 다만 이러한 조인 쿼리가 많아질수록 카디널리티와 쿼리 비용이 함께 증가하므로, 자주 사용하는 조합은 Recording Rule로 미리 계산해 두는 편이 운영 부담을 크게 줄여줍니다.

# 예: pod_cpu와 pod_memory 조인 (메트릭명은 환경에 맞게)
sum by (pod, namespace) (rate(container_cpu_usage_seconds_total[5m]))
  * on (pod, namespace) group_left
sum by (pod, namespace) (container_memory_working_set_bytes)

시간 오프셋과 예측 함수

과거 시점과 비교하려면 offset 연산자를 사용할 수 있습니다. predict_linear 함수는 주기성이 뚜렷한 트래픽 패턴에서는 오탐이 발생하기 쉬우므로, 실제 임계값을 정하기 전에 스테이징 환경에서 충분히 검증하는 과정이 필요합니다.

# 1주 전 대비 에러율 변화 (예시)
(
  sum(rate(http_requests_total{status=~"5.."}[5m]))
  /
  sum(rate(http_requests_total[5m]))
)
-
(
  sum(rate(http_requests_total{status=~"5.."}[5m] offset 1w))
  /
  sum(rate(http_requests_total[5m] offset 1w))
)

Recording Rule과 Alert Rule 설계

언제 Recording Rule을 쓰는가

Recording Rule과 Alert Rule을 구분하는 기준은 비교적 명확합니다. 동일한 PromQL 쿼리를 Grafana와 알림 규칙이 반복적으로 실행한다면, Recording Rule로 한 번만 계산해 결과를 저장해 두는 것이 효율적입니다. 반대로 사람이 판단해서 개입해야 하는 조건은 Alert Rule로 정의합니다. 또한 interval 값과 scrape_interval 값을 정수 배수로 맞추어 두면 나중에 쿼리 결과를 해석할 때 혼란을 줄일 수 있습니다.

# recording_rules.yml 예시
groups:
  - name: api_recording
    interval: 30s
    rules:
      - record: job:http_requests:rate5m
        expr: |
          sum by (job) (rate(http_requests_total[5m]))
      - record: job:http_errors:ratio5m
        expr: |
          sum by (job) (rate(http_requests_total{status=~"5.."}[5m]))
          /
          sum by (job) (rate(http_requests_total[5m]))

Alert Rule 작성 시 주의할 점

Alert Rule에서 for 옵션을 사용하면 일시적인 스파이크로 인한 오탐을 걸러낼 수 있지만, 이 값이 너무 길면 실제 장애가 발생했을 때 알림이 늦게 도착하는 문제가 생깁니다. “이 알림은 몇 분간 지속되어야 실제 문제로 판단할 것인가”를 팀 차원에서 사전에 합의해 두는 것이 좋습니다. 알림 메시지에 런북 링크를 포함시켜 두면 온콜 대응 시간을 실질적으로 단축할 수 있으므로, annotations.description에는 실제로 도움이 되는 정보를 담아야 합니다.

# alert_rules.yml 예시
groups:
  - name: api_alerts
    rules:
      - alert: HighErrorRate
        expr: |
          (
            sum by (job) (rate(http_requests_total{status=~"5.."}[5m]))
            /
            sum by (job) (rate(http_requests_total[5m]))
          ) > 0.05
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "높은 HTTP 5xx 비율 (job={{ $labels.job }})"
          description: "5분 윈도 기준 에러 비율이 5%를 넘었습니다. 대시보드/런북 링크를 여기에."

서비스 디스커버리로 타겟 관리하기

Kubernetes SD 설정

Kubernetes 환경이라면 정적인 targets 목록보다 kubernetes_sd_configs를 사용하는 방식이 훨씬 현실적입니다. 파드와 서비스의 메타데이터가 자동으로 라벨로 붙으며, 어노테이션을 통해 스크랩 여부·포트·경로를 조절하는 패턴이 널리 사용됩니다. relabel_configs는 “무엇을 수집할 것인가”를 결정하는 단계이고, metric_relabel_configs는 “저장 시점에 라벨을 어떻게 줄일 것인가”에 가깝습니다. user_id, order_id처럼 카디널리티가 매우 높은 값을 메트릭 라벨에 넣는 것은 TSDB 성능을 급격히 저하시키는 대표적인 원인이므로 반드시 피해야 합니다.

# prometheus.yml 발췌 — kubernetes_sd_configs
scrape_configs:
  - job_name: kubernetes-pods
    kubernetes_sd_configs:
      - role: pod
    relabel_configs:
      - source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_scrape]
        action: keep
        regex: true
      - source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_path]
        action: replace
        target_label: __metrics_path__
        regex: (.+)
      - source_labels: [__address__, __meta_kubernetes_pod_annotation_prometheus_io_port]
        action: replace
        regex: ([^:]+)(?::\d+)?;(\d+)
        replacement: $1:$2
        target_label: __address__

Consul SD 설정

Consul을 서비스 레지스트리로 사용하는 조직이라면 consul_sd_configs로 서비스와 태그 정보를 읽어온 뒤, relabel_configs에서 metrics 태그가 붙은 서비스만 남기는 방식을 자주 사용합니다. 이때 헬스체크는 통과했지만 실제 스크랩 준비가 아직 끝나지 않은 경우가 종종 있으므로, 배포 순서와 타이밍을 함께 고려해야 합니다.

scrape_configs:
  - job_name: consul-services
    consul_sd_configs:
      - server: consul:8500
        services: []
    relabel_configs:
      - source_labels: [__meta_consul_service]
        target_label: job
      - source_labels: [__meta_consul_tags]
        regex: '.*,metrics,.*'
        action: keep

Exporter와 커스텀 메트릭

node_exporter, kube-state-metrics, postgres/redis용 exporter처럼 표준화된 패턴이 이미 정립된 영역은 “우리 팀의 SLO에 어떤 지표가 필요한가”를 먼저 합의한 뒤 도입하는 것이 바람직합니다. 애플리케이션 메트릭은 RED(Rate·Error·Duration) 또는 USE(Utilization·Saturation·Errors) 프레임워크에 맞추어 설계하면 이후 대시보드를 구성하기가 한결 수월해집니다. Go 애플리케이션이라면 아래와 같이 Counter와 Histogram을 정의하고, 실제 서비스에서는 promhttp.Handler()로 /metrics 엔드포인트를 노출한 뒤 미들웨어에서 값을 갱신하는 방식으로 구현합니다.

// 개념 예시: Counter + Histogram (실서비스는 promhttp.Handler() 등으로 노출)
var (
    httpRequests = promauto.NewCounterVec(
        prometheus.CounterOpts{
            Name: "http_requests_total",
            Help: "Total HTTP requests",
        },
        []string{"method", "path", "status"},
    )
    httpDuration = promauto.NewHistogramVec(
        prometheus.HistogramOpts{
            Name:    "http_request_duration_seconds",
            Help:    "HTTP latency",
            Buckets: prometheus.DefBuckets,
        },
        []string{"method", "path"},
    )
)

애플리케이션 메트릭 직접 노출하기: Node.js prom-client와 라벨 카디널리티

Exporter가 없는 자체 서비스는 클라이언트 라이브러리로 /metrics를 직접 노출합니다. Node.js(Express)에서 흔히 쓰는 형태는 다음과 같습니다.

import express from 'express';
import client from 'prom-client';

const app = express();
const register = new client.Registry();
client.collectDefaultMetrics({ register });   // 이벤트 루프 지연, 힙, GC 등

const httpDuration = new client.Histogram({
  name: 'http_request_duration_seconds',
  help: 'HTTP request duration in seconds',
  labelNames: ['method', 'route', 'status'],
  buckets: [0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5],
  registers: [register],
});

app.use((req, res, next) => {
  const end = httpDuration.startTimer();
  res.on('finish', () => {
    // ✅ 라우트 "패턴"만 라벨로: /users/:id — 실제 URL(/users/1234)을 넣지 않는다
    const route = req.route ? req.baseUrl + req.route.path : 'unmatched';
    end({ method: req.method, route, status: String(res.statusCode) });
  });
  next();
});

app.get('/metrics', async (_req, res) => {
  res.set('Content-Type', register.contentType);
  res.end(await register.metrics());
});

여기서 가장 흔한 실수는 라우트 라벨의 대체값입니다. 인터넷 예제에 자주 보이는 route: req.route?.path || req.path는 라우트에 걸리지 않은 요청(404, 스캐너가 두드리는 임의 URL)에서 실제 경로 문자열을 라벨로 넣습니다. 그러면 /wp-login.php, /.env, /random-1234 같은 값마다 새 시계열이 생기고, 히스토그램은 버킷 수만큼 곱해지므로 시계열 수가 공격 트래픽에 비례해 늘어납니다. Prometheus 메모리 사용량은 대체로 활성 시계열 수에 비례하기 때문에, 이런 라벨 하나로 모니터링 서버가 먼저 죽는 일이 생깁니다. 매칭되지 않은 요청은 unmatched 같은 고정값으로 묶고, req.route.path만 쓰면 Router로 마운트한 경로의 앞부분이 빠지므로 req.baseUrl을 함께 붙입니다.

같은 원칙이 모든 라벨에 적용됩니다. user_id, request_id, 이메일, 전체 URL처럼 값의 종류가 사실상 무한한 것은 라벨이 아니라 로그나 트레이스에 둡니다. 이미 카디널리티가 높은 메트릭이 있다면 Prometheus UI의 Status → TSDB Status에서 시계열이 가장 많은 메트릭과 라벨을 확인할 수 있고, 급한 경우 metric_relabel_configs로 문제 라벨을 스크랩 시점에 버립니다. 스크랩 간격도 비용에 직결되므로, 알림 규칙의 for, evaluation_interval과 함께 설계합니다. 짧은 스파이크를 무시하려고 for: 5m을 두었는데 스크랩 간격이 1분이면 실제로는 샘플 5개만 보고 판단하게 됩니다.

Grafana 대시보드 구성 전략

Grafana 대시보드를 설계할 때는 모든 정보를 한 화면에 몰아넣기보다, 장애 대응 시 빠르게 훑어볼 수 있는 요약 대시보드와 원인 분석을 위한 상세 대시보드를 분리하는 방식을 권장합니다. namespace, job, pod 정도의 템플릿 변수만 잘 설계해도 대시보드 활용도가 크게 올라갑니다. 동일한 PromQL을 여러 대시보드에 복사·붙여넣기 하다 보면 팀마다 계산 결과가 미묘하게 달라지는 문제가 발생하기 쉬우므로, 공통 지표의 정의는 Recording Rule에 모아두고 대시보드에서는 그 결과를 참조하는 방식이 유지보수에 유리합니다.

고가용성과 Federation

Prometheus의 고가용성 구성을 “인스턴스를 두 대 띄우는 것”만으로 끝내는 경우가 종종 있는데, 동일한 타겟을 두 인스턴스가 중복으로 스크랩하면 시계열이 겹쳐서 그래프와 알림이 예상과 다르게 동작할 수 있습니다. 샤딩, 단일 리더만 스크랩하도록 하는 구조, Thanos·Mimir·VictoriaMetrics를 활용한 장기 저장 및 글로벌 뷰 구성 등 명확한 아키텍처 의도를 가지고 설계해야 합니다. Federation은 /federate 엔드포인트를 통해 이미 집계된 시계열만 상위 Prometheus로 가져오는 메커니즘이며, 전체 데이터를 복제하는 기능이 아닙니다. 여러 리전의 데이터를 전부 복제하려다 네트워크 부담과 단일 장애점만 키우는 사례를 실무에서 여러 번 확인했습니다.

# 상위 prometheus.yml 발췌
scrape_configs:
  - job_name: federate-region-a
    honor_labels: true
    metrics_path: /federate
    params:
      match[]:
        - '{job="api"}'
    static_configs:
      - targets: ['prometheus-region-a:9090']

SLO와 에러 버짓 모니터링

SLO를 설계할 때는 SLI(무엇을 측정할 것인가), SLO(목표 수치), 에러 버짓(허용 가능한 실패 여력)의 세 가지 개념을 명확히 구분해 정리하는 것이 기본입니다. HTTP 성공률은 아래와 같은 형태로 계산하는 경우가 일반적입니다. 다만 30일 전체 구간을 매번 실시간 쿼리로 계산하면 비용이 크기 때문에, 일 단위로 결과를 기록해 두거나 별도 시스템과 결합하는 방식을 함께 고려해야 합니다.

# 성공 요청 비율 (실무에선 5xx/timeout 규칙에 맞게 조정)
sum(rate(http_requests_total{status!~"5.."}[30d]))
/
sum(rate(http_requests_total[30d]))

번 레이트(burn rate)는 에러 버짓을 비정상적으로 빠른 속도로 소진하고 있는지를 감지하는 패턴입니다. 아래 예시의 14.4와 0.999는 참고용 수치이므로, Google SRE 문헌이나 팀 내부 SLO 문서를 기준으로 환경에 맞게 다시 튜닝해야 합니다. 예시 수치를 그대로 복사해 사용했다가 온콜 알림이 마비되는 사례를 직접 겪은 적이 있으므로, 반드시 실제 트래픽 패턴을 기준으로 검증해야 합니다.

# 예시: 임계는 환경마다 다름
groups:
  - name: slo_burn
    rules:
      - alert: ErrorBudgetBurnFast
        expr: |
          (
            sum(rate(http_requests_total{status=~"5.."}[1h]))
            / sum(rate(http_requests_total[1h]))
          )
          >
          14.4 * (1 - 0.999)
        for: 5m
        labels:
          severity: critical
        annotations:
          summary: "에러 버짓 소진이 빠름 (예시 임계)"

트러블슈팅 체크리스트

Prometheus 운영 중 자주 마주치는 문제는 표로 정리하기보다 진단 흐름으로 이해하는 편이 실무에 더 도움이 됩니다. rate() 결과가 이상하게 나온다면 스크랩 간격, 윈도 길이, 서비스 디스커버리 갱신 주기부터 확인해야 합니다. 히스토그램 분위수 값이 이상하다면 버킷 경계 설계와 라벨 카디널리티를 점검해야 하며, le 라벨에 경로나 사용자 ID처럼 값의 종류가 많은 라벨을 함께 넣는 순간 카디널리티가 폭발적으로 증가할 수 있습니다.

고가용성 구성에서 동일한 알림이 중복으로 발송된다면 중복 스크랩과 외부 라벨 설정, Alertmanager의 그룹핑·인히비션(inhibition) 규칙을 함께 점검해야 합니다. 스크랩 타임아웃이 자주 발생한다면 타겟 서버의 CPU 사용률이나 TLS 핸드셰이크 지연을 scrape_duration_seconds 메트릭으로 함께 확인하는 것이 좋습니다. 디스크 사용량이 선형으로 증가한다면 retention 설정값과 함께 이미 누적된 시계열 개수(prometheus_tsdb_head_series 등)를 점검해야 하며, 단순히 메트릭을 하나 더 추가하기 전에 현재 기준선이 어디인지부터 파악하는 것이 순서입니다.

일반적인 애플리케이션 장애 유형별로도 접근 방식을 정리해 두면 도움이 됩니다. 간헐적인 실패는 DNS 해석 지연, 타임아웃 설정, 레이스 컨디션을 우선 의심하고, 성능 저하는 N+1 쿼리, 락 경합, 동기 I/O 호출을 확인합니다. 메모리 누수는 무한히 증가하는 캐시, 정리되지 않는 이벤트 리스너, 닫히지 않는 커넥션이 주요 원인이며, 배포 직후에만 문제가 발생한다면 환경 변수, 권한, lockfile 불일치를 점검합니다. 설정 불일치는 프로필·시크릿·리전 값이 어긋난 경우가 많고, 데이터 불일치는 멱등성 미보장, 부분 쓰기, 캐시 무효화 누락이 대표적인 원인입니다. 이러한 항목들은 표로 암기하기보다 실제로 한 번씩 겪어보아야 체득되는 경우가 많습니다. 문제를 해결할 때는 최소 재현 케이스 확보 → 최근 변경 사항 축소 → 환경 차이 비교 → 관측 데이터 기반 가설 수립 → 수정 후 부하 테스트, 순서로 접근하는 것이 효율적입니다.

참고 자료

공식 문서는 참고할 내용이 방대하므로 링크로 정리해 둡니다. Prometheus 문서, PromQL 기본, Configuration, Alertmanager, Grafana Prometheus 데이터 소스.

마무리

로그만으로는 장애 대응에 충분하지 않습니다. 메트릭과 알림, SLO 체계가 함께 갖춰져야 “얼마나 많은 요청이, 언제부터, 어떤 원인으로” 실패하고 있는지를 한 화면에서 파악할 수 있으며, 그 이후에야 로그를 통한 세부 원인 분석이 의미를 가집니다. 이 글에서 다룬 PromQL 패턴, Recording/Alert Rule 설계, 서비스 디스커버리, SLO 모니터링을 실제 환경에 적용해 새벽 3시의 장애 대응을 반복하지 않기를 바랍니다.

같이 보면 좋은 글