들어가며
EKS 위에 마이크로서비스 5개(order, kitchen, delivery, notification, batch)를 올린 후 주문이 느려진 경우가 있었는데 어느 서비스에서 지연되는지 알 수 없는 문제가 생겼다.
처음엔 kubectl logs -f -l app=shoong-order로 로그를 보려고 했다. order는 replicaCount가 2였고 --prefix로 Pod 구분은 할 수 있었지만 같은 시간대에 들어온 다른 주문 로그 사이에서 그 요청 한 건에 해당하는 줄들을 골라서 보기 어려웠다. 어찌저찌 order Pod를 찾아 본다고 해도 order는 kitchen, kitchen은 delivery, delivery는 notification을 부른다. 터미널을 네 개 열어 kubectl logs를 동시에 띄우고 로그에서 userName으로 grep해서 같은 요청에 해당하는 줄들을 시간순으로 직접 짜맞춰야 했다.
그래도 어디가 느린지는 못 알아냈다. 로그는 요청 시작과 요청 끝만 찍을 뿐 그 사이의 쿼리나 HTTP 호출 같은 것들이 각각 얼마나 걸렸는지는 안 보였다. 결국 코드 중간 중간 콘솔을 찍고 로그를 확인했었다.
한참을 이렇게 디버깅하고 나서야 문제의 윤곽이 보였다. 로그는 노드에 흩어져 있고, 메트릭은 어딘가에 따로 쌓이고 있고, 서비스 간 호출 흐름은 아예 어디에도 없었다. 각자 따로 놀고 있었다.
그래서 메트릭, 로그, 트레이스를 Grafana 한 화면에 묶어보기로 했다.

1. 풀고 싶었던 문제: MSA에서 디버깅이 안 된다
모놀리스에서 디버깅은 한 서버에 들어가 tail -f app.log 하나로 끝난다. 그런데 MSA로 분리되면 디버깅이 아래와 같은 이유로 막혔다.
| 문제 | 모놀리스 | MSA (서비스 5개) |
|---|---|---|
| 로그 위치 | 단일 파일 | 5개 Pod의 stdout. 어느 Pod부터 봐야 할지 모름 |
| 요청 추적 | 스택 트레이스 하나로 충분 | order → kitchen → delivery → notification, 어디서 끊겼는지 |
| 성능 측정 | 함수 단위 프로파일링 | 한 요청의 총 시간이 서비스별로 어떻게 분해되는지 측정 불가 |
옵저버빌리티(observability) 외부에서 보이는 신호만 가지고 내부 상태를 짐작할 수 있는 것을 말하는데 이런 부분이 필요했다.
처음엔 그냥 모니터링이랑 같은 말 아닌가 싶었는데, 찾아보니 둘이 좀 다르다고 한다. 모니터링은 CPU 사용률이나 응답 시간처럼 미리 정해둔 지표를 추적하는 쪽이고, 옵저버빌리티는 사전에 예상 못 한 질문(가령 이번 주문은 왜 유독 느렸는가 같은)에도 답을 찾을 수 있게 하는 것이라고 생각하면 될거 같다.
2. 시그널 3종을 따로 보면 안 되는 이유
옵저버빌리티에서 흔히 3대 시그널이라고 부르는 게 있다.
| 시그널 | 답하는 질문 | 형식 | 예시 |
|---|---|---|---|
| Metrics | "얼마나 자주/얼마?" | 시계열 숫자 | 초당 요청 수, P95 지연시간 |
| Logs | "그때 무슨 일이?" | 타임스탬프 + 텍스트 | "주문 생성 실패: DB timeout" |
| Traces | "어디서 시간이?" | 인과관계 있는 span 트리 | order → kitchen → delivery |
- P95: 전체 데이터나 요청 중 95%가 해당 값 이하에 속하는 통계 지점
- span: 트레이싱을 구성하는 가장 작은 작업의 단위이자 하나의 빌딩 블록(체크포인트)
처음엔 셋을 같이 봐야한다는 생각을 하지 못했다. Prometheus만 있어도 P95 latency 그래프는 보이니까. 그런데 그래프에서 스파이크 하나가 보였을 때 그 원인을 알려면 같은 시점의 로그가 필요했고, 로그에서 에러 메시지를 봤을 때 그게 어떤 요청 체인의 어느 서비스에서 발생한 건지 알려면 다시 트레이스가 필요했다.
결국 신호 하나하나보다 신호 사이를 왔다 갔다 할 수 있느냐가 필요하다는 걸 알게 됐다. 그래서 도구를 고를 때도 메트릭에서 같은 시점의 로그로, 거기서 다시 그 요청의 트레이스로 자연스럽게 넘어갈 수 있는지를 보게 되었다.
3. 통합 스택 vs SaaS — 왜 OSS를 골랐나
처음엔 단일 도구로 한 방에 풀 수 있는지부터 따져봤다. DataDog 같은 SaaS는 셋을 다 묶어 주고 셋업도 가장 단순해 보였으니까. 그런데 두 가지가 걸렸다.
첫째, 비용 구조. DataDog은 인프라 모니터링(호스트당 정액) + APM(호스트당 별도) + 로그 인제스션(GB당)이 따로 청구되는 구조라고 한다. dev/prod 분리에 트래픽 부하 테스트까지 시작하면 비용이 많이 들 것으로 예상되었다.
둘째, 벤더 종속. 코드에 DataDog Agent SDK를 박는 순간 갈아끼우기 어렵다는 얘기를 자주 봤다. 회사마다 쓰는 스택이 다르고 옵저버빌리티는 운영 조직 색깔이 가장 강하게 드러나는 영역이라 학습 자산이 한 SaaS에 묶이는 게 아깝게 느껴졌다.
그래서 눈에 들어온 게 Grafana Labs의 OSS 통합 스택이었다. Prometheus(메트릭) + Loki(로그) + Tempo(트레이스) + Grafana(시각화). Grafana가 단일 진입점이 된다는 점이 마음에 들었다.
- OSS: Open Source Software. 소스 코드가 공개돼 있고 무료로 쓰고 수정·재배포할 수 있는 소프트웨어
트레이드오프는 분명하다. 자체 운영 부담은 SaaS보다 크다. 다만 이 프로젝트의 학습 목표 자체가 운영 스택을 직접 다뤄 보는 데 있었기 때문에 그 운영 부담 자체가 학습 가치라고 봤다. 비용도 좀 줄이고.
4. 8개 컴포넌트와 시그널별 선택 근거
전체 스택은 8개 컴포넌트로 정리된다. 그중 셋(Prometheus / Grafana / AlertManager)은 kube-prometheus-stack 한 차트에 묶여 있어서 실제 Helm 릴리스는 6개다.
| # | 컴포넌트 | 역할 | 분류 | Helm 차트 (버전) |
|---|---|---|---|---|
| 1 | Prometheus | 메트릭 TSDB | 메트릭 | kube-prometheus-stack 84.5.0 |
| 2 | Grafana | 통합 시각화 UI | 시각화 | (위에 포함) |
| 3 | AlertManager | 알림 라우팅 | 알림 | (위에 포함) |
| 4 | Loki | 로그 저장소 | 로그 | loki 6.55.0 |
| 5 | Promtail | 로그 수집 DaemonSet | 로그 | promtail 6.17.1 |
| 6 | Tempo | 트레이스 저장소 | 트레이스 | tempo 1.24.4 |
| 7 | OTel Collector | 트레이스 수집/전달 | 트레이스 | opentelemetry-collector 0.153.0 |
| 8 | Kiali | 서비스 메시 시각화 | 서비스 메시 | kiali-server 2.25.0 |
메트릭 — Prometheus
많은 오픈소스 메트릭 도구 중에 Prometheus / Thanos / InfluxDB 셋을 비교 후보로 좁혔다. 세 도구를 고른 기준은 두 가지다. 첫째, 쿠버네티스 환경에서 메트릭 저장 옵션으로 가장 자주 거론된다. 둘째, Grafana 데이터소스로 모두 지원되어서 다른 신호(로그/트레이스)와의 통합 비용이 작다.
| 후보 | 포지셔닝 | 트레이드오프 |
|---|---|---|
| Prometheus | Pull 기반 단일 인스턴스 TSDB, K8s ServiceDiscovery 네이티브 | 단일 인스턴스 보존이 짧고 HA·장기 저장이 약함 |
| Thanos | Prometheus 위에 얹는 장기 저장(S3) + 글로벌 쿼리 레이어 | 단독으로 동작하지 않음. Prometheus가 전제이며 dev 단계엔 과스펙 |
| InfluxDB | 푸시 기반 독립 TSDB, IoT·고빈도 메트릭에 강함 | K8s 표준 패턴(ServiceMonitor·PrometheusRule) 부재. 수집기를 별도 구성해야 함 |
먼저 InfluxDB는 후보에서 뺐다. 직접 운영해 본 적은 없기도 하고, K8s 환경의 메트릭 수집 표준이 Prometheus Operator의 CRD(ServiceMonitor / PodMonitor / PrometheusRule) 중심으로 자리 잡아 있어 InfluxDB는 이 패턴과는 거리가 있어 보였다. 수집 대상과 알림 룰을 별도 도구로 관리해야 한다는 점이 옵저버빌리티 전체 스택을 GitOps 한 흐름에 묶으려는 이 프로젝트 방향과 맞지 않는다고 봤다.
남은 둘은 알고 보니 양자택일이 아니었다. Thanos가 Prometheus를 대체하는 게 아니라 위에 얹는 장기 저장/글로벌 쿼리 레이어라고 한다. 그래서 Prometheus와 Thanos 중 무엇을 쓰느냐가 아니라 지금 단계에서 Thanos까지 갈 필요가 있나?라는 생각이 들었다. 이 프로젝트는 dev 환경 기준 보존 기간이 며칠 내로 짧아도 상관 없을거라고 판단했고, 나중에 보존 기간을 늘리거나 멀티 클러스터 통합 쿼리가 필요해지면 그때 Prometheus 위에 Thanos를 추가하면 될꺼라고 생각했다.
그리고 ServiceMonitor / PodMonitor / PrometheusRule이 CRD로 제공돼서 메트릭 수집 대상과 알림 룰을 K8s manifest로 선언해서 앱 배포와 옵저버빌리티 설정이 같은 GitOps 흐름 위에 올라갈 수 있다. 거기에 kube-prometheus-stack 차트가 Prometheus + Grafana + AlertManager + 기본 ServiceMonitor 셋을 한 번에 묶어 준다고 해서 초기 셋업 부담도 가장 가벼웠다.
장기 저장이 약하다는 단점이 있지만 실제 운영환경이 아닌 사이드 플젝이라 보존 기간을 짧게 잡아도 됐다.
보존 기간을 늘리고 싶어지면 Prometheus 위에 Thanos나 Mimir를 추가하거나 EKS의 경우 AWS Managed Prometheus로 운영하는 방법이 있다고 한다.
# infra/monitoring/values.yaml
prometheus:
prometheusSpec:
retention: 7d
로그 — Loki + Promtail
Loki는 이번 프로젝트에서 처음 도입해 본 솔루션이다. ELK(Elasticsearch, Logstash, Kibana)와 고민하다 Loki 쪽으로 기울어진 가장 큰 이유는 앞 섹션에서 정한 방향과 정합성이 높았기 때문이다. 세 신호를 같은 시간축에서 단일 UI로 보고 trace_id 한 줄로 점프가 가능해야 한다는 기준에서 Loki는 같은 Grafana쪽 도구라 datasource 설정만으로 로그 → 트레이스, 트레이스 → 로그 양방향 점프가 잡힌다. LogQL의 쿼리도 PromQL과 비슷한데 Prometheus로 정했기 때문에 따로 익혀야 할 게 많지 않다는 점도 한몫했다.
기술 자료들을 조사하며 파악한 Loki의 가장 큰 매력은 로그 본문 전체를 풀텍스트 인덱싱하지 않고 라벨만 인덱싱하여 본문은 압축 저장한다는 점이었다. 모든 로그 본문을 인덱싱하는 Elasticsearch에 비해 저장 공간과 메모리를 크게 아낄 수 있다는 장점이 있었다.
현재 프로젝트의 예상 로그 사용 패턴을 그려보았을 때 namespace=shoong에서 trace_id=X인 로그처럼 특정 라벨과 ID 기반으로 범위를 좁혀 가며 디버깅하는 경우가 대부분일 것이라 생각했다. 따라서 굳이 무거운 풀텍스트 인덱싱 비용을 감당하기보다는 가볍고 핵심에 집중한 Loki가 현 상황에 더 적합한 선택지라고 판단했다.(만약 본문 텍스트 검색 자체가 핵심인 서비스라면 ELK가 여전히 더 좋은 대안이 될 것 같다)
운영 관리 측면에서도 차이가 있었다. ELK는 Filebeat → Logstash → Elasticsearch → Kibana로 컴포넌트가 4개 묶여 있고 Elasticsearch는 JVM 위에서 돌아 heap 튜닝과 메모리 압력이 운영 비중에서 큰 부분을 차지한다고 한다. Loki + Promtail은 컴포넌트가 2개이고, 둘 다 Go로 작성돼 메모리 풋프린트가 작다는 점이 강점으로 꼽힌다고. 거기에 이미 메트릭용으로 띄워 둔 Grafana UI를 그대로 재사용할 수 있어서 진입점도 늘지 않았다.
# infra/loki/values.yaml — SingleBinary 모드 (분산 컴포넌트 비활성화)
deploymentMode: SingleBinary
loki:
storage:
type: filesystem
singleBinary:
replicas: 1
persistence:
size: 2Gi
dev 환경 기준 SingleBinary 모드에 로컬 filesystem 백엔드로 시작했다. prod로 가면 S3 백엔드로 전환 + replication 도입이 다음 과제다.
트레이스 — Tempo + OTel Collector
분산 추적(Distributed Tracing) 분야는 이번 프로젝트를 진행하며 가장 생소했던 영역 중 하나였다. 트레이스 저장소 후보군으로 Jaeger, Zipkin, Tempo 등이 있었는데, 최종적으로 Tempo를 선택했다. 결정적인 이유는 앞서 구축한 메트릭(Prometheus) 및 로그(Loki)와 마찬가지로 Grafana 생태계의 도구라는 점이었다. 대시보드 한 곳에서 데이터소스(Datasource)를 묶어 로그를 보다가 관련 트레이스로 매끄럽게 이동할 수 있는 통합 환경이 매력적이었다. (사실 Jaeger의 마스코트 로고가 귀여워서 써보고 싶다는 생각이 들긴했다..ㅎ)
구축 과정에서 오픈텔레메트리(OpenTelemetry, 이하 OTel)를 접하게 되었는데, 처음에는 OTel 자체와 OTel Collector의 개념이 조금 헷갈리기도 했다. 결론적으로 애플리케이션 코드가 표준 규격(OTLP)으로 데이터를 내보내면, 이를 받아 처리해 줄 중간 대리인으로 OTel Collector를 구성했다.
단순히 앱이 Tempo로 직접 데이터를 쏘게 할 수도 있었지만, 찾아 보니 Collector를 아키텍처 중간에 배치했을 때 확실한 이점이 있었다.
벤더 종립성 확보 — 애플리케이션은 특정 솔루션이 아닌 개방형 표준(OTLP) 인터페이스로만 데이터를 던진다. 만약 추후 저장소를 Tempo에서 Jaeger나 다른 상용 APM(Datadog 등)으로 바꾸더라도, 앱 코드는 단 한 줄도 수정할 필요 없이 Collector의 설정만 변경하면 된다.
안정적인 데이터 가공 및 인프라 보호 — memory_limiter나 batch 같은 프로세서(Processor)를 파이프라인 중간에 끼워 넣을 수 있다. 트래픽이 폭발할 때 애플리케이션이 직접 무거운 전송 로직을 감당하다가 뻗는 불상사를 막고, 인프라 단에서 안전하게 버퍼링하여 전송 성능을 최적화해 준다.
# infra/otel-collector/values.yaml
config:
receivers:
otlp:
protocols:
grpc: { endpoint: "0.0.0.0:4317" }
http: { endpoint: "0.0.0.0:4318" }
processors:
memory_limiter:
{ check_interval: 1s, limit_percentage: 80, spike_limit_percentage: 25 }
batch: { timeout: 1s, send_batch_size: 1024 }
exporters:
otlp/tempo:
endpoint: "infra-tempo.monitoring.svc:4317"
tls: { insecure: true }
service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, batch]
exporters: [otlp/tempo]
비록 AI의 가이드를 받으며 시작한 낯선 셋업이었지만, 결과적으로 대규모 MSA 환경에서 필수적인 '느슨한 결합(Loose Coupling)'과 '전송 안정성'을 모두 챙긴 만족스러운 파이프라인을 구축할 수 있었다.
시각화 / 알림 — Grafana + AlertManager
Grafana는 Prometheus/Loki/Tempo를 모두 datasource로 붙일 수 있고, 그 결과 메트릭 패널에서 로그·트레이스로 점프하는 동선이 만들어진다. 단일 UI에서 세 신호를 같은 시간축으로 본다는 가치가 가장 컸다.
AlertManager는 Prometheus 진영 표준이라 별도 비교는 안 했다. PrometheusRule(K8s CRD) → AlertManager → Slack 흐름이 가장 단순하다.
서비스 메시 — Kiali
Istio가 이미 깔려 있는 환경에선 Kiali가 사실상 디폴트 선택이다. 별도 저장소를 두지 않고 Prometheus 메트릭을 그대로 재사용해서 서비스 그래프를 그린다. 같은 데이터를 두 번 저장하지 않는다는 점이 깔끔했다.
# infra/kiali/values-dev.yaml
external_services:
prometheus:
url: "http://infra-monitoring-kube-prom-prometheus.monitoring.svc:9090"
grafana:
enabled: true
in_cluster_url: "http://infra-monitoring-grafana.monitoring.svc"
5. 전체 데이터 흐름
선택한 컴포넌트들이 어떻게 연결되는지 본다. 신호별로 경로가 다르다.
[ 소스 (앱 Pod) ] [ 수집 / 저장 ] [ 시각화 / 알림 ]
shoong-* x 5
(order/kitchen/delivery/
notification/batch)
├── /metrics ────────▶ Prometheus (TSDB) ─────────▶ Grafana
│
├── stdout ────────▶ Promtail ──▶ Loki ───────▶ Grafana
│ (DaemonSet)
│
└── OTLP gRPC ────────▶ OTel Collector ──▶ Tempo ──▶ Grafana
Kiali ─(Prometheus 메트릭 재사용)─▶ 자체 UI
PrometheusRule 발화 ─▶AlertManager─▶ Slack화살표마다 프로토콜과 책임이 다르다.
| 화살표 | 방식 | 비고 |
|---|---|---|
| 앱 → Prometheus | HTTP pull | ServiceMonitor가 /metrics를 30초마다 scrape |
| 앱 → Promtail | 파일 tail | Promtail이 노드 /var/log/pods/* 감시 |
| 앱 → OTel Collector | gRPC push | OTLP 4317 포트, 앱이 능동적으로 전송 |
| Kiali → Prometheus | HTTP pull | 별도 저장소 없이 메트릭 재사용 |
| AlertManager → Slack | HTTPS webhook | AlertmanagerConfig CRD로 receiver/route 선언 |

6. 설치 — ArgoCD App-of-Apps로 통째로
직접 helm install을 치지 않는다. 모든 컴포넌트는 ArgoCD Application으로 선언되어 있고, app-of-apps 부트스트랩 후 자동 동기화된다.
- App-of-Apps 패턴: 여러 개의 쿠버네티스 애플리케이션 정의 파일들을 하나의 마스터 애플리케이션(Root App)으로 묶어, Git에 올리기만 하면 하위 앱들이 줄줄이 자동 설치·관리되도록 만드는 ArgoCD의 배포 설계 기법
# argocd/dev/infra-monitoring.yaml — kube-prometheus-stack 설치 Application
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: infra-monitoring
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "1"
spec:
sources:
- repoURL: https://prometheus-community.github.io/helm-charts
chart: kube-prometheus-stack
targetRevision: 84.5.0
helm:
valueFiles:
- $values/infra/monitoring/values.yaml
- $values/infra/monitoring/values-dev.yaml
- repoURL: https://github.com/shoong-delivery/shoong-gitops
targetRevision: HEAD
ref: values
destination:
namespace: monitoring
syncPolicy:
automated: { prune: true, selfHeal: true }
syncOptions: [CreateNamespace=true, ServerSideApply=true]
sync-wave로 설치 순서가 정해진다.
| Wave | 컴포넌트 | 이유 |
|---|---|---|
| 1 | infra-monitoring | Prometheus가 먼저 떠야 다른 컴포넌트가 메트릭 송신처 확보 |
| 2 | infra-loki, infra-tempo, infra-kiali | 저장소·뷰어 계층 (Prometheus 의존 가능) |
| 3 | infra-promtail, infra-otel-collector | 수집기 계층 (저장소가 떠 있어야 의미 있음) |

7. 앱 쪽에서 한 일 — 메트릭/로그/트레이스 송신
옵저버빌리티 도구들이 떠 있다고 데이터가 들어오는 건 아니다. 앱 코드에 세 가지를 박아야 한다.
트레이스 — @opentelemetry/sdk-node auto instrumentation
각 Node.js 서비스의 진입점에서 OTel SDK를 먼저 부트스트랩한다.
// shoong-order-api/src/instrumentation.ts
import { NodeSDK } from "@opentelemetry/sdk-node";
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-grpc";
const sdk = new NodeSDK({
traceExporter: new OTLPTraceExporter(),
instrumentations: [
getNodeAutoInstrumentations({
"@opentelemetry/instrumentation-fs": { enabled: false },
}),
],
});
sdk.start();
auto-instrumentations-node가 HTTP / Express / Prisma 호출을 자동으로 span으로 만들어 준다. 송신처는 환경변수로 주입한다.
# envs/dev/shoong-order.yaml — Helm values
env:
OTEL_SERVICE_NAME: shoong-order
OTEL_EXPORTER_OTLP_ENDPOINT: http://infra-otel-collector-opentelemetry-collector.monitoring.svc:4317
OTEL_TRACES_EXPORTER: otlp
OTEL_METRICS_EXPORTER: none # 메트릭은 Prometheus가 /metrics 스크레이핑
OTEL_LOGS_EXPORTER: none # 로그는 Promtail이 stdout 수집
OTel SDK는 세 신호를 다 내보낼 수 있지만, 메트릭과 로그는 이미 Prometheus/Promtail 경로가 있어 트레이스만 활성화했다.
메트릭 — prom-client 커스텀 메트릭
기본 시스템 메트릭(CPU/이벤트루프 등)은 collectDefaultMetrics()로 자동 수집되고, 비즈니스 이벤트는 직접 정의했다.
// shoong-order-api/src/metrics.ts
export const orderCreateTotal = new Counter({
name: "order_create_total",
help: "Total order creation attempts",
labelNames: ["result"] as const,
registers: [registry],
});
export const orderStatusCount = new Gauge({
name: "order_status_count",
help: "Current number of orders in each status",
labelNames: ["status"] as const,
registers: [registry],
});
export const orderOrphanCookedCount = new Gauge({
name: "order_orphan_cooked_count",
help: "Orders stuck in COOKED status without Delivery record",
registers: [registry],
});
/metrics 엔드포인트로 노출하면 ServiceMonitor가 30초마다 스크레이핑한다.
# charts/shoong-app/templates/servicemonitor.yaml — 자동 생성됨
spec:
selector:
matchLabels:
app: { { .Release.Name } }
endpoints:
- port: http
path: /metrics
interval: 30s
로그 — pino + trace_id mixin
로그에 trace_id를 자동으로 박는 게 5장에서 말한 correlation의 출발점이다. pino logger의 mixin으로 현재 active span을 읽어 trace_id를 모든 로그에 주입한다.
// shoong-order-api/src/logger.ts
import pino from "pino";
import { trace } from "@opentelemetry/api";
export const logger = pino({
level: process.env.LOG_LEVEL ?? "info",
base: { service: process.env.OTEL_SERVICE_NAME ?? "order-api" },
formatters: { level: (label) => ({ level: label }) },
timestamp: pino.stdTimeFunctions.isoTime,
mixin() {
const span = trace.getActiveSpan();
if (!span) return {};
const { traceId, spanId } = span.spanContext();
return { trace_id: traceId, span_id: spanId };
},
});
결과적으로 stdout으로 나가는 모든 로그가 다음 형태를 갖는다.
{
"level": "info",
"time": "2026-05-19T...",
"service": "shoong-order",
"trace_id": "a1b2...",
"span_id": "...",
"msg": "order created"
}
Promtail은 라벨 카디널리티 폭발을 피하기 위해 본문 안에 trace_id를 두고, LogQL에서 | json | trace_id="..."로 추출한다.
# infra/promtail/values.yaml — 라벨은 최소화
scrapeConfigs: |
- job_name: kubernetes-pods
pipeline_stages:
- cri: {}
relabel_configs:
- source_labels: [__meta_kubernetes_namespace]
target_label: namespace
- source_labels: [__meta_kubernetes_pod_label_app]
target_label: app
# ... pod, container, host 정도만 라벨로 승격
라벨에 trace_id를 넣지 않는 이유는 trace_id마다 새 stream이 생겨 Loki의 stream label limit(기본 15)을 즉시 넘기고 카디널리티가 폭발하기 때문이다. 검색은 본문(JSON) 파싱으로 한다.
8. Correlation — trace_id 한 줄로 세 신호 연결
Grafana datasource 설정에 양방향 점프 동선을 박아 뒀다.
Loki → Tempo (로그에서 트레이스로)
로그 패널에서 본문의 trace_id를 클릭하면 Tempo로 자동 점프한다.
# infra/monitoring/values.yaml — Grafana additionalDataSources
- name: Loki
type: loki
uid: loki
url: http://infra-loki-gateway.monitoring.svc:80
jsonData:
derivedFields:
- name: trace_id
matcherRegex: '"trace_id":"([a-f0-9]+)"'
url: "$${__value.raw}"
datasourceUid: tempo
urlDisplayLabel: "Tempo에서 trace 보기"
핵심은 matcherRegex다. pino mixin이 박아 둔 "trace_id":"..." 패턴을 정규식으로 추출해서 Tempo로 보낸다.
Tempo → Loki (트레이스에서 로그로)
반대 방향도 마찬가지다. Tempo의 span 디테일 화면에서 "Logs for this span" 버튼을 누르면 같은 trace_id로 Loki를 조회한다.
- name: Tempo
type: tempo
uid: tempo
url: http://infra-tempo.monitoring.svc:3200
jsonData:
tracesToLogsV2:
datasourceUid: loki
spanStartTimeShift: "-5m"
spanEndTimeShift: "5m"
filterByTraceID: true
customQuery: true
query: '{namespace="shoong"} | json | trace_id="$${__trace.traceId}"'
query에 들어간 LogQL이 결국 7장에서 본 JSON 로그를 trace_id로 좁혀 가져온다.
실제 동선
장애 디버깅 시나리오를 예로 들면 이렇게 흐른다.
1. AlertManager가 ShoongOrderCookingStuck 발화 → Slack 알림
2. Grafana 대시보드에서 같은 시각 order_status_count{status="COOKING"} 스파이크 확인 (메트릭)
3. Tempo Explore → 해당 시간대의 느린 trace 검색
4. waterfall에서 느린 span 클릭 → "Logs for this span"
5. Loki에 같은 trace_id로 검색된 로그 → DB 응답 지연 메시지 확인
6. 근본 원인 도달세 신호가 별도 도구에 흩어져 있을 때라면 1번에서 6번까지 가는 데 컨텍스트 스위칭이 다섯 번 일어났을 거다. 같은 Grafana UI 안에서 클릭 두 번이면 된다는 게 통합 스택의 진짜 가치였다.



9. 보는 법 — Grafana 단일 진입점
설치된 컴포넌트마다 자체 UI가 있지만(Prometheus, Kiali) 운영 시 진입점은 Grafana 하나로 모았다. Istio VirtualService로 외부 도메인을 연결한다.
# infra/istio-resources/dev/vs-grafana.yaml
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
name: vs-grafana
namespace: istio-system
spec:
hosts:
- grafana.internal.dev.shoong.cloud
gateways:
- istio-system/shoong-gateway
http:
- route:
- destination:
host: infra-monitoring-grafana.monitoring.svc.cluster.local
port: { number: 80 }
Prometheus와 Kiali도 같은 패턴으로 노출되어 있다 (prometheus.internal.dev.shoong.cloud, kiali.internal.dev.shoong.cloud).
Grafana datasource 4종
| Datasource | UID | 용도 |
|---|---|---|
| Prometheus | prometheus | 메트릭 패널, AlertManager 룰 평가 |
| Loki | loki | 로그 탐색, trace_id derivedField |
| Tempo | tempo | 트레이스 탐색, tracesToLogsV2 점프 |
| Alertmanager | - | 발화 중 알림 목록 (기본 포함) |

비즈니스 대시보드
현재 dev에는 비즈니스 흐름 한 종(shoong-business)이 ConfigMap으로 배포되어 있다.
# infra/monitoring-config/dev/dashboard-shoong-business.yaml (발췌)
metadata:
name: shoong-business-dashboard
labels:
grafana_dashboard: "1" # sidecar가 이 라벨 보고 자동 import
data:
shoong-business.json: |-
{ "title": "Shoong — Business", ... }
패널에 들어가는 PromQL은 7장에서 정의한 커스텀 메트릭을 그대로 쓴다. 위 캡처처럼 주문 생성률, 상태별 적체, 조리·배달 P50/P95, 알림 발송 추이까지 한 화면에서 본다.
# 시간당 주문 생성 (성공/실패)
sum by (result) (rate(order_create_total[5m])) * 3600
# 현재 상태별 주문 수
order_status_count
# Orphan COOKED (체인 호출 실패 신호)
order_orphan_cooked_count

Kiali — 메시 시각화
서비스 메시 관점의 시각화는 Kiali에서 본다. Prometheus 메트릭을 재사용하므로 별도 데이터 입력은 없다. shoong 네임스페이스의 5개 서비스가 mTLS 자물쇠와 함께 그래프로 그려진다.

10. 알림 — PrometheusRule → AlertManager → Slack
옵저버빌리티의 마지막 단계는 사람에게 도달하는 알림이다. Prometheus 룰이 발화 조건을 평가하고, AlertManager가 라우팅·그룹핑을 처리하고, Slack으로 떨어진다.
룰은 비즈니스 시그널 중심으로
쿠버네티스/노드 레벨 알림은 kube-prometheus-stack 기본 룰로 충분하다. 그 위에 비즈니스 흐름에서 의미 있는 룰을 PrometheusRule CRD로 추가했다. 세 그룹으로 묶여 있다.
| 그룹 | 룰 | 의미 |
|---|---|---|
| workflow | ShoongOrderPendingStuck / CookingStuck / DeliveringStuck / OrphanCooked | 주문 워크플로우가 어느 상태에 정체 |
| errors | ShoongOrderCreateFailureSpike / NotificationFailureSpike | 비즈니스 이벤트 실패율 급증 |
| performance | ShoongCookDurationHigh / DeliveryDurationHigh | 조리·배달 P95가 임계 초과 |
룰 하나의 형태는 이런 식이다.
# infra/monitoring-config/dev/prometheus-rule.yaml (발췌)
- alert: ShoongOrderCookingStuck
# COOKING 임계값 3분 + 배치 주기 1분 + 여유 6분 = 10분
expr: min_over_time(order_status_count{status="COOKING"}[10m]) > 0
for: 1m
labels:
severity: critical
service: kitchen-api
annotations:
summary: "주문이 COOKING으로 멈춤"
description: "10분간 COOKING 주문 수가 0이 된 적 없음 — 배치 미동작 또는 kitchen /complete 실패"
AlertManager — severity 기반 채널 라우팅
기본 receiver는 slack-observability이고, severity=critical 라벨이 붙으면 별도 채널(slack-critical)로 빠진다. Watchdog(liveness ping)은 null로 보내 노이즈를 차단한다.
# infra/monitoring-config/dev/alertmanager-config.yaml (발췌)
spec:
route:
receiver: slack-observability
groupBy: ["alertname", "namespace"]
groupWait: 30s
groupInterval: 5m
repeatInterval: 4h
routes:
- matchers:
- name: alertname
value: Watchdog
receiver: null-receiver
- matchers:
- name: severity
value: critical
receiver: slack-critical
continue: false
slack webhook URL은 평문으로 두지 않고 ExternalSecret으로 AWS Parameter Store에서 가져온다 (alertmanager-slack-webhook K8s Secret).
ArgoCD Notifications — 배포 이벤트는 별도 채널
PrometheusRule 발화와는 별개로, ArgoCD가 직접 발신하는 알림이 있다. Sync 성공/실패는 #alerts-deploy로, Health Degraded·자동 롤백은 #alerts-critical로 분리해 두었다 (infra/argocd-notifications/configmap.yaml).
| 발신 주체 | 시나리오 | 채널 |
|---|---|---|
| ArgoCD | Sync 성공/실패 | #alerts-deploy |
| ArgoCD | Health Degraded / 자동 롤백 | #alerts-critical |
| AlertManager | severity=critical (워크플로우 정체) | #alerts-critical |
| AlertManager | severity=warning (기본) | #alerts-observability |


'Project: Shoong-Delivery' 카테고리의 다른 글
| [테스트] Load Test와 Spike Test 찍먹해보기 (0) | 2026.05.28 |
|---|---|
| [보안] WAF · CloudFront OAC · IRSA · ESO — 보안 설계와 트레이드오프 (0) | 2026.05.25 |
| [AWS] Shoong Delivery 네트워크 설계 정리 (0) | 2026.05.20 |
| [자동화] terrafom apply 후 수동 작업 대신 자동화 스크립트 (0) | 2026.05.20 |
| [Terraform] shoong-delivery 테라폼 구조 및 회고 (0) | 2026.05.20 |