IT/모니터링

OpenTelemetry Collector Tail Sampling 내부 동작 파헤치기

gfrog 2026. 7. 26. 09:15

트레이스 저장 비용이 슬금슬금 오르기 시작한 게 지난 분기부터였다. 우리 팀은 마이크로서비스 40여 개에서 초당 8만 스팬 정도를 뽑아내고 있었는데, 벤더 청구서가 예산의 두 배를 찍은 걸 보고 "샘플링을 진지하게 다시 봐야겠다"는 결론에 도달했다.

Head sampling은 이미 걷어냈다. 어차피 랜덤으로 10% 남기는 방식은 정작 봐야 할 P99 지연이나 에러 트레이스를 놓치기 일쑤였다. 그래서 tail sampling으로 갈아탔는데, 처음 도입할 때는 "결정을 뒤로 미룬다" 정도의 개념만 알고 시작했다가, 운영하면서 그 안에서 벌어지는 일들이 훨씬 복잡하다는 걸 알게 됐다. 이 글은 그때 내가 코드를 뜯어보며 정리한 노트에 가깝다.

Decision Wait의 의미와 함정

Tail sampling processor의 첫 번째 진입점은 decision_wait다. 기본값이 30초인데, 이 시간이 뭘 의미하는지 처음엔 오해했다. 나는 "트레이스가 완성될 때까지 기다리는 시간"이라고 생각했다. 반은 맞는데, 반은 틀렸다.

사실 내부적으로는 이렇게 동작한다. 스팬이 들어오면 traceID를 키로 해서 메모리 안의 map에 쌓인다. 이 map은 링 버퍼처럼 관리되는데, 각 배치 슬롯이 decision_wait / num_batches 시간마다 하나씩 회전한다. 30초에 10개 배치면 3초마다 한 슬롯이 만료된다. 슬롯이 만료되는 순간, 그 슬롯에 들어있던 traceID들이 정책 평가 대상이 된다.

여기서 함정이 하나. 배치가 만료되기 전에 새로운 스팬이 계속 들어와도 결정은 유예되지 않는다. traceID가 처음 도착한 시점을 기준으로 30초 카운트다운이 시작되고, 그 시점에 도달한 스팬만으로 정책을 평가한다. 즉 루트 스팬이 30초 안에 도착하지 않으면 트레이스는 미완성 상태로 판정된다.

우리는 이걸 모르고 decision_wait: 10s로 시작했다가 배치 처리 잡의 트레이스를 절반 이상 버렸다. 잡 자체가 20~40초 걸리다 보니 루트 종료 시점의 상태(성공/실패)를 기준으로 하는 정책이 무의미해진 거다. decision_wait는 시스템의 P99 트레이스 길이보다 넉넉하게 잡아야 한다.

정책 평가 순서와 OR 시맨틱

Tail sampling의 정책은 여러 개를 걸 수 있다. 우리 팀 설정은 대략 이렇다.

processors:
  tail_sampling:
    decision_wait: 45s
    num_traces: 100000
    expected_new_traces_per_sec: 500
    policies:
      - name: errors
        type: status_code
        status_code: {status_codes: [ERROR]}
      - name: slow-traces
        type: latency
        latency: {threshold_ms: 500}
      - name: baseline
        type: probabilistic
        probabilistic: {sampling_percentage: 5}
      - name: critical-service
        type: and
        and:
          and_sub_policy:
            - name: svc
              type: string_attribute
              string_attribute:
                key: service.name
                values: [payment-api, order-service]
            - name: prob
              type: probabilistic
              probabilistic: {sampling_percentage: 30}

여기서 중요한 건 정책 간의 관계다. 상위 정책들은 OR로 묶인다. 하나라도 "sample" 결정을 내리면 트레이스는 유지된다. 하지만 내부적으로는 모든 정책이 순차적으로 평가되고, 첫 번째로 sample 결정을 내린 정책의 이름이 트레이스에 태그로 붙는다. 그래서 정책 순서가 관측 가능성에 영향을 준다. 에러 정책을 앞에 두면, 에러이면서 동시에 느린 트레이스는 "errors"로 태깅되고 "slow-traces"로는 잡히지 않는다.

그리고 AND 정책은 별개다. 위 예시에서 critical-service는 서비스 이름과 확률을 모두 만족해야 하고, 이건 하나의 원자적 정책으로 평가된다. AND 안에서 다시 정책을 조합할 수 있어서 복잡한 룰을 만들 수 있는데, 남용하면 평가 비용이 커진다. 정책 개수 × 트레이스당 스팬 수만큼 CPU가 갈리기 때문이다.

Load Balancing Exporter가 필수인 이유

이건 도입 초기에 가장 큰 삽질이었다. Collector를 3대로 스케일 아웃했더니 트레이스가 뜯긴 상태로 저장되기 시작했다. 왜냐면 tail sampling은 같은 traceID의 모든 스팬이 같은 collector 인스턴스에 도착해야 한다는 절대 조건이 있기 때문이다.

이걸 해결하려면 앞단에 별도의 "라우팅 레이어" collector를 두고, 거기서 loadbalancing exporter로 traceID hash 기반 라우팅을 해야 한다.

exporters:
  loadbalancing:
    protocol:
      otlp:
        tls: {insecure: true}
    resolver:
      k8s:
        service: otel-sampling-backend.observability
        ports: [4317]

k8s 리졸버가 endpoint 목록을 실시간으로 갱신해준다. Deployment가 아니라 StatefulSet + headless service로 백엔드를 운영해야 endpoint가 안정적이다. 스케일링이나 롤링 업데이트 순간에 traceID 해싱 결과가 바뀌면서 트레이스가 잠깐 쪼개지는 현상이 있는데, 이건 어쩔 수 없이 감수하는 부분이다. 최근 collector-contrib 커뮤니티에서 consistent hashing 개선 논의가 올라왔던데, 아직 우리 프로덕션엔 반영 못 했다.

메모리, 그리고 num_traces의 진짜 의미

num_traces는 collector가 동시에 버퍼링할 수 있는 트레이스의 최대 개수다. 이 값을 넘으면 오래된 트레이스부터 evict된다. 우리 팀 초기 설정은 50000이었는데, 트래픽 급증 시 evict가 발생하면서 결정 이전에 사라진 트레이스가 조용히 드롭됐다. 로그에도 잘 안 남는다. 그래서 지표 하나가 필요하다.

otelcol_processor_tail_sampling_new_trace_id_receivedotelcol_processor_tail_sampling_trace_evicted_from_map 두 지표를 대시보드에 나란히 걸어두고, evict 지표가 0이 아니면 알럿을 쏘게 해뒀다. evict가 조금이라도 발생한다면 num_traces를 올리든지 collector 개수를 늘려야 한다는 신호다.

메모리 계산은 대략 num_traces × 평균_스팬수_per_trace × 스팬_사이즈_바이트다. 우리 환경에서는 트레이스당 평균 30 스팬, 스팬당 2KB 정도라 100000 × 30 × 2048 ≈ 6GB. 여기에 collector 자체 오버헤드 붙으면 8GB pod로 잡아야 안전하게 돌아간다.

최근 몇 달의 변화

작년 말부터 processor.tailsamplingprocessor.usetracestate 피처 게이트가 alpha로 들어왔다. 이게 뭐가 좋냐면, 업스트림 SDK에서 head sampling으로 이미 결정을 내린 트레이스에 대해 tail sampler가 그 결정을 W3C tracestate로 존중할 수 있다. 여러 계층에서 샘플링이 겹칠 때 확률 계산이 꼬이는 걸 막아준다.

그리고 올해 3월쯤 정책 평가 타이밍을 최적화하자는 이슈가 열렸다. 지금은 배치가 만료된 후 모든 정책을 순차 평가하는데, 이걸 스팬이 들어올 때마다 조기 결정할 수 있는 정책(예: error status는 스팬 하나만 봐도 판정 가능)은 미리 처리하자는 아이디어다. 아직 머지 전이지만, 우리처럼 정책 20개 이상 걸어 쓰는 팀에겐 CPU 부담을 크게 덜어줄 것 같다.

그래서 결론

Tail sampling은 마법이 아니다. 앞단 라우팅, 메모리 사이징, 정책 순서, decision_wait 튜닝까지 신경 쓸 게 꽤 많다. 다만 이 모든 걸 감내할 만한 가치는 있었다. 우리 팀 저장 비용은 도입 6주 만에 62% 줄었고, 그러면서도 SRE가 봐야 할 에러/슬로우 트레이스는 100% 보존됐다.

아직 튜닝은 진행형이다. 특히 서비스별로 다른 decision_wait을 걸 수 있으면 좋겠는데 현재 구조론 안 되고, 이건 별도 collector pool을 나눠 우회하는 방식으로 실험 중이다. 다음에 결과가 나오면 그것도 정리해봐야겠다.