며칠 전 새벽 2시 반쯤, 핸드폰이 또 울렸다. 또 otel-collector 파드 OOMKilled 알람. 이번 주만 네 번째다.
처음에는 그냥 메모리 limit이 작다고 생각해서 1Gi → 2Gi → 4Gi 까지 올렸다. 그래도 죽었다. 8Gi로 올렸더니 죽기 직전까지 가서 GC가 미친듯이 돌면서 export 큐가 밀리고, 결국 백엔드(Tempo)로 가는 trace 데이터가 통째로 30분쯤 누락됐다. 멘탈이 나갔다.
상황
우리 환경은 좀 흔하다. EKS 1.30, otel-collector v0.115 (contrib), DaemonSet으로 노드 28대에 깔려있고, Receiver는 OTLP gRPC/HTTP, Processor는 batch + memory_limiter + resource, Exporter는 otlp(Tempo), prometheusremotewrite(VictoriaMetrics) 두 개를 동시에 쓰고 있다.
평소 메모리는 노드당 600~800Mi 정도였다. 그런데 트래픽이 좀 튀는 시간(주로 새벽 배치/리포팅 잡이 도는 시간대)에 갑자기 메모리가 수직 상승해서 limit을 찍고 죽었다. 죽으면 다시 뜨고, 그동안 노드에서 발생한 trace는 그냥 버려졌다.
처음 의심한 것들
가장 먼저 의심한 건 batch processor의 send_batch_size. 우리는 8192로 잡혀있었는데, 트래픽이 튈 때 큐가 쌓이면서 메모리가 같이 올라가는 건 자연스러웠다. 그래서 1024로 줄였다.
processors:
batch:
send_batch_size: 1024
send_batch_max_size: 2048
timeout: 5s
근데 별로 효과가 없었다. 메모리 그래프 모양이 거의 똑같이 나왔다. 큐가 작아진 만큼 export 빈도가 올라가서 export 쪽에서 막히는 게 보였다. Tempo가 좀 느릴 때 sending_queue가 가득 차면서 receiver 쪽에서 backpressure가 안 걸리고 그냥 메모리에 계속 쌓이는 형태였다.
이게 진짜 답답한 게, 로그만 봐서는 별다른 에러도 없다. 그냥 어느 순간 RSS가 쭉 올라가고, OOMKilled. dmesg 봐도 별 단서가 없다.
진짜 원인 - memory_limiter 위치
문제는 memory_limiter processor의 위치였다. 우리 파이프라인은 이렇게 되어 있었다.
service:
pipelines:
traces:
receivers: [otlp]
processors: [batch, memory_limiter, resource]
exporters: [otlp]
batch가 먼저, 그 다음 memory_limiter. 이게 함정이었다. memory_limiter는 파이프라인 맨 앞에 있어야 한다. 받자마자 메모리 체크해서 임계치 넘으면 receiver에 GRPC error를 리턴해야 클라이언트가 백오프하면서 다시 시도한다. 근데 우리는 batch가 먼저였으니, 데이터가 일단 batch 큐에 다 쌓인 다음에야 memory_limiter가 발동되는 구조였다. 즉, batch 큐가 메모리 limit을 다 먹은 다음에 limiter가 "어 메모리 부족하네요" 하고 외쳐도 이미 늦은 거다.
이거 문서에도 분명히 적혀있다. "memory_limiter MUST come first in the processor pipeline." 근데 우리는 처음에 누가 batch를 앞에 두는 패턴으로 잡아놓고 그게 그대로 쭉 굳어버렸다. 코드 리뷰 때 아무도 못 잡았다. 솔직히 나도 못 잡았었다.
수정한 설정
순서를 바꾸고, 동시에 GOMEMLIMIT 환경변수도 같이 손봤다. Go 1.19+ 에서 추가된 soft memory limit인데, OTel collector 같은 Go 프로세스에서는 이게 굉장히 중요하다. 컨테이너 limit이 4Gi라고 해도 Go GC는 그걸 모른다. GOMEMLIMIT으로 컨테이너 limit의 80% 정도를 알려주면, GC가 그 근처에서 더 자주 돌면서 메모리 사용을 줄이려고 한다.
processors:
memory_limiter:
check_interval: 1s
limit_percentage: 80
spike_limit_percentage: 25
batch:
send_batch_size: 1024
send_batch_max_size: 2048
timeout: 5s
resource:
attributes:
- key: cluster
value: prod-eks-1
action: upsert
service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, batch, resource]
exporters: [otlp]
# DaemonSet env
env:
- name: GOMEMLIMIT
value: "3200MiB"
- name: GOGC
value: "80"
limit_percentage는 cgroup 메모리 limit 기준 80%로 잡았고, spike_limit_percentage는 25%. 즉 80%까지는 정상 동작, 80~95% 사이는 강제로 데이터 drop. 어차피 OOMKilled 나서 다 잃는 것보다 일부만 떨어뜨리는 게 낫다는 판단이다.
결과
수정 배포 후 3일째인데, 노드당 메모리 RSS가 1.2Gi 근처에서 안정적으로 머무른다. 새벽 트래픽 피크 때도 2.5Gi 정도까지만 올라가고 80% 임계치를 넘는 순간 receiver가 RESOURCE_EXHAUSTED 를 리턴하면서 클라이언트(SDK 쪽)가 백오프한다. 클라이언트의 retry로 약간의 데이터 손실은 있지만, OOMKilled로 30분씩 통째로 사라지던 것보다는 훨씬 낫다.
근데 사실 이게 진짜 끝인지는 모르겠다. 트래픽이 더 늘면 또 다른 병목이 나올 거고, 그때는 collector를 DaemonSet에서 분리해서 별도 Deployment로 두는 gateway 패턴을 고려해야 할 수도 있다. 노드 단위로는 한계가 있으니까.
교훈 같은 거
하나는 "공식 문서 좀 제대로 읽자"는 거다. memory_limiter 순서는 OpenTelemetry collector 문서 첫 페이지에 가까운 위치에 적혀있다. 우리는 그냥 처음에 누가 짜놓은 yaml을 복붙해서 쓰다가 그 함정에 빠졌다.
두 번째는 Go 기반 컨테이너 워크로드에서 GOMEMLIMIT을 안 쓰는 건 거의 자살행위에 가깝다는 것. 우리 팀에서는 이걸 계기로 모든 Go 기반 사이드카/에이전트(otel-collector, fluent-bit는 Go가 아니지만, vector, jaeger-agent 등)에 GOMEMLIMIT 설정을 표준 helm values로 박아넣기로 했다.
세 번째는, 알람만으로는 부족하다는 것. 우리는 OOMKilled 알람은 있었지만, "트래픽 스파이크 시 collector 메모리 사용률" 같은 선행지표가 없었다. 이제는 collector 자체의 메모리 사용률을 별도 대시보드로 보고 있다.
혹시 비슷한 증상 겪고 계신 분 있으면 일단 memory_limiter 위치부터 확인해보시길.
'IT > 모니터링' 카테고리의 다른 글
| Vector vs Fluent Bit, 6개월 둘 다 굴려본 노트 (1) | 2026.05.23 |
|---|---|
| Prometheus absent 알람, 이거 모르고 쓰면 새벽에 안 울린다 (0) | 2026.05.21 |
| Loki 인덱스가 무릎 꿇은 새벽 — 라벨 카디널리티 삽질 노트 (0) | 2026.05.14 |
| Prometheus remote_write 큐가 메모리를 잡아먹은 새벽 (0) | 2026.05.10 |
| Thanos vs Mimir, 둘 다 1년쯤 굴려보고 정리한 트레이드오프 (0) | 2026.05.09 |