메트릭·로그와 연결하고 장애 훈련까지
Keycloak 로그인 한 번이 어디서 시간을 쓰는지 Span 단위로 보고, Collector와 Tempo를 일부러 멈춰 본 기록
OpenTelemetry(OTel)는 앱에서 트레이스·메트릭·로그를 만들고 모아서 원하는 저장소로 보내는 표준 규격과 도구 모음입니다. 저장소나 화면은 아니고, 데이터를 만들고 나르는 쪽을 맡습니다. kind 클러스터에 OpenTelemetry Operator 0.129.0(Collector 0.162.0)과 Grafana Tempo 3.1.1을 설치하고, 7편의 Keycloak과 Grafana에 트레이싱을 켰습니다. 그 결과 평소 약 40ms 걸리는 Keycloak 로그인 처리의 절반가량이 비밀번호 해시 계산(Argon2, 중앙값 22.4ms)이라는 것과, 재시작 직후 첫 로그인은 671ms까지 느려진다는 것을 Span 단위로 확인했습니다. 메트릭의 점(exemplar)에서 트레이스로, 트레이스에서 같은 요청의 로그(Loki)로 넘어가게 연결했고, 로그인 30번 기준 샘플링 100%와 10%의 저장량 차이(30개와 3개)를 쟀습니다. Collector와 Tempo를 각각 멈춰도 로그인은 모두 성공했고, 멈춘 동안의 트레이스도 큐와 재시도로 사라지지 않았습니다. 운영 환경에서 AWS(CloudWatch·X-Ray)의 OTLP 엔드포인트로 보내는 방법과 SigV4 인증도 함께 정리했습니다.
1~7편의 kind 클러스터(노드 3개, Envoy Gateway, kube-prometheus-stack, cert-manager 사설 CA HTTPS, Keycloak 2개 + PostgreSQL 2개)를 이어서 씁니다. 5편 뒤 지웠던 Loki와 Fluent Bit은 5편 설정 그대로 다시 설치했습니다. OpenTelemetry Operator 0.129.0, Collector 배포판 opentelemetry-collector-k8s 0.162.0, Tempo 차트 3.2.1(Tempo 3.1.1), Keycloak 26.8.2, Grafana 13.3.0 기준입니다. AWS로는 실제로 보내지 않고 설정 예시만 다루며, 매니페스트와 화면에 AWS 키를 넣지 않았습니다.
이번 편에서 만드는 구성
4편에서 메트릭(Prometheus), 5편에서 로그(Loki)를 모았습니다. 이번 편은 마지막 관측 신호인 트레이스를 붙입니다. 추적 대상은 따로 데모 앱을 만들지 않고 7편의 로그인 흐름을 그대로 씁니다. 브라우저 → Grafana → Keycloak → PostgreSQL로 이어지는 실제 요청이라, 트레이스가 무엇을 보여 주는지 확인하기 좋습니다.
| 구성 요소 | 네임스페이스 | 역할 |
|---|---|---|
| OpenTelemetry Operator | opentelemetry-operator-system | Collector 리소스를 보고 Deployment·Service를 만들어 줌 |
| OTel Collector | observability | Keycloak·Grafana가 보낸 트레이스를 받아 걸러 내고 Tempo로 전달 |
| Grafana Tempo | tracing | 트레이스 저장소. 트레이스로 RED 메트릭과 서비스 그래프도 만듦 |
| Keycloak(7편) | keycloak | 내장된 OpenTelemetry 트레이싱을 켬 |
| Grafana(4편) | monitoring | 자체 트레이스를 보내고, 세 신호를 한 화면에서 조회 |
| Loki·Fluent Bit(5편) | loki, logging | 트레이스 ID가 들어간 로그를 저장 |
글의 앞부분에서는 개념을 정리합니다. 트레이스가 메트릭·로그와 무엇이 다른지, OpenTelemetry가 정확히 어디까지 맡는지, 데이터는 어디서 보는지부터 설명합니다. 그다음 설치 → 트레이싱 켜기 → 샘플링 → 세 신호 연결 → 장애 훈련 순서로 진행하고, 마지막에 운영 환경(AWS)으로 보내는 방법과 SigV4를 다룹니다.
트레이스와 메트릭, 로그는 뭐가 다른가
한 줄로 줄이면 메트릭은 숫자로 된 전체 통계이고, 트레이스는 요청 하나의 상세 기록입니다. 로그는 그 사이에서 특정 순간에 앱이 남긴 문장입니다.
| 구분 | 메트릭 | 트레이스 | 로그 |
|---|---|---|---|
| 단위 | 시간에 따른 숫자(예: 30초마다 집계) | 요청 1건 | 이벤트 1줄 |
| 예시 | 로그인 p95 0.205초, 오류율 2% | 19:33:06 로그인: Keycloak 41.75ms 중 해시 21.69ms | Client 'grafana' ... full scope allowed |
| 답하는 질문 | 무엇이, 얼마나 나빠졌나 | 어디서, 왜 느렸나 | 그때 무슨 일이 있었나 |
| 데이터 크기 | 작음(요청이 늘어도 시리즈 수는 비슷) | 큼(요청 수에 비례) | 큼(로그 줄 수에 비례) |
| 보관 | 전부, 오래 | 샘플링해서 일부만, 짧게 | 필요한 만큼 |
| 알림 | 알림의 기준으로 적합 | 원인 분석용 | 특정 오류 감지에 보조로 |
비유하면 메트릭은 고속도로 전광판("서울→부산 평균 5시간, 정체 중")이고, 트레이스는 차 한 대의 블랙박스("대전 IC에서 40분 정체")입니다. 전광판으로 정체를 알아채고, 블랙박스로 정체 구간을 찾습니다.
운영에서는 세 신호를 순서대로 씁니다.
- 메트릭 알림: "Keycloak 로그인 p95가 2초를 넘었다"
- 트레이스로 원인 찾기: 느린 요청 몇 개를 열어 보니 DB 쿼리 Span만 유독 길다
- 로그로 확인: 같은 트레이스 ID의 로그에 "connection pool exhausted"가 찍혀 있다
이 흐름을 클릭 몇 번으로 이어 주는 것이 이번 편의 목표입니다. 메트릭 그래프의 한 점에 그 순간의 대표 트레이스 ID를 붙여 두는 것을 exemplar라고 하고, 트레이스에서 같은 파드·같은 시간대의 로그로 넘어가는 것을 trace-to-logs라고 합니다.
OpenTelemetry가 하는 일
트레이스의 기본 단위부터 정리합니다. 택배 배송 조회와 비슷합니다.
- Trace: 요청 하나의 전체 여정입니다. 송장번호 같은 트레이스 ID(32자리 16진수)가 붙습니다.
- Span: 여정 중 한 구간입니다. "Keycloak 로그인 처리 41.75ms", "SELECT USER_ENTITY 1.7ms"처럼 이름, 시작 시각, 걸린 시간, 부모 Span을 가집니다.
- Context propagation: 서비스가 다음 서비스를 부를 때 HTTP 헤더(
traceparent)에 트레이스 ID를 실어 보냅니다. 받는 쪽이 이 값을 이어받아야 서로 다른 서비스의 Span이 하나의 트레이스로 묶입니다.
OpenTelemetry는 이 데이터를 만들고 나르는 부분을 표준으로 정했습니다. 구성 요소는 네 가지입니다.
| 구성 요소 | 하는 일 | 이 실습에서 |
|---|---|---|
| API·SDK(계측) | 앱 안에서 Span을 만드는 언어별 라이브러리. 에이전트를 붙이는 자동 계측도 있음 | Keycloak(Java)과 Grafana(Go)에 이미 내장되어 있어 설정만 켬 |
| OTLP | 관측 데이터를 주고받는 표준 프로토콜(gRPC 4317, HTTP 4318) | Keycloak·Grafana → Collector → Tempo 모두 OTLP |
| Collector | 받고(receivers) → 가공하고(processors) → 내보내는(exporters) 중계 서버 | 소음 제거, 쿠버네티스 정보 붙이기, 샘플링, 큐·재시도 |
| Operator | 쿠버네티스에서 Collector 배포와 자동 계측 주입을 관리 | Collector 리소스 하나로 Deployment·Service 생성 |
저장소는 OpenTelemetry가 아닙니다. 트레이스는 Tempo나 Jaeger, 메트릭은 Prometheus, 로그는 Loki 같은 백엔드가 저장합니다. 앱을 OTel로 계측해 두면 저장소를 바꿀 때 앱 코드는 그대로 두고 Collector의 exporter 설정만 바꾸면 됩니다. 예전에는 관측 제품마다 전용 에이전트를 따로 깔아야 했는데, 지금은 주요 상용 제품과 AWS도 OTLP를 직접 받습니다. 이것이 "벤더 종속을 피한다"는 말의 실제 의미입니다.
OTel 데이터는 어디서 보나
OpenTelemetry 자체에는 화면이 없습니다. "OTel 로그·트레이스를 어디서 보나"의 답은 Collector가 어느 저장소로 보내느냐에 따라 달라집니다.
| 신호 | 저장소 | 보는 곳 |
|---|---|---|
| 트레이스 | Tempo(이번 편) | Grafana → Explore → Tempo |
| 로그 | Loki(5편) | Grafana → Explore → Loki |
| 메트릭 | Prometheus(4편) | Grafana 대시보드, Explore → Prometheus |
세 저장소를 모두 Grafana에 데이터 소스로 연결하므로 보는 곳은 Grafana 한 곳입니다. Grafana가 아닌 화면도 있습니다. 트레이스 전용 오픈소스인 Jaeger UI, Datadog·New Relic·Honeycomb 같은 상용 제품, AWS의 X-Ray와 CloudWatch가 모두 OTLP로 데이터를 받습니다.
OpenTelemetry는 로그도 다룹니다. Collector의 filelog 수신기가 5편의 Fluent Bit처럼 노드의 컨테이너 로그 파일을 읽을 수 있습니다. 운영에서는 두 방식 중 하나를 고릅니다.
- Fluent Bit은 로그, OTel은 트레이스: 이미 로그 수집이 안정적으로 도는 곳에서 흔한 구성입니다. 이 글도 이 방식입니다.
- OTel Collector 하나로 세 신호 모두: 에이전트 종류가 줄어 관리가 편합니다. 대신 Collector가 멈추면 모든 신호가 함께 끊기므로 Collector를 더 신경 써서 운영해야 합니다.
준비: Loki 재설치와 차트 받기
트레이스에서 로그로 넘어가는 것까지 보여 주려고, 5편 뒤 정리했던 Loki와 Fluent Bit을 5편 설정 그대로 다시 설치했습니다. Loki는 48초, Fluent Bit은 3초 만에 준비됐습니다(5편에서 늘려 둔 WSL inotify 한도가 남아 있었습니다).
Tempo와 OTel Operator 차트를 받을 때 문제가 하나 있었습니다. 차트 목록(index.yaml)은 받아지는데, 차트 파일 자체를 받는 단계에서 둘 다 같은 오류가 났습니다.
두 차트 저장소 모두 차트 파일을 GitHub 릴리스에 올려 두는 방식이라, GitHub 릴리스 다운로드가 504를 내는 동안에는 helm install이 실패합니다(github.com 접속 자체는 정상이었습니다). 두 프로젝트는 같은 차트를 OCI 레지스트리(ghcr.io)에도 올리고 있어서 그쪽에서 받았습니다.
helm pull oci://ghcr.io/open-telemetry/opentelemetry-helm-charts/opentelemetry-operator --version 0.129.0
운영 배포 파이프라인이 외부 차트 저장소에 직접 의존하면, 저장소 장애가 곧 배포 장애가 됩니다. 사내 레지스트리(Harbor, ECR 등)에 차트와 이미지를 복제해 두고 버전을 고정해서 설치하면 이런 장애와 "같은 버전인데 내용이 바뀌는" 문제를 함께 막을 수 있습니다.
Tempo 설치: 기본값에서 바꾼 것
Tempo 차트는 두 가지입니다. 단일 바이너리로 띄우는 tempo와 구성 요소를 나눠 띄우는 tempo-distributed입니다. 실습은 단일 바이너리를 썼습니다. 차트 3.2.1은 Tempo 3.1.1을 설치하며, 3.x부터는 ingester가 live-store로, compactor가 backend scheduler·worker로 바뀌었습니다.
기본값을 그대로 쓰면 안 되는 부분이 몇 가지 있었습니다.
| 항목 | 기본값 | 바꾼 값 | 이유 |
|---|---|---|---|
| persistence | 꺼짐 | 켜짐, 5Gi | 꺼져 있으면 파드가 재시작될 때 트레이스가 모두 사라짐 |
| retention | 24h | 72h | 주말을 넘긴 장애도 볼 수 있게. 로그·메트릭보다는 짧게 |
| reportingEnabled | true | false | 사용 통계를 Grafana Labs로 보내는 기본값 |
| receivers | Jaeger 4종 + OTLP | OTLP만 | Collector가 OTLP로만 보내므로 열린 포트를 줄임 |
| metricsGenerator | 꺼짐 | 켜짐 | 트레이스에서 RED 메트릭과 서비스 그래프를 만들어 Prometheus로 보냄 |
| resources | 없음 | 요청 100m/256Mi, 한도 1Gi | 다른 파드와 자원을 다투지 않게 |
reportingEnabled: false
retention: 72h
receivers:
otlp:
protocols:
grpc:
endpoint: "0.0.0.0:4317"
http:
endpoint: "0.0.0.0:4318"
metricsGenerator:
enabled: true
remoteWriteUrl: "http://kps-kube-prometheus-stack-prometheus.monitoring:9090/api/v1/write"
overrides:
defaults:
metrics_generator:
processors:
- service-graphs
- span-metrics
persistence:
enabled: true
size: 5Gi
serviceMonitor:
enabled: true
additionalLabels:
release: kps
metrics generator가 만든 메트릭을 받고 exemplar를 저장하려면 Prometheus 쪽 기능도 켜야 합니다. kube-prometheus-stack에서는 두 값을 추가합니다.
prometheusSpec:
enableFeatures:
- exemplar-storage
enableRemoteWriteReceiver: true
업그레이드 후 Prometheus 실행 인자에 --enable-feature=exemplar-storage와 --web.enable-remote-write-receiver가 붙은 것을 확인했습니다. Tempo 설치는 32초 걸렸습니다. Service에는 Jaeger 포트가 여전히 나열되는데, 차트의 Service 템플릿이 포트를 고정으로 적어 두었기 때문이고 실제로 듣는 포트는 OTLP뿐입니다.
Collector 설치: Operator와 배치 방식
OpenTelemetry Operator 차트는 기본값이 "cert-manager가 이미 설치되어 있다"는 가정입니다. Operator의 웹훅(Collector 리소스를 검사하는 API)에 인증서가 필요한데, 이를 cert-manager로 발급합니다. 6편에서 설치한 cert-manager가 그대로 쓰였습니다.
-n opentelemetry-operator-system --create-namespace \
-f 08-otel-operator-values.yaml --wait
kubectl -n opentelemetry-operator-system get certificate \
-o custom-columns=NAME:.metadata.name,READY:.status.conditions[0].status,NOT_AFTER:.status.notAfter
설치는 20초 걸렸고, cert-manager가 자체 서명 Issuer와 웹훅 인증서(만료 2027-02-06, 갱신 예정 2027-01-07)를 만들었습니다. 6편에서 만든 인증서 만료 알림 규칙은 클러스터의 모든 Certificate를 보기 때문에, 이 인증서도 자동으로 감시 대상이 됩니다.
Collector를 어떻게 배치할지가 운영에서 첫 번째 결정입니다.
| 방식 | 구성 | 장점 | 단점 |
|---|---|---|---|
| 에이전트(DaemonSet) | 노드마다 1개, 앱은 같은 노드의 Collector로 보냄 | 네트워크 홉이 짧고, 노드 로그·메트릭도 같이 수집 | 노드 수만큼 자원 사용, tail 샘플링 불가(트레이스가 노드마다 흩어짐) |
| 게이트웨이(Deployment) | 한곳에 모아 몇 개만 둠 | 샘플링·필터 정책을 한곳에서 관리, 외부로 나가는 출구 하나 | Collector가 단일 장애점이 되기 쉬움 |
| 둘 다 | 에이전트 → 게이트웨이 | 대규모 운영의 일반적 형태 | 구성 요소가 늘어남 |
이 실습은 게이트웨이 방식(Deployment) 1개로 시작했습니다. tail 샘플링은 한 트레이스의 모든 Span이 같은 Collector에 모여야 판단할 수 있어서, 게이트웨이를 2개 이상으로 늘리려면 앞단에 트레이스 ID를 기준으로 나눠 주는 load_balancing exporter 계층을 둬야 합니다.
kind: OpenTelemetryCollector
metadata:
name: otel
namespace: observability
spec:
mode: deployment
replicas: 1
serviceAccount: otel-collector
resources:
requests: { cpu: 100m, memory: 128Mi }
limits: { memory: 512Mi }
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: 20
k8s_attributes:
extract:
metadata: [k8s.namespace.name, k8s.pod.name, k8s.node.name, k8s.deployment.name, k8s.statefulset.name]
tail_sampling: { ... } # 샘플링 절에서 설명
batch:
send_batch_size: 1024
timeout: 5s
exporters:
otlp_grpc/tempo:
endpoint: tempo.tracing.svc:4317
tls: { insecure: true }
sending_queue: { enabled: true, queue_size: 5000 }
retry_on_failure: { enabled: true, max_elapsed_time: 300s }
service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, filter/noise, k8s_attributes, tail_sampling, batch]
exporters: [otlp_grpc/tempo]
- memory_limiter: 메모리 한도(512Mi)의 80%(로그에 limit_mib 409로 표시)에 닿으면 새 데이터를 거절해 OOMKilled를 막습니다. 처리기 목록의 맨 앞에 둡니다.
- k8s_attributes: 데이터를 보낸 파드의 IP로 네임스페이스·파드·노드 이름을 찾아 붙입니다. 파드 목록을 읽어야 하므로 pods·namespaces·nodes·replicasets 읽기 권한(ClusterRole)을 따로 만들어 줬습니다. 이 값이 있어야 트레이스에서 같은 파드의 로그로 넘어갈 수 있습니다.
- batch: 모아서 보내 네트워크 호출 수를 줄입니다. 처리기 목록의 맨 뒤에 둡니다.
- sending_queue·retry_on_failure: Tempo가 잠시 멈춰도 메모리 큐에 쌓아 두고 다시 보냅니다. 장애 훈련에서 실제로 확인합니다.
Collector 0.162.0 배포판에서는 구성 요소 이름이 밑줄 형식입니다(k8s_attributes, tail_sampling, load_balancing, exporter otlp_grpc·otlp_http). 인터넷의 예전 예시(k8sattributes, otlp)를 그대로 붙여 넣기 전에 components 명령으로 실제 이름을 확인하는 것이 안전합니다. 또 Operator 기본 이미지인 opentelemetry-collector-k8s 배포판에는 AWS SigV4 인증 확장이 들어 있지 않습니다. 이 내용은 AWS 절에서 다시 다룹니다.

설치를 마친 뒤 Headlamp에서 본 파드 목록입니다. Collector(observability), Tempo(tracing), Operator(opentelemetry-operator-system)와 다시 설치한 Loki·Fluent Bit이 모두 Running이고, Collector와 Tempo는 서로 다른 워커 노드에 있습니다.
운영 함정: 수집되지 않는 Collector 메트릭
Collector 리소스에 observability.metrics.enableMetrics: true를 넣으면 Operator가 Collector 자체 메트릭(받은·보낸·버린 Span 수, 큐 크기)을 위한 ServiceMonitor를 만들어 줍니다. 그런데 Prometheus에서 조회되지 않았습니다.
# app.kubernetes.io/component, app.kubernetes.io/name 등 — release 라벨 없음
kubectl -n monitoring get prometheus -o jsonpath='{.items[0].spec.serviceMonitorSelector}'
# {"matchLabels":{"release":"kps"}}
4편에서 설명한 대로 kube-prometheus-stack의 Prometheus는 release: kps 라벨이 붙은 ServiceMonitor만 읽습니다. Operator가 만든 ServiceMonitor에는 이 라벨이 없었습니다. 자동 생성을 끄고, Operator가 만든 모니터링용 Service(8888 포트)를 가리키는 ServiceMonitor를 직접 만들었습니다.
kind: ServiceMonitor
metadata:
name: otel-collector
namespace: observability
labels:
release: kps
spec:
selector:
matchLabels:
app.kubernetes.io/name: otel-collector-monitoring
endpoints:
- port: monitoring
interval: 30s
적용 후 Prometheus에서 otelcol_receiver_accepted_spans가 조회되기 시작했습니다(이 시점 약 22 Span/초). Collector는 관측 데이터의 통로라서, Collector 자신이 관측되지 않으면 "트레이스가 없는 것"과 "Collector가 버린 것"을 구분할 수 없습니다.
Keycloak과 Grafana에 트레이싱 켜기
Keycloak은 OpenTelemetry 트레이싱을 내장하고 있어서, 7편의 Keycloak 리소스에 두 항목만 추가하면 됩니다. 클러스터의 CRD를 kubectl explain keycloak.spec.tracing으로 확인한 필드 이름을 썼습니다.
spec:
telemetry:
endpoint: http://otel-collector.observability.svc:4317
protocol: grpc
serviceName: keycloak
tracing:
enabled: true
samplerType: traceidratio
samplerRatio: 1.0
kubectl -n keycloak patch keycloak keycloak --type merge --patch-file 08-keycloak-tracing-patch.yaml
tracing.serviceName과 tracing.resourceAttributes는 CRD 설명에 DEPRECATED로 표시되어 있어 공통 설정인 telemetry 쪽에 넣었습니다. 운영자는 이 값을 KC_TELEMETRY_*, KC_TRACING_* 환경 변수로 바꾸고, k8s.namespace.name=keycloak 속성도 스스로 붙였습니다. 파드 2개가 하나씩 교체되는 데 82초 걸렸습니다(7편에서 잰 롤링 업데이트와 같은 과정입니다).
앱 쪽(head) 샘플링 비율은 1.0으로 두어 전부 보내고, 무엇을 남길지는 Collector가 정하게 했습니다. 이유는 샘플링 절에서 설명합니다.
Grafana도 자체 트레이스를 보낼 수 있습니다. kube-prometheus-stack 값에 다음을 추가했습니다.
grafana.ini:
tracing.opentelemetry:
sampler_type: const
sampler_param: 1
tracing.opentelemetry.otlp:
address: otel-collector.observability.svc:4317
propagation: w3c
로그인을 몇 번 하고 Grafana의 Explore → Tempo에서 TraceQL로 Keycloak 로그인 처리 트레이스를 찾았습니다.

검색 결과 목록입니다. 같은 로그인 처리가 거의 매번 40ms 안팎으로 끝나는 것을 한눈에 볼 수 있습니다.
트레이스 하나를 열면 Span이 폭포 모양으로 펼쳐집니다. 아래는 19:33:06의 로그인 처리(Span 62개, JGroups 메시지 Span 포함)에서 주요 구간만 옮긴 것입니다.
keycloak LoginActionsService.authenticateForm 32.33ms
keycloak SELECT keycloak.LOGIN_FAILURE 0.46ms
keycloak PasswordCredentialProvider.isValid 22.16ms
keycloak Argon2PasswordHashProvider.encode 21.69ms
keycloak SELECT keycloak.OFFLINE_USER_SESSION 0.39ms
keycloak SELECT keycloak.OFFLINE_CLIENT_SESSION 0.39ms
keycloak DefaultKeycloakTransactionManager.commit 7.31ms

왼쪽 목록에서 트레이스 ID를 누르면 오른쪽에 폭포 그림이 열립니다. PasswordCredentialProvider.isValid 아래 Argon2PasswordHashProvider.encode 막대가 전체의 절반을 차지하고, 오른쪽 위의 "Logs for this trace"를 누르면 같은 트레이스의 로그로 넘어갑니다.
DB 쿼리는 하나하나가 1ms도 걸리지 않고, 전체 41.75ms 중 절반 이상(21.69ms)이 비밀번호 해시 계산(Argon2)입니다. 2시간 동안 모인 Argon2 Span 57개를 보면 최소 19.3ms, 중앙값 22.4ms로 거의 일정했습니다. Argon2는 비밀번호 대입 공격을 어렵게 하려고 일부러 CPU와 메모리를 많이 쓰게 만든 알고리즘입니다. 그래서 로그인이 몰리면 Keycloak의 CPU가 먼저 부족해지고, CPU 한도를 너무 낮게 잡으면 로그인 지연이 바로 늘어납니다. 메트릭만 봤다면 "로그인이 0.04초 걸린다"까지만 알았을 내용을 트레이스가 구간별로 보여 준 사례입니다. 커밋 단계의 INSERT는 7편에서 켠 영구 사용자 세션이 DB에 저장되는 부분입니다.
같은 로그인 처리 트레이스를 시간순으로 늘어놓으면, 트레이싱을 켜려고 Keycloak을 교체한 직후의 첫 로그인은 671ms, 그다음은 77ms·67ms였고 그 뒤로는 35~58ms에서 안정됐습니다(중간에 한 번 226ms, 이때 Argon2가 65.1ms). Java 기반 서버는 재시작 직후 코드가 최적화(JIT)되기 전이라 첫 요청들이 느립니다. 롤링 업데이트 중에는 새 파드로 넘어간 사용자가 이 느린 구간을 겪으므로, 지연 알림의 기준을 정할 때 재시작 직후 값을 구분해서 보고, 필요하면 준비(readiness) 전에 예열 요청을 보내는 방법을 검토합니다.
운영 함정: 끊어진 트레이스와 배경 소음
기대와 다른 점이 두 가지 있었습니다.
첫째, Grafana와 Keycloak이 한 트레이스로 이어지지 않았습니다. Grafana의 로그인 콜백 처리(HTTP GET /login/:name) 537ms 중 authn.oauth.Authenticate가 458ms였는데, 그 아래에 Keycloak을 호출하는 Span이 없었습니다. 같은 시각 Keycloak의 토큰 발급(POST .../token, 23.5ms)과 userinfo(6~9ms)는 Keycloak을 루트로 하는 별도 트레이스로 잡혔습니다. Tempo가 트레이스로 그린 서비스 그래프(Explore → Tempo → Service Graph)를 보면 차이가 더 분명합니다. user→grafana, user→keycloak, keycloak→keycloak-db-rw 간선이 있고, Grafana가 데이터 소스를 조회하는 grafana→loki, grafana→prometheus, grafana→tempo 간선도 있지만, grafana→keycloak 간선만 없습니다.

Tempo의 Service Graph 화면입니다. grafana에서 loki·prometheus·tempo로 가는 화살표는 있지만 grafana에서 keycloak으로 가는 화살표는 없고, user에서 keycloak으로 따로 들어갑니다.
Grafana 트레이스에는 이 호출의 클라이언트 Span이 없고, Keycloak 쪽은 부모 없이 새 트레이스로 시작했습니다. Grafana가 데이터 소스를 부를 때는 트레이스가 이어지므로 Grafana 전체가 전파를 못 하는 것은 아니고, OAuth 토큰 교환에 쓰는 HTTP 클라이언트만 traceparent 헤더를 싣지 않는 것으로 보입니다. 받는 쪽(Keycloak)이 계측되어 있어도, 호출하는 쪽 클라이언트가 계측되어 있지 않으면 트레이스는 거기서 끊깁니다.
트레이스가 서비스 사이에서 이어지려면 호출 경로의 모든 구간이 같은 전파 방식(보통 W3C traceparent)을 지원해야 합니다. 직접 만든 앱은 HTTP 클라이언트·DB 드라이버·메시지 큐 라이브러리까지 계측되어 있는지 확인하고, 이번 Grafana처럼 손댈 수 없는 구간은 "여기서 끊긴다"는 사실을 문서로 남겨 둡니다. 서비스 그래프에서 기대한 간선이 없는 것으로 끊긴 곳을 찾을 수 있습니다.
둘째, 아무도 로그인하지 않아도 트레이스가 계속 쌓였습니다. 5분 동안 생긴 트레이스 1,182개를 루트 이름으로 묶어 보니 대부분 사용자 요청이 아니었습니다.
| 서비스 | 루트 Span | 개수 | 정체 |
|---|---|---|---|
| grafana | open session | 280 | Grafana 내부 DB 세션 |
| grafana | grpcutils.Authenticate | 166 | Grafana 내부 인증 호출 |
| keycloak | SELECT keycloak.JGROUPS_PING | 94 | 7편 클러스터링(jdbc-ping)이 몇 초마다 DB로 동료 파드 확인 |
| grafana | initialization·validation·bootstrap | 각 54 | Grafana 내부 작업 |
Keycloak 클러스터 탐색 쿼리는 사용자 요청과 무관하므로 Collector의 filter 처리기로 받자마자 버렸습니다. 처음에는 SELECT만 막았는데 exemplar와 서비스 그래프에 INSERT·DELETE keycloak.JGROUPS_PING이 계속 보여서 정규식으로 넓혔습니다.
filter/noise:
error_mode: ignore
traces:
span:
- 'IsMatch(name, "^(SELECT|INSERT|UPDATE|DELETE) keycloak\\.JGROUPS_PING$")'
유휴 상태 120초 기준으로 Tempo에 보낸 Span이 2,143개에서 1,390개로 35% 줄었고, 넓힌 뒤 120초 동안 JGROUPS 트레이스는 0개였습니다. 남은 대부분은 Grafana 내부 작업입니다. 운영에서는 이런 소음이 저장 비용의 대부분을 차지할 수 있으므로, 처음 도입할 때 "아무도 안 쓸 때 몇 Span이 들어오는가"를 먼저 재 보는 것이 좋습니다.
샘플링: 전부 저장하면 생기는 일
트레이스 양은 요청 수에 비례합니다. 로그인 처리 한 번이 Keycloak에서만 Span 62개였으니, 초당 수백 건의 요청이 있는 서비스에서 전부 저장하면 저장 비용이 메트릭과 비교할 수 없게 커집니다. 그래서 일부만 남기는 샘플링이 필수입니다.
| 방식 | 판단 시점·위치 | 장점 | 단점 |
|---|---|---|---|
| head 샘플링 | 요청이 시작될 때, 앱에서(예: Keycloak samplerRatio) | 앱과 네트워크 부담까지 줄어듦, 단순함 | 오류가 날지 미리 알 수 없어 오류 트레이스도 같은 비율로 버려짐 |
| tail 샘플링 | 트레이스가 끝난 뒤, Collector에서 | 오류·느린 요청은 모두 남기고 정상 요청만 줄일 수 있음 | 트레이스가 끝날 때까지 Collector 메모리에 들고 있어야 함, 트레이스 단위로 한곳에 모아야 함 |
이 실습은 앱에서는 전부 보내고(head 1.0), Collector에서 tail 샘플링으로 정했습니다. 정책은 위에서부터 하나라도 맞으면 남깁니다.
decision_wait: 10s # 트레이스가 끝나길 10초 기다린 뒤 판단
num_traces: 20000 # 판단 대기 중인 트레이스를 메모리에 들고 있을 최대 개수
policies:
- name: keep-errors
type: status_code
status_code: { status_codes: [ERROR] }
- name: keep-slow
type: latency
latency: { threshold_ms: 500 }
- name: sample-rest
type: probabilistic
probabilistic: { sampling_percentage: 100 }
나머지 정책의 비율만 바꿔 로그인을 30번씩 해 봤습니다.
| 비율 | Tempo에 남은 로그인 트레이스 | 그 구간 sample-rest 판정 |
|---|---|---|
| 100% | 30개 | 모두 sampled |
| 10% | 3개 | sampled 31, not_sampled 299(약 9.4%) |
비율대로 줄어드는 것을 확인했습니다. 첫 측정 구간에서는 오류 정책이 트레이스 8개, 느림 정책이 12개를 따로 남겼는데, 비율을 10%로 낮춰도 이런 트레이스는 그대로 모두 남습니다. 이것이 tail 샘플링을 쓰는 이유입니다. 실습에서는 화면 확인을 위해 100%로 되돌려 두었습니다.
운영에서는 나머지 비율을 10% 안팎에서 시작하고, 오류·느린 요청 정책을 따로 둡니다. decision_wait보다 오래 걸리는 트레이스는 일부 Span만으로 판단되므로, 서비스의 가장 긴 요청 시간보다 넉넉하게 잡습니다. num_traces가 부족하면 판단 전에 트레이스가 밀려나므로 Collector 메모리 한도와 함께 정합니다. 또 샘플링으로 버린 요청은 트레이스로는 볼 수 없으므로, 정확한 요청 수와 오류율은 샘플링 전의 데이터로 만든 메트릭으로 봐야 합니다. 이 실습의 Tempo span metrics는 샘플링을 거친 뒤의 트레이스로 만들어지므로, 비율을 10%로 낮추면 요청 수도 함께 줄어 보입니다. 앱 자체 메트릭을 쓰거나, Collector의 span_metrics connector(이 배포판에 포함)를 샘플링 앞에 두어 만듭니다.
세 신호 연결: 메트릭 → 트레이스 → 로그
Grafana에 Tempo 데이터 소스를 추가하면서 로그·메트릭과의 연결을 함께 설정했습니다. kube-prometheus-stack의 Grafana는 grafana_datasource: "1" 라벨이 붙은 ConfigMap을 사이드카가 읽어 재시작 없이 등록합니다(5편과 같은 방식).
- name: Tempo
type: tempo
uid: tempo
url: http://tempo.tracing.svc:3200
jsonData:
tracesToLogsV2:
datasourceUid: loki
spanStartTimeShift: "-1m"
spanEndTimeShift: "1m"
filterByTraceID: true
tags:
- key: k8s.namespace.name
value: namespace
- key: k8s.pod.name
value: pod
serviceMap:
datasourceUid: prometheus
nodeGraph:
enabled: true
tags는 Span의 리소스 속성(Collector의 k8s_attributes가 붙인 값)을 5편 Loki의 라벨 이름으로 바꿔 줍니다. Keycloak 트레이스에는 k8s.pod.name: keycloak-0, Grafana 트레이스에는 k8s.deployment.name: kps-grafana와 파드 이름이 붙어 있었습니다.
로그 쪽 재료는 이미 있었습니다. Keycloak은 트레이싱을 켜면 로그 형식에 트레이스 ID를 자동으로 넣고, Grafana 로그에는 원래 traceID=가 들어 있습니다.
2026-11-08 10:14:39,192 traceId=e5bc06a6…, parentId=2a4e4e72…, spanId=a7a1576d…, sampled=true WARN [...TokenEndpoint.full-scope-allowed] ...
# Keycloak 로그(요청 밖의 줄)
2026-11-08 10:14:04,841 traceId=, parentId=, spanId=, sampled= INFO [...KeycloakApplication] Bootstrap completed ...
# Grafana 로그
logger=backgroundsvcs.managerAdapter traceID=602f1ae2… level=info msg=starting module=router
Loki 데이터 소스에 derivedFields를 추가해 로그 줄의 트레이스 ID를 Tempo 링크로 만들었습니다. ConfigMap 안에서 $는 Grafana 환경 변수 치환 기호라서 $$로 적어야 합니다.
derivedFields:
- name: TraceID
matcherRegex: '(?:traceId|traceID)=(\w+)'
datasourceUid: tempo
url: '$${__value.raw}'
urlDisplayLabel: 트레이스 보기
적용 후 Explore → Loki에서 {namespace="keycloak"} |~ "traceId=[0-9a-f]+"로 검색하면 로그 줄의 traceId=… 부분이 강조되고 링크가 됩니다. 토큰 요청 로그(19:34:22)의 링크를 누르자 오른쪽에 해당 트레이스가 열렸습니다. Keycloak의 POST .../token 처리 8.95ms, Span 16개였고, 그중 토큰 발급 처리(TokenEndpoint.processGrantRequest)가 6.58ms였습니다. 반대로 트레이스 화면의 "Logs for this trace" 버튼을 누르면 같은 트레이스 ID의 로그로 돌아갑니다.


위는 Loki에서 traceId=…가 강조된 Keycloak 로그이고, 아래는 그 링크를 눌러 연 토큰 발급 트레이스입니다. 로그 원문에는 뒤에서 설명할 full-scope-allowed 경고도 그대로 보입니다.
마지막으로 메트릭 → 트레이스 연결(exemplar)입니다. Tempo metrics generator가 만든 span metrics에는 exemplar가 붙고, Prometheus는 이를 exemplar-storage 기능으로 저장합니다. Grafana의 기본 Prometheus 데이터 소스에 exemplar 링크를 켜는 값은 kube-prometheus-stack에 있습니다.
sidecar:
datasources:
exemplarTraceIdDestinations:
datasourceUid: tempo
traceIdLabelName: traceID
처음에는 traceIdLabelName: trace_id로 적었습니다. Prometheus의 /api/v1/query_exemplars로 실제 exemplar를 조회해 보니 라벨 이름이 traceID였습니다(시리즈 163개, exemplar 489개). 라벨 이름이 다르면 오류 없이 링크만 생기지 않으므로, 설정 전에 실제 데이터의 라벨 이름을 확인하는 것이 빠릅니다.
연결 후 Grafana의 데이터 소스 상태 확인에서 Tempo는 "Data source is working", Loki는 "Data source successfully connected."를 반환했습니다. span metrics로 본 10분 p95는 로그인 처리 0.205초, 토큰 발급 0.031초, userinfo 0.015초였습니다. 이 값들은 앱 코드에 메트릭을 하나도 추가하지 않고 트레이스에서 만든 것입니다.
트레이스 ID가 붙은 Keycloak 로그를 보다가 토큰 요청마다 TokenEndpoint.full-scope-allowed 경고가 찍히는 것을 발견했습니다. 메시지는 7편에서 만든 grafana 클라이언트에 "Full scope allowed" 스위치가 켜져 있고, 이 스위치는 더 이상 권장되지 않아(deprecated) 앞으로 제거될 예정이니 끄고 클라이언트에 필요한 역할만 client role scope로 설정하라는 내용입니다. 이 스위치가 켜져 있으면 토큰에 사용자의 모든 역할이 담깁니다. Grafana는 groups 값만 쓰므로, 끄고 필요한 범위만 매핑하는 것이 최소 권한 원칙에도 맞습니다. 관측 신호를 연결해 두면 이런 설정 문제도 함께 드러납니다.
장애 훈련: Collector나 Tempo가 멈추면 앱도 멈추나
관측 도구가 서비스를 멈추게 하면 안 됩니다. 로그인 시간을 재면서 Collector와 Tempo를 차례로 0개로 줄여 봤습니다. 로그인은 7편의 브라우저 흐름을 curl로 재현한 것(Grafana → Keycloak 로그인 → 콜백 → 사용자 정보)입니다.
kubectl -n observability patch otelcol otel --type merge -p '{"spec":{"replicas":0}}'
# 훈련 B: Tempo를 0개로
kubectl -n tracing scale sts tempo --replicas=0
| 상황 | 로그인 5회 | 소요 시간(ms) | 관측 데이터 |
|---|---|---|---|
| 정상(기준) | 5/5 성공 | 503, 476, 441, 479, 424 | - |
| A. Collector 0개(39초) | 5/5 성공 | 433, 470, 407, 399, 463 | 중단 중 로그인 트레이스 5개 모두 복구 후 도착 |
| B. Tempo 0개(46초) | 5/5 성공 | 414, 407, 406, 421, 398 | 중단 중 로그인 트레이스 5개 모두 복구 후 조회됨 |
두 경우 모두 로그인 시간은 기준과 차이가 없었습니다. 앱의 OpenTelemetry SDK는 Span을 백그라운드에서 모아 보내기 때문에, 보낼 곳이 없어도 요청 처리를 기다리게 하지 않습니다.
훈련 A에서 Grafana는 10초마다 오류 로그를 남겼고, Keycloak 로그에는 내보내기 오류 줄이 없었습니다. Collector가 돌아온 뒤 중단 시간대의 Keycloak 로그인 트레이스 5개가 모두 Tempo에 들어왔습니다. SDK가 메모리 버퍼에 들고 있다가 다시 보낸 것으로 보입니다. 다만 이 버퍼는 크기가 정해져 있어서, 중단이 길어지거나 요청이 많으면 넘친 만큼은 사라집니다.
훈련 B에서는 Collector의 큐가 일을 했습니다. Tempo가 멈추자 보낸 Span 수(otelcol_exporter_sent_spans)가 2,345에서 멈추고 큐 크기(otelcol_exporter_queue_size)가 0에서 12로 늘었으며, 로그에는 재시도 메시지가 찍혔습니다. Tempo가 돌아오고 60초 뒤 보낸 Span은 6,006으로 늘었고 큐는 3으로 줄었습니다. 중단 시간대의 로그인 트레이스 5개도 모두 조회됐습니다.
Collector의 sending_queue는 기본이 메모리 큐라서, 저장소가 멈춘 사이 Collector까지 재시작되면 쌓아 둔 데이터가 사라집니다. 유실을 줄여야 한다면 file_storage 확장으로 디스크 큐를 쓰고, retry_on_failure.max_elapsed_time(이 실습은 300초)보다 긴 저장소 장애는 유실로 이어진다는 것을 전제로 둡니다. 큐가 가득 차면 memory_limiter와 함께 새 데이터를 거절하므로, 큐 크기와 거절 수에 알림을 겁니다.
모니터링: Collector 자신을 지켜보기
Collector 메트릭이 Prometheus로 들어오므로, 운영에서 지켜볼 값과 쿼리를 정리했습니다. 메트릭 이름은 이 실습의 Collector 0.162.0에서 확인한 이름입니다.
| 보는 것 | 메트릭 | 의미 |
|---|---|---|
| 받은 양 | otelcol_receiver_accepted_spans | 앱이 보낸 Span. 갑자기 0이면 앱이나 네트워크 문제 |
| 거절 | otelcol_receiver_refused_spans | memory_limiter 등이 거절. 0이 아니면 Collector가 버거움 |
| 보낸 양 | otelcol_exporter_sent_spans | 저장소로 보낸 Span. 받은 양과 함께 보면 샘플링·필터 효과 |
| 보내기 실패 | otelcol_exporter_send_failed_spans | 재시도까지 실패한 Span = 유실. 실패가 한 번도 없으면 메트릭 자체가 아직 없음 |
| 큐 | otelcol_exporter_queue_size | 저장소가 느리거나 멈추면 늘어남 |
| 필터 | otelcol_processor_filter_spans.filtered | 소음 필터로 버린 Span(Prometheus 3에서는 이름에 점이 그대로 남음) |
| 샘플링 판정 | otelcol_processor_tail_sampling_count_traces_sampled | 정책별로 남긴·버린 트레이스 수 |
sum(rate(otelcol_exporter_sent_spans[5m])) / sum(rate(otelcol_receiver_accepted_spans[5m]))
# 큐가 5분 넘게 쌓이는 중 → 저장소 문제 의심
max_over_time(otelcol_exporter_queue_size[5m]) > 100
# 유실 발생
sum(increase(otelcol_exporter_send_failed_spans[10m])) > 0
자원 사용량도 적어 둡니다. 유휴에 가까운 상태에서 메모리(working set)는 Tempo 142.5MiB, Collector 25.1MiB, Operator 36.5MiB였고, 다시 설치한 Loki는 171.6MiB, Fluent Bit 3개는 합계 18.6MiB였습니다. 이번 편의 모든 구성 요소를 띄운 뒤 WSL 메모리 사용은 23GiB 중 6.6GiB였습니다.
운영 환경으로: AWS OTLP 엔드포인트
OTel로 계측해 두면 저장소를 바꿀 때 Collector의 exporter만 바꿉니다. AWS는 CloudWatch와 X-Ray가 OTLP를 직접 받는 엔드포인트를 제공합니다. 앱이나 Collector가 OTLP로 보내면 AWS 전용 형식으로 바꾸지 않아도 그대로 들어갑니다.
| 신호 | 엔드포인트 | 저장·조회 |
|---|---|---|
| 트레이스 | https://xray.<리전>.amazonaws.com/v1/traces | X-Ray, CloudWatch Transaction Search, Application Signals |
| 로그 | https://logs.<리전>.amazonaws.com/v1/logs | CloudWatch Logs |
| 메트릭 | https://monitoring.<리전>.amazonaws.com/v1/metrics | CloudWatch(PromQL 조회 가능) |
AWS 문서는 새로 만드는 관측 구성에 "OTel SDK → OTel Collector → CloudWatch OTLP 엔드포인트" 경로를 권장하며, 이 경로는 EKS, ECS, EC2, 온프레미스에서 똑같이 동작한다고 설명합니다. https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/GettingSetup.html
이 실습 구성에서 Tempo 대신 AWS로 보낸다면 exporter와 인증 확장만 바뀝니다. 아래는 구성 예시이며 이 실습에서 적용하지 않았습니다. 키는 어디에도 적지 않습니다.
extensions:
sigv4auth/xray:
region: ap-northeast-2
service: xray
exporters:
otlphttp/xray:
traces_endpoint: https://xray.ap-northeast-2.amazonaws.com/v1/traces
auth:
authenticator: sigv4auth/xray
service:
extensions: [sigv4auth/xray]
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, k8s_attributes, tail_sampling, batch]
exporters: [otlphttp/xray]
앞에서 확인했듯 이 실습의 Collector 이미지(opentelemetry-collector-k8s 0.162.0)에는 sigv4auth 확장이 없습니다. AWS로 SigV4 인증을 해서 보내려면 확장이 들어 있는 contrib 배포판이나 AWS가 배포하는 ADOT(AWS Distro for OpenTelemetry) Collector로 이미지를 바꿔야 합니다. 예시의 구성 요소 이름도 쓰는 배포판·버전의 components 출력으로 다시 확인합니다.
- ADOT: AWS가 배포하는 OTel Collector와 SDK입니다. 기본 Collector에 X-Ray·CloudWatch 전송 기능과 SigV4 인증을 넣어 둔 버전입니다.
- CloudWatch 에이전트: 이 에이전트도 로컬 4317(gRPC)·4318(HTTP)로 OTLP를 받아 CloudWatch로 넘길 수 있습니다.
- 비용: 받은 메트릭·로그·트레이스 양만큼 요금이 나오므로, 이 글의 소음 필터와 tail 샘플링을 Collector에서 먼저 적용한 뒤 보내는 것이 비용에 직접 영향을 줍니다.
SigV4와 자격 증명
AWS로 보내는 데이터는 인증이 필요합니다. AWS 문서는 트레이스 OTLP 엔드포인트의 인증으로 SigV4(권장)와 Bearer 토큰(API 키) 두 가지를 안내합니다. https://docs.aws.amazon.com/xray/latest/devguide/xray-opentelemetry.html
SigV4(Signature Version 4)는 AWS API 요청에 서명해서 "누가 보냈는지"와 "중간에 바뀌지 않았는지"를 증명하는 방식입니다. AWS CLI, SDK, Collector가 AWS로 보내는 요청 대부분이 이 방식을 씁니다. 핵심은 비밀 키를 보내지 않는다는 점입니다.
- 자격 증명은 Access Key ID(공개 이름표)와 Secret Access Key(비밀)로 이루어집니다.
- 요청을 보낼 때 Secret Key를 그대로 보내지 않고, Secret Key로 요청 내용을 서명(HMAC-SHA256)한 값만 붙입니다.
- 서명에는 요청 메서드·경로·주요 헤더·본문 해시와 함께 시각, 날짜, 리전, 서비스 이름이 들어갑니다. 그래서 오래된 요청을 가로채 다시 보내거나, 서울 리전 X-Ray용 서명을 다른 리전·다른 서비스에 쓰는 것이 막힙니다.
- AWS는 같은 방식으로 서명을 다시 계산해 비교하고, 일치할 때만 요청을 받아들입니다. 요청을 누가 엿봐도 Secret Key는 알 수 없고, 내용을 바꾸면 서명이 맞지 않습니다.
| 구분 | SigV4 | Bearer 토큰(API 키) |
|---|---|---|
| 보내는 것 | 요청마다 새로 계산한 서명 | 토큰 문자열 그대로 |
| 유출 시 | 서명 하나로는 다른 요청에 재사용이 어려움 | 토큰만 있으면 누구나 사용 가능 |
| 자격 증명 | 보통 IAM 역할의 단기 자격 증명(자동 만료·갱신) | 장기 키인 경우가 많음 |
| 구현 | 복잡해서 SDK나 Collector 확장이 대신 처리 | 헤더 한 줄이라 간단 |
| 쓰는 곳 | AWS 안의 워크로드(EKS, ECS, EC2) | AWS SDK나 단기 자격 증명을 쓸 수 없는 곳(다른 클라우드 등) |
EKS에서는 Collector 파드의 서비스 계정에 IAM 역할을 연결합니다(IRSA 또는 EKS Pod Identity). 그러면 Collector가 단기 자격 증명을 자동으로 받아 SigV4로 서명하므로, 키를 매니페스트나 Secret에 넣을 필요가 없습니다. 역할에는 트레이스를 보내는 데 필요한 권한만 줍니다.
로컬 kind 클러스터에서 AWS로 보내려면 결국 장기 키를 어딘가에 둬야 합니다. 이 실습에서 AWS로 실제로 보내지 않고 설정 예시로만 다룬 이유입니다.
운영 체크리스트
이번 실습에서 확인한 것과 운영으로 옮길 때 추가로 할 일을 정리합니다.
- 차트와 이미지는 사내 레지스트리에 복제하고 버전을 고정한다. 외부 저장소 장애(이번의 504)가 배포 장애로 이어지지 않게 한다.
- Tempo는 persistence를 켜고 보관 기간을 정한다. 기본값(꺼짐)은 재시작 때 트레이스를 모두 잃는다. 운영 규모에서는 객체 저장소(S3 등)를 쓴다.
- Collector는 memory_limiter를 맨 앞, batch를 맨 뒤에 두고, 메모리 한도와 tail 샘플링의 num_traces를 함께 정한다.
- tail 샘플링을 쓰면서 Collector를 늘릴 때는 load_balancing 계층으로 트레이스 ID 단위로 모은다.
- 도입 직후 유휴 상태의 Span 양을 재고, 클러스터 탐색·헬스 체크 같은 소음을 필터로 버린다.
- 오류·느린 요청은 모두 남기고 나머지만 비율로 줄인다. 정확한 요청 수·오류율은 샘플링 전 데이터로 만든 메트릭으로 본다.
- Operator가 자동으로 만드는 ServiceMonitor 같은 부속 리소스가 Prometheus 선택 라벨과 맞는지 확인한다.
- Collector의 거절·보내기 실패·큐 크기에 알림을 건다. Collector가 관측되지 않으면 유실을 알 수 없다.
- 서비스 그래프에서 기대한 간선이 있는지 보고, 트레이스가 끊기는 구간(이번 Grafana → Keycloak)은 문서로 남긴다.
- 로그 형식에 트레이스 ID가 들어가는지 확인하고, 로그 저장소 데이터 소스에 트레이스 링크를 건다.
- Collector와 저장소를 일부러 멈춰 앱 영향이 없는지, 데이터가 얼마나 살아남는지 측정해 둔다(이번: 39초·46초 중단에서 유실 없음).
- AWS로 보낼 때는 sigv4auth가 있는 배포판(contrib·ADOT)을 쓰고, 키 대신 IRSA나 Pod Identity로 단기 자격 증명을 쓴다.
자주 묻는 질문
OpenTelemetry와 Prometheus는 경쟁 관계인가요?
아닙니다. OpenTelemetry는 데이터를 만들고 나르는 쪽이고 Prometheus는 메트릭을 저장하고 조회하는 쪽입니다. 이 실습에서도 Tempo가 트레이스에서 만든 메트릭을 Prometheus로 보냈고, Collector 자신의 메트릭도 Prometheus가 수집했습니다. OTel Collector가 메트릭을 받아 Prometheus로 보내는 구성도 흔합니다.
Fluent Bit을 OTel Collector로 바꿔야 하나요?
꼭 그렇지는 않습니다. 로그 수집이 안정적으로 돌고 있다면 이 글처럼 Fluent Bit은 로그, OTel은 트레이스를 맡겨도 됩니다. 트레이스와 로그를 잇는 데 필요한 것은 수집 도구가 아니라 로그 줄에 트레이스 ID가 들어 있는지와, 로그 라벨(namespace, pod)이 트레이스의 리소스 속성과 맞는지입니다.
트레이스는 얼마나 보관해야 하나요?
트레이스는 주로 최근 장애의 원인을 찾는 데 쓰므로 메트릭·로그보다 짧게 둡니다. 이 실습은 주말을 넘긴 장애도 볼 수 있게 72시간으로 했습니다. 오래 봐야 하는 추세(요청 수, 지연, 오류율)는 트레이스에서 만든 메트릭으로 Prometheus에 남기면 트레이스 원본을 오래 들고 있지 않아도 됩니다.
AWS로 보낼 때 키는 어디에 두나요?
EKS라면 키를 두지 않는 것이 원칙입니다. Collector의 서비스 계정에 IRSA나 EKS Pod Identity로 IAM 역할을 연결하면, Collector가 단기 자격 증명을 자동으로 받아 SigV4로 서명합니다. 매니페스트나 Secret에 Access Key를 넣는 방식은 유출과 교체 부담이 커서 피합니다.
작성 방식과 다음 편
이 글의 명령, 오류 메시지, 측정값은 1편의 실습 PC에서 직접 실행한 것이며, 설정은 OpenTelemetry, Grafana Tempo, Keycloak, Grafana, AWS 공식 문서를 기준으로 했습니다. AWS 부분은 실제로 전송하지 않은 구성 예시이고, 키와 비밀번호는 출력하지 않았습니다. 실행 기록 정리와 글 작성에 AI 도구를 함께 사용했습니다. 다음 편에서는 지금까지 손으로 적용한 매니페스트와 Helm 값을 Argo CD로 Git에서 관리하는 GitOps 구성으로 옮깁니다.



















