OpenTelemetry Collector batch processor에서 삽질한 이야기
지난주에 OTel Collector가 메모리를 계속 먹다가 OOMKilled로 재시작되는 사고를 겪었다. 클러스터 전체 트레이스가 5분 단위로 뚝뚝 끊기는데, 그 시점 대시보드는 그냥 흰 도화지였다. 새벽 2시에 페이지가 울렸고, 눈을 뜨자마자 "batch processor" 4글자가 머리에 떠올랐다. 왜 하필이면 걔였는지, 지금 돌아보면 뻔한 이유가 있었지만 당시엔 한참을 헤맸다.
우리 팀은 OTel Collector를 Deployment 3replica로 돌리고 있다. Ingress-nginx, 애플리케이션 SDK, kube-state-metrics에서 오는 트레이스/메트릭/로그를 모아 Tempo, Prometheus, Loki로 흘려보낸다. 트래픽 자체는 초당 스팬 8만개 정도. 그리 크지 않다. 그런데 어느 순간부터 각 Pod 메모리가 2GB에서 8GB까지 톱니처럼 올랐다가 OOM으로 죽는 패턴이 반복됐다.
처음에 의심한 것들
가장 먼저 의심한 건 exporter 쪽이었다. Tempo가 병목이면 큐가 쌓이고 메모리가 늘 테니까. 근데 Tempo distributor의 수신 latency는 30ms 안쪽. 큐잉 지표(otelcol_exporter_queue_size)도 평온했다. Tempo는 죄가 없었다.
두 번째로 memory_limiter를 봤다. 4GB로 잡혀 있길래 늘려봤다. 6GB, 8GB, 12GB. 늘리는 만큼 그대로 채워서 죽었다. 메모리 리미터는 "이 이상 쓰면 데이터를 버려" 지시일 뿐, 진짜 원인을 눌러주진 않는다. 이걸 알면서도 그때는 뭐라도 잡고 싶었던 것 같다.
세 번째로 receiver가 이상한 페이로드를 받나 싶어서 debug exporter를 잠깐 붙였다. 스팬 하나가 1MB 넘게 오는 경우가 몇 개 보였다. HTTP body 전체를 attribute로 넣어놓은 서비스가 하나 있긴 했다. 근데 그것만으로 8GB까지 부풀 리가 없었다. 여기서 한 두 시간을 날렸다.
진짜 원인은 batch processor 설정이었다
새벽 4시쯤, Collector 프로파일링을 뜨고 pprof를 봤다. go.opentelemetry.io/collector/processor/batchprocessor 아래로 힙 점유가 몰려 있었다. 그제서야 batch 설정을 다시 열어봤다.
processors:
batch:
send_batch_size: 30000
send_batch_max_size: 50000
timeout: 10s
metadata_keys:
- tenant_id
- service.namespace
문제가 두 개 겹쳐 있었다. 하나는 send_batch_size. 기본이 8192인데, 예전에 어떤 문서 보고 "크게 잡으면 exporter가 편해진다"고 30000까지 올려놨었다. 스팬 하나가 평균 3KB라고 치면 배치 하나가 90MB. 여기까진 그럴 수 있다. 근데.
진짜 범인은 metadata_keys였다. 우리는 멀티테넌트 환경이라 tenant별로 나눠서 export하려고 tenant_id를 metadata key로 걸어뒀다. 서비스 네임스페이스도 같이. 문서에 이런 문장이 있다.
Each distinct combination of metadata triggers the allocation of a new background task in the Collector that runs for the lifetime of the process, and each background task holds one pending batch of up to send_batch_size records.
읽고 나서 조용해졌다. 우리 tenant가 40개, 네임스페이스 조합까지 하면 실제 조합 수가 200개쯤 됐다. 200 × 90MB = 18GB. 이게 이론상 최대치. 트래픽이 갑자기 몰릴 때 그 근처까지 갔다가 OOM으로 갔던 거다.
metadata_cardinality_limit이 기본 1000으로 걸려 있긴 한데, 그 안에서도 조합이 늘어나면 늘어나는 대로 메모리를 쓴다. 리미트는 무한대 폭주를 막을 뿐이지, 200개면 200개 만큼의 병렬 배치가 진짜로 존재한다.
뭘 바꿨나
일단 급한 불부터 껐다. send_batch_size를 8192로, send_batch_max_size를 16384로 내렸다. 이것만으로 즉시 Pod 메모리가 2GB 이하로 안정됐다. tenant별 배치를 유지하고 싶긴 했지만, 최소한 배치 크기는 방어적으로 잡아야 한다는 걸 배웠다.
processors:
memory_limiter:
check_interval: 1s
limit_percentage: 75
spike_limit_percentage: 15
batch:
send_batch_size: 8192
send_batch_max_size: 16384
timeout: 5s
metadata_keys:
- tenant_id
metadata_cardinality_limit: 100
memory_limiter는 파이프라인 맨 앞에 두라는 게 정석인데 우리는 batch 뒤에 있었다. 이것도 순서를 바꿨다. 앞단에서 컷하면 뒤로 흐르는 양 자체가 줄어든다. metadata_cardinality_limit도 우리가 실제로 서비스하는 tenant 수 근처로 좁혔다. 초과분은 어차피 우리 것이 아니니까 드롭돼도 상관 없다.
service.namespace는 metadata_key에서 뺐다. 굳이 그걸로 배치를 나눠야 할 이유가 없었다. exporter routing이 필요하다면 processor 단이 아니라 routing connector로 처리하는 게 맞다는 결론.
배운 것
첫째, batch processor의 send_batch_size는 기본값 8192를 웬만하면 건드리지 말자. Dash0에서 얼마 전에 "이 컴포넌트는 결국 사라질 방향으로 간다"는 글도 나왔더라. 언젠간 exporter queue가 batching을 흡수하는 방향으로 리팩터링될 것 같다. 어차피 미래에 없어질 거라면 지금 이걸 열심히 튜닝하는 시간이 아깝다.
둘째, metadata_keys는 카디널리티 곱으로 메모리가 늘어난다. 이건 진짜 함정이었다. 우리 팀은 처음 설계할 때 "테넌트별로 나누면 export도 깔끔하잖아" 정도의 이유로 넣었는데, 그 결정이 6개월 뒤 새벽에 나를 깨웠다. 설정 하나 넣을 때마다 최악의 경우 얼마나 부풀 수 있는지 곱해봐야 한다.
셋째, memory_limiter는 안전벨트지 브레이크가 아니다. 리미트만 늘리면 문제가 뒤로 미뤄질 뿐 사라지진 않는다.
혹시 비슷한 상황 겪으신 분 있으면 어떻게 푸셨는지 댓글로 알려주세요. 특히 멀티테넌트 파이프라인에서 tenant별 export를 우아하게 나누는 패턴은 아직도 정답을 못 찾았다.