쿠버네티스 파드 메모리 초과 장애 해결: OOMKilled 분석 및 리소스 최적화 가이드

쿠버네티스 파드 메모리 초과 장애 해결: OOMKilled 분석 및 리소스 최적화 가이드

프로덕션 환경에서 쿠버네티스(Kubernetes) 클러스터를 운영하다 보면 한 번쯤 마주치는 치명적인 에러가 있습니다. 바로 컨테이너가 갑자기 종료되고 재시작을 반복하는 OOMKilled (Out Of Memory Killed) 상태입니다. 특정 시간에 트래픽이 몰리거나, 혹은 코딩 실수로 인해 지속적인 메모리 누수가 발생했을 때 주로 나타나며, 관리자는 쏟아지는 슬랙 알람 속에서 급하게 파드를 롤백하거나 리소스를 늘리곤 합니다. 저 역시 과거에 트래픽이 폭증하는 이벤트 기간 중 주요 마이크로서비스 파드가 연쇄적으로 OOMKilled 되면서 서비스 장애를 겪은 아찔한 경험이 있습니다. 이 글에서는 땜질식 처방을 넘어, 근본적인 쿠버네티스 파드 메모리 초과 장애 해결을 위한 원인 분석부터 리소스 설정 최적화, 그리고 애플리케이션 레벨의 프로파일링 기법까지 상세하게 다룹니다. 지금 겪고 있는 OOMKilled의 정확한 원인을 파악하고 다시는 동일한 장애가 발생하지 않도록 견고한 인프라를 구축하는 핵심 노하우를 얻어 가실 수 있습니다.

OOMKilled 에러의 동작 원리와 발생 이유

쿠버네티스 환경에서 OOMKilled 이벤트는 커널 레벨의 보호 메커니즘이 작동한 결과입니다. 컨테이너가 시스템에 할당된 메모리 limits를 초과하여 자원을 요청할 때, 리눅스 커널의 OOM Killer (Out Of Memory Killer)가 시스템 전체의 안정성을 보호하기 위해 해당 컨테이너 프로세스를 강제로 종료시킵니다.

OOMKilled가 발생하는 주요 원인은 크게 세 가지로 분류할 수 있습니다. 첫째, 애플리케이션 자체의 메모리 누수(Memory Leak)입니다. 사용이 끝난 객체나 캐시가 가비지 컬렉터(Garbage Collector)에 의해 수거되지 않고 지속적으로 쌓일 때 발생합니다. 둘째, 트래픽 폭증에 따른 정상적인 메모리 사용량 증가입니다. 일시적인 부하를 견딜 수 있도록 충분한 버퍼를 두지 않고 타이트하게 리소스 제한을 설정했을 때 흔하게 나타납니다. 셋째, JVM이나 Node.js 등 런타임 환경의 힙(Heap) 메모리 설정이 쿠버네티스의 컨테이너 제한과 맞지 않을 때입니다. 컨테이너는 1GB로 제한되어 있는데, JVM 힙 사이즈를 시스템 전체 메모리 기준으로 잡게 되면 필연적으로 충돌이 발생합니다.

실제로 kubectl describe pod <파드_이름> 명령어를 통해 이벤트 로그를 살펴보면 아래와 같은 명확한 흔적을 찾을 수 있습니다.

State:          Waiting
  Reason:       CrashLoopBackOff
Last State:     Terminated
  Reason:       OOMKilled
  Exit Code:    137

여기서 Exit Code 137은 프로세스가 SIGKILL(9) 신호를 받아 강제 종료되었음을 의미하며 (128 + 9 = 137), 메모리 초과로 인해 커널이 가차 없이 컨테이너를 죽였음을 나타내는 가장 확실한 증거입니다.

쿠버네티스 Requests와 Limits의 올바른 이해

메모리 장애를 해결하기 위해서는 쿠버네티스 리소스 할당의 핵심 개념인 requestslimits를 정확히 이해해야 합니다. 수많은 초기 쿠버네티스 도입 기업들이 이 두 가지 값을 잘못 설정하여 자원 낭비나 예기치 못한 OOMKilled를 겪습니다.

requests는 파드가 실행되기 위해 보장받아야 하는 최소한의 자원 양을 의미합니다. 쿠버네티스 스케줄러는 이 값을 기준으로 클러스터 내의 어느 노드에 파드를 배치할지 결정합니다. 반면, limits는 파드가 사용할 수 있는 자원의 최대 상한선입니다.

파드의 메모리 사용량이 requests를 넘어서더라도 limits에 도달하기 전까지는 정상적으로 동작합니다. 하지만 메모리 사용량이 limits를 초과하는 순간 앞서 설명한 OOM Killer가 작동하여 프로세스가 종료됩니다. CPU의 경우 한도를 초과하면 스로틀링(Throttling)이 걸려 속도만 느려지지만, 메모리는 압축 불가능한 자원(Incompressible Resource)이기 때문에 용서를 구하지 않고 즉각 종료(Kill) 처리되는 특성이 있습니다.

이를 올바르게 설정하는 베스트 프랙티스는 다음과 같습니다: 1. Requests는 평상시 사용량의 120% 수준으로 설정: 파드가 안정적으로 구동될 수 있는 기본 체력을 보장합니다. 2. Limits는 Requests의 1.5배 ~ 2배 수준으로 설정: 급격한 트래픽 스파이크를 견딜 수 있는 여유 공간을 둡니다. 단, 너무 높게 설정하면 노드 전체의 자원을 고갈시키는 Noisy Neighbor 문제를 유발할 수 있습니다. 3. 가장 중요한 서비스의 경우 Requests와 Limits를 동일하게 설정 (Guaranteed QoS): 메모리가 부족한 상황에서 우선적으로 종료되는 타겟을 피하고 클러스터 내에서 최고 수준의 서비스 품질(Quality of Service)을 보장받을 수 있습니다.

JVM 및 Node.js 컨테이너 환경의 메모리 튜닝

쿠버네티스의 설정만 변경한다고 해서 모든 문제가 해결되는 것은 아닙니다. 컨테이너 내부에서 동작하는 애플리케이션 런타임(Runtime) 환경이 컨테이너의 제약을 인지하도록 설정하는 과정이 필수적입니다.

특히 Java 기반의 스프링 부트(Spring Boot) 애플리케이션은 JVM 환경에서 동작하므로 OOMKilled의 단골 손님입니다. 기존의 가상 머신(VM) 환경에서는 JVM이 호스트 OS의 전체 메모리를 기준으로 힙 크기를 결정했지만, 컨테이너 환경에서는 cgroup으로 제한된 한도를 읽지 못하고 호스트 노드의 메모리를 전체 메모리로 착각하는 경우가 있었습니다. (다행히 Java 10 이후, 그리고 Java 8의 최신 업데이트에서는 컨테이너 지원이 강화되었습니다.)

Java 애플리케이션에서 컨테이너 한도를 인지하게 하려면 실행 인자에 다음 옵션을 반드시 추가해야 합니다: -XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0

위와 같이 설정하면 JVM이 쿠버네티스의 limits를 인지하고, 그 제한된 메모리의 75%만 힙 영역으로 사용하여 나머지 25%를 스레드 스택 및 네이티브 메모리(Metaspace 등)를 위한 여유 공간으로 남겨 OOMKilled를 효과적으로 방지할 수 있습니다.

Node.js 환경 역시 유사한 문제가 발생할 수 있습니다. V8 엔진은 기본적으로 64비트 시스템에서 최대 1.4GB 정도의 메모리만 사용하도록 설정되어 있습니다. 만약 컨테이너의 메모리 한도를 4GB로 늘렸더라도, Node.js가 이를 인지하지 못하면 프로세스 내에서 스스로 FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory를 내뱉으며 죽게 됩니다. 이런 경우 아래와 같이 V8 엔진 옵션을 명시적으로 조정해야 합니다. --max-old-space-size=3072 (단위: MB)

애플리케이션 프로파일링을 통한 메모리 누수 추적

쿠버네티스 파드 메모리 초과 장애 해결의 종착지는 결국 코드 레벨의 최적화입니다. limits를 아무리 늘려도 메모리 누수(Leak)가 존재한다면 OOMKilled는 시점만 지연될 뿐 언젠가 반드시 발생합니다. 이를 위해 메모리 덤프를 뜨고 프로파일링 툴을 사용하여 근본 원인을 찾아야 합니다.

저의 실무 경험을 예로 들면, 그라파나(Grafana) 대시보드에서 특정 파드의 메모리 사용량 그래프가 지속적으로 우상향(톱니바퀴 형태가 아닌 계단식 상승)하는 것을 발견했습니다. 이는 전형적인 메모리 누수 패턴입니다.

이 경우 다음과 같은 절차로 트러블슈팅을 진행합니다. 1. 힙 덤프(Heap Dump) 생성: OOM이 발생하기 직전의 파드에 접속하여(혹은 OOM 발생 시 자동으로 덤프를 남기도록 옵션을 설정하여) 현재 메모리 상태를 파일로 추출합니다. Java의 경우 jmap이나 jcmd를 사용합니다. 2. 프로파일링 도구 분석: 추출한 .hprof 파일을 Eclipse MAT(Memory Analyzer Tool)나 VisualVM과 같은 분석 도구로 엽니다. 3. Leak Suspects 확인: 도구가 자동으로 분석한 '메모리 누수 의심 객체' 리포트를 확인합니다. 보통 맵(Map) 자료구조에 데이터를 넣고 삭제하지 않았거나, 커넥션 풀을 제대로 반환하지 않아 발생한 객체 참조가 대부분의 원인입니다.

최근에는 Prometheus와 연동하여 애플리케이션의 메모리 사용률이 80%를 넘길 때 자동으로 슬랙 알람을 보내고 힙 덤프 스크립트를 실행하는 자동화 파이프라인을 구축하는 기업도 많아지고 있습니다. 클라우드 최적화와 관련된 내용은 AWS Lambda 콜드 스타트 최적화 가이드에서도 다양한 관점을 다루고 있으니 함께 참고해 보시길 권장합니다.

자주 묻는 질문 (FAQ)

Q. OOMKilled 에러 코드는 어디서 확인할 수 있나요?

파드가 종료된 후 kubectl describe pod [파드명] 명령어를 입력하면 Last State 항목에서 Exit Code: 137과 함께 OOMKilled 이유를 확인할 수 있습니다. 시스템의 커널 메시지를 확인하려면 노드에 접속하여 dmesg -T | grep -i oom을 실행하면 상세 로그를 볼 수 있습니다.

Q. Requests와 Limits 설정 비율은 어느 정도가 적당한가요?

애플리케이션 특성에 따라 다르지만, 일반적으로 Limits를 Requests의 1.5배에서 2배 사이로 설정하는 것을 권장합니다. 메모리가 극도로 중요한 서비스라면 아예 둘을 1:1 동일하게 맞추어 노드에서 절대 축출(Eviction)되지 않게 만드는 Guaranteed QoS를 적용하는 가장 안전한 방법을 사용하세요.

Q. Java 파드에서 지속적으로 OOM이 발생하는데 힙 메모리를 늘리면 되나요?

단순히 메모리 한도만 늘리는 것은 미봉책에 불과합니다. 먼저 -XX:MaxRAMPercentage 옵션을 통해 JVM이 컨테이너 환경을 인지하게 설정했는지 확인하고, 우상향하는 메모리 패턴이 보인다면 반드시 힙 덤프를 떠서 내부 코드의 메모리 누수를 해결해야 합니다.

지금 당장 운영 중인 쿠버네티스 클러스터의 그라파나 대시보드를 열어 컨테이너 메모리 사용량 그래프를 확인해 보세요. 우상향하는 패턴이 보인다면 이 가이드에서 제시한 프로파일링과 리소스 최적화 단계를 즉시 적용해 안정적인 서비스 인프라를 구축하시길 바랍니다.