컨테이너가 OOMKilled로 죽을 때 — 원인을 좁히는 점검 순서

OOMKilled

결론부터 말씀드리면, 컨테이너가 OOMKilled로 죽었을 때 무작정 리밋부터 올리는 건 순서가 틀렸습니다. 진짜 메모리 부족 때문에 죽은 게 맞는지, 그 순간 컨테이너 메모리 제한이 얼마로 걸려 있었는지를 docker inspectkubectl describe로 먼저 확인해야 같은 장애가 사이즈만 커져서 재발하는 걸 막을 수 있습니다. 이 글에서는 그 확인 순서를, 실제 명령어와 출력 예시를 곁들여 정리해 드립니다.

OOMKilled인지부터 구분하세요

컨테이너가 갑자기 죽으면 로그부터 보게 되는데, 정작 원인을 알려주는 필드는 애플리케이션 로그가 아니라 컨테이너 런타임이 남기는 상태 값입니다. exit code가 137이라고 해서 전부 OOMKilled는 아닙니다. 137은 128 + 9, 즉 SIGKILL로 종료됐다는 뜻이라서 노드 유지보수 중 강제 종료되거나 헬스체크 실패로 orchestrator가 kill한 경우에도 같은 코드가 찍힙니다.

정확히 구분하려면 도커 단독 환경에서는 아래처럼 State.OOMKilled 필드를 직접 확인하는 게 가장 빠릅니다.

docker inspect my-container --format='{{json .State}}'

출력에 "OOMKilled":true, "ExitCode":137 이 같이 찍혀 있으면 커널이 메모리 부족을 이유로 프로세스를 죽인 게 맞습니다. 반대로 "OOMKilled":false인데 137이면 다른 원인(수동 kill, liveness probe 실패)을 봐야 합니다.

docker inspect·kubectl describe로 컨테이너 메모리 제한값 확인하기

원인이 진짜 메모리라면 다음 순서는 “그때 걸려 있던 컨테이너 메모리 제한이 얼마였는가”입니다. 애플리케이션이 실제로 필요한 양보다 제한이 낮게 잡혀 있었는지, 아니면 제한 자체는 적절한데 순간적으로 스파이크가 났는지를 구분해야 이후 조치가 달라집니다.

터미널 화면에 표시된 컨테이너 오류 메시지

쿠버네티스 환경이라면 아래 명령으로 파드 이벤트와 리소스 스펙을 같이 봅니다.

kubectl describe pod my-pod

# 출력 일부
Last State:     Terminated
  Reason:       OOMKilled
  Exit Code:    137
Limits:
  memory:       512Mi
Requests:
  memory:       256Mi

Limits.memory가 실제 워크로드 대비 얼마나 타이트한지가 여기서 드러납니다. 512Mi로 걸려 있는데 힙이나 캐시가 그 근처까지 늘 차 있었다면 리밋 문제이고, 평소엔 여유가 있다가 특정 요청 한 번에 튄 거라면 리밋보다 코드 쪽 스파이크를 봐야 합니다. cgroup v2를 쓰는 노드라면 컨테이너 안에서 /sys/fs/cgroup/memory.max/sys/fs/cgroup/memory.eventsoom_kill 카운터를 직접 열어봐도 같은 정보를 확인할 수 있습니다.

메모리 사용량이 언제부터 치솟았는지 어떻게 보나요?

리밋 값을 확인했다면 그 다음은 시간축입니다. 죽기 직전 몇 분 사이 메모리 그래프를 봐야 “느리게 새는 누수”인지 “특정 이벤트에서 한 번에 튀는 스파이크”인지 구분됩니다. 두 경우는 대응이 완전히 다릅니다.

메트릭 서버가 떠 있는 클러스터라면 kubectl top으로 현재 스냅샷을 확인할 수 있습니다.

kubectl top pod my-pod --containers
POD          NAME    CPU(cores)   MEMORY(bytes)
my-pod       app     120m         498Mi

다만 이건 순간 값이라 죽는 시점을 놓치기 쉽습니다. 실제로는 Prometheus의 container_memory_working_set_bytes 지표를 파드 단위로 시계열로 보는 게 훨씬 정확하고, 이 값이 Limits.memory에 근접하는 기울기를 보이면 리밋을 올리기 전에 메모리 증가 패턴부터 코드에서 찾아야 합니다. 메트릭 서버가 없는 도커 단독 환경이라면 docker stats my-container로 실시간 폴링하면서 부하 재현 테스트를 돌리는 방법이 가장 현실적입니다.

서버 메모리 사용량 모니터링 대시보드

메모리 제한을 올려도 다시 죽는 이유

컨테이너 메모리 제한을 두 배로 올렸는데도 얼마 못 가 또 OOMKilled가 뜨는 경우가 있습니다. 대부분 런타임이 컨테이너 제한을 제대로 인식하지 못하고 호스트 전체 메모리를 기준으로 힙 크기를 잡기 때문입니다.

JVM은 JDK 10부터(8u191부터는 백포트로) 컨테이너 인식 기능이 기본 활성화되어 cgroup 제한을 자동으로 읽지만, 그 이전 버전이거나 -Xmx를 고정 값으로 박아둔 경우엔 컨테이너 제한과 무관하게 힙을 잡아버립니다. -XX:MaxRAMPercentage=75.0처럼 비율로 지정하고, Non-Heap 영역(메타스페이스, 스레드 스택, 다이렉트 버퍼)까지 감안해서 컨테이너 제한보다 20~30% 여유를 두는 편이 안전합니다. Node.js는 --max-old-space-size 플래그로 V8 힙 크기를 제한할 수 있지만 이 값도 컨테이너 메모리 제한을 자동으로 따라가지 않으므로 직접 맞춰줘야 합니다.

여기서 트레이드오프도 하나 짚어야 합니다. 힙을 컨테이너 제한에 거의 맞춰버리면 OOMKilled는 줄어들지만 GC 압박이 심해져 응답 지연이 늘어날 수 있습니다. 반대로 여유를 너무 많이 두면 컨테이너 밀도(같은 노드에 띄울 수 있는 파드 수)가 떨어집니다. 이 값을 조정한 뒤에는 반드시 부하 테스트로 두 지표를 같이 확인해야 합니다.

런타임·오케스트레이터별로 점검 포인트가 달라집니다

지금까지 순서를 표로 정리하면 다음과 같습니다.

점검 단계 확인 명령 확인할 필드
원인이 메모리인지 구분 docker inspect --format='{{json .State}}' OOMKilled, ExitCode
리밋 값 확인 kubectl describe pod Limits.memory, Last State.Reason
시간축 사용량 확인 kubectl top / docker stats MEMORY(bytes) 추이
런타임 힙 설정 확인 JVM/Node 실행 옵션 -XX:MaxRAMPercentage, --max-old-space-size

다만 이 순서가 항상 통하는 건 아닙니다. 호스트의 vm.overcommit_memory 설정이나 스왑 구성에 따라 컨테이너 자체는 제한 이내인데 노드 전체 메모리 압박으로 커널 OOM 킬러가 개입해 파드가 축출되는 경우도 있는데, 이때는 docker inspectOOMKilled가 false로 나올 수 있어 위 표만으로는 원인이 안 잡힙니다. 이런 케이스는 노드 단위 dmesg나 kubelet 이벤트, cgroup v2의 memory.events까지 함께 봐야 합니다. 리소스 관리의 기본 개념과 requests/limits의 동작 방식은 쿠버네티스 공식 문서의 리소스 관리 가이드에, 도커 단독 환경의 메모리 제한 옵션은 도커 공식 문서의 리소스 제약 설정 가이드에 정리되어 있으니 컨테이너 메모리 제한 값을 조정하기 전에 한 번씩 확인해 보시길 권합니다.

정리하면, OOMKilled 로그를 본 순간 리밋 숫자부터 바꾸지 마시고 docker inspect로 진짜 OOM인지, kubectl describe로 그때 걸려 있던 제한이 얼마였는지, 시계열 그래프로 누수인지 스파이크인지를 차례로 확인하신 다음 런타임 힙 설정을 손보시는 순서를 권해드립니다. 다음에 같은 알림을 받으시면 이 표를 그대로 따라가 보세요.

FastAPI 파일 업로드, 대용량은 스트리밍과 임시파일로 처리하세요

Leave a Comment