
OpenTelemetry Collector를 운영하다 보면 refused_spans, enqueue_failed, OOMKilled 같은 시그널을 한 번쯤은 본다. 다들 "memory_limiter 처음에 두면 된다"고 말하지만, 정작 메모리가 한계에 닿았을 때 collector 내부에서 무슨 일이 일어나는지는 의외로 흐릿하게 알고 있는 경우가 많다. 나도 처음엔 그랬다. 어느 날 traces 파이프라인이 갑자기 데이터를 흘리기 시작해서 메트릭을 파보다가, 그때서야 batch와 queue, retry가 메모리 한계 앞에서 어떻게 상호작용하는지 본격적으로 파악하게 됐다.
이 글은 그 흐름을 정리한 노트다. 2026년 2월 즈음 oneuptime이나 dash0 쪽에서 모범 사례 가이드가 다시 한번 정리됐는데, 핵심은 비슷하다. 그래도 "왜 그래야 하는가"는 직접 코드와 메트릭을 봐야 와닿는다.
파이프라인은 사실 동기 호출 체인이다
가장 큰 오해부터. Collector 파이프라인을 "스트림"으로 그리는 그림이 많은데, 실제로는 receiver → processor → exporter가 동기 함수 호출 체인이다. receiver가 ConsumeTraces(ctx, td) 같은 메서드를 호출하면, 그게 모든 processor를 순서대로 거쳐 exporter까지 내려간다. 중간에 비동기 큐가 들어가는 지점은 두 곳뿐이다 — batch processor 내부, 그리고 exporter 앞단의 sending_queue. 나머지는 전부 caller goroutine에서 그 자리에서 처리된다.
이게 왜 중요하냐면, 어떤 processor가 에러를 반환하거나 시간을 끌면 그 펌프 압력이 그대로 receiver까지 거꾸로 전달되기 때문이다. 이게 OTel Collector의 backpressure 본질이다. 별도의 신호 채널이 따로 있는 게 아니다. 그냥 error를 반환하면 그게 backpressure다.
memory_limiter가 데이터를 "거절"한다는 의미
memory_limiter는 check_interval 주기로 메모리 사용량을 측정한다. 기본 1초. 측정값이 soft limit을 넘으면 내부 플래그가 켜지고, 그 뒤로 들어오는 ConsumeTraces 호출에서 즉시 에러를 반환한다. hard limit을 넘으면 추가로 runtime.GC()를 명시적으로 호출해 GC를 강제한다.
여기서 사람들이 자주 놓치는 디테일이 있다. memory_limiter가 반환하는 에러는 consumererror.NewPermanent가 아닌 일반 에러다. 즉, 호출자 입장에서는 "지금은 안 되지만 잠시 후엔 될 수도 있다"는 의미다. exporter의 retry 큐는 이 신호를 보고 잠시 후 다시 시도한다. 만약 permanent로 설정돼 있었다면 그냥 버려졌을 텐데, 그건 아니다.
그리고 첫 processor가 아니라 두 번째에 두면 어떻게 될까? 첫 processor — 예컨대 attributes나 filter — 가 이미 메모리에 데이터를 펼쳐놓은 다음에 거절당한다. 그 메모리는 GC 사이클 한두 번을 기다려야 회수된다. 즉, "memory_limiter는 첫 processor여야 한다"는 규칙은 미관 문제가 아니라 회수 지연 문제다.
batch processor는 별도 goroutine이다
batch processor는 위에서 말한 "비동기가 들어가는 첫 지점"이다. 들어온 데이터를 자체 버퍼에 쌓고, send_batch_size 또는 timeout이 충족되면 별도 goroutine이 다음 단계로 보낸다.
문제는, batch processor는 자체 버퍼에 메모리 상한이 없다는 점이다. 정확히는 send_batch_max_size가 batch 하나의 최대 크기를 정할 뿐, 동시에 처리 중인 batch가 몇 개인지는 제한하지 않는다. 그래서 다운스트림(다음 processor 또는 exporter)이 느려지면 batch processor 안에 처리되지 않은 batch가 점점 쌓인다. 이게 메모리 압박의 두 번째 큰 원인이다.
여기서 미묘한 점. memory_limiter는 batch processor "앞"에 둬도 batch 안에 쌓인 데이터를 막을 수 없다. memory_limiter는 receiver로부터 들어오는 신규 데이터만 막을 뿐, 이미 안쪽 goroutine으로 흘러간 데이터는 손대지 않는다. 즉, 다운스트림이 멈췄을 때 batch processor가 사실상 메모리 폭탄이 될 수 있다는 뜻이다.
processors:
memory_limiter:
check_interval: 1s
limit_percentage: 75
spike_limit_percentage: 20
batch:
timeout: 10s
send_batch_size: 8192
send_batch_max_size: 10000
이 설정에서 다운스트림 OTLP exporter가 답이 없으면, batch processor 안에 N개의 batch가 쌓이고 각 batch는 최대 1만 span을 들고 있다. 메모리 한도가 75%여도 batch 안에 있는 건 한도 계산엔 잡히지만 거절은 안 되니까 그냥 자란다. 결국 한도를 넘기고 거절이 시작되는데, 그때는 이미 batch 자체가 거대해진 뒤다.
sending_queue와 retry, 그리고 영구 손실
exporter 앞단에는 sending_queue가 있다. 기본은 메모리 큐, queue_size로 슬롯 수가 제한된다. queue가 가득 차면 enqueue 자체가 실패하고 호출자에게 에러가 올라간다 — 이게 backpressure로 변환되는 두 번째 지점이다.
retry_on_failure는 이 sending_queue와 별개의 메커니즘이다. exporter가 실제로 전송을 시도했는데 실패한 경우, 지수 백오프로 재시도한다. 재시도 중에도 queue 슬롯을 점유하기 때문에, retry가 길어지면 queue가 채워지고 결국 enqueue가 실패하기 시작한다.
여기서 운영자가 가장 자주 다치는 지점이 있다. retry_on_failure.max_elapsed_time을 너무 길게 잡으면, 잠깐 다운스트림 장애가 났을 때 retry 중인 데이터가 queue를 다 점유해서 신규 데이터 enqueue가 막힌다. 그게 receiver까지 거꾸로 전달되고, receiver가 응답을 늦게 주면 어플리케이션 SDK에서 export 타임아웃이 나면서 결국 trace는 손실된다. 한 다운스트림의 장애가 SDK 쪽 손실로 이어지는 우회 경로다.
지속성 큐(file-backed)를 쓰면 이게 좀 완화되긴 하는데, 디스크 IOPS를 잡아먹는다. 그래서 우리는 트래픽 큰 클러스터에서는 sending_queue를 메모리로 두되 queue_size를 의도적으로 작게 잡고, exporter 측 timeout과 retry max_elapsed_time을 짧게 가져간다. 손실을 늦추는 것보다 빠르게 포기하고 다음 데이터를 받는 게 낫다는 판단이다.
GOMEMLIMIT을 안 잡으면 한쪽 다리로 뛰는 꼴
마지막으로 빼먹기 쉬운 부분. Go 1.19부터 들어온 GOMEMLIMIT 환경변수를 설정하지 않으면, Go GC는 힙 크기를 두 배로 늘리는 GOGC=100 기본값으로 동작한다. 그러면 memory_limiter가 hard limit 80%에서 거절을 시작해도, Go 런타임이 그 시점에서 GC를 적극적으로 돌리지 않아 실제 RSS는 한참 더 올라간다. cgroup OOMKill을 만날 수도 있다.
권장은 GOMEMLIMIT을 collector의 hard limit의 80% 정도로 잡는 것. 이렇게 하면 Go 런타임이 그 한도 근처에서 GC를 더 자주 돌려 RSS가 안정적으로 유지된다. memory_limiter의 hard limit과 GOMEMLIMIT은 비슷한 영역을 다루지만 동작 계층이 다르다 — 전자는 application-level admission control, 후자는 runtime-level GC pacing. 둘 다 잡아야 그림이 맞는다.
실제로 어떻게 잡아내나
지표 측면에서 보는 포인트는 셋이다.
otelcol_processor_refused_spans (또는 logs/metrics)가 0보다 크게 올라오면 memory_limiter가 일하고 있다는 뜻이다. 정상 동작이긴 한데, 지속적으로 올라가면 사이즈가 부족하다는 신호다.
otelcol_exporter_queue_size / otelcol_exporter_queue_capacity 비율이 1에 가까워지면 sending_queue가 압박을 받고 있다는 뜻. 다운스트림 latency나 에러율을 같이 봐야 한다.
otelcol_exporter_send_failed_spans는 retry 한도까지 다 쓰고 결국 버린 데이터의 양이다. 이게 올라오면 진짜 손실이 발생하고 있는 거다.
대시보드에서 이 셋을 함께 두고 보면 backpressure가 어느 단에서 막혀 있는지 한눈에 보인다. 막연하게 "메모리 부족"이라고 적기보다는 어느 layer에서 거부가 시작되고 있는지 명확하게 알아야 튜닝의 방향이 잡힌다.
다 알아도 막상 production에서는 batch size, queue size, retry timing, GOMEMLIMIT, 한도 비율 같은 변수가 서로 얽혀서 한 번에 맞추기 어렵다. 솔직히 처음엔 그냥 가이드 그대로 복붙해도 된다. 다만 트래픽이 일정 규모를 넘어가면 위 흐름을 머릿속에 넣고 봐야 그래프를 읽을 수 있다. 우리 팀도 한참을 헤매다가 결국은 한 번 이 그림을 그려본 뒤로 알람 대응이 빨라졌다.
다음에는 이걸 토대로 multi-tenant 환경에서 처리 우선순위를 어떻게 가져갈지도 한 번 정리해보려고 한다.
'IT > 모니터링' 카테고리의 다른 글
| Loki ingester가 OOM으로 죽고 7시간 동안 로그가 사라진 이야기 (0) | 2026.06.21 |
|---|---|
| 새벽에 burn rate 알람이 안 울렸다 — multiwindow SLO 알람 삽질 노트 (0) | 2026.06.21 |
| VictoriaMetrics vs Mimir, 1년 굴려보고 뭘 쓸까 (0) | 2026.06.15 |
| absent_over_time 알람에 for 절 안 넣으면 생기는 일 (0) | 2026.06.12 |
| Grafana Alloy vs OpenTelemetry Collector, 결국 뭘로 갈까 (0) | 2026.06.12 |