Monitoring

Prometheus Query(PromQL) 기본 이해하기

토마스.dev 2021. 4. 8. 21:06

Prometheus Query(이하 PromQL)는 SQL과는 달라 처음 접하게 되면 이해하는데 조금 어려움을 겪을 수 있다. 하지만 제대로 이해하고 나면 정말 잘 만들어진 언어라는 것을 알 수 있다. 여기서는 PromQL의 기본 문법과 Metric Join(Vector matching)에 관해 설명하고자 한다.

Data Model

먼저 Prometheus 에서 metric을 출력하는 형태를 살펴보면, 다음과 같다.

 

http_requests_total{container="A"} 1037
<메트릭 이름>{<레이블 키>=<레이블 값>, <레이블 키>=<레이블 값> ...} <메트릭 값> [<timestamp>]
 
Metric 이름이 제일 먼저 나오고, metric의 특징을 표현하는 레이블(label)들이 있다. 그리고 가장 마지막으로는 metric 값(value)이 있다. 필요에 따라 timestamp도 표시될 수 있다. timestamp를 보통 출력하지 않는데, 그 이유는 마지막으로 수집된 최근 값이라서 그렇다(실제로는 timestamp도 보내주긴하는데 웹에서 표시를 굳이 안한다)

Data Type

Prometheus에서 Data Type은 4가지로 분류된다. 위에서 언급한 Metric 값(value)의 타입을 말하는 것이 아니다. Prometheus에서 사용하는 값의 타입은 오로지 float64 이다.

  • Instant vector - a set of time series containing a single sample for each time series, all sharing the same timestamp
  • Range vector - a set of time series containing a range of data points over time for each time series
  • Scalar - a simple numeric floating point value
  • String - a simple string value; currently unused

https://prometheus.io/docs/prometheus/latest/querying/basics/#expression-language-data-types

 

Time-Series

위 4가지 타입의 설명만으로는 이해가 안될 수 있기에 추가적인 설명을 하도록 한다. 처음부터 Instant vector가 나와 좀 헷갈릴 수 있는데, 먼저 Time-series에 대해서 설명한다.

 

Time series란 시계열 이란 의미로 시간변화에 따른 값이 변화를 말한다. 우리가 보고자 하는 metric 데이터는 시간에 따른 값의 변화를 나타내는데, [시간, 값]...[시간, 값] 이런식으로 표현이 된다. 예를 들면,

 

Container A의 CPU 값은 [1분, 0.1], [2분, 0.2], [3분, 0.1] 과 같이 나타낼 수 있다. 이런 배열들을 묶어서 하나의 Time-series라 하는 것이다. 여기서 1개 요소인 [1분, 0.1] 을 Sample이라 한다.

 

http_requests_total{container="A"} 1037
http_requests_total{container="B"} 500
 
위 예를 보면, 두개의 Sample이 존재하고, 시간의 변화에 따라 기록되는 Sample들의 묶음(배열)이 바로 Time-series라고 생각 하면 된다. 

Instant Vector

Instant vector는 여러 time-series에서 같은 시간대를 가리키는 Sample 집합을 말한다. 위와 같이 http_requests_total에 대해 2가지 time-series가 존재하는데 같은 시간에 해당되는 2개의 Sample집합을 Instant vector라 한다. 

 

비유를 좀 하자면 Time-series가 시간에 따른 가로축을 표현했다라고 하면, Instant Vector는 Time-series를 세로로 자른 단면? 같다고 할 수 있다.

 

기본적으로 단순히 metric 이름으로 쿼리를 실행하면 Instance Vector를 얻을 수 있고, 필터링을 하고 싶으면 Instant Vector selector를 사용하면 된다.(뒤에 설명)

 

Range Vector

특정 시간동안(period)의 값들을 배열로 가진 타입이다. Instant vector는 각 time-series들이 1개의 Sample만을 가지는 타입인데, Range vector은 각 Time-series들이 주어진 기준시간부터 명시된 과거 시간사이의 모든 값들을 가질 수 있다. 

 

Range Vector 표시는 metric이름에 대괄호 [<period>] 를 붙이면 된다. (Range selector 라 한다)

 

예를들어 http_requests_total[5m] 로 쿼리를 실행해보면 다음과 같이 나온다.

 

http_requests_total{container="A"}[5m]
[1037 @1551242271.728, 1038 @1551242331.728, 1040 @1551242391.728]

http_requests_total{container="B"}[5m]
[500 @1551242484.013, 501 @1551242544.013, 502 @1551242604.013]

배열 요소로 <값> @<timestamp> 형태로 들어가 있다. 예시에서는 5분동안에 3개의 값이 존재하고 있다.

이렇게 값이 1개가 아닌, 여러개가 배열로 표현되어 이 결과만으로 그래프를 그릴 수 없다. 그래프를 그리려면 시간축에 따른 값으로 하나의 (시간, 값) 쌍을 이뤄야 하는데 여기서는 3개가 존재하기 때문이다. 

 

그렇다면 왜 Range Vector라는게 존재하는 걸까? 그 이유는 특정 기간동안의 값들로 평균값이라든가, 변동폭(rate)이라든가 하는 연산과정이 필요할 수도 있기 때문이다. 위에 예를 보면 1037 > 1038 > 1040으로 값이 증가하는걸 볼 수 있다. 이건 누적값의 증가량이다. 하지만 우리가 보고 싶은게 5분동안의 변화량이라면? 그렇다면 "5분 동안의 http_requests_total 의 변화량" 이라는 의미를 뜻하는 메트릭 연산이 필요하다는 것이고, 이는 rate(http_requests_total[5m]) 로 표현될 수 있다.

 

여기서 Range Query와 Range vector를 혼동할 수 있을 것 같아 이에 대해 설명하자면, Range Query는 Start 와 End 시간을 옵션으로 주는 쿼리 표현 종류(API에 비유)이고, Range vector는 데이터 타입이다.

 

쿼리를 실행해서 그래프를 그리기 위해서는 Instant vector + Range Query로 실행되어야 한다. Prometheus Dashboard의 Console 입력화면에서 예시로 설명한 http_requests_total로 쿼리를 실행했을때 최근 5분(왜 5분인지 이 부분에 대해서도 뒤에 설명)의 가장 최근 값만 보여주게 된다. 이때는 Range Query로 실행되는게 아닌 그냥 기본 Query(Instant Query)로 실행된다. 그래프로 그리기 위해서는 보려는 Start-End시간 사이의 모든 Instant vector의 값들이 필요하다.

 

Instant Query

 
GET /api/v1/query

Grafana에서 기본 Query로 실행할때의 API 호출 예시. start, end가 아닌 특정 시간(time)이 query string으로 들어가 있다. resultType은 vector 이다.

 

Range Query

 
GET /api/v1/query_range

Grafana에서 Query Range로 실행할때의 API호출 예시. API가 다르고 start, end가 query string 으로 들어간 것을 알 수 있다. resultType은 matrix이다(시간대별로 vector값이 들어간 내부적으로 쓰이는 복합타입)

 

Range vector로는 그래프를 그릴 수 없다면, Range vector는 어떻게 사용해야 할까? 이를 위해서는 Range vector의 결과를 aggregation하여 1개의 Instant vector로 만들어 주는 aggregation function을 사용해야한다. 

 

대표적인 function이 rate 이다.

 

rate(http_requests_total{container="A"}[5m])
0.01098

rate 함수는 1초당 변화량으로 환산하는 함수로, 앞선 예시에서 5분 동안에 포함되는 3개의 값의 변화를 초당으로 변환해 1개의 값으로 나타낸다.

 

이러한 aggregation function없이 range vector로만 가지고 그래프로 표현하려고 하면 에러가 발생한다. 즉, Range vector + Query range 는 불가하다는 얘기다.

 

Invalid expression type "range vector" for range query, must be Scalar or instant Vector 라는 에러가 발생한다.

 

Scalar/String

Scalar는 시간 정보가 없는 값을 말하며, 단순히 숫자라고 생각하면 된다.

String은 문자열인데 실제 사용하지 않는다. (Prometheus 코드상으로도 사용하지 않는다)

 

기본 쿼리 이해하기

Selectors

가장 기본이 되는 문법으로, label을 조건으로 원하는 Instant vector를 검색할 수 있다.

 

http_requests_total

=~ 는 regex를 이용할 수 있는 operator이며, 위 예제를 설명하자면 environment label값이 staging 혹은 testing 혹은 development 이고, method가 GET이 아닌 http_requests_total 이름의 instant vector를 검색한다.

 

다만 모든 Label 값을 검색하고자 할때는 .* 를 쓰지말고 .+를 써야한다. 

 

Metric 이름은 __name__ 이란 Label로도 저장되기 때문에 여러 metric 이름이나 패턴으로 검색하고 싶다면 metric이름은 비우고 __name__ label로 검색하면 된다.

 

 

위의 쿼리는 container 로 시작되는 이름을 가진 Instant vector를 모두 보여준다.

Aggregation Operators

검색된 metric 결과를 합산하거나 평균내는 등 여러가지 수학적인 계산이 필요할 수 있다. 그럴때는 아래와 같은 Operator들을 이용 하면 되는데, SQL에서의 Group by와 같은 문법을 적용할 수 있다.

  • sum (calculate sum over dimensions)
  • min (select minimum over dimensions)
  • max (select maximum over dimensions)
  • avg (calculate the average over dimensions)
  • stddev (calculate population standard deviation over dimensions)
  • stdvar (calculate population standard variance over dimensions)
  • count (count number of elements in the vector)
  • count_values (count number of elements with the same value)
  • bottomk (smallest k elements by sample value)
  • topk (largest k elements by sample value)
  • quantile (calculate φ-quantile (0 ≤ φ ≤ 1) over dimensions)

https://prometheus.io/docs/prometheus/latest/querying/operators/#aggregation-operators

 

<aggr-op>([parameter,] <vector expression>) [without|by (<label list>)]

by 와 without이 group by 와 같은 역할인데, without은 by의 반대 관계이다. 즉, without을 사용하면 해당 label은 제외하고 나머지로 group by를 한다.

 

sum(http_requests_total) by (method)

위는

http_requests_total을 

method 로 group by 하여 합산해 보여준다는 것으로 그 결과는 다음과 같은 형태로 나타난다.

 

 2000
 3000

aggregation되고나서는 metric이름이 사라진 것을 알 수 있다. 새로운 time-series가 된 것이다. 

 

뒤에 vector expression이 길어지면 by 를 앞으로 이동해 사용할 수 있다.

 

sum by (method) (http_requests_total)

 

Join 쿼리 이해하기(Vector matching)

PromQL의 가장 강력한 기능이 아닐까 하고, 실제로 가장 많이 사용하는 문법이 아닐까 한다. 여러가지 다른 이름의 Instant Vector들을 Label을 통해 matching하여 다음과 같은 목적에 활용 할 수 있다.
 
  • 비율, 퍼센트 등 두개 이상의 값의 연산
  • Label 결합
 
Vector matching를 할때 다음과 같은 경우가 생긴다.
 
  • One-to-One: Vector가 1:1로 정확히 일치하는 경우(Label matching으로 두개 이상의 vector들이 1:1로 매핑되는 경우)
  • One-to-Many: Vector가 1:N 혹은 N:1의 관계가 되는 경우
  • Many-to-Many: Vector가 N:M 관계가 되는 경우
 
결국 2개 이상의 vector를 매칭해서 하나의 Value가 되어야 한다. 그러므로 계산이 불가능한 Many-to-Many는 논리상 허용될 수 없다.

One-to-One

One-to-One의 문법은 다음과 같이 단순하다.
 
<vector expr> <bin-op> ignoring(<label list>) <vector expr>
<vector expr> <bin-op> on(<label list>) <vector expr>
왼쪽 <vector expr> 과 오른쪽 <vector expr>는 매칭할 2개의 vector를 말한다.
 
<bin-op>는 결국 두 개의 vector의 value를 연산해서 1개의 값으로 도출해야 하므로 이 연산 operator를 말한다.
 
on은 매칭을 할 label 리스트를 말하는 것으로 ignoring은 on의 반대의미이다. ignoring에 들어간 label을 제외하고 나머지를 매칭하게 된다.
 
method_code:http_errors:rate5m{method="get", code="500"}  24
method_code:http_errors:rate5m{method="get", code="404"}  30
method_code:http_errors:rate5m{method="put", code="501"}  3
method_code:http_errors:rate5m{method="post", code="500"} 6
method_code:http_errors:rate5m{method="post", code="404"} 21

method:http_requests:rate5m{method="get"}  600
method:http_requests:rate5m{method="del"}  34
method:http_requests:rate5m{method="post"} 120

위의 Metric을 예제로 설명하겠다. 다음의 쿼리를 자세히 설명해보자면,

 

method_code:http_errors:rate5m{code="500"} / ignoring(code) method:http_requests:rate5m

code가 500인 method_code:http_errors:rate5m과 method:http_requests:rate5m을 매칭한다. 

 

method_code:http_errors:rate5m 결과만 먼저 보면 다음과 같다.

 

method_code:http_errors:rate5m{method="get", code="500"}  24
method_code:http_errors:rate5m{method="post", code="500"} 6

ignoring 으로 code를 제외해서 남아있는 method label로만 매칭을 한다.

 

method_code:http_errors:rate5m{method="get", code="500"}  24
method_code:http_errors:rate5m{method="post", code="500"} 6

method:http_requests:rate5m{method="get"}  600
method:http_requests:rate5m{method="del"}  34
method:http_requests:rate5m{method="post"} 120

각각 1개만 남고 1:1로 매칭한다. 연산자는 / (나누기) 이므로 결과는 다음과 같다.

 

{method="get"}  0.04            //  24 / 600
{method="post"} 0.05            //   6 / 120

One-to-many

One-to-many의 문법은 다음과 같다.

 

<vector expr> <bin-op> ignoring(<label list>) group_left(<label list>) <vector expr>
<vector expr> <bin-op> ignoring(<label list>) group_right(<label list>) <vector expr>
<vector expr> <bin-op> on(<label list>) group_left(<label list>) <vector expr>
<vector expr> <bin-op> on(<label list>) group_right(<label list>) <vector expr>

group_left/group_right 문법을 설명하기 전에 예시부터 보도록 하자.

 

method_code:http_errors:rate5m / ignoring(code) group_left method:http_requests:rate5m

위의 예에서 code를 제외하고 method 만으로 매칭하면, method="get"인 경우 다음과 같이 2:1(N:1) 의 관계가 된다. 

 

method_code:http_errors:rate5m{method="get", code="500"}  24
method_code:http_errors:rate5m{method="get", code="404"}  30

method:http_requests:rate5m{method="get"}  600

여기서 최종적으로 보여줄수 있는 값은 N에 해당되는 method_code:http_errors:rate5m vector이다.

 

{method="get", code="500"}  0.04            //  24 / 600
{method="get", code="404"}  0.05            //  30 / 600

이처럼 많은 갯수를 가진 vector를 "high cardinality를 가졌다"고 한다.

(* cadinality: vector가 취하는 샘플의 범위(레이블의 다양함)을 말한다. 여기서 method는 get 하나일 뿐이고,  code는 하나의 get에도 여러가지 종류를 가진다. 즉, method:code = 1:N 의 관계를 가지고, code를 가진 method_code:http_errors:rate5m 가 high cardinality를 가졌다)

 

group_left/group_right 은 이 higher cardinality를 가진 vector가 어떤 것인지 선택하는 문법이다.(결과적으로 표현되는 최종 기준 vector라고 생각하면 된다)

 

위의 예제에서 반대로 하게되면 당연히 에러가 난다. 600이란 값을 어떤 값으로 나눌지 판단할 수 없기 때문이다.

 

Label 병합

group_left/group_right 에 label list를 넣을 수 있는데, 이건 낮은 cardinality를 가진 vector의 label을 높은 cardinality의 vector로 결합한다는 의미이다. 사실 값의 연산보다 이런 label 병합에 vector matching이 주로 사용 된다.

 

method_code:http_errors:rate5m  24
method_code:http_errors:rate5m  30
method_code:http_errors:rate5m  3
method_code:http_errors:rate5m 6
method_code:http_errors:rate5m 21

method:http_requests:rate5m  600
method:http_requests:rate5m  34
method:http_requests:rate5m 120

위 변형된 예제에서 message label 을 최종결과에 넣고 싶다면 쿼리는 다음과 같다.

 

method_code:http_errors:rate5m / ignoring(code) group_left(message) method:http_requests:rate5m

method="get" 인 경우

 

  0.04            //  24 / 600
  0.05            //  30 / 600

좀 더 복잡한 쿼리를 이해해보자.

 

sum by (node, type) (
    kube_node_status_allocatable{resource="cpu"}
    * on (node) group_left(type)
    label_replace(
        kube_node_labels, "type", "$1", "label_type", "(.+)"
    )
)

위의 쿼리의 목적은 kube_node_status_allocatable 에 type이란 label을 kube_node_labels 로부터 가져와 결합해 보여주기 위함이다.

 

kube_node_labels는 kube-state-metrics에서 생성하는 metric중 하나로, node에 설정된 label 정보(kubernetes label) 을 가지고 있는 metric이다. 각 label 키 이름은 "label_<label key>" 형태로 저장된다. 

 

kube_node_labels

label_replace는 label key와 value를 변경해주는 함수로, 사용법은 다음과 같다.

 

label_replace(v instant-vector, dst_label string, replacement string, src_label string, regex string)

 

예제를 가지고 설명을 하면, label_type이란 label을 찾아 이름을 type으로 변경하고, label의 전체 값을 그대로 가져간다는 의미이다. 

 

label_replace 부분만 실행해 보면 다음과 같다. label_type 이 type으로 바뀐 것을 알 수 있다.

*정정: label_replace는 실제로 변경을 해주는 것이 아니라 새로 생성해주는 기능을 한다. type이 생성된다고 해도 label_type은 그대로 남아있게 된다. 해서 최종적으로는 label_type을 제거해주는 처리를 추가로 해줘야 한다.

 

 

이 두개의 vector를 node 라는 label 로 매칭하고 곱한 다음(실제 kube_node_labels은 label 정보를 위한 metric으로 값은 모두 1 이라 곱셈을 해도 원래 metric인 kube_node_status_allocatable 값은 변하지 않는다) type이란 label 을 결과에 넣게 된다.

 

kube_node_status_allocatable 또한 kube-state-metrics에서 생성하는 metric중 하나이고, 이에 대한 예시는 다음과 같다. 예제에서는 cpu 리소스를 대상으로 한다.

 

kube_node_status_allocatable{endpoint="http",instance="10.240.3.2:8080",job="kube-state-metrics",namespace="default",node="worker-1",pod="nginx-77cfp4rb",resource="cpu",service="kube-state-metrics",unit="core"} 0.1

추가적으로 sum by (node, type) 연산으로 node, type을 제외한 job, endpoint 등을 무시하고 합산한다.

 

결과는 다음과 같다.

{node="worker-1",type="bare-metal"}	15.8
{node="worker-2",type="vm"}		4.8
{node="worker-3",type="vm"}		4.8

Vector matching시 주의해야할 점은 계산된 쿼리가 테스트 당시에는 1:1 혹은 1:N 이더라도 상황에 따라 N:M이 되어 "many-to-many" 에러가 발생할 수 있다(결국 metric이 생성되지 않을 수 있다는 점이다)

 

Vector Matching은 강력한 기능이지만 Prometheus 성능을 저하시키는 가장 큰 문제거리기도 하다. 1번의 Vector Matching정도는 괜찮지만 쿼리를 복잡하게 만들다보면 2중, 3중으로 Vector Matching을 하게 된다. 거기에 Range Vector까지 조합시킨다면 자칫 Prometheus가 OOM으로 죽을수도 있다. 쿼리가 복잡해질때는 반드시 RecordRule로 만들도록 한다.(RecordRule은 일반 DBMS에서 말하는 View에 해당한다. 쿼리를 Instance Vector단위로 계산하여 새로운 metric으로 저장한다. 이는 설정해놓은 시간마다 계산해 저장하므로 쿼리시 단일 metricr과 동일한 성능으로 리턴된다.)

 

Vector matching시 시간대 계산

Vector들은 항상 정확히 동일한 시간을 가지지 않는다. 예를들어 mysql exporter와 node exporter가 있고 이를 Prometheus에서 scrape한다고 하면, 두개의 exporter 의 interval도 다를 수 있고, 수집 duration도 달라 수집 시간이 정확히 일치할 가능성은 적다. 헌데 앞서 말한 Vector의 의미에 대한 설명을 보면 "all sharing the same timestamp" 이란 표현이 있다. 

 

Prometheus에서는 이 의미를, "buffer를 둔 시간대"를 적용해 그 안에 포함된 vector들을 "same timestamp"로 간주한다.

 

앞서 Range Vector 설명시 나온 예제를 다시 보면, 각각의 값의 시간이 다른것을 알 수 있다.

 

http_requests_total
[1037 @1551242271.728, 1038 @1551242331.728, 1040 @1551242391.728]

http_requests_total
[500 @1551242484.013, 501 @1551242544.013, 502 @1551242604.013]

그렇다면 그 "시간대"는 어느정도를 말할까? 기본적으로 prometheus에는 5분으로 설정되어 있다. 

Staleness

When queries are run, timestamps at which to sample data are selected independently of the actual present time series data. This is mainly to support cases like aggregation (sumavg, and so on), where multiple aggregated time series do not exactly align in time. Because of their independence, Prometheus needs to assign a value at those timestamps for each relevant time series. It does so by simply taking the newest sample before this timestamp.

If a target scrape or rule evaluation no longer returns a sample for a time series that was previously present, that time series will be marked as stale. If a target is removed, its previously returned time series will be marked as stale soon afterwards.

If a query is evaluated at a sampling timestamp after a time series is marked stale, then no value is returned for that time series. If new samples are subsequently ingested for that time series, they will be returned as normal.

If no sample is found (by default) 5 minutes before a sampling timestamp, no value is returned for that time series at this point in time. This effectively means that time series "disappear" from graphs at times where their latest collected sample is older than 5 minutes or after they are marked stale.

Staleness will not be marked for time series that have timestamps included in their scrapes. Only the 5 minute threshold will be applied in that case.

 

https://prometheus.io/docs/prometheus/latest/querying/basics/#staleness

 

위와 같은 특징으로 인해 Prometheus Console 창에서도 5분이 지난 metric은 나타나지 않음을 알 수 있다. 

해당 값은 prometheus 실행시 option 값으로 조정할 수 있다. option 이름은 query.lookback-delta 이다.

 

마무리

이렇게 해서 Prometheus Query 에 대해 간단히 이해해 보았다. 여러가지 추가 예제들과 각종 계산식, 함수에 대한 사용법은 공식 홈페이지를 참고하기 바란다. 이 설명으로 Prometheus Query를 이해하는데 다소 도움이 되면 좋겠다.