버킷 경계를 바꾸면 p99가 출렁이는 이유 — histogram_quantile은 무엇을 계산하나

seonwoo_jung·6일 전

1. p99가 실측값이 아니라는 걸 알아챈 순간

대시보드에 histogram_quantile(0.99, ...)로 찍히는 p99를 나는 오랫동안 "실제로 관측된 요청들 중 99번째 백분위에 해당하는 응답시간"이라고 믿었다. 그런데 버킷 경계(le) 설정을 조금 바꾸자 같은 트래픽인데도 p99 값이 눈에 띄게 출렁였다. 실제 관측값을 그대로 정렬해서 뽑은 숫자라면 경계를 바꾼다고 값이 달라질 리가 없다. 그래서 이 숫자가 대체 무엇을 근거로 나오는지 promql/quantile.gobucketQuantile 구현까지 따라가 봤다.

결론부터 핵심 한 문장으로 박아두면 이렇다.

Prometheus 히스토그램은 미리 정한 경계별 누적 카운터일 뿐이고, histogram_quantile은 목표 순위가 떨어지는 버킷을 찾아 경계 사이를 선형 보간해 되돌려주는 근사값이다 — 원본 관측값은 어디에도 저장되지 않는다.

2. 히스토그램은 하나의 메트릭이 아니라 여러 시계열

http_request_duration_seconds라는 히스토그램을 선언하면, 실제로 저장되는 것은 아래처럼 경계별로 쪼개진 여러 시계열이다.

http_request_duration_seconds_bucket{le="0.1"}    # ≤0.1s 관측 수 (누적)
http_request_duration_seconds_bucket{le="0.25"}   # ≤0.25s (아래 버킷 포함)
http_request_duration_seconds_bucket{le="0.5"}
http_request_duration_seconds_bucket{le="1"}
http_request_duration_seconds_bucket{le="+Inf"}   # == _count
http_request_duration_seconds_sum                  # 관측값 합
http_request_duration_seconds_count                # 총 관측 수

핵심은 _bucket누적(cumulative)이라는 점이다. le="0.5" 버킷은 "0.5초 이하 관측의 총 개수"이고, 당연히 le="0.25"의 값을 포함한다. 클라이언트 라이브러리는 observe(v) 호출 시 v <= le인 모든 상위 버킷을 1씩 증가시킨다. 모든 버킷은 단조 증가 카운터라 프로세스 재시작 전까지 줄지 않으며, 정의상 le="+Inf"_count와 같다. 개별 관측값은 어디에도 남지 않고, 남는 것은 "경계별로 몇 개가 그 아래였나"라는 카운트뿐이다.

3. bucketQuantile — 누적 카운트에서 순위를 되짚는 보간

histogram_quantile(φ, b)는 버킷 시계열 집합을 받아 다음 순서로 계산한다. promql/quantile.go의 흐름을 그대로 옮기면:

  1. 버킷을 le 오름차순으로 정렬한다. 최상위는 반드시 +Inf여야 하고, 아니면 NaN을 낸다.
  2. 목표 순위 rank = φ * count를 구한다 (count+Inf 버킷 값, 즉 총 관측 수).
  3. 누적 카운트가 rank 이상이 되는 첫 버킷을 이진 탐색으로 찾는다.
  4. 그 버킷 구간 [하한, 상한]에서 선형 보간한다.
bucketStart = (첫 버킷이면 0, 아니면 이전 버킷의 le)
bucketEnd   = 목표 버킷의 le
count       = 목표 버킷 누적 수 − 이전 버킷 누적 수   # 이 버킷만의 개수
rankInBkt   = rank − 이전 버킷 누적 수                # 버킷 안에서의 순위
결과 = bucketStart + (bucketEnd − bucketStart) * (rankInBkt / count)

즉 "버킷 안에서는 관측값이 균등 분포한다"고 가정하고 경계 사이를 직선으로 나눈다. 이 균등 가정이 근사의 원천이자 오차의 원천이다. 버킷이 넓거나 분포가 한쪽으로 쏠려 있을수록 실제와 벌어진다.

경계 예외 하나가 결과를 크게 좌우한다. 목표 버킷이 최상위 +Inf면 보간할 상한이 없으므로, Prometheus는 두 번째로 큰 유한 le을 그대로 반환한다. 다시 말해 목표 순위가 가장 큰 유한 버킷을 넘어가면, 실제값이 얼마든 그 경계로 clamp된다. 버킷 경계를 바꿀 때 p99가 출렁였던 것도 이 때문이었다 — 순위가 어느 버킷에 걸리느냐, 그리고 그 버킷의 폭이 얼마냐에 따라 보간 결과가 통째로 달라진다.

4. 숫자로 따라가 보기 — clamp가 실제로 어떻게 터지나

공식에 구체적 숫자를 넣어 손으로 검증해 보면 감이 확실히 잡힌다. rate 적용 후 누적 카운트가 아래와 같다고 하자(총 100).

le=0.1 : 20     le=0.25 : 50     le=0.5 : 90
le=1   : 98     le=+Inf : 100
  • p90: rank = 0.9·100 = 90. 누적 ≥90인 첫 버킷은 le=0.5(90). 이전(le=0.25)=50 → 버킷 개수 40, 버킷 내 순위 40. 결과 = 0.25 + (0.5−0.25)·(40/40) = 0.5s.
  • p95: rank = 95. 첫 버킷은 le=1(98). 이전 90 → 개수 8, 내 순위 5. 결과 = 0.5 + (1−0.5)·(5/8) = 0.8125s.
  • p99: rank = 99. 누적 ≥99인 첫 버킷은 le=+Inf. 최상위라 보간 불가 → 두 번째로 큰 유한 le1s로 clamp. 실제 p99가 3s여도 대시보드엔 1s로 찍힌다.

p99가 +Inf 버킷에 걸리는 순간 숫자가 마지막 유한 경계에 붙어버린다 — 이게 "SLO 주변에 버킷 경계를 촘촘히 깔아라"는 조언의 실제 이유다. 반대로 p90처럼 순위가 버킷 경계와 딱 맞아떨어지면(rankInBkt/count = 1) 상한을 그대로 돌려주는데, 이는 경계가 관측 밀도와 우연히 겹친 결과일 뿐 "정확히 그 값"이라는 뜻은 아니다.

5. rate와 sum by(le)가 안쪽에 들어가는 이유

버킷은 카운터라 그대로 쓰면 프로세스 수명 전체의 누적이다. 최근 창의 분포를 보려면 rate(..._bucket[5m])로 초당 증가율을 먼저 구한다(카운터 리셋도 rate가 흡수한다). 여러 인스턴스를 합칠 때는 le별로 먼저 더한 뒤 분위수를 구한다.

histogram_quantile(0.99,
  sum by (le) (rate(http_request_duration_seconds_bucket[5m])))

여기서 한 가지 주의. rate()가 부동소수점 결과를 내면서 반올림 오차로 상위 버킷이 하위보다 아주 살짝 작아지는 비단조 상황이 생길 수 있다. bucketQuantile은 이진 탐색 전에 버킷을 훑으며 각 값을 "직전까지 본 최댓값 이상"으로 끌어올려 단조성을 강제(rectify)한다고 알려져 있다 — 덕분에 미세한 float 오차가 분위수를 뒤집지 않는다.

6. 왜 Summary가 아니라 Histogram인가 — 집계가능성

내가 함께 고쳐 잡은 오해가 "인스턴스별 p99를 평균 내면 전체 p99"라는 것이었다. 분위수는 평균이 되지 않는다. 여기서 두 메트릭 타입이 갈린다.

분위수 계산 위치정확도인스턴스 간 집계
Summary클라이언트가 슬라이딩 윈도우로 미리 계산정확(보간 없음)불가 — 이미 계산된 분위수라 합/평균 불가
Histogram서버가 sum by(le) 후 보간근사가능 — 버킷이 카운터라 합산 후 계산

Summary는 정확하지만 이미 계산된 0.99 quantile을 여러 인스턴스에서 더하거나 평균낼 수 없다. 반면 Histogram은 버킷이 카운터라 sum by (le)로 정직하게 합친 뒤 서버에서 분위수를 구할 수 있다 — 다인스턴스 환경에서 히스토그램을 권장하는 근본 이유다. 참고로 평균 지연은 분위수와 무관하게 rate(_sum[5m]) / rate(_count[5m])로 정확히 구할 수 있다. 평균은 꼬리를 못 보지만 정확하고, 분위수는 꼬리를 보지만 근사다 — 둘은 상호 보완이지 대체가 아니다.

7. 정리

  • histogram_quantile은 실측 분위수가 아니라 버킷 내부 균등분포 가정 아래의 선형 보간 근사다. 원본 값은 저장되지 않는다.
  • 목표 순위가 최상위 +Inf 버킷에 들어가면 마지막 유한 leclamp되어 "그 이상은 못 본다". 버킷 경계를 SLO 주변에 촘촘히 깔라는 이유다.
  • 다인스턴스 분위수는 histogram_quantile(φ, sum by(le) (rate(..._bucket[5m]))) 순서가 필수다. Summary로는 이 집계가 불가능하다.

더 파고들 만한 주제로는 (1) 고정 le 대신 지수적으로 자라는 스파스 버킷을 쓰는 Native histograms(Prometheus 2.40+)가 clamp 문제를 어떻게 바꾸는가, (2) 고카디널리티 라벨이 붙은 히스토그램이 시계열 수를 어떻게 폭증시키는가가 있다.

참고 자료

  • Prometheus Docs — Metric types (Histogram), Best practices (Histograms and summaries)
  • prometheus/prometheuspromql/quantile.gobucketQuantile
  • Prometheus Docs — Native histograms

0개의 댓글