죽어도 살아나지 않는 Pod: emptyDir, 28회 OOM 루프, 그리고 HPA의 역설
쿠버네티스를 사용하면서 우리가 가장 신뢰하는 가치 중 하나는 “자가 치유(Self-healing)” 다. 프로세스에 문제가 생겨 컨테이너가 죽더라도, kubelet이 알아서 새 컨테이너를 띄워 시스템을 정상 상태로 되돌려 놓을 것이라 믿기 때문이다.
하지만 당시 Ingester 파드의 재시작 카운트는 1, 2를 넘어 어느새 28회 까지 치솟고 있었다.
파드는 살아난 지 정확히 22초 만에 어김없이 다시 OOMKilled로 쓰러졌다. 마치 정교하게 프로그래밍된 타이머라도 달린 것처럼 죽고 살아나기를 끝없이 반복했다.
“프로세스가 새로 떴다면 이전의 메모리 상태는 완전히 초기화되었을 텐데, 대체 왜 똑같은 22초 뒤에 다시 죽는 걸까?” “왜 쿠버네티스의 자가 치유는 이 상황에서 아무런 도움이 되지 못하고 끝없는 무한 루프를 만들었을까?”
이 기이한 현상을 해결하기 위해 파드의 기동 로그와 쿠버네티스의 상태 관리 메커니즘을 파고들기 시작했다.
22초의 타임라인: 새로 뜬 프로세스가 한 일
파드가 살아나서 죽기까지의 22초 동안 내부에서 무슨 일이 벌어지는지 타임스탬프를 초 단위로 추적했다.
2026-07-10 10:11:30 KST - 컨테이너 기동
2026-07-10 10:11:30.120 - wal replay complete (72ms 소요)
2026-07-10 10:11:30.250 - reloading local blocks tenants=35
2026-07-10 10:11:31.010 - Tempo started
2026-07-10 10:11:33.400 - existing instance found in ring state=ACTIVE tokens=128
2026-07-10 10:11:35.100 - completing block tenant=대규모트래픽API block=247b1aa4-...
2026-07-10 10:11:52.890 - (OOMKilled, exit 137 발생)로그를 여러 세대에 걸쳐 비교해 보았을 때, 소름 돋는 사실 하나가 눈에 들어왔다.
재시작된 컨테이너가 10시 11분 35초에 변환을 시작한 블록의 ID(247b1aa4-...)는, 직전 세대 컨테이너를 죽음으로 몰고 갔던 바로 그 블록 ID와 완벽히 일치 했다.
1세대 컨테이너가 특정 테넌트의 복잡한 트레이스 블록을 Parquet으로 변환하다가 메모리 한계를 넘어 죽었다. 그런데 2세대 컨테이너가 뜨자마자 그 실패했던 블록을 디스크에서 다시 집어 들고 변환을 시도하다가 똑같이 죽었다. 3세대, 4세대도 마찬가지였다.
새로 뜬 프로세스는 전 세대를 죽인 독약(Poison Pill) 을 스스로 다시 먹고 있었던 것이다.
emptyDir의 배신: 컨테이너 재시작과 파드 삭제의 차이
“컨테이너가 새로 떴는데, 왜 이전 세대의 실패한 블록 파일이 디스크에 그대로 남아있었을까?”
이 질문의 답을 찾으며 나는 내가 쿠버네티스의 emptyDir 볼륨 수명 주기 를 오해하고 있었다는 것을 깨달았다.
많은 엔지니어들이 emptyDir를 “임시 볼륨이니까 컨테이너가 재시작되면 깨끗이 비워진다”고 생각하곤 한다. 나 역시 무의식중에 그렇게 믿고 있었다. 하지만 쿠버네티스 공식 문서에 명시된 emptyDir의 수명은 컨테이너가 아니라 파드(Pod) 에 종속된다.
flowchart TD subgraph RESTART["컨테이너 재시작 (Container Restart)"] direction TB R_PROC["컨테이너 프로세스 종료 (Exit 137)"] --> R_NEW["새 컨테이너 기동<br>(동일 Pod UID 유지)"] R_NEW -.->|"파일 보존!"| R_VOL["/var/tempo (emptyDir)<br>미완료 블록, WAL 그대로 유지"] R_VOL --> R_RETRY["이전 세대를 죽인 블록을 다시 변환 시도"] end subgraph DELETE["파드 삭제 (Pod Delete)"] direction TB D_DEL["파드 강제 삭제 (kubectl delete pod)"] --> D_NEW["완전히 새로운 파드 스케줄링<br>(새로운 Pod UID 발급)"] D_NEW --> D_VOL["깨끗한 빈 emptyDir 할당<br>(로컬 블록 없음, tenants=0)"] end classDef restart fill:#fff3e0,stroke:#e65100,stroke-width:1px; classDef delete fill:#e8f5e9,stroke:#2e7d32,stroke-width:1px; class RESTART restart; class DELETE delete;
당시 Ingester 파드는 빠른 로컬 디스크 쓰기를 위해 /var/tempo 경로를 emptyDir로 마운트하고 있었다.
컨테이너가 OOM으로 죽었을 때, kubelet은 파드를 다시 스케줄링한 것이 아니라 동일한 파드 안에서 컨테이너 프로세스만 다시 실행 시켰다. 파드의 UID가 바뀌지 않았으니 /var/tempo에 마운트되어 있던 디스크 파일들(WAL과 아직 S3로 업로드되지 못한 미완료 로컬 블록들)은 티끌 하나 손상되지 않고 고스란히 보존되었다.
새로 뜬 Tempo 프로세스 입장에서는 로컬 디스크에 미완료 블록이 남아있으니, 이를 백엔드로 안전하게 플러시하기 위해 성실하게 변환 작업을 재개한 것이 당연했다.
결국 쿠버네티스의 성실한 프로세스 재시작과 emptyDir의 상태 보존 특성이 결합하면서, 실패한 작업을 무한히 재시도하는 완벽한 DoS(Denial of Service) 루프 가 완성되었던 것이다.
Hash Ring과 트래픽의 이중고
여기에 상황을 한층 더 악화시킨 요소가 있었다. 바로 Tempo의 해시 링(Hash Ring) 동작 방식이었다.
상식적으로 생각하면 이렇게 20초마다 죽어 나가는 노드가 있다면, 클러스터 차원에서 해당 노드를 빠르게 비정상(Unhealthy) 처리하고 트래픽을 차단해야 마땅했다. 하지만 기동 로그를 보면 파드는 뜨자마자 다음과 같은 선언을 남겼다.
msg="existing instance found in ring" state=ACTIVE tokens=128Ingester 파드가 기동되자마자 이전에 자신이 링에서 들고 있던 토큰 정보를 그대로 복원하면서 상태를 즉시 ACTIVE로 올려버렸다.
이로 인해 파드는 치유를 위한 유예 시간도 갖지 못한 채 다음과 같은 극단적인 부하에 노출되었다.
- 디스크에 남은 독약 블록의 Parquet 변환 작업
- 이전 세대에서 밀려있던 로컬 블록들의 S3 업로드 작업
ACTIVE상태를 보고 Distributor가 쏘기 시작한 대규모 신규 트레이스 유입
파드가 숨을 고를 틈도 없이 신규 트래픽까지 밀려드니, 메모리는 한계선을 향해 더 가파르게 치솟을 수밖에 없었다.
주변 인프라가 겪은 위기와 내가 본 것들
이 OOM 루프가 28회나 지속되는 동안, 시스템의 다른 영역에서도 불안한 신호들이 포착되었다.
1. Collector(Alloy)의 재시도 부재와 데이터 손실
Distributor가 Ingester 쓰기 실패로 90%의 요청에 에러를 뱉고 있을 때, 업스트림의 Alloy가 데이터를 로컬 큐에 보관하고 재시도(Retry)해 주었다면 파드가 복구된 뒤 지표가 메워졌을 것이다.
하지만 당시 Alloy exporter 설정을 확인해 보니 retry_on_failure.enabled = false로 되어 있었고, 디스크 기반의 Persistent Queue도 없었다. 네트워크 에러가 발생한 수많은 배치 데이터는 재시도의 기회조차 얻지 못하고 그 자리에서 영구적으로 소실 되었다.
2. HPA의 역설 (Reverse Scaling의 위험)
가장 아찔했던 관찰은 쿠버네티스 HPA(Horizontal Pod Autoscaler) 의 움직임이었다. 당시 Ingester 클러스터는 CPU와 메모리 사용률(목표 70%)을 기반으로 자동 스케일링되도록 구성되어 있었다.
장애 중 지표를 모니터링하던 중 이상한 현상을 발견했다. OOM 루프에 빠진 1대를 제외한 나머지 정상 Ingester 8대의 CPU 사용량이 평소보다 오히려 뚝 떨어지는 현상 이 나타난 것이다.
이유는 단순했다. 앞선 1편에서 보았듯 단 1대의 실패로 인해 Distributor에서 요청 전체가 에러 처리되면서, 정상 Ingester들이 처리해야 할 유효한 쓰기 작업량(Successful Append)마저 절반 이하로 줄어들었기 때문이다.
일감이 줄어드니 정상 파드들의 CPU 사용률이 내려갔고, HPA는 이를 “전체 트래픽이 줄어들었으니 파드 수를 줄이자”고 판단해 Scale-down 추천값 을 계산하고 있었다.
다행히 우리 설정에 scaleDown.stabilizationWindowSeconds: 600이 걸려 있어서 10분간 실제 파드가 축소되는 참사는 일어나지 않았다. 하지만 만약 안정화 윈도우가 없었다면, 장애로 인해 시스템이 신음하고 있는데 HPA가 정상 파드마저 강제로 줄여버리는 끔찍한 연쇄 붕괴 로 이어질 뻔했다.
내가 내린 조치: Container Restart가 아닌 Pod Delete
독약 블록이 emptyDir에 남아있는 한, 컨테이너를 100번 재시작해도 이 루프는 영원히 끝나지 않는다는 확신이 들었다.
필요한 것은 프로세스 재기동이 아니라 파드의 완전한 소멸과 볼륨의 초기화 였다. 나는 주저 없이 해당 Ingester 파드를 강제로 삭제했다.
# 컨테이너 재시작이 아닌, 파드를 완전히 삭제하여 볼륨을 리셋한다
kubectl delete pod tempo-ingester-4 -n monitoring결과는 즉각적이었다.
StatefulSet에 의해 스케줄링된 새로운 파드는 완전히 새로운 UID를 발급받았고, 오염된 로컬 파일이 없는 깨끗한 빈 emptyDir를 마운트했다.
기동 로그에 reloading local blocks tenants=0이라는 문구가 찍히며 부채 없는 완벽한 Clean Start 가 이루어졌다. 독약 블록이 사라지자 파드는 정상 Ready 상태를 유지했고, 28회 동안 이어지던 append failure는 즉시 0으로 떨어지며 Metrics Generator의 지표도 1초 만에 평소 수준으로 완벽히 복구되었다.
이 장애가 내게 남긴 생각
이 사건을 겪으며 나는 분산 시스템과 컨테이너 인프라를 바라보는 중요한 교훈 세 가지를 얻었다.
첫째, 쿠버네티스의 자동 재시작이 항상 구원이 되는 것은 아니다. 상태(State)를 로컬 디스크에 임시로 들고 있는 애플리케이션의 경우, 복구 불가능한 데이터 결함이 볼륨에 남았을 때 컨테이너 재시작은 오히려 자가 치유를 가로막는 족쇄가 된다.
둘째, Stateful 워크로드의 장애 대응 런북은 달라야 한다. 메모리 누수나 일시적 부하가 아니라 동일 블록/동일 작업으로 반복 OOM이 발생할 때는, 재시작을 지켜보는 대신 파드를 재생성하여 로컬 볼륨을 비워내는 과감한 결단이 필요하다.
셋째, 지표 기반 오토스케일링(HPA)의 맹점을 경계해야 한다. 에러로 인해 처리량이 줄어드는 장애 상황에서, 단순 CPU/메모리 사용률은 인프라의 실제 부하 상태를 정반대로 왜곡할 수 있다.