왜 라벨 카디널리티가 문제인가
Loki는 Prometheus와 달리 로그 본문을 인덱싱하지 않고 라벨 조합(스트림)만 인덱싱한다. 하나의 고유한 라벨 조합이 하나의 스트림이 되고, 각 스트림은 별도의 chunk로 관리된다. 문제는 라벨 값의 종류가 많아질수록 스트림 수가 곱셈으로 폭발한다는 점이다.
예를 들어 cluster(3) × namespace(20) × app(50)까지는 3,000개로 관리 가능하다. 하지만 여기에 user_id나 request_id 같은 고유값을 라벨로 넣으면 스트림이 수십만 개로 늘어난다. 이를 고카디널리티(high cardinality) 라벨이라 부르며, Loki 성능 저하의 가장 흔한 원인이다.
고카디널리티가 유발하는 구체적 증상
- ingester 메모리 급증: 각 스트림은 활성 chunk를 메모리에 보관한다. 스트림이 많으면 OOM으로 이어진다.
- 수많은 작은 chunk 생성: 트래픽이 스트림별로 잘게 쪼개져 chunk가 flush 임계치를 못 채우고 조기 flush된다. 오브젝트 스토리지 요청 폭증과 압축률 저하가 따라온다.
- 쿼리 지연: 쿼리 시 매칭되는 모든 스트림의 chunk를 열어야 하므로 스캔량이 폭발한다.
per_stream_rate_limit및max_streams_per_user초과로 로그 유실이 발생한다.
라벨은 무엇을 넣고 무엇을 빼야 하나
원칙은 명확하다. 값의 종류가 유한하고 예측 가능한 것만 라벨로 만든다. 나머지는 로그 본문에 두고 쿼리 시점에 필터링한다.
| 구분 | 예시 | 라벨 적합성 |
|---|---|---|
| 인프라 차원 | cluster, namespace, app, level | 적합 (저카디널리티) |
| 준동적 값 | pod, instance | 주의 (재시작 시 증가) |
| 고유 식별자 | user_id, trace_id, ip, url path | 부적합 (라벨 금지) |
Promtail/Alloy 파이프라인에서 걸러내기
수집 단계에서 고카디널리티 라벨을 만들지 않는 것이 가장 효과적이다. 아래는 JSON 로그에서 필드를 추출하되, 라벨로는 level만 승격하고 user_id는 라벨화하지 않는 예시다.
scrape_configs:
- job_name: app-logs
static_configs:
- targets: [localhost]
labels:
job: app
__path__: /var/log/app/*.log
pipeline_stages:
- json:
expressions:
level: level
user_id: user_id # 추출만 하고
- labels:
level: # level만 라벨로 승격
# user_id는 labels에 넣지 않음 → 본문에 남아 쿼리 시 필터 가능
- drop:
expression: ".*healthcheck.*" # 노이즈 로그 사전 제거
쿼리에서 필터 순서 최적화
LogQL은 스트림 셀렉터 → 라인 필터 → 파서 → 라벨 필터 순으로 평가한다. 스트림 셀렉터로 최대한 좁힌 뒤, 비용이 싼 문자열 라인 필터(|=, !=)를 먼저 적용하고 무거운 json/logfmt 파싱은 마지막에 두는 것이 핵심이다.
# 나쁜 예: 넓은 셀렉터 + 먼저 파싱
{cluster="prod"} | json | user_id="42" | level="error"
# 좋은 예: 셀렉터로 좁히고 → 싼 라인필터 → 마지막에 파싱
{cluster="prod", app="checkout", level="error"}
|= "user_id" # 후보 라인 먼저 축소 (인덱스 없이 grep)
| json
| user_id="42"
user_id를 라벨이 아닌 본문 필터로 처리해도, 셀렉터로 스캔 범위를 충분히 좁히면 실측 성능은 오히려 안정적이다. 카디널리티 폭발 없이 필요한 검색이 가능하다.
운영 설정으로 방어선 구축하기
사람의 실수는 반드시 발생하므로 서버 측 한도를 둔다. limits_config에서 테넌트당 스트림·라벨 수를 제한하면 고카디널리티가 클러스터 전체를 마비시키는 사고를 막을 수 있다.
limits_config:
max_streams_per_user: 10000 # 테넌트당 활성 스트림 상한
max_label_names_per_series: 15 # 시리즈당 라벨 개수 제한
max_label_value_length: 2048
per_stream_rate_limit: 3MB
per_stream_rate_limit_burst: 15MB
reject_old_samples: true
reject_old_samples_max_age: 168h
현황 파악에는 logcli의 series API가 유용하다. 특정 라벨이 얼마나 많은 값을 만들어내는지 정기적으로 확인한다.
# 최근 1시간 동안 특정 셀렉터의 스트림(시리즈) 수 확인
logcli series '{cluster="prod"}' --since=1h | wc -l
# 라벨별 값 개수 조회 (카디널리티 후보 식별)
logcli labels user_id --since=1h | wc -l
주의점
카디널리티를 줄이겠다고 라벨을 지나치게 줄이면, 셀렉터가 너무 넓어져 매 쿼리마다 대량 스캔이 발생하는 반대 문제가 생긴다. 목표는 스트림 수는 통제하면서 셀렉터로 검색 범위를 충분히 좁힐 수 있는 균형점이다. 또한 pod처럼 재시작마다 값이 바뀌는 라벨은 시간이 지나며 누적 카디널리티가 커지므로, chunk가 닫히고 스트림이 비활성화되는 chunk_idle_period 설정과 함께 모니터링해야 한다. 마지막으로, 라벨 스키마 변경은 소급 적용되지 않는다. 이미 저장된 데이터의 스트림 구조는 그대로 남으므로, 스키마 설계는 초기에 신중히 결정하는 것이 좋다.