3. Loki: Labels and Log Lines
label만 색인하는 로그 저장소, 왜 label을 적게 두는지, 그리고 LogQL
- Haram Lee
- 2026-10-15
- studies / Topics / Observability
3.1 Index the Labels, Not the Logs
Loki는 Prometheus의 데이터 모델을 로그에 옮겨 온 저장소다. 저장 단위는 stream이고, label 조합 하나가 stream 하나다. Prometheus에서 label 조합 하나가 시계열 하나였던 것과 같은 구조다.
다른 점은 무엇을 색인하느냐다. Loki의 index에는 label만 들어간다. 로그 본문은 색인하지 않고, stream별로 chunk에 묶어 압축해 object storage에 저장한다. 전문 검색 엔진처럼 로그의 모든 단어를 색인하지 않으니 저장 비용과 쓰기 비용이 낮다. 대신 본문으로 거르는 일은 조회할 때 chunk를 읽으면서 한다.
flowchart LR
line["로그 줄"] --> labels["label 조합<br/>{namespace, app, pod}"]
labels -- "index" --> idx[("index<br/>label → stream")]
line -- "본문" --> chunk[("chunk<br/>stream별 압축")]그래서 Loki에서 cardinality는 곧 label 조합의 수, 즉 stream 수다.
3.2 Write and Read Paths
Loki는 여러 컴포넌트로 나눠 돌릴 수 있고, 쓰기와 읽기 경로가 분리되어 있다.
flowchart LR
agent["수집 agent<br/>(Alloy 등)"] --> dist["distributor"]
dist -- "stream hash로 분배, 복제" --> ing["ingester"]
ing -- "chunk flush" --> obj[("object storage")]
qf["query-frontend"] --> qs["query-scheduler"] --> q["querier"]
q --> ing
q --> obj쓰기에서는 distributor가 stream을 hash해서 ingester들에 나눠 보내고 복제한다. ingester는 stream별로 chunk를 메모리에 모았다가 object storage로 내보낸다. 읽기에서는 query-frontend가 쿼리를 시간 구간으로 나누고, querier가 최근 데이터는 ingester에서, 오래된 데이터는 storage에서 읽는다.
수집 방향은 Prometheus와 반대다. Prometheus는 서버가 대상을 긁어 가는 pull이지만, Loki는 각 노드에 떠 있는 agent가 로그를 밀어 넣는 push다. 쿠버네티스에서는 보통 Grafana Alloy 같은 agent를 DaemonSet으로 띄워, 노드의 /var/log/containers/*.log를 읽고 pod 정보를 label로 붙여 보낸다. 어떤 label을 붙일지는 agent 설정이 정하는데, 이것도 Prometheus와 같은 relabel 문법을 쓴다.
같은 stream 안에서는 줄의 순서가 조금 뒤섞여 들어와도 받아들인다. 허용 범위는 그 stream의 가장 최근 줄을 기준으로 max_chunk_age의 절반이고, 기본 설정에서는 1시간이다. stream 하나에 들어오는 속도에도 제한이 있다.
3.3 Why Keep Labels Few
공식 문서의 원칙은 단순하다. 값이 몇 개로 정해진 label은 자유롭게 쓰고, 값이 계속 늘어나는 label은 피한다. namespace, 앱 이름, 환경, 로그 레벨 같은 것은 좋은 label이다. 요청 id, 사용자 id, trace id는 나쁜 label이다.
이유는 저장 구조에서 나온다. label 값이 늘면 stream이 쪼개진다. stream마다 chunk가 따로 생기므로, 같은 양의 로그가 수많은 작은 chunk로 흩어진다. 그러면 index가 커지고, ingester는 열려 있는 stream마다 메모리를 쓰며, 조회할 때는 작은 chunk를 많이 읽어야 한다. Prometheus에서 시계열 수가 비용이었던 것처럼, Loki에서는 stream 수가 비용이다.
그러면 값이 많은 필드로는 어떻게 거를까. 두 가지 길이 있다. 하나는 label로 범위를 좁힌 다음 본문을 line filter로 거르는 것이다. Loki는 이 방식에 맞춰 설계되어 있다. 다른 하나는 structured metadata다.
3.4 Structured Metadata
structured metadata는 로그 줄마다 붙는 key-value다. index에 들어가지 않으므로 stream 수를 늘리지 않는다. 그러면서도 줄마다 값이 남아 있어 조회할 때 label처럼 거를 수 있다.
| label | structured metadata | 본문 | |
|---|---|---|---|
| index에 들어가나 | 들어간다 | 들어가지 않는다 | 들어가지 않는다 |
| stream을 나누나 | 나눈다 | 나누지 않는다 | 나누지 않는다 |
| 거르는 방법 | {} 안의 stream selector | {} 뒤의 label filter | line filter, parser |
| 어울리는 값 | 값이 적고 고정된 것 | 값이 많지만 자주 거르는 것 | 나머지 |
pod 이름은 좋은 예다. 쿠버네티스에서 pod 이름은 재시작이나 배포마다 바뀌므로 label로 두면 stream이 계속 생긴다. structured metadata로 옮기면 stream은 앱 단위로 유지되면서, 필요할 때 특정 pod의 로그만 고를 수 있다. 로그 레벨도 agent에서 파싱해 structured metadata로 붙이는 경우가 많다.
3.5 LogQL Basics
LogQL은 PromQL의 모양을 빌렸다. 쿼리는 두 부분으로 나뉜다.
{namespace="my-namespace", app="my-streaming-job"} |= "ERROR" | pod="my-streaming-job-driver"{} 안이 stream selector다. index에서 label로 stream을 고르고, 이것이 조회 범위를 정한다. | 뒤는 pipeline으로, 고른 stream의 로그를 읽으면서 거른다. 위 쿼리에서 |= "ERROR"는 본문에 ERROR가 들어 있는 줄만 남기는 line filter이고, | pod="..."는 structured metadata로 거르는 label filter다.
pipeline 단계는 몇 가지가 있다.
# 본문에 문자열이 있는 줄 / 없는 줄 / 정규식에 맞는 줄
{app="my-streaming-job"} |= "timeout"
{app="my-streaming-job"} != "healthcheck"
{app="my-streaming-job"} |~ "Exception|Error"
# JSON 줄을 파싱해 필드로 거르기
{app="my-streaming-job"} | json | level="error"PromQL처럼 로그 수를 시계열로 바꿔 집계할 수도 있다.
# pod별 최근 5분 ERROR 줄 수
sum by (pod) (count_over_time({app="my-streaming-job"} |= "ERROR" [5m]))쿼리 비용은 대부분 stream selector에서 정해진다. pipeline은 고른 stream의 chunk를 모두 읽으면서 동작하므로, {}가 넓으면 아무리 뒤에서 좁게 걸러도 읽는 양은 그대로다. 그래서 {}에는 namespace와 앱처럼 범위를 크게 줄이는 label을 꼭 넣는다.
3.6 Metrics and Logs Side by Side
두 시스템은 같은 발상 위에 있지만, 무엇을 저장하느냐가 다르다.
| Prometheus | Loki | |
|---|---|---|
| 저장하는 것 | 숫자 (주기마다 하나) | 로그 줄 |
| 저장 단위 | 시계열. 메트릭 이름과 label 조합으로 정해진다 | stream. label 조합으로 정해진다 |
| index에 들어가는 것 | 이름과 label | label |
| 수집 방향 | 서버가 대상을 긁는다 (pull) | agent가 밀어 넣는다 (push) |
| cardinality 비용 | 시계열 수 | stream 수 |
| label을 빼면 | 그 값은 사라진다 | structured metadata로 옮기면 줄마다 남는다 |
마지막 행이 실무에서 가장 크게 다가온 차이다. Prometheus에서 실행 id label을 지우면 그 정보는 메트릭에서 완전히 사라진다. 그래서 2편에서 본 것처럼 무엇으로 묶을지를 신중하게 골라야 한다. Loki에서는 index에서 빼더라도 structured metadata나 본문에 값을 남겨 둘 수 있다. 메트릭에서 특정 실행을 볼 수 없게 되더라도, 그 실행의 pod 이름을 알면 로그에서는 여전히 그 실행만 골라 볼 수 있다.
둘을 이어 쓰는 방법도 같은 원리다. 메트릭에서 문제가 생긴 시점과 앱을 찾고, 같은 label로 Loki의 stream selector를 만들어 그 시점의 로그를 본다. 두 시스템의 label 이름과 값을 맞춰 두면, 대시보드에서 메트릭 패널과 로그 패널을 같은 변수로 묶을 수 있다.