Grafana Loki로 로그 수집하기: 라벨만 인덱싱하는 구조, LogQL, Alloy 수집, 보존 비용

이 글의 핵심

Grafana Loki는 로그 본문은 인덱싱하지 않고 라벨만 인덱싱하는 설계로, 전문 검색 엔진보다 인덱스를 훨씬 작게 유지하고 본문은 S3·GCS 같은 오브젝트 스토리지에 압축 저장합니다. 이 글은 아키텍처와 설치, Grafana Alloy 수집, 카디널리티를 좌우하는 라벨 설계, LogQL과 알림, 보존 정책과 자주 겪는 운영 오류를 다룹니다.

Loki가 해결하는 문제

Elasticsearch 기반 로그 스택(ELK)의 전형적 문제:

  • 모든 로그 본문을 인덱싱 → 저장 비용 폭증
  • JVM 운영 부담
  • 스케일 아웃 복잡성
  • 장기 보존을 위한 별도 아키텍처 필요

Loki의 접근:

  • 라벨만 인덱싱 → 인덱스는 “어떤 라벨 조합(스트림)의 로그가 어느 청크에 있는가”만 기록하므로 본문 크기와 거의 무관하게 작음
  • 본문은 gzip/snappy 등으로 압축한 청크 단위로 object storage에 저장
  • Prometheus와 동일한 라벨 모델 → 운영 개념 통일
  • 대부분의 컴포넌트가 상태를 오브젝트 스토리지에 두어 수평 확장이 단순

이 설계의 대가는 쿼리 시점의 비용입니다. Elasticsearch는 쓸 때 모든 단어를 역색인에 넣어 두므로 쓰기와 저장이 비싸지만 드문 단어도 즉시 찾습니다. Loki는 쓸 때 거의 아무것도 하지 않는 대신, 읽을 때 라벨로 고른 스트림의 청크를 내려받아 통째로 훑습니다. 그래서 “비용을 쓰기에서 읽기로 옮긴” 구조라고 이해하면 됩니다. 로그는 대부분 한 번도 읽히지 않고 보존 기간이 지나 삭제되므로 이 거래가 유리한 경우가 많지만, 라벨로 범위를 좁히지 못하는 쿼리가 잦은 조직에서는 쿼리 비용이 커집니다.

아키텍처 (SimpleScalable)

로그 생성                 에이전트(수집)            Loki 분산 서비스              저장
─────────                ───────────              ─────────────────          ────────────
Pod stdout ─► Alloy/Promtail ─► Distributor ─► Ingester ─► S3/GCS/Azure Blob
                                               │  WAL       ▲
                                               ▼            │
                                          Compactor    Query Frontend
                                                           │
                                                           ▼
                                                       Querier ──► Grafana

핵심 서비스:

  • Distributor: 수신·검증·샤딩
  • Ingester: 메모리 버퍼링 + 주기적 chunk flush
  • Querier: 쿼리 실행
  • Query Frontend: 쿼리 분할·캐시
  • Compactor: 인덱스 최적화·보존 정책

운영에서 가장 신경 써야 할 컴포넌트는 Ingester입니다. 받은 로그를 스트림별로 메모리에 모았다가 청크가 차거나 일정 시간이 지나면 오브젝트 스토리지로 내보내므로, 아직 flush되지 않은 최근 로그는 Ingester 메모리와 WAL(디스크)에만 있습니다. Ingester가 WAL 없이 재시작되면 이 구간이 사라지고, 쿼리도 최근 구간은 Ingester에, 과거 구간은 스토리지에 물어봐야 합니다. Kubernetes에서 Ingester(SimpleScalable의 write 경로)를 StatefulSet과 영구 볼륨으로 두고, 롤링 재시작 때 한 번에 하나씩 내려가도록 설정하는 이유가 여기에 있습니다.

설치

Docker (1노드 개발)

docker run -d --name loki -p 3100:3100 grafana/loki:3.2.0
docker run -d --name grafana -p 3000:3000 \
  -e GF_SECURITY_ADMIN_PASSWORD=admin grafana/grafana:latest

# Grafana에서 Loki datasource URL: http://host.docker.internal:3100

docker-compose (개발)

version: "3.9"
services:
  loki:
    image: grafana/loki:3.2.0
    ports: ["3100:3100"]
    command: -config.file=/etc/loki/config.yaml
    volumes:
      - ./loki-config.yaml:/etc/loki/config.yaml

  alloy:
    image: grafana/alloy:latest
    volumes:
      - /var/log:/var/log:ro
      - ./alloy-config.alloy:/etc/alloy/config.alloy
      - /var/run/docker.sock:/var/run/docker.sock
    command: run /etc/alloy/config.alloy
    depends_on: [loki]

  grafana:
    image: grafana/grafana:latest
    ports: ["3000:3000"]
    environment:
      - GF_SECURITY_ADMIN_PASSWORD=admin

Kubernetes (Helm, 프로덕션)

helm repo add grafana https://grafana.github.io/helm-charts
helm upgrade --install loki grafana/loki \
  --namespace loki --create-namespace \
  --values - <<EOF
deploymentMode: SimpleScalable
loki:
  auth_enabled: false
  schemaConfig:
    configs:
      - from: "2024-01-01"
        store: tsdb
        object_store: s3
        schema: v13
        index: { prefix: loki_index_, period: 24h }
  storage:
    type: s3
    bucketNames:
      chunks: my-loki-chunks
      ruler: my-loki-ruler
    s3:
      region: ap-northeast-2
      # IRSA 사용
read:
  replicas: 3
write:
  replicas: 3
backend:
  replicas: 3
EOF

S3 + IRSA(IAM Roles for Service Accounts, 최근에는 EKS Pod Identity도 가능) 조합이 AWS에서 흔한 배포 방식입니다. 액세스 키를 values 파일에 넣지 않아도 되기 때문입니다.

schemaConfig의 from 날짜는 “이 날짜부터 이 스키마로 저장한다” 는 뜻이라 한번 정하면 바꾸면 안 됩니다. 스키마를 올릴 때는 기존 항목을 지우지 말고, 미래 날짜를 from으로 하는 새 항목을 목록에 추가해야 과거 데이터를 계속 읽을 수 있습니다. 과거 날짜로 새 스키마를 추가하면 이미 저장된 데이터를 새 형식으로 읽으려다 쿼리가 실패합니다. 또 auth_enabled: false는 모든 로그를 fake라는 단일 테넌트로 저장하는 단일 테넌트 모드이므로, 여러 팀이 공유하는 클러스터라면 true로 두고 X-Scope-OrgID 헤더로 테넌트를 나누는 편이 좋습니다.

Grafana Alloy로 Kubernetes 로그 수집

// alloy-config.alloy
discovery.kubernetes "pods" {
  role = "pod"
}

discovery.relabel "pods" {
  targets = discovery.kubernetes.pods.targets
  rule {
    source_labels = ["__meta_kubernetes_namespace"]
    target_label  = "namespace"
  }
  rule {
    source_labels = ["__meta_kubernetes_pod_name"]
    target_label  = "pod"
  }
  rule {
    source_labels = ["__meta_kubernetes_pod_label_app"]
    target_label  = "app"
  }
}

loki.source.kubernetes "pods" {
  targets    = discovery.relabel.pods.output
  forward_to = [loki.process.default.receiver]
}

loki.process "default" {
  forward_to = [loki.write.default.receiver]
  stage.json {
    expressions = { level = "level", msg = "message" }
  }
  stage.labels {
    values = { level = "level" }
  }
}

loki.write "default" {
  endpoint {
    url = "http://loki.loki.svc.cluster.local:3100/loki/api/v1/push"
  }
}

loki.source.kubernetes는 노드의 로그 파일을 읽는 대신 Kubernetes API로 Pod 로그를 스트리밍합니다. 여기서 흔한 실수가 생깁니다. 이 설정을 DaemonSet으로 배포하면 각 노드의 Alloy가 클러스터 전체 Pod를 발견하므로, 노드가 10개면 모든 로그가 10번씩 중복 수집됩니다. DaemonSet이라면 discovery.relabel에 __meta_kubernetes_pod_node_name이 자기 노드 이름(sys.env("HOSTNAME") 등으로 주입)과 같은 대상만 남기는 keep 규칙을 추가하거나, Alloy의 클러스터링 기능으로 대상을 나눠 가져야 합니다. API 대신 노드의 /var/log/pods 파일을 직접 읽는 loki.source.file 방식은 API 서버 부하가 없고 노드별 분리가 자연스러워 대규모 클러스터에서 더 흔히 쓰입니다.

stage.labels로 level을 라벨로 올리는 것도 한 번 생각해 볼 부분입니다. 값이 info, warn, error 정도로 적어 카디널리티 문제는 없지만, 스트림 수가 레벨 개수만큼 늘어나고 로그 순서가 스트림 사이에서 나뉩니다. Loki 3.x는 로그 레벨을 자동 감지해 detected_level 필드로 제공하므로, 라벨 대신 이것이나 structured metadata를 쓰는 방법도 있습니다.

라벨 설계: Loki의 가장 중요한 선택

좋은 라벨: 카디널리티 낮고(유한 값 집합) 자주 쿼리되는 차원

  • namespace, app, container, level, env, cluster

나쁜 라벨: 카디널리티 폭발의 원인

  • user_id, trace_id, request_id, ip — 로그 본문에 남기고 라벨은 X

너무 많은 라벨 조합은 Loki 성능을 크게 떨어뜨립니다.

라벨이 왜 이렇게 중요한지는 스트림 개념을 알면 이해됩니다. Loki는 라벨 조합 하나를 스트림 하나로 다루고, 스트림마다 별도의 청크를 만듭니다. namespace 10개 × app 50개 × level 4개라면 최대 2000개 스트림이지만, 여기에 user_id를 추가하면 사용자 수만큼 곱해집니다. 스트림이 많아지면 Ingester 메모리에 열려 있는 청크가 늘어나고, 각 청크에 로그가 조금씩만 쌓여 작은 파일이 대량으로 스토리지에 올라가며, 인덱스도 커집니다. 저는 “trace_id로 바로 검색하고 싶다”는 요구 때문에 trace_id를 라벨로 올렸다가 Ingester 메모리가 급증하고 스트림 한도 에러가 나는 상황을 겪은 적이 있는데, 결국 라벨에서 빼고 본문 필터(|= "trace_id")로 되돌려야 했습니다.

Loki 3.0부터는 이런 고유 값을 위한 structured metadata가 있습니다. 라벨처럼 스트림을 나누지 않고 로그 줄마다 key-value를 붙여 저장하므로 카디널리티 문제 없이 | trace_id="abc"처럼 필터링할 수 있습니다. OTLP로 수신한 로그의 속성은 기본적으로 structured metadata로 저장됩니다.

LogQL: Loki 쿼리 언어

PromQL과 유사한 문법 + 로그 필터링.

# 기본
{namespace="prod", app="web"}

# 본문 필터 (line filter)
{namespace="prod", app="web"} |= "error"
{namespace="prod"} != "health"
{namespace="prod"} |~ "timeout|failed"

# JSON 파싱 후 레벨 필터
{app="api"}
  | json
  | level = "error"
  | status >= 500

# 속도(로그 라인 수)
rate({app="api"} |= "error" [5m])

# 집계
sum by (app) (rate({namespace="prod"} |= "error" [5m]))

# 특정 엔드포인트 지연 분포 (p99)
quantile_over_time(0.99,
  {app="api"} | json | path = "/api/v1/users" | unwrap duration_ms [5m]
) by (app)

# 특정 trace_id로 연결 로그 찾기
{namespace="prod"} |= "abc123def456"

# 정규식 캡처
{app="nginx"} | regexp `(?P<status>\d{3}) (?P<bytes>\d+)` | status = "500"

LogQL 파이프라인은 왼쪽에서 오른쪽으로 실행되고, 순서가 성능을 좌우합니다. 라벨 선택자 {...}가 읽을 스트림을 정하고, line filter(|=, !=, |~)는 파싱 없이 문자열만 비교하므로 가장 쌉니다. | json이나 | regexp 같은 파서는 모든 줄을 분석하므로 비쌉니다. 그래서 {app="api"} |= "error" | json | status >= 500처럼 line filter로 먼저 줄 수를 줄이고 나서 파싱하는 것이 요령입니다. unwrap을 쓰는 지연 분포 쿼리는 로그 본문 값을 숫자로 꺼내 집계하는 것이라, 자주 보는 지표라면 로그에서 매번 계산하기보다 애플리케이션에서 Prometheus 히스토그램으로 내보내는 편이 훨씬 저렴합니다. 로그 기반 지표는 “원래 메트릭이 없던 것을 임시로 확인할 때” 가장 유용합니다.

Grafana 통합

  1. Grafana → Connections → Data sources → Loki
  2. URL: http://loki:3100
  3. Explore에서 LogQL 쿼리
  4. 대시보드에서 로그 패널·메트릭 패널 혼합
  5. Traces ↔ Logs: Tempo와 연동해 trace_id로 로그 점프

알림 (Ruler)

groups:
  - name: errors
    rules:
      - alert: HighErrorRate
        expr: sum(rate({app="api"} |= "error" [5m])) by (app) > 10
        for: 5m
        labels: { severity: page }
        annotations:
          summary: "High error rate in {{ $labels.app }}"

      - alert: NoLogsFromWeb
        expr: absent_over_time({app="web"}[10m])
        for: 10m
        labels: { severity: warn }

Ruler가 LogQL 쿼리를 주기 평가하고 Alertmanager로 전송합니다. Prometheus와 같은 alert 인프라를 로그에 재사용할 수 있습니다.

|= "error"로 에러율 알림을 만들 때는 오탐을 조심해야 합니다. 이 필터는 대소문자와 위치를 가리지 않고 줄 어딘가에 error가 있으면 모두 세므로, "errors": 0이나 error_count=0 같은 정상 로그도 걸립니다. 구조화 로그라면 | json | level="error"처럼 필드로 거르는 것이 정확합니다. “로그가 끊겼다”는 알림(absent_over_time)은 수집 에이전트 장애를 잡는 데 유용하지만, 야간에 트래픽이 원래 없는 서비스에서는 오탐이 되므로 대상 서비스를 신중히 고르세요.

애플리케이션 연동

OpenTelemetry (앱에서 직접 OTLP 전송)

# Python
from opentelemetry._logs import set_logger_provider
from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter
import logging

provider = LoggerProvider()
provider.add_log_record_processor(BatchLogRecordProcessor(
    OTLPLogExporter(endpoint="http://alloy:4318/v1/logs")
))
set_logger_provider(provider)

handler = LoggingHandler(logger_provider=provider)
logging.basicConfig(level=logging.INFO, handlers=[handler])

logging.info("hello from otel", extra={"user_id": 42})

구조화 로그 (JSON)

import pino from "pino"

const logger = pino({
  level: "info",
  messageKey: "message",
  timestamp: pino.stdTimeFunctions.isoTime,
})

logger.info({ userId: 42, traceId }, "User logged in")
// {"level":30,"time":"...","userId":42,"traceId":"...","message":"User logged in"}

Alloy의 stage.json이 이를 파싱해 level·traceId 등을 필드로 인식합니다.

보존 정책·비용 관리

limits_config:
  retention_period: 30d       # 기본 보존
  retention_stream:
    - selector: '{namespace="prod", app="critical"}'
      priority: 1
      period: 90d
    - selector: '{namespace="dev"}'
      priority: 2
      period: 7d

compactor:
  working_directory: /var/loki/compactor
  retention_enabled: true
  retention_delete_delay: 2h

스트림별 차등 보존으로 비용·컴플라이언스 균형을 맞출 수 있습니다.

보존 정책에서 가장 흔한 실수는 retention_period만 설정하고 compactor.retention_enabled: true를 빠뜨리는 것입니다. 이 경우 설정은 있어도 실제로는 아무것도 삭제되지 않아 스토리지가 계속 늘어납니다. 반대로 S3 Lifecycle 규칙으로 오래된 객체를 직접 지우는 방식은 Loki의 인덱스와 어긋나서, 인덱스는 있는데 청크가 없는 구간을 쿼리하면 에러가 납니다. 삭제는 compactor에 맡기고, Lifecycle은 compactor가 지운 뒤 남는 불완전 업로드 정리 정도에만 쓰는 편이 안전합니다. retention_delete_delay는 삭제 표시 후 실제 삭제까지 기다리는 시간으로, 이미 실행 중인 쿼리가 지워질 청크를 읽다가 실패하지 않도록 두는 여유입니다.

성능 튜닝

  • Chunk 크기: chunk_target_size: 1572864 (1.5MB) 권장
  • Shard: 고트래픽 라벨은 split_queries_by_interval로 병렬 쿼리
  • 캐시: Memcached/Redis로 쿼리 결과 캐시
  • Parallel queries: max_query_parallelism: 32
  • Ingester WAL: 디스크 성능이 쓰기 지연을 좌우

Kubernetes 내 관측 권장 구성

Grafana Alloy (DaemonSet)
  ├─ 로그 → Loki
  ├─ 메트릭 → Prometheus (또는 Mimir)
  ├─ 트레이스 → Tempo
  └─ 프로파일 → Pyroscope

Grafana
  ├─ Dashboards
  ├─ Explore (모든 시그널 하나의 UI)
  └─ Alerting

이 스택을 LGTM(Loki, Grafana, Tempo, Mimir)이라 부르고, Pyroscope(프로파일)까지 포함하기도 합니다. 네 시그널을 같은 라벨(namespace, app, pod)로 맞춰 두는 것이 이 구성의 핵심입니다. 라벨이 일치해야 Grafana에서 메트릭 그래프의 급증 구간을 클릭해 같은 서비스의 로그로, 로그의 trace_id에서 Tempo 트레이스로 바로 넘어갈 수 있습니다.

트러블슈팅

too many streams / stream limit exceeded

라벨 카디널리티 폭발. 실제 에러는 per-user streams limit (local limit: ..., global limit: ...) exceeded 같은 형태로 수집 에이전트 쪽 로그에 HTTP 429와 함께 찍힙니다. user_id 같은 고유 ID를 라벨로 쓰지 말 것. logcli series '{}' --analyze-labels나 Grafana의 cardinality 대시보드로 값이 가장 많은 라벨을 찾고, 고유 값은 structured metadata나 본문으로 옮깁니다. max_global_streams_per_user 한도를 올리는 것은 원인을 고치기 전까지 임시 조치로만 쓰세요.

쿼리가 느림

  • 시간 범위 좁히기
  • 라벨로 먼저 필터
  • {app="web"} |= "error" 가 {app="web"} | json | msg =~ "error" 보다 훨씬 빠름
  • Query Frontend 샤딩·캐시 확인

S3 비용 증가

  • 작은 chunk 다량 → 대개 스트림이 너무 잘게 나뉜 것이 원인. 라벨 수를 줄이는 것이 근본 해결
  • 스토리지 용량보다 요청 수(GET/PUT) 비용이 큰 경우가 많음 → 쿼리 캐시(Memcached)와 청크 캐시로 반복 조회를 줄임
  • 보존 기간이 지난 데이터는 compactor retention으로 삭제 (Glacier 같은 아카이브 클래스로 옮기면 Loki가 즉시 읽을 수 없어 쿼리가 실패함)

Ingester OOM

  • 메모리 대비 들어오는 스트림 많음 → 샤드 증설
  • chunk_idle_period 줄여 자주 flush

로그 유실

  • Distributor의 rate limit 도달 → ingestion_rate_mb 상향
  • Alloy의 retry·buffer 설정 확인

마무리

Loki는 “대부분의 로그 조회는 라벨로 범위를 좁힌 뒤 grep하면 충분하다”는 관찰에서 출발한 시스템입니다. 전문 검색 엔진의 유연함을 포기하는 대신 쓰기와 저장을 싸게 만들고, 비용을 쿼리 시점으로 옮겼습니다. 그래서 성패는 거의 전적으로 라벨 설계에 달려 있습니다. 라벨을 적고 안정적으로 유지하고, 고유 값은 structured metadata나 본문에 두고, 쿼리는 라벨과 line filter로 먼저 좁히는 습관을 들이면 작은 운영 부담으로 오래 보존할 수 있습니다. Elasticsearch를 쓰고 있다면 일부 서비스의 로그를 Loki로 병행 수집해 비용과 쿼리 경험을 직접 비교해 보는 방식이 안전합니다.


자주 묻는 질문 (FAQ)

Q. too many streams 오류는 왜 나고 어떻게 고치나요?

A. user_id, trace_id, request_id, ip처럼 값이 거의 무한한 필드를 라벨로 붙여 스트림 수가 폭발했을 때 납니다. Loki는 라벨 조합마다 별도 스트림을 만들기 때문에, 이런 값은 라벨이 아니라 로그 본문에 남기고 쿼리할 때 |= 필터나 | json으로 찾아야 합니다. 라벨은 namespace, app, level, env처럼 값의 종류가 적은 차원으로 제한하고, Grafana의 카디널리티 대시보드로 문제가 되는 라벨을 먼저 찾습니다.


같이 보면 좋은 글