← 전체 글

EKS Observability 풀스택 구축: OpenTelemetry + Grafana LGTM 스택 완전 정복


작성일: 2026-04-11
태그: kubernetes eks observability opentelemetry grafana loki mimir tempo lgtm devops

배경 / 문제 상황

쿠버네티스 클러스터를 운영하다 보면 가장 먼저 부딪히는 현실적인 고민이 있다. “지금 클러스터가 어떤 상태인지 어떻게 알 수 있을까?” 그리고 “장애가 났을 때 어디서부터 봐야 하지?”

기존 모니터링 환경은 Prometheus + Grafana 조합이 사실상 업계 표준으로 자리잡았지만, 분산 시스템이 복잡해질수록 한계가 명확해진다.

  • 메트릭(Metrics): Pod CPU가 스파이크를 쳤다. 왜 올랐는가?
  • 로그(Logs): 에러 로그가 있다. 어느 요청에서 발생했는가?
  • 트레이스(Traces): 특정 API가 느리다. 어느 서비스에서 병목이 생겼는가?

세 가지 신호(Signal)가 서로 다른 시스템에 분산되어 있으면 장애 시 점프 앤 가이드가 불가능하다. 로그 시스템에서 에러를 발견하고, 해당 시점의 메트릭을 다른 시스템에서 찾고, 다시 트레이스 시스템으로 이동해야 하는 컨텍스트 스위칭이 반복된다.

이 문제를 해결하기 위해 Grafana LGTM 스택 + OpenTelemetry 기반의 통합 Observability 플랫폼을 EKS 위에 구축했다. 이 글은 그 설계 배경과 구현 과정, 그리고 과정에서 마주친 삽질을 기록한다.

기술 선택 배경 (왜 이 기술인가)

Prometheus 대신 OTel을 택한 이유

전통적인 Prometheus 기반 모니터링은 메트릭 수집에 특화되어 있다. 하지만 여러 수집 에이전트가 클러스터에 난립하게 되면 다음 문제가 생긴다.

  • Prometheus scraper (메트릭용)
  • Fluent Bit / Fluentd (로그용)
  • Jaeger / Zipkin agent (트레이스용)

각 에이전트가 독립적으로 운영되고, 수집 설정도 각기 다른 포맷을 따른다. OpenTelemetry Collector는 이 모든 신호를 단일 파이프라인으로 수집, 가공, 전송한다. Receiver → Processor → Exporter 구조로 어느 백엔드든 플러그인 방식으로 연결할 수 있다.

또한 OpenTelemetry는 CNCF 표준으로 자리잡고 있어 벤더 종속성에서 자유롭다. 오늘은 Grafana LGTM이지만, 내일 다른 백엔드로 교체해도 애플리케이션 코드를 수정할 필요가 없다.

Grafana LGTM 스택을 선택한 이유

구성 요소 역할 선택 이유
Loki 로그 저장/쿼리 S3 백엔드 지원, LogQL의 직관성, Grafana 네이티브 연동
Grafana 시각화 / 대시보드 세 가지 신호를 단일 UI에서 상관 분석 가능
Tempo 분산 트레이스 저장/쿼리 S3 백엔드 지원, Exemplar를 통한 Metrics↔Traces 연결
Mimir 메트릭 장기 저장 Prometheus 호환 API, S3 백엔드로 저비용 장기 보관

비용 측면에서도 LGTM 스택은 매력적이다. 모든 장기 스토리지를 S3로 오프로드하기 때문에 EKS 노드의 PV 비용을 크게 절감할 수 있다. Prometheus의 경우 장기 보관을 위해 대용량 PV가 필요하지만, Mimir는 S3에 블록 단위로 저장하므로 운영 비용이 선형적으로 낮아진다.

단일 Collector vs 분리 배포

OTel Collector를 어떻게 배포할지가 초기의 핵심 설계 결정이었다. 두 가지 패턴이 있다.

  1. 단일 Collector (모든 신호 통합): 단순하지만, 메트릭 scrape 부하와 로그/트레이스 수신 부하가 동일 인스턴스에 집중
  2. 역할별 분리 배포: 복잡하지만, 각 워크로드의 특성에 맞게 리소스를 독립 조정 가능

최종적으로 역할별 분리 배포를 선택했다. 특히 Prometheus scrape는 pull 방식이라 DaemonSet보다 Deployment 형태가 적합하고, 로그/트레이스 수신은 push 방식이라 별도로 운영하는 것이 확장성 측면에서 유리하다.

구현 과정

전체 데이터 흐름

[애플리케이션 / k8s 컴포넌트]

        ├─── 메트릭 (Pull/Scrape)
        │         └─→ OTel Collector (Prometheus Receiver)
        │                   └─→ Mimir (Remote Write)

        ├─── 로그 (Push/stdout)
        │         └─→ OTel Collector (K8s Log Receiver)
        │                   └─→ Loki (Loki Exporter)

        └─── 트레이스 (Push/OTLP)
                  └─→ OTel Collector (OTLP Receiver)
                            └─→ Tempo (OTLP Exporter)

[Tempo Metrics Generator]
        └─→ Mimir (Service Graph Metrics, RED Metrics)

[Grafana]
        ├─── Mimir (메트릭 쿼리)
        ├─── Loki (로그 쿼리)
        └─── Tempo (트레이스 쿼리 + Correlation)

1단계: IRSA 설정 — 각 컴포넌트의 S3 권한 분리

LGTM 스택은 모두 S3를 장기 스토리지로 사용한다. IRSA(IAM Roles for Service Accounts)를 사용하여 각 컴포넌트에 필요한 최소 권한만 부여한다.

공통 S3 정책(각 컴포넌트 Role에 부여):

{
    "Statement": [
        {
            "Sid": "S3LgtmAccess",
            "Effect": "Allow",
            "Action": [
                "s3:ListBucket",
                "s3:PutObject",
                "s3:GetObject",
                "s3:DeleteObject"
            ],
            "Resource": [
                "arn:aws:s3:::{component-bucket}",
                "arn:aws:s3:::{component-bucket}/*"
            ]
        }
    ]
}

각 컴포넌트는 고유한 S3 버킷과 IAM Role을 가진다. IRSA Trust Policy는 각 컴포넌트의 Kubernetes ServiceAccount를 Principal로 설정한다.

{
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "Federated": "arn:aws:iam::{ACCOUNT_ID}:oidc-provider/oidc.eks.{region}.amazonaws.com/id/{OIDC_ID}"
            },
            "Action": "sts:AssumeRoleWithWebIdentity",
            "Condition": {
                "StringEquals": {
                    "oidc.eks.{region}.amazonaws.com/id/{OIDC_ID}:sub": "system:serviceaccount:{namespace}:{service-account-name}",
                    "oidc.eks.{region}.amazonaws.com/id/{OIDC_ID}:aud": "sts.amazonaws.com"
                }
            }
        }
    ]
}

2단계: Mimir 설치 — 메트릭 장기 저장

Mimir는 Grafana Labs가 제공하는 Prometheus 호환 장기 메트릭 스토리지다. Cortex에서 파생된 분산 아키텍처를 제공하지만, 소규모 클러스터에서는 monolithic 모드로 충분하다.

핵심 Helm values:

mimir:
  structuredConfig:
    common:
      storage:
        backend: s3
        s3:
          bucket_name: {mimir-bucket}
          endpoint: s3.ap-northeast-2.amazonaws.com
          region: ap-northeast-2
    blocks_storage:
      storage_prefix: mimir
    alertmanager_storage:
      storage_prefix: alertmanager
    ruler_storage:
      storage_prefix: ruler

serviceAccount:
  create: true
  name: "mimir"
  annotations:
    "eks.amazonaws.com/role-arn": "arn:aws:iam::{ACCOUNT_ID}:role/Mimir_S3"

minio:
  enabled: false  # 자체 S3를 사용하므로 minio 비활성화

Prometheus는 /api/v1/query를, Mimir는 /prometheus/api/v1/query를 사용한다는 점에 주의한다. Grafana의 Prometheus 데이터소스 URL은 http://mimir-nginx.mimir.svc:80/prometheus로 설정한다.

3단계: Loki 설치 — 로그 저장

Loki는 인덱스를 별도로 두지 않고 레이블 기반으로 로그를 저장하는 경량 로그 시스템이다. Elasticsearch와 달리 전문 검색(full-text search)이 아닌 레이블 필터링 + 정규식 검색 방식을 사용해 비용이 낮다.

핵심 Helm values:

loki:
  auth_enabled: false  # 단일 테넌트 환경에서는 false
  storage:
    bucketNames:
      chunks: {loki-bucket}
      ruler: {loki-bucket}
      admin: {loki-bucket}
    type: s3
    s3:
      region: ap-northeast-2
  schemaConfig:
    configs:
    - from: 2024-11-01
      store: tsdb        # 주의: 오타 'stor'로 설치하면 에러 발생
      object_store: aws
      schema: v13
      index:
        prefix: index_
        period: 24h
  storage_config:
    aws:
      bucketnames: {loki-bucket}
      region: ap-northeast-2

serviceAccount:
  create: true
  annotations:
    "eks.amazonaws.com/role-arn": "arn:aws:iam::{ACCOUNT_ID}:role/Loki_S3"

삽질 포인트: schemaConfig.configs[].storestor로 오타 입력하면 에러 없이 설치되지만 데이터가 저장되지 않는다. 이 문제를 찾는 데 상당한 시간이 소요됐다. values.yaml의 키 이름은 항상 공식 문서와 대조 확인이 필수다.

삽질 포인트 2: auth_enabled: true가 Loki의 기본값이다. 멀티 테넌트 환경이 아닌 경우 반드시 false로 설정해야 한다. 기본값 그대로 두면 OTel Collector에서 Loki로 로그를 push할 때 인증 오류가 발생한다.

4단계: Tempo 설치 — 분산 트레이스

Tempo는 Grafana Labs의 분산 트레이스 백엔드다. Jaeger나 Zipkin과 달리 오직 TraceID로만 조회하는 단순한 설계를 택했기 때문에 인덱스가 없고 S3에 trace 데이터를 그대로 저장한다.

Tempo의 차별화된 기능은 Metrics Generator다. 수신된 트레이스를 분석하여 서비스 간 호출 그래프(Service Graph)와 RED(Rate, Errors, Duration) 메트릭을 자동으로 생성하고 Mimir로 원격 쓰기한다. 이를 통해 Grafana에서 메트릭에서 트레이스로, 트레이스에서 메트릭으로의 Correlation이 가능해진다.

핵심 Helm values:

global_overrides:
  metrics_generator_processors:
  - service-graphs   # 서비스 간 호출 그래프 메트릭 생성
  - span-metrics     # RED 메트릭 생성

metricsGenerator:
  enabled: true
  config:
    storage:
      remote_write:
      - send_exemplars: true
        url: http://mimir-nginx.mimir.svc:80/api/v1/push  # Mimir로 전송

storage:
  trace:
    backend: s3
    s3:
      bucket: {tempo-bucket}
      endpoint: s3.ap-northeast-2.amazonaws.com

traces:
  otlp:
    grpc:
      enabled: true
    http:
      enabled: true

serviceAccount:
  create: true
  name: "tempo"
  annotations:
    "eks.amazonaws.com/role-arn": "arn:aws:iam::{ACCOUNT_ID}:role/Tempo_S3"

5단계: Operator + Collector 설치

OTel Operator를 먼저 설치하면 OpenTelemetryCollector, Instrumentation 등의 CRD를 사용할 수 있다.

메트릭 수집용 Collector (Deployment): Kubernetes Service Discovery로 클러스터 내 메트릭 엔드포인트를 스크레이핑하고 Mimir로 전송한다.

# OpenTelemetryCollector CRD 예시 (메트릭용)
spec:
  config:
    receivers:
      prometheus:
        config:
          scrape_configs:
            # ArgoCD 메트릭
            - job_name: argocd
              kubernetes_sd_configs:
              - role: service
                namespaces:
                  names: [argocd]
              metric_relabel_configs:
              - source_labels: [__name__]
                action: keep
                regex: argocd_app_sync_total|argocd_app_info
              relabel_configs:
              - source_labels: [__meta_kubernetes_service_name]
                action: keep
                regex: argocd-metrics

            # OTel Collector 자체 메트릭
            - job_name: otel-collector-metrics
              kubernetes_sd_configs:
              - role: service
                namespaces:
                  names: [otel]
                selectors:
                - role: service
                  label: operator.opentelemetry.io/collector-service-type=monitoring

    exporters:
      prometheusremotewrite:
        endpoint: http://mimir-nginx.mimir.svc:80/api/v1/push

    service:
      pipelines:
        metrics:
          receivers: [prometheus]
          exporters: [prometheusremotewrite]

로그 + 트레이스 수신용 Collector (DaemonSet): 애플리케이션이 OTLP로 push하는 로그와 트레이스를 수신한다.

spec:
  mode: daemonset
  config:
    receivers:
      otlp:
        protocols:
          grpc:
            endpoint: 0.0.0.0:4317
          http:
            endpoint: 0.0.0.0:4318

    exporters:
      loki:
        endpoint: http://loki.loki.svc:3100/loki/api/v1/push
      otlp/tempo:
        endpoint: http://tempo.tempo.svc:4317

    service:
      pipelines:
        logs:
          receivers: [otlp]
          exporters: [loki]
        traces:
          receivers: [otlp]
          exporters: [otlp/tempo]

6단계: Auto-Instrumentation 설정

OTel Operator의 Instrumentation CRD를 사용하면 애플리케이션 코드 수정 없이 자동 계측이 가능하다. Operator가 Pod 생성 시 사이드카나 init container를 주입한다.

apiVersion: opentelemetry.io/v1alpha1
kind: Instrumentation
metadata:
  name: otel-instrumentation
  namespace: otel
spec:
  exporter:
    endpoint: http://otel-otlp-collector.otel.svc.cluster.local:4317
  propagators:
  - tracecontext
  - baggage
  sampler:
    type: parentbased_traceidratio
    argument: "0.2"  # 20% 샘플링
  python:
    env:
    # Python은 기본적으로 HTTP(4318)를 사용하므로 명시적으로 설정 필요
    - name: OTEL_EXPORTER_OTLP_ENDPOINT
      value: http://otel-otlp-collector.otel.svc.cluster.local:4318

자동 계측을 활성화하려면 Deployment의 annotation에 한 줄만 추가한다:

spec:
  template:
    metadata:
      annotations:
        instrumentation.opentelemetry.io/inject-python: otel/otel-instrumentation
        # 컨테이너가 여러 개인 경우 특정 컨테이너만 지정
        instrumentation.opentelemetry.io/container-names: "flask-app"

7단계: Grafana Correlation 설정

세 가지 신호를 단일 Grafana UI에서 연결하는 것이 LGTM 스택의 핵심 가치다.

Trace → Metrics (Exemplar): Tempo의 Metrics Generator가 생성한 메트릭에는 Exemplar가 포함된다. Grafana에서 Mimir 데이터소스의 Exemplar 기능을 활성화하면, 메트릭 그래프에서 이상 지점을 클릭하여 해당 시점의 Trace를 바로 확인할 수 있다.

주의: Exemplar는 Tempo Metrics Generator가 생성하는 메트릭에만 포함된다. 일반 애플리케이션 메트릭에는 Exemplar가 없을 수 있다.

Trace → Logs: Trace ID를 로그에 포함시키면 이상적이지만, 로그에 Trace ID가 없는 경우 Pod 이름으로 필터링한 후 시간대를 맞춰 로그를 조회한다. Grafana의 Derived Fields 설정으로 로그 내 TraceID 패턴을 인식하면 클릭 한 번으로 Tempo로 점프할 수 있다.

Metrics → Logs: Grafana의 Data Link 기능으로 메트릭 그래프에서 특정 레이블(Pod, Namespace)을 기반으로 Loki 로그 쿼리로 직접 이동할 수 있다.

핵심 트러블슈팅

문제 1: Mixin 대시보드와 OTel 레이블 불일치

처음에는 Kubernetes 공식 Prometheus Mixin의 완성도 높은 대시보드를 그대로 활용하려 했다. 그런데 Mixin 대시보드는 job, instance 레이블이 Prometheus 네이티브 환경을 기준으로 설계되어 있다.

OTel Collector의 Prometheus Receiver는 스크레이핑 대상의 레이블 구조가 Prometheus 그 자체와는 미묘하게 다르다. 결국 수백 개의 패널을 일일이 수정해야 하는 비용이 OTel 도입 이점을 상쇄할 수 있다는 판단이 섰다.

해결: Mixin 대시보드를 전면 적용하는 대신, 핵심 메트릭(CPU, Memory, 상태값)만 취사선택하여 커스텀 대시보드를 구성했다. Karpenter, ArgoCD 등 각 컴포넌트는 공식 제공 대시보드를 import하되, OTel 환경에 맞게 최소한의 레이블 조정만 수행했다.

문제 2: Python OTLP 프로토콜 불일치

Python OTel 라이브러리는 기본적으로 HTTP 프로토콜(포트 4318)을 사용하지만, 대부분의 예제는 gRPC(포트 4317)를 기준으로 설명한다. Instrumentation CRD에서 endpoint를 4317로 지정하면 Python 애플리케이션에서 트레이스가 전송되지 않는다.

해결: Python 전용으로 OTEL_EXPORTER_OTLP_ENDPOINT 환경변수를 4318(HTTP)로 명시 설정한다.

python:
  env:
  - name: OTEL_EXPORTER_OTLP_ENDPOINT
    value: http://otel-otlp-collector.otel.svc.cluster.local:4318

문제 3: Loki store 오타로 인한 데이터 미저장

앞서 언급했듯 schemaConfigstore 키를 stor로 오타 입력하면 Loki가 정상 기동은 되지만 로그가 저장되지 않는다. 별도의 에러 로그도 없어 원인 파악에 오랜 시간이 걸렸다.

해결: stor: tsdbstore: tsdb로 수정 후 Loki 재시작.

교훈: Helm values는 스키마 검증이 느슨한 경우가 많다. 핵심 설정값은 반드시 공식 values.yaml 레퍼런스와 대조 확인한다.

문제 4: Tempo에서 서비스 엔드포인트 사용

Tempo Metrics Generator가 Mimir로 메트릭을 remote write할 때, Mimir의 push endpoint를 정확히 지정해야 한다.

# 잘못된 설정 — Mimir의 Prometheus remote write endpoint가 아님
url: http://mimir-nginx.mimir.svc:80/prometheus/api/v1/push

# 올바른 설정
url: http://mimir-nginx.mimir.svc:80/api/v1/push

Prometheus Remote Write의 endpoint(/api/v1/push)와 Prometheus 쿼리 endpoint(/prometheus/api/v1/query)를 혼동하기 쉽다.

결과 및 개선 효과

LGTM + OTel 스택 구축 이후 달라진 장애 대응 흐름을 비교해보면 차이가 명확하다.

도입 전: 메트릭 → 다른 시스템 접속 → 로그 → 또 다른 시스템 접속 → 트레이스 → 시스템 없음

도입 후: Grafana 단일 UI에서 메트릭 이상 감지 → Exemplar 클릭 → Trace 확인 → 관련 로그 점프

실질적인 효과:

  • MTTR(평균 복구 시간) 단축: 신호 간 컨텍스트 스위칭 제거로 장애 원인 파악 시간이 크게 줄었다
  • 스토리지 비용 절감: PV 기반 Prometheus 대비 S3 오프로딩으로 장기 보관 비용 절감
  • 수집 에이전트 일원화: 여러 에이전트 대신 OTel Collector 하나로 수집 파이프라인 단일화
  • 벤더 독립성 확보: OTLP 표준 준수로 향후 백엔드 교체 시 애플리케이션 코드 변경 불필요

마무리 및 다음 스텝

Grafana LGTM + OpenTelemetry 조합은 설계는 단순하지만, 각 컴포넌트 간의 미묘한 연동 설정이 많다. 특히 IRSA 설정, OTLP 프로토콜 불일치, Loki 스키마 설정 오타처럼 에러 메시지가 명확하지 않은 문제들이 초기 구축의 주요 장벽이다.

하지만 한 번 구축이 완료되면 세 가지 신호의 통합 분석이라는 강력한 가치를 얻는다. 특히 Tempo Metrics Generator → Exemplar → Trace 연결 흐름은 분산 시스템 디버깅의 패러다임을 바꾸는 경험이었다.

이후 개선 방향으로는 다음을 고려하고 있다:

  • SLO 대시보드: Mimir + Grafana의 SLO 패널로 서비스 신뢰성 지표 시각화
  • 알림 고도화: Grafana Alerting → Slack 연동 (현재는 Alertmanager 기반 기초 알럿만 운영)
  • AI 분석 통합: Grafana의 ML-based anomaly detection 기능 실험
  • 샘플링 전략 고도화: Head Sampling(20%)에서 Tail-based Sampling으로 전환하여 에러 트레이스 100% 수집

돌아보며

  • Greenfield EKS 위에 메트릭·로그·트레이스 통합 Observability 플랫폼(LGTM + OTel)을 단독 설계·구축한 아키텍처 능력
  • Collector 단일 vs 분리 배포, PV vs S3 오프로딩 같은 결정을 워크로드 특성(pull/push, 저장 비용 구조) 기준으로 내리고 근거를 남긴 판단 능력
  • 컴포넌트 설치를 넘어 신호 상관관계(Exemplar → Trace → Log)까지 연결해 MTTR 단축이라는 운영 가치로 완성한 플랫폼 관점

회사 내부 정보(계정 ID, 도메인, 버킷명 등)는 일반화하여 표기했습니다.