왜 라벨 카디널리티가 문제인가

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 설정과 함께 모니터링해야 한다. 마지막으로, 라벨 스키마 변경은 소급 적용되지 않는다. 이미 저장된 데이터의 스트림 구조는 그대로 남으므로, 스키마 설계는 초기에 신중히 결정하는 것이 좋다.