OpenTelemetry Collector tail sampling, 사실 내부에선 이렇게 돌아간다

지난 분기에 우리 팀은 트레이싱 백엔드를 Tempo로 옮기면서 OpenTelemetry Collector 게이트웨이 레이어를 다시 설계했다. 처음엔 head sampling으로 1%만 떼서 보내고 있었는데, 막상 장애가 터지면 정작 보고 싶은 에러 트레이스가 빠져 있는 일이 잦았다. 그래서 tail sampling으로 바꿨다. 그런데 도입한 지 며칠 지나니 collector 파드가 OOMKilled 당하면서 자꾸 죽는다. memory_limiter는 켜져 있었고, num_traces도 늘렸다 줄였다 하면서 한 주를 보냈다.
문제는 tail sampling processor의 동작 원리를 정확하게 모르고 노브만 돌리고 있었다는 점이다. 사실 내부적으로는 어떻게 돌아가는지를 한 번 정리하지 않으면, 메모리 튜닝이 그냥 도박이 된다. 이 글은 그 정리 노트다.
왜 tail sampling은 비싼가
Head sampling은 트레이스의 첫 스팬이 시작될 때 통계적으로 결정한다. 1%면 1%의 trace ID를 미리 골라두고 그 trace에 속한 스팬만 보낸다. SDK 레벨에서 끝나니 collector는 부담이 거의 없다.
Tail sampling은 다르다. 정의상, 트레이스가 완성된 뒤에 정책을 평가해야 한다. 에러가 있는 트레이스만, 레이턴시가 P99를 넘는 트레이스만, 특정 사용자 ID가 포함된 트레이스만 — 이런 결정은 모든 스팬을 다 본 뒤에야 가능하다. 그러려면 일단 어딘가에 들고 있어야 한다는 얘기고, 그 "어딘가"가 collector의 힙이다.
여기서 첫 번째 잘못된 직관이 생긴다. "트레이스 하나당 스팬 몇 개인데 메모리가 얼마나 들겠어?" 이게 함정이다. tail sampling이 메모리를 쓰는 방식은 평균치로 계산하면 안 된다.
결정 윈도와 num_traces, 진짜 의미
설정에서 가장 자주 만지는 값이 두 개 있다. decision_wait과 num_traces.
decision_wait은 "trace ID가 처음 등장한 시점부터 얼마나 기다린 뒤 정책을 평가할지"다. 기본값은 30초다. 이 시간이 지나면 collector는 그 trace ID에 모인 스팬들을 보고 정책을 평가하고, 통과하면 exporter로, 탈락하면 그냥 버린다.
num_traces는 "동시에 메모리에 들고 있을 trace 개수의 상한"이다. 기본값은 50000.
처음에 나는 이 둘이 독립적인 줄 알았다. 결정 윈도는 시간이고, num_traces는 개수니까. 그런데 둘은 곱셈으로 묶여 있다. 메모리 사용량은 대략 이렇게 잡힌다:
메모리 ≈ 평균 트레이스 크기 × num_traces
≈ 평균 트레이스 크기 × 초당 새 트레이스 수 × decision_wait
두 식이 같은 이유는 정상 상태에선 새로 들어오는 속도와 결정 후 빠지는 속도가 균형을 이루기 때문이다. 그래서 num_traces는 "용량 상한"이지, 실제 사용량을 결정하는 변수가 아니다. 실제 사용량은 트래픽이 결정한다.
근데 이게 한가지 더 꼬인다. num_traces를 트래픽 대비 너무 작게 잡으면 LRU eviction이 발생한다. 결정 윈도가 차기도 전에 trace가 쫓겨나고, 결국 그 trace의 일부 스팬이 버려진다. 평소엔 그냥 일부 트레이스 손실로 보이지만, 트래픽 스파이크가 오면 의미 있는 트레이스부터 같이 날아간다. 결정 시간 도달 전에 evict됐는데 늦게 들어온 스팬은 새 trace로 인식되니, 같은 trace ID가 두 번 나뉘어 평가되기도 한다.
정책 평가는 어떻게 일어나는가
tail sampling processor는 trace ID 단위로 묶은 스팬 묶음에 정책 리스트를 순서대로 적용한다. 정책은 OR로 결합된다. 한 정책이라도 sampled 결정을 내면 그 trace는 통과한다.
processors:
tail_sampling:
decision_wait: 30s
num_traces: 100000
expected_new_traces_per_sec: 1000
policies:
- name: errors-policy
type: status_code
status_code: { status_codes: [ERROR] }
- name: slow-policy
type: latency
latency: { threshold_ms: 500 }
- name: probabilistic
type: probabilistic
probabilistic: { sampling_percentage: 1 }
정책 평가는 결정 윈도가 끝난 뒤에만 도는 게 아니다. 일부 정책 타입은 스팬이 들어올 때마다 incremental하게 평가된다. 예를 들어 status_code 정책은 ERROR 스팬이 보이는 즉시 그 trace를 sampled로 마크할 수 있다. 다만 마크만 해두고 실제로 export는 결정 윈도가 끝나야 한다. 이미 결정난 trace에도 늦게 도착한 스팬이 있을 수 있어서 그렇다.
여기서 두 번째 직관 오류가 나오기 쉽다. "어차피 0.1%만 살릴 건데 메모리가 별로 안 들겠지." 아니다. 결정이 나기 전까지는 모든 trace를 다 들고 있어야 한다. 살릴지 말지 모르니까. tail sampling의 메모리 비용은 sampling_percentage가 아니라 traffic_rate × decision_wait에 비례한다.
OOMKilled를 만든 진짜 원인
우리 케이스로 돌아가자. OOM의 원인은 세 가지가 겹친 거였다.
첫째, expected_new_traces_per_sec를 기본값으로 두고 있었다. 이 값은 내부 해시 맵의 초기 용량을 결정하는데, 우리 트래픽은 초당 8000~12000 trace 수준이었다. 너무 작게 잡으면 맵이 계속 리사이즈되고, 그 과정에서 일시적으로 메모리가 두 배로 튄다.
둘째, 한 trace에 비정상적으로 큰 trace가 가끔 섞여 있었다. 백엔드에서 fan-out 처리하는 잡이 가끔 한 trace에 수만 개 스팬을 만들어 냈다. 비율로는 0.01%도 안 됐는데, 평균을 계산하면 무시할 만했다. 그런데 이 큰 trace 하나가 들어오면 메모리가 폭발적으로 늘어난다. P99 trace size로 capacity planning을 했어야 했다.
셋째, memory_limiter를 tail_sampling 뒤에 두고 있었다. memory_limiter는 ballast 위에서 동작하면서 한계를 넘으면 receiver에 backpressure를 거는 식이다. 그런데 tail_sampling 뒤에 있으면 이미 메모리가 차고 나서야 작동한다. 파이프라인 맨 앞 — receiver 직후에 둬야 들어오는 양 자체를 줄일 수 있다.
새로 알게 된 노브: maximum_trace_size_bytes
이 사고를 겪고 contrib 저장소를 다시 뒤지다가 비교적 최근에 추가된 옵션을 발견했다. maximum_trace_size_bytes. 한 trace의 누적 스팬 데이터가 이 크기를 넘으면 그 trace는 즉시 폐기한다. 우리처럼 대형 trace에 메모리를 잠식당하는 케이스에 정확히 들어맞는다.
processors:
tail_sampling:
decision_wait: 20s
num_traces: 80000
expected_new_traces_per_sec: 10000
maximum_trace_size_bytes: 5242880 # 5 MiB
policies:
- name: errors-policy
type: status_code
status_code: { status_codes: [ERROR] }
- name: slow-policy
type: latency
latency: { threshold_ms: 500 }
이 값을 도입한 뒤 OOM은 사라졌다. 다만 폐기된 trace는 metric으로 노출되니, 그것대로 모니터링해야 한다. 너무 작게 잡으면 멀쩡한 fan-out trace까지 잘려 나간다.
또 하나 눈여겨볼 만한 게 있는데, TailStorage extension이라는 실험적인 기능이 진행 중이다. 인메모리 대신 외부 스토리지에 트레이스를 임시 저장하면서 메모리 사용을 줄이는 방향이다. 아직 production에 쓸 단계는 아니지만, 결국엔 이 방향으로 갈 것 같다. 트레이스를 메모리에서만 다루는 건 본질적으로 확장이 어렵다.
게이트웨이를 어떻게 배치할 것인가
여기까지 와서 보면, tail sampling은 단일 collector에서 끝낼 수 없다는 결론에 도달한다. 이유는 단순하다. 같은 trace ID에 속한 스팬이 같은 collector 인스턴스로 가야 정책 평가가 가능하다. 로드밸런서가 라운드로빈으로 분산하면, trace는 여러 collector에 쪼개지고 어떤 collector도 완전한 trace를 못 본다.
해결책은 이층 구조다. 첫 번째 레이어는 trace ID 기반 hash routing만 한다. loadbalancing exporter가 그 역할을 한다. 두 번째 레이어가 실제 tail sampling을 한다.
SDK → [edge collector] → loadbalancing exporter (trace ID hash)
↓
[sampling collector pool]
↓
Tempo / Jaeger
이 구조에서 sampling collector를 늘리면 처리량은 비교적 선형으로 늘어난다. 단, edge collector는 routing만 하므로 hash 분포가 균등한지 모니터링해야 한다. 우리는 처음에 edge layer를 빼먹고 바로 sampling에 LB를 붙였다가, 일부 trace가 sampling 정책에서 떨어지지 않고 그대로 통과하는 이상한 현상을 봤다. 한참을 헤맨 끝에 trace가 두 collector에 쪼개져서 각각 부분 trace로 평가되고 있던 거였다.
정리하면
tail sampling을 켜기 전에 머릿속에 박아둘 만한 것들을 짚어 보면 — decision_wait × 초당 trace 수가 num_traces의 진짜 의미다. 평균 trace size가 아니라 P99 trace size로 메모리 용량을 잡아야 한다. memory_limiter는 파이프라인 맨 앞에 둬야 한다. maximum_trace_size_bytes를 명시적으로 설정하면 fan-out trace로 인한 폭주를 막을 수 있다. 그리고 trace ID 기반 라우팅이 없으면 tail sampling은 정상 작동하지 않는다.
아직 검증 중인 부분도 있다. TailStorage extension이 GA가 되면 메모리 모델 자체가 바뀔 가능성이 크고, 그땐 num_traces 같은 노브의 의미도 다시 봐야 할 거다. 우리 팀은 일단 maximum_trace_size_bytes와 이층 구조로 6주째 안정 운영 중인데, 트래픽이 더 늘면 다시 깨질 수도 있다.
혹시 이 부분을 다르게 해결한 사례가 있으면 댓글로 공유해 주시면 좋겠다. 특히 trace 분포가 long-tail한 환경에서 어떻게 capacity planning을 하는지 다른 팀의 노하우가 궁금하다.