메모리는 60%인데 Pod가 죽는다고? Kubernetes OOMKilled의 진짜 원인
멀쩡해 보이는데 갑자기 사라지는 Pod
모니터링 대시보드상으로는 모든 게 정상인데, Kubernetes Pod가 간헐적으로 죽어나가는 경험. 한 번쯤 겪어봤을 겁니다. 애플리케이션 로그도 깔끔하고, 크래시 루프도 없고, 메모리 사용률은 60% 정도로 여유로워 보였죠.
그런데 kubectl describe pod를 해보면 거기에 OOMKilled가 찍혀 있습니다.
저도 처음엔 납득이 안 갔습니다. 메모리가 60%밖에 안 쓰이는데 왜 메모리 부족으로 죽지? 설마 모니터링 도구가 거짓말을 하는 건가 싶었죠. 그런데 도구를 의심하기 시작하면 답이 안 나옵니다. 대부분의 경우 도구는 정직합니다. 다만 우리가 도구에게 잘못된 질문을 던지고 있을 뿐이죠.

평균이 숨기는 진실
문제를 파헤쳐보니, 우리가 보고 있던 모든 지표가 시간에 따른 평균값이었습니다.
평균 메모리 사용률 60%라는 숫자는, 실제로는 이런 상황을 한 줄로 뭉개버린 결과일 수 있습니다.
- 평소: 50MB
- 갑작스러운 요청: 600MB (0.5초간)
- 다시: 50MB
평균적으로는 안전한데, 순간적으로는 한계를 넘어선 거죠.
여기서 중요한 건 Kubernetes가 평균으로 판단하지 않는다는 점입니다. Kubernetes의 메모리 제한은 가이드라인이 아니라 엄격한 벽입니다. 컨테이너가 설정된 한계를 넘어서는 그 순간, 커널이 즉시 SIGKILL을 날립니다. 협상의 여지도, graceful shutdown도 없어요. 0.5초든 5분이든 커널 입장에서는 똑같습니다. 한 번 넘었으면 끝입니다.
결국 우리는 '평균으로 안전한 시스템'을 보고 있었고, 커널은 '순간 최대값으로 위험한 시스템'을 보고 있었습니다. 같은 컨테이너를 두고 두 관점이 완전히 다른 판단을 내린 거죠.
왜 모니터링 시스템은 이걸 못 잡을까
대부분의 모니터링 시스템이 이런 메모리 스파이크를 놓치는 데는 구조적인 이유가 있습니다.
1. 스크래핑 간격이 너무 길다
- 보통 15~30초마다 메트릭을 수집
- 200~500ms 지속되는 스파이크는 두 수집 시점 사이에 끼어서 통째로 사라짐
2. 평균 기반의 시각화
- 대시보드는
avg()함수와 긴 시간 윈도우를 기본값으로 씀 - 600MB 스파이크가 320MB짜리 매끄러운 선으로 둔갑
3. 잘못된 지점을 측정
- 애플리케이션 힙 메모리만 보고 안심
- 정작 Kubernetes는 컨테이너 수준 메모리(working set)로 판단
4. 비용과 해상도의 타협
- 고해상도 메트릭은 저장 비용과 성능 부담이 같이 올라감
- 그래서 대부분의 팀은 해상도를 낮추는 쪽으로 타협하고, 그 대가로 가시성을 포기
네 가지를 묶어보면 결국 하나의 문제로 수렴합니다. 우리가 보는 그래프는 시스템의 실제 모습이 아니라, 수집 간격과 집계 함수로 한 번 가공된 요약본이라는 거죠. 요약본은 평소엔 충분합니다. 문제는 장애가 항상 요약 과정에서 잘려나간 디테일에서 터진다는 점이고요.
진짜 문제를 보는 방법
이런 숨겨진 스파이크를 잡으려면 보는 방식 자체를 바꿔야 합니다.
1. 평균 대신 최대값으로
# 기존: avg_over_time(container_memory_usage_bytes[5m])
# 개선: max_over_time(container_memory_usage_bytes[30s])
윈도우를 줄이고 max로 보는 것만으로도, 그동안 평균선에 묻혀 있던 봉우리들이 드러나기 시작합니다.
2. 올바른 메트릭 선택
container_memory_working_set_bytes를 봐야 함- 애플리케이션 레벨이 아니라 커널이 OOM 판단에 쓰는 그 값을 추적
3. 짧은 스크래핑 간격
- 중요 서비스는 1~5초 간격으로 수집
- 비용은 늘지만, 적어도 장애 원인을 사후에 설명할 수는 있게 됨
4. 이벤트 상관관계 분석
- Pod 재시작과 OOMKilled 이벤트를 연결해서 보기
- 그 타이밍을 메모리 스파이크와 겹쳐놓으면 인과가 보임
해본 사람은 알겠지만, 가장 효과가 빠른 건 1번과 2번입니다. 쿼리 한 줄 바꾸고 메트릭 하나 갈아끼우는 것만으로도 "왜 죽었는지 모르겠다"가 "여기서 600까지 튀었네"로 바뀝니다.

메모리 스파이크가 생기는 이유
실제 운영 환경에서 메모리가 순간적으로 치솟는 흔한 케이스들입니다.
- 갑작스러운 트래픽 증가: 동시 요청이 한꺼번에 몰리면서 메모리 사용량 폭증
- 대용량 데이터 처리: JSON 파싱, 파일 업로드처럼 통째로 메모리에 올리는 작업
- 가비지 컬렉션 지연: 해제가 늦어지면서 일시적으로 누적
- 인메모리 데이터 변환: 배치 처리나 대용량 응답 생성
- 동시성 버그: 메모리 릭이나 비효율적인 동시 처리
눈여겨볼 건, 이 중 상당수가 버그가 아니라 부하 상황에서 나타나는 정상 동작이라는 점입니다. JSON 600MB를 한 번에 파싱하면 메모리가 600MB 튀는 게 당연합니다. 코드는 설계대로 동작한 거예요. 문제는 그 정상적인 봉우리가 엄격한 메모리 제한과 만나는 순간 장애로 번역된다는 데 있습니다.
그래서 OOMKilled를 볼 때 무조건 코드부터 의심하면 길을 잃습니다. 먼저 물어야 할 질문은 "이게 버그인가, 아니면 그냥 부하의 모양인가"입니다.
실용적인 해결 방법
완벽한 해결책은 없습니다. 결국 상황에 맞는 절충안을 고르는 일이에요.
1. 메모리 제한에 여유 확보
- 평소 사용률을 40~50% 수준으로 잡고 운영
- 스파이크가 들어올 공간을 미리 비워둠
- 대신 그만큼 비용이 늘어나는 건 감수해야 함
2. 애플리케이션 최적화
- 통째로 올리지 말고 스트리밍 방식으로 처리
- 대용량 객체 할당 자체를 줄이기
- 런타임 메모리 설정 조정 (Node.js
--max-old-space-size등)
3. 현실적인 부하 테스트
- 매끄러운 부하 말고 버스트 트래픽을 흉내내야 함
- 실제 사용자 패턴과 비슷한 시나리오로 던져보기
- 운영에서 터지기 전에 스파이크 패턴을 미리 봐두는 것
4. Request와 Limit 분리
- Request와 Limit을 다르게 설정
- 일시적 초과는 허용하되 지속적 초과는 막는 구조
네 가지를 자리에서 보던 시선으로 정리하면 이렇습니다. 1번은 운영의 해법, 2번은 개발의 해법, 3번은 검증의 해법, 4번은 아키텍처의 해법입니다. 같은 OOMKilled 한 줄이 어느 자리에서 보느냐에 따라 다른 처방으로 갈라지는 거죠. 그리고 대부분의 현장에서 진짜 답은 이 넷을 섞는 데 있습니다.

정상 상태가 아니라 경계에서 터진다
이 일을 겪고 다시 확인하게 된 건, 대부분의 운영 문제는 정상 상태에서 발생하지 않는다는 점입니다.
장애는 늘 경계에서 터집니다.
- 예상치 못한 트래픽 패턴
- 시스템 간 상호작용의 복잡성
- 사소한 가정이 무너지는 순간
평균적인 지표는 정상 구간을 잘 보여줍니다. 문제는 장애가 정상 구간 밖에서 일어난다는 거죠. 그래서 평균만 보고 있으면 사고를 예측하지 못하고, 매번 터진 다음에야 원인을 찾으러 내려가게 됩니다.
결국 모니터링이라는 건 시스템이 멀쩡할 때의 모습을 그리는 게 아니라, 무너지기 직전 경계에서 어떻게 흔들리는지를 보는 일이라고 생각합니다. 평소에 안전해 보이는 시스템일수록, 경계에서 어떻게 동작하는지를 한 번은 직접 들여다봐야 합니다. 그 60%짜리 평균선 아래에 무엇이 숨어 있는지 모르는 채로는, 다음 새벽 호출을 막을 방법이 없으니까요.