Grafana 입문 3편 — Loki + LogQL 깊이 (Label Index · 비용 효율)

2026-05-18Grafana 입문에서 운영까지

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편 — Loki + LogQL 깊이 (Label Index · 비용 효율)

이 글은 Grafana 입문에서 운영까지 시리즈 3편이에요. 2편 Prometheusmetrics 의 기반 이었다면, 이번 글 = 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: trueX-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

  1. Log Query — {app="x"} |= "error"
  2. Metric Query — rate({...} [5m])
  3. Aggregation — sum by (...) (...)
  4. 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 을 확인할 수 있어요.

시리즈 다른 편 (앞뒤 글 모음)

이전 글:

다음 글:

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!