
지난주 새벽에 알림으로 깼다. otel-collector 데몬셋의 절반이 CrashLoopBackOff로 떨어졌다는 메시지. 그 시점에 트레이스 수집이 사실상 끊겨 있어서 우리 팀이 운영하는 서비스 절반 정도의 트레이스 대시보드가 비어 있었다. 평일 새벽 두 시였고 멘탈은 그닥이었다.
처음엔 단순히 노드 메모리 압박이려니 했다. 그런데 살펴보니까 콜렉터만 죽는 거다. 다른 파드는 다 멀쩡. OOMKilled가 한두 번이 아니라 5분에 3-4번씩 반복되고 있었다. 메모리 limit이 1Gi였는데, 어떻게 잡힌 메모리가 그 한도를 매번 풀로 채우고 떨어졌다 다시 살아나고를 반복.
일단 상황 정리
kubectl describe로 보니까 Last State가 Terminated, Reason: OOMKilled, Exit Code 137. 너무 익숙한 그림이다. 그런데 이상한 점은 콜렉터에 들어가는 트래픽 자체는 평소랑 큰 차이가 없었다는 거. 굳이 따지자면 새벽 배치 잡 하나가 트레이스를 좀 더 보내긴 했지만, 분당 스팬 수가 평소의 1.3배 정도. 이걸로 1Gi가 터질 일은 아니다.
설정 파일을 다시 봤다.
processors:
batch:
timeout: 10s
send_batch_size: 8192
resource:
attributes:
- key: env
value: prod
action: upsert
exporters:
otlphttp/tempo:
endpoint: http://tempo-distributor.observability:4318
retry_on_failure:
enabled: true
initial_interval: 5s
max_interval: 30s
max_elapsed_time: 300s
여기까지 읽으면서 뭔가 빠진 게 보였다. memory_limiter가 없다. 그리고 retry 설정이 무려 5분이다. exporter 쪽이 막히면 5분 동안 큐에 쌓이는 구조였다.
진짜 원인
새벽에 Tempo distributor가 30초쯤 응답을 안 한 시점이 있었다. (왜 그랬는지는 별개 조사로 빠졌다. 일단 콜렉터 쪽 얘기.) 30초 동안 OTLP exporter는 retry 큐에 데이터를 계속 쌓는다. 우리는 sending_queue를 default(1000) 그대로 뒀고, queue_size 한 칸당 들어가는 batch가 8192 스팬짜리.
계산해보면 1000 × 8192 = 약 820만 스팬. 한 스팬 평균 1KB로 잡아도 8GB. 1Gi 한도에 8GB를 욱여넣으려고 했으니 당연히 OOM. 그리고 OOMKilled 후 재시작하면 디스크의 persistent queue가 없으니 메모리 큐는 비워지지만, 새로운 트래픽은 계속 들어오고, Tempo는 여전히 느리고, 다시 OOM. 무한루프.
여기서 진짜 황당했던 건, 콜렉터 본인이 백프레셔를 줄 수 있는 메커니즘이 분명 있는데 그게 꺼져 있었다는 점이다. memory_limiter는 추가하면 되는 거였고, 우리는 처음 콜렉터 도입할 때 "필요하면 나중에 넣자"고 미뤘던 항목이었다. 그 "나중"이 새벽 두 시에 왔다.
그래서 어떻게 고쳤나
응급으로 우선 콜렉터 replica를 5개에서 12개로 임시 스케일아웃했다. 인스턴스당 트래픽을 분산시켜 일단 살려놨다. 그 사이에 설정을 고쳤다.
processors:
memory_limiter:
check_interval: 1s
limit_percentage: 80
spike_limit_percentage: 20
batch:
timeout: 10s
send_batch_size: 8192
resource:
attributes:
- key: env
value: prod
action: upsert
exporters:
otlphttp/tempo:
endpoint: http://tempo-distributor.observability:4318
sending_queue:
enabled: true
num_consumers: 10
queue_size: 200
retry_on_failure:
enabled: true
initial_interval: 5s
max_interval: 30s
max_elapsed_time: 120s
service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, resource, batch]
exporters: [otlphttp/tempo]
핵심은 세 가지다.
첫째, memory_limiter를 파이프라인 맨 앞에 둔다. 이건 공식 문서에서 명시적으로 권장하는 부분인데, 백프레셔를 receiver까지 빨리 전파하려면 첫 번째 프로세서로 둬야 한다. 우리는 limit_percentage를 80, spike를 20으로 잡았다. 컨테이너 메모리 limit의 80%까지 도달하면 soft limit, 거기서 20%P 더 쌓이면 hard limit으로 보고 수신을 거부한다.
둘째, GOMEMLIMIT 환경변수도 같이 설정했다.
env:
- name: GOMEMLIMIT
value: "800MiB"
memory_limiter는 응급 차단 장치고, GOMEMLIMIT은 Go 런타임 GC를 더 적극적으로 돌리는 장치다. 둘은 다른 레이어에서 작동한다. dash0 가이드에서 두 개를 같이 쓰라고 권장하는데, 우리도 적용 후 OOM이 한 번도 안 났다.
셋째, sending_queue의 queue_size를 1000에서 200으로 줄였다. 큐가 너무 크면 어차피 메모리에서 OOM이 나니까, 차라리 빨리 drop하고 백프레셔를 주는 쪽이 낫다는 판단이었다.
그리고 한 가지 더
문제는 받는 쪽 - Tempo distributor가 왜 30초 동안 응답을 안 했냐였다. 이건 우리 팀 어느 다른 멤버가 추적했는데, distributor의 인메모리 dedup이 cardinality 폭발해서 그랬다고 한다. 별개 이슈고 이건 다음에 정리해서 따로 쓰려고 한다.
여튼 이번 일로 배운 건 명확하다. OTel Collector를 프로덕션에 올릴 때 memory_limiter가 없으면 그건 시한폭탄이다. 처음 도입할 때 "기본값으로 충분하다"는 가정을 했었는데, 콜렉터의 디폴트는 메모리 보호장치가 없는 상태다. 이게 디폴트로 켜져 있으면 좋겠다는 생각도 잠깐 했지만, 사용자가 limit_mib를 직접 정해야 하는 구조라 디폴트로 켜는 것도 애매하긴 하다.
점검 포인트 한 줄 정리
혹시 OTel Collector 운영 중이신 분들은 한번 확인해보면 좋겠다.
- processors에 memory_limiter 있는지
- 파이프라인 맨 앞에 있는지
- limit_percentage와 spike_limit_percentage 합리적인지 (80/20 권장)
- GOMEMLIMIT 환경변수 같이 설정했는지
- sending_queue.queue_size가 너무 크진 않은지
혹시 우리처럼 OOM CrashLoop 겪어보신 분 계시면 어떻게 풀었는지 댓글로 공유해주시면 감사하겠다.
'IT > 모니터링' 카테고리의 다른 글
| Loki structured metadata, 이거 모르면 라벨 카디널리티로 계속 운다 (0) | 2026.06.02 |
|---|---|
| Prometheus native histogram, 사실 내부적으로는 이렇게 동작한다 (0) | 2026.05.31 |
| OTel Collector head sampling vs tail sampling, 우리 팀은 결국 뭘 골랐나 (0) | 2026.05.27 |
| OpenTelemetry Collector가 자꾸 OOM 나서, 결국 memory_limiter와 GOMEMLIMIT을 다시 봤다 (0) | 2026.05.26 |
| Vector vs Fluent Bit, 6개월 둘 다 굴려본 노트 (1) | 2026.05.23 |