IT/모니터링

OTel Collector head sampling vs tail sampling, 우리 팀은 결국 뭘 골랐나

gfrog 2026. 5. 27. 21:16
반응형

작년 말부터 트레이스 양이 폭증했다. 서비스가 늘어난 것도 있고, 한 요청이 마이크로서비스 7~8개를 거치다 보니 한 트랜잭션에 span이 200개 가까이 붙는 케이스도 생겼다. 그대로 Tempo에 다 밀어 넣었더니 스토리지 비용이 분기마다 1.6배씩 뛰었다. 샘플링을 손봐야 한다는 결론은 너무 자명했는데, 막상 head냐 tail이냐를 고르는 자리에선 팀 안에서도 의견이 갈렸다.

결론부터 말하면 우리는 두 개를 섞었다. 그래서 이 글은 어느 한쪽이 정답이라는 얘기가 아니다. 각각의 결을 보고, 어디서 어떤 걸 골랐는지 정리한다.

Head sampling — 빠르고 가난한 선택지

Head sampling은 트레이스가 시작되는 시점, 그러니까 SDK가 root span을 만들 때 보낼지 말지를 결정한다. ParentBased + TraceIdRatioBased 조합이 가장 흔하다. 비율을 10%로 잡으면 SDK 단에서 90%는 그냥 버리고 시작한다.

장점은 두 가지다. 네트워크로 나가는 트래픽 자체가 줄고, Collector 쪽 메모리 부담이 없다. 비용 곡선이 예측 가능하다는 게 가장 크다. 트래픽이 2배가 되어도 트레이스 데이터는 2배만 늘어난다.

단점도 두 가지다. 에러 트레이스를 일부러 더 잡지 못한다. 200ms도 안 걸리는 일반 요청과 1.2초 걸리는 P99 outlier가 동일한 확률로 버려진다. 그리고 한 트레이스를 살릴지 말지를 root에서 결정하기 때문에, downstream 서비스에서 뭔가 흥미로운 일이 일어났더라도 시작 단에서 버려졌으면 영원히 못 본다.

Tail sampling — 똑똑하지만 비싼 선택지

Tail sampling은 트레이스의 모든 span이 Collector에 도착한 다음에 결정한다. tail_sampling 프로세서가 정책을 평가한다. 에러는 100%, 느린 요청은 100%, 나머지는 5% 같은 식으로 정책을 쌓는다.

이게 매력적인 이유는 분명하다. 평소엔 적게 보내다가 장애 났을 때 보고 싶은 트레이스만 골라서 보낼 수 있다. 비용 대비 디버깅 가치를 최대로 끌어올린다는 측면에선 거의 유일한 선택지다.

문제는 운영 부담이다. 한 트레이스의 모든 span을 메모리에 일정 시간 들고 있어야 한다. decision_wait는 보통 P99 레이턴시의 2~3배로 잡으라고 권장하는데, 우리 환경에선 30초로 잡았다. 30초치 트레이스를 다 들고 있으려면 메모리가 꽤 든다. num_traces를 잘못 잡으면 OOM이 나고, 너무 크게 잡으면 GC 압박이 온다. 우리 팀도 작년에 이걸로 한 번 새벽에 알람을 받았다.

또 하나, collector를 sharding 해야 한다. 같은 trace_id의 span이 같은 collector로 가야 정책 평가가 의미 있다. 보통 load balancer collector를 앞에 두고 trace_id 기반으로 routing하는 구조를 쓴다. 그러면 인프라가 한 단 더 늘어난다.

우리가 결국 고른 조합

위 두 가지를 고르는 게 아니라 같이 쓴다. 요즘은 이게 거의 합의된 패턴인 것 같다. SDK에서 head sampling으로 50%를 자르고, gateway collector에서 tail sampling으로 또 자른다.

이렇게 한 이유:

먼저 트래픽 폭주가 두렵다. 우리 환경은 트래픽이 평상시의 5배까지 튀는 이벤트가 분기에 한 번씩 있다. 이때 tail sampling만 쓰면 collector 메모리가 그대로 5배가 된다. Head sampling으로 50%만 잘라줘도 collector 부담이 절반이 된다.

두 번째로, 로컬 개발이나 일부 서비스는 head sampling만으로 충분하다. 신뢰성이 중요한 결제 쪽만 tail sampling을 통과시키고, 나머지는 SDK 단의 head sampling으로 끝낸다.

세 번째로, KubeCon EU 2026에서 VictoriaMetrics가 발표한 retroactive sampling 같은 새 방식도 슬슬 보고 있다. 메모리 부담이 크게 줄어든다고는 하는데, 아직 우리 스택에 도입하기엔 검증이 부족해서 그냥 관찰 중이다.

processors:
  tail_sampling:
    decision_wait: 30s
    num_traces: 50000
    expected_new_traces_per_sec: 1000
    policies:
      - name: errors
        type: status_code
        status_code: { status_codes: [ERROR] }
      - name: slow
        type: latency
        latency: { threshold_ms: 1000 }
      - name: rate-limited
        type: probabilistic
        probabilistic: { sampling_percentage: 5 }

대충 이런 모양인데, 정책 순서가 중요하다. status_code/latency를 먼저 잡고 probabilistic은 마지막에 둔다. 정책 평가 비용도 무시 못 한다.

그래서 결론

head냐 tail이냐는 잘못된 질문에 가까웠다. 진짜 결정해야 할 건 샘플링 결정의 부담을 어디에 둘 거냐다. SDK(=애플리케이션)에 둘 거면 head, 인프라(=collector)에 둘 거면 tail. 보통은 둘 다 쓰고 비중을 조절하는 게 답이다.

아직도 우리 팀에선 비율을 두고 한 달에 한 번씩 토론한다. 비용이 더 줄어들면 좋겠지만, 그러다가 P99 outlier를 놓치면 디버깅이 안 된다. 이 균형점은 트래픽과 사고 빈도에 따라 계속 바뀌는 것 같다. 정답이 있다기보단 지속적으로 조정하는 일이라고 본다.

혹시 retroactive sampling 도입해 보신 분 계시면 의견 듣고 싶다. 다음에는 그 쪽도 직접 테스트해 보고 정리해 보려고 한다.

반응형