Ingester 1대가 죽었는데 왜 RED 메트릭 90%가 증발했을까

사내 분산 트레이싱 환경을 모니터링하던 중, Grafana 대시보드에서 전사 서비스들의 RED 메트릭(Rate, Error, Duration) 패널에 간헐적인 데이터 결측이 발생하는 현상을 확인했다.

초기에는 메트릭을 수집 및 연산하는 Metrics Generator나 백엔드 스토리지(Mimir/Prometheus)의 부하를 의심했다. 그러나 컴포넌트별 상태와 지표를 점검했을 때 확인된 수치는 예상과 달랐다.

  • 트래픽의 인입 창구인 Distributor 는 정상이었고, 유입되는 트레이스 인입량도 평소 수준인 초당 약 150k spans/s를 유지하고 있었다.
  • 지표 결측으로 가장 먼저 의심했던 Metrics Generator 역시 모든 파드가 Ready 상태였으며, 스토리지 푸시 실패(push failure)도 0건이었다. 그러나 Metrics Generator로 들어오는 실제 데이터 유입량은 평소의 155k spans/s에서 19k spans/s로 약 87% 급감 해 있었다.
  • 유일하게 비정상 상태였던 곳은 Ingester 였다. 전체 9대 중 단 1대가 메모리 제한(16GiB)을 초과하여 OOMKilled와 재시작을 반복하고 있었다.

실제 장애가 발생한 컴포넌트는 Ingester 1대였으나, 이상 징후는 다운스트림인 Metrics Generator의 데이터 유입량이 90% 가까이 증발하는 형태로 관측되었다.

이 상황에서 두 가지 의문이 제기되었다.

  1. Ingester 1대의 장애가 왜 물리적으로 분리되어 동작하는 Metrics Generator 파이프라인의 차단으로 이어졌는가?
  2. Ingester 9대 중 1대의 결함(약 11%)이 왜 전체 요청의 90%에 달하는 실패로 증폭되었는가?

단순히 컨테이너를 재시작하고 넘어가기에는 영향 범위가 지나치게 컸고, 원인을 규명하지 않으면 동일한 패턴의 지표 결손을 통제할 수 없다고 판단했다.


1. Ingester 장애가 Metrics Generator를 멈춘 이유

이 연결고리를 규명하기 위해 트래픽 입구인 Distributor의 내부 처리 흐름을 확인했다.

아키텍처 구성도상으로는 Distributor 뒤에서 Ingester와 Metrics Generator가 독립적으로 데이터를 수신하는 것처럼 보인다. 그러나 실제 데이터 분기가 일어나는 지점은 네트워크 계층이 아니라 Distributor의 소스코드 내부 였다.

Distributor의 PushTraces 함수를 확인했을 때, 두 경로 사이의 실행 순서 의존성을 확인할 수 있었다.

// Tempo Distributor의 PushTraces 내부 흐름 요약
ringTokens, rebatchedTraces, ... := requestsByTraceID(...)
 
// 1. Ingester 쓰기 시도
if d.cfg.IngesterWritePathEnabled {
    err = d.sendToIngestersViaBytes(ctx, userID, rebatchedTraces, ringTokens)
    if err != nil {
        // Ingester 쓰기 중 하나라도 에러가 발생하면 즉시 함수를 종료한다.
        return nil, err
    }
}
 
// 2. Metrics Generator로의 포워딩
d.generatorForwarder.SendTraces(ctx, userID, ringTokens, rebatchedTraces)

코드의 실행 순서는 엄격히 직렬화되어 있었다.

Distributor는 수신한 트레이스를 Ingester로 먼저 보낸다. 그리고 Ingester 쓰기가 에러 없이 완전히 성공했을 때만 generatorForwarder.SendTraces를 호출하여 Metrics Generator로 데이터를 넘긴다. 만약 Ingester 쓰기 과정에서 에러(err != nil)가 발생하면, 함수는 즉시 에러를 반환하며 종료된다.

이 로직을 통해 첫 번째 의문이 설명되었다.

Metrics Generator 파드 자체는 정상 상태였으나, 선행 작업인 Ingester 쓰기가 실패하면서 상위 함수가 조기 종료되어 Metrics Generator 포워딩 코드가 아예 호출되지 못한 것이다. 아키텍처 다이어그램에서는 독립된 컴포넌트로 보였지만, 코드 레벨에서는 Ingester 쓰기 성공이 Metrics Generator 동작의 절대적인 선행 조건 으로 결합되어 있었다.


2. 1대의 노드 장애가 90%의 요청 실패로 증폭된 이유

선행 조건 실패로 Metrics Generator 호출이 스킵된다는 사실은 확인했으나, 영향도의 크기는 여전히 설명이 필요했다.

클러스터에는 총 9대의 Ingester가 동작하고 있었고, Trace ID는 해시 함수에 의해 균등하게 분산된다. 노드 1대의 장애라면 단순 계산상 실패율 역시 약 11%(1/9) 수준이어야 한다. 90%에 달하는 요청 실패는 어디서 비롯된 것일까?

원인은 업스트림의 요청 단위인 배치(Batch) 와 해시 링 기반의 샤딩 방식에 있었다.

단일 트레이스가 아닌 OTLP 배치(Batch) 전송

Alloy나 OpenTelemetry Collector는 네트워크 오버헤드를 줄이기 위해 Span을 개별 전송하지 않는다. 여러 서비스에서 인입된 수십~수백 개의 Span을 하나의 OTLP Request 배치로 묶어 Distributor로 전송한다.

Distributor는 배치를 수신하면 내부의 해시 링(Hash Ring)을 조회한다.

  • Tempo는 Ingester 클러스터를 Hash Ring으로 관리하며, Trace ID를 해싱하여 담당 Ingester(Owner)를 결정한다.
  • 당시 환경은 비용 최적화를 위해 Replication Factor = 1 (RF=1)로 설정되어 있었다. 즉, 특정 Trace ID의 복제본은 클러스터 내에 오직 1대뿐이다.

Distributor는 단일 OTLP 요청 내에 포함된 여러 Trace들을 Owner별로 분리한 뒤, 각 Ingester를 대상으로 병렬(Parallel) gRPC 쓰기 를 호출한다.

flowchart TD
  REQ["단일 OTLP Request<br>(Span A, B, C, D 포함)"] --> DIST["Distributor"]
  DIST -->|"Trace ID 해싱"| SPLIT{"Owner 분기"}
  
  SPLIT -->|"Trace A, C"| ING1["Ingester-1 (정상) → 성공"]
  SPLIT -->|"Trace B"| ING2["Ingester-2 (정상) → 성공"]
  SPLIT -->|"Trace D"| ING3["Ingester-3 (OOM 상태) → 실패"]

  ING1 --> MERGE{"결과 취합"}
  ING2 --> MERGE
  ING3 --> MERGE

  MERGE -->|"단 하나라도 실패 시"| FAIL["Request 전체 실패 (에러 반환)"]
  FAIL --> SKIP["Metrics Generator 포워딩 취소"]

  classDef err fill:#ffebee,stroke:#c62828,stroke-width:1px;
  classDef ok fill:#e8f5e9,stroke:#2e7d32,stroke-width:1px;
  class ING3,FAIL,SKIP err;
  class ING1,ING2 ok;

여기서 중요한 동작 특성은 분산 RPC 환경의 에러 처리 방식이다. 상위 요청은 자신이 전송한 하위 병렬 쓰기 RPC 중 단 하나라도 실패하면 요청 전체를 에러(ERROR)로 처리 한다.

예를 들어 단일 배치 요청 안에 20개의 서로 다른 Trace가 포함되어 있다고 가정하자. 19개는 정상 Ingester로 전송되어 쓰기에 성공했더라도, 단 1개의 Trace가 OOM 상태인 Ingester에 배정되었다면 해당 OTLP 요청 전체가 최종 실패로 판정된다. 그리고 요청 전체가 실패했으므로 앞서 확인한 로직에 따라 Metrics Generator 포워딩은 완전히 건너뛰어진다.

확률 모델을 통한 증폭 메커니즘 검증

하나의 배치 요청 안에 여러 Trace가 묶여 들어가는 구조가 실패율을 어떻게 증폭시키는지 확인하기 위해 확률 모델을 적용했다.

Ingester 9대 중 1대가 고장 난 상태일 때, 임의의 Trace 하나가 정상 Ingester(8대 중 하나)에 배정될 확률은 다.

단일 OTLP 배치 요청 안에 서로 다른 고유 Trace가 개 포함되어 있다면, 개의 Trace가 모두 정상 Ingester에만 배정되어 요청이 정상 처리될 확률 은 다음과 같다.

반대로, 개 중 최소 하나의 Trace라도 고장 난 Ingester에 걸려 요청 전체가 에러로 실패할 확률 은 여사건의 확률에 따라 다음과 같이 계산된다.

이 식에 배치 크기 의 변화를 대입해 보면 다음과 같은 결과가 도출된다.

배치 내 고유 Trace 수 (N)요청 성공 확률 (8/9)ᴺ요청 실패 확률 1 - (8/9)ᴺ해석
1개88.9%11.1%Span을 개별 전송했을 때의 실패율 (노드 결함 비율과 일치)
5개55.5%44.5%소규모 배치에서도 노드 결함 대비 실패율 4배 증가
10개30.8%69.2%과반 이상의 요청 실패 발생
20개9.5%90.5%실측 지표(약 87% 감소)와 근접하는 구간
30개2.9%97.1%사실상 전체 파이프라인 마비 상태

실제 당시 Collector 설정을 확인했을 때, 단일 요청에 포함된 고유 Trace 수는 평균 26개에서 최대 90개에 달했다.

물론 수식은 Trace ID가 9대 노드에 독립적이고 균등하게 분산된다는 가정을 전제로 한 이론 모델이다. 실제 운영 환경의 트래픽 분포가 수학 모델과 정확히 일치하지는 않는다.

그러나 프로메테우스에서 실측된 Metrics Generator 입력 감소율 약 87% 와 이 모델의 수치(90.5%)가 근접했다는 점은, “배치 묶음이 단일 노드의 결함을 요청 단위에서 90% 수준으로 증폭시켰다” 는 분석의 타당성을 뒷받침한다.


3. Raw Trace와 RED 메트릭의 비대칭 현상

조사 과정에서 한 가지 주목할 만한 비대칭 현상이 관찰되었다. RED 메트릭 패널에서는 지표가 거의 사라졌으나, 장애 시간대에 인입된 개별 Trace ID를 직접 검색했을 때는 일부 트레이스가 정상 조회되었다.

상위 요청이 에러로 실패했음에도 트레이스 데이터가 남아있던 이유는 Ingester의 인메모리 버퍼 적재 방식 때문이었다.

Distributor가 보낸 병렬 쓰기 요청 중 정상 Ingester들은 이미 메모리에 Span 데이터를 적재(t.batches = append(trace))한 상태였다. 다른 Ingester의 실패로 Distributor 레벨에서 상위 요청 전체는 에러로 반환되었지만, 이미 정상 노드의 메모리에 쓰인 데이터는 롤백(Rollback)되지 않고 유지되어 이후 블록으로 플러시되었다.

결과적으로 두 파이프라인 사이에서 다음과 같은 데이터 비대칭이 발생했다.

  • Raw Trace: 실패한 배치 요청에 속해 있었더라도, 정상 Ingester로 전송된 Span 데이터는 메모리에 유지되어 스토리지로 저장되었다.
  • RED 메트릭: Metrics Generator는 Distributor의 선행 성공 검증에 걸려 호출 자체가 취소되었으므로, 해당 배치에 포함된 모든 Span에 대한 실시간 메트릭 연산이 완전히 누락되었다.

결론 및 정리

이번 장애 분석을 통해 얻은 핵심적인 아키텍처 관점의 정리는 다음과 같다.

  1. 배치 처리와 에러 전파의 트레이드오프: 배치(Batching)는 고처리량 분산 환경에서 네트워크 I/O 오버헤드를 줄이는 필수적인 최적화다. 그러나 단일 요청 내에 여러 파티션/샤드의 작업이 묶여 있을 때, 상위 계층이 All-or-Nothing 방식으로 에러를 처리한다면 단일 노드의 결함이 시스템 전체의 파이프라인을 중단시키는 증폭기로 작용할 수 있다.
  2. 코드 레벨의 선행 의존성 검토: 아키텍처 구성도상 병렬로 분리되어 있는 컴포넌트라 하더라도, 코드 레벨에서 선행 조건이 어떻게 결합되어 있는지를 확인하기 전까지는 장애 격리(Fault Isolation) 수준을 보장할 수 없다.

배치 실패 증폭 메커니즘을 규명하며 의문은 해소되었으나, 근본적인 질문은 남아 있었다.

max_block_bytes를 64MiB로 보수적으로 설정해 두었음에도 불구하고, 애초에 Ingester 1대가 왜 16GiB의 메모리 한계를 초과하여 OOM으로 종료되었는가 하는 점이다. 다음 글에서는 Go 런타임 힙 프로파일(pprof)과 스토리지 엔진 내부 로직을 통해 vParquet 블록 completion 과정의 메모리 스파이크 원인을 다룬다.