Tempo Ingester OOM: 반복되는 장애의 구조를 파헤치다
Ingester 하나가 죽었을 뿐인데 대시보드 전체가 멈췄다. 같은 패턴이 2~4주마다 반복됐고, 매번 누군가가 파드를 수동으로 삭제해야만 복구됐다. 3개월간 이 장애를 추적하며 발견한 것은, replication_factor=1이라는 구조적 단일 장애점과 WAL replay가 자동 복구를 가로막는 연쇄 OOM 메커니즘이었다.
환경: Grafana Tempo (microservices mode) · Kubernetes (EKS) · Ingester 9 pods / 9 nodes 기간: 2025.12 ~ 2026.02 (약 3개월간 반복 관찰)
Observability가 사라진 아침
어느 오전 9시 10분, 슬랙 알람이 울렸다. Grafana 대시보드의 spanmetrics 패널이 전부 “No data”를 표시하고 있었다. Tempo Ingester 파드 하나가 OOM으로 죽어 있었다.
파드를 Restart하면 될 것 같았지만, 복구되지 않았다. 결국 파드를 Delete해서 emptyDir을 통째로 날려야만 정상으로 돌아왔다. 문제는 이게 처음이 아니었다는 것이다. 같은 패턴이 2~4주 간격으로 반복되고 있었고, 매번 오전 트래픽 피크에 발생했다.
알람에 지쳐가면서 세 가지 질문이 떠올랐다.
- 왜 OOM이 발생하는가?
- 왜 OOM이 반복되는가?
- 왜 Ingester 하나가 죽었을 뿐인데, 전체 메트릭이 사라지는가?
Tempo의 데이터 흐름과 단일 장애점
장애의 구조를 이해하려면 먼저 Tempo의 데이터 흐름을 짚어야 한다.

flowchart LR A["App Services (OTLP)"] --> B["tempo-distributor"] B --> C["tempo-ingester (WAL/block)"] B --> D["tempo-metrics-generator"] C --> E["Object Storage (S3)"] D --> F["Mimir remote_write"] G["Grafana"] --> H["Mimir PromQL"] H --> I["spanmetrics series"]
App에서 보낸 트레이스는 Distributor를 거쳐 두 갈래로 나뉜다. Ingester는 트레이스를 WAL에 저장한 뒤 S3로 flush하고, metrics-generator는 트레이스에서 spanmetrics를 생성해 Mimir로 보낸다. Grafana는 Mimir에서 이 메트릭을 조회한다.
여기서 핵심은 Distributor가 Ingester를 선택하는 방식이다. Distributor는 TraceID를 해시하여 consistent hash ring에서 Ingester를 선택한다. replication_factor=1이면 각 TraceID는 딱 하나의 Ingester에만 저장된다. 대체 경로가 없다. 비용을 아끼려고 1로 둔 설정이, 사실상 전체 Observability 스택의 가용성을 결정하고 있었다.
3개월간 모든 장애에서 반복된 패턴
약 3개월간 반복되는 OOM 상황을 지켜보며, 모든 사례에서 공통적으로 확인된 사실들을 정리했다.
| # | 관찰 사실 | 근거 |
|---|---|---|
| 1 | OOM 발생 시 Distributor에서 connection refused 에러 발생 | Distributor 로그 |
| 2 | 파드 Restart로는 복구 실패, Delete(emptyDir 제거) 시에만 정상 기동 | 반복 운영 경험 |
| 3 | CPU limit 제거 이후 OOM 발생 주기 감소 | 운영 이력 |
| 4 | Ingester 9개가 노드 9개에 1:1 배치, HPA 최대치 도달 | 클러스터 상태 |
| 5 | 평상시 메모리 3~4 GiB → OOM 직전 40 GiB 초과 (10배 급증) | 메트릭 대시보드 |
| 6 | CPU pressure event 없음, 노드 자원은 충분 | 메트릭/이벤트 |
| 7 | OOM 발생 시각: 주로 오전 9시 10분경 (트래픽 피크) | 알람 이력 |
자원이 부족해서 터지는 건 아니었다. 평상시 메모리 사용량은 3~4 GiB로 넉넉했고, CPU pressure도 없었다. 그런데 OOM 직전에 메모리가 순식간에 40 GiB까지 치솟았다. 점진적인 누수가 아니라, 급격한 이벤트가 있다는 뜻이었다.
Ingester 하나가 전체를 먹는 캐스케이드
가장 눈에 띄는 세 번째 질문부터 파고들었다. Ingester 하나가 죽었을 뿐인데 왜 전체 대시보드가 멈출까?
sequenceDiagram participant App as App Services participant Dist as Distributor participant Ing as Ingester participant Ring as Hash Ring participant MG as metrics-generator participant Mimir as Mimir participant Grafana as Grafana Note over App, Grafana: 정상 운영 중 App->>Dist: OTLP traces Dist->>Ing: gRPC Push Dist->>MG: trace forward MG->>Mimir: remote_write (spanmetrics) Note over Ing: OOM Kill 발생 App->>Dist: OTLP traces Dist->>Ing: gRPC Push (retry) Ing--xDist: connection refused Dist->>Ing: gRPC Push (retry) Ing--xDist: connection refused Note over Dist: 실패/재시도 누적<br/>worker·queue 소모 Dist--xMG: trace forwarding 감소 Note over MG: spanmetrics 생성 감소 MG--xMimir: remote_write 감소 Note over Ing: K8s가 파드 Restart Ing->>Ring: heartbeat 전송 Note over Ring: healthy로 복귀 Note over Ing: 실제로는 WAL replay 중<br/>(요청 처리 불가) Dist->>Ring: Ingester 상태 조회 Ring-->>Dist: healthy Dist->>Ing: gRPC Push (ring says healthy) Ing--xDist: connection refused (좀비 상태) Grafana->>Mimir: PromQL query Mimir--xGrafana: No data (rate window 공백)
장애 캐스케이드를 시간순으로 정리하면 다음과 같다.
- Ingester 하나에서 OOM 발생
- Distributor가 해당 Ingester로 write 시도 →
connection refused반복 - 실패/재시도가 누적되면서 Distributor의 처리 효율 저하 (추정)
- metrics-generator가 받는 trace 원천 감소 → spanmetrics 생성 감소 (추정)
- K8s Restart → Ring에 healthy 복귀, 그러나 WAL replay 중이라 실제로는 요청 처리 불가
- Grafana의 spanmetrics 쿼리 결과 No data
여기서 교묘한 문제가 하나 더 있었다. ring에서 unhealthy Ingester가 자동으로 제거되려면 heartbeat_timeout(기본 5분)이 지나야 한다. 그런데 OOM으로 죽은 파드가 Kubernetes에 의해 Restart되면, 재기동 직후 heartbeat를 다시 보내면서 ring에서 곧바로 healthy로 복귀한다.
실제로 /ring 페이지에서 관찰한 결과, unhealthy로 전환되자마자 파드가 Restart되어 다시 healthy로 돌아오는 것이 반복되었다. Distributor 입장에서는 ring이 “정상”이라고 알려주는 Ingester인데, 실제로는 WAL replay 중이라 connection refused가 계속되는 좀비 상태였다.
WAL Replay: 자동 복구를 가로막는 구조적 함정
”Delete는 되고 Restart는 안 된다”가 가리키는 것
이제 두 번째 질문, “왜 OOM이 반복되는가”를 추적했다. 결정적 단서는 복구 방식에 있었다.
| 복구 방식 | 동작 | 결과 |
|---|---|---|
| Restart | emptyDir 유지 → WAL 데이터 잔존 → replay 시도 | 복구 실패, 재OOM |
| Delete | emptyDir 제거 → WAL 데이터 없음 → 깨끗한 기동 | 복구 성공 |
차이는 WAL 데이터의 존재 여부뿐이었다. OOM 파드의 로그를 분석하자 그 이유가 드러났다.
| 항목 | 건수 |
|---|---|
WAL replay 실패 (meta.json: no such file) | 28건 / 28 tenant |
| Local block reload 성공 | 20건 / 20 tenant |
| flush 미완료 block 재처리 | 5건 |
| replay 실패 후 reload으로 이어지지 못한 tenant | 8개 |
28건의 WAL replay 실패는 전부 meta.json: no such file 패턴이었다. OOM은 커널이 프로세스를 강제 kill하는 것이므로, 디스크 I/O 도중에 죽으면 block 디렉터리는 생성됐지만 meta.json 쓰기가 완료되지 못한 상태로 남는다.
연쇄 OOM의 메커니즘
여기서 반복 OOM의 구조가 보이기 시작했다.
① 첫 OOM 발생
└─ emptyDir에 flush되지 못한 WAL/block 데이터 잔존
② Restart 시 WAL replay + local block reload 시도
└─ 잔존 데이터 전체를 메모리에 재구성 → 메모리 급증
③ 메모리 limit 재초과 → 2차 OOM
④ ①~③ 반복 (cascade OOM)
이 추론은 유력하지만, replay 시점의 메모리 사용량 추이를 직접 측정하지는 못했다. 남은 질문은 replay 중 메모리가 왜 그만큼 필요한가이다. replay 대상 블록 수에 비례해 선형으로 늘어나는 것인지, 특정 조건에서 폭발적으로 치솟는 것인지에 따라 대응이 달라진다.
PV로 바꾸면 해결될까?
“Delete하면 정상 기동”이라는 관찰에서 자연스럽게 “안정적인 볼륨(PV)을 쓰면 되지 않나?”라는 생각이 나왔다. 그러나 관찰 사실이 말하는 것은 “WAL 데이터가 없으면 정상 기동된다”이지, “WAL 데이터가 안전하게 보존되면 정상 기동된다”가 아니다.
meta.json: no such file 오류는 emptyDir의 문제가 아니다. OOM kill 시점에 I/O가 중단되면 PV(EBS)를 써도 동일하게 불완전한 블록이 남는다. 오히려 PV 전환은 역효과를 낼 수도 있다. replay 대상 데이터가 삭제되지 않고 계속 누적되면서 cascade OOM이 더 심해질 수 있기 때문이다.
| 원인 가설 | PV 전환 시 예상 |
|---|---|
| 데이터량 과부하가 원인 | 악화 가능 (replay 대상 계속 누적) |
| 데이터 손상이 원인 | 개선 가능 (atomic write 지원 시) |
현재 증거로는 두 가설을 구분할 수 없으므로, 전면 전환보다 canary 검증이 먼저다.
첫 OOM의 트리거는 아직 미확정이다
근본 원인, 즉 첫 OOM을 촉발하는 트리거는 아직 확정하지 못했다. 평상시 34 GiB에서 순식간에 40 GiB로 치솟는 것은 점진적 누수가 아니라 급격한 이벤트가 있다는 뜻이다. 오전 910시 트래픽 피크에 집중되는 점을 고려하면, 큰 트레이스의 유입이 트리거일 가능성이 있다.
이 가설을 증명하려면 OOM 시점의 tempo_distributor_bytes_received_total과 tempo_ingester_live_traces 메트릭을 비교해야 한다. “트레이스 수는 그대로인데 바이트만 늘었다”면 큰 트레이스가 원인이고, “트레이스 수 자체가 폭증했다”면 단순 트래픽 급증이다. 2~4주에 한 번 발생하는 간헐적 장애라서, 개선 조치와 발생 감소의 인과관계를 증명하기가 구조적으로 어렵다.
교훈
replication_factor=1은 SPOF다. 분산 시스템에서 “복제 없음”은 단일 장애점을 만든다. 비용을 아끼려고 1로 둔 설정이, Ingester 하나의 OOM을 전체 Observability 블랙아웃으로 증폭시키고 있었다. hash ring 기반 분산에서 replication_factor는 가용성의 하한선이다.
1차 OOM보다 2차 cascade가 더 위험하다. 첫 OOM은 트래픽 피크라는 외부 요인일 수 있지만, WAL replay cascade는 시스템이 스스로 복구하지 못하게 만드는 구조적 함정이다. OOM이 WAL에 불완전한 데이터를 남기고, 그 데이터가 다음 기동에서 다시 OOM을 유발하는 피드백 루프가 형성된다.
“Delete하면 고쳐진다”는 해결이 아니다. 매번 데이터를 날리며 복구하는 것은 운영자의 수동 개입에 의존하는 것이고, 그 사이 데이터는 유실된다. 자동 복구가 가능한 구조를 만들어야 한다.
앞으로의 방향
문제를 두 방향으로 나누었다. OOM 자체를 막는 것(근본 원인 해결)은 트리거가 미확정이라 현재로선 어렵다. 대신 OOM이 발생해도 피해를 줄이는 것(폭발 반경 축소)은 지금 실행 가능하다. 수비를 먼저 확보하고, 근본 원인은 데이터를 축적하며 접근하는 전략을 택했다.
| 시기 | 행동 | 목적 |
|---|---|---|
| 즉시 | replication_factor 1 → 2 상향 | 폭발 반경 축소 (수비) |
| 1주 내 | 장애 시점 ±30분 메트릭/로그 보존 체계 구축 | 증거 수집 강화 |
| 2~4주 | PV canary vs emptyDir 대조군 운영 | WAL 안정성 가설 검증 |
| 지속 | replay 메모리 프로파일 + 유입량 측정 | 근본 원인 추적 |
replication_factor=2로 상향하면, OOM 구간에서도 다른 replica가 write/read를 처리하여 데이터 유실과 대시보드 공백을 방지할 수 있다. 다만 각 Ingester의 메모리 사용량이 최대 2배까지 증가할 수 있으므로, 전환 후 리소스 추이를 면밀히 관찰해야 한다.
다음 OOM 발생 시에는 Delete로 즉시 복구하지 않고, Restart 직후의 메모리 사용량 추이를 관찰할 계획이다. 메모리가 replay와 함께 선형 증가한다면 replay 대상 블록이 너무 많은 것이고, 특정 시점에 폭발적으로 증가한다면 특정 블록이나 tenant에서 비정상적 메모리 소비가 발생하는 것이다. 두 경우의 대응 방향이 다르기 때문에, 이 관찰이 다음 단계의 분기점이 된다.