마이크로서비스 환경에서 "어디서 느려졌지?"를 추적하는 건 쉽지 않다.
OpenTelemetry, Jaeger, OpenSearch로 분산 트레이싱 인프라를 구축한 경험을 정리한다.
1. 왜 분산 트레이싱일까?
게임 서버를 마이크로서비스로 운영하면서 반복해서 부딪힌 상황은 "응답이 느린데 어디가 느린지 모르겠다"였다.
클라이언트 요청 하나가 GameServer를 거쳐 ChatServer, MySQL, Redis까지 이어지는데, 이 중 어느 구간에서 시간을 쓰는지 로그만으로는 보이지 않았다.
서비스별 로그를 열어 타임스탬프를 대조하는 식으로 추적하다 보면 요청 하나의 흐름을 복원하는 데만 한참이 걸렸다.
에러가 났을 때도 사정은 같았다.
GameServer 로그에는 호출 실패만 남고, 원인이 ChatServer 쪽인지 그 뒤의 저장소 쪽인지는 양쪽 로그를 대조해야 보였다.
어떤 서비스가 어떤 서비스를 호출하는지도 코드를 뒤져야 알 수 있었다.
2. 아키텍처 설계
기술 스택 선정
| 컴포넌트 | 역할 | 선택 이유 |
|---|---|---|
| OpenTelemetry SDK | Trace 데이터 생성 | 벤더 중립적, 표준화 |
| OpenTelemetry Collector | 수집/처리/전송 | Tail-based sampling 가능 |
| Jaeger | 시각화 | 무료, 강력한 UI |
| OpenSearch | 영구 저장 | AWS 관리형, Elasticsearch 호환 |
전체 아키텍처
3. 핵심 구현
3.1 OpenTelemetry SDK 설정
services.AddOpenTelemetry()
.ConfigureResource(resource => resource
.AddService("MonsterQuestGameServer", serviceVersion: version))
.WithTracing(tracing => tracing
.AddSource("MonsterQuest.GameServer")
.SetSampler(new AlwaysOnSampler()) // Collector에서 sampling
.AddEntityFrameworkCoreInstrumentation() // MySQL
.AddRedisInstrumentation() // Redis
.AddGrpcClientInstrumentation() // gRPC
.AddOtlpExporter(options =>
{
options.Endpoint = new Uri("http://localhost:4317");
options.Protocol = OtlpExportProtocol.Grpc;
}));샘플링 결정은 앱이 아니라 Collector에 맡기기 위해 AlwaysOnSampler를 썼다.
앱은 모든 Trace를 localhost의 Sidecar Collector로 일단 전송하고, 남길지 버릴지는 Collector가 판단한다.
EF Core, Redis, gRPC 호출은 auto instrumentation으로 계측하므로 span을 코드에 직접 추가할 일은 거의 없다.
3.2 Sidecar 패턴
Collector를 중앙에 하나 두는 대신 각 Pod에 Sidecar로 배포했다.
앱은 localhost로만 전송하면 되니 네트워크 지연이 거의 없고, 서비스마다 Collector 설정을 독립적으로 가져갈 수 있다.
Collector에 문제가 생겨도 영향 범위가 해당 Pod로 격리된다.
containers:
- name: game-server
# 앱 컨테이너
- name: otel-collector
image: opentelemetry-collector
env:
- name: GOMEMLIMIT
value: "200MiB" # 컨테이너 메모리의 80%GOMEMLIMIT은 빼먹기 쉬운데, Go로 작성된 Collector는 이 값이 없으면 컨테이너 메모리 제한을 인식하지 못해 OOMKilled될 수 있다.
컨테이너 메모리의 80% 수준으로 잡았다.
3.3 Tail-based Sampling
processors:
tail_sampling:
decision_wait: 10s
num_traces: 50000
policies:
- name: error-traces
type: status_code
status_code:
status_codes: [ERROR]
- name: success-traces
type: probabilistic
probabilistic:
sampling_percentage: 10Tail-based sampling은 Trace가 끝난 뒤에 저장 여부를 결정하기 때문에 "에러가 난 Trace만 전부 남기는" 정책이 가능하다.
에러 Trace는 문제 분석에 필요하니 100% 저장하고, 성공 Trace는 10%만 남겨 OpenSearch 저장 비용을 줄였다.
4. Jaeger v2 설정
Jaeger v2는 OpenTelemetry Collector 기반으로 변경되었다.
기존의 Jaeger 전용 exporter를 둘 필요 없이 OTLP로 바로 보내면 되고, 파이프라인 구성도 Collector 문법을 그대로 쓰기 때문에 더 유연하다.
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
exporters:
jaeger_storage_exporter:
trace_storage: opensearch_backend
extensions:
jaeger_storage:
backends:
opensearch_backend:
opensearch:
server_urls: "${OPENSEARCH_URL}"
jaeger_query:
trace_storage: opensearch_backend
ui:
config_file: /etc/jaeger/ui-config.json
service:
pipelines:
traces:
receivers: [otlp]
processors: [batch]
exporters: [jaeger_storage_exporter]5. AWS OpenSearch 연동
AWS OpenSearch는 SigV4 인증이 필요하다.
aws-sigv4-proxy 사이드카를 사용:
containers:
- name: jaeger
# Jaeger 컨테이너
env:
- name: OPENSEARCH_URL
value: "http://localhost:8080" # proxy 경유
- name: aws-sigv4-proxy
image: amazon/aws-sigv4-proxy
args:
- --host=search-xxx.es.amazonaws.com
- --region=ap-northeast-2
- --service=es6. Span Metrics (RED 메트릭 자동 생성)
Jaeger의 SpanMetrics Connector로 Trace에서 자동으로 RED 메트릭 생성:
connectors:
spanmetrics:
namespace: traces.spanmetrics
dimensions:
- name: service
- name: operation
- name: status_code
exporters:
prometheusremotewrite:
endpoint: "${PROMETHEUS_REMOTE_WRITE_URL}"
service:
pipelines:
traces:
exporters: [jaeger_storage_exporter, spanmetrics]
metrics/spanmetrics:
receivers: [spanmetrics]
exporters: [prometheusremotewrite]이 설정만으로 호출 수(calls_total)와 응답 시간 분포(duration_milliseconds_bucket)가 Trace에서 자동 생성되어 Prometheus로 전송된다.
메트릭 계측 코드를 따로 작성하지 않아도 서비스·오퍼레이션·상태 코드 단위의 RED 메트릭을 얻는다.
7. 결과
전에는 응답이 느려지면 서비스별 로그를 열어 타임스탬프를 대조하며 병목 구간을 추정했다.
지금은 Jaeger UI에서 해당 요청의 Trace를 열면 GameServer → ChatServer → MySQL → Redis 각 구간의 소요 시간이 한 화면에 보인다.
파악 시간이 얼마나 줄었는지 따로 계측해 두진 않았지만, 로그를 대조하는 단계 자체가 없어졌다.
에러 추적 방식도 바뀌었다.
tail sampling이 에러 Trace를 100% 남기므로, 에러가 난 요청은 Trace에서 어느 span이 실패했는지 바로 확인한다.
서비스 간 호출 관계는 Jaeger가 Trace 데이터로 의존성 그래프를 자동으로 그리므로 문서로 따로 관리하지 않는다.
마무리
분산 트레이싱을 적용한 뒤에는 "어디서 느려졌지?"라는 질문에 로그 대신 Trace로 답할 수 있게 됐다.