IT/모니터링

OTel Collector 파이프라인, 사실 내부는 이렇게 돌아간다

gfrog 2026. 8. 27. 18:19

OpenTelemetry Collector 를 몇 년째 굴리다 보면 이상하게 감이 안 오는 순간이 온다. memory_limiter 를 batch 앞에 두라는 말은 모두가 하는데, 왜 그래야 하는지 정확히 설명할 수 있는 사람은 의외로 적다. 큐 사이즈랑 배치 사이즈를 같이 튜닝하라는 말도 마찬가지다. 각자 뭘 하는지 알아야 튜닝이 되는데, 대부분은 예제 설정을 그대로 복붙해서 쓴다.

이번 글은 그 안쪽을 파본다. 리시버가 데이터를 받아서 익스포터까지 흘려보내는 사이에 사실 뭐가 일어나는지, 그리고 최근 컨트리뷰터들이 왜 큐 구조를 뜯어고치는지에 대한 이야기다.

파이프라인이라는 이름의 파이프

Collector 문서에서 "파이프라인" 이라고 부르는 건 겉보기에 리시버 → 프로세서 → 익스포터 로 이어지는 한 줄이다. 그런데 코드 레벨에서 보면 이건 실제로 파이프가 아니다. 각 컴포넌트는 Consumer 인터페이스를 통해 다음 단계로 데이터를 밀어넣는 함수 호출 체인이다. 리시버가 ConsumeTraces(ctx, td) 를 부르면, 그게 첫 프로세서의 ConsumeTraces 로, 다시 다음 프로세서로 이어진다.

즉 기본적으로는 synchronous call chain 이다. Go routine 이 나뉘어 돌아가는 건 batch processor 나 exporter 의 queued_retry 처럼 명시적으로 큐를 두는 컴포넌트에서만 벌어진다. 이걸 몰라서 종종 오해가 생긴다. "리시버가 받으면 알아서 뒤에서 비동기로 처리되겠지" 라고 생각하는데, 실제로는 리시버 고루틴이 익스포터의 반환까지 대기하는 경우가 많다.

우리 팀도 이걸 늦게 깨달았다. gRPC OTLP receiver 앞단에서 갑자기 latency 가 튀는 일이 있었는데, 원인을 파고 들어가 보니 뒤쪽 익스포터가 백프레셔를 걸면서 그게 리시버까지 그대로 전파된 거였다. 클라이언트 SDK 는 200ms 안에 응답이 안 오면 재시도하도록 잡혀 있었고, 재시도가 다시 큐를 채우는 나쁜 사이클이 만들어졌다.

memory_limiter 가 사실 하는 일

memory_limiter 는 이름만 보면 "메모리 초과하면 뭘 어떻게 해준다" 는 느낌인데, 하는 일은 딱 두 가지다. 주기적으로 runtime.ReadMemStats 를 호출해서 힙 사용량을 재고, 임계치를 넘으면 Refused 에러를 반환한다. 그게 끝이다.

포인트는 이 에러가 어디로 가느냐다. call chain 을 거슬러 올라가면서 결국 리시버까지 도달한다. gRPC 리시버라면 클라이언트로 RESOURCE_EXHAUSTED 응답을 던진다. 이게 소위 말하는 backpressure 다. 즉 memory_limiter 자체는 데이터를 버리지 않는다. 대신 위쪽에 "그만 보내" 라고 전한다. 실제로 데이터가 사라지는 건 클라이언트 SDK 에서 재시도를 포기하는 시점이다.

그래서 memory_limiter 를 batch 뒤에 두면 좀 애매해진다. batch 가 이미 데이터를 축적한 상태에서 memory_limiter 가 refuse 를 던져도, 배치 안에 쌓인 데이터는 이미 큐에 있다. 이걸 어떻게 되돌릴 수 있는 방법이 없다. 결과적으로 다음 배치 flush 는 실패하고, batch processor 내부의 데이터는 그냥 버려진다. 문서에서 그렇게 강조하는 순서 규칙이 여기서 나온다. 사실은 "backpressure 를 batch 이전 단계에서 걸어야 데이터 손실 없이 재시도가 가능하다" 라는 뜻이다.

batch processor 는 왜 하필 큐가 아닌가

Batch processor 를 보면 재밌는 게, 이건 큐가 아니다. 정확히는 accumulator 다. 내부 상태로 Traces / Metrics / Logs 오브젝트를 가지고 있다가, send_batch_sizetimeout 조건이 맞으면 flush 한다. flush 시점에 새로운 고루틴을 스핀업하지 않고, 배치를 축적한 스레드에서 그대로 다음 컨슈머로 넘긴다.

이게 왜 중요하냐면, 배치 flush 가 오래 걸리면 그 시간 동안 다음 배치를 받을 수 없다. send_batch_max_size 를 크게 잡고 뒤쪽 익스포터가 느리면, 다음 배치가 밀리면서 리시버 쪽 큐가 쌓인다. 그래서 batch 사이즈만 크게 늘리는 게 항상 답이 아니다. 익스포터 처리량과 균형이 맞아야 한다.

한 가지 실무 팁이라면 시그널마다 batch 를 분리하는 편이 안전하다. 텔레메트리 종류가 다르면 볼륨과 latency 요구가 다르다. 예를 들어 트레이스는 초당 수만 개가 튈 수 있지만 로그는 상대적으로 안정적이다. 하나의 batch processor 로 묶어놓으면 트레이스 스파이크가 로그 flush 를 지연시키는 상황이 생긴다. 배치 설정을 signal 별로 나누는 이유가 이거다.

processors:
  memory_limiter:
    check_interval: 1s
    limit_percentage: 80
    spike_limit_percentage: 25

  batch/traces:
    send_batch_size: 8192
    send_batch_max_size: 10000
    timeout: 200ms

  batch/logs:
    send_batch_size: 512
    timeout: 1s

  batch/metrics:
    send_batch_size: 1024
    timeout: 5s

limit_percentage 는 컨테이너 memory limit 대비 퍼센트인데, 최근 릴리즈부터 이걸 절대값 대신 권장한다. cgroup v2 환경에서 실제 컨테이너 한도가 잘 잡히는 시점부터 안정적이다. 우리는 초기에 limit_mib 를 절대값으로 잡았다가 Karpenter 로 노드 스펙이 바뀔 때마다 조정하느라 고생했다. spike_limit 은 soft limit 과 hard limit 사이의 여유 공간이다. 이 여유가 있어야 순간 스파이크 때 refuse 를 던지고 GC 를 유도할 시간이 확보된다.

익스포터 큐와 재시도의 관계

Batch processor 다음에 붙는 것 중 자주 놓치는 게 exporter 쪽 sending queue 다. sending_queue 를 활성화하면 익스포터 앞에 큐가 생기고, 실패한 배치는 여기 저장돼 재시도된다. 문제는 이 큐가 메모리 기반이라는 점이다.

queue_size 를 크게 잡으면 재시도 여유가 늘어나는 대신 메모리가 그만큼 잡아먹힌다. 배치 사이즈가 크면 큐 하나당 메모리 점유가 상당하다. 이 계산이 안 맞으면 memory_limiter 가 계속 발동하면서 refuse 를 던지고, 그게 다시 다음 재시도를 위한 큐에 쌓이는 이상한 상태가 된다.

일반적으로는 worst_case_batch_size × queue_size < container_memory × 0.7 정도가 되도록 잡는다. 그런데 이 공식이 완벽하지 않아서, 실제로는 부하 테스트로 감을 잡아야 한다. 우리 팀에서는 프로덕션 트래픽의 1.5배 정도를 부하로 흘리면서 collector 메모리를 관찰한다. 스파이크 상황에서 heap 이 memory_limiter 임계치 근처까지 갔다가 다시 내려오는 패턴이 정상이다. 계속 임계치에 붙어있으면 큐가 과하게 잡혀있다는 뜻이다.

한 가지 최근 변화라면 파일 기반 persistent queue 를 쓰는 사람이 늘고 있다. file_storage extension 을 붙이면 익스포터 큐를 디스크에 유지할 수 있다. Collector 재시작 시 in-flight 데이터가 살아남는다. 근데 이걸 무작정 켜면 오히려 나쁜 경우도 있다. 디스크 IO 가 exporter 처리량의 병목이 되면서 큐가 계속 차오르기만 한다. SSD 를 쓰고 있고, 재시작 시 데이터 유실 비용이 크다면 그때 켜는 게 맞다.

파이프라인 fan-out 과 성능

한 receiver 를 여러 파이프라인에 연결하는 fan-out 을 자주 쓴다. 예를 들어 하나의 OTLP receiver 에서 받은 트레이스를 tail sampling 파이프라인과 metrics 파이프라인 양쪽으로 보내는 식이다. 이때 내부적으로 receiver 는 fanOutConsumer 를 사용해 각 파이프라인에 순차적으로 데이터를 넘긴다.

여기서 함정이 하나 있다. fan-out 은 병렬이 아니라 순차 호출이다. 첫 파이프라인의 처리가 끝나야 두 번째 파이프라인으로 넘어간다. 그래서 tail sampling 처럼 무거운 파이프라인을 다른 파이프라인과 fan-out 으로 묶으면, 가벼운 파이프라인 쪽 latency 도 tail sampling 대기 시간에 종속된다.

이걸 피하려면 각 파이프라인 앞단에 batch 를 두거나, 아예 collector 를 gateway 계층과 workload 계층으로 나눠서 무거운 처리를 뒤로 뺀다. 요즘 흔한 아키텍처가 agent(DaemonSet) → gateway(Deployment) 계층 분리인데, 이유의 반은 fan-out 성능 격리 때문이다.

그래서 뭘 조심하냐

정리하면 이렇다. Collector 파이프라인은 겉보기와 다르게 대부분 synchronous 이고, backpressure 가 리시버까지 그대로 전달된다. memory_limiter 는 데이터를 버리는 게 아니라 위쪽에 "그만" 을 보내는 역할이라 배치 전에 있어야 한다. batch 는 accumulator 지 큐가 아니고, exporter 큐는 별개 존재다. Fan-out 은 순차 호출이다.

이 네 개만 정확히 잡고 있으면 대부분의 튜닝 이슈는 감이 온다. 요즘도 컨트리뷰터들이 큐 구조와 백프레셔 시그널을 좀 더 우아하게 만드는 시도를 하고 있어서, 향후 몇 릴리즈 지나면 memory_limiter 없이도 안전한 흐름 제어가 가능해질 수도 있다. 그 전까지는 위의 규칙이 여전히 유효하다.

혹시 sending queue 를 file_storage 로 옮겨서 재밌는 결과를 본 분 계시면 얘기 좀 들려주세요. 우리는 아직 검토만 하고 있다.


태그: OpenTelemetry, otel-collector, 관측성, observability, 모니터링