# OTel Collector를 어디에 둘 것인가 - 데몬셋과 게이트웨이는 대체재가 아니다

OpenTelemetry Collector를 클러스터에 올릴 때 첫 결정은 배치 형태입니다. 노드마다 데몬셋으로 둘지, 별도 Deployment 하나로 게이트웨이를 세울지. 게이트웨이가 관리할 것이 적어 보여 그쪽부터 시작하는 경우가 많습니다.

그리고 앞단에 에이전트를 붙이는 순간 트레이스에 k8s.pod.name이 전부 컬렉터 파드로 찍힌 것을 발견합니다. 반대로 데몬셋으로만 구성하면 테일 샘플링이 이상하게 동작합니다.

두 배치는 대체재가 아니라 서로 다른 문제를 푸는 계층입니다. 이 글에서는 각 배치가 무엇을 할 수 있고 없는지, 왜 대개 두 계층이 필요한지 정리합니다.

# 1. 게이트웨이가 연결 IP로 매칭하면 메타데이터가 잘못 붙는다

Kubernetes 메타데이터를 붙이는 것은 k8sattributes 프로세서의 일입니다. 이 프로세서가 "이 텔레메트리가 어느 파드에서 왔는가"를 판정하는 기본 방식이 문제의 출발점입니다.

By default, it associates the incoming connection IP to the Pod IP.

연결의 출발지 IP를 파드 IP로 간주합니다. 애플리케이션이 컬렉터에 직접 연결하는 구조에서는 맞습니다. 그런데 중간에 무언가가 끼면 틀립니다.

게이트웨이 배치에서 애플리케이션 → 게이트웨이로 바로 보내면 연결 IP가 곧 애플리케이션 파드 IP이므로 동작합니다. 문제는 계층이 생기는 순간입니다. 에이전트를 거쳐 게이트웨이로 보내면 게이트웨이가 보는 연결 IP는 에이전트의 IP입니다. 그러면 모든 텔레메트리에 에이전트 파드의 메타데이터가 붙습니다.

해결은 앞단에서 파드 IP를 리소스 속성으로 실어 보내고, 뒷단이 그 속성으로 매칭하게 하는 것입니다.

# 게이트웨이 쪽: 연결 IP가 아니라 전달받은 속성으로 매칭
processors:
  k8sattributes:
    pod_association:
      - sources:
          - from: resource_attribute
            name: k8s.pod.ip
      - sources:
          - from: resource_attribute
            name: k8s.pod.uid

앞단 에이전트는 이 값을 채워 주기만 하는 통과 모드로 둡니다. 즉 에이전트가 "누가 보냈는지"를 알아내고, 게이트웨이는 그 정보를 신뢰하는 분업입니다.

여기서 왜 에이전트가 필요한지가 드러납니다. 파드 IP를 알아내려면 그 파드와 같은 노드에 있거나 직접 연결을 받아야 합니다. 에이전트 뒤에 선 게이트웨이는 구조적으로 그 위치에 있을 수 없습니다.

# 2. 데몬셋의 숨은 비용 - API 서버 부하

그렇다고 데몬셋이 공짜는 아닙니다. k8sattributes는 파드 정보를 알기 위해 Kubernetes API를 감시합니다. RBAC이 필요한 이유이고, 클러스터 스코프 권한이 요구됩니다.

기본 설정으로 두면 각 노드의 컬렉터가 클러스터 전체 파드를 감시합니다. 노드가 100대면 전체 파드 목록에 대한 watch가 100개 걸립니다. 파드 수가 많은 클러스터에서는 이것만으로 API 서버에 부하가 됩니다.

문서가 대응을 제시합니다.

Use the downward API to inject the node name as an environment variable.

# 파드 스펙
env:
  - name: KUBE_NODE_NAME
    valueFrom:
      fieldRef:
        fieldPath: spec.nodeName
# 프로세서 설정
processors:
  k8sattributes:
    filter:
      node_from_env_var: KUBE_NODE_NAME

효과는 명확합니다.

This ensures each processor will only query the k8s API for pods running on its own node, preventing unnecessary API load on large clusters.

데몬셋으로 배포한다면 이 설정은 선택이 아닙니다. 노드 수가 적을 때는 차이가 안 보이다가, 클러스터가 커지면 API 서버 지연으로 나타납니다. 그리고 원인이 관측 스택이라는 것을 떠올리기까지 시간이 걸립니다.

# 3. 게이트웨이가 필요한 진짜 이유 - 상태가 있는 처리

데몬셋으로 메타데이터까지 잘 붙였다면 게이트웨이는 없어도 될 것 같습니다. 그런데 못 하는 일이 있습니다. 여러 노드의 데이터를 한데 모아야 하는 처리입니다.

대표가 테일 샘플링입니다. "에러가 있거나 느린 트레이스만 남긴다"는 판단은 그 트레이스의 스팬이 다 모여야 가능합니다. 그런데 하나의 트레이스는 여러 서비스, 즉 여러 노드에 걸쳐 있습니다. 데몬셋의 각 인스턴스는 자기 노드의 스팬만 보므로 판단할 근거가 없습니다.

이 문제를 푸는 것이 로드밸런싱 익스포터입니다.

spans belonging to the same traceID ... will be sent to the same backend

트레이스 ID를 기준으로 라우팅해서 같은 트레이스의 스팬이 같은 게이트웨이 인스턴스에 도착하도록 만듭니다. 그래야 그 인스턴스가 전체를 보고 판단할 수 있습니다.

# 데몬셋 쪽 익스포터 - 게이트웨이로 보내되 traceID로 라우팅
exporters:
  loadbalancing:
    routing_key: traceID
    protocol:
      otlp:
        tls:
          insecure: true
    resolver:
      k8s:
        service: otel-gateway.observability.svc.cluster.local
# 게이트웨이 쪽 - 여기서만 테일 샘플링이 성립한다
processors:
  tail_sampling:
    decision_wait: 30s
    policies:
      - name: errors
        type: status_code
        status_code: { status_codes: [ERROR] }
      - name: slow
        type: latency
        latency: { threshold_ms: 2000 }
      - name: baseline
        type: probabilistic
        probabilistic: { sampling_percentage: 1 }

일반 로드밸런서를 쓰면 안 됩니다. 라운드로빈으로 분산되면 같은 트레이스의 스팬이 여러 인스턴스로 흩어져 테일 샘플링이 부분적인 트레이스만 보고 판단합니다. 결과가 틀리는데 오류는 안 나므로 발견이 늦습니다.

스팬에서 메트릭을 만드는 처리에도 같은 제약이 있습니다. 서비스 단위 집계라면 routing_key: service가 맞고, 트레이스 단위 판단이라면 traceID입니다. 무엇을 집계하느냐가 라우팅 키를 정합니다.

샘플링 시점에 따라 무엇을 할 수 있는지가 달라지는 문제는 스팬에 무엇을 넣지 말아야 하는가 (opens new window)에서도 짚었는데, 결론이 여기로 이어집니다. 결과를 보고 샘플링하려면 게이트웨이 계층이 있어야 합니다.

# 4. 두 계층의 역할 분담

정리하면 표준 구성이 이렇게 나옵니다.

계층 배치 담당
에이전트 데몬셋 수신, 호스트·k8s 메타데이터, 배치, 압축
게이트웨이 Deployment 테일 샘플링, 스팬 메트릭, 백엔드 인증, 재시도 큐

역할을 나누는 기준은 위치가 필요한 일인가, 전체가 필요한 일인가입니다.

  • 파드가 누구인지 아는 것은 위치가 필요합니다 → 에이전트
  • 트레이스 전체를 보는 것은 모음이 필요합니다 → 게이트웨이

백엔드 자격 증명을 게이트웨이에만 두는 것도 실질적인 이점입니다. 데몬셋은 모든 노드에 있으므로, 거기에 백엔드 API 키를 두면 노드 수만큼 유출 표면이 늘어납니다. 게이트웨이 파드 몇 개로 좁히는 편이 낫습니다.

재시도 큐도 게이트웨이에 두는 편이 낫습니다. 백엔드가 잠깐 죽었을 때 버퍼링해야 하는데, 데몬셋에 큰 큐를 두면 노드 메모리를 노드 수만큼 먹습니다.

# 5. 사이드카는 언제 쓰는가

세 번째 선택지가 사이드카입니다. 파드마다 컬렉터 컨테이너를 하나 더 붙입니다.

장점은 격리입니다. 파드별로 다른 설정이나 다른 백엔드를 쓸 수 있고, 네트워크 홉이 없어 손실 가능성이 낮습니다. 애플리케이션이 localhost로 보내면 됩니다.

대가가 큽니다. 파드마다 컨테이너가 하나씩 늘어납니다. 파드 1000개면 컬렉터 1000개이고, 각각이 메모리를 잡습니다. 설정을 바꾸려면 모든 파드를 재시작해야 합니다.

기준을 정하면 이렇습니다.

상황 배치
일반적인 경우 데몬셋 + 게이트웨이
파드마다 다른 백엔드·설정 사이드카
노드 접근이 제한된 환경(일부 관리형) 사이드카 또는 게이트웨이 직결
아주 작은 클러스터, 테일 샘플링 불필요 데몬셋만
클러스터 밖에서 오는 텔레메트리 게이트웨이

마지막 줄도 게이트웨이의 고유 역할입니다. Lambda나 클러스터 밖 VM에서 오는 데이터는 붙을 노드가 없으므로 게이트웨이로 직접 받아야 합니다.

# 6. 두 계층을 나눌 때 새로 생기는 문제

계층을 나누면 실패 지점이 하나 늘어납니다.

게이트웨이가 병목이 됩니다. 모든 데이터가 통과하므로 게이트웨이의 처리량이 전체 상한입니다. 그런데 3절의 이유로 단순히 레플리카를 늘리는 것으로 해결되지 않습니다. 라우팅 키 기반 분산이므로 특정 트레이스가 몰리면 한 인스턴스만 바빠집니다.

테일 샘플링은 메모리를 씁니다. 판단을 내리기까지 스팬을 들고 있어야 하고, 대기 시간이 길수록 메모리가 늘어납니다. 위 예시의 30초는 "느린 트레이스도 완성될 때까지" 기다리는 값이고, 그동안의 모든 스팬이 메모리에 있습니다. 이 값을 늘리면 메모리 요구량도 곧바로 늘어납니다.

게이트웨이 재시작 때 데이터가 사라집니다. 대기 중이던 스팬은 판단 없이 버려집니다. 롤아웃마다 30초어치 트레이스가 사라진다는 뜻이므로, 게이트웨이 배포는 관측 공백을 만듭니다.

이 셋 때문에 게이트웨이에는 리소스를 넉넉하게 주고, memory_limiter 프로세서를 반드시 걸어야 합니다.

processors:
  memory_limiter:
    check_interval: 1s
    limit_percentage: 75
    spike_limit_percentage: 15
service:
  pipelines:
    traces:
      processors: [memory_limiter, k8sattributes, tail_sampling, batch]

memory_limiter는 파이프라인의 맨 앞에 두어야 의미가 있습니다. 뒤에 두면 이미 메모리를 쓴 다음에 막게 됩니다.

# 7. 직접 확인하는 방법

메타데이터가 제대로 붙는지가 첫 확인입니다.

# 디버그 익스포터로 실제 리소스 속성을 본다
# (게이트웨이 설정에 debug 익스포터를 임시로 추가)
kubectl -n observability logs deploy/otel-gateway --tail=50 | grep -A15 'Resource attributes'

k8s.pod.name이 애플리케이션 파드가 아니라 컬렉터 파드로 찍혀 있으면 1절의 상황입니다.

API 부하 문제는 컬렉터 자신의 메트릭으로 봅니다.

# 컬렉터가 캐시에 들고 있는 파드 수(게이지)와 받는 파드 watch 이벤트량
# v0.146부터 기본 메트릭은 otelcol.k8s.watcher.pod_cache.size / otelcol.k8s.watcher.pod.updated 로 바뀌었다
sum by (pod) (otelcol_otelsvc_k8s_pod_table_size_ratio)
sum by (pod) (rate(otelcol_otelsvc_k8s_pod_updated_total[5m]))

# 노드 필터가 걸렸다면 인스턴스마다 값이 작고 균일해야 한다

테일 샘플링이 온전한 트레이스를 보고 있는지 확인하는 방법도 있습니다.

# 정책별 결정 수 - sampled 대비 dropped 비율
sum by (policy, sampled) (rate(otelcol_processor_tail_sampling_count_traces_sampled[5m]))

# 판단 전에 버려진 스팬 - 이 값이 크면 라우팅이나 대기 시간 문제
rate(otelcol_processor_tail_sampling_sampling_trace_dropped_too_early[5m])

too_early가 계속 잡히면 같은 트레이스의 스팬이 여러 인스턴스로 흩어지고 있거나, decision_wait가 트레이스 길이보다 짧은 것입니다.

파이프라인 전체의 손실도 봐야 합니다.

# 거부되거나 실패한 스팬 - 0에 가까워야 한다
rate(otelcol_processor_refused_spans[5m])
rate(otelcol_exporter_send_failed_spans[5m])
max_over_time(otelcol_exporter_queue_size[5m])   # 게이지라 rate가 아니라 최댓값을 본다

refused_spans가 잡히면 memory_limiter가 동작 중이라는 뜻이고, 게이트웨이 자원이 부족한 상태입니다.

# 8. 트러블슈팅

증상 원인 조치
모든 데이터에 컬렉터 파드 정보가 붙음 연결 IP 기반 매칭 pod_association을 리소스 속성으로
클러스터가 커지자 API 서버가 느려짐 노드마다 전체 파드 감시 node_from_env_var 설정
테일 샘플링 결과가 이상함 트레이스가 여러 인스턴스로 분산 로드밸런싱 익스포터, routing_key: traceID
too_early 드롭이 많음 라우팅 문제 또는 대기 시간 부족 위와 동일, decision_wait 조정
게이트웨이가 OOM 테일 샘플링 대기 스팬 누적 memory_limiter 선두 배치, 리소스 증설
배포할 때마다 트레이스 공백 대기 중 스팬 소실 롤아웃 속도 조절, 공백 감수
레플리카를 늘려도 안 빨라짐 라우팅 키 기반 편중 키 재검토, 게이트웨이 수직 확장
노드 수만큼 API 키가 배포됨 데몬셋에 백엔드 자격 증명 자격 증명을 게이트웨이로
클러스터 밖 데이터가 안 들어옴 데몬셋만 존재 게이트웨이 엔드포인트 노출

# 9. 마무리

  • 두 배치는 대체재가 아닙니다. 에이전트는 위치가 필요한 일(파드가 누구인지), 게이트웨이는 모음이 필요한 일(트레이스 전체 판단)을 합니다.
  • k8sattributes는 기본적으로 연결 IP로 파드를 판정합니다. 계층이 끼면 앞단의 IP가 잡히므로, 파드 IP를 속성으로 실어 보내고 뒷단이 그것으로 매칭해야 합니다.
  • 데몬셋에 node_from_env_var를 안 걸면 노드마다 클러스터 전체를 감시합니다. 클러스터가 커진 뒤에 API 서버 지연으로 드러납니다.
  • 테일 샘플링은 같은 트레이스의 스팬이 한 인스턴스에 모여야 성립합니다. 일반 로드밸런서로 분산하면 오류 없이 틀린 판단을 내립니다.
  • 계층을 나누면 게이트웨이가 새 실패 지점이 됩니다. 메모리 제한을 파이프라인 맨 앞에 두고, 배포 시 관측 공백이 생긴다는 점을 감안해야 합니다.

# 참고