Loki 라벨 카디널리티 폭발, structured metadata로 잡는 법
Loki를 운영해봤다면 한 번쯤은 겪는다. 어느 날 갑자기 인덱스가 폭발하고 쿼리가 느려지고, 로그 라이터에서 stream limit exceeded 에러가 뜬다. 원인은 대개 하나다. 누군가 라벨에 넣지 말아야 할 값을 넣었다.
이 글은 그런 상황을 만난 팀을 위한 짧은 실무 가이드다. Loki 3.x 이후 정착된 structured metadata를 어떻게 쓰는지, 라벨과 structured metadata를 어떻게 나눠야 하는지 정리한다.
무엇을 라벨에 넣으면 안 되는가
원칙은 단순하다. 라벨은 수십 개 이내의 고유 값만, 그리고 오래 살아있는 값만 넣는다. 아래는 라벨에 넣으면 안 되는 대표적인 값들이다.
trace_id,request_id,order_id같은 요청 단위 IDuser_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_query 와 loki_request_duration_seconds를 대시보드에 걸어두고 이관 전후를 비교하면 된다. 우리 팀에서는 트래픽이 큰 서비스 하나에서 스트림 수가 8만 → 1200으로 떨어지면서 P95 쿼리 레이턴시가 4.2초 → 900ms로 줄었다.
셋째, structured metadata도 무제한은 아니다. limits_config.max_structured_metadata_size와 max_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를 오가며 찾아주지 않는다.
그래서 이관 순서는 이렇다.
- structured_metadata 스테이지를 먼저 추가한다 (라벨은 유지)
- 새로 들어오는 로그부터 양쪽에 다 붙는지 확인한다
- 대시보드/알람 쿼리를 하나씩 새 문법으로 바꾼다
- 마지막에 labels 스테이지에서 해당 필드를 뺀다
3번과 4번 사이에 최소 로그 보관 기간(예: 7일)만큼은 두는 게 안전하다. 오래된 로그를 찾을 때 기존 라벨 쿼리가 계속 통해야 하니까.
마무리
아직도 라벨에 pod_name이나 request_id를 넣고 있다면, 지금이 옮길 때다. Loki 3.x가 안정화된 지 꽤 됐고 structured metadata는 검증이 끝났다. 카디널리티가 이미 폭발한 상태라면 이관 전에 retention_period를 짧게 잡아서 기존 인덱스를 먼저 정리하는 것도 방법이다.
혹시 Vector나 fluent-bit에서 structured metadata를 쓰는 팀 있으면 설정 예제 공유 부탁드립니다. Alloy 이외 파이프라인은 예제가 아직 부족해서요.