OpenTelemetry Collector를 이용한 Kubernetes 클러스터 모니터링: Agent + Gateway 패턴 설명
요약
Kubernetes 환경에서 OpenTelemetry Collector를 활용한 Agent + Gateway 패턴을 통해 모니터링 인프라의 안정성을 확보하는 방법을 다룹니다. 모든 Pod가 백엔드에 직접 연결될 때 발생하는 동시 연결 제한 및 데이터 유실 문제를 해결하는 아키텍처를 설명합니다.
핵심 포인트
- Pod가 백엔드에 직접 연결 시 동시 연결 제한으로 인한 데이터 유실 위험
- Agent DaemonSet과 Gateway를 분리하여 연결 수 및 부하 관리
- Gateway를 통한 데이터 배치(Batching) 및 샘플링(Sampling) 최적화
- gRPC 연결 유지 비용과 리소스 점유 문제 해결 전략
요약(TL;DR): 운영 환경에서 실제로 발생하기 전까지는 아무도 말하지 않는 장애 모드: 모든 Pod가 Tempo 또는 Loki의 인제스트(Ingest) 엔드포인트로 직접 gRPC 연결을 생성하며, 백엔드는 CPU 과부하가 아니라 동시 연결 제한(Concurrent connection limit)에 도달하여 Span을 드롭하기 시작합니다.
📖 읽기 시간: 약 23분
이 글의 내용
- 문제점: 수집 전략이 없을 때 발생하는 노드별 혼란
- 아키텍처 개요: Agent DaemonSet + Gateway 배포
- Agent 배포: 실제로 작동하는 DaemonSet 설정
- Gateway 배포: 배치(Batching) 및 샘플링(Sampling)이 이루어지는 곳
- 당신을 곤란하게 만들 세 가지 비직관적인 동작
- 파이프라인 검증 및 Collector 자체에 대해 모니터링해야 할 사항
- 이 패턴을 사용해야 할 때와 건너뛰어야 할 때
문제점: 수집 전략이 없을 때 발생하는 노드별 혼란
운영 환경에서 실제로 발생하기 전까지는 아무도 말하지 않는 장애 모드: 모든 Pod가 Tempo 또는 Loki의 인제스트(Ingest) 엔드포인트로 직접 gRPC 연결을 생성하며, 백엔드는 CPU 과부하가 아니라 동시 연결 제한(Concurrent connection limit)에 도달하여 Span을 드롭하기 시작합니다. gRPC 연결은 공짜가 아닙니다. 각 연결은 상태를 유지하고, Keepalive를 협상하며, 파일 디스크립터(File descriptor)를 점유합니다. 노드가 5개이고 각 노드에 Pod가 몇 개씩 있는 상황에서는 이 문제가 보이지 않습니다. 하지만 오토스케일링(Autoscaling) 워크로드가 있는 20개의 노드 환경에서는, 원래 그보다 훨씬 적은 규모로 설계된 단일 엔드포인트에 수백 개의 지속적인 연결(Persistent connections)을 생성하게 됩니다. 인제스트(Ingest) 서비스는 점진적으로 성능이 저하되는 것이 아니라, RESOURCE_EXHAUSTED를 반환하기 시작하며 여러분의 트레이스(Traces)는 소리 없이 사라집니다.
팬아웃 (Fan-out) 계산은 단순하지만 가혹합니다. 만약 각 노드가 게이트웨이(Gateway)에 직접 연결되는 DaemonSet 콜렉터(Collector)를 실행하고, 각 콜렉터가 메트릭 (OTLP gRPC), 트레이스 (Traces), 로그 (Logs)를 위해 각각 별도의 연결을 연다면, 세 가지 시그널 (Signal) 타입을 사용하는 20개 노드 클러스터는 이미 최소 60개의 지속적인 업스트림 (Upstream) 연결을 갖게 됩니다. 이는 직접 홈으로 전화를 걸기로(Phone home) 결정한 애플리케이션 레벨의 SDK 익스포터 (Exporter)들을 고려하기도 전의 수치입니다. Tempo의 기본 인제스트 (Ingest) 설정은 단일 클러스터로부터 발생하는 이 정도의 연결 수를 처리하도록 설계되지 않았으며, Loki의 디스트리뷰터 (Distributor) 또한 동일한 압박 하에서 푸시 (Push)를 거부하기 시작할 것입니다. 스팬 (Spans)은 큐 (Queue)에 쌓이지 않고, 대부분의 SDK가 한 번 로그를 남기고 버려버리는 일시적인 오류와 함께 익스포터 단계에서 드롭 (Drop)됩니다.
DaemonSet 전용 배포는 서류상으로는 깔끔해 보입니다. 노드당 하나의 콜렉터를 두고, 로컬 포드 (Pods)를 스크레이핑 (Scrape)하여 업스트림으로 전달하는 방식입니다. 문제는 "업스트림으로 전달"한다는 것이 DaemonSet 에이전트 (Agent)가 두 가지 작업을 동시에 수행해야 함을 의미한다는 점입니다. 즉, 노드를 공유하는 워크로드 (Workloads)로부터 리소스를 뺏어오지 않을 만큼 가볍게 유지되어야 하는 동시에, 시그널을 안정적으로 버퍼링 (Buffering), 배치 (Batching), 재시도 (Retry), 라우팅 (Routing)할 수 있을 만큼 상태 유지 (Stateful) 능력을 갖춰야 합니다. 이는 서로 모순되는 요구사항입니다. 실패한 익스포트 (Export)를 위해 재시도 큐 (Retry queue)를 유지하려고 시도하면서 동시에 좁은 간격으로 Prometheus 엔드포인트 (Endpoints)를 스크레이핑하는 콜렉터는, 메모리 압박 하에서 스크레이프 루프 (Scrape loop)가 굶주리거나(Starve) 노드에 의해 축출 (Evict)될 때 큐를 드롭하게 됩니다. 이 두 역할은 진정으로 별도의 리소스 프로필 (Resource profiles)을 가진 별개의 프로세스로 분리되어야 합니다.
실제로 작동하는 아키텍처는 설계 단계부터 이러한 관심사(concerns)를 분리합니다. DaemonSet으로 실행되는 에이전트(agent)는 의도적으로 단순하고 가볍게 설계됩니다. 즉, OTLP를 통해 로컬 포드(pod)로부터 신호를 수신하고, 최소한의 처리(노드/포드 레이블 추가 등 비용이 많이 들지 않는 작업)를 수행한 뒤 내부 게이트웨이(gateway) 엔드포인트로 전달합니다. 게이트웨이(gateway)는 영구 스토리지(persistent storage) 또는 최소한 적절한 메모리 큐(memory queue)를 갖춘 Deployment로 실행되며, 배치(batching), 백오프(backoff)를 포함한 재시도(retry), 외부 백엔드(backend)로의 TLS 연결, 모든 에이전트로부터 관리 가능한 수의 업스트림(upstream) 연결으로 모으는 팬인(fan-in) 등 모든 상태 유지(stateful) 작업을 처리합니다. 게이트웨이는 클러스터에 노드가 몇 개가 있든 상관없이 외부로 나가는 연결을 대략 3~4개 정도로 유지합니다. 이것이 바로 귀하의 Tempo 인제스트(ingest)가 실제로 설계된 연결 수입니다.
하나의 컬렉터(collector) 설정으로 두 역할을 모두 수행하려고 하면 매우 특정한 방식으로 리소스 경합(resource contention)이 발생하며 이는 매우 성가신 문제입니다. batch 프로세서는 버퍼링하는 볼륨에 비례하는 메모리 여유 공간(memory headroom)이 필요합니다. prometheusreceiver는 스크레이프(scrape) 주기를 위한 CPU가 필요합니다. 부하가 많은 노드에서는 이들이 서로 경쟁하게 됩니다. 스크레이프 스파이크(scrape spike)가 발생하는 동안 배치 프로세서의 전송 큐(send queue)가 가득 차는 것을 볼 수 있는데, 이는 리시버(receiver)로의 백프레셔(backpressure)를 유발하고, 결과적으로 백엔드로의 엑스포터(exporter) 타임아웃을 일으키며, 이는 재시도 루프(retry loop)를 트리거합니다. 결국 가벼워야 할 DaemonSet 포드가 400MB RAM을 점유하며 OOMKilled(메모리 부족으로 인한 종료)되는 상황이 발생합니다. 노드가 깨끗한 상태로 돌아오고 컬렉터가 재시작되면, 큐에 있던 데이터는 모두 손실됩니다. 역할을 분리하면 각 구성 요소의 크기를 적절하게 조절할 수 있습니다. 에이전트는 64–128MB 제한으로 설정하고, 게이트웨이는 실제 버퍼링 워크로드(buffering workload)가 요구하는 만큼 설정하십시오.
아키텍처 개요: Agent DaemonSet + Gateway Deployment
대부분의 사람들이 OpenTelemetry Collector 문서를 처음 읽을 때 놓치는 핵심적인 구분은, 에이전트(Agent)와 게이트웨이(Gateway) 중 하나를 선택하는 것이 아니라 서로 다른 역할을 위해 두 가지를 모두 실행한다는 점입니다. 에이전트는 모든 노드에 상주하며 리소스가 제한된 프로세스입니다. 게이트웨이는 그 모든 데이터를 흡수하여 비용이 많이 드는 작업을 수행하는 정식 서비스입니다. 이 둘을 하나의 배포(Deployment)로 혼동하여 통합하는 것은 노드의 메모리 예산을 초과하거나, 백엔드 장애 발생 시 텔레메트리(Telemetry) 데이터를 손실하게 만드는 가장 빠른 방법입니다.
에이전트는 DaemonSet으로 실행됩니다. 예외 없이 노드당 하나의 포드(Pod)가 할당됩니다. 에이전트의 작업 목록은 의도적으로 좁게 설정되어 있습니다. 노드 수준의 데이터를 위해 kubeletstats 및 hostmetrics를 스크레이핑(Scrape)하고, filelog 리시버(Receiver)를 통해 컨테이너 로그를 테일링(Tail)하며, 동일한 노드에 있는 애플리케이션 컨테이너로부터의 OTLP 푸시를 기다리며 localhost:4317에서 대기합니다. 마지막 항목이 중요한 이유는, 에이전트가 노드 로컬(Node-local)에 있기 때문에 사이드카(Sidecar) 또는 SDK 인스트루멘테이션(Instrumentation)이 서비스 디스커버리(Service Discovery) 오버헤드 없이 루프백(Loopback)을 통해 에이전트에 접속할 수 있기 때문입니다. 에이전트는 영구 큐(Persistent Queue)를 가지고 있지 않으므로, 로그 볼륨에 따라 약 200–300Mi 정도로 에이전트의 메모리 상한선(Memory ceiling)을 엄격하게 제한해야 합니다. 만약 게이트웨이에 도달할 수 없다면, 에이전트는 데이터를 드롭(Drop)합니다. 이것은 버그가 아니라 기능입니다. 파티션 이벤트(Partition event) 발생 시 모든 노드에서 DaemonSet이 기가바이트 단위의 재시도 버퍼(Retry buffer)를 조용히 쌓아두는 상황은 원치 않으실 것입니다.
게이트웨이(Gateway)는 ClusterIP 서비스 뒤에서 두 개 이상의 레플리카(Replica)를 가진 Deployment로 실행됩니다. 게이트웨이는 클러스터 내의 모든 에이전트(Agent)로부터 gRPC를 통해 OTLP를 수신하며, 전체 트레이스(Trace)에 대해 테일 기반 샘플링(Tail-based sampling) 결정(특정 트레이스 ID에 대한 모든 스팬(Span)을 한 곳에서 확인해야 하며, 이에 대한 자세한 내용은 샘플링 섹션에서 다룹니다)을 적용하고, 전달하기 전에 공격적으로 배치(Batch) 처리를 수행하며, 모든 재시도 큐(Retry queue)와 원격 백엔드 자격 증명(Remote backend credentials)을 관리합니다. 게이트웨이는 Prometheus의 remote_write URL, Tempo 엔드포인트(Endpoint), Loki 푸시(Push) URL이 존재하는 곳입니다. 이 정보들은 모든 노드 수준의 ServiceAccount가 읽을 수 있는 ConfigMap에 포함되는 것이 아니라, 게이트웨이 포드(Pod)에 마운트된 Kubernetes Secret으로 관리됩니다. 데이터 흐름을 간단히 설명하면 다음과 같습니다: 애플리케이션이 localhost:4317에 있는 에이전트로 OTLP를 푸시하면, 에이전트는 gRPC를 통해 게이트웨이의 ClusterIP(일반적으로 otel-gateway.monitoring.svc.cluster.local:4317와 같은 형태)로 전달하고, 게이트웨이는 이를 백엔드로 팬 아웃(Fan out)합니다. 즉, Prometheus는 remote_write를 통해 메트릭(Metrics)을 받고, Tempo는 OTLP HTTP 또는 gRPC를 통해 트레이스(Traces)를 받으며, Loki는 자체 푸시 API를 통해 로그(Logs)를 받습니다.
RBAC(역할 기반 액세스 제어)는 이러한 분리가 구체적인 보안 측면에서 이점을 제공하는 부분입니다. 에이전트 ServiceAccount는 디스커버리(Discovery) 작업을 수행해야 하므로 실제 클러스터 권한이 필요합니다:
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
...
게이트웨이 ServiceAccount는 이러한 권한이 전혀 필요하지 않습니다. 게이트웨이는 네트워크 소켓을 통해 데이터를 수신하고 이를 외부 엔드포인트로 푸시할 뿐입니다. 만약 누군가 게이트웨이의 컬렉터 설정(Collector config)을 잘못 구성하여 의도하지 않은 수신기(Receiver)를 열더라도, 그 영향 범위(Blast radius)는 Kubernetes API 접근 권한이 전혀 없는 상태로 제한됩니다. 이 두 ServiceAccount를 완전히 분리해 두면, 침해되거나 잘못 설정된 게이트웨이 포드가 클러스터 토폴로지(Topology)를 열거할 수 없으며, 잘못 설정된 에이전트 포드가 원격 백엔드 자격 증명에 접근할 수 없음을 의미합니다. 이것이 바로 분리의 실제 가치입니다. 단순히 리소스 격리뿐만 아니라, 단일 설정 오류가 영향을 미칠 수 있는 범위를 실질적으로 줄여주는 것입니다.
에이전트 배포: 실제로 작동하는 DaemonSet 설정
대부분의 튜토리얼이 생략하는 부분은 다음과 같습니다. 에이전트의 ConfigMap(설정 맵)이야말로 게이트웨이가 아닌, 대부분의 운영 환경 장애가 발생하는 지점입니다. 잘못 설정된 memory_limiter (메모리 제한기) 프로세서는 부하를 부드럽게 조절(shed load)하지 못합니다. 대신 Collector (컬렉터) 포드가 OOMKill (메모리 부족으로 인한 프로세스 종료)을 당하고 무한 재시작 루프에 빠지게 만들며, 재시작 주기당 30~90초 동안 노드 수준의 메트릭(metrics)이 수집되지 않는 '다크(dark)' 상태를 유발합니다. 이 프로세서는 메모리가 이미 폭발하기 전에 작동할 수 있도록, 모든 파이프라인(pipeline)의 프로세서 체인에서 batch (배치) 프로세서보다 가장 먼저 나열되어야 합니다. 이 내용은 OpenTelemetry Collector 문서에 명시되어 있지만, 처음 읽을 때는 놓치기 쉬울 정도로 깊숙이 숨겨져 있습니다.
다음은 실제로 작동하는 최소한의 구성이지만 완전한 ConfigMap입니다. 플레이스홀더(placeholder) 없이 실제 필드 이름과 실제 단위를 사용합니다:
apiVersion: v1
kind: ConfigMap
metadata:
...
리소스 제한(resource limits)에 대하여: 에이전트 포드의 적절한 시작 상한선은 CPU 200m 및 메모리 256Mi입니다. 이는 특정 벤치마크 때문이 아니라, 실제 고충이 발생하는 지점 때문입니다. 에이전트의 CPU는 거의 병목 현상(bottleneck)이 발생하지 않습니다. 200m 상한선은 방어적인 설정으로, 오작동하는 스크레이퍼(scraper)가 다른 노드 워크로드(workloads)를 고갈시키는 것을 방지합니다. 실제로 문제가 발생하는 곳은 메모리입니다. memory_limiter는 220 MiB의 하드 제한(hard limit)과 60 MiB의 스파이크 허용치(spike allowance)로 구성되어 있으며, 이는 256Mi 포드 제한보다 약간 낮은 간격을 남겨둡니다. 이 간격은 의도된 것입니다. 만약 제한기가 충분히 빠르게 부하를 조절하지 못하더라도, 컨테이너가 요동(thrashing)치기보다는 깔끔하게 OOMKill 되도록 하기 위함입니다. 만약 이 수치들을 뒤집어서 limit_mib를 컨테이너 제한보다 높게 설정하면 최악의 결과가 발생합니다. 즉, 제한기가 작동하기도 전에 커널(kernel)이 프로세스를 종료해 버립니다.
resources:
requests:
cpu: 50m
...
filelog 리시버(receiver)는 기본 설치 가이드에는 나타나지 않는 두 개의 hostPath 마운트(mounts)가 필요합니다. 이것들이 없으면 리시버는 정상적으로 시작되지만 아무것도 수집하지 않으며, 유일한 증거는 로그 파이프라인(log pipeline)의 침묵뿐입니다:
volumeMounts:
- name: varlogpods
mountPath: /var/log/pods
...
containerd 전용 노드(k3s, 대부분의 최신 kubeadm 클러스터)의 경우, /var/lib/docker/containers가 존재하지 않겠지만 마운트 자체가 실패하지는 않으며 단지 비어 있게 됩니다. 실제 로그 파일은 런타임(runtime)에 관계없이 /var/log/pods 아래에 있으므로, 해당 마운트가 매우 중요합니다. Docker 경로를 유지하는 것은 혼합형 클러스터(mixed clusters)를 사용하거나 Docker-in-Docker 워크로드를 실행하는 경우를 위해 유용합니다.
0.0.0.0:13133에 위치한 헬스 체크(health check) 확장 기능은 메트릭스 파이프라인(metrics pipelines)이 감지할 수 없는 실패 모드를 포착함으로써 그 가치를 증명합니다. 즉, 콜렉터(collector)가 실행 중이고 준비 상태(readiness) 체크는 통과하고 있지만, 내부적으로 느린 익스포터(exporter)로 인해 데드락(deadlock) 상태에 빠진 경우를 잡아냅니다. 라이브니스 프로브(liveness probe)는 OTLP 포트가 아닌 13133 포트의 / 경로를 호출해야 합니다. 데드락에 빠진 콜렉터는 체크 간격 내에 내부 헬스 엔드포인트(health endpoint) 업데이트를 중단하게 되며, 메트릭스에서 공백을 인지하기도 전에 포드(pod) 재시작을 트리거합니다. 이 프로브가 없다면, 멈춰버린 콜렉터는 수신되는 모든 시그널(signal)을 조용히 누락시키면서도 Kubernetes에는 정상인 것처럼 보일 수 있습니다.
livenessProbe:
httpGet:
path: /
...
게이트웨이 배포: 배치(Batching)와 샘플링(Sampling)이 이루어지는 곳
이 전체 패턴에서 가장 중요한 단일 아키텍처 결정은 테일 샘플링(tail sampling)을 어디에 배치하느냐이며, 정답은 _절대로 에이전트(agent)에 두어서는 안 된다_는 것입니다. 에이전트 데몬셋(DaemonSet) 포드는 하나의 노드에서 발생하는 스팬(spans)만 볼 수 있습니다. 단일 사용자 요청에 대한 트레이스(trace)는 세 개의 서로 다른 노드에 있는 포드들을 거칠 수 있으며, 이는 각 에이전트가 파편화된 정보만을 본다는 것을 의미합니다. 만약 에이전트에서 테일 샘플링 정책을 적용한다면, 불완전한 그림을 바탕으로 유지(keep) 또는 폐기(drop) 결정을 내리게 됩니다. 정책은 트레이스가 완료되었다고 판단할 때 실행되지만, 실제로는 완료되지 않은 상태입니다. 다른 노드들로부터 오는 스팬들이 누락되었기 때문입니다. 에러가 발생하는 것이 아닙니다. Tempo에서 조용히 불완전한 트레이스가 생성될 뿐이며, 당신은 데이터베이스 호출 스팬이 왜 사라졌는지 고민하며 한 시간을 허비하게 될 것입니다.
게이트웨이(Gateway)는 모든 에이전트(Agent)로부터 OTLP를 수신하고, 어떠한 정책을 평가하기 전에 전체 트레이스(Trace)를 재구성하기 때문에 적절한 위치입니다. tail_sampling 프로세서는 설정 가능한 결정 대기 시간 동안 스팬(Span)을 메모리에 유지한 다음, 조립된 트레이스에 대해 사용자의 정책을 적용합니다. 아래는 이를 엔드 투 엔드(end-to-end)로 연결하는 ConfigMap입니다. 여기에는 500ms 기준의 지연 시간 기반(latency-based) 정책, 원격 쓰기(remote write) 전의 공격적인 배치(batching), 그리고 게이트웨이 포드(Pod)가 재시작될 때 큐에 가득 찬 스팬을 잃지 않도록 도와주는 파일 스토리지 확장 기능(file storage extension)이 포함되어 있습니다:
apiVersion: v1
kind: ConfigMap
metadata:
...
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기