OpenTelemetry Collector가 자꾸 OOM 나서, 결국 memory_limiter와 GOMEMLIMIT을 다시 봤다
지난주 새벽에 페이지가 울렸다. OTel Collector DaemonSet이 또 OOMKilled. 이번 분기에만 세 번째다. 솔직히 처음 두 번은 "그냥 limit을 올리지" 하고 넘어갔는데, 이번엔 메모리를 2Gi → 4Gi로 올렸는데도 또 죽으니까 멘탈이 살짝 나갔다.
근본 원인을 보려고 새벽 3시에 노트북을 열었다. 결론부터 말하면 memory_limiter 설정과 GOMEMLIMIT이 둘 다 잘못 잡혀 있었고, batch processor의 순서까지 어긋나 있었다. 우리 팀은 1년 전에 OTel Collector를 처음 도입했을 때 공식 예제 그대로 복붙해 놓고 그동안 트래픽이 4배가 늘었는데도 손을 안 댔던 거다. 부끄럽다.
일단 무슨 일이 일어났던 건가
우리 클러스터는 노드 80대 정도 되고 각 노드에 OTel Collector를 DaemonSet으로 띄워서 트레이스 + 메트릭을 수집한다. exporter는 외부 백엔드(상용 SaaS)로 OTLP/HTTP. 평소엔 노드당 600-800MiB 정도 쓰는데 이번 사고 당시 RSS가 3.8Gi까지 치솟다가 죽었다.
처음엔 단순 트래픽 스파이크인 줄 알았다. 그런데 메트릭을 다시 보니까 RSS는 한참 전부터 슬금슬금 차오르고 있었고 어느 순간 한계를 넘은 거였다. memory_limiter가 떠 있는데 왜 막지 못했을까? 이게 진짜 질문이었다.
memory_limiter는 마법이 아니다
당시 우리 설정은 대략 이랬다.
processors:
batch:
send_batch_size: 8192
timeout: 5s
memory_limiter:
check_interval: 1s
limit_mib: 3500
spike_limit_mib: 500
service:
pipelines:
traces:
receivers: [otlp]
processors: [batch, memory_limiter]
exporters: [otlphttp]
문제가 두 개나 있었다.
첫째, memory_limiter가 batch 뒤에 있었다. 이건 정말 흔한 실수인데 의미가 완전히 거꾸로 된다. memory_limiter는 백프레셔를 receiver 쪽으로 던져야 의미가 있다. 그런데 batch가 앞에 있으면 데이터가 일단 batch에 쌓이고 그 뒤에 memory_limiter가 보는 시점엔 이미 메모리가 차 있는 상태다. 공식 문서에 "must be the first processor in each pipeline"이라고 분명히 적혀 있는데, 우리는 한참 동안 그걸 놓치고 있었다.
둘째, limit_mib: 3500인데 컨테이너 limit이 4096이었다. 즉 soft limit이 hard limit에 거의 붙어 있었다. 보통 soft = 70-80%, hard = 80-85% 정도가 권장값인데 우리는 그냥 "4Gi에서 좀 빼고 잡자"라는 식이었다. 게다가 spike_limit_mib이 500이라 soft가 3000, hard가 3500이 되는 셈이었는데, 트래픽 스파이크가 500MiB를 넘기는 순간 그냥 컨테이너 OOM이었다.
GOMEMLIMIT을 안 잡고 있었다
이게 진짜 헛웃음이었다. Go 1.19부터 GOMEMLIMIT 환경변수로 런타임의 soft memory limit을 잡을 수 있는데, OTel Collector도 이걸 따른다. 안 잡아두면 Go runtime은 자기가 다 써도 된다고 생각하고 GC를 늦게 돌린다. 그러면 RSS가 일시적으로 컨테이너 limit을 넘기고, kubelet은 그걸 보고 그냥 죽인다.
oneuptime 글에서 GOMEMLIMIT을 hard limit의 80% 정도로 잡으라는 가이드가 있었다. 우리는 이걸 아예 안 잡고 있었으니, Go runtime은 4Gi 컨테이너에서도 5Gi, 6Gi 쓸 수 있다고 가정하고 살았던 거다. memory_limiter가 백프레셔를 걸기도 전에 Go GC가 한참 게으르게 돌면서 RSS가 먼저 폭발한 셈이다.
고쳐놓은 설정
이렇게 바꿨다.
processors:
memory_limiter:
check_interval: 1s
limit_mib: 3200
spike_limit_mib: 800
batch:
send_batch_size: 8192
send_batch_max_size: 10000
timeout: 5s
service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, batch]
exporters: [otlphttp]
metrics:
receivers: [otlp]
processors: [memory_limiter, batch]
exporters: [otlphttp]
그리고 DaemonSet env에 추가.
env:
- name: GOMEMLIMIT
value: "3000MiB"
컨테이너 limit은 4096MiB 그대로. 정리하면:
- 컨테이너 hard limit: 4096MiB
- GOMEMLIMIT: 3000MiB (hard의 ~75%)
- memory_limiter limit_mib: 3200 (hard)
- spike_limit_mib: 800 → soft limit ≈ 2400MiB
이 배치라면 RSS가 2400MiB를 넘는 순간 memory_limiter가 receiver에 backpressure를 걸기 시작하고, 3200MiB를 넘으면 강제 GC가 돌고, 그래도 못 막으면 그제서야 컨테이너 limit에 닿는다. 마진이 의도적으로 넉넉하다.
send_batch_max_size도 새로 잡았다. 이게 없으면 batch가 무한정 커질 수 있는데 (timeout 안에 들어온 모든 데이터를 한 번에 묶는다), max_size를 명시해서 한 번에 나가는 배치 크기 상한을 보장했다.
일주일 뒤 측정해 보니
배포 후 일주일 동안 OOM은 0건. 평균 RSS는 1.1GiB 정도. 가끔 spike가 와도 2.5GiB쯤에서 평탄해진다. 백프레셔가 걸리는 순간 OTLP exporter 쪽에서 reject 카운터가 살짝 올라가는데, 이게 정상 동작이다. 죽는 것보다 일부 데이터를 거절하는 게 훨씬 낫다.
그래도 한 가지 찜찜한 건, 우리가 정말 4Gi가 필요한 노드인지 아직 모른다는 거다. 대부분 노드는 평소에 1Gi 쓰는데 limit이 4Gi라 낭비가 적지 않다. 다음 분기엔 노드별 OTLP 트래픽을 측정해서 VPA를 도입하거나 노드 그룹별로 다른 리소스 셋을 줘볼까 한다. 이건 검증 중이라 아직 결론을 못 냈다.
정리하면
memory_limiter가 있다고 안전한 게 아니다. 순서가 맞아야 하고, soft/hard 마진이 있어야 하고, GOMEMLIMIT을 같이 잡아야 한다. 이 셋 중 하나라도 빠지면 결국 OOMKilled를 보게 된다.
혹시 비슷한 패턴으로 OTel Collector 운영하시는 분 있으면, 일단 자기 설정에서 processors 리스트 첫 번째가 memory_limiter인지부터 확인해 보시길.