개요
프로젝트를 진행하다 보면 OOM 또는 OOMKilled를 마주치는 경우가 있습니다. 특히 Kubernetes 환경에서는 "Pod가 OOMKilled로 인해 재시작되었다"는 상황을 종종 접하게 됩니다.
OOMKilled는 단순히 서버가 재시작되는 것처럼 보일 수 있지만, 원인을 파악하지 못한 채 방치하면 서비스 부하가 높은 상황에서 반복적으로 발생하여 치명적인 장애로 이어질 수 있습니다.
이 문서는 프로젝트를 진행하면서 마주친 OOMKilled가 무엇인지, 왜 발생하는지, 그리고 어떻게 분석하고 해결해야 하는지 조사하고 검토한 내용을 정리한 것입니다.
OOM과 OOMKilled의 차이
OOM이든 OOMKilled이든 둘 다 메모리 할당과 관련하여 발생하는 이슈입니다. 그러나 둘은 발생 주체와 동작 방식에서 차이가 있습니다.
OOM (Out Of Memory Error)은 JVM 내부에서 발생합니다. Heap 메모리가 가득 차 더 이상 객체를 할당할 수 없을 때 JVM이 java.lang.OutOfMemoryError를 발생시키며 프로세스가 종료됩니다. 즉, JVM 스스로 한계를 인지하고 에러를 던지는 것입니다.
OOMKilled는 JVM 외부, 즉 Kubernetes와 같은 Container 환경에서 발생합니다. Container에 설정된 메모리 Limit을 초과하는 순간 Kubernetes가 해당 프로세스를 강제 종료합니다. JVM이 에러를 인지하기 전에 외부에서 먼저 kill하는 것이기 때문에, OOM과 달리 별도의 에러 로그가 남지 않아 원인 파악이 더 어렵습니다.
|
구분 |
OOM |
OOMKilled |
|---|---|---|
|
발생 주체 |
JVM 내부 |
Kubernetes (cgroup) |
|
원인 |
Heap 메모리 고갈 |
Container Limit 초과 |
|
에러 로그 |
OutOfMemoryError 로그 남음 |
로그 없이 강제 종료 |
|
Exit Code |
1 |
137 |
|
대상 메모리 |
Heap 영역 |
Heap + Non-Heap + Off-Heap 전체 |
OOMKilled 발생 시 Kubernetes의 재시작 정책(restartPolicy)에 따라 Pod이 자동으로 재시작되므로, 단순 트래픽이 낮은 시간대에 발생한다면 큰 문제가 없을 수도 있습니다. 그러나 서비스 부하가 높은 상황에서 OOMKilled가 발생하면 상황이 심각해질 수 있습니다. Pod 상황에 따라 재시작 중에는 서비스가 중단될 수도 있으며, 재시작 직후 트래픽이 다시 몰리면서 메모리가 빠르게 차올라 연속적인 OOMKilled가 반복될 수 있습니다. 결국 CPU와 메모리 부하가 가중되면서 서비스 전체에 치명적인 장애로 이어질 수 있습니다.
메모리의 구분
OOMKilled와 관련하여 메모리를 지칭할 때는 Heap 메모리 할당 뿐 아니라 Container, JVM 등 좀더 세부적인 메모리에 대한 이해가 필요합니다.
Container 메모리
Kubernetes 환경에서 주의해야 할 점은 메모리 설정의 범위입니다. Container에 설정하는 메모리 Limit은 JVM만을 위한 것이 아니라 Container 전체를 대상으로 합니다.
즉 Container 메모리 > JVM 메모리 관계를 반드시 인지해야 합니다.
Container 메모리
│
├── JVM 프로세스 메모리
│ ├── Heap
│ ├── Non-Heap
│ └── Off-Heap (Direct Buffer)
│
├── OS 및 시스템 라이브러리
└── 사이드카 Container (Istio sidecar 등) 등 기타…
JVM 메모리
JVM 메모리는 크게 세 가지 영역으로 구분됩니다.
Heap 영역
Java 애플리케이션에서 생성되는 객체가 저장되는 공간입니다. GC(Garbage Collection)의 대상이 되는 영역이기도 합니다.
|
지표 |
설명 |
|---|---|
|
Heap Used |
현재 실제로 사용 중인 메모리 |
|
Heap Committed |
JVM이 OS로부터 미리 확보해둔 메모리. GC 후에도 즉시 반납하지 않음 |
|
Heap Max |
JVM이 사용 가능한 최대 Heap 크기 |
세 지표의 관계는 항상 Used ≤ Committed ≤ Max이고 Committed는 Full GC 시 정리됩니다.
Non-Heap 영역
JVM 자체 운영을 위한 영역으로, GC 대상이 아닙니다.
|
영역 |
설명 |
|---|---|
|
Metaspace |
클래스 메타데이터, Static 변수 저장 |
Metaspace는 -XX:MaxMetaspaceSize를 설정하지 않으면 이론상 무제한으로 증가할 수 있습니다.
Off-Heap 영역 (Direct Buffer)
Heap도 Non-Heap도 아닌 영역으로, JVM이 OS에 직접 요청하여 할당하는 메모리입니다. NIO 기반의 파일 처리나 네트워크 I/O에서 주로 사용됩니다.
GC의 대상이 아니므로 명시적으로 해제되지 않으면 누적될 수 있으며, -XX:MaxDirectMemorySize로 상한을 설정하지 않으면 무제한으로 증가할 수 있습니다.
Container 메모리와 JVM 메모리의 관계
JVM의 Heap 크기가 Container Limit보다 작게 설정되어 있으면 문제없다고 생각하기 쉽습니다. 그러나 Heap뿐만 아니라 Non-Heap, Off-Heap 모두 Container Limit 안에서 함께 관리되기 때문에 세 영역을 합산하여 신중하게 계산해야 합니다. 특히 시스템의 성향에 따라 파일 처리가 많은 서비스는 Off-Heap을, 클래스 로딩이 많은 서비스는 Non-Heap을 많이 사용할 수 있기 때문에 단순히 Heap 크기만 볼 것이 아니라 자신의 시스템 특징을 충분히 파악한 후 메모리를 설계해야 합니다.
기본적으로 Non-Heap이 200MB, Off-Heap이 100MB, JVM 외 기타 메모리가 50MB를 사용하는 시스템에서 Container Limit을 1Gi로, -XX:MaxRAMPercentage=80으로 설정한다면 아래와 같은 상황이 발생합니다.
Heap Max (80%) : 819MB ← JVM이 사용 가능하다고 인식하는 Heap 한도
Non-Heap : 200MB
Off-Heap : 100MB
JVM 외 기타 : 50MB
─────────────────────────
Heap 외 합계 : 350MB
실질적으로 Heap에 허용되는 메모리 : 1,024MB - 350MB = 674MB
JVM은 Heap을 819MB까지 사용할 수 있다고 인식하고 Full GC 없이 메모리를 계속 할당합니다. 그러나 Kubernetes는 Heap + Non-Heap + Off-Heap + 기타를 모두 합산하여 Container Limit과 비교하기 때문에, Heap 사용량이 674MB를 초과하는 순간 Container Limit 1Gi를 넘어서게 되고 JVM이 인지하기 전에 Kubernetes가 프로세스를 강제 종료합니다. 이것이 OOMKilled입니다.
JVM 설정
아래는 관련 OOMKilled와 관련된 JVM 옵션으로 간략하게 정의만 하고 넘어가겠습니다.
MaxRAMPercentage
-
Container Limit 대비 Heap Max 크기를 비율로 설정합니다. 너무 작게 하면 메모리를 낭비할 수 있고 너무 높게 설정하면 Non-Heap, Off-Heap의 소비량이 합산되어 Container Limit을 초과할 수 있습니다.
MaxMetaspaceSize
-
Non-Heap 영역 중 Metaspace의 최대 크기를 제한합니다. 설정하지 않으면 Metaspace는 이론상 무제한으로 증가할 수 있습니다. 현재 사용량에 여유를 두어 상한선을 설정하는 것을 권장합니다.
MaxDirectMemorySize
-
Off-Heap(Direct Buffer)의 최대 크기를 제한합니다. NIO 기반 파일 처리나 네트워크 I/O를 많이 사용하는 서비스에서 설정하지 않으면 Direct Buffer가 무제한으로 증가하여 Container Limit을 초과할 수 있습니다. 이 옵션을 설정하면 한도 초과 시 OutOfMemoryError: Direct buffer memory 로그가 남아 원인 파악이 쉬워지는 부가적인 장점도 있습니다.
OOMKilled 서버 분석 Case
아래는 OOMKilled가 발생한 서비스의 메모리 현황을 입력으로 AI로 분석한 결과를 요약한 내용입니다. 최종 JVM 옵션 수정 적용 후 OOMKilled 문제는 해결되었습니다.
원본 노션 링크 : https://app.notion.com/p/vizend/Pod-OOMKilled-38b35bc54c13806699e8e3ae329bf38f
메모리 현황 분석
1. Container 리소스 설정
|
항목 |
값 |
|---|---|
|
Memory Limit |
1Gi (1,073 MB) |
|
Memory Request |
256Mi |
|
QoS Class |
Burstable |
2. JVM 메모리 현황 (현재 시점)
|
영역 |
값 |
MB 환산 |
|---|---|---|
|
Heap Used |
376,595,248 bytes |
~359 MB |
|
Heap Committed |
512,229,376 bytes |
~488 MB |
|
Heap Max |
859,308,032 bytes |
~819 MB |
|
Non-Heap Used |
210,018,744 bytes |
~200 MB |
|
└ Metaspace |
127,576,696 bytes |
~122 MB |
3. 핵심 문제 분석
Container Limit : 1,073 MB (1Gi)
────────────────────────────────────────
Heap Max (JVM 설정) : 819 MB
Non-Heap Used (현재) : 200 MB
────────────────────────────────────────
합산 최대 사용 가능 : 1,019 MB ← 한계에 매우 근접
나머지 여유 : ~54 MB ← 위험 수준
JVM의 Heap Max + Non-Heap만 합쳐도 Container Limit의 약 95% 를 차지합니다.
여기에 JVM 내부 오버헤드 (Thread Stack, GC 메타데이터 등)가 추가되면 1Gi를 초과하여 OOMKilled가 발생합니다.
4. GC 방식 확인
Actuator 응답에 PS Eden Space, PS Old Gen, PS Survivor Space가 보입니다.
G1GC 사용을 권장합니다.
5. 원인 요약
|
원인 |
설명 |
|---|---|
|
Heap Max 과다 |
819MB Heap + 200MB Non-Heap = 1,019MB로 Limit 초과 직전 |
|
GC 방식 부적합 |
Parallel GC → Container 메모리 인식 미흡 |
|
JVM 옵션 미설정 |
-XX:MaxRAMPercentage 등 Container 인식 옵션 부재 추정 |
|
QoS Burstable |
Request ≠ Limit → 메모리 보장 없음, OOM 우선 대상 |
MaxRAMPercentage 시뮬레이션 결과
MaxRAMPercentage를 현재 80%에서 60%까지 비교 분석했습니다.
Heap 사용량
|
항목 |
80% |
70% |
65% |
60% |
|---|---|---|---|---|
|
Container Limit |
1,073MB |
1,073MB |
1,073MB |
1,073MB |
|
Heap Max |
858MB |
~751MB |
~698MB |
~644MB |
|
Non-Heap 실측 |
~200MB |
~200MB |
~200MB |
~200MB |
|
Thread Stack 등 오버헤드 |
~50~80MB |
~50~80MB |
~50~80MB |
~50~80MB |
|
합계 추정 |
~1,108~1,131MB |
~1,001~1,031MB |
~948~978MB |
~894MB |
|
여유 |
~42~72MB |
~95~125MB |
~179MB |
옵션별 비교
|
설정 |
Heap Max |
여유 |
평가 |
비교 |
|---|---|---|---|---|
|
현재 80% |
~858MB |
~15MB |
위험 |
Non-Heap, Off-Heap 추가 사용 시 문제 발생 |
|
70% |
~751MB |
~42~72MB |
불안정 |
G1GC와 함께 Container Limit 증가 (1.5Gi) |
|
60% |
~644MB |
~149~179MB |
권장 |
G1GC 적용 |
|
50% |
~536MB |
~257MB |
안전하나 Heap 부족 가능 |
검토 제외 |
-
가장 안전한 접근은 60%로 설정 후 모니터링하면서 필요시 올립니다.
-
Non-Heap 또는 Off-Heap 200MB는 고정 값이 아니며, 추가적인 파일 업로드 등 부하 상황에서 OOMKilled를 유발할 수 있습니다.
권장 JVM 옵션 (최종)
# 현재
-XX:InitialRAMPercentage=25
-XX:MaxRAMPercentage=80
-XX:+UseParallelGC
# 권장
-XX:InitialRAMPercentage=25
-XX:MaxRAMPercentage=60 # Heap ~644MB로 제한
-XX:+UseG1GC # Container 친화적 GC
-XX:MaxMetaspaceSize=192m # Metaspace 상한 설정 (현재 122MB 사용 중)
-XX:MaxDirectMemorySize=140m # Direct Buffer 상한 설정 (현재 108MB 사용 중)
-
Heap Max: ~644MB
-
Non-Heap: ~200MB
-
합계: ~844MB → Container Limit 1Gi 내에서 여유 확보
sby