카운터가 4 늘었는데 increase()는 왜 5.33일까 — Prometheus rate의 경계 외삽

seonwooj0810·3일 전

1. 정수 카운터에서 왜 소수가 나오나

increase(http_requests_total[1m])가 정수 카운터인데도 5.33을 돌려줬다. 처음엔 부동소수 오차나 버그를 의심했는데, Prometheus 소스 promql/functions.go의 extrapolatedRate를 따라가 보니 의도된 동작이었다. 윈도우 안 샘플이 덮지 못한 경계까지의 빈틈을 선형으로 외삽하기 때문이다.

rate()와 increase()는 둘 다 이 함수 하나를 부른다. 차이는 마지막에 윈도우 길이(초)로 나누느냐뿐이라, 공식 문서도 increase를 rate에 윈도우 초를 곱한 syntactic sugar라고 설명한다.

2. "마지막 − 처음"이라는 오해

흔한 이해는 increase = 마지막 값 − 첫 값이다. 실제 계산은 그 값(raw)에 배율을 곱한다.

factor = (covered + left + right) / covered

covered는 첫 샘플부터 마지막 샘플까지의 시간, left·right는 윈도우 경계까지 남은 빈틈이다. range selector는 (start, end] 구간이라 왼쪽 경계와 같은 타임스탬프의 샘플은 빠진다는 점도 함께 기억해 둘 만하다.

scrape 15초, [1m] 윈도우에서 샘플이 t=5, 20, 35, 50초에 값 100, 101, 103, 104로 찍혔다고 하자. raw는 4, covered는 45초, 빈틈은 왼쪽 5초·오른쪽 10초다. 배율 60/45가 곱해져 결과는 4 × 4/3 ≈ 5.33이 된다. 윈도우 60초 전체에서 같은 기울기로 늘었으리라 추정한 값이다.

increase()는 "관측된 증가량"이 아니라 "윈도우 전체에 대한 추정 증가량"이다.

3. 외삽을 언제 멈추나

무조건 경계까지 늘리면 윈도우 중간에 새로 뜬 파드의 카운터가 크게 부풀려진다. 그래서 두 가지 브레이크가 있다.

첫째, 1.1배 임계. 평균 샘플 간격 avg = covered / (샘플수 − 1)을 구하고, 빈틈이 avg × 1.1 이상이면 "있어야 할 자리에 샘플이 없다", 즉 시리즈가 윈도우 안에서 시작하거나 끝났다고 본다. 이때는 경계까지가 아니라 avg / 2만 외삽한다.

둘째, 카운터의 0점 클램프(왼쪽만). 카운터는 음수가 될 수 없으므로 기울기로 0이 되는 지점 covered × (첫 값 / raw)을 역산해, 왼쪽 외삽이 그보다 길어지지 않게 자른다.

빈틈외삽 거리
< 1.1 × avg경계까지 전부
≥ 1.1 × avgavg / 2
카운터의 왼쪽위 값과 0점까지 거리 중 작은 쪽

중간에 값이 줄면 카운터 리셋으로 보고 직전 값을 raw에 더해 준다. 100 → 110 → 5 → 15라면 raw는 −85 + 110 = 25다.

4. 직접 돌려보기

알고리즘을 Python으로 옮겨 숫자를 확인했다. 소스의 기본 경로(수정자 없는 range selector)만 단순화한 것이다.

def extrapolated_increase(samples, range_start, range_end, is_counter=True):
    """samples: [(t초, 값)] — 이미 (range_start, range_end] 안에 든 것만"""
    if len(samples) < 2:
        return None                        # 간격을 추정할 수 없음
    (t0, v0), (tn, vn) = samples[0], samples[-1]
    raw = vn - v0
    for (_, prev), (_, curr) in zip(samples, samples[1:]):
        if curr < prev:                    # 카운터 리셋
            raw += prev
    covered = tn - t0
    avg = covered / (len(samples) - 1)
    to_start, to_end = t0 - range_start, range_end - tn
    if to_start >= avg * 1.1:              # 시리즈가 윈도우 안에서 시작
        to_start = avg / 2
    if is_counter and raw > 0 and v0 >= 0:
        to_start = min(to_start, covered * (v0 / raw))  # 0 아래로 외삽 금지
    if to_end >= avg * 1.1:                # 시리즈가 윈도우 안에서 끝남
        to_end = avg / 2
    return raw * (covered + to_start + to_end) / covered

print(extrapolated_increase([(5, 100), (20, 101), (35, 103), (50, 104)], 0, 60))  # 5.33
print(extrapolated_increase([(35, 1), (50, 5)], 0, 60))                          # 7.67
print(extrapolated_increase([(30, 100), (60, 103)], 0, 60))                      # 6.0

두 번째는 t=35에 태어난 시리즈다. 왼쪽 빈틈 35초는 임계 16.5초를 넘어 7.5초로 줄고, 0점까지 거리 3.75초로 다시 잘린다. 결과 7.67은 t=31.25에 0에서 출발해 t=60까지 기울기 4/15로 늘렸을 때의 값과 같다.

세 번째가 노트에 없던 실패 케이스다. scrape 30초에 [1m] 윈도우를 쓰면 t=0 샘플이 left-open 경계에 걸려 빠지고 두 개만 남는다. 실제 증가량은 3인데 배율 2가 곱해져 6이 나온다. 샘플이 적을수록 배율과 오차가 같이 커지기 때문에, 윈도우를 scrape 간격의 최소 4배로 잡으라는 권장이 흔히 인용된다. 같은 이유로 [15s]처럼 샘플이 하나뿐인 윈도우는 결과가 아예 비어 버린다.

5. 정리

rate/increase는 샘플 사이 증가량(리셋 보정 포함)을 구한 뒤, 경계 빈틈이 평균 간격의 1.1배 미만이면 경계까지, 이상이면 반 간격만 외삽하고, 카운터는 0 아래로 내려가지 않게 자른다. 그래서 소수 결과는 버그가 아니며, 짧은 윈도우에서 오차가 커진다.

히스토그램 버킷에도 같은 rate가 먼저 적용되므로, histogram_quantile 글의 결과에도 이 외삽이 그대로 섞인다. 다음에는 최신 main에 들어온 anchored/smoothed 수정자, 즉 외삽 대신 경계 보간을 쓰는 extendedRate가 이 오차를 어떻게 줄이는지 볼 생각이다.

참고 자료

  • Prometheus 소스 promql/functions.go — extrapolatedRate, funcRate, funcIncrease (commit e71425d)
  • Prometheus Docs querying/functions.md — rate(), increase()
  • Prometheus Docs querying/basics.md — range vector selector의 구간 정의

0개의 댓글