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

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

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

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