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 (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이 어느 워커에 요청을 넘기느냐가 문제다.
Worker B의 카운터가 Worker A보다 작으면 Prometheus 입장에서는 카운터가 감소한 것이다.
Pod 여러 개는 문제가 없다. Prometheus가 각 Pod를 별도 타겟으로 인식하고, instance 라벨로 시계열을 분리하기 때문이다. 반면 워커 여러 개는 같은 포트를 공유하니까 Prometheus는 단일 타겟으로 본다.
# 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 # 프로세스 종료
카운터 값이 어디에 저장되는지 짚고 넘어가자.
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를 직접 써야 한다.
PrometheusMetricReader → OTLPMetricExporter로 바꿔서 각 워커가 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_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을 쓰는 의미가 없어진다.
self._value monkey-patch_SumAggregation.self._value를 multiprocessing.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 처리한다.
워커별 시계열을 합산해야 하니까 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 포맷을 유지하면서 문제를 해결한다.