-
이코에코(Eco²) Observability #4: 분산 트레이싱 통합이코에코(Eco²)/Observability 2025. 12. 19. 02:37
개요
마이크로서비스 환경에서 하나의 요청이 여러 서비스를 거치며 처리됩니다. 이 글에서는 Kiali, Jaeger, OpenTelemetry를 활용하여 서비스 간 호출 관계를 시각화하고, E2E 트랜잭션을 추적하는 방법을 다룹니다.
2025-12-18 업데이트: Istio Sidecar와 App OTEL SDK 간 트레이스 연결 완료. B3 Propagator를 통해 동일 traceID로 전체 요청 흐름 추적 가능.
목표
- 서비스 토폴로지 시각화: Kiali로 서비스 간 관계 파악
- 분산 트레이싱: Jaeger로 요청 흐름 추적
- 자동 계측: OpenTelemetry로 코드 수정 없이 트레이싱
- 외부 서비스 시각화: OAuth, OpenAI, AWS 등 외부 의존성 표시
- E2E 트레이스 연결: Istio Sidecar ↔ App OTEL SDK 트레이스 통합
아키텍처
전체 트레이싱 파이프라인

프로토콜별 트래픽 흐름
소스 대상 프로토콜 포트 용도 Istio Sidecar Jaeger Zipkin 9411 Envoy 트레이스 전송 App OTEL SDK Jaeger OTLP gRPC 4317 앱 트레이스 전송 Sidecar → App - B3 Headers - Trace Context 전파
핵심 아키텍처 결정
결정 1: Trace Source of Truth = Istio Ingress Gateway

결정 배경:
옵션 장점 단점 ① Istio (선택) 모든 요청 추적, 앱 미도달도 가능 Istio 의존성 ② App OTEL SDK 앱 로직 세밀 추적 인프라 레벨 blind spot ③ 클라이언트 E2E 완전 추적 클라이언트 통제 필요 선택 이유
- 100% 샘플링으로 모든 요청 추적 - dev 환경에서 디버깅 용이
- ext-authz 거부, 404 등 앱 미도달 요청도 추적 가능 - 인프라 레벨 문제 파악
- B3 헤더 전파로 앱 OTEL SDK와 자연스럽게 연결 - 추가 설정 최소화
결정 2: Jaeger All-in-One (메모리 저장소)

선택 이유
- 개발 환경 - 트레이스 영구 보존 불필요
- 리소스 절약 - ES 추가 배포 없이 512MB로 운영
- 빠른 구축 - Helm All-in-One으로 5분 내 배포
결정 3: 듀얼 프로토콜 (Zipkin + OTLP)

왜 두 프로토콜인가?
- Zipkin (9411): Envoy/Istio가 네이티브로 지원, 설정 변경 없이 사용
- OTLP (4317): OpenTelemetry SDK 표준, 더 풍부한 메타데이터
결정 4: B3 Propagator로 Trace Context 연결
문제: Istio Sidecar와 App OTEL SDK가 별도 traceID 생성
❌ Before: 같은 요청인데 traceID가 다름 Sidecar: traceID=abc123 App: traceID=xyz789 (연결 안됨)해결: App에서 B3 헤더를 읽어 동일 traceID 사용
env: - name: OTEL_PROPAGATORS value: "b3,tracecontext,baggage" # B3 먼저!✅ After: 동일 traceID로 연결 Sidecar: traceID=abc123 App: traceID=abc123 (Jaeger에서 하나의 trace로 표시)
Trace Propagation 흐름

🔧 구현: Python tracing.py
왜 커스텀 tracing.py인가?
방식 장점 단점 opentelemetry-instrument만제로 코드 세부 제어 어려움 커스텀 tracing.py ✅ 세부 제어, 조건부 비활성화 코드 필요 선택 이유:
OTEL_ENABLED=false로 완전 비활성화 가능- 샘플링 레이트 동적 조절
- 수동 span 생성 헬퍼 제공
전체 코드
# domains/auth/core/tracing.py """ OpenTelemetry Distributed Tracing Configuration Architecture: App (OTel SDK) → OTLP/gRPC (4317) → Jaeger Collector → (Memory) """ import logging import os from typing import Optional from fastapi import FastAPI logger = logging.getLogger(__name__) # Environment variables OTEL_EXPORTER_ENDPOINT = os.getenv( "OTEL_EXPORTER_OTLP_ENDPOINT", "jaeger-collector-clusterip.istio-system.svc.cluster.local:4317", ) OTEL_SAMPLING_RATE = float(os.getenv("OTEL_SAMPLING_RATE", "1.0")) OTEL_ENABLED = os.getenv("OTEL_ENABLED", "true").lower() == "true" _tracer_provider = None def configure_tracing( service_name: str, service_version: str, environment: str = "dev", ) -> bool: """OpenTelemetry 트레이싱 설정""" global _tracer_provider if not OTEL_ENABLED: logger.info("OpenTelemetry tracing disabled (OTEL_ENABLED=false)") return False try: from opentelemetry import trace from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter from opentelemetry.sdk.resources import Resource from opentelemetry.sdk.trace import TracerProvider from opentelemetry.sdk.trace.export import BatchSpanProcessor from opentelemetry.sdk.trace.sampling import TraceIdRatioBased # Resource attributes (ECS/OTel semantic conventions) resource = Resource.create({ "service.name": service_name, "service.version": service_version, "deployment.environment": environment, "telemetry.sdk.name": "opentelemetry", "telemetry.sdk.language": "python", }) # Sampler (production: 1%, dev: 100%) sampler = TraceIdRatioBased(OTEL_SAMPLING_RATE) _tracer_provider = TracerProvider(resource=resource, sampler=sampler) # OTLP gRPC Exporter exporter = OTLPSpanExporter( endpoint=OTEL_EXPORTER_ENDPOINT, insecure=True, # ClusterIP, no TLS needed ) # BatchSpanProcessor (async, low overhead) _tracer_provider.add_span_processor( BatchSpanProcessor( exporter, max_queue_size=2048, max_export_batch_size=512, schedule_delay_millis=1000, ) ) trace.set_tracer_provider(_tracer_provider) logger.info("OpenTelemetry tracing configured", extra={ "service": service_name, "endpoint": OTEL_EXPORTER_ENDPOINT, "sampling_rate": OTEL_SAMPLING_RATE, }) return True except ImportError as e: logger.warning(f"OpenTelemetry not available: {e}") return False def instrument_fastapi(app: FastAPI) -> None: """FastAPI 자동 계측""" if not OTEL_ENABLED: return try: from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor FastAPIInstrumentor.instrument_app( app, excluded_urls="health,ready,metrics", # Health check 제외 ) logger.info("FastAPI instrumentation enabled") except ImportError: logger.warning("FastAPIInstrumentor not available") def instrument_httpx() -> None: """HTTPX 자동 계측 (외부 API 호출 추적)""" if not OTEL_ENABLED: return try: from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor HTTPXClientInstrumentor().instrument() logger.info("HTTPX instrumentation enabled") except ImportError: logger.warning("HTTPXClientInstrumentor not available") def shutdown_tracing() -> None: """트레이싱 종료 (graceful shutdown)""" global _tracer_provider if _tracer_provider is not None: _tracer_provider.shutdown() logger.info("OpenTelemetry tracing shutdown complete")
외부 서비스 시각화 (ServiceEntry)
현재 등록된 외부 서비스
ServiceEntry 호스트 용도 google-externalaccounts.google.com, www.googleapis.com Google OAuth kakao-externalkauth.kakao.com, kapi.kakao.com Kakao OAuth naver-externalnid.naver.com, openapi.naver.com Naver OAuth openai-externalapi.openai.com AI 챗봇 aws-s3-external*.s3.amazonaws.com 이미지 저장 aws-cloudfront*.cloudfront.net, images.dev.growbin.app CDN 왜 ServiceEntry가 필요한가?

문제: 외부 호출이
PassthroughCluster로 표시되어 구분 불가
해결: ServiceEntry로 외부 서비스 명시적 등록매니페스트
# workloads/routing/global/external-services.yaml apiVersion: networking.istio.io/v1alpha3 kind: ServiceEntry metadata: name: kakao-external namespace: istio-system spec: hosts: - kauth.kakao.com - kapi.kakao.com ports: - number: 443 name: https protocol: HTTPS resolution: DNS location: MESH_EXTERNAL --- apiVersion: networking.istio.io/v1alpha3 kind: ServiceEntry metadata: name: openai-external namespace: istio-system spec: hosts: - api.openai.com ports: - number: 443 name: https protocol: HTTPS resolution: DNS location: MESH_EXTERNAL --- # AWS는 와일드카드 DNS라 resolution: NONE apiVersion: networking.istio.io/v1alpha3 kind: ServiceEntry metadata: name: aws-s3-external namespace: istio-system spec: hosts: - '*.s3.amazonaws.com' - '*.s3.ap-northeast-2.amazonaws.com' ports: - number: 443 name: https protocol: HTTPS resolution: NONE # 와일드카드는 DNS 해석 불가 location: MESH_EXTERNAL
NetworkPolicy 설정
왜 9411 포트가 중요한가?

문제: 9411 누락 시 Sidecar 트레이스가 전송 안됨
증상: Jaeger Dependencies에 "No service dependencies found"매니페스트
# workloads/network-policies/base/allow-jaeger-egress.yaml apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: allow-jaeger-egress namespace: auth # 각 앱 네임스페이스별로 적용 spec: podSelector: {} policyTypes: - Egress egress: - to: - namespaceSelector: matchLabels: kubernetes.io/metadata.name: istio-system podSelector: matchLabels: app.kubernetes.io/name: jaeger ports: - port: 4317 # OTLP gRPC (App SDK) - port: 4318 # OTLP HTTP - port: 9411 # ⚠️ Zipkin (Istio Sidecar) - 필수!
텔레메트리 신호별 수집 전략
왜 Traces만 OTEL인가?

신호 수집 방법 OTEL 사용 이유 Traces OTLP → Jaeger ✅ 신규 도입, 표준화 Metrics Prometheus scrape ❌ 기존 인프라 활용, Pull 모델 장점 Logs Fluent Bit → ES ❌ EFK 스택 이미 구축 설계 원칙: "Don't fix what isn't broken"
기존에 잘 동작하는 Prometheus/Fluent Bit 유지, 없었던 Traces만 추가Deployment 환경변수 (전체)
env: - name: OTEL_SERVICE_NAME value: "auth-api" - name: OTEL_TRACES_EXPORTER value: "otlp" # ✅ Jaeger로 전송 - name: OTEL_EXPORTER_OTLP_ENDPOINT value: "http://jaeger-collector-clusterip.istio-system.svc.cluster.local:4317" - name: OTEL_METRICS_EXPORTER value: "none" # ❌ Prometheus가 scrape - name: OTEL_LOGS_EXPORTER value: "none" # ❌ Fluent Bit가 수집 - name: OTEL_PROPAGATORS value: "b3,tracecontext,baggage" # ✅ Istio와 연결
검증 결과
Jaeger에서 확인
# 같은 요청의 istio-proxy와 auth-api 로그 { "service.name": "istio-proxy", "trace.id": "49069056832712b6d1a76403290e3520", "url.path": "/api/v1/auth/refresh" } { "service.name": "auth-api", "trace.id": "49069056832712b6d1a76403290e3520", # ✅ 동일 "message": "HTTP 401 UNAUTHORIZED: Missing refresh token" }Span 구조 예시
istio-ingressgateway: POST /api/v1/auth/kakao/callback (traceID: 525f...) └── auth-api.auth: inbound (Envoy Sidecar) └── auth-api: POST /api/v1/auth/kakao/callback (OTEL SDK) ├── httpx: POST kauth.kakao.com/oauth/token (15ms) ├── asyncpg: INSERT users... (5ms) └── redis: SET auth:session:xxx (2ms)현재 클러스터 상태
항목 상태 Telemetry 리소스 global-sampling(100%),mesh-default(access logging)Jaeger Services jaeger-collector-clusterip,jaeger-query-clusteripServiceEntry 6개 (Google, Kakao, Naver, OpenAI, AWS S3, CloudFront) B3 Propagation ✅ App OTEL SDK에서 활성화
로그-트레이스 연결

검색 예시
# Kibana에서 trace.id 검색 trace.id: "4bf92f3577b34da6a3ce929d0e0e4736" # Jaeger에서 동일 trace 확인 https://jaeger.dev.growbin.app/trace/4bf92f3577b34da6a3ce929d0e0e4736
트러블슈팅
상세 트러블슈팅 문서
이슈 문서 소요시간 NetworkPolicy Zipkin 포트 누락 트러블슈팅 블로그 ~2시간 Fluent Bit CRI Parser 오류 트러블슈팅 블로그 ~30분 Issue 1: "No service dependencies found"
증상: 개별 서비스 트레이스는 있지만 dependencies 없음
원인: NetworkPolicy에서 Zipkin 포트(9411) 누락
해결: port 9411 추가Issue 2: App traceID가 Sidecar와 다름
증상: 같은 요청인데 별도 traceID
원인: App OTEL SDK가 B3 헤더 미인식
해결:OTEL_PROPAGATORS=b3,tracecontext,baggageIssue 3: Jaeger UI 503 오류
증상: VirtualService로 접근 시 503
원인: Headless Service
해결: ClusterIP Service 별도 생성
Reference
트러블슈팅 사례 (ECO2)
CNCF & OpenTelemetry
Referenc
Service Mesh Integration
'이코에코(Eco²) > Observability' 카테고리의 다른 글
이코에코(Eco²) Observability #6: Log-Trace 연동 및 Kibana 검색 구조 (1) 2025.12.19 이코에코(Eco²) Observability #5: 인덱스 전략 및 라이프사이클 관리 (0) 2025.12.19 이코에코(Eco²) Observability #3: 도메인별 ECS 구조화 로깅 (0) 2025.12.19 이코에코(Eco²) Observability #2: 로깅 정책 수립 (0) 2025.12.19 이코에코(Eco²) Observability #1: EFK 파이프라인 구축 (0) 2025.12.19