Grafana 입문 3편. Loki + LogQL 깊이 — Elasticsearch 대비 label-based indexing 만 사용 (비용 1/10), Stream · Chunk · Index 의 구조, LogQL 의 4 가지 query type (line filter · label filter · parser · metric query), Promtail · Alloy 의 agent 차이, Single Binary vs Simple Scalable vs Microservices 의 배포 mode, S3/GCS object storage, retention 정책, Grafana 의 metric+log correlation. Logs 의 비용 효율 자리.
이 글은 Grafana 입문에서 운영까지 시리즈 3편이에요. 2편 Prometheus 가 metrics 의 기반 이었다면, 이번 글 = logs 의 비용 효율 답.
이번 글의 범위
Elasticsearch · OpenSearch 의 비싼 운영비용을 대체하는 게 Grafana Loki(로그 전용 백엔드). Prometheus 의 철학을 logs 에 그대로 옮긴 결과 — label 만 indexing. 동급 데이터를 1/10 비용으로 다룬다.
| 자리 | 자산 |
|---|---|
| 수집 | Promtail(파일 watching agent) · Alloy(통합 collector) |
| 저장 | Stream(label 묶음) · Chunk(압축 로그) · Index, S3/GCS object storage |
| Query | LogQL(로그 질의어, line · label · parser · metric) |
| 운영 | Single Binary · Simple Scalable · Microservices mode |
Loki 의 첫 결정 — Label Index 만
Elasticsearch 의 비싼 자리
Elasticsearch:
- 모든 word · phrase 의 inverted index
- "어떤 단어든 빠르게 검색" 가능
- RAM · CPU · 저장소 = 매우 비싸
- 대규모 로그 = 비용 $$$
예 — 1 TB 로그/일:
- Elasticsearch: $5000+ / 월 (인스턴스 · RAM)
- Loki: $500 / 월 (object storage 중심)
Loki 의 다른 길
Loki:
- 로그 내용 indexing X
- Label 만 indexing (메타데이터)
- 검색 = label 로 stream 좁힌 후 chunk scan
- 약간 느리지만 매우 저렴
Stream 의 의미
Stream = 같은 label combination 의 로그 집합
예 — 같은 stream:
{app="nginx", env="prod", instance="i-001"}
→ "2026-05-18T10:00:00 GET /api/users 200"
"2026-05-18T10:00:01 POST /api/login 401"
"2026-05-18T10:00:02 GET /api/products 200"
...
다른 stream:
{app="nginx", env="staging", instance="i-001"} ← env 다름
여기서 시험 함정 — Loki 의 cardinality(고유 label 조합 수) 도 Prometheus 와 같은 덫. unique label combination 이 너무 많으면 stream 이 폭증해 성능과 비용이 동시에 무너진다.
Cardinality 의 위험
나쁜 label:
{request_id="abc123"} → 모든 request 마다 새 stream
{user_id="12345"} → 모든 user 마다 새 stream
{timestamp="..."} → 모든 초마다 새 stream
좋은 label:
{app="api", env="prod", level="info"}
{namespace="default", pod="api-deployment-xxx"}
{component="auth", service="user-service"}
정적이고 값 종류가 한정된 것만 label 로 박고, 동적으로 바뀌는 값은 log line 안에 둔다.
Chunk · Index 의 구조
저장 구조
[Label Index] [Chunk Store]
↓ (작은 인덱스) ↓ (실제 로그)
{app="nginx"} → Chunk 1 (압축된 로그 라인 들)
{app="api"} → Chunk 2
... Chunk 3
...
→ S3 · GCS · Azure Blob · 로컬 등
Chunk 의 특징
한 chunk:
- 한 stream 의 일정 시간 의 로그
- 압축 (gzip · snappy · LZ4)
- 보통 2시간 또는 1.5 MB 단위 로 flush
장점:
- object storage 의 무한 retention
- 저렴한 비용
- 백업 용이
Index Storage 선택
TSDB(시계열 DB 형식 인덱스) 가 현재 권장이다. Loki 의 기본 index 라서 object storage 에 그대로 얹히고 (보통 같은 S3), 운영도 단순하다. BoltDB Shipper 는 레거시 — 이전 mode 이므로 신규 deployment 에서는 TSDB 를 쓴다.
Agent — Promtail vs Alloy
Promtail (전통)
Promtail:
- Loki 의 원조 agent
- 파일 watching → label 추가 → Loki 로 push
- Kubernetes 통합 (Pod 의 stdout · 로그 파일)
- 단순한 config
config 예 (Promtail):
server:
http_listen_port: 9080
positions:
filename: /tmp/positions.yaml
clients:
- url: http://loki:3100/loki/api/v1/push
scrape_configs:
- job_name: kubernetes-pods
kubernetes_sd_configs:
- role: pod
relabel_configs:
- source_labels: [__meta_kubernetes_pod_label_app]
target_label: app
- source_labels: [__meta_kubernetes_namespace]
target_label: namespace
- source_labels: [__meta_kubernetes_pod_name]
target_label: pod
Alloy — 새 권장
Alloy (구 Grafana Agent):
- Promtail 의 *후계*
- logs + metrics + traces 통합 collector
- OpenTelemetry · Prometheus · Loki 모두 호환
- 한 agent 로 모든 신호 수집
장점:
- 단일 binary
- 운영 단순화 (3 agent → 1 agent)
- Grafana Labs 의 권장
# Alloy config (HCL 비슷)
loki.source.file "logs" {
targets = [
{"__path__" = "/var/log/*.log"},
]
forward_to = [loki.write.default.receiver]
}
loki.write "default" {
endpoint {
url = "http://loki:3100/loki/api/v1/push"
}
}
prometheus.scrape "metrics" {
targets = [...]
forward_to = [prometheus.remote_write.default.receiver]
}
# ... metrics · traces 동시 처리
Kubernetes 의 통합
Kubernetes 의 표준 패턴:
- DaemonSet 으로 Alloy 의 Pod 마다 1개
- Pod 의 /var/log/containers/* 자동 수집
- K8s metadata 자동 label
- Loki 로 push
Helm chart 한 명령:
helm install alloy grafana/alloy
LogQL — Loki 의 Query Language
4 가지 Query Type
1. Log Query (로그 라인 반환)
{app="nginx"} |= "error"
2. Metric Query (시계열 반환)
rate({app="nginx"} |= "error" [5m])
3. Aggregation (집계)
sum by (status) (rate({app="api"}[5m]))
4. Pattern parsing
{app="api"} | json | level="error"
Stream Selector
# 기본
{app="nginx"}
# 여러 label
{app="nginx", env="prod", level="error"}
# Regex match
{app=~"nginx|api"} # OR
{app!~"test.*"} # NOT regex
# Label 존재
{app=~".+", env="prod"}
여기서 정말 중요한 시험 함정 — Stream selector 의 label 로 좁혀진 stream 만 처리한다. selector 가 너무 넓으면 (예: {env="prod"}) 무수한 stream 을 scan 하느라 매우 느려진다. 최대한 좁혀 쓰는 게 답.
Line Filter
# 포함
{app="nginx"} |= "error"
# 미포함
{app="nginx"} != "404"
# Regex 포함
{app="nginx"} |~ "error|fail"
# Regex 미포함
{app="nginx"} !~ "/health|/metrics"
# 조합 (AND)
{app="nginx"} |= "POST" |= "/api/login" != "/health"
중요 — Line filter 는 순서가 성능을 좌우한다. 첫 filter 에 가장 selective(매칭 결과가 적은) 패턴을 둔다.
Parser — 구조화
# JSON 로그
{app="api"} | json
# JSON 의 field 로 추가 filter
{app="api"} | json | level="error" | duration > 1s
# logfmt
{app="api"} | logfmt | method="POST"
# Regex
{app="nginx"} | regexp "(?P<method>\\w+) (?P<path>\\S+) (?P<status>\\d+)"
# Pattern (간단한 case)
{app="nginx"} | pattern "<ip> <_> <_> [<_>] \"<method> <path> <_>\" <status> <_>"
Parser 를 거치면 추출된 field 를 label 처럼 쓸 수 있다.
Label Filter
# parser 후 의 field 필터
{app="api"} | json | level="error"
{app="api"} | json | duration > 1s
{app="api"} | json | status >= 500
{app="api"} | json | method="POST" | path=~"/api/users/.*"
# Comparison
status > 200
status < 500
status >= 400
duration < 1s
size != 0
Line Format · Label Format
# 출력 format 변경
{app="nginx"}
| json
| line_format "{{.method}} {{.path}} → {{.status}}"
# Label 추가/수정
{app="api"}
| json
| label_format service="{{.app}}-{{.env}}"
Metric Query
# 1. 시간당 로그 카운트
sum by (level) (count_over_time({app="api"}[1m]))
# 2. Error rate
sum(rate({app="api"} |= "error" [5m])) /
sum(rate({app="api"}[5m]))
# 3. p99 응답시간 (로그 의 JSON 의 duration field)
quantile_over_time(0.99,
{app="api"}
| json
| unwrap duration
[5m]
)
# 4. Top N 의 source IP
topk(10,
sum by (ip) (
count_over_time({app="nginx"} |~ "(?P<ip>\\d+\\.\\d+\\.\\d+\\.\\d+)" [1h])
)
)
LogQL 의 metric query 는 로그를 시계열로 바꿔 Grafana 차트로 그리는 길이다.
Loki 의 Deployment Mode
Loki 는 Distributor(수신 router), Ingester(메모리 버퍼·chunk 생성), Querier(query 실행), Query Frontend(분산 query 분배), Compactor(chunk 압축·retention) 같은 component 로 쪼개진다. 이 component 들을 어떻게 묶느냐가 mode 의 차이다.
1. Single Binary
모든 component 가 한 binary:
- Distributor (수신)
- Ingester (메모리 buffer + chunk 생성)
- Querier (query)
- Query Frontend (분산 query)
- Compactor (chunk 압축)
사용 case:
- 작은 환경 (< 100GB/일)
- 개발 · 시험
- 단순 운영
2. Simple Scalable
Read 와 Write 분리:
- Write path — Distributor + Ingester
- Read path — Querier + Query Frontend
- Backend — Compactor + Ruler
사용 case:
- 중간 규모 (100GB ~ 5TB/일)
- 운영 부담 < Microservices
- 가장 흔한 production 선택
3. Microservices
모든 component 가 독립 service:
- Distributor 만 scale up · ...
- 매우 큰 규모
사용 case:
- 대규모 (5TB+/일)
- 멀티 tenant
- 운영 부담 큼 (component 마다 scale 의식)
Mode 선택 가이드
< 100GB/일 → Single Binary
100GB ~ 5TB/일 → Simple Scalable (대부분)
5TB+ /일 → Microservices
Helm chart 에서는 deployment mode 가 변수 하나로 결정된다.
Retention · Cost
Retention Policy
# loki config
limits_config:
retention_period: 720h # 30일 default
# 무제한 (object storage 비용 만 고려)
compactor:
working_directory: /var/loki/compactor
retention_enabled: true
retention_delete_delay: 2h
Tenant 별 Retention (multi-tenant)
limits_config:
retention_period: 720h # 기본 30일
overrides:
team-a:
retention_period: 2160h # 90일 (감사 의무)
team-b:
retention_period: 168h # 7일 (저비용 우선)
비용 계산
1 TB 로그/일 의 비용:
저장:
- S3 standard: $0.023 / GB / 월
- 30일 보존 = 30 TB → $700/월
전송 (cold tier 활용):
- S3 Glacier: $0.004 / GB / 월
- 30일 후 자동 transition → 90% 절감
Compute (Loki 인스턴스):
- Simple Scalable: m5.xlarge 3 대 → $400/월
총: 약 $1100 / 월 (1 TB/일)
→ Elasticsearch 동급 환경: $8000+ / 월 (인스턴스 10대+)
Grafana 의 Metric + Log Correlation
Derived Fields
Tempo(분산 trace 백엔드, 시리즈 4편) 와 짝지을 때 가장 자주 쓰는 기능이다.
Loki datasource 의 설정:
Derived field:
Name: TraceID
Regex: trace_id=(\w+)
URL: ${__value.raw}
Internal link: Tempo datasource
→ Log 안의 trace_id 가 자동 link
→ 클릭 = Tempo 의 trace view 로 이동
Logs + Metrics 같은 panel
Grafana Panel:
Datasource: Mixed
Query A (Prometheus): rate(http_requests_total[5m]) ← 메트릭
Query B (Loki): rate({app="api"} |= "error" [5m]) ← 로그 의 metric
→ 한 차트에 *메트릭 trend + 에러 로그 빈도* 같이 보기
Annotation 의 Loki 활용
Grafana Dashboard 의 annotation:
Datasource: Loki
Query: {app="ci-deploy"} | json | event="deploy"
→ 모든 배포 event 가 *시계열 차트 위 의 세로 선* 으로 자동 표시
→ "배포 후 latency 변화" 즉시 확인
함정 정리
사고 1: Stream Cardinality 폭발
원인 — request_id · user_id 를 label 로 박았더니 stream 이 수백만 개로 불어 메모리가 터졌다.
해결 — label 은 static 값만 두고, 동적인 값은 log line 안에 남겨 parser 로 꺼내 쓴다.
사고 2: Stream Selector 너무 광범위
원인 — {env="prod"} |= "error" 로 던지면 모든 prod stream 을 scan 해서 매우 느려진다.
해결 — selector 를 최대한 좁힌다. {env="prod", app="api"} |= "error" 처럼.
사고 3: Line Filter 순서 잘못
원인 — |= "error" 다음에 |= "POST" 를 붙였더니, error 가 많아 POST filter 가 뒤늦게 걸리며 시간을 잡아먹었다.
해결 — selective 한 filter 부터 둔다. POST 가 더 적게 매칭되면 |= "POST" 를 먼저 건다.
사고 4: Promtail 의 부담
원인 — Promtail 의 파일 watching 이 작은 파일이 많은 환경(예: PHP-FPM 의 process 별 파일) 에서 I/O 를 폭증시켰다.
해결 — Alloy 로 갈아타고, 로그는 single file 로 모은 뒤 systemd journal 도 함께 활용한다.
사고 5: Retention 잘못 설정
원인 — retention_period: 30d 로 잡았는데 compactor 가 disabled 라서 실제 삭제가 일어나지 않아 저장소가 끝없이 불었다.
해결 — compactor.retention_enabled: true 는 필수다. retention 이 진짜로 돌고 있는지 확인까지 마친다.
사고 6: Single Binary 의 규모 한계
원인 — Single Binary mode 로 1 TB/일 을 받았더니 메모리가 터지면서 out of memory 가 났다.
해결 — 100 GB/일 미만에서만 Single Binary 를 쓴다. 그 이상은 Simple Scalable.
사고 7: LogQL 의 unwrap 잘못
원인 — unwrap duration 으로 던졌는데 duration 이 string ("100ms") 이라 숫자 변환에 실패하며 에러가 났다.
해결 — unwrap 대상 field 는 숫자여야 한다. 필요하면 duration_seconds(duration) 처럼 단위까지 함께 변환한다.
사고 8: JSON parser 의 성능
원인 — 모든 로그를 JSON parse 로 돌렸더니 큰 query 가 매우 느려졌다.
해결 — line filter 로 먼저 줄여 놓고 그 뒤에 parser 를 건다. parser 는 마지막 단계로 미룬다.
사고 9: 로그 의 PII 누출
원인 — 사용자 password · email · 신용카드 가 로그 라인에 그대로 박혀 Loki 에 저장됐고, 결과는 컴플라이언스 위반.
해결 — application 의 logging 부터 신경 쓰고, Promtail/Alloy 의 redaction stage 로 한 번 더 거른 뒤 주기적으로 audit 한다.
사고 10: Multi-tenant 분리 X
원인 — 한 Loki 에 여러 회사 로그를 같이 넣고 tenant 분리를 빼먹어, 한 회사 query 가 다른 회사 데이터를 들여다봤다.
해결 — auth_enabled: true 와 X-Scope-OrgID header 로 tenant 를 분리한다.
운영 권장 패턴
Pattern 1: Kubernetes 표준 stack
# Helm
helm install loki grafana/loki-stack \
--set grafana.enabled=true \
--set promtail.enabled=false \
--set alloy.enabled=true \
--set loki.persistence.enabled=true \
--set loki.persistence.storageClassName=gp3
이 한 줄로 Alloy DaemonSet(노드마다 1개 띄우는 K8s 워크로드) + Loki single binary + Grafana + 표준 dashboard 가 한꺼번에 깔린다.
Pattern 2: Application 의 로그 표준
JSON 로그 표준:
{
"timestamp": "2026-05-18T10:00:00Z",
"level": "info",
"service": "api",
"trace_id": "abc123",
"user_id": "12345", ← log line 안에 (label X)
"message": "user logged in",
"method": "POST",
"path": "/api/login",
"status": 200,
"duration_ms": 45
}
Pattern 3: Recording Rule 의 LogQL
# loki ruler
groups:
- name: api_errors
interval: 1m
rules:
- record: api:error_count_5m
expr: |
sum(count_over_time({app="api"} |= "error" [5m])) by (env, service)
이후로는 metric 을 그대로 query 해서 dashboard 가 빠르게 뜬다.
Pattern 4: Alert 의 LogQL
# loki alert
groups:
- name: api_log_alerts
rules:
- alert: HighErrorLogRate
expr: |
sum(rate({app="api"} |= "ERROR" [5m])) by (env) > 10
for: 5m
labels:
severity: critical
annotations:
summary: "Error log rate > 10/s in {{ $labels.env }}"
LogQL 의 metric query 는 Prometheus alert 과 동일한 방식으로 쓴다.
Pattern 5: Tail 의 활용
logcli(Loki CLI client) 의 tail 모드를 쓰면 클러스터 전체 로그를 실시간으로 따라간다.
# 실시간 로그 tail
logcli query '{app="api"} |= "error"' --tail
# 시간 범위 query
logcli query '{app="api"}' --from="2026-05-18T10:00:00Z" --to="2026-05-18T11:00:00Z"
logcli 의 CLI tail 은 kubectl logs -f 를 cluster-wide 로 확장한 셈이다.
Pattern 6: 로그 의 sampling
# Alloy 의 log sampling
loki.process "sample" {
forward_to = [loki.write.default.receiver]
stage.match {
selector = "{app=\"api\"} |= \"GET /health\""
action = "drop"
drop_counter_reason = "healthcheck_noise"
}
# 또는 sampling
stage.sampling {
rate = 0.1 # 10% sample
}
}
로그가 너무 많아지면 비용이 폭증한다. health check 같은 noise 는 drop 하고, 나머지도 적절히 sampling 한다.
시험 직전 한 번 더 — Loki + LogQL 압축 노트
Label Index 만
- Loki = label 만 indexing (로그 내용 X)
- Elasticsearch 비교 1/10 비용
- Cardinality 의식 (Prometheus 와 같은 함정)
Stream 의 정의
- 같은 label combination = 1 stream
- request_id · user_id 같은 dynamic 값 = label X
- log line 안에 (parser 로 추출)
저장 구조
- Index (작음) + Chunk (실제 로그, 압축)
- Object storage (S3 · GCS · Azure Blob)
- 무한 retention 가능 (비용 만 의식)
Agent
- Promtail (전통) → Alloy (권장)
- Alloy = logs + metrics + traces 통합 collector
- Kubernetes DaemonSet 의 표준 패턴
LogQL 4 type
- Log Query —
{app="x"} |= "error" - Metric Query —
rate({...} [5m]) - Aggregation —
sum by (...) (...) - Parser —
| json·| logfmt·| pattern·| regexp
Stream Selector
{app="nginx"}·{app=~"a|b"}·{app!~"test"}- 최대한 좁혀 사용 (광범위 = 매우 느림)
Line Filter
|= "..."(포함)!= "..."(미포함)|~ "..."(regex 포함)!~ "..."(regex 미포함)- 가장 selective 한 filter 부터
Label Filter
- parser 후 의 field 비교
| level="error"·| duration > 1s·| status >= 500
Metric Query
- count_over_time · rate · sum · avg · quantile_over_time
- unwrap 으로 숫자 field 추출
- Prometheus 의 alert 와 동일 사용
Deployment Mode
- Single Binary — < 100 GB/일
- Simple Scalable — 100 GB ~ 5 TB/일 (가장 흔함)
- Microservices — 5 TB+/일
Grafana Correlation
- Derived Field (log → trace 자동 link)
- Mixed datasource (metric + log 한 panel)
- Annotation (배포 event → 차트 의 세로 선)
사고
- Stream Cardinality 폭발
- Stream Selector 너무 광범위
- Line Filter 순서 잘못
- Promtail 의 부담 (Alloy 권장)
- Retention compactor 비활성
- Single Binary 의 규모 한계
- LogQL unwrap 의 type 잘못
- JSON parser 의 성능 (line filter 먼저)
- 로그 의 PII 누출 (redaction stage)
- Multi-tenant 분리 X
패턴
- Kubernetes Helm 의 Alloy + Loki + Grafana 자동
- Application 의 JSON 로그 표준
- Recording Rule (LogQL → metric)
- Alert (LogQL metric 기반)
- logcli 의 CLI tail
- 로그 sampling · healthcheck drop
공식 문서: Loki get started · LogQL 에서 더 깊은 spec 을 확인할 수 있어요.
시리즈 다른 편 (앞뒤 글 모음)
이전 글:
- 1편 — Observability 3 pillar · LGTM stack 종합
- 2편 — Prometheus + PromQL 깊이 (Pull · Exporter · Alertmanager)
다음 글: