Post

처리율 제한기 동작 검증

처리율 제한기 gate-limiter의 동작을 검증하기 위해 Go 프로젝트에 프로메테우스를 적용하고 그라파나와 연동하였습니다.

Go 프로젝트에서 프로메테우스 적용하기

프로메테우스는 애플리케이션의 성능과 상태를 시계열 데이터로 수집, 질의할 수 있는 오픈소스 모니터링 시스템이다. 이전 Java+Spring 프로젝트에서는 Actuator와 Micrometer가 톰캣/서블릿 계층을 자동 계측해 주어 http.server.requests 같은 지표를 별도 작업 없이 얻을 수 있었다. 반면 Go 생태계에서는 기본 자동 계측이 없기 때문에, 처리율 제한기 gate-limiter가 의도대로 동작하는지 확인하려면 직접 메트릭을 정의하고 미들웨어로 계측을 넣어야 했다. 본 글에서는 왜 계측이 필요한지, 어떤 지표를 수집했는지, 그리고 그 지표로 제한 기능이 올바르게 작동함을 어떻게 검증했는지까지를 정리한다.

지표를 수집한 이유

지표를 수집하는 가장 큰 이유는 기능이 제대로 동작하는지 검증하기 위해서다.

  • 설정한 정책으로 처리율 제한기가 동작하는지
  • 허용치를 넘으면 정말로 요청이 차단되는지

크게 위의 두가지 기능을 검증하고자 지표를 수집하였다.

제한 기능 검증

첫번째로 사용자의 요청이 허용치를 넘어서 차단되면 429 Too Many Requests 응답코드를 전달하기 때문에 실제로 차단되었을 때 429응답의 비율이 증가하는지를 살펴보고자 하였다. 두번째로 요청이 허용되고 차단되었는지를 커스텀 메트릭의 라벨에 정보를 담아 관찰하고자 하였다. 허용/차단 횟수는 QPS가 허용치에 도달했을 때 설정값 근처에서 평탄해지고, 차단횟수가 증가하기 시작하면 기능이 제대로 동작한다고 볼 수 있다.

프로젝트에 프로메테우스 추가

Go 프로젝트에서 프로메테우스를 사용하기 위해서는 먼저 프로메테우스 클라이언트를 종속성에 추가해야한다.

1
2
3
go get github.com/prometheus/client_golang/prometheus
go get github.com/prometheus/client_golang/prometheus/promauto
go get github.com/prometheus/client_golang/prometheus/promhttp
  • prometheus는 핵심 패키지로 메트릭 타입(Counter, Gauge, Histogram, Summary)를 정의하고 수집/등록하는 기능을 한다.
  • promauto는 자동 등록 도우미이다. 예를 들어 prometheus.NewCounter 대신 promauto.NewCounter 를 사용하면 레지스트리에 메트릭이 자동등록된다. prometheus 패키지를 더 편리하게 사용할 수 있도록 도와주는 패키지라고 보면된다.
  • promhttp는 HTTP 핸들러를 제공하는 패키지이다. /metrics 엔드포인트에서 현재 수집된 메트릭을 노출할 때
    1
    
    http.Handle("/metrics", promhttp.Handler())
    

    와 같이 사용할 수 있다.

프로젝트를 적용했다면 먼저 promhttp를 사용해서 /metrics 엔드포인트를 열어주어야한다.

1
2
3
import "github.com/prometheus/client_golang/prometheus/promhttp"
// init() 등에서
http.Handle("/metrics", promhttp.Handler())

하지만 내 프로젝트의 특성상 위와 같은 방식으로는 프로메테우스가 메트릭데이터를 가져갈 수 없다. 처리율 제한기는 들어오는 모든 요청 (/)에 대해서 검사하고 통과된 요청을 target 서버로 전달하기 때문이다. 따라서 프로메테우스 서버가 /metrics 엔드포인트로 요청을 전달하면, 메트릭 정보를 반환받는 것이 아니라 target 서버의 /metircs 엔드포인트에 접근하게 된다.

이 문제를 해결하기 위해서 같은 포트, 전용 mux로 /metrics 엔드포인트를 우선 매핑해야한다.

1
2
3
4
5
6
7
mux := http.NewServeMux()

// 1) 메트릭은 직접 응답(제한/프록시 우회)
mux.Handle("/metrics", promhttp.Handler())

// 2) 나머지는 게이트 리미터
mux.Handle("/", limitHandler)

Go의 기본 ServeMux는 가장 구체적인 경로 매칭이 우선이기 때문에 /metrics/보다 먼저 매칭된다.

이후에 커스텀 지표를 추가하면서 이 부분에는 또 수정이 필요하다.

이렇게 promhttp로 핸들러 까지 추가했다면 서버의 /metrics 엔드포인트에 접근했을 때 사용가능한 메트릭정보들을 확인할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
# HELP gatelimiter_http_requests_total Total inbound requests
# TYPE gatelimiter_http_requests_total counter
# HELP go_gc_duration_seconds A summary of the wall-time pause (stop-the-world) duration in garbage collection cycles.
# TYPE go_gc_duration_seconds summary
go_gc_duration_seconds{quantile="0"} 5.6751e-05
go_gc_duration_seconds{quantile="0.25"} 0.000126792
go_gc_duration_seconds{quantile="0.5"} 0.00018525
go_gc_duration_seconds{quantile="0.75"} 0.000296333
go_gc_duration_seconds{quantile="1"} 0.001805166
go_gc_duration_seconds_sum 0.116357709
go_gc_duration_seconds_count 408
...

위에 작성되어있는 정보 외에도 많은 메트릭정보를 확인할 수 있다. 특히 실행중인 고루틴 갯수와 같은 Go언어 프로젝트의 특징적인 메트릭정보도 확인해볼 수 있다.

커스텀 지표 추가

Go 프로젝트에 프로메테우스를 적용했지만 기본 메트릭 정보 만으로는 기능이 제대로 동작하는지 확인할 수 없기 때문에 이를 위한 커스텀 지표를 추가해야한다.

첫번째로는 HTTP 상태코드 지표를 추가해볼 것이다. gate-limiter는 다음 기준에 따라 클라이언트에게 상태코드를 전달한다.

  • 사용자가 보낸 요청이 허용되면 target 서버로 요청을 프록시 해준다.
    • 사용자가 받은 응답코드는 서버의 처리 내용에 따라 달라진다.
  • 사용자가 보낸 요청이 허용치를 넘어 거부되면 429 Too Many Requests 응답코드를 클라이언트에게 전달한다.

따라서 2XX 상태코드와 429 상태코드의 비율을 확인하면 기능이 제대로 동작하는지 확인할 수 있다. 단, 서버측 문제가 아님을 확실히 하기위해 5XX 상태코드도 함께 확인하면 좋다.

상태코드 정보를 code 라벨에 담은 메트릭데이터를 정의해보자

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
var httpReqTotal = promauto.NewCounterVec(
	prometheus.CounterOpts{
		Namespace: "gatelimiter",
		Subsystem: "http",
		Name:      "requests_total",
		Help:      "Total inbound requests",
	},
	[]string{"route", "code", "method"},
)

var httpReqDur = promauto.NewHistogramVec(
	prometheus.HistogramOpts{
		Namespace: "gatelimiter",
		Subsystem: "http",
		Name:      "request_duration_seconds",
		Help:      "End-to-end request latency",
		Buckets:   prometheus.DefBuckets,
	},
	[]string{"route", "code", "method"},
)

// 전역 미들웨어: 모든 요청을 계측
func WithMetrics(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// /metrics 자체는 계측 제외
		if r.URL.Path == "/metrics" {
			next.ServeHTTP(w, r)
			return
		}
		route := normalizeMetricName(r.URL.Path)

		// route 라벨을 고정(currying)하고, code/method는 promhttp가 채운다.
		h := promhttp.InstrumentHandlerCounter(
			httpReqTotal.MustCurryWith(prometheus.Labels{"route": route}),
			promhttp.InstrumentHandlerDuration(
				httpReqDur.MustCurryWith(prometheus.Labels{"route": route}),
				next,
			),
		)
		h.ServeHTTP(w, r)
	})
}

먼저 메트릭에 있는 라벨들은 다음과 같은 정보를 의미합니다.

  • route: 요청 URL을 일반화해 기능 단위로 집계
  • code: 요청 처리 결과
  • method: HTTP 메서드별 구분

메트릭변수를 정의한 부분과 핸들러를 파라미터로 받아 요청을 수행하는 WithMetrics 메서드를 볼 수 있습니다. WithMetrics메서드에는 아래와 같이 URL을 일반화 하는 과정이 들어있습니다.

1
route := normalizeMetricName(r.URL.Path)

이것은 요청 URL의 카디널리티를 줄이고 같은의도로 들어온 요청은 하나의 메트릭으로 관리하기 위한 작업입니다. 예를 들어 아이템 등록이라는 요청은 다음과 같은 URL Path을 가질 수 있습니다.

  • /item/123?name=재훈
  • /item/222?name=아무나

어떤 아이템에 등록하느냐에 따라 아이템 번호가 달라질 수도 있고, 쿼리 스트링이 달라지면 또 다른 URL로 판단됩니다. 하나의 동작에 대해서 id나 쿼리스트링 때문에 URL의 카디널리티가 높아지고 메트릭정보를 관리하기 힘들어 집니다. 아이템 등록이라는 동작은 하나의 아이템 등록이라는 컨셉으로 관리되어야 합니다. 따라서 요청 URL 경로에 들어있는 id를 삭제하거나 쿼리스트링을 제거하는 작업을 normalizeMetricName메서드에서 수행하고 있습니다.

위에서 예시로 들어준 URL 경로가 일반화를 거치면 /item/:id 와 같이 변경됩니다.

1
2
httpReqTotal.MustCurryWith(prometheus.Labels{"route": route})
httpReqDur.MustCurryWith(prometheus.Labels{"route": route})

위의 두 표현은 메트릭 벡터에 URL을 일반화한 경로를 route 라벨에 고정해두는 작업입니다. 이렇게 해두면 나머지 라벨(method, code)는 자동으로 채워집니다. 라벨의 키 셋이 정의와 다르면 패닉이 나기 때문에 httpReqTotal, httpReqDur 가 반드시 route 라벨을 가지고 있어야 합니다.

1
2
3
4
5
6
7
h := promhttp.InstrumentHandlerCounter(
    httpReqTotal.MustCurryWith(prometheus.Labels{"route": route}),
    promhttp.InstrumentHandlerDuration(
        httpReqDur.MustCurryWith(prometheus.Labels{"route": route}),
        next,
    ),
)

InstrumentHandlerCounter 메서드는 요청이 끝날 때마다 카운터를 1증가시키는 역할을 합니다. promhttp가 code(HTTP 상태코드)와 method(HTTP 메서드) 라벨을 자동으로 붙여줍니다. InstrumentHandlerDuration 는 요청 처리 시간을 관측(히스토그램/요약)합니다. 마찬가지로 code/method 라벨을 붙여줍니다.

두번째로 요청 통과/거부를 직접적으로 카운팅해주는 메트릭 정보를 정의해보겠습니다. 먼저 카운터 벡터를 정의해주었습니다.

1
2
3
4
5
6
7
8
9
var rlDecisionTotal = promauto.NewCounterVec(
	prometheus.CounterOpts{
		Namespace: "gatelimiter",
		Subsystem: "rate_limit",
		Name:      "decisions_total", // gatelimiter_rate_limit_decisions_total
		Help:      "Rate limit decisions per policy",
	},
	[]string{"result", "policy", "reason"},
)

다음과 같은 라벨 정보를 가지게 됩니다.

  • result: 요청의 통과(allowed)/거부(blocked) 정보가 적힘
  • policy: 사용한 처리량 제한 정책
  • reason: 통과된 경우 ok, 거부된 경우 거부된 이유가 담길 라벨

그리고 아래와 같은 편의 함수를 정의해줍니다.

1
2
3
4
5
6
7
func ObserveAllowed(policy string) {
	rlDecisionTotal.WithLabelValues("allowed", policy, "ok").Inc()
}

func ObserveBlocked(policy, reason string) {
	rlDecisionTotal.WithLabelValues("blocked", policy, reason).Inc()
}
  • ObserveAllowed: allowed 지표의 값을 늘림
  • ObserveBlocked: blocked 지표의 값을 늘림

위 두가지 편의 메서드를 정의하고 요청의 허용/거부 여부가 결정되는 코드에서 위 코드를 삽입해 메트릭 정보를 갱신했습니다.

메트릭 검증

제한 환경

  • 댓글 작성 API(POST /api/item/:id/comment)
  • 같은 유저가 1분간 요청을 5회 이상 수행할 수 없음
  • 슬라이딩 윈도우 카운터 알고리즘 적용

상태코드 검증

메트릭 데이터를 관측하기 위해서 다음과 같이 PromQL을 작성했습니다.

1
2
3
4
5
6
7
8
9
10
11
# 2XX 비율
sum(rate(gatelimiter_http_requests_total{job="gate-limiter",code="2.."}[1m]))
/ sum(rate(gatelimiter_http_requests_total{job="gate-limiter"}[1m]))

# 429 비율
sum(rate(gatelimiter_http_requests_total{job="gate-limiter",code="429"}[1m]))
/ sum(rate(gatelimiter_http_requests_total{job="gate-limiter"}[1m]))

# 오탐 방지(백엔드/리버스프록시의 문제가 아님을 확인)
# 5XX 가 같이 오르지 않음을 확인함으로써 알 수 있다.
sum(rate(gatelimiter_http_requests_total{job="gate-limiter",code=~"5.."}[5m]))

성공한 HTTP 요청의 비율을 계산하는 쿼리가 첫줄에 있습니다. 즉, gate-limiter 서비스가 처리한 전체 요청 중에서 성공적으로 응 만약 쿼리의 결과가 0.98 이라면 지답(2xx)한 요청이 차지하는 비중을 나타냅니다.난 1분 동안 gate-limiter로 들어온 요청 중 98% 가 2XX 응답을 받았다는 것을 의미합니다.

위 쿼리는 요청이 거부된 요청의 비율을 계산하는데 사용됩니다. gate-limiter 서비스가 처리한 전체 요청 중에서 지난 1분간 429 Too Many Requests의 비율을 계산하는 쿼리입니다.

또한 동일하게 5XX 응답의 비율을 세는 쿼리를 작성해 서버에서 발생한 문제로 응답이 차단된것이 아님을 확인했습니다.

한명의 유저가 반복해서 동일한 댓글작성 요청을 전달하자 어느 시점부터 2XX응답의 비율이 감소하고 429응답의 비율이 증가하는 것을 관측할 수 있습니다.

허용/거부 횟수 카운트 검증

응답 코드만으로도 기능이 제대로 동작한다는 것을 간접적으로 증명할 수 있었지만 더 확실한 증거가 필요하다고 생각했습니다. 그래서 애플리케이션 코드에서 요청의 허용/거부 여부가 결정되는 시점에 갱신되는 메트릭 데이터를 활용하기로 했습니다.

  • gatelimiter_rate_limit_decisions_total 변수 활용
  • 요청이 허용되면 result 라벨의 값이 allowed인 메트릭 카운터 + 1
  • 요청이 거부되면 result 라벨의 값이 blocked인 메트릭 카운터 + 1
1
2
3
sum by (policy)(rate(gatelimiter_rate_limit_decisions_total{job="gate-limiter",result="allowed"}[1m]))

sum by (policy)(rate(gatelimiter_rate_limit_decisions_total{job="gate-limiter",result="blocked"}[1m]))

위 쿼리는 gate-limiter 서비스에서 각 정책(token_bucket, leaky_bucket, sliding_window_counter, …) 별로 허용/거부된 요청의 초당 평균 개수를 의미합니다. 1분동안 요청이 허용된 횟수의 초당 평균 증가율을 보여주는 쿼리입니다.

1분간 요청이 허용치에 도달했을 때 허용(allowed) 메트릭은 유지되고 거부(blocked) 메트릭이 상승하는 것을 확인할 수 있었습니다.

참고

This post is licensed under CC BY 4.0 by the author.