IT/모니터링

OpenTelemetry Collector tail sampling, 메모리 압박 없이 쓰는 법

gfrog 2026. 7. 30. 00:17

트레이스 샘플링을 tail 기반으로 돌리면 늘 만나는 문제가 있다. Collector 메모리가 훅훅 튀어 오른다. num_traces를 조금만 크게 잡아도 OOMKilled를 반복하고, 반대로 줄이면 span drop이 쌓인다. 우리 팀도 얼마 전까지 gateway 뒤에 붙은 tail-sampling collector 4대가 매일 새벽마다 OOM으로 재시작되는 걸 보고 있었다.

이 글은 그 상황을 정리한 실무 가이드다. 최근에 OpenTelemetry Collector 쪽에서 나온 변화도 짚는다. 사실 이번 달(2026년 7월) Elastic이 upstream에 기여한 span-ingest 샘플링과 Pebble 기반 tail storage extension이 머지되면서 메모리 65%까지 줄었다는 리포트가 올라왔는데, 이게 실제로 어느 정도 도움이 되는지 우리 팀에서도 붙여 봤다.

왜 tail sampling이 메모리를 그렇게 먹는가

Head 샘플링은 SDK에서 trace 시작 시점에 결정한다. 그래서 collector는 이미 걸러진 것만 받는다. 반면 tail sampling은 정반대다. 하나의 trace에 속한 모든 span이 collector에 도착할 때까지 버퍼에 쥐고 있다가, 다 모이면 규칙에 따라 keep/drop을 결정한다.

decision_wait가 30초라면, 30초치 span 전부를 메모리에 들고 있어야 한다는 뜻이다. 여기에 num_traces(동시에 버퍼링할 trace 수)가 곱해진다. 100k trace × 평균 span 20개 × span 하나당 ~2KB만 잡아도 4GB가 그냥 사라진다.

그리고 tail sampling의 전제 조건이 하나 더 있다. 같은 trace의 span이 반드시 같은 collector 인스턴스로 가야 한다. 안 그러면 절반만 보고 결정하게 되니까. 이걸 맞추려고 앞단에 loadbalancing exporter를 두고 trace ID로 라우팅한다. 아키텍처가 자연히 2-tier가 된다.

기본 파이프라인 구조

우리 팀에서 쓰고 있는 구조는 이렇다.

[app SDK]
   │ OTLP
   ▼
[gateway collector]   ← 여러 대, HPA
   │ loadbalancing exporter (routing_key: traceID)
   ▼
[sampling collector]  ← StatefulSet, HPA 안 씀
   │
   ▼
[backend (Tempo/Elastic APM)]

gateway는 stateless라 쉽게 늘리고 줄인다. sampling collector는 상태를 들고 있어서 StatefulSet으로 두고, HPA 대신 리소스를 넉넉히 잡는 쪽으로 갔다. HPA로 늘렸다 줄이면 buffer가 통째로 날아가는데, 그 순간의 trace들은 sampling 판단 없이 그냥 사라진다. 이게 은근히 잡기 어려운 이슈다.

decision_waitnum_traces 감 잡기

이 두 값이 메모리를 지배한다. 가이드는 흔히 이렇게 나와 있다.

  • decision_wait = p99 latency × 2 ~ 3
  • num_traces = 초당 유입 trace × decision_wait × 1.5

말은 쉬운데 실제로는 tuning이 짜증나는 부분이다. p99가 8초면 decision_wait 20~24초. 우리 팀 서비스 중에는 결제 관련해서 p99가 12초 이상 튀는 게 있어서 처음엔 30초로 잡았다. 그런데 30초로 잡으니 num_traces가 커지면서 메모리가 폭발했다.

결국 어떻게 했느냐면, 결제 서비스는 별도 sampling collector pool로 분리했다. 나머지 서비스는 15초짜리 collector로 처리하고, 느린 서비스는 결제 pool로 라우팅했다. 서비스 이름으로 라우팅을 나눈다.

exporters:
  loadbalancing/fast:
    routing_key: traceID
    resolver:
      k8s:
        service: otel-sampling-fast.observability.svc
  loadbalancing/slow:
    routing_key: traceID
    resolver:
      k8s:
        service: otel-sampling-slow.observability.svc

connectors:
  routing:
    default_pipelines: [traces/fast]
    table:
      - context: span
        statement: route() where resource.attributes["service.name"] == "payment"
        pipelines: [traces/slow]

이렇게 하고 나서 결제 pool은 num_traces를 절반으로 줄여도 됐다. 다른 서비스는 원래 규모로 유지. 전체 메모리 사용량이 40% 정도 떨어졌다.

샘플링 정책, 뭘 남길지

정책은 프로세서에서 정한다. 실무에서 매번 넣는 건 세 가지다.

processors:
  tail_sampling:
    decision_wait: 15s
    num_traces: 50000
    expected_new_traces_per_sec: 5000
    policies:
      # 1. 에러는 무조건 남긴다
      - name: keep-errors
        type: status_code
        status_code:
          status_codes: [ERROR]

      # 2. 느린 요청은 남긴다
      - name: keep-slow
        type: latency
        latency:
          threshold_ms: 500

      # 3. 나머지는 확률적으로 5%
      - name: sample-rest
        type: probabilistic
        probabilistic:
          sampling_percentage: 5

여기서 자주 걸리는 함정이 정책 순서. tail_sampling processor는 정책을 OR로 평가하기 때문에 하나라도 keep이면 keep이다. 그래서 "에러인데 느리지 않은 요청은 버리자" 같은 AND 조건은 and 정책으로 감싸야 한다.

- name: error-and-slow
  type: and
  and:
    and_sub_policy:
      - name: is-error
        type: status_code
        status_code: {status_codes: [ERROR]}
      - name: is-slow
        type: latency
        latency: {threshold_ms: 1000}

composite 정책을 쓰면 rate limit도 걸 수 있다. 초당 최대 N개까지만 특정 정책으로 keep하도록 하는 건데, 특정 서비스가 폭발적으로 에러를 뿜을 때 backend가 죽는 걸 막는다.

최근 변화, Pebble tail storage

2026년 7월에 머지된 새로운 옵션이 tail-storage extension이다. Elastic이 기여한 건데, trace 버퍼를 메모리 대신 Pebble(로컬 디스크 기반 KV store)에 쌓는다. NVMe SSD가 있으면 메모리보다 훨씬 큰 num_traces를 쓸 수 있다.

설정은 대충 이렇다.

extensions:
  tail_storage:
    directory: /var/lib/otelcol/tail
    max_size_mib: 8192

processors:
  tail_sampling:
    decision_wait: 30s
    num_traces: 500000
    storage: tail_storage  # ← 이거 추가

우리 팀도 스테이징에 붙여 봤다. 결과는 좀 애매하다. 메모리는 확실히 줄었다. 원래 8GB 쓰던 pod이 2.5GB 정도로 떨어졌다. 대신 P99 sampling latency가 살짝 늘었다. 초당 처리량이 많은 pod에서는 disk write가 병목이 될 여지도 있고. gp3 EBS로는 확실히 부족했고 io2나 NVMe가 있어야 안정적이다.

정리하자면 이런 상황에 유용하다.

  • 메모리는 부족한데 SSD/NVMe는 여유가 있다
  • num_traces를 훨씬 크게 잡고 싶다 (긴 분산 트랜잭션)
  • OOM으로 인한 trace loss가 P99 latency 소폭 증가보다 아프다

반대로 이런 상황에서는 그냥 메모리 버전이 낫다.

  • 초당 유입 trace가 아주 많고, decision_wait는 짧다
  • 노드가 gp3 같은 범용 SSD를 쓴다
  • collector가 memory-heavy 프로파일에 이미 튜닝돼 있다

운영하면서 자꾸 겪는 것들

Load balancer가 traceID로 안 붙는 경우. k8s Service의 sessionAffinity: ClientIP로 잘못 알고 붙이는 팀이 있다. 이건 client IP 기준이라 같은 SDK에서 온 것만 묶는다. Trace 조각은 서로 다른 client에서 오는 것도 많아서 반드시 loadbalancing exporter를 써야 한다.

HPA가 sampling collector에 붙어서 buffer 날려먹기. 이미 언급했지만, HPA로 sampling collector를 늘렸다 줄이면 그 순간의 num_traces만큼 trace가 판단도 못 받고 사라진다. StatefulSet + 넉넉한 리소스로 가는 게 정신 건강에 좋다.

메트릭 없이 튜닝하기. collector 자체가 노출하는 otelcol_processor_tail_sampling_sampling_trace_dropped_too_early, ..._sampling_policy_evaluation_error 같은 메트릭이 있다. 이걸 Prometheus로 긁어서 대시보드에 붙여 두지 않으면 뭐가 잘못됐는지 감이 안 잡힌다.

Backend가 뒤에서 backpressure를 걸 때. Tempo/Elastic 쪽에서 rate limit이 걸리면 sampling collector 앞단 큐에 span이 쌓인다. 이게 tail sampling buffer랑 별개로 메모리를 먹기 때문에 조합이 나쁘면 폭발한다. 뒷단 backpressure를 감안해서 exporter의 sending_queue랑 memory_limiter를 반드시 세팅해 둔다.

마무리

Tail sampling은 켜기는 쉬운데 안정화하는 데 시간이 꽤 든다. 우리 팀도 처음엔 그냥 튜토리얼 그대로 붙였다가 몇 주 동안 새벽 알람 받으면서 지금 구조가 됐다. Pebble tail storage는 아직 우리도 프로덕션까지 못 밀었지만, 조만간 붙여 볼 계획이다. 붙여 보고 P99 sampling latency에 문제 없으면 num_traces를 5배쯤 늘려서 긴 트랜잭션 케이스를 더 잘 잡아 보려고 한다.

혹시 tail-storage extension을 프로덕션에 이미 붙인 분 있으면 댓글 남겨 주세요. 특히 어떤 디스크 스펙을 쓰고 계신지 궁금합니다.


태그: OpenTelemetry, tail-sampling, 관측성, observability, otel-collector, 모니터링