IT/모니터링

OpenTelemetry Collector 메모리 누수, 며칠 싸운 기록

gfrog 2026. 5. 8. 09:13
반응형

지난주에 우리 팀 OpenTelemetry Collector 파드들이 갑자기 OOMKill 잔치를 벌였다. 평소 워킹셋이 1.2GB 정도였는데, 어느 날 새벽부터 4GB까지 치솟더니 limit(6GB)을 넘기고 죽기 시작했다. 트래픽이 갑자기 늘어난 것도 아니고 설정을 건드린 것도 아니었다. 그라파나 패널 보면서 "아 이거 또 시작이네" 싶었다.

결론부터 말하자면 batch processor의 send_batch_size를 잘못 키운 게 시작이었고, 거기에 exporter queue가 백프레셔를 못 받아주면서 메모리가 무한정 쌓였다. 글 안에 다 풀어쓰겠지만, 비슷한 증상 보시는 분들은 일단 memory_limiter부터 위에 끼워두시는 걸 권한다.

증상 — 처음 3시간 동안 본 것

오전 5시 23분에 첫 페이지가 떴다. otel-collector-gateway-7 파드가 OOMKilled. 1분 뒤에 otel-collector-gateway-3. 5분 뒤에 또 다른 파드. 우리는 게이트웨이 콜렉터를 6대 운영하고 있었는데, 한 대씩 차례로 죽으니까 트래픽이 살아남은 파드로 몰리고, 그 파드가 또 죽고, 도미노였다.

처음에는 그냥 트래픽 스파이크인 줄 알았다. kubectl top pod 찍어보니 메모리는 limit 근처인데 CPU는 멀쩡했다. exporter 쪽에서 백엔드(Mimir, Tempo)로 못 보내고 있나 싶어서 백엔드 헬스 체크부터 봤는데 그쪽은 깨끗했다.

NAME                                       CPU(cores)   MEMORY(bytes)
otel-collector-gateway-2                   180m         5.8Gi
otel-collector-gateway-4                   210m         5.9Gi
otel-collector-gateway-5                   190m         3.2Gi

CPU 200m 수준에 메모리만 5.9Gi. 평소엔 1.2Gi 였으니까 5배. 이게 메모리 누수의 전형적인 그림이다.

첫 번째 삽질 — pprof로 헛다리 짚기

콜렉터가 pprof를 노출하고 있어서 일단 heap 덤프부터 떴다.

kubectl port-forward otel-collector-gateway-2 1777:1777
go tool pprof http://localhost:1777/debug/pprof/heap

top 보니까 confmap.Conf 객체가 메모리의 30%를 먹고 있었다. 어? 이게 왜 이렇게 많지? 한참 의심했는데, 결국 이건 정상이었다. 콜렉터 내부에서 컨피그를 여러 컴포넌트가 참조하다 보니 retention이 좀 있는 거지 누수는 아니었다. 새벽 3시에 눈이 번쩍 떠진 채로 30분 헛다리.

진짜 문제는 두 번째 덤프에서 보였다. runtime.allocm이랑 bytes.Buffer.grow가 시간이 지날수록 계속 커졌다. 누수가 아니라 — 정확히는 누수가 아니라 — 버퍼에 데이터가 계속 쌓이고 있는 거였다.

진짜 원인 — batch processor와 exporter queue의 합작

설정을 다시 봤다. 이건 한 달 전에 우리 팀에서 "처리량 좀 올려보자"고 손댔던 그 설정이다.

processors:
  batch:
    send_batch_size: 16384
    send_batch_max_size: 32768
    timeout: 5s

exporters:
  otlp/tempo:
    endpoint: tempo-distributor:4317
    sending_queue:
      enabled: true
      num_consumers: 10
      queue_size: 5000
    retry_on_failure:
      enabled: true
      max_elapsed_time: 300s

문제 1: send_batch_size가 16384. 우리가 처리하는 span이 평균 2KB 정도니까 한 배치가 32MB다. send_batch_max_size까지 가면 64MB. 이게 큐에 5000개 쌓이면 — 산수해보자. 32MB × 5000 = 160GB. 물론 한 번에 다 차진 않지만, exporter가 잠깐만 느려져도 메모리가 우주 끝까지 갈 수 있는 구조다.

문제 2: memory_limiter processor가 파이프라인에 없었다. 누가 떼냈는지는 git blame을 안 봤다(어차피 내가 떼낸 거 같다). 메모리 가드가 없으니 콜렉터는 계속 데이터를 받았다.

문제 3: 그날 새벽 백엔드 Tempo distributor 한 대가 GC pressure 때문에 잠깐 느려졌다. 평소 5ms 응답이 200ms로 떨어졌다. 그게 트리거였다. 큐가 차고, 큐 객체들이 GC 대상이 안 되고, OOM.

Dash0의 "Why the OpenTelemetry Batch Processor is Going Away (Eventually)"에서도 batch processor 자체가 결국 사라질 거라고 얘기하는데, 그 이유 중 하나가 정확히 이거다. exporter 레벨에서 batching을 하는 게 메모리 측면에서 더 안전하다는 것.

수습 — 일단 응급처치

새벽이라 일단 살리는 게 우선이었다. 두 가지 했다.

첫째, send_batch_size를 8192로, send_batch_max_size를 16384로 내렸다. 이론적으로 하한선까지 더 내릴 수 있는데, 그러면 export RPC 횟수가 늘어서 백엔드 부하가 다른 식으로 올라간다. 절반에서 시작했다.

둘째, memory_limiter를 다시 끼웠다.

processors:
  memory_limiter:
    check_interval: 1s
    limit_percentage: 75
    spike_limit_percentage: 25

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, batch]   # 순서 중요
      exporters: [otlp/tempo]

memory_limiter는 반드시 파이프라인 첫 번째에 와야 한다. 이게 한도 넘기면 receiver를 거절(soft refuse)해서 백프레셔를 송신측으로 던진다. 콜렉터가 데이터 다 받아놓고 뒤에서 죽는 게 아니라, 받기 전에 거절해서 살아남는 구조.

이 두 개만 적용했더니 30분 안에 메모리가 1.5GB 수준으로 안정됐다. 살았다.

그 다음 — 좀 더 근본적인 변경

응급처치 후에 며칠 천천히 손봤다. 바뀐 부분은 세 가지다.

1) exporter sending_queue를 영속 큐로 변경

exporters:
  otlp/tempo:
    sending_queue:
      enabled: true
      num_consumers: 10
      queue_size: 1000
      storage: file_storage/queue   # 파일에 보관

큐 사이즈를 1000으로 줄이고, 대신 디스크에 백업되는 file_storage 익스텐션을 붙였다. 메모리에 다 들고 있지 말고, 못 보낸 건 디스크에 쟁이라는 얘기다. 쓰루풋이 약간 떨어지긴 하는데(디스크 IO 비용), 메모리 안정성과는 비교가 안 된다.

2) Pipelines를 분리

trace, metric, log를 한 파이프라인으로 묶어 쓰던 걸 분리했다. metric은 처리량은 적은데 cardinality가 폭발적이고, trace는 큐가 깊어지면 메모리를 많이 먹는다. 이걸 한 큐로 처리하면 한쪽이 망가질 때 다른 쪽도 같이 망가진다. 분리하니까 blast radius가 작아졌다.

3) Collector를 Agent + Gateway로 분리

이건 원래도 어느 정도 분리되어 있었지만, 더 깔끔하게 갈랐다. 노드 레벨 agent는 batch 없이 바로 forward만, gateway에서만 batch + sampling. 이렇게 하니까 부하가 한 곳에 안 몰린다.

배운 것 몇 가지

send_batch_size 무작정 키우면 안 된다. 처리량 늘린다고 했던 건데, 그게 뒤통수를 친다. 큰 배치 = 큰 메모리. 백엔드가 잠깐만 느려져도 큐가 폭발한다. 처리량은 batching보다 num_consumers로 푸는 게 안전한 경우가 많다.

memory_limiter 없이 콜렉터 운영하지 마시라. 진짜로. 한 줄짜리 설정이 최후의 보루다.

원인을 찾기 전에 그래프를 더 봤어야 했다. heap pprof가 화려해 보여서 거기 빠졌는데, 사실 답은 otelcol_exporter_queue_size 메트릭에 다 있었다. 이 메트릭이 평소 100 미만이다가 사고 직전부터 4500까지 올라갔다. 콜렉터가 자기 메트릭을 다 노출하는데 우리가 그걸 대시보드에 안 박아뒀던 거다. 부끄럽다.

다음에는 OpenTelemetry Collector v0.105에서 들어온 새로운 exporter helper의 batcher 기능 — exporter 레벨 배칭 — 으로 옮겨볼 생각이다. 이게 안정되면 batch processor를 빼도 될 것 같다. 검증되면 또 글 쓸게요.

혹시 비슷한 OOM 보시는 분 있으면 댓글로 환경 좀 알려주세요. 어떤 receiver 쓰시는지, 큐 사이즈 얼마인지가 특히 궁금합니다.

추가 리소스

반응형