운영 중인 쿠버네티스(Kubernetes) 클러스터에서 갑자기 파드가 죽어버리고 OOMKilled
상태를 보게 된다면, 정말 당황스러울 수 있습니다. 특히 노드에 여유 메모리가 충분해 보이는데도 이런 현상이 발생하면 더욱 혼란스럽죠.
OOMKilled는 쿠버네티스 환경에서 가장 빈번하게 발생하는 문제 중 하나로, 제대로 된 대응책을 마련하지 않으면 지속적으로 발생할 수 있는 골치 아픈 이슈입니다. 이번 포스트에서는 OOMKilled 에러의 근본 원인부터 실질적인 해결 방법까지 구체적인 메모리/리소스 관리방법을 제공해 드리고자 합니다.
1. OOMKilled 에러란 무엇인가?
OOMKilled는 “Out Of Memory Killed”의 줄임말로, 컨테이너가 할당된 메모리 한계를 초과했을 때 리눅스 커널의 OOM Killer가 해당 프로세스를 강제 종료하는 현상입니다.
OOMKilled의 정확한 동작 방식
많은 분들이 쿠버네티스가 직접 이 시그널을 보낸다고 생각하시는데, 실제로는 리눅스 커널이 메모리 한계를 감지하고 컨테이너를 종료시킵니다:
- 컨테이너가 메모리 limit를 초과
- 리눅스 커널이 SIGKILL(신호 9) 발송
- Kubelet이 종료를 감지하고 쿠버네티스 API에 알림
- 파드 상태가 “OOMKilled”로 업데이트
이때 나타나는 Exit Code 137은 128+9(SIGKILL)의 결과로, OOMKilled의 확실한 증거입니다.
실제 로그에서 확인하는 방법
# 파드 상태 확인
kubectl describe pod <pod-name> -n <namespace>
# 이벤트 로그에서 다음과 같은 내용을 확인할 수 있습니다
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Warning Killing 32s kubelet Killing container with id...
Normal Pulled 30s kubelet Container image pulled
Warning BackOff 15s kubelet Back-off restarting failed container
2. OOMKilled가 발생하는 주요 원인들
실제 운영 환경에서 발생하는 OOMKilled의 원인을 분석해보면 크게 다섯 가지로 나눌 수 있습니다.
잘못된 메모리 리소스 설정
가장 흔한 원인입니다. 애플리케이션이 실제로 필요한 메모리보다 훨씬 낮게 limit를 설정하는 경우가 많습니다.
실제 사례: 한 핀테크 회사의 사기 탐지 시스템에서 메모리 limit를 2Gi로 설정했으나, 피크 시간대에는 3.5Gi가 필요했던 사례가 있었습니다.
애플리케이션 내 메모리 누수
코드 레벨에서 발생하는 메모리 누수는 시간이 지날수록 메모리 사용량이 점진적으로 증가하다가 결국 OOMKilled를 발생시킵니다.
언어별 특성:
- Java: JVM 가비지 컬렉터가 컨테이너 제약을 완전히 인식하지 못할 때
- Go: Go 1.19부터
GOMEMLIMIT
환경변수로 개선되었지만 여전히 주의 필요 - Node.js: 이벤트 루프와 관련된 메모리 누수 패턴
예기치 못한 트래픽 급증
평상시에는 정상적으로 동작하다가 갑작스러운 트래픽 증가로 메모리 사용량이 급상승하는 경우입니다.
실제 사례: 온라인 쇼핑몰의 결제 서비스가 플래시 세일 기간 중 사용자 급증으로 인해 메모리 사용량이 폭증하여 파드가 종료된 사례가 있었습니다.
노드 레벨 메모리 경합
같은 노드에 있는 다른 파드들과의 메모리 경합으로 인해 발생할 수 있습니다. 노드 전체 메모리가 부족해지면 QoS(Quality of Service) 클래스에 따라 우선순위가 낮은 파드부터 종료됩니다.
QoS 우선순위:
- BestEffort: 가장 먼저 종료
- Burstable: 중간 우선순위
- Guaranteed: 가장 나중에 종료
리소스 오버커밋
노드에서 실행 중인 모든 파드의 메모리 request 합계가 노드 용량을 초과하는 상황입니다.
3. 메모리 관리의 핵심 원칙들
효과적인 메모리 관리를 위해서는 몇 가지 핵심 원칙을 이해해야 합니다.
Memory Request vs Limit의 올바른 설정
2025년 현재 베스트 프랙티스는 memory limit = memory request
로 설정하는 것입니다. 이는 CPU와는 다른 접근 방식입니다.
apiVersion: v1
kind: Pod
spec:
containers:
- name: my-app
resources:
requests:
memory: "2Gi"
cpu: "500m"
limits:
memory: "2Gi" # request와 동일하게 설정
# CPU limit는 설정하지 않음 (권장사항)
왜 메모리는 limit=request로 설정해야 할까요?
피자 파티에 비유해보면, 각 손님이 피자 2조각을 주문했는데 실제로는 4조각까지 먹을 수 있다고 허용한다면, 파티 중간에 피자가 부족해져서 일부 손님은 아예 못 먹게 될 수 있습니다. 메모리도 마찬가지로, 실제 사용량이 request를 초과하면 예측 불가능한 상황이 발생할 수 있습니다.
QoS 클래스 활용하기
쿠버네티스는 리소스 설정에 따라 세 가지 QoS 클래스를 자동으로 할당합니다:
# Guaranteed QoS - 최고 우선순위
resources:
requests:
memory: "1Gi"
cpu: "500m"
limits:
memory: "1Gi"
cpu: "500m"
# Burstable QoS - 중간 우선순위
resources:
requests:
memory: "512Mi"
cpu: "250m"
limits:
memory: "1Gi"
cpu: "1000m"
# BestEffort QoS - 최저 우선순위
# requests와 limits를 모두 설정하지 않음
메모리 백킹 볼륨 주의사항
memory-backed emptyDir
을 사용할 때는 특별한 주의가 필요합니다:
volumes:
- name: memory-volume
emptyDir:
medium: Memory
sizeLimit: 1Gi
이런 볼륨에 저장된 파일들은 메모리를 직접 사용하므로, 파일 크기가 커질수록 컨테이너의 메모리 사용량도 증가합니다.
4. 모니터링 도구 및 설정 가이드
OOMKilled 문제를 해결하려면 먼저 정확한 모니터링이 필요합니다. 2025년 현재 가장 효과적인 모니터링 스택을 소개해드리겠습니다.
Prometheus + Grafana 스택 구축
Prometheus는 쿠버네티스 메트릭 수집의 사실상 표준이 되었습니다. 다음과 같이 설치할 수 있습니다:
# Helm을 사용한 kube-prometheus-stack 설치
helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
helm repo update
helm install monitoring prometheus-community/kube-prometheus-stack \
--namespace monitoring \
--create-namespace \
--set prometheus.prometheusSpec.retention=30d \
--set prometheus.prometheusSpec.storageSpec.volumeClaimTemplate.spec.resources.requests.storage=50Gi
핵심 메모리 메트릭들
컨테이너 레벨 메트릭:
container_memory_usage_bytes
: 현재 메모리 사용량container_memory_working_set_bytes
: OOM 결정에 사용되는 실제 메트릭container_spec_memory_limit_bytes
: 설정된 메모리 한계
파드 레벨 메트릭:
kube_pod_container_resource_limits
: 리소스 제한값kube_pod_container_resource_requests
: 리소스 요청값kube_pod_status_phase
: 파드 상태
실용적인 PromQL 쿼리 모음
메모리 사용률 모니터링:
# 파드별 메모리 사용률 (%)
100 * (
container_memory_working_set_bytes{job="kubelet", container!=""}
/
container_spec_memory_limit_bytes{job="kubelet", container!=""}
)
# 네임스페이스별 총 메모리 사용량
sum by (namespace) (container_memory_working_set_bytes{job="kubelet", container!=""})
# 메모리 사용률이 80% 이상인 파드 감지
(
container_memory_working_set_bytes{job="kubelet", container!=""}
/
container_spec_memory_limit_bytes{job="kubelet", container!=""}
) > 0.8
OOMKilled 파드 감지:
# 최근 1시간 내 OOMKilled 발생 횟수
increase(kube_pod_container_status_restarts_total{reason="OOMKilled"}[1h])
# OOMKilled로 재시작된 파드 목록
kube_pod_container_status_restarts_total{reason="OOMKilled"} > 0
Grafana 대시보드 구성
메모리 모니터링 패널 구성:
패널 유형 | 메트릭 | 용도 |
---|---|---|
Time Series | 메모리 사용률 트렌드 | 시간대별 패턴 분석 |
Stat | 현재 메모리 사용량 | 실시간 상태 확인 |
Table | OOMKilled 파드 목록 | 문제 파드 식별 |
Heatmap | 노드별 메모리 분포 | 리소스 균형 확인 |
5. 실제 문제 해결 방법과 예제
이제 실제 상황에서 OOMKilled 문제를 어떻게 해결할 수 있는지 구체적인 예제와 함께 살펴보겠습니다.
단계별 문제 진단 과정
1단계: 현재 상황 파악
# 파드 상태 확인
kubectl get pods -n <namespace> | grep -E "(OOMKilled|Error|CrashLoopBackOff)"
# 상세 정보 확인
kubectl describe pod <pod-name> -n <namespace>
# 리소스 사용량 확인 (metrics-server 필요)
kubectl top pods -n <namespace> --sort-by=memory
kubectl top nodes --sort-by=memory
2단계: 로그 분석
# 이전 컨테이너 로그 확인 (OOMKilled 발생 직전)
kubectl logs <pod-name> -n <namespace> --previous
# 이벤트 확인
kubectl get events -n <namespace> --sort-by='.lastTimestamp' | grep OOMKilled
3단계: 메트릭 분석
# Prometheus에서 메모리 사용 패턴 확인
curl -G 'http://prometheus:9090/api/v1/query' \
--data-urlencode 'query=container_memory_working_set_bytes{pod="<pod-name>"}'
실제 해결 사례별 대응 방법
사례 1: 데이터 처리 애플리케이션의 메모리 스파이크
문제: 대용량 파일 처리 시 메모리 사용량이 급증하여 OOMKilled 발생
해결책:
# 기존 설정
resources:
requests:
memory: "1Gi"
limits:
memory: "2Gi"
# 개선된 설정
resources:
requests:
memory: "4Gi"
limits:
memory: "4Gi"
# 추가: 메모리 백킹 볼륨을 디스크 기반으로 변경
volumes:
- name: temp-storage
emptyDir:
medium: "" # 메모리 대신 디스크 사용
sizeLimit: 10Gi
사례 2: Java 애플리케이션의 힙 메모리 관리
문제: JVM이 컨테이너 메모리 제한을 올바르게 인식하지 못함
해결책:
env:
- name: JAVA_OPTS
value: "-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0 -XX:+UseG1GC"
resources:
requests:
memory: "2Gi"
limits:
memory: "2Gi"
사례 3: Node.js 애플리케이션의 메모리 누수
문제: 시간이 지날수록 메모리 사용량이 점진적으로 증가
해결책:
# 메모리 제한 설정
env:
- name: NODE_OPTIONS
value: "--max-old-space-size=1536" # 1.5GB로 제한
resources:
requests:
memory: "2Gi"
limits:
memory: "2Gi"
# 주기적 재시작으로 메모리 누수 완화 (임시 방편)
spec:
template:
spec:
containers:
- name: nodejs-app
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 15"]
Vertical Pod Autoscaler (VPA) 활용
VPA 설정 예제:
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
name: my-app-vpa
spec:
targetRef:
apiVersion: apps/v1
kind: Deployment
name: my-app
updatePolicy:
updateMode: "Auto" # 자동으로 파드 재시작하여 리소스 조정
# updateMode: "Off" # 권장사항만 제공
resourcePolicy:
containerPolicies:
- containerName: my-app
minAllowed:
memory: "100Mi"
cpu: "100m"
maxAllowed:
memory: "8Gi"
cpu: "2"
controlledResources: ["memory", "cpu"]
메모리 프로파일링 도구 활용
Go 애플리케이션 프로파일링:
// 애플리케이션에 pprof 엔드포인트 추가
import _ "net/http/pprof"
go func() {
log.Println(http.ListenAndServe("localhost:6060", nil))
}()
# 힙 프로파일 수집
kubectl port-forward pod/<pod-name> 6060:6060 &
go tool pprof http://localhost:6060/debug/pprof/heap
# 메모리 사용 패턴 분석
(pprof) top
(pprof) list <function-name>
6. OOMKilled 예방책과 체계적 해결 방법
OOMKilled 문제를 사전에 방지하기 위한 체계적인 접근 방법을 살펴보겠습니다.
개발 단계에서의 예방책
1. 로컬 환경에서의 메모리 프로파일링
# Docker 컨테이너에서 메모리 사용량 모니터링
docker stats <container-id>
# 애플리케이션별 메모리 사용 패턴 분석
# Java: JVisualVM, JProfiler
# Node.js: clinic.js, heapdump
# Go: go tool pprof
# Python: memory_profiler, pympler
2. 적절한 메모리 테스트
# 부하 테스트 시나리오에 메모리 체크 포함
apiVersion: v1
kind: ConfigMap
metadata:
name: load-test-config
data:
test-script: |
# 점진적 부하 증가로 메모리 사용 패턴 확인
for i in {1..100}; do
echo "테스트 $i 진행중..."
# 메모리 사용량 기록
kubectl top pod <pod-name> >> memory-usage.log
sleep 30
done
운영 환경에서의 모니터링 전략
1. 프로액티브 알림 설정
# PrometheusRule 예제
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: memory-alerts
spec:
groups:
- name: memory.rules
rules:
- alert: HighMemoryUsage
expr: |
(
container_memory_working_set_bytes{job="kubelet", container!=""}
/
container_spec_memory_limit_bytes{job="kubelet", container!=""}
) > 0.8
for: 2m
labels:
severity: warning
annotations:
summary: "컨테이너 메모리 사용률이 80%를 초과했습니다"
description: "{{ $labels.namespace }}/{{ $labels.pod }}의 메모리 사용률이 {{ $value | humanizePercentage }}입니다"
- alert: OOMKilledDetected
expr: |
increase(kube_pod_container_status_restarts_total{reason="OOMKilled"}[5m]) > 0
labels:
severity: critical
annotations:
summary: "OOMKilled 이벤트가 감지되었습니다"
description: "{{ $labels.namespace }}/{{ $labels.pod }}에서 OOMKilled가 발생했습니다"
2. 자동화된 대응 시스템
# HorizontalPodAutoscaler로 메모리 기반 스케일링
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: memory-based-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: my-app
minReplicas: 2
maxReplicas: 10
metrics:
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 70 # 메모리 사용률 70%에서 스케일링
behavior:
scaleUp:
stabilizationWindowSeconds: 60
policies:
- type: Percent
value: 100
periodSeconds: 15
인프라 레벨의 최적화
1. 노드 리소스 예약
# kubelet 설정에서 시스템 리소스 예약
apiVersion: v1
kind: Node
metadata:
name: worker-node-1
spec:
# 시스템과 kubelet을 위한 리소스 예약
allocatable:
memory: "7Gi" # 8Gi 노드에서 1Gi를 시스템용으로 예약
cpu: "3500m" # 4 vCPU에서 500m을 시스템용으로 예약
2. 노드 어피니티와 안티-어피니티 활용
# 메모리 집약적 애플리케이션을 별도 노드에 분산
apiVersion: apps/v1
kind: Deployment
spec:
template:
spec:
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchExpressions:
- key: app
operator: In
values: ["memory-intensive-app"]
topologyKey: kubernetes.io/hostname
nodeSelector:
node-type: memory-optimized # 메모리 최적화 노드 선택
애플리케이션 아키텍처 개선
1. 마이크로서비스 분리
# 메모리 집약적 기능을 별도 서비스로 분리
# 기존: 하나의 파드에서 모든 기능 처리
apiVersion: apps/v1
kind: Deployment
metadata:
name: monolithic-app
spec:
template:
spec:
containers:
- name: app
resources:
limits:
memory: "8Gi" # 높은 메모리 요구사항
# 개선: 기능별로 서비스 분리
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: data-processor
spec:
template:
spec:
containers:
- name: processor
resources:
limits:
memory: "4Gi"
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: api-server
spec:
template:
spec:
containers:
- name: server
resources:
limits:
memory: "1Gi"
2. 캐싱 전략 최적화
# Redis를 사용한 외부 캐싱으로 애플리케이션 메모리 부담 감소
apiVersion: v1
kind: ConfigMap
metadata:
name: app-config
data:
config.yaml: |
cache:
type: redis
redis:
host: redis-service
port: 6379
max_memory_policy: allkeys-lru
# 인메모리 캐시 최소화
local_cache:
max_size: 100MB
ttl: 300s
7. 에러를 방지하기 위한 메모리 관리 기법
Memory QoS (Quality of Service) 활용
쿠버네티스 1.27부터 도입된 Memory QoS 기능을 활용하면 더욱 정교한 메모리 관리가 가능합니다.
# Memory QoS를 활용한 설정
apiVersion: v1
kind: Pod
metadata:
annotations:
# 파드별로 Memory QoS 비활성화 (필요시)
qos.memory.kubernetes.io/disabled: "true"
spec:
containers:
- name: my-app
resources:
requests:
memory: "1Gi"
limits:
memory: "2Gi"
Memory QoS는 cgroups v2의 memory.high
설정을 통해 OOM이 발생하기 전에 메모리 사용을 제한합니다.
메모리 압박 상황 대응
Pressure Stall Information (PSI) 모니터링:
# kubelet에서 PSI 기능 활성화
--feature-gates=KubeletPSI=true
# PSI 메트릭 확인
curl http://kubelet:10250/metrics/cadvisor | grep container_pressure_memory
파드 우선순위를 통한 리소스 보호:
# 높은 우선순위 파드 설정
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
name: high-priority
value: 1000
globalDefault: false
description: "중요한 애플리케이션을 위한 높은 우선순위"
---
apiVersion: v1
kind: Pod
spec:
priorityClassName: high-priority
containers:
- name: critical-app
resources:
requests:
memory: "2Gi"
limits:
memory: "2Gi"
메모리 누수 자동 감지 시스템
eBPF를 활용한 연속 프로파일링:
# Parca 배포로 연속 프로파일링 시스템 구축
apiVersion: v1
kind: ConfigMap
metadata:
name: parca-config
data:
parca.yaml: |
object_storage:
bucket:
type: FILESYSTEM
config:
directory: "./tmp"
scrape_configs:
- job_name: 'kubernetes-pods'
kubernetes_sd_configs:
- role: pod
relabel_configs:
- source_labels: [__meta_kubernetes_pod_annotation_profiles_grafana_com_memory_scrape]
action: keep
regex: true
이제 마무리 부분으로 넘어가겠습니다.
OOMKilled 에러는 쿠버네티스 환경에서 피할 수 없는 문제이지만, 올바른 이해와 체계적인 접근을 통해 충분히 관리할 수 있습니다. 핵심은 사전 예방에 있습니다. 적절한 리소스 설정, 지속적인 모니터링, 그리고 프로액티브한 알림 시스템을 갖추면 대부분의 OOMKilled 문제를 미연에 방지할 수 있습니다. 특히memory limit = memory request
설정과 Memory QoS 기능을 적극 활용하여 더욱 안정적인 메모리 관리가 가능해졌습니다. Prometheus와 Grafana를 통한 모니터링 스택도 더욱 성숙해져서 실시간으로 메모리 사용 패턴을 파악하고 대응할 수 있습니다.