soobook
KUBERNETES

Grafana Loki

로그 본문 대신 라벨만 인덱싱하는 로그 집계 시스템, k3s에 직접 배포해 보기

Loki는 로그 본문을 인덱싱하지 않는다. namespace, pod, app 같은 소수 라벨만 인덱싱하고 본문은 압축해 object storage에 던진다.

Intro

로그를 어디에 쌓을지 정할 때 오래 기본값이던 답은 ELK(Elasticsearch, Logstash, Kibana)였다.

강력하지만 비싸다.

Elasticsearch는 들어오는 로그의 모든 필드를 inverted index에 넣는다.

inverted index = 단어 → 문서 위치를 뒤집어 놓은 색인

덕분에 아무 단어나 즉시 full-text 검색이 되지만, 그 색인을 만들고 메모리에 얹는 비용이 로그량에 비례해 커진다.

로그는 대부분 한 번 쓰고 거의 안 읽는데, 전부를 검색 가능하게 유지하는 셈이다.

Loki는 여기서 질문을 바꾼다.

“모든 로그를 검색 가능하게 만들어야 하나?”

실제로 로그를 찾을 때 우리는 보통 어떤 서비스의, 어떤 파드의, 어떤 레벨의 로그인지를 먼저 좁힌 다음, 그 안에서 본문을 훑는다.

Loki는 그 앞 절반(좁히기)만 인덱싱하고, 뒷 절반(본문 훑기)은 grep처럼 그때그때 스캔한다.

Labels, Not Full Text

Loki가 인덱싱하는 것은 라벨(label)뿐이다. 라벨은 로그 한 줄에 붙는 key-value 메타데이터로, Prometheus의 그것과 같은 모델이다. k8s라면 보통 이렇게 붙는다.

{namespace="demo", app="payment-api", pod="payment-api-7f8df-52zlh", container="app"}

같은 라벨 조합을 가진 로그 줄의 시간순 나열을 Loki는 stream이라 부른다. 인덱스는 “이 라벨 조합의 stream은 object storage의 이 chunk들에 있다”만 기록하고 본문은 색인하지 않는다. 로그 본문은 stream별로 모아 압축한 chunk로 S3, GCS, Azure Blob 같은 object storage에 저장된다.

 

인덱스는 라벨만, 본문은 압축 chunk로\textbf{\text{인덱스는 라벨만, 본문은 압축 chunk로}}

 

Elasticsearch는 로그 본문의 모든 토큰을 inverted index에 넣어 “아무 단어나 검색”을 O(1)에 가깝게 만든다. Loki는 라벨로 chunk를 좁힌 뒤 그 안을 순차 스캔하므로, 본문 검색은 색인 조회가 아니라 분산 grep에 가깝다. 대신 색인이 작아 저장 비용과 운영 부담이 낮다.

위쪽 Elasticsearch는 로그 본문의 모든 토큰을 inverted index에 넣어 아무 단어나 즉시 찾는 대신 색인이 로그량을 따라 커지고, 아래쪽 Loki는 라벨 조합만 작은 인덱스에 넣고 본문은 압축 chunk로 object storage에 던져 라벨로 좁힌 뒤 스캔한다

이 설계에는 함정도 있다. 라벨 값의 조합 하나하나가 별도 stream이고 별도 인덱스 엔트리다. 그래서 카디널리티가 높은 값(요청 ID, 사용자 ID, 타임스탬프 같은 무한에 가까운 값)을 라벨로 넣으면 stream이 폭발하고 인덱스가 다시 무거워진다. 라벨은 낮은 카디널리티로 유지하고(namespace, app, level 정도), 세밀한 값은 본문이나 structured metadata에 두는 것이 Loki를 잘 쓰는 첫 번째 규칙이다.

Architecture

쓰기 경로부터 보자.

  • Distributor: 에이전트가 밀어 넣은 로그를 받는 입구. stream을 consistent hashing으로 ingester들에 분배한다. 보통 replication factor 3으로 복제하고, 셋 중 둘이 성공하면(floor(3/2)+1 = 2) 쓰기를 인정한다.
  • Ingester: stream을 메모리에서 chunk로 쌓다가 object storage로 flush한다. 유실을 막기위해 들어온 쓰기를 디스크의 WAL(write-ahead log)에도 남긴다.
  • Object storage: chunk와 인덱스가 최종적으로 모이는 곳. Loki에서 유일한 상태 저장소다.

Distributor 가 받아서 Ingester 한테 분배, Ingester 는 메모리에 쌓다가 Object Storage Backend 에 저장

읽기 경로는 반대로 흐른다.

  • Querier: LogQL 쿼리를 받아 인덱스로 chunk를 좁히고, 아직 메모리에 있는 최근 데이터는 ingester에서, 오래된 것은 object storage에서 긁어 합친다.
  • Compactor: 인덱스를 주기적으로 압축하고 보존 기간이 지난 데이터를 삭제한다.

Loki 아키텍처. 에이전트가 distributor로 로그를 push하면 distributor가 stream을 해시해 ingester로 분배하고, ingester는 chunk를 쌓다가 object storage로 flush한다. Grafana의 쿼리는 querier가 받아 ingester의 최근 데이터와 object storage의 오래된 chunk를 합쳐 응답하고, compactor가 인덱스 압축과 보존 기간 삭제를 맡는다

Deployment Modes

  • Monolithic: -target=all로 모든 컴포넌트를 한 프로세스에 담는다. 소규모 read/write에 공식 권장된다.
  • Simple Scalable(SSD): read, write, backend 세 갈래로 나눠 하루 수백 GB에서 TB 근처까지 감당한다. Helm 차트의 기본값이었지만 deprecated되어 제거될 예정이고, 마이그레이션 경로는 microservices 또는 HA monolithic이다.
  • Microservices: 컴포넌트를 전부 따로 배포해 개별 스케일한다. 대규모 프로덕션용이고 운영 복잡도가 가장 높다.

단일 replica로 띄울 때는 복제 정족수를 맞추라고 요구하지 않도록 replication_factor: 1을 반드시 넣어야 한다. 안 넣으면 쓰기가 정족수 부족으로 실패한다.

LogQL

LogQL은 Loki의 쿼리 언어다. PromQL을 로그판으로 옮긴 형태라, 크게 두 종류의 쿼리를 쓴다.

로그 쿼리는 stream을 고르고 본문을 걸러 로그 줄을 돌려준다. 문법은 세 층이다.

{app="payment-api"} | json | level="error"
  • {app="payment-api"} — stream selector. 라벨로 어떤 stream을 볼지 고른다. 이 중괄호는 필수다. 인덱스를 타는 유일한 부분이다.
  • | json — parser. JSON 로그 본문을 파싱해 각 필드(level, status, latency_ms)를 쿼리 가능한 라벨로 꺼낸다. logfmt, regexp, pattern 파서도 있다.
  • | level="error" — label filter. 파싱해 꺼낸 필드로 다시 거른다.

메트릭 쿼리는 로그를 숫자 시계열로 바꾼다. 로그 줄을 세거나 필드를 집계해 그래프로 만든다.

sum by (level) (count_over_time({app="payment-api"} | json [1m]))

count_over_time(... [1m])은 1분 창마다 매칭된 로그 줄 수를 세고, sum by (level)은 그걸 level별로 묶는다. 로그가 곧바로 대시보드용 지표가 된다. 이 “로그 → 메트릭” 변환이 Loki가 Prometheus와 한 스택으로 붙는 지점이다.

Hands-on: Loki on k3s

한 번 로컬에 실제로 띄워보자.

Docker 위에 k3d로 k3s 클러스터를 만들고, Helm으로 Loki, 로그 수집 에이전트, Grafana를 배포하는 방식으로 진행해보자.

1. Create Cluster

k3d cluster create loki-demo --servers 1 --agents 2

2. Deploy Loki

Helm 저장소를 더하고 monolithic 모드 values를 만든다.

helm repo add grafana https://grafana.github.io/helm-charts
helm repo update

loki-values.yaml:

deploymentMode: SingleBinary       # monolithic. 최신 차트는 Monolithic으로도 부른다

loki:
  auth_enabled: false
  commonConfig:
    replication_factor: 1          # 단일 replica면 필수
  schemaConfig:
    configs:
      - from: "2024-04-01"
        store: tsdb
        object_store: filesystem   # 로컬이라 object storage 대신 파일시스템
        schema: v13                # 3.x 기본, structured metadata 지원
        index:
          prefix: index_
          period: 24h
  storage:
    type: filesystem
  limits_config:
    allow_structured_metadata: true
    volume_enabled: true

singleBinary:
  replicas: 1

# 나머지 스케일 타깃과 캐시는 로컬에선 끈다
read: { replicas: 0 }
write: { replicas: 0 }
backend: { replicas: 0 }
minio: { enabled: false }
chunksCache: { enabled: false }
resultsCache: { enabled: false }
kubectl create namespace loki
helm install loki grafana/loki -n loki -f loki-values.yaml

loki-0 파드가 2/2 Running이 되면 Loki 본체는 준비됐다. 다만 아직 아무 로그도 들어오지 않는다. Loki는 로그를 받기만 하지 스스로 수집하지 않는다.

3. Grafana Alloy

Alloy는 로그, 메트릭, 트레이스를 함께 다루는 OpenTelemetry Collector 배포판으로, DaemonSet으로 모든 노드에 붙어 파드 로그를 긁어 Loki로 밀어 넣는다.

alloy-values.yaml에는 Alloy 설정을 담는다. 쿠버네티스 파드를 발견하고, 메타데이터를 라벨로 바꾼 뒤, 로그를 Loki로 보낸다.

// 1. 클러스터의 모든 파드 발견
discovery.kubernetes "pods" { role = "pod" }

// 2. 쿠버네티스 메타데이터를 Loki 라벨로
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_container_name"]; target_label = "container" }
  rule { source_labels = ["__meta_kubernetes_pod_label_app"];    target_label = "app" }
}

// 3. 컨테이너 로그를 tail
loki.source.kubernetes "pods" {
  targets    = discovery.relabel.pods.output
  forward_to = [loki.write.default.receiver]
}

// 4. Loki로 push
loki.write "default" {
  endpoint { url = "http://loki-gateway.loki.svc.cluster.local/loki/api/v1/push" }
}
helm install alloy grafana/alloy -n loki -f alloy-values.yaml

Alloy가 뜨면 클러스터의 모든 파드 로그가 Loki로 흐르기 시작한다.

4. Grafana

이제 Grafana를 배포하면서 Loki를 기본 데이터소스로 미리 물린다.

datasources:
  datasources.yaml:
    apiVersion: 1
    datasources:
      - name: Loki
        type: loki
        access: proxy
        url: http://loki-gateway.loki.svc.cluster.local
        isDefault: true
helm install grafana grafana/grafana -n loki -f grafana-values.yaml
kubectl port-forward -n loki svc/grafana 3000:80

http://localhost:3000으로 접속하면(초기 계정은 admin/admin) Loki가 이미 데이터소스로 붙어 있다. Loki 자체엔 인증을 걸지 않았으므로(auth_enabled: false) 클러스터 내부 서비스 주소만 넣으면 끝이다.

Grafana 데이터소스 설정 화면 — Loki 타입, URL은 클러스터 내부 주소 http://loki-gateway.loki.svc.cluster.local, 인증 없음으로 프로비저닝되어 있다

5. 로그 조회

JSON 로그를 뿜는 더미 payment-apidemo 네임스페이스에 띄웠다.

level, status, latency_ms 필드를 무작위로 섞어 내보내고, 일부는 level=error, status=500이다. Grafana의 Explore에서 앞서 본 로그 쿼리를 던진다.

{app="payment-api"} | json | level="error"

Grafana Explore에서 payment-api의 error 로그를 조회한 화면 — 상단 Logs volume 히스토그램에 error 발생량이 막대로 뜨고, 아래 로그 패널에 json 파싱으로 추출된 level·status·latency_ms 필드가 색으로 구조화되어 보인다

상단 Logs volume 히스토그램은 시간대별 매칭 로그 수를, 아래 패널은 실제 로그 줄을 보여준다. | json 덕분에 본문의 각 필드가 색인 없이도 필드로 꺼내져, 왼쪽에서 필드별로 다시 좁힐 수 있다.

이번엔 로그를 지표로 바꿔 본다.

sum by (level) (count_over_time({app="payment-api"} | json | __error__="" [1m]))

level별 로그 발생량을 1분 창으로 집계한 시계열 그래프 — info·warn·error·debug 네 선이 시간에 따라 그려지며, info가 가장 높고 나머지가 아래에 깔린다

로그 패널 대신 시계열 그래프가 나온다. level별 로그 발생 추이가 선으로 그려지고, 이 쿼리를 그대로 대시보드 패널이나 알림으로 승격할 수 있다.

여기 붙은 | __error__=""는 실전 디테일이다. | json으로 파싱하다 JSON이 아닌 줄(예: 파드가 초기화 중 뱉은 평문 에러)을 만나면 그 줄에 __error__ 라벨이 붙는데, 메트릭 쿼리는 이 파싱 실패를 그대로 에러로 올린다. | __error__=""는 “파싱에 성공한 줄만 남겨라”라는 뜻으로, 지저분한 실제 로그 스트림에서 메트릭을 뽑을 때 거의 항상 필요하다.

References