Gunicorn 멀티워커 환경 Prometheus 메트릭 오류 분석

김영상 (dudtkd1221)·2026년 3월 15일

Gunicorn 멀티워커 환경에서 Prometheus 메트릭이 뻥튀기되는 이유

Grafana에서 /health RPS가 실제보다 5~7배 높게 찍혔다. 원인을 파고들었더니 Gunicorn + OTEL SDK + Prometheus pull 방식의 구조적 충돌이었다.


증상

카운터 시계열이 이렇게 움직이고 있었다.

347 → 306 → 354 → 311 → 358 → 320 → ...

카운터는 절대 감소하면 안 된다. 근데 감소하고 있다.

Prometheus의 rate()는 카운터가 감소하면 "프로세스가 재시작됐다"고 판단한다. 그리고 감소 이후 값 전체를 새로운 증가분으로 계산한다.

[t=0]  카운터 = 120
[t=15] 카운터 = 80  ← 감소

→ Prometheus: "리셋 발생. 80까지 새로 쌓인 것."
→ rate = 80 / 15 = 5.3 req/s  (실제는 몇 건 안 됨)

이게 RPS 부풀림의 원인이다.


왜 카운터가 감소하나

Gunicorn 워커별 독립 메모리

현재 구성은 이렇다.

Gunicorn (sync workers 3개, preload_app=False)
├── Worker A: OTEL SDK → in-memory 카운터
├── Worker B: OTEL SDK → in-memory 카운터
└── Worker C: OTEL SDK → in-memory 카운터

Prometheus는 15초마다 GET /metrics를 호출해서 카운터를 읽어간다. 이때 Gunicorn이 어느 워커에 요청을 넘기느냐가 문제다.

  • t=0 scrape → Worker A가 응답 → 347 반환
  • t=15 scrape → Worker B가 응답 → 306 반환

Worker B의 카운터가 Worker A보다 작으면 Prometheus 입장에서는 카운터가 감소한 것이다.

Pod 여러 개는 문제가 없다. Prometheus가 각 Pod를 별도 타겟으로 인식하고, instance 라벨로 시계열을 분리하기 때문이다. 반면 워커 여러 개는 같은 포트를 공유하니까 Prometheus는 단일 타겟으로 본다.

워커 교체(recycle)로 카운터 리셋

# gunicorn.conf.py
max_requests = 1000
max_requests_jitter = 100

이 설정은 메모리 누수 방지용이다. 워커가 요청을 1000개쯤 처리하면 Gunicorn 마스터가 해당 프로세스를 kill하고 새 프로세스를 fork한다. 새 프로세스의 카운터는 0부터 시작한다.

Gunicorn 소스 (gunicorn/workers/sync.py):

self.nr += 1
if self.nr >= self.max_requests:
    self.log.info("Autorestarting worker after current request.")
    self.alive = False  # 프로세스 종료

OTEL SDK 구조

카운터 값이 어디에 저장되는지 짚고 넘어가자.

HTTP 요청
  → DjangoInstrumentor (자동 계측)
    → otel_middleware.py: counter.add(1, attrs)
      → instrument.py: Counter.add() → Measurement 생성
        → aggregation.py: _SumAggregation.aggregate()
          → self._value += 1  ← 프로세스 힙 메모리의 Python int

self._value는 그냥 메모리 변수다. 프로세스 죽으면 같이 날아간다.

Prometheus scrape 시에는 PrometheusMetricReader가 이 값을 읽어서 prometheus_client 형식 텍스트로 변환한다. 각 워커가 자기 메모리를 읽어서 내보내기 때문에, 어느 워커가 응답하느냐에 따라 값이 달라진다.


검토한 해결책과 기각 이유

prometheus_multiproc_dir (prometheus_client 멀티프로세스 모드)

mmap 파일 기반 공유 메모리를 쓰는 방법이다. OTEL SDK와 호환이 안 된다. OTEL은 _SumAggregation.self._value(in-memory)에 값을 쌓고, PrometheusMetricReader가 그걸 bridge하는 구조다. prometheus_client의 mmap 파일에는 아무것도 쓰지 않는다. 적용하려면 OTEL을 버리고 prometheus_client를 직접 써야 한다.

OTLP push 방식

PrometheusMetricReaderOTLPMetricExporter로 바꿔서 각 워커가 OTEL Collector에 push하는 방법이다. 구조적으로 깔끔하게 해결된다. 그런데 DevOps팀이 OTEL Collector 운영 부담을 이유로 거부했다.

max_requests = 0 (워커 교체 비활성화)

카운터 리셋은 줄겠지만, 워커 간 scrape 교차 문제는 그대로다. 그리고 메모리 누수 방지 기능을 포기해야 한다.

preload_app=True

마스터에서 앱을 한 번 로드하면 REGISTRY가 공유될 것 같지만, fork 후 각 워커 메모리는 Copy-on-Write로 독립된다. 결국 같은 문제다. 추가로 OTEL SDK 내부의 lock, thread, file descriptor를 fork하면 fork-safety 위반 가능성이 있다. 현재 코드에 preload_app=False로 설정된 이유가 이것이다.

prometheus_client 직접 사용 (OTEL 제거)

prometheus_multiproc_dir까지 붙이면 멀티워커 문제는 해결된다. 문제는 메트릭 이름과 라벨이 OTEL semantic convention과 달라진다.

OTEL:  http_server_request_duration_seconds{http_request_method="GET", http_response_status_code="200"}
직접:  http_requests_total{method="GET", status="200"}

OTEL semconv랑 동일하게 직접 정의하면? 가능은 하다. 그런데 그러면 OTEL 자동 계측을 직접 미들웨어로 재구현하는 것이 되고, semconv 버전 변경 시 직접 따라가야 한다. OTEL을 쓰는 의미가 없어진다.

OTEL SDK self._value monkey-patch

_SumAggregation.self._valuemultiprocessing.Value로 교체하는 아이디어다. 이걸 제대로 하려면 self._previous_value, self._lock, aggregate(), collect() 메서드까지 다 바꿔야 한다. Histogram이면 self._bucket_counts 리스트도 공유 메모리로 교체해야 한다. 결국 OTEL aggregation 레이어를 통째로 재구현하는 수준이고, _internal private 클래스라 SDK 버전 올리면 깨질 수 있다.


채택한 방법: service.instance.id에 PID 포함

OTEL Python SDK 공식 이슈 #3885에서 제안된 방법이다.

# shared/observability/metrics.py
import os

_resource = Resource.create({
    "service.name": _SERVICE_NAME,
    "service.instance.id": f"{os.getenv('HOSTNAME', 'local')}:{os.getpid()}",
})

코드 변경은 이게 전부다.

각 워커가 다른 PID를 가지므로 Prometheus에 별도 시계열로 노출된다.

# Worker A (pid 101)
http_server_request_duration_seconds_count{service_instance_id="pod-abc:101"} 347

# Worker B (pid 102)
http_server_request_duration_seconds_count{service_instance_id="pod-abc:102"} 306

각 시계열은 해당 워커만 값을 기록하니까 단조 증가가 보장된다. 워커 교체 시 새 PID → 새 시계열이 생기고, 기존 시계열은 Prometheus가 5분 후 stale 처리한다.

Grafana PromQL 변경

워커별 시계열을 합산해야 하니까 sum()을 붙인다.

# 기존
rate(http_server_request_duration_seconds_count[1m])

# 변경
sum(rate(http_server_request_duration_seconds_count[1m])) by (http_request_method, http_route)

다른 마이크로서비스에는 영향 없다. service_instance_id 라벨이 없는 서비스에서도 sum(rate(...)) PromQL은 정상 동작한다. 합산할 시계열이 하나뿐이면 원래 값 그대로 나온다.


정리

멀티프로세스 WSGI 서버(Gunicorn, uWSGI)에 Prometheus pull 방식을 쓰면 이 문제가 구조적으로 발생한다. OTEL SDK는 프로세스 간 메트릭 공유를 지원하지 않는다.

service.instance.id에 PID를 박아서 워커별 시계열을 분리하는 방법은 코드 1줄 변경으로 OTEL 포맷을 유지하면서 문제를 해결한다.

profile
아직 배고프다

0개의 댓글