Loki 는 로그 본문을 색인하지 않고 레이블만 색인한다. 레이블로 후보 스트림을 좁힌 뒤 그 청크를 실제로 읽어 내려가며 필터를 적용하는 구조다. 따라서 조회가 느린 원인은 거의 항상 둘 중 하나다 — 레이블로 범위를 못 좁혔거나, 레이블 설계가 잘못돼 스트림이 폭증했거나.
레이블 셀렉터가 첫 관문이다. 여기서 걸러지지 않은 데이터는 전부 읽힌다.
# 느리다 — job 전체를 읽고 나서 문자열을 찾는다
{job="nifi"} |= "ERROR"
# 빠르다 — 색인 단계에서 이미 좁혀진다
{job="nifi", level="error"}
두 번째가 가능하려면 수집 단계에서 level 을 레이블로 뽑아 두어야 한다.
라인 필터는 파싱보다 먼저 둔다. |= "ERROR" 같은 문자열 필터는 매우 싸고, | json · | logfmt 같은 파서는 줄마다 구조를 해석하므로 비싸다. 순서를 바꾸면 읽는 양이 그대로인데 파싱 비용만 늘어난다.
# 좋다
{app="nifi"} |= "ERROR" | json | status = "500"
# 나쁘다 — 모든 줄을 파싱한 뒤에야 걸러낸다
{app="nifi"} | json | status = "500" |= "ERROR"
정규식 필터(|~ · !~)는 고정 문자열 필터(|= · !=)보다 느리다. 고정 문자열로 크게 거른 다음 정규식을 쓴다.
시간 범위를 좁히는 것이 가장 확실한 절약이다. Grafana 패널의 기본 범위가 24시간이면 매번 하루치를 읽는다. 로그 탐색 패널은 기본 범위를 짧게 잡아 둔다. 범위를 좁히는 일은 대시보드의 시간 선택기로 하는 것이지 쿼리 안에서 하는 것이 아니다 — LogQL 에는 쿼리 본문에서 절대 시각을 거르는 문법이 없다.
레이블 값의 조합 수가 곧 스트림 수이고, 스트림이 많아질수록 색인이 커지고 조회가 느려진다. 값이 거의 변하지 않는 것만 레이블로 올린다.
| 레이블로 적합 | 레이블로 부적합 |
|---|---|
app · namespace · pod · container · env · level |
user_id · request_id · trace_id · client_ip · error_code |
부적합한 값은 레이블 대신 로그 본문에 두고 조회 시점에 | json 으로 뽑아 필터한다. 한 번 잘못 올린 레이블은 이미 쌓인 청크를 되돌리지 못하므로 수집 설정을 바꾼 뒤에도 보존 기간이 지날 때까지 영향이 남는다.
Promtail 또는 Grafana Alloy 의 파이프라인에서 필요한 필드를 미리 뽑으면 조회 시 파싱 비용이 사라진다. 다만 뽑은 값을 전부 레이블로 올리면 위의 카디널리티 문제로 되돌아간다. 레이블로 올릴 것은 값의 가짓수가 작은 것만 고른다.
pipeline_stages:
- json:
expressions:
level: level
msg: message
- labels:
level:
보존 기간이 길수록 조회 대상이 늘어난다. 애플리케이션 로그는 7~30일, 인프라 로그는 30~90일 정도로 나눠 잡고 그 이상 보관해야 하는 것은 별도 아카이브로 뺀다. 보존은 compactor 가 처리하므로 compactor 의 retention 설정을 켜 두어야 실제로 삭제된다.
청크가 너무 작으면 오브젝트 스토리지 호출 수가 늘어 조회가 느려진다. 기본값에서 시작해 필요할 때만 조정한다.
limits_config:
max_chunk_age: 2h
ingester:
chunk_target_size: 1572864 # 1.5 MB
chunk_idle_period: 30m
청크 저장소는 오브젝트 스토리지(S3 · GCS · MinIO)를 쓴다. NFS 는 쓰지 않는다 — 잠금과 일관성 문제로 조회가 불안정해진다. 아카이브 계층(S3 Glacier 류)도 청크 저장소로 쓸 수 없다.
규모가 커지면 단일 바이너리 모드에서 벗어나 query-frontend 와 compactor 를 켠다. query-frontend 는 큰 조회를 시간 단위로 쪼개 병렬 처리하고 결과를 캐시하므로 체감 효과가 가장 크다.