장애가 끝난 뒤 원인을 찾으려 했지만 문제 파드는 이미 재시작되어 로컬 로그가 사라졌습니다. 컨테이너 로그를 중앙에서 수집하지 않으면 재현이 어려운 장애는 흔적 없이 지나갑니다. Loki는 Kubernetes 로그를 라벨 기반으로 모아 빠르게 추적하게 해줍니다.
Loki로 Kubernetes 로그 수집
운영 중인 쇼핑몰 결제 서비스에서 새벽 2시에 간헐적 500 에러가 발생했습니다. 노드가 12개, 파드가 80개 이상인 클러스터에서 어느 파드가 문제인지 찾으려면 어떻게 해야 할까요. kubectl logs를 파드마다 하나씩 실행하면 범인을 찾기 전에 날이 밝을 수도 있습니다. Loki는 Prometheus와 같은 레이블 기반 철학으로 설계된 로그 집계 시스템입니다. Promtail DaemonSet이 모든 노드의 컨테이너 로그를 자동으로 수집하고 Loki에 전송하면, Grafana에서 {namespace="production", app="payment"} |= "ERROR" 한 줄로 클러스터 전체의 관련 로그를 단번에 조회할 수 있습니다. Elasticsearch 기반 EFK 스택 대비 저장 비용이 70~90% 낮고, Prometheus와 같은 레이블 체계를 사용하기 때문에 메트릭-로그 연동이 자연스럽습니다. 이 모듈을 마치면 Loki 스택을 클러스터에 배포하고 LogQL로 실무 수준의 로그 분석을 수행할 수 있습니다.
- 1Loki 아키텍처: Loki + Promtail + Grafana 삼각 구조
- 2Promtail DaemonSet 배포 및 hostPath 마운트로 컨테이너 로그 수집
- 3LogQL 기초: 레이블 필터, 라인 필터, 집계 함수
- 4Grafana 데이터소스 연결 및 Explore 탭에서 로그 조회
- 5Loki 대시보드 구성: 로그 볼륨, 에러율, 특정 키워드 알림
- 6TroubleCase: Loki에 로그가 보이지 않을 때 단계적 진단
kubectl get nodeskubectl create namespace monitoring --dry-run=client -o yaml | kubectl apply -f -helm version --shortkubectl get pods -n monitoring | grep -E 'prometheus|grafana'helm repo add grafana https://grafana.github.io/helm-charts && helm repo updateLoki 스택 아키텍처
Loki 생태계는 세 가지 컴포넌트로 구성됩니다.
┌─────────────────────────────────────────────────────┐
│ Kubernetes 클러스터 │
│ │
│ Node-1 Node-2 Node-3 │
│ ┌──────────┐ ┌──────────┐ ┌────────┐ │
│ │ Promtail │ │ Promtail │ │Promtail│ │ ← DaemonSet
│ │(DaemonSet│ │(DaemonSet│ │DaemonSet│ │
│ └────┬─────┘ └────┬─────┘ └───┬────┘ │
│ │ /var/log/pods/ │ │ │
│ └──────────────┬────┘──────────────────┘ │
│ │ HTTP Push (port 3100) │
│ ┌──────▼──────┐ │
│ │ Loki │ ← StatefulSet │
│ │ (port 3100)│ │
│ └──────┬──────┘ │
│ │ 쿼리 (LogQL) │
│ ┌──────▼──────┐ │
│ │ Grafana │ ← Deployment │
│ │ (port 3000)│ │
│ └─────────────┘ │
└─────────────────────────────────────────────────────┘
Promtail: 각 노드의 /var/log/pods/ 에서 로그 파일을 읽어 Loki로 전송
Loki: 로그를 청크 단위로 저장, LogQL 쿼리 처리
Grafana: Loki를 데이터소스로 연결, Explore 탭에서 조회 + 대시보드
왜 Promtail은 DaemonSet인가 — 로그 파일 위치와 수집 구조
로그 수집기를 Deployment로 배포했더니 일부 노드의 로그가 수집되지 않았습니다. 어떤 노드에 파드가 배포될지 예측할 수 없기 때문에, 수집기가 없는 노드의 컨테이너 로그는 아무도 가져가지 못합니다. 새 노드가 클러스터에 추가될 때마다 수동으로 수집기를 배포해야 한다면 운영 부담이 커지고 누락 사고가 발생합니다. Kubernetes에서 컨테이너 로그는 각 노드의 로컬 파일시스템에 저장되기 때문에, 수집기는 반드시 해당 노드에서 실행되어야 합니다. DaemonSet은 현재와 미래의 모든 노드에 파드를 하나씩 자동 배포하는 유일한 방식입니다. 이 CB에서는 컨테이너 로그 파일이 노드에 어떻게 저장되는지와, Promtail이 DaemonSet으로 그것을 읽는 구조를 다룹니다. Promtail이 이 파일을 읽으려면 해당 노드에서 실행되어야 하고, 모든 노드의 로그를 빠짐없이 수집하려면 모든 노드에 하나씩 배포되어야 합니다. DaemonSet이 정확히 이 요구사항을 충족합니다.

# 노드에서 컨테이너 로그 파일 실제 위치 확인
kubectl debug node/worker-1 -it --image=busybox -- ls /host/var/log/pods/
# 출력 예시:
# production_payment-7d9b4c8f6-xkp2n_abc123/
# payment/
# 0.log ← 실제 로그 파일 (JSON Lines 형식)
# 1.log ← 이전 컨테이너 로그 (재시작된 경우)
로그 파일은 JSON Lines 형식으로 저장됩니다:
{"log":"2024-01-15T02:31:44Z [ERROR] payment gateway timeout\n","stream":"stderr","time":"2024-01-15T02:31:44.123456789Z"}
{"log":"2024-01-15T02:31:44Z [INFO] retrying request...\n","stream":"stdout","time":"2024-01-15T02:31:44.234567890Z"}
Promtail은 이 파일을 tail하면서 새 라인이 추가될 때마다 Loki에 push합니다. 파드가 재시작되거나 새 파드가 배포되어도 kubelet이 새 경로를 만들고 Promtail이 자동으로 감지합니다.
Loki + Promtail 배포
Helm으로 Loki Stack 설치
# values 파일 생성 — 실습용 단순 설정
cat > loki-values.yaml << 'EOF'
loki:
auth_enabled: false # 인증 비활성화 (실습용)
storage:
type: filesystem # 프로덕션에서는 S3/GCS 권장
commonConfig:
replication_factor: 1 # 단일 인스턴스 (실습용)
limits_config:
retention_period: 7d # 7일 보관
ingestion_rate_mb: 16
ingestion_burst_size_mb: 32
promtail:
enabled: true
config:
clients:
- url: http://loki-gateway/loki/api/v1/push
scrape_configs:
- job_name: kubernetes-pods
kubernetes_sd_configs:
- role: pod
pipeline_stages:
- cri: {} # CRI 포맷 파싱 (containerd)
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
- source_labels: [__meta_kubernetes_container_name]
target_label: container
grafana:
enabled: true
sidecar:
datasources:
enabled: true
additionalDataSources:
- name: Loki
type: loki
url: http://loki-gateway:80
access: proxy
isDefault: false
EOF
# Loki Stack 설치
helm install loki grafana/loki-stack \
--namespace monitoring \
--values loki-values.yaml
# 배포 상태 확인
kubectl get pods -n monitoring -l app.kubernetes.io/instance=loki
# 예상 출력:
NAME READY STATUS RESTARTS AGE
loki-0 1/1 Running 0 2m
loki-promtail-4k9dx 1/1 Running 0 2m ← Node-1
loki-promtail-7rmnx 1/1 Running 0 2m ← Node-2
loki-promtail-9vzph 1/1 Running 0 2m ← Node-3
loki-grafana-6d8f4b5c7-xm2nk 1/1 Running 0 2m
Promtail DaemonSet 구조 살펴보기
# Promtail DaemonSet 상세 확인
kubectl describe daemonset loki-promtail -n monitoring
# hostPath 마운트 확인 — 로그 파일 접근 경로
kubectl get daemonset loki-promtail -n monitoring -o yaml | \
grep -A5 "hostPath"
핵심 볼륨 마운트 구조:
# Promtail Pod spec 핵심 부분
volumes:
- name: containers # 컨테이너 로그 경로
hostPath:
path: /var/lib/docker/containers
- name: pods # Pod 로그 경로 (kubelet 관리)
hostPath:
path: /var/log/pods
volumeMounts:
- name: pods
mountPath: /var/log/pods
readOnly: true # 읽기 전용 (보안)
- name: containers
mountPath: /var/lib/docker/containers
readOnly: true
LogQL 기초
LogQL은 Prometheus PromQL의 로그 버전입니다. 두 가지 유형의 쿼리가 있습니다.
로그 쿼리 (Log Query)
특정 조건의 로그 라인을 반환합니다.
# 기본 레이블 셀렉터 — app=nginx 레이블의 모든 로그
{app="nginx"}
# 네임스페이스 + 앱 조합
{namespace="production", app="payment"}
# 라인 필터: ERROR 포함 라인만
{namespace="production"} |= "ERROR"
# 라인 필터: 특정 문자열 제외
{app="nginx"} != "health-check"
# 정규식 필터
{app="api-server"} |~ "timeout|connection refused"
# JSON 파싱 후 특정 필드 필터
{app="payment"} | json | status_code=500
# 파이프라인 체이닝
{namespace="production", app="payment"}
|= "ERROR"
| json
| line_format "{{.level}} [{{.request_id}}] {{.message}}"
메트릭 쿼리 (Metric Query)
로그에서 숫자 메트릭을 추출합니다.
# 분당 에러 로그 수 (rate)
rate({app="payment"} |= "ERROR" [5m])
# 네임스페이스별 로그 수신 속도
sum(rate({namespace=~".+"}[5m])) by (namespace)
# HTTP 500 에러 비율 계산
sum(rate({app="nginx"} |= "HTTP/1.1 5" [5m])) /
sum(rate({app="nginx"} [5m]))
# 특정 패턴 카운트
count_over_time({app="order-service"} |= "payment_failed" [1h])
Grafana Explore에서 LogQL 실행
# Grafana 포트포워딩
kubectl port-forward svc/loki-grafana -n monitoring 3000:80
# 기본 admin 패스워드 확인
kubectl get secret loki-grafana -n monitoring \
-o jsonpath='{.data.admin-password}' | base64 -d
브라우저에서 http://localhost:3000 접속 → Explore → 데이터소스 Loki 선택 → 쿼리 입력:
# 실습 1: 최근 1시간 production 네임스페이스 에러 로그
{namespace="production"} |= "ERROR" | limit 100
# 실습 2: 특정 파드 로그 실시간 tail
{pod=~"payment-.*"} | limit 50
# 실습 3: 분당 에러 수 그래프
rate({namespace="production"} |= "ERROR" [5m])
Loki 레이블 전략 — 카디널리티 폭발 주의
Loki를 도입한 초반에는 잘 동작하다가 어느 시점부터 쿼리가 느려지고 Loki 자체가 메모리를 과다 사용합니다. 원인을 찾아보면 Promtail 설정에서 user_id나 request_id 같은 고유값을 레이블로 추가한 것이 문제입니다. 레이블 조합이 수천 개로 늘어나면 Loki는 그만큼 많은 스트림을 관리해야 해 성능이 급격히 저하됩니다. 이를 카디널리티 폭발이라고 하며, Loki 운영에서 가장 흔한 성능 문제입니다. 이 CB에서는 낮은 카디널리티 레이블만 사용하는 설계 원칙과, 고유값 검색은 라인 필터로 처리하는 올바른 Loki 레이블 전략을 다룹니다. 레이블 수와 값의 조합이 너무 많아지면 스트림 수가 폭발적으로 증가해 성능이 급격히 저하됩니다. 이를 카디널리티 폭발이라고 합니다.

# 나쁜 예: 카디널리티 폭발
# pod_id, user_id, request_id 같은 고유값을 레이블로 사용하면
# 스트림이 파드/사용자/요청 수만큼 생성됨
scrape_configs:
- pipeline_stages:
- labels:
pod_id: __meta_kubernetes_pod_uid # 파드마다 고유 → 수천 개 스트림
user_id: user_id # 절대 사용 금지
request_id: request_id # 절대 사용 금지
# 좋은 예: 낮은 카디널리티 레이블만 사용
scrape_configs:
- pipeline_stages:
- labels:
app: # 앱 이름 (수십 개)
namespace: # 네임스페이스 (한 자릿수~수십 개)
container: # 컨테이너 이름 (앱당 보통 1~3개)
level: # 로그 레벨 (INFO/WARN/ERROR — 5개 미만)
user_id나 request_id 같은 고유값은 로그 라인 내용 안에 두고 LogQL의 라인 필터(|= "user_id=12345")나 JSON 파싱으로 검색하세요. 레이블은 스트림을 선택하는 용도, 내용 필터는 스트림 내에서 검색하는 용도로 구분하는 것이 Loki 성능의 핵심입니다.
# 현재 레이블 카디널리티 확인 (Loki API)
curl -s http://localhost:3100/loki/api/v1/labels | jq '.data'
curl -s "http://localhost:3100/loki/api/v1/label/pod/values" | jq '.data | length'
# 값이 수천 개라면 레이블 전략 재검토 필요
Grafana 대시보드 구성
주요 패널 설정
// 패널 1: 네임스페이스별 에러 로그 수 (시계열)
{
"title": "Error Rate by Namespace",
"type": "timeseries",
"targets": [{
"expr": "sum(rate({namespace=~\".+\"} |= \"ERROR\" [5m])) by (namespace)",
"legendFormat": "{{namespace}}"
}]
}
// 패널 2: 최근 에러 로그 (Logs 패널)
{
"title": "Recent Errors",
"type": "logs",
"targets": [{
"expr": "{namespace=\"production\"} |= \"ERROR\" | limit 200",
"legendFormat": ""
}]
}
// 패널 3: 로그 볼륨 (스택 바)
{
"title": "Log Volume",
"type": "barchart",
"targets": [{
"expr": "sum(count_over_time({namespace=~\".+\"} [1h])) by (app)",
"legendFormat": "{{app}}"
}]
}
알림(Alert) 설정
# Grafana Alert Rule — 1분간 에러 로그 10개 초과 시 알림
apiVersion: 1
groups:
- orgId: 1
name: loki-alerts
folder: Kubernetes
interval: 1m
rules:
- uid: loki-error-alert
title: "High Error Rate"
condition: C
data:
- refId: A
queryType: range
relativeTimeRange:
from: 300
to: 0
model:
expr: 'sum(count_over_time({namespace="production"} |= "ERROR" [1m]))'
- refId: C
type: classic_conditions
model:
conditions:
- type: query
evaluator:
type: gt
params: [10]
트러블슈팅
Loki를 배포하고 Grafana에서 {namespace="production"} 쿼리를 실행했는데 "No data" 또는 빈 결과가 반환됩니다.
1단계: Promtail DaemonSet Pod 상태 확인
# 모든 노드에서 Promtail이 Running 상태인지 확인
kubectl get daemonset loki-promtail -n monitoring
# DESIRED와 READY가 같아야 함
# DESIRED CURRENT READY UP-TO-DATE AVAILABLE
# 3 3 3 3 3
# 특정 노드 Promtail Pod 로그 확인
kubectl logs -n monitoring -l app=promtail --tail=50
# 에러 메시지 패턴:
# level=error ... msg="error connecting to loki" err="connection refused"
# level=warn ... msg="failed to send batch" err="Post ... : dial tcp..."
# level=error ... msg="error reading log file" err="permission denied"
- NAME—조회 대상 리소스 이름이 예상한 대상과 일치하는지 확인합니다.
- STATUS/READY—Running, Ready, Available처럼 정상 상태를 나타내는 필드가 있는지 봅니다.
- RESTARTS/EVENTS—재시작 횟수나 Warning 이벤트가 증가하지 않는지 확인합니다.
2단계: Loki Service 연결 확인
# Loki Service 이름과 포트 확인
kubectl get svc -n monitoring | grep loki
# Promtail ConfigMap에서 Loki URL 확인
kubectl get configmap loki-promtail -n monitoring -o yaml | \
grep -A3 "clients:"
# Promtail Pod 내부에서 Loki 연결 테스트
kubectl exec -n monitoring -it $(kubectl get pod -n monitoring -l app=promtail -o name | head -1) \
-- wget -qO- http://loki-gateway:80/ready
# 출력: "ready" 라면 정상
3단계: 로그 파일 마운트 확인
# Promtail Pod에 /var/log/pods 가 마운트됐는지 확인
kubectl exec -n monitoring \
$(kubectl get pod -n monitoring -l app=promtail -o name | head -1) \
-- ls /var/log/pods/
# 특정 네임스페이스 파드 로그 파일 존재 확인
kubectl exec -n monitoring \
$(kubectl get pod -n monitoring -l app=promtail -o name | head -1) \
-- ls /var/log/pods/ | grep "^production"
4단계: Promtail 수집 상태 API 확인
# Promtail Pod 포트포워딩
kubectl port-forward -n monitoring \
$(kubectl get pod -n monitoring -l app=promtail -o name | head -1) \
9080:9080
# 수집 중인 타겟 확인
curl -s http://localhost:9080/targets | grep -E "state|filename"
# state: "success" 여야 함
# state: "dropped" 라면 relabel 설정 문제
# tail 중인 파일 목록
curl -s http://localhost:9080/targets | jq '.[].labels'
5단계: Loki 수신 상태 확인
# Loki 포트포워딩
kubectl port-forward svc/loki -n monitoring 3100:3100
# 메트릭 확인 — 수신된 로그 청크 수
curl -s http://localhost:3100/metrics | grep "loki_ingester_chunks_stored_total"
# Loki 레이블 목록 (데이터가 있으면 여기 나타남)
curl -s "http://localhost:3100/loki/api/v1/labels" | jq '.data'
흔한 원인과 해결:
# 원인 1: Promtail ConfigMap의 Loki URL이 잘못됨
# helm values 수정 후 재배포
helm upgrade loki grafana/loki-stack -n monitoring \
--set promtail.config.clients[0].url=http://loki:3100/loki/api/v1/push
# 원인 2: RBAC 권한 부족 (Promtail이 Pod 메타데이터를 못 읽음)
kubectl auth can-i list pods --as=system:serviceaccount:monitoring:loki-promtail
# "no" 라면 ClusterRole 수정 필요
# 원인 3: 노드에 Promtail이 배포 안 됨 (Taint 문제)
kubectl describe nodes | grep -E "Taints:|Name:"
kubectl get daemonset loki-promtail -n monitoring -o yaml | grep -A5 tolerations
시나리오: 결제 서비스 간헐적 오류 — 멀티 파드 환경에서 로그로 원인 특정
금요일 오후 6시, 고객센터에서 "결제가 간헐적으로 실패한다"는 보고가 들어왔습니다. Deployment에 payment 파드가 5개 실행 중이고, 어느 파드가 문제인지 알 수 없습니다.
# Step 1: 최근 1시간 결제 서비스 에러 전수 조회
{namespace="production", app="payment"} |= "ERROR" | limit 500
# Step 2: 에러 패턴 파악 — JSON 파싱 후 error_type 집계
{namespace="production", app="payment"}
| json
| error_type != ""
| line_format "{{.pod}} {{.error_type}} {{.message}}"
# Step 3: 특정 파드에 에러가 집중되는지 확인
sum(count_over_time(
{namespace="production", app="payment"} |= "ERROR" [1h]
)) by (pod)
# 출력 예시:
# {pod="payment-7d9b4c8f6-xkp2n"} → 142건
# {pod="payment-7d9b4c8f6-abc12"} → 3건
# {pod="payment-7d9b4c8f6-def34"} → 2건
# xkp2n 파드에 집중 → 해당 파드 단독 조사
# Step 4: 해당 파드 상세 로그 시간순 조회
{pod="payment-7d9b4c8f6-xkp2n"} | limit 200
# Step 5: 에러 발생 직전 컨텍스트 파악
{pod="payment-7d9b4c8f6-xkp2n"}
| json
| line_format "{{.time}} [{{.level}}] {{.message}}"
| limit 50
# 발견: "DB connection pool exhausted" 에러가 반복됨
# → 해당 파드 환경변수의 DB_POOL_SIZE 설정 확인
kubectl describe pod payment-7d9b4c8f6-xkp2n -n production | grep DB_POOL
# DB_POOL_SIZE: 5 ← 다른 파드는 20, 이 파드만 재배포 시 설정 누락됨
Loki 없이 kubectl logs를 파드 5개에 수동으로 실행해서 패턴을 찾았다면 20~30분은 걸렸을 작업을 LogQL 쿼리 몇 줄로 5분 내에 완료했습니다. 실제 프로덕션에서는 파드 수십 개, 네임스페이스 수십 개에서 이 차이가 더 극적으로 드러납니다.
핵심 요약
| 구성요소 | 역할 | 배포 방식 |
|---|---|---|
| Loki | 로그 저장, LogQL 처리 | StatefulSet |
| Promtail | 노드 로그 수집, Loki 전송 | DaemonSet |
| Grafana | 시각화, 알림 | Deployment |
LogQL 필터 우선순위: 레이블 셀렉터({}) → 라인 필터(\|=, !=, \|~) → 파서(\| json) → 레이블 필터 → 포맷(\| line_format)
카디널리티 원칙: 레이블은 낮은 카디널리티 값만 (app, namespace, level). 고유값(user_id, request_id)은 로그 내용 안에 두고 라인 필터로 검색.
다음 단계
- Loki Ruler로 로그 기반 알림 규칙 설정
- Tempo와 연동해서 로그↔트레이스 상관 분석
- S3/GCS를 백엔드로 설정해서 장기 보관 비용 최적화
- Vector, Fluent Bit과 Loki 비교 — 수집 파이프라인 선택 기준