Tempo Ingester OOM 추적하기
어느 날 오전 9시 10분, 슬랙 알람이 울렸습니다. Grafana 대시보드의 SpanMetrics 패널이 “No data”를 가리키고 있었습니다. 원인을 찾아보니 클러스터 내 9개의 Tempo Ingester 파드 중 하나가 OOM(Out Of Memory)으로 죽어 있었습니다.
파드 하나가 죽었을 뿐인데 대시보드 전체가 멈췄고, 파드가 재시작(Restart)되어도 복구되지 않은 채 계속해서 OOM을 반복했습니다. 결국 파드를 수동으로 삭제(kubectl delete pod)해야만 서비스가 정상으로 돌아왔습니다. 문제는 이 현상이 2~4주 간격으로 반복되었다는 것입니다.
약 3개월 동안 이 간헐적인 장애를 쫓으며 세 가지 질문이 생겼습니다.
- 왜 인제스터 1개만 죽었는데 전체 지표에 문제가 생길까?
- 왜 파드가 스스로 복구하지 못하고 ‘연쇄 OOM(Cascade OOM)’ 루프에 빠질까?
- 애초에 첫 번째 OOM은 왜 발생했을까?
이 글은 위 질문들에 대한 해답을 찾아가며, 분산 시스템의 구조적 함정과 복구 메커니즘을 파헤친 기록입니다.
1. 아키텍처 개요: LGTM 스택
본격적인 문제 분석에 앞서, 문제가 발생한 환경을 간단하게 짚고 넘어가겠습니다.
조직의 모니터링 및 Observability 인프라는 EKS 단일 클러스터에 집중되어 있습니다. 이곳에는 Grafana Labs의 LGTM 스택 (Loki, Grafana, Tempo, Mimir) 이 배포되어 전사적인 로그, 트레이스, 메트릭을 책임지고 있습니다.
이번 장애의 주인공인 Tempo 역시 이 클러스터 위에서 마이크로서비스 모드로 동작하고 있습니다.
flowchart LR A["App Services<br/>(OTLP)"] --> B["Tempo Distributor"] B --> C["Tempo Ingester<br/>(WAL/Local Block)"] B --> D["Metrics Generator"] C --> E["Object Storage<br/>(S3)"] D --> F["Mimir<br/>(remote_write)"] subgraph cashwalk-cluster["EKS"] B C D F end
- Distributor: 애플리케이션에서 보낸 트레이스(OTLP)를 받아 Ingester로 라우팅합니다.
- Ingester: 트레이스를 메모리 버퍼(Head Block)와 WAL(Write-Ahead Log)에 저장한 뒤, 최종적으로 S3로 내보냅니다.
- Metrics-generator: 유입된 트레이스를 기반으로 SpanMetrics를 생성하고, 이를 Mimir로 전송(remote_write)합니다.
2. 인제스터 1개만 죽었는데…
첫 번째 의문은 파급력이었습니다. Ingester는 수평 확장이 가능한 컴포넌트인데, 왜 한 대가 죽었다고 전체 모니터링이 마비되었을까요?
해답은 Tempo의 데이터 라우팅 방식과 Replication Factor(복제 계수) 설정에 있었습니다.
flowchart LR subgraph Distributor D1[distributor-1] D2[distributor-2] end subgraph Ring I1[ingester-1] I2[ingester-2] I3[ingester-3<br/>dead] end D1 -->|TraceID Hash| I3 D2 -->|TraceID Hash| I3 style I3 fill:#f8d7da,stroke:#d33,stroke-width:2px
애플리케이션이 보낸 트레이스는 Distributor를 거쳐 Ingester로 향합니다. 이때 Distributor는 TraceID를 해시하여 Consistent Hash Ring 기반으로 데이터를 저장할 Ingester를 결정합니다.
당시 환경의 설정은 비용 최적화를 위해 replication_factor=1 로 맞춰져 있었습니다. 즉, 특정 트레이스는 대체 경로 없이 단 하나의 Ingester에만 저장된다는 뜻입니다.
Ingester 1대가 죽자, 그 노드에 할당된 Shard로 향하는 쓰기 요청이 실패하기 시작했습니다. 모든 Distributor가 동일한 Hash Ring을 공유하므로, 클러스터 전체의 Distributor에서 connection refused 및 append failure 에러가 발생했습니다.
결과적으로 트레이스 유입이 병목에 걸리면서, 이를 기반으로 메트릭을 생성하는 Metrics-generator 역시 데이터를 받지 못해 Mimir로 보내는 SpanMetrics가 끊겼고, 최종적으로 Grafana 대시보드에 “No data”가 표시된 것입니다.
비용 최적화를 위해 설정한 replication_factor=1이 결과적으로 전체 Observability 스택의 단일 장애점(SPOF) 으로 작용하고 있었습니다.
3. “Delete는 되고 Restart는 안 된다”
두 번째 의문은 복구 과정이었습니다. K8s가 죽은 파드를 살려내는데도 왜 20여 초 만에 다시 OOM으로 죽기를 반복했을까요? 반면, 수동으로 파드를 삭제(delete)하면 왜 정상으로 돌아왔을까요?
두 동작의 유일한 차이는 emptyDir 볼륨의 유지 여부였습니다.
- Restart: 파드 내 컨테이너만 재시작되므로
emptyDir의 데이터가 유지됨. - Delete: 파드 전체가 날아가며
emptyDir데이터(기존 WAL 및 Block)가 초기화됨.
즉, “이전 상태(Old State)를 로드하면서 올라오면 죽고, 백지상태로 시작하면 산다” 는 뜻이었습니다. 이를 확인하기 위해 OOM 파드의 재시작 직후 로그를 확인했습니다.
1. beginning wal replay
2. wal replay complete (96ms)
3. reloading local blocks tenants=33
4. existing instance found in ring state=ACTIVE
5. completing block ...
6. flushing block ...
7. OOMKilled원인은 복구 과정의 순서에 있었습니다. 파드가 재시작되면서 디스크에 남아있던 Local block들을 메모리에 올리고(Reload), 미처 S3에 올리지 못한 블록들을 정리(Completing/Flushing)하는 복구 작업을 시작합니다.
그런데 이 작업이 끝나기도 전인 4번 단계에서, 파드가 Hash Ring에 스스로를 ACTIVE 상태로 등록해버립니다.
Distributor는 K8s의 Readiness Probe가 아닌 이 Hash Ring 상태를 보고 트래픽을 라우팅합니다.
결과적으로 Ingester는 기존 블록들을 메모리에 복원하여 S3로 쏴 올리는(Flush) 동시에, Distributor가 보내는 새로운 쓰기(Write) 트래픽까지 받아내야 했습니다. 평소 3~4GiB이던 메모리 사용량이 수십 GiB로 치솟으며 Limit을 넘었고, 20초 만에 다시 OOM Killed를 당하는 ‘연쇄 OOM(Cascade OOM)’ 루프가 발생한 것입니다.
4. 첫 OOM은 왜 발생했을까?
루프의 원인은 찾았지만, 애초에 첫 번째 OOM을 유발한 트리거는 무엇이었을까요?
장애 직전의 메트릭을 살펴보아도 죽어버린 Ingester 파드만 트래픽을 많이 받은 흔적은 없었습니다. 다만 이전 로그 분석 중 단서를 발견할 수 있었습니다.
msg=TRACE_TOO_LARGE max=5000000 totalSize=104957032특정 Tenant에서 약 105MB에 달하는 단일 트레이스가 유입된 흔적이 있었습니다. 당시 시스템에는 max_bytes_per_trace 제한이 설정되어 있지 않았습니다.
Tempo Ingester는 들어온 트레이스를 곧바로 객체 스토리지에 쓰지 않고 메모리 버퍼(Head Block)에 모아둡니다. 수십~수백 MB의 트레이스들이 몰리거나, Block Cut/Flush 시점과 맞물려 메모리 스파이크가 발생했을 때 Go GC가 이를 제때 정리하지 못하면 메모리 Limit을 초과할 수 있습니다.
완벽한 인과관계를 증명하려면 다음 OOM 발생 시 힙 덤프(Heap Dump)나 pprof 수집이 필요합니다.
💡 해결 방법 및 운영 가이드
1. 즉각적인 가드레일: Trace 크기 제한
비정상적으로 큰 트레이스가 메모리 스파이크를 일으키는 것을 막기 위해 설정에 하드 리미트를 추가했습니다.
overrides:
defaults:
max_bytes_per_trace: 5000000 # 5MB 초과 트레이스 차단이로 인해 5MB가 넘는 트레이스는 버려지겠지만(tempo_discarded_spans_total), 전체 시스템 장애를 예방하는 것이 더 중요하다고 판단했습니다.
2. 운영 런북(Runbook) 변경: Restart를 기다리지 말 것
Tempo Ingester가 OOM으로 죽고 CrashLoopBackOff에 빠졌다면, K8s의 Restart를 기다리는 것은 복구가 아니라 연쇄 장애의 반복일 수 있습니다.
- 알람 발생 즉시 OOM 파드의 최소 로그와 메트릭(Flush Queue, Append Failure 등)을 캡처합니다.
- 지체 없이 **
kubectl delete pod**를 실행해 망가진 복구 루프를 끊고 백지상태에서 파드를 띄워 MTTR(평균 복구 시간)을 줄입니다.
3. 구조적 개선: SPOF 제거
장기적으로는 단일 장애점을 없애기 위해 replication_factor를 2 이상으로 상향해야 합니다. 하나의 Ingester가 죽더라도 다른 Replica가 읽기/쓰기를 처리하여 데이터 유실과 대시보드 공백을 막을 수 있습니다. 단, 이는 클러스터 전반의 리소스(메모리, 노드) 사용량이 증가하므로, Headroom 확보 후 진행하기로 했습니다.
마무리하며
이번 이슈는 분산 시스템에서 단일 노드의 실패(OOM)가 시스템 전체의 장애로 전파되는 과정, 그리고 의도와 다르게 동작하는 복구 메커니즘(Recovery Path)이 장애를 반복시키는 과정을 보여주는 사례였습니다.
문제의 원인을 “메모리가 부족하다”로 판단하고 Limit만 늘렸다면 연쇄 루프의 원인을 파악하지 못했을 것입니다. “왜 재시작은 안 되고 삭제는 되는가?”라는 관찰이 구조적 결함을 찾는 단서가 되었습니다.
때로는 시스템이 스스로 복구되기를 기다리는 것보다, 이전 상태를 초기화하는 것이 올바른 복구 방식일 수 있습니다.