2. Cardinality and Relabeling
메트릭 이름에 실행 id가 박힐 때, relabel로 떼어 내기와 그 한계
- Haram Lee
- 2026-10-01
- studies / Topics / Observability
2.1 What Cardinality Means
cardinality는 시계열 수, 또는 label 하나가 가질 수 있는 값의 수를 말한다. 1편에서 본 것처럼 label 값 조합 하나가 시계열 하나이고, 시계열마다 index 항목과 메모리, 디스크 비용이 든다. 이름 짓기 문서는 이렇게 경고한다.
Remember that every unique combination of key-value label pairs represents a new time series, which can dramatically increase the amount of data stored. Do not use labels to store dimensions with high cardinality (many different label values), such as user IDs, email addresses, or other unbounded sets of values.
계측 문서는 숫자로 기준을 준다.
As a general guideline, try to keep the cardinality of your metrics below 10, and for metrics that exceed that, aim to limit them to a handful across your whole system. The vast majority of your metrics should have no labels.
If you have a metric that has a cardinality over 100 or the potential to grow that large, investigate alternate solutions such as reducing the number of dimensions or moving the analysis away from monitoring and to a general-purpose processing system.
사용자 id, 요청 id, 실행 id처럼 끝없이 늘어나는 값은 label에 두지 말라는 것이 요지다.
2.2 When the Metric Name Carries an App ID
Spark driver의 PrometheusServlet sink는 이 원칙을 기본 설정에서 어긴다. driver endpoint(/metrics/driver/prometheus/)가 내놓는 이름은 이런 모양이다.
metrics_spark_3f9c2a7b1e4d_driver_BlockManager_memory_maxMem_MB_Number
└── app id ──┘이름이 만들어지는 과정은 Spark 코드에서 확인할 수 있다. MetricsSystem.buildRegistryName은 key를 {namespace}.{driver 또는 executor id}.{source}.{metric}으로 만든다. namespace는 spark.metrics.namespace이고, 비어 있으면 spark.app.id를 쓴다. 이어서 PrometheusServlet.normalizeKey가 key의 영숫자가 아닌 문자를 _로 바꾸고 앞에 metrics_를 붙인다. 그래서 spark-3f9c2a7b1e4d 같은 app id가 그대로 이름 한가운데에 들어간다.
Spark 문서도 이 점을 짚는다.
By default, the root namespace used for driver or executor metrics is the value of
spark.app.id. However, often times, users want to be able to track the metrics across apps for driver and executors, which is hard to do with application ID (i.e.spark.app.id) since it changes with every invocation of the app.
app id는 “측정 대상의 특징"이 아니라 “어느 실행인지"를 말하는 값이므로, 이름보다는 label에 들어가야 할 정보다. 이 값이 이름에 들어가면 실행할 때마다 새 이름이 생기고, 짧은 batch 잡이 자주 도는 환경에서는 메트릭 이름 수가 끝없이 늘어난다. 이름 목록 조회(/api/v1/label/__name__/values)와 Grafana의 메트릭 탐색기가 느려지는 것이 첫 증상이다. 반면 executor 요약 endpoint(/metrics/executors/prometheus/)의 이름은 metrics_executor_*로 고정이라 이 문제가 없다.
이름을 고치는 방법은 둘이다. Spark 쪽에서 spark.metrics.namespace를 고정하거나, 수집하는 쪽에서 relabel로 이름을 바꾸는 것이다. 먼저 relabel이 무엇인지부터 본다.
2.3 How relabel_config Works
relabel config를 한 문장으로 줄이면 이렇다. label 집합을 받아서, 규칙 목록을 위에서부터 차례로 적용해 label을 고치거나 대상을 버리는 설정이다.
Prometheus에서는 거의 모든 것이 label이다. 메트릭 이름은 __name__이고, 수집할 주소와 경로, 주기도 __address__, __metrics_path__, __scrape_interval__ 같은 label로 표현된다. 그래서 무엇을 긁을지, 어떤 label을 붙일지, 무엇을 저장할지, 이름을 어떻게 바꿀지를 모두 같은 문법 하나로 다룬다.
같은 문법이 수집 경로의 네 군데에서 쓰인다. 차이는 무엇의 label을 고치느냐다.
flowchart LR
sd["service discovery<br/>__meta_* label"] --> rc["relabel_configs<br/>target label 고치기"]
rc --> scrape["scrape<br/>(HTTP GET)"]
scrape --> mrc["metric_relabel_configs<br/>sample label 고치기"]
mrc --> store[("로컬 저장")]
mrc --> wrc["write_relabel_configs"] --> remote[("원격 저장")]| 시점 | Prometheus 설정 | ServiceMonitor 필드 | 고치는 대상 | 대표 용도 |
|---|---|---|---|---|
| scrape 전 | relabel_configs | relabelings | target의 label | 긁을 target 고르기, target label 붙이기 |
| scrape 후, 저장 전 | metric_relabel_configs | metricRelabelings | 긁어 온 sample 하나하나의 label | 이름 바꾸기, 비싼 메트릭 버리기 |
| 원격 저장으로 보내기 전 | write_relabel_configs | Prometheus CR의 remoteWrite | 보낼 sample | 원격으로 보낼 것만 고르기 |
| 알림 보내기 전 | alert_relabel_configs | Prometheus CR 설정 | 알림 label | 알림 label 정리 |
메트릭 이름을 바꾸거나 메트릭을 버리는 일은 두 번째 시점, metric_relabel_configs에서 한다.
Metric relabeling is applied to samples as the last step before ingestion. It has the same configuration format and actions as target relabeling. Metric relabeling does not apply to automatically generated timeseries such as
up. One use for this is to exclude time series that are too expensive to ingest.
규칙 하나는 기본 action인 replace 기준으로 이렇게 동작한다.
source_labels에 적은 label들의 값을 읽는다. 없는 label은 빈 문자열이다.- 값들을
separator(기본;)로 이어 붙여 문자열 하나를 만든다. - 그 문자열을
regex와 비교한다. 정규식은 양 끝이 고정되어 있어 문자열 전체가 맞아야 한다. - 맞으면
replacement의${1},${2}자리를 캡처 그룹으로 채워target_label에 쓴다. 안 맞으면 아무것도 하지 않는다.
규칙 목록은 위에서부터 차례로 적용되고, 앞 규칙이 바꾼 label을 뒤 규칙이 읽는다. 정규식의 양 끝 고정을 풀고 싶을 때를 위해 문서는 “To un-anchor the regex, use .*<regex>.*.“라고 적어 둔다.
필드 이름은 Prometheus 설정과 ServiceMonitor에서 표기만 다르다(source_labels와 sourceLabels, target_label과 targetLabel). 기본값은 regex가 (.*), replacement가 $1, action이 replace다.
action은 몇 가지가 더 있다.
| action | 동작 | 쓰는 경우 |
|---|---|---|
replace | regex와 맞으면 target_label을 replacement로 설정한다 | label 값 뽑기, __name__ 바꾸기 |
drop | regex와 맞는 대상을 버린다 | 비싼 메트릭 통째로 버리기 |
keep | regex와 안 맞는 대상을 버린다 | 남길 대상만 고르기 |
labeldrop | 이름이 regex와 맞는 label을 지운다 | 필요 없는 label 정리 |
labelkeep | 이름이 regex와 안 맞는 label을 지운다 | 남길 label만 고르기 |
labelmap | label 이름을 패턴으로 바꿔 복사한다 | __meta_*를 일반 label로 옮기기 |
labeldrop과 labelkeep에는 문서가 따로 주의를 준다. label을 지운 뒤에도 남은 label만으로 시계열이 서로 구분되어야 한다.
__로 시작하는 label에도 규칙이 있다. scrape 전 단계가 끝나면 __로 시작하는 label은 모두 지워지므로, __meta_*는 그 단계 안에서만 쓸 수 있다. 규칙 사이에서 값을 잠시 담아 둘 때는 __tmp 접두사를 쓴다. __name__만은 예외로 저장까지 남는다.
2.4 Pulling the App ID Out of the Name
목표는 메트릭을 버리지 않고, app id를 이름에서 떼어 app_id label로 옮기는 것이다.
metrics_spark_driver_BlockManager_memory_maxMem_MB_Number{app_id="3f9c2a7b1e4d"}driver endpoint의 metric_relabel_configs에 규칙 두 개를 둔다.
metric_relabel_configs:
# 1) 이름에서 app id를 뽑아 app_id label에 쓴다.
- source_labels: [__name__]
regex: metrics_spark_([0-9a-z]+)_driver_(.+)
target_label: app_id
replacement: ${1}
# 2) 이름에서 app id를 뺀다.
- source_labels: [__name__]
regex: metrics_spark_[0-9a-z]+_driver_(.+)
target_label: __name__
replacement: metrics_spark_driver_${1}동작 순서대로 읽으면 이렇다. 두 규칙 모두 __name__, 즉 메트릭 이름을 읽는다. 정규식이 양 끝 고정이라 이름 전체가 metrics_spark_…_driver_… 모양일 때만 맞고, 다른 메트릭은 건드리지 않는다. 첫 규칙은 캡처 그룹 1로 app_id label을 만든다. 두 번째 규칙은 __name__ 자체를 새 이름으로 바꾼다. 순서가 중요하다. 이름을 먼저 바꾸면 첫 규칙이 app id를 찾을 수 없다.
Prometheus Operator를 쓴다면 같은 규칙을 ServiceMonitor endpoint의 metricRelabelings에 단다. endpoint별로 달 수 있어서 driver 경로에만 규칙을 걸 수 있다.
endpoints:
- port: spark-ui
path: /metrics/driver/prometheus/
metricRelabelings:
- sourceLabels: [__name__]
regex: metrics_spark_([0-9a-z]+)_driver_(.+)
targetLabel: app_id
replacement: ${1}
- sourceLabels: [__name__]
regex: metrics_spark_[0-9a-z]+_driver_(.+)
targetLabel: __name__
replacement: metrics_spark_driver_${1}한 가지 함정이 있다. app id 형식이 바뀌면, 예를 들어 대문자가 섞이면 규칙은 오류 없이 매칭에 실패한다. 매칭되지 않은 규칙은 아무것도 하지 않으므로 메트릭이 옛 이름 그대로 들어온다. 이렇게 실패가 조용히 지나간다는 점을 알고 있어야 한다.
2.5 Fixing It at the Source Instead
같은 결과를 Spark 쪽에서 만들 수도 있다. spark.metrics.namespace를 고정값으로 주면 이름이 처음부터 metrics_spark_driver_…로 나온다.
spark.metrics.namespace=spark| relabel | spark.metrics.namespace 고정 | |
|---|---|---|
| 고치는 곳 | 수집 설정 | 잡 설정 |
| 적용 범위 | 해당 scrape 설정을 거치는 메트릭만 | 그 설정을 쓰는 모든 잡, 모든 수집 경로 |
| 정규식 의존 | 있다 | 없다 |
| app id label | 규칙으로 만든다 | 남지 않는다. pod label 등에서 찾아야 한다 |
| 긁어 오는 부하 | 그대로 | 그대로 |
어느 쪽이 나은지는 누가 무엇을 바꿀 수 있는지에 달려 있다. 수집 설정을 환경별로 따로 관리한다면 relabel로 한 환경씩 적용해 볼 수 있다. Spark 문서는 ${spark.app.name}을 namespace로 쓰는 예를 든다. 하지만 app 이름에 실행 시각 같은 값이 들어 있으면 같은 문제가 반복되므로 고정 문자열이 안전하다.
2.6 What Relabeling Cannot Do
relabel은 결국 “label 집합 하나를 받아 고치거나 버리는 필터"다. 한계도 여기서 나온다.
하나를 여러 개로 복사할 수 없다. sample 하나가 들어가면 결과는 0개나 1개다. 옛 이름과 새 이름을 둘 다 저장하는 alias는 만들 수 없다.
긁는 비용은 줄지 않는다. metric_relabel_configs는 scrape 뒤에 동작한다. endpoint가 내놓는 양을 줄이려면 원천을 고쳐야 한다.
이미 저장된 데이터는 바뀌지 않는다. relabel은 들어오는 sample에만 적용된다. 이미 쌓인 옛 이름은 보관 기간이 지나야 사라진다.
recording rule로도 동적 이름을 되살릴 수 없다. recording rule의 결과 이름은 고정 문자열이다.
그리고 가장 중요한 한계가 하나 더 있다. churn은 줄지 않는다.
2.7 Cardinality Is the Whole Label Set
relabel을 적용하고 나서야 제대로 이해한 것이 있다. cardinality는 이름의 문제가 아니라 label 조합 전체의 문제다. TSDB 안에서 메트릭 이름도 label 하나일 뿐이라, app id를 이름에서 label로 옮기는 것만으로는 시계열 수가 줄지 않는다.
| 지표 | app id가 이름에 있을 때 | label로 옮기면 |
|---|---|---|
| 메트릭 이름 수 | 실행마다 늘어난다 | 고정된다 |
| 시계열 수 | 실행마다 늘어난다 | 여전히 실행마다 늘어난다 |
이름 목록 조회가 느린 문제는 이름 수 때문이므로 relabel로 해결된다. 하지만 실제 부담은 실행마다 새 시계열이 생겼다가 버려지는 churn이다. 이전 실행의 시계열은 더 이상 값이 들어오지 않은 채 index에 남고, 다음 실행은 새 시계열을 만든다. scrape_series_added가 바로 이 양을 보여 준다.
게다가 실행마다 바뀌는 label은 app id 하나가 아니다. 쿠버네티스에서 수집하면 pod, instance처럼 대상을 가리키는 label도 실행마다 새 값이 된다. ServiceMonitor가 기본으로 붙이는 service, job도 잡 구성에 따라 실행마다 바뀔 수 있다.
flowchart LR r1["실행 1<br/>pod=job-a1-driver"] --> s1["시계열 A"] r2["실행 2<br/>pod=job-b2-driver"] --> s2["시계열 B"] r3["실행 3<br/>pod=job-c3-driver"] --> s3["시계열 C"] fix["실행 id label 제거<br/>+ 잡 이름 label"] --> one["시계열 하나를 이어 씀"]
그래서 해법은 실행과 무관한 값으로 묶는 것이다. 잡 이름처럼 실행이 바뀌어도 그대로인 값을 pod label로 붙이고, podTargetLabels로 시계열 label에 옮긴다. 그런 다음 실행마다 바뀌는 label은 labeldrop으로 지운다. 그러면 다음 실행이 같은 시계열을 이어 쓴다.
metricRelabelings:
- action: labeldrop
regex: app_id|pod|instance묶는 단위를 고를 때는 먼저 동시에 도는 실행끼리 값이 섞이지 않는지 본다. 같은 label 값을 가진 실행 두 개가 동시에 돌면, label을 지운 뒤 두 실행의 sample이 같은 시계열로 들어가 서로를 덮어쓴다. 앞에서 본 “labeldrop 뒤에도 시계열이 서로 구분되어야 한다"는 주의가 바로 이 상황이다. 여러 잡을 묶은 그룹처럼 너무 넓은 단위로 묶으면 동시 실행이 자주 겹치고, 잡 하나 단위로 묶으면 겹치는 일이 드물다.
같은 관점으로 다른 시스템을 보면 비교가 된다. Flink는 job id 같은 값을 처음부터 label로 붙이기 때문에 이름 폭발은 없다. 하지만 잡이 자주 재시작하면 재시작마다 새 시계열이 생겨 churn이 커진다. 이름이 고정되어 있다고 cardinality 문제가 없는 것은 아니다.
2.8 Migrating Dashboards
relabel로 이름이 바뀌면 옛 이름을 찾던 대시보드 쿼리는 결과가 비게 된다. alias를 둘 수 없으니 전환은 쿼리에서 처리한다. 먼저 쿼리가 두 이름을 or로 함께 받도록 바꾸고, 그다음 relabel을 적용하면 대시보드가 끊기지 않는다.
metrics_spark_driver_DAGScheduler_stage_runningStages_Value{app_id="$app_id"}
or
{__name__=~"metrics_.*_DAGScheduler_stage_runningStages_Value", __name__=~".*$app_id.*"}옛 이름이 보관 기간을 지나 모두 사라지면 or 뒤쪽을 지운다.