OTel Collector 메모리 폭발한 이야기 - ballast 사라진 줄도 모르고
지난주에 한 번 크게 데였다. 사내 관측성 파이프라인 담당인데, 신규 서비스 트래픽이 붙으면서 OTel Collector 파드가 계속 OOMKilled 로 떨어졌다. 처음엔 그냥 "리소스 좀 올리면 되겠지" 했는데, 결론부터 말하면 그게 문제의 본질이 아니었다.
상황
우리 팀은 클러스터당 DaemonSet + Gateway 형태로 OTel Collector 를 운영한다. Gateway 3대에서 트레이스 초당 4만 span, 메트릭 초당 12만 데이터 포인트 정도를 받아 Tempo / Mimir 로 보낸다. 원래는 안정적으로 돌던 파이프라인이었다.
그런데 신규 서비스가 하나 붙으면서 뭔가 이상해졌다. Gateway 파드가 5분에 한 번씩 재시작. 로그 보면 그냥 컨테이너가 SIGKILL 로 죽어있고, 이벤트엔 OOMKilled. 리소스는 request 2Gi / limit 4Gi 로 잡혀 있었다.
삽질 1회차: 메모리만 올리자
가장 흔한 반사행동. limit 을 8Gi 로 올렸다. 30분 잘 버티다가 또 OOM. 12Gi 로 올렸다. 이번엔 1시간. 근데 이상한 건, container_memory_working_set_bytes 를 보면 처음엔 3Gi 근처에서 잘 놀다가 어느 순간 확 튀어오르면서 limit 을 뚫고 죽는다. 완만한 첕가가 아니라 계단식.
이 시점에서 좀 이상하다 싶었다. 스파이크 트래픽 때문이면 왜 이렇게 갑자기? 그리고 왜 계단식이지?
삽질 2회차: 배치 사이즈 튜닝
두 번째로 의심한 건 batch processor. send_batch_size: 8192, timeout: 200ms 로 잡혀 있었는데, 트래픽이 늘면 배치가 쌓이면서 메모리를 잡아먹는 게 아닐까 싶었다. 배치 사이즈를 2048 로 줄이고 timeout 도 100ms 로 당겼다.
효과는 있었다. 정확히는, 없었다. 배치 크기는 줄었는데 OOM 은 그대로였다. 오히려 exporter 큐가 밀리면서 다른 경고가 켜졌다.
이때 Dash0 블로그 를 우연히 봤는데, "batch processor 가 결국은 없어질 방향이다, exporter 자체 batcher 로 옮겨가라" 는 이야기가 있었다. 흥미로웠지만 지금 문제와는 직접 관련은 없어보였다. 일단 킵.
삽질 3회차: 진짜 원인
새벽 2시에 config 를 다시 정독하다가 발견했다. extensions 섹션에 이런 게 있었다.
extensions:
memory_ballast:
size_mib: 683
이거 뭐지... 하고 최신 Collector 릴리즈 노트를 뒤졌다. 그러다 메모리 ballast 가 제거됐다는 이슈 를 봤다. 우리 이미지 태그는 최근에 자동으로 올라간 상태였고, memory_ballast extension 은 이미 사라진 지 오래된 컴포넌트였다.
근데 왜 파드는 정상적으로 떴을까? 로그를 grep 해봤다.
warn extensions/extensions.go:... ballast extension is deprecated and will be removed
경고 딱 한 줄. 심지어 fail 도 안 나고 그냥 startup 됐다. 그리고 우리 Alloy/Loki 파이프라인에서 warn 레벨은 필터링해서 안 보고 있었다. 그러니까 지난 몇 주 동안 ballast 없이 돌고 있었던 거다.
Go 런타임은 heap 이 작으면 GC 를 자주 돌린다. Ballast 는 이 heap 을 "가짜로 크게" 보이게 해서 GC 빈도를 낮추는 트릭이었다. 근데 그게 사라지면서 어떻게 됐냐면, 평상시엔 별 문제 없다가 트래픽이 살짝만 튀어도 GC 가 미친 듯이 돌기 시작하고, GC pause 때문에 새 데이터가 쌓이고, 그게 다시 heap 을 밀어올리고... 결국 힙이 감당 안 될 때 OOM. 이게 그 계단식 스파이크의 정체였다.
해결
지금은 ballast 대신 GOMEMLIMIT 를 쓰라고 한다. 컨테이너에선 이렇게.
env:
- name: GOMEMLIMIT
valueFrom:
resourceFieldRef:
containerName: otel-collector
resource: limits.memory
divisor: "1"
# 실제로는 limit 의 80% 정도로 잡는 게 안전하다
# 예: GOMEMLIMIT=6GiB (limit 이 8Gi 라면)
Config 쪽은 memory_limiter processor 를 파이프라인 맨 앞에 두고, batch 앞에 놓는다. 이 순서가 중요하다. 뒤에 두면 이미 배치에 물린 데이터는 밀어낼 수가 없다.
processors:
memory_limiter:
check_interval: 1s
limit_percentage: 80
spike_limit_percentage: 20
batch:
send_batch_size: 8192
timeout: 200ms
service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, batch]
exporters: [otlphttp/tempo]
이렇게 바꾸고 나서 3일째 안정. GC 지표 (process_runtime_go_gc_duration_seconds) 도 P99 가 30ms 에서 8ms 로 내려왔다.
교훈이라기보단
- 이미지 자동 업데이트는 편하지만, deprecation 로그를 필터링하고 있으면 소리소문 없이 사라지는 게 있다. warn 레벨 로그를 최소한 매주 한 번은 뒤져봐야 하지 않나 싶다.
- "OOM 이면 메모리 올리기" 반사행동은 진짜 조심해야 한다. 원인 파악 없이 리소스만 올리면 문제가 다른 형태로 바뀔 뿐이다.
- Ballast 는 사라졌지만 그게 왜 있었는지는 여전히 유효한 통찰이다. Go 런타임 특성을 모르면 지금도 비슷한 함정에 빠질 수 있다.
솔직히 아직도 완벽히 해결됐다고 자신은 못 하겠다. 트래픽 패턴이 더 커지면 exporter batcher 로 넘어가는 것도 검토해야 할 것 같다. 우리 팀 내부에선 다음 분기에 아예 Gateway 를 없애고 Agent 에서 바로 백엔드로 쏘는 구조도 논의 중이다. 관측성 인프라는 참 끝이 없다.
혹시 비슷한 경험 있으신 분, 특히 exporter 자체 batcher 로 전환해본 분 있으면 어떻게 튜닝하셨는지 궁금하다.