IT/모니터링

Loki 라벨 카디널리티 폭발, structured metadata로 잡는 법

gfrog 2026. 8. 12. 12:19

Loki를 운영해봤다면 한 번쯤은 겪는다. 어느 날 갑자기 인덱스가 폭발하고 쿼리가 느려지고, 로그 라이터에서 stream limit exceeded 에러가 뜬다. 원인은 대개 하나다. 누군가 라벨에 넣지 말아야 할 값을 넣었다.

이 글은 그런 상황을 만난 팀을 위한 짧은 실무 가이드다. Loki 3.x 이후 정착된 structured metadata를 어떻게 쓰는지, 라벨과 structured metadata를 어떻게 나눠야 하는지 정리한다.

무엇을 라벨에 넣으면 안 되는가

원칙은 단순하다. 라벨은 수십 개 이내의 고유 값만, 그리고 오래 살아있는 값만 넣는다. 아래는 라벨에 넣으면 안 되는 대표적인 값들이다.

  • trace_id, request_id, order_id 같은 요청 단위 ID
  • user_id, tenant_id 같은 사용자 단위 ID (사용자가 소수라면 예외)
  • pod_name (pod가 자주 재기동되는 워크로드에서)
  • HTTP path 전체 (예: /api/v1/users/1234/orders/5678)
  • 에러 메시지 원문

이 값들은 스트림 수를 폭발시킨다. Loki는 라벨의 조합마다 별도 스트림을 만들고, 각 스트림마다 청크를 따로 flush한다. 카디널리티가 높으면 수만 개의 작은 청크가 오브젝트 스토리지에 쌓이고, 인덱스도 같이 커진다. 쿼리는 느려지고 비용은 올라간다.

structured metadata가 뭘 바꿨나

Loki 2.9부터 실험적으로 들어왔던 structured metadata가 3.0 정식, 3.5에서 성능 개선을 거치며 지금은 사실상 표준이 됐다. 핵심은 하나다.

"고카디널리티 값을 인덱스 없이 로그 라인 옆에 붙여둘 수 있다."

전에는 trace_id로 로그를 찾으려면 라벨에 넣어야 했다. 넣으면 카디널리티가 폭발했다. 안 넣으면 |~ "trace_id=abc" 같은 정규식 필터를 걸어야 했는데, 이건 로그 라인을 다 스캔하는 부하가 있었다. structured metadata는 그 중간이다. 인덱스는 안 만들지만, 파싱 없이 바로 필터할 수 있다.

실제 설정 예시

Promtail을 아직 쓰고 있다면 (Alloy로 넘어간 곳도 많지만) 아래처럼 pipeline_stages에서 structured_metadata로 승격시킨다.

scrape_configs:
  - job_name: kubernetes-pods
    pipeline_stages:
      - json:
          expressions:
            level: level
            trace_id: trace_id
            user_id: user_id
            http_path: http.path
      - labels:
          level:  # 라벨로 유지: 값이 debug/info/warn/error 정도
      - structured_metadata:
          trace_id:   # 인덱스 없이 옆에 붙임
          user_id:
          http_path:

Grafana Alloy(구 Grafana Agent)에서는 이렇게 쓴다.

loki.process "app_logs" {
  forward_to = [loki.write.default.receiver]

  stage.json {
    expressions = {
      level     = "level",
      trace_id  = "trace_id",
      user_id   = "user_id",
    }
  }

  stage.labels {
    values = { level = "" }
  }

  stage.structured_metadata {
    values = {
      trace_id = "",
      user_id  = "",
    }
  }
}

쿼리는 어떻게 달라지는가

기존에 trace_id가 라벨이었다면 이렇게 썼을 것이다.

{app="checkout", trace_id="abc123"}

structured metadata로 옮기면 이렇게 된다.

{app="checkout"} | trace_id="abc123"

문법 하나 바뀌었는데 인덱스 부하가 사라진다. 라벨 스트림 수는 {app="checkout"} 조합만큼만 존재하고, trace_id 필터링은 스트림 내에서 이뤄진다. LogQL 파서가 structured metadata를 인식하기 때문에 정규식 스캔보다 훨씬 빠르다.

얼마나 옮길지는 실측으로 정한다

라벨을 structured metadata로 이관할 때 무작정 다 옮기면 안 된다. 몇 가지 체크가 필요하다.

첫째, 라벨별 카디널리티를 먼저 본다. Loki 자체가 loki_ingester_streams_created_total 같은 메트릭을 노출한다. 그리고 logcli series '{...}' --analyze-labels 명령을 쓰면 스트림 수와 라벨별 고유 값 개수를 뽑아준다.

logcli series '{namespace="prod"}' --analyze-labels --since=24h

여기서 고유 값이 100개 넘어가는 라벨은 후보다. 1000개 넘어가면 무조건 옮겨야 한다.

둘째, 옮긴 뒤에는 압축/쿼리 성능을 모니터링한다. loki_chunk_store_index_lookups_per_queryloki_request_duration_seconds를 대시보드에 걸어두고 이관 전후를 비교하면 된다. 우리 팀에서는 트래픽이 큰 서비스 하나에서 스트림 수가 8만 → 1200으로 떨어지면서 P95 쿼리 레이턴시가 4.2초 → 900ms로 줄었다.

셋째, structured metadata도 무제한은 아니다. limits_config.max_structured_metadata_sizemax_structured_metadata_entries_count가 있다. 3.5 기준 기본값이 각각 64KB, 128개인데, 페이로드가 큰 서비스는 늘려야 한다.

limits_config:
  max_structured_metadata_size: "128kb"
  max_structured_metadata_entries_count: 256

마이그레이션할 때 흔한 함정

한 가지 주의할 점. 기존에 라벨로 쿼리하던 alert 룰, 대시보드가 다 깨진다. {app="x", trace_id="y"} 형태를 쓰던 쿼리는 라벨을 빼는 순간 아무것도 못 찾는다. Loki는 알아서 라벨과 structured metadata를 오가며 찾아주지 않는다.

그래서 이관 순서는 이렇다.

  1. structured_metadata 스테이지를 먼저 추가한다 (라벨은 유지)
  2. 새로 들어오는 로그부터 양쪽에 다 붙는지 확인한다
  3. 대시보드/알람 쿼리를 하나씩 새 문법으로 바꾼다
  4. 마지막에 labels 스테이지에서 해당 필드를 뺀다

3번과 4번 사이에 최소 로그 보관 기간(예: 7일)만큼은 두는 게 안전하다. 오래된 로그를 찾을 때 기존 라벨 쿼리가 계속 통해야 하니까.

마무리

아직도 라벨에 pod_name이나 request_id를 넣고 있다면, 지금이 옮길 때다. Loki 3.x가 안정화된 지 꽤 됐고 structured metadata는 검증이 끝났다. 카디널리티가 이미 폭발한 상태라면 이관 전에 retention_period를 짧게 잡아서 기존 인덱스를 먼저 정리하는 것도 방법이다.

혹시 Vector나 fluent-bit에서 structured metadata를 쓰는 팀 있으면 설정 예제 공유 부탁드립니다. Alloy 이외 파이프라인은 예제가 아직 부족해서요.