[Troubleshooting] Docker OOMKilled (Exit Code 137) 에러의 진실: 컨테이너 메모 누수 방어 및 리소스 최적화 아키텍처

"새벽에 이커머스 결제 컨테이너가 죽어버렸습니다. 로그를 뒤져보니 아무런 에러 코드도 없고 단지 `Exit Code 137` 하나만 덩그러니 남아있습니다."

도커(Docker)와 쿠버네티스(Kubernetes) 기반의 마이크로서비스 아키텍처를 운영하는 데브옵스(DevOps) 엔지니어들을 가장 공포에 떨게 하는 에러, 바로 OOMKilled (Out of Memory Killed) 현상이다. 애플리케이션의 버그라면 스택 트레이스(Stack Trace)라도 남아 코드를 고칠 텐데, 이 현상은 범인이 애플리케이션이 아니기 때문에 로그조차 남지 않고 프로세스가 흔적 없이 암살당한다.

이 미스터리한 살인 사건의 범인은 바로 리눅스 커널(Linux Kernel)의 OOM Killer다. 본 가이드에서는 왜 운영체제가 자신이 품고 있는 도커 컨테이너를 가차 없이 죽여버리는지 그 매커니즘을 해체하고, 메모리 누수로 인한 인프라 붕괴를 원천 차단하는 방어 아키텍처를 단호하게 제시한다.

1단계: 핵심 원인 분석 (Exit Code 137의 의미)

137이라는 숫자는 수학적인 의미를 갖는다. 리눅스에서 128 + 9 (SIGKILL) = 137이다. 즉, 누군가 "묻지도 따지지도 말고 즉시 죽어라"라는 9번 시그널(Kill)을 보냈다는 뜻이다. 도커 컨테이너 내부의 자바(Java), 파이썬(Python) 프로세스가 할당된 메모리(RAM) 한계치를 초과하여 호스트 OS 전체를 멈추게 할 위험이 감지되는 순간, 리눅스 커널은 생존을 위해 가장 메모리를 많이 파먹는 뚱뚱한 컨테이너를 찾아내 단칼에 목을 친다.

2단계: 해결책 - Cgroups 리소스 하드 리미트(Hard Limit) 통제

많은 개발자가 "서버 램이 32GB나 되는데 왜 컨테이너가 죽나요?"라고 묻는다. 도커를 실행할 때 메모리 제한(Limit)을 걸어두지 않으면, 컨테이너 내의 프로세스(JVM 등)는 호스트의 32GB 전체가 자기 것인 줄 착각하고 무한히 힙(Heap) 메모리를 팽창시킨다. 이를 막기 위해서는 `docker-compose.yml`이나 `docker run` 명령어에 반드시 엄격한 제약을 걸어 운영체제의 Cgroups(Control Groups)가 이를 통제하게 만들어야 한다.

# 완벽하게 리소스가 통제된 docker-compose.yml 방어 아키텍처
version: '3.8'
services:
  backend-api:
    image: my-backend-api:latest
    deploy:
      resources:
        limits:
          cpus: '1.0'           # CPU 코어 최대 1개만 사용
          memory: 1024M         # 컨테이너의 절대적 하드 리미트 (이 이상 쓰면 OOMKilled)
        reservations:
          cpus: '0.2'           # 최소 보장 CPU
          memory: 512M          # 최소 보장 메모리
    environment:
      # (중요) Java 기반일 경우, JVM이 도커 리미트를 인식하도록 옵션 필수 부여
      - JAVA_OPTS="-XX:MaxRAMPercentage=75.0" 

위와 같이 `limits`를 설정하면, 프로세스가 메모리를 무한정 요구하더라도 1024M에서 정확히 벽에 부딪힌다. 자바(JVM)나 Node.js 같은 언어들은 이 한계에 도달하면 스스로 가비지 컬렉터(Garbage Collector)를 미친 듯이 가동하여 메모리를 확보하며 살아남는다.

3단계: 추가 최적화 (Swappiness 및 OOM_Score_Adj)

컨테이너가 메모리 한계에 다다랐을 때 하드디스크를 메모리처럼 사용하는 스왑(Swap) 현상이 발생하면 서버 전체의 I/O 병목이 발생해 사이트가 멈춘다. 엔터프라이즈 환경에서는 `memory-swap` 값을 `memory` 값과 동일하게 맞추어 스왑을 원천 차단(Disable)하는 것이 정석이다. 또한 호스트 OS가 핵심 데이터베이스 컨테이너를 죽이는 것을 막고 싶다면, 도커 실행 시 `--oom-score-adj=-500` 옵션을 주어 킬러의 타겟 순위에서 해당 컨테이너를 밑으로 빼돌려야 한다.

아키텍트의 시선 (Insight)

OOMKilled는 인프라의 장애가 아니다. 탐욕스럽게 메모리를 먹어 치우는 당신의 애플리케이션 코드를 돌아보라는 시스템의 강력한 채찍질이다. 자원을 무한정 늘려 에러를 덮는 아마추어적인 접근을 버려라. Cgroups의 엄격한 리소스 통제(Limit)와 예약(Reservation) 아키텍처를 도입하여, 어떤 트래픽의 파도 속에서도 서버 전체의 무결성을 지켜내는 강인한 격벽(Bulkhead)을 구축하라.

댓글

이 블로그의 인기 게시물

[Troubleshooting] 504 Gateway Timeout 에러 완벽 방어: 파이썬(Python) Celery와 Redis 기반 비동기 백그라운드 아키텍처

[Troubleshooting] 리액트(React) & 뷰(Vue) SPA 웹사이트 GA4 페이지뷰 누락 완벽 해결 아키텍처

[Troubleshooting] AWS Lambda 콜드 스타트(Cold Start) 지연 완벽 해결: Provisioned Concurrency 및 메모리 최적화 아키텍처