IT/모니터링

Prometheus native histogram, 사실 내부적으로는 이렇게 동작한다

gfrog 2026. 5. 31. 00:17
반응형

요즘 운영하는 클러스터에서 메트릭 카디널리티가 슬슬 부담스러워졌다. http_request_duration_seconds 하나만 봐도 le 버킷이 12개씩 붙고, 거기에 method/status/route 라벨까지 곱해지면 한 서비스가 5만 series를 우습게 넘긴다. 그래서 작년부터 native histogram 으로 옮기는 작업을 조금씩 해왔는데, v3.8 부터 stable 표기가 붙으면서 본격적으로 손을 댔다.

이번 글은 "어떻게 켜는지"가 아니라 "왜 이게 그렇게 효율적인지"에 대한 이야기다. 사실 내부 구조를 모르고 켜면 ingester 메모리만 튀어서 한참 헤매게 된다.

classic histogram 의 비효율은 어디서 오는가

classic histogram 은 도구라기보다 관행에 가깝다. le 라벨에 미리 정해둔 bucket boundary 를 박아두고, 관측값이 들어올 때마다 해당 bucket 의 카운터를 올린다. 문제는 이게 시계열 단위로 따로 저장된다는 점이다.

bucket 12개 + _sum, _count 까지 14 series. 라벨 조합이 200개면 그 자체로 2800 series 다. 게다가 모든 bucket 은 cumulative 라서, 관측이 거의 없는 꼬리 쪽 bucket 도 0이 아니라 누적된 값을 들고 다닌다. TSDB 입장에서는 압축이 잘 되긴 하지만 인덱스 부담이 줄어드는 건 아니다.

근데 진짜 짜증나는 건 boundary 가 잘못 잡혔을 때다. p99 가 800ms 인데 bucket 이 0.5, 1, 2.5 같은 식으로 듬성하게 잡혀 있으면 quantile 추정이 사실상 거짓말이 된다. 이걸 고치려면 instrument 코드를 다시 배포해야 한다. 운영하면서 가장 자주 깨지는 지점이 여기다.

native histogram 의 schema 와 bucket index

native histogram 은 이 boundary 문제를 schema 라는 정수 하나로 푼다. schema 가 정해지면 bucket boundary 는 수식으로 결정된다.

schema = n  (정수, 기본 8)
factor = 2^(2^-n)
bucket[i] 의 상한 = factor^i

schema=8 이면 factor ≈ 1.00271. 즉 bucket 하나당 약 0.27% 증가. p50 이 50ms 든 p99 가 800ms 든, 들어오는 관측값이 어느 bucket 에 떨어질지를 로그 변환으로 즉시 계산할 수 있다. boundary 를 따로 박을 필요가 없다.

이 부분이 핵심이다. classic 처럼 "내가 어떤 구간을 보고 싶다"를 미리 선언하는 게 아니라, "이 정도 해상도면 충분하다"만 선언하고 실제 boundary 는 런타임에서 산출된다.

// 의사 코드. 실제 client_golang 구현은 더 복잡하다
func bucketIndex(v float64, schema int) int {
    if v <= 0 { return /* zero/negative bucket */ }
    return int(math.Ceil(math.Log2(v) * float64(int(1)<<schema)))
}

math.Log2 한 번이라 hot path 비용도 무시할 만하다. 실제로 instrument 쪽 CPU 가 늘었느냐 측정해봤는데 1% 미만이었다.

sparse representation 이라는 단어의 의미

이름이 sparse histogram 인 건 마케팅 용어가 아니라 자료구조 그대로다. 관측이 들어오지 않은 bucket 은 아예 메모리에 존재하지 않는다.

내부 표현은 대략 이렇다.

positive_spans: [{offset, length}, ...]
positive_buckets: [delta, delta, delta, ...]
negative_spans: [...]
negative_buckets: [...]
zero_count, count, sum, schema

연속된 bucket 묶음을 span 으로 표현하고, 그 안에서 값은 이전 bucket 과의 delta 로 저장된다. 관측이 한쪽 구간에만 쏠려 있으면 span 하나만 들고 있으면 끝난다. 우리 서비스의 응답시간을 native 로 옮겨 보니, classic 에서 14 series 짜리던 게 단일 series 로 압축되고 메모리는 6~8 분의 1 수준으로 떨어졌다.

다만 span 이 진짜로 sparse 한지는 트래픽 특성에 달려 있다. 양쪽 꼬리가 다 길게 늘어지는 워크로드 (예: 외부 API gateway) 는 생각보다 bucket 수가 많아질 수 있다. 우리도 한 곳에서 active bucket 이 240개 넘어가는 케이스가 있었다.

bucket limit 과 schema 자동 축소

bucket 수가 무한정 늘어나면 그것 자체가 문제다. 그래서 instrument 측에 NativeHistogramMaxBucketNumber 같은 제한을 걸 수 있고, 한도를 넘으면 schema 를 한 단계 낮춰서 (factor 가 커지므로 bucket 폭이 넓어진다) 자동으로 재구성된다.

여기가 좀 까다롭다. schema 가 동적으로 바뀌면 PromQL 에서 quantile 계산은 여전히 동작하지만, 시계열 간 schema 가 들쭉날쭉할 수 있다. histogram_quantile 함수는 schema 가 다른 시리즈를 합칠 때 더 큰 factor (낮은 schema) 에 맞춰서 down-sample 한다. 정밀도가 떨어진다는 뜻이다.

우리 팀에서는 그래서 두 가지 룰을 정해뒀다.

  • schema 는 6~8 범위 내에서만 쓰고, 그 아래로 떨어지면 alert
  • MaxBucketNumber 는 160 (sum/count 포함해도 series 수 부담은 미미)

scrape_native_histograms 설정의 함정

v3.8 부터 stable 이지만, v3.9 이후로는 feature flag 가 no-op 이 되고 scrape_configs 에서 명시적으로 scrape_native_histograms: true 를 설정해야 한다. 이걸 안 해두면 exposition format 에서 protobuf 가 협상되지 않아 native histogram 데이터가 그냥 무시된다.

그리고 classic 과 native 를 동시에 노출하는 dual emit 모드가 있다. 마이그레이션 기간에는 거의 필수다.

scrape_configs:
  - job_name: my-app
    scrape_native_histograms: true
    scrape_classic_histograms: true   # 옵션, 기본 true
    metrics_path: /metrics

scrape_classic_histograms 를 false 로 바꾸는 시점은 신중해야 한다. 모든 대시보드, alert rule, recording rule 이 native 쿼리 (histogram_quantile(0.99, sum(rate(http_request_duration_seconds[5m]))) — le 라벨 없이) 로 옮겨졌는지 확인 후에 끄는 게 맞다. 우리도 alert rule 하나 빠뜨려서 새벽에 한 번 호출당했다.

Remote Write 2.0 와 저장소 효율

Remote Write 1.0 은 native histogram 을 보낼 때 결국 bucket 별로 풀어서 전송하던 흐름이 있었다. 그래서 Thanos / Mimir 쪽 ingester 메모리에서 효율이 다 빠지는 문제가 있었다. RW 2.0 부터는 histogram 을 통째로 (sparse 표현 그대로) 전송한다. 우리는 Mimir 를 쓰는데, RW 2.0 전환 후 ingester 메모리가 평균 22% 줄었다.

다만 RW 2.0 은 receiver 측이 지원해야 한다. Thanos receive 는 2025년 후반에 지원 들어갔고, Cortex/Mimir 는 좀 더 일찍 받았다. 자체 구축한 remote write sink 가 있다면 sample 종류가 늘어난 걸 처리해야 한다 (composite samples 라는 개념이 새로 들어왔다).

그래서 켤 만한가

켤 만하다. 다만 다음 세 가지를 먼저 정리하는 게 좋다.

첫째, bucket limit 과 schema 안정성에 대한 운영 규칙. 둘째, dual emit 기간을 충분히 두고 alert/dashboard 마이그레이션. 셋째, remote write 경로의 RW 2.0 호환성.

이걸 모르고 그냥 스위치 켜듯 켜면 ingester 가 OOM 나거나, p99 가 분명히 800ms 인데 quantile 이 1.2s 로 잘못 나오는 식의 이상한 상황을 만나게 된다. 사실 우리도 첫 시도에서 두 번째 사례를 겪고 한 달 미뤘다.

native histogram 자체는 7년 만에 prometheus 가 받은 가장 큰 변화 중 하나라는 평가를 받는다. 그만큼 운영 측에서 짚고 넘어가야 할 게 좀 있다. 이번 분기 안에 우리 팀은 classic 을 완전히 끄는 걸 목표로 잡았는데, 끄고 나서 한 번 더 정리해볼 생각이다.

반응형