OpenTelemetry로 관측성 구축하기: Traces·Metrics·Logs, 자동 계측, Collector, Kubernetes Operator

이 글의 핵심

OpenTelemetry(OTel)는 CNCF의 관측성 표준 프로젝트로, 앱은 OTLP로 내보내기만 하고 어느 백엔드로 보낼지는 Collector에서 정하게 해 줍니다. 다만 tail sampling이 여러 Collector에 흩어진 스팬 때문에 동작하지 않거나, 로그 exporter처럼 구성 요소가 버전마다 바뀌는 등 운영에서 부딪히는 함정이 있습니다. SDK 초기화 순서, Collector 파이프라인 설계, Kubernetes 자동 주입, 카디널리티와 비용 관리를 실제 설정과 함께 정리합니다.

OpenTelemetry의 세 기둥

시그널설명상태
Traces분산 호출 추적GA
Metrics시계열 지표GA
Logs구조화 로그GA (2024+)

모두 OTLP(gRPC/HTTP) 라는 단일 프로토콜로 전송됩니다.

세 시그널은 각각 다른 질문에 답합니다. 메트릭은 “무엇이 이상한가”(오류율이 올랐다, p99가 느려졌다)를 싸게 알려 주고, 트레이스는 “어디서 느린가”(어느 서비스의 어느 호출이 시간을 잡아먹었나)를, 로그는 “그 순간 무슨 일이 있었나”를 보여 줍니다. OpenTelemetry의 핵심 가치는 이 셋을 같은 리소스 속성(service.name 등)과 같은 trace ID로 연결할 수 있게 해 준다는 데 있습니다. 대시보드에서 느려진 메트릭을 보고, 그 시간대의 느린 트레이스로 넘어가고, 그 트레이스의 로그를 바로 여는 흐름이 가능해지는 것입니다. 반대로 각 시그널을 서로 다른 도구로 따로 수집하면 이 연결이 끊겨 “지표는 튀었는데 원인을 찾을 수 없는” 상황이 됩니다.

아키텍처

  앱 (OTel SDK + Instrumentation)
         │ OTLP
         ▼
  ┌─────────────────────────┐
  │  OTel Collector          │
  │  ┌─────┐ ┌──────┐ ┌────┐ │
  │  │Recv │→│Proc  │→│Exp │ │
  │  └─────┘ └──────┘ └────┘ │
  └─────────────────────────┘
         │        │       │
         ▼        ▼       ▼
    Datadog   Honeycomb  Grafana
     (OTLP)    (OTLP)    Tempo/Loki/Mimir
  • Receivers: OTLP·Jaeger·Zipkin·Prometheus scrape·syslog 등
  • Processors: batch, attribute, filter, tail_sampling
  • Exporters: OTLP, Prometheus, 벤더별 exporter (Jaeger는 OTLP를 직접 받으므로 전용 exporter 대신 OTLP 사용)

Collector 배포판은 두 가지가 있다는 점을 먼저 알아야 합니다. otelcol(core)은 OTLP 등 핵심 구성 요소만 포함하고, otelcol-contrib는 tail_sampling, 벤더 exporter, 각종 receiver를 포함한 큰 배포판입니다. 문서의 설정을 core 배포판에 넣으면 unknown type: "tail_sampling" 같은 오류로 기동에 실패하므로, 대부분의 예제는 contrib를 전제로 합니다. 운영 환경에서는 필요한 구성 요소만 골라 OpenTelemetry Collector Builder(ocb)로 직접 빌드하는 방법도 있는데, 이미지 크기와 공격 표면을 줄일 수 있다는 장점이 있습니다.

Node.js SDK + 자동 계측

// instrumentation.ts (앱 엔트리보다 먼저 로드)
import { NodeSDK } from "@opentelemetry/sdk-node"
import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node"
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http"
import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics"
import { Resource } from "@opentelemetry/resources"
import { SemanticResourceAttributes } from "@opentelemetry/semantic-conventions"

const sdk = new NodeSDK({
  resource: new Resource({
    [SemanticResourceAttributes.SERVICE_NAME]: "web-api",
    [SemanticResourceAttributes.SERVICE_VERSION]: process.env.APP_VERSION,
    [SemanticResourceAttributes.DEPLOYMENT_ENVIRONMENT]: process.env.NODE_ENV,
  }),
  traceExporter: new OTLPTraceExporter({ url: "http://collector:4318/v1/traces" }),
  metricReader: new PeriodicExportingMetricReader({
    exporter: new OTLPMetricExporter({ url: "http://collector:4318/v1/metrics" }),
    exportIntervalMillis: 15_000,
  }),
  instrumentations: [getNodeAutoInstrumentations()],
})

sdk.start()

process.on("SIGTERM", () => sdk.shutdown().finally(() => process.exit(0)))
node -r ./instrumentation.js dist/server.js
# 또는 TS: node --import ./instrumentation.mjs dist/server.js

HTTP·Express·Fastify·gRPC·pg·mysql·Redis·Kafka 등이 자동 계측됩니다.

이 파일이 다른 모든 모듈보다 먼저 로드되어야 하는 이유는 Node.js 자동 계측의 동작 방식에 있습니다. 계측 라이브러리는 require/import를 가로채서 http, pg, express 같은 모듈이 로드되는 순간 그 함수들을 감쌉니다. 앱 코드가 먼저 express를 불러와 버리면 이미 로드된 모듈은 감싸지지 않으므로, 에러 없이 조용히 스팬이 하나도 생기지 않습니다. node -r(CommonJS)이나 --import(ESM)로 로드하는 이유가 이것이고, ESM 프로젝트에서는 계측 라이브러리가 ESM 로더 훅을 등록해야 하므로 공식 문서의 ESM 설정 방법을 따라야 합니다. SDK 패키지는 버전 변화가 빠른 편이라 최신 버전에서는 new Resource(...) 대신 resourceFromAttributes(...), SemanticResourceAttributes.SERVICE_NAME 대신 ATTR_SERVICE_NAME 같은 이름을 쓰므로, 예제를 옮길 때 설치한 버전의 문서와 맞춰 보세요.

getNodeAutoInstrumentations()는 기본적으로 매우 많은 계측을 켜므로, 파일 시스템 계측(@opentelemetry/instrumentation-fs)처럼 스팬을 대량으로 만드는 것은 끄는 편이 좋습니다. 켜 두면 모듈 로딩과 설정 파일 읽기마다 스팬이 생겨 트레이스가 읽기 어려워지고 비용도 늘어납니다. SIGTERM 핸들러에서 sdk.shutdown()을 호출하는 것도 중요한데, 배치로 모아 둔 스팬과 메트릭이 프로세스 종료와 함께 사라지지 않게 마지막으로 내보내는 단계이기 때문입니다.

수동 트레이싱

import { trace, SpanStatusCode } from "@opentelemetry/api"

const tracer = trace.getTracer("my-service")

async function checkout(userId: string) {
  return await tracer.startActiveSpan("checkout", async (span) => {
    try {
      span.setAttribute("user.id", userId)
      const cart = await loadCart(userId)      // 자동 계측 child span
      span.setAttribute("cart.items", cart.length)
      const order = await createOrder(cart)
      span.setAttribute("order.id", order.id)
      return order
    } catch (err) {
      span.recordException(err as Error)
      span.setStatus({ code: SpanStatusCode.ERROR })
      throw err
    } finally {
      span.end()
    }
  })
}

startActiveSpan은 새 스팬을 만드는 동시에 활성 컨텍스트로 설정하므로, 콜백 안에서 호출되는 loadCart의 자동 계측 스팬(DB 쿼리, HTTP 호출)이 자동으로 checkout의 자식이 됩니다. Node.js에서는 AsyncLocalStorage로 비동기 경계를 넘어 이 컨텍스트를 전달하므로 await를 거쳐도 부모-자식 관계가 유지됩니다. 이 연결이 끊기는 흔한 경우는 콜백 기반 라이브러리나 직접 만든 작업 큐처럼 컨텍스트 전파를 모르는 코드를 지날 때이며, 이때는 context.with(ctx, fn)으로 명시적으로 컨텍스트를 넘겨야 합니다.

finally에서 span.end()를 호출하는 구조가 중요합니다. 끝나지 않은 스팬은 내보내지지 않으므로, 예외 경로에서 end()를 빠뜨리면 오류가 난 요청일수록 트레이스에서 사라지는 역설적인 상황이 됩니다. user.id 같은 속성은 검색에 유용하지만, 이메일이나 이름처럼 개인정보가 될 수 있는 값을 스팬에 넣으면 관측 백엔드가 개인정보 저장소가 되어 버린다는 점도 주의해야 합니다.

커스텀 메트릭

import { metrics } from "@opentelemetry/api"

const meter = metrics.getMeter("my-service")

const orderCounter = meter.createCounter("orders_total", {
  description: "Total orders placed",
})
const orderValue = meter.createHistogram("order_value_usd", {
  description: "Order value in USD",
  unit: "usd",
})

orderCounter.add(1, { plan: "pro" })
orderValue.record(99.9, { plan: "pro", country: "KR" })

메트릭 속성(라벨)은 값의 종류가 적은 것만 써야 합니다. 시계열 저장소는 메트릭 이름과 속성 값 조합마다 별도의 시계열을 만들기 때문에, plan처럼 값이 몇 개뿐인 속성은 괜찮지만 user_id나 order_id를 넣으면 사용자 수만큼 시계열이 생겨 비용과 메모리가 폭발합니다. 개별 요청 단위의 정보는 메트릭이 아니라 트레이스의 스팬 속성에 넣는 것이 원칙입니다. 이름 규칙도 알아 두면 좋습니다. OTel 메트릭은 orders 같은 이름과 unit을 따로 두는 것이 권장이고, Prometheus로 내보낼 때 exporter가 단위와 _total 접미사를 붙여 주므로 이름에 직접 _total을 넣으면 Prometheus에서 orders_total_total처럼 중복될 수 있습니다.

Python 예시

pip install opentelemetry-distro opentelemetry-exporter-otlp
opentelemetry-bootstrap --action=install
opentelemetry-instrument \
  --traces_exporter otlp \
  --metrics_exporter otlp \
  --logs_exporter otlp \
  --exporter_otlp_endpoint http://collector:4317 \
  --service_name web-api \
  python manage.py runserver

Django·Flask·FastAPI·SQLAlchemy·Redis 자동 계측. 별도 코드 변경 거의 없음.

opentelemetry-bootstrap --action=install은 현재 환경에 설치된 패키지를 보고 맞는 계측 라이브러리를 골라 설치합니다. 그래서 애플리케이션 의존성을 모두 설치한 뒤에 실행해야 하며, Docker 이미지라면 pip install -r requirements.txt 다음 단계에 두어야 합니다. 여기서 엔드포인트가 Node 예제와 달리 4317인 이유는 Python의 기본 OTLP exporter가 gRPC이기 때문입니다. HTTP로 보내려면 OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf와 4318 포트를 함께 써야 하며, 프로토콜과 포트를 섞어 쓰는 것이 “스팬이 안 보인다”의 가장 흔한 원인 중 하나입니다. gunicorn처럼 워커 프로세스를 fork하는 서버에서는 fork 이전에 초기화된 exporter의 스레드가 자식 프로세스로 복제되지 않아 내보내기가 멈출 수 있으므로, 공식 문서의 post-fork 훅 설정을 확인해야 합니다. 개발 서버인 runserver는 자동 리로더가 프로세스를 두 개 띄우므로 --noreload를 붙이는 것이 좋습니다.

Go 예시

import (
    "go.opentelemetry.io/otel"
    "go.opentelemetry.io/otel/attribute"
    "go.opentelemetry.io/otel/trace"
)

func handler(ctx context.Context, userID string) error {
    tracer := otel.Tracer("my-service")
    ctx, span := tracer.Start(ctx, "handler",
        trace.WithAttributes(attribute.String("user.id", userID)))
    defer span.End()
    // ...
    return nil
}

HTTP 미들웨어 otelhttp.NewHandler로 자동 계측.

Go에는 Java나 Node 같은 런타임 가로채기 방식의 자동 계측이 없으므로, otelhttp, otelgrpc 같은 계측 라이브러리를 코드에 직접 연결하는 방식이 기본입니다(eBPF 기반의 자동 계측 프로젝트도 있지만 별도 도구입니다). 이 방식에서 가장 중요한 것은 ctx를 끝까지 전달하는 것입니다. tracer.Start가 돌려준 새 ctx를 하위 함수, DB 호출, HTTP 클라이언트 요청에 넘겨야 자식 스팬이 연결되고, 어딘가에서 context.Background()로 새로 시작하면 그 아래 스팬은 별개의 트레이스가 됩니다. 또 otel.Tracer는 전역 TracerProvider를 쓰므로, main에서 otel.SetTracerProvider로 SDK를 설정하지 않으면 아무것도 기록하지 않는 no-op 트레이서가 반환되어 에러 없이 스팬이 사라집니다.

Collector 설정

# otel-collector.yaml
receivers:
  otlp:
    protocols:
      grpc: { endpoint: 0.0.0.0:4317 }
      http: { endpoint: 0.0.0.0:4318 }

processors:
  memory_limiter:
    check_interval: 1s
    limit_mib: 512

  batch:
    send_batch_size: 1024
    timeout: 5s

  attributes:
    actions:
      - key: http.request.header.authorization
        action: delete
      - key: environment
        value: production
        action: upsert

  tail_sampling:
    decision_wait: 10s
    num_traces: 50000
    policies:
      - name: errors
        type: status_code
        status_code: { status_codes: [ERROR] }
      - name: slow
        type: latency
        latency: { threshold_ms: 1000 }
      - name: sample_10pct
        type: probabilistic
        probabilistic: { sampling_percentage: 10 }

exporters:
  otlphttp/honeycomb:
    endpoint: https://api.honeycomb.io
    headers:
      x-honeycomb-team: ${env:HONEYCOMB_API_KEY}

  # Datadog은 contrib의 datadog exporter(또는 Datadog Agent의 OTLP 수신)를 사용
  datadog:
    api:
      key: ${env:DD_API_KEY}

  prometheus:
    endpoint: 0.0.0.0:9464

  # Loki 3.x는 OTLP를 직접 받음 (예전 전용 loki exporter는 contrib에서 제거됨)
  otlphttp/loki:
    endpoint: http://loki:3100/otlp

service:
  telemetry:
    logs: { level: info }
  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, tail_sampling, attributes, batch]
      exporters: [otlphttp/honeycomb]
    metrics:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [prometheus]
    logs:
      receivers: [otlp]
      processors: [memory_limiter, batch]
      exporters: [otlphttp/loki]

이 설정에서 순서에 의미가 있는 부분은 processors 목록입니다. 프로세서는 적힌 순서대로 실행되므로, memory_limiter는 가장 앞에 두어 메모리가 한도에 가까워지면 데이터를 더 받기 전에 거부하게 하고, batch는 가장 뒤에 두어 필터와 샘플링을 거친 데이터만 묶어 보내게 합니다. 샘플링으로 버릴 데이터를 먼저 배치로 묶는 것은 낭비입니다. 정의한 exporter도 service.pipelines에 넣지 않으면 쓰이지 않는다는 점을 기억하세요. 위 예제의 datadog exporter는 정의만 되어 있으므로, 실제로 보내려면 파이프라인의 exporters에 추가해야 합니다. 환경 변수는 ${env:DD_API_KEY} 형태가 현재 권장 문법이며, 비밀 값은 설정 파일에 직접 쓰지 않고 이렇게 주입합니다.

tail_sampling에는 반드시 알아야 할 전제가 있습니다. 트레이스 하나를 구성하는 모든 스팬이 같은 Collector 인스턴스에 도착해야 트레이스 전체를 보고 판단할 수 있습니다. Collector를 여러 대로 늘리고 로드 밸런서로 요청을 나누면 스팬이 흩어져서, 에러가 난 스팬을 받지 못한 인스턴스는 그 트레이스를 정상으로 보고 샘플링해 버립니다. 그래서 여러 Collector 앞단에 loadbalancing exporter를 둔 계층을 만들고 trace ID 기준으로 같은 Gateway에 보내는 구성이 필요합니다. 저는 Gateway를 수평 확장한 뒤 “에러 트레이스가 가끔 불완전하게 보인다”는 증상을 처음 봤을 때 이 전제를 놓쳤다는 것을 알았습니다. decision_wait(여기서는 10초)는 그동안 모든 스팬을 메모리에 들고 있어야 한다는 뜻이므로, num_traces와 함께 메모리 사용량을 결정한다는 점도 설정할 때 고려해야 합니다.

실행

otelcol-contrib --config otel-collector.yaml

Docker 이미지: otel/opentelemetry-collector-contrib를 latest 대신 특정 버전 태그로 고정해 사용하세요. Collector는 아직 1.0 이전 구성 요소가 많아 버전 사이에 설정 키가 바뀌거나 구성 요소가 제거되는 일이 있으므로, 올릴 때는 변경 기록(changelog)의 breaking changes를 확인해야 합니다. 설정을 배포하기 전에 otelcol-contrib validate --config otel-collector.yaml로 문법과 구성 요소 이름을 검사할 수 있습니다.

Kubernetes: OpenTelemetry Operator

kubectl apply -f https://github.com/open-telemetry/opentelemetry-operator/releases/latest/download/opentelemetry-operator.yaml
apiVersion: opentelemetry.io/v1beta1
kind: OpenTelemetryCollector
metadata: { name: otel-gateway, namespace: observability }
spec:
  mode: deployment
  replicas: 3
  config:
    receivers:
      otlp:
        protocols:
          grpc: { endpoint: 0.0.0.0:4317 }
          http: { endpoint: 0.0.0.0:4318 }
    # ... (동일)

---
apiVersion: opentelemetry.io/v1alpha1
kind: Instrumentation
metadata: { name: java-instr }
spec:
  exporter:
    endpoint: http://otel-gateway-collector:4317
  propagators: [tracecontext, baggage, b3]
  sampler:
    type: parentbased_traceidratio
    argument: "0.25"
  java:
    image: ghcr.io/open-telemetry/opentelemetry-operator/autoinstrumentation-java:latest

Pod에 annotation instrumentation.opentelemetry.io/inject-java: "true"만 추가하면 Java 앱이 자동 계측됩니다. Node·Python·.NET도 유사하고, Go는 eBPF 기반 방식이라 별도의 권한 설정이 필요합니다.

Operator의 자동 주입은 Pod가 생성될 때 mutating webhook이 init 컨테이너와 환경 변수(JAVA_TOOL_OPTIONS 등)를 넣는 방식입니다. 그래서 annotation을 추가하거나 Instrumentation 리소스를 바꾼 뒤에는 기존 Pod를 재시작해야 적용되고, Operator가 cert-manager 같은 웹훅 인증서 구성 없이 설치되면 주입이 조용히 실패합니다. 이미지 태그를 latest로 두면 Pod가 재시작될 때마다 다른 버전의 계측 에이전트가 주입될 수 있으므로, 운영에서는 버전을 고정하는 것이 안전합니다. 샘플러 설정 parentbased_traceidratio는 상위 서비스가 이미 샘플링 결정을 내렸다면 그 결정을 따르고, 트레이스를 새로 시작할 때만 25% 비율로 결정한다는 뜻입니다. 서비스마다 비율이 달라도 트레이스가 중간에 끊기지 않게 해 주는 설정입니다.

Context Propagation

마이크로서비스 간 trace 연결의 핵심. 표준 traceparent 헤더(W3C Trace Context)가 기본.

HTTP Request
  traceparent: 00-<trace_id>-<span_id>-<flags>
  tracestate: vendor=xyz

모든 OTel SDK가 기본 생성·전파. HTTP·gRPC는 계측 라이브러리가 자동으로 처리하고, 메시지 큐는 계측이 있는 클라이언트에서만 메시지 헤더로 전파됩니다.

traceparent의 마지막 flags는 샘플링 여부를 담고 있어서, 앞 서비스가 “이 트레이스는 기록하지 않는다”고 정하면 뒤 서비스도 이를 따릅니다. 트레이스가 서비스 경계에서 끊기는 문제는 대부분 전파 형식이 맞지 않을 때 생깁니다. 예를 들어 Zipkin 시절의 b3 헤더를 쓰는 서비스와 W3C traceparent만 쓰는 서비스가 섞이면 서로의 헤더를 읽지 못합니다. 이럴 때는 위 Operator 예제처럼 여러 propagator를 함께 켜서 과도기를 넘깁니다. API 게이트웨이나 프록시가 모르는 헤더를 지워 버리는 경우, 그리고 Kafka처럼 비동기 메시지를 거치는 경우도 끊김의 흔한 원인입니다. 비동기 메시지에서는 소비자 쪽 스팬을 생산자 스팬의 자식으로 둘지, 링크(span link)로 연결할지 선택해야 하는데, 한 번에 여러 메시지를 처리하는 배치 소비자라면 링크가 더 정확한 표현입니다.

Semantic Conventions

속성 이름을 표준화해 벤더 간 호환성 보장.

  • service.name, service.version, deployment.environment
  • http.request.method, http.response.status_code, http.route
  • db.system, db.query.text(예전 이름 db.statement), db.operation.name
  • messaging.system, messaging.destination.name

커스텀 키보다 표준 키 우선 사용. 시맨틱 컨벤션은 영역별로 안정화가 진행되면서 속성 이름이 바뀌어 왔습니다. HTTP 속성은 예전 http.method에서 http.request.method로 바뀌었고, DB 속성도 같은 과정을 거쳤습니다. 계측 라이브러리 버전에 따라 옛 이름과 새 이름이 섞여 나올 수 있으므로, 백엔드의 대시보드나 알림 쿼리가 어떤 이름을 기준으로 하는지 확인해 두어야 업그레이드 뒤에 그래프가 갑자기 비는 일을 막을 수 있습니다. 일부 계측은 OTEL_SEMCONV_STABILITY_OPT_IN 환경 변수로 옛 이름과 새 이름을 함께 내보내는 전환 모드를 제공합니다.

비용 최적화 전략

Tail Sampling

전체 trace가 끝난 뒤 판단해 “에러·느린·샘플만” 저장. 감축 비율은 확률 샘플링 비율과 에러·지연 트레이스의 비중으로 정해집니다.

head sampling(트레이스 시작 시 SDK에서 결정)은 비용이 거의 들지 않지만, 시작 시점에는 이 요청이 실패할지 알 수 없어서 드문 에러 트레이스를 버릴 수 있습니다. tail sampling은 끝까지 본 뒤 결정하므로 에러와 느린 요청을 빠짐없이 남길 수 있는 대신, 앞서 말한 것처럼 모든 스팬을 한곳에 모아 메모리에 들고 있어야 하는 인프라 비용이 듭니다. 두 방식을 섞어 SDK에서는 넉넉하게 head sampling을 하고 Gateway에서 tail sampling으로 한 번 더 거르는 구성도 흔합니다. 샘플링된 트레이스만 남기면 트레이스 수로 계산한 요청률이 실제보다 작아지므로, 요청률과 오류율 같은 집계는 샘플링과 무관한 메트릭으로 보는 것이 원칙입니다.

Attribute Pruning

processors:
  attributes:
    actions:
      - key: http.request.header.cookie
        action: delete
      - key: http.request.header.authorization
        action: delete

Cardinality Control

메트릭 라벨 값 수를 제한:

processors:
  metricstransform:
    transforms:
      - include: http_request_duration_seconds
        action: update
        operations:
          - action: aggregate_labels
            label_set: [service.name, http.route, http.response.status_code]
            aggregation_type: sum

Logs 레벨 필터

processors:
  filter/logs:
    logs:
      log_record:
        - 'severity_number < SEVERITY_NUMBER_INFO'

토폴로지 선택

  1. Sidecar: 각 Pod에 Collector 사이드카. 격리·저지연 가능
  2. DaemonSet (Agent): 노드당 Collector. 모든 Pod이 localhost:4317로 전송
  3. Gateway (Deployment): 별도 Collector 풀. 샘플링·배치 집중

일반적으로 Agent + Gateway 2계층이 권장. Agent는 빠른 첫 홉, Gateway는 복잡한 처리·벤더 연결.

로그와 트레이스 연결

import { trace, context } from "@opentelemetry/api"
import pino from "pino"

const logger = pino({
  mixin() {
    const span = trace.getSpan(context.active())
    const sc = span?.spanContext()
    return sc ? { trace_id: sc.traceId, span_id: sc.spanId } : {}
  },
})

logger.info("processing order")
// { trace_id: "...", span_id: "...", msg: "processing order" }

Grafana·Honeycomb 등에서 trace_id로 로그를 역추적 가능.

주요 백엔드 통합

백엔드수신
DatadogOTLP (Agent 또는 직접)
HoneycombOTLP native
New RelicOTLP native
DynatraceOTLP native
Grafana Tempo/Mimir/LokiOTLP/Prometheus/Loki
JaegerOTLP native
Azure MonitorOTLP (2024+)
AWS ADOTOTLP, CloudWatch exporter
Elastic APMOTLP native

모두 OTLP를 말할 줄 아는 시대. 애플리케이션은 OTel만 알면 됩니다.

트러블슈팅

Span이 나오지 않음

  • SDK 초기화를 앱 엔트리보다 먼저 로드 (--require·--import)
  • exporter endpoint·프로토콜(gRPC vs HTTP) 확인
  • Collector receiver가 활성

Cold start에 spans 손실

  • BatchSpanProcessor의 scheduled export 대기 중 프로세스 종료 → shutdown() 호출 확인
  • 서버리스에서는 forceFlush() in finally

너무 많은 카디널리티

  • service.instance.id를 pod name으로 하면 폭발 → 의도적 설계

카디널리티 문제는 대개 리소스 속성과 메트릭 속성의 구분을 놓쳐서 생깁니다. Kubernetes에서는 Pod가 재시작될 때마다 이름이 바뀌므로, Pod 이름이 메트릭 시계열의 식별자에 들어가면 배포할 때마다 새 시계열이 생기고 옛 시계열은 남습니다. 인스턴스 단위로 볼 필요가 없는 메트릭이라면 Collector에서 해당 속성을 제거하거나 집계하고, http.route처럼 정규화된 값 대신 실제 URL 경로(/users/123)가 들어가고 있지 않은지도 확인해야 합니다. 라우트가 없는 404 요청이나 스캐너가 보내는 임의 경로가 속성 값으로 들어가면 시계열 수가 제한 없이 늘어납니다.

Collector OOM

  • memory_limiter processor 필수
  • batch 크기 조정
  • Gateway 샤딩

비용 폭증

  • Tail Sampling 도입
  • attribute cleanup
  • 장기 보관 티어(S3)로 이관

체크리스트

  • service.name·service.version·deployment.environment를 모든 서비스에 설정
  • 자동 계측 활성 + 핵심 로직만 수동 계측
  • Collector를 Agent + Gateway로 구성
  • Tail Sampling·attribute 정제로 비용 관리
  • 로그에 trace_id·span_id 포함
  • W3C Trace Context 전파 확인 (멀티 서비스)
  • Operator·Instrumentation CRD로 K8s 자동 주입
  • 비용·카디널리티 주기 모니터링

마무리

OpenTelemetry는 계측 코드와 관측 백엔드 사이에 표준 계층을 두어, 백엔드를 바꿔도 앱을 다시 계측하지 않아도 되게 만드는 프로젝트입니다. 주요 상용·오픈소스 백엔드가 OTLP 수신을 지원하므로 백엔드 선택의 자유가 커졌지만, 대시보드·알림·쿼리는 여전히 백엔드마다 다르므로 “락인이 완전히 사라진다”기보다는 “가장 비싼 부분인 계측 코드가 이식 가능해진다”고 보는 편이 정확합니다. 자동 계측으로 서비스 간 흐름을 먼저 확보하고, Collector에서 샘플링과 속성 정제로 비용을 앱 밖에서 제어하는 구성이 현실적인 출발점입니다.


자주 묻는 질문 (FAQ)

Q. SDK를 설치했는데 Span이 백엔드에 하나도 보이지 않으면 무엇을 확인하나요?

A. 가장 흔한 원인은 SDK 초기화가 애플리케이션 코드보다 늦게 로드되는 것이므로, --require나 --import로 엔트리보다 먼저 로드되는지 확인합니다. 그다음 exporter의 endpoint와 프로토콜(gRPC와 HTTP)이 Collector receiver 설정과 일치하는지 봅니다. 서버리스처럼 프로세스가 금방 끝나는 환경이라면 BatchSpanProcessor가 내보내기 전에 종료되지 않도록 forceFlush()나 shutdown()을 호출해야 합니다.


같이 보면 좋은 글