IT/모니터링

OpenTelemetry Tail Sampling에서 메모리 폭탄 맞은 이야기

gfrog 2026. 8. 6. 12:17

 

지난주에 관측성 파이프라인이 새벽에 두 번 죽었다. 슬랙 알람이 4시 47분에 왔고, 눈 뜨자마자 노트북 열어서 로그부터 봤다. OTel Collector가 OOM으로 재시작을 반복하고 있었다. 이번 글은 그 삽질을 정리한 회고다. 결론부터 말하면 tail sampling 튜닝을 잘못했고, 트래픽 스파이크와 겹치면서 폭탄이 터졌다.

상황

우리 팀은 대략 40개 서비스에서 스팬을 뽑아 OpenTelemetry Collector 게이트웨이 → tail sampling 전용 Collector → 백엔드(Tempo)로 흘려보내고 있다. 게이트웨이는 15대, 샘플링 Collector는 6대. 각각 c6i.xlarge. 사고 당일 트래픽은 평소 대비 2.3배쯤 튀었다. 프로모션 이벤트가 있었는데 관측성 팀은 그 스케줄을 못 받았다. 이건 사실 조직 문제인데, 여기선 일단 넘어가자.

sampling Collector에 걸어둔 정책은 대충 이랬다.

processors:
  tail_sampling:
    decision_wait: 30s
    num_traces: 200000
    expected_new_traces_per_sec: 5000
    policies:
      - name: errors
        type: status_code
        status_code: { status_codes: [ERROR] }
      - name: slow
        type: latency
        latency: { threshold_ms: 500 }
      - name: probabilistic
        type: probabilistic
        probabilistic: { sampling_percentage: 5 }

새벽 4시 47분, 샘플링 Collector 6대 중 4대가 연달아 OOM으로 죽었다. 남은 2대에 트래픽이 쏠리면서 걔네도 5분 안에 같이 죽었다. 게이트웨이는 살아있었지만 다음 홉이 없으니 스팬을 큐에 쌓다가 backpressure로 인해 게이트웨이도 CPU 100%에서 허덕였다.

잘못된 가설 몇 개

처음엔 그냥 트래픽 스파이크 때문이라고 생각했다. 그래서 샘플링 Collector를 6대 → 12대로 스케일 아웃했다. 근데 안 죽던 게 다시 죽기 시작했다. 이때 좀 이상했다. 인스턴스가 두 배가 됐으면 인스턴스당 부하는 절반이 돼야 정상 아닌가.

두 번째 가설은 expected_new_traces_per_sec 값이 너무 낮다는 거였다. Collector가 내부적으로 sync.Map을 미리 할당하는 크기와 관련 있는 값이니까. 그래서 5000 → 20000으로 올렸다. 큰 효과는 없었다.

세 번째로 의심한 건 백엔드 Tempo였다. 근데 Tempo 메트릭 보니까 아주 여유로웠다. ingester_bytes_received_total은 오히려 평소보다 적게 들어오고 있었다. 즉, 문제는 샘플링 Collector 안에서 스팬이 밖으로 못 나가고 죽는 거였다.

진짜 원인

디버깅하면서 pprof 힙 덤프를 떴다. tailsamplingprocessor.internal/idbatcher가 힙의 60% 이상을 점유하고 있었다. 즉, decision_wait 동안 트레이스를 메모리에 붙잡고 있는 버퍼가 폭발한 거다.

원리를 다시 떠올려보면 tail sampling은 이렇게 동작한다. 트레이스가 완전히 끝나야 판단할 수 있는 정책(예: "이 트레이스에 에러 스팬이 하나라도 있는가?")을 쓰려면, 스팬이 도착하는 대로 결정을 못 내리고 decision_wait 시간(우리 경우 30초) 동안 트레이스ID별로 스팬을 메모리에 다 쌓아둬야 한다. 그리고 30초가 지나면 그때까지 모인 스팬들 보고 "얘 저장할지 말지" 결정한다.

문제는 두 가지가 겹쳤다.

첫째, 트래픽이 2.3배가 되니까 30초 동안 붙잡고 있어야 할 스팬 양도 2.3배가 됐다. 하지만 우리 num_traces 상한(200000)은 그대로였다. 표면적으로는 num_traces가 상한이라 메모리 폭발이 안 날 것 같지만, 실제로는 num_traces에 안 잡히는 spillover 스팬들이 존재한다. 스팬 하나당 payload 크기, attribute 개수에 따라 프로세스 힙이 훨씬 크게 부풀 수 있다.

둘째, decision_wait: 30s가 너무 길었다. 우리 서비스 P99 트레이스 duration이 4초 남짓인데 굳이 30초를 붙잡고 있을 이유가 없었다. 이 값은 예전에 어떤 팀원이 "여유롭게 잡자"고 30초로 세팅한 뒤 아무도 재검토하지 않았다. 부끄러운 얘긴데, 30초는 그냥 관성이었다.

임시 조치

일단 새벽에 잠 못 자고 한 응급 조치는 이렇다.

processors:
  tail_sampling:
    decision_wait: 8s     # 30s → 8s
    num_traces: 100000    # 200000 → 100000
    expected_new_traces_per_sec: 15000
    policies:
      # (동일)

decision_wait을 우리 P99의 두 배쯤인 8초로 줄이고, num_traces도 절반으로 낮췄다. 스케일 아웃한 12대는 유지. 그러니까 이제 스팬을 붙잡고 있는 시간이 30초 → 8초로 줄어서 메모리 점유가 훨씬 낮게 유지됐다. 응급 조치 후 OOM은 다시 안 났다.

8초로 줄이면 8초를 넘기는 긴 트레이스는 판정을 못 받고 드랍된다는 리스크는 있다. 실제로 이 조치 이후 로그를 보니 하루에 두어 건 정도 드랍이 있었다. 우리 팀 도메인에서는 감수 가능한 수준이라 판단했지만, 배치 잡이 많은 서비스라면 다르게 잡아야 한다.

이후 개선

응급조치는 응급조치고, 근본적으로 tail sampling 아키텍처를 다시 봤다. 최근에 Elastic이 컨트리뷰션한 개선 사항 두 개가 눈에 들어왔다. 하나는 ingest 시점에 결정을 앞당길 수 있는 span-ingest sampling 전략, 다른 하나는 Pebble LSM에 트레이스를 디스크로 오프로드하는 pebble tail storage extension이다. 후자는 상한이 RAM이 아니라 디스크 용량이 된다는 점에서, 이번 같은 트래픽 스파이크 시나리오에 확실히 유리하다. 벤치마크상 메모리 65%까지 절감된다는 얘기가 있어서, 다음 분기에 스테이징에서 검증 붙여볼 예정이다.

또 하나 배운 건, tail sampling Collector는 반드시 게이트웨이와 분리해서 전용으로 배포해야 한다는 점. 예전엔 "리소스 아끼자"고 게이트웨이 파드에 tail sampling까지 얹어놓은 적도 있었는데, 그 구성이었다면 이번 사고 때 관측성뿐 아니라 스팬 수집 자체가 마비됐을 거다. 게이트웨이는 상태 없는 리시버로, 샘플링 Collector는 상태 있는 결정 노드로 분리하는 게 정답이었다.

남은 숙제

  • decision_wait을 서비스별로 다르게 잡을 방법. 지금은 단일 값. 배치 잡 서비스는 좀 길게, 실시간 API 서비스는 짧게 잡고 싶은데, 하나의 Collector에서는 안 된다. Collector를 도메인별로 나눠야 하나 고민 중.
  • 알람. 이번 사고에서 OOM은 이미 벌어진 뒤 감지됐다. otelcol_processor_tail_sampling_new_trace_id_received 랑 힙 사용률을 조합해서 조기 경보 알람을 붙여야겠다.
  • 프로모션 스케줄 공유. 이건 조직 얘긴데, 사실 이번 사고의 가장 큰 트리거였다. 인프라 팀 슬랙에 프로모션 캘린더를 붙이자고 얘기 중이다.

혹시 tail sampling 운영하시는 분 중에 decision_wait을 다이나믙하게 조정하는 방법 쓰시는 분 있으면 댓글로 알려주세요. 우리 팀도 아직 고민 중이라 사례가 궁금합니다.