SSD Nodes Learn Hosting plans →
가이드 Matt Connor작성자 Matt Connor

Docker Compose 메모리 제한 설정으로 OOM 방지하기

Docker Compose에서 deploy.resources.limits를 사용하여 컨테이너 메모리를 제한하는 방법을 설명합니다. exit 137 오류를 방지하고 특정 컨테이너가 VPS의 전체 RAM을 점유하여 서버가 중단되는 상황을 예방하는 구체적인 설정법을 확인하십시오.

Docker Compose 메모리 제한의 역할

Docker Compose 메모리 제한은 Linux 커널이 특정 컨테이너의 cgroup(프로세스 집합의 자원을 측정하는 커널 기능)에 적용하는 엄격한 상한선입니다. 서비스에 deploy.resources.limits.memory을 설정하면 해당 컨테이너는 지정한 수치 이상의 메모리를 절대 사용할 수 없습니다. 컨테이너가 이 제한을 초과하려고 하면 커널은 컨테이너 내부의 프로세스를 강제 종료하며, 컨테이너는 보통 코드 137로 종료됩니다.

이는 RAM 용량이 고정되어 있고 빌려올 여유 호스트 메모리가 없는 VPS 환경에서 특히 중요합니다. 메모리 누수가 있거나 잘못된 쿼리를 실행하는 컨테이너 하나가 8GB 서버의 모든 여유 페이지를 점유할 수 있기 때문입니다. 이때 커널은 가장 문제가 된다고 판단되는 프로세스를 종료하는데, 흔히 문제를 일으킨 컨테이너가 아니라 데이터베이스나 사용자의 SSH 세션이 종료되곤 합니다. 제한을 설정하면 서버 전체가 중단되는 상황을 특정 서비스만 재시작되는 상황으로 바꿀 수 있습니다.

services:
  app:
    image: ghcr.io/example/app:1.4
    deploy:
      resources:
        limits:
          cpus: "1.5"
          memory: 1g
        reservations:
          memory: 256m

설정을 적용하고 제한이 활성화되었는지 확인합니다.

docker compose up -d
docker stats --no-stream

MEM USAGE / LIMIT 열에는 142MiB / 1GiB와 같은 값이 표시되어야 합니다. 만약 제한 열에 호스트 전체 RAM 용량이 표시된다면 설정이 적용되지 않은 것이며, 이 문제가 해결되기 전까지는 이 가이드의 나머지 내용을 진행해도 소용이 없습니다. Compose 파일 자체가 생소하다면 VPS를 위한 Docker Compose 기초에서 이 가이드의 기반이 되는 파일 구조를 확인하십시오.

deploy.resources.limits 또는 mem_limit: 무엇을 사용해야 하는가

동일한 개념에 두 가지 표기법이 존재하여 혼란을 야기합니다.

mem_limit, mem_reservation, memswap_limit, cpus 및 cpu_shares는 이전 Compose 파일 형식에서 상속된 최상위 서비스 키입니다. deploy.resources는 Swarm 스키마에서 유래했으며, 현재는 docker compose이 읽어 들이는 Compose Specification의 일부입니다.

두 방식 모두 단일 호스트에서 작동합니다. docker compose 플러그인인 Docker Compose는 Swarm 클러스터가 없는 환경에서도 docker compose up을 실행하면 deploy.resources.limits 및 deploy.resources.reservations를 적용합니다. 해당 플러그인은 현재 v5 라인에 있습니다(2026-10-06 확인 기준 최신 안정화 릴리스는 v5.6.0). 이전 Compose 파일 형식 버전과의 혼동을 피하기 위해 v2에서 v3와 v4를 건너뛰고 v5로 바로 넘어갔으며, v5 릴리스 중 리소스 제한 처리 방식이 변경된 적은 없습니다. deploy 블록에서 Swarm 전용인 부분은 나머지 키들입니다. mode, placement, update_config 및 endpoint_mode는 docker stack deploy에게는 의미가 있지만 docker compose up은 이를 무시합니다. 따라서 "deploy는 Swarm이 필요하다"는 일반적인 조언은 resources 하위 섹션에 대해서는 틀린 말이며, 이를 따르면 서비스에 아무런 제한이 설정되지 않습니다.

프로젝트당 하나의 표기법을 선택하십시오. 동일한 서비스에 mem_limit: 512m과 deploy.resources.limits.memory: 1g를 모두 작성하면 한눈에 파악하기 어려운 파일이 됩니다. 어떤 숫자가 적용되었는지 추측하지 말고 데몬에 직접 확인하십시오.

docker inspect --format '{{.HostConfig.Memory}} {{.HostConfig.MemoryReservation}} {{.HostConfig.NanoCpus}}' app-1

메모리 값은 바이트 단위이므로 1g은 1073741824로 출력됩니다. CPU는 nano CPU 단위이므로 1.5는 1500000000으로 출력됩니다. 필드에 0가 표시되면 제한이 설정되지 않았음을 의미합니다. Docker가 허용하는 최소 메모리 제한은 6m이며, 이보다 낮게 설정하면 컨테이너가 시작되지 않습니다.

컨테이너가 제한에 도달하면 발생하는 일

컨테이너는 속도가 느려지지 않습니다. 즉시 종료됩니다.

프로세스가 페이지를 요청했는데 cgroup이 이미 memory.max에 도달한 상태라면, 커널은 먼저 해당 cgroup 내부에서 회수 가능한 자원(clean page cache, 스왑 가능한 페이지 등)을 확보합니다. 회수 후에도 메모리가 충분하지 않으면 cgroup OOM(out of memory) killer가 컨테이너 내부의 프로세스를 선택하여 SIGKILL을 보냅니다. 컨테이너의 PID 1이 종료되면 컨테이너 전체가 정지합니다. 종료 코드 137은 128에 시그널 9를 더한 값이므로, 137은 SIGKILL이 발생했다는 증거일 뿐 그 자체가 OOM의 증거는 아닙니다.

docker compose ps -a
docker inspect --format '{{.State.OOMKilled}} {{.State.ExitCode}}' app-1

true 137은 OOM kill을 의미합니다. false 137은 다른 원인으로 SIGKILL이 전달되었음을 뜻하며, 일반적인 원인은 애플리케이션이 SIGTERM을 무시하여 docker compose stop가 10초의 유예 기간을 초과했기 때문입니다. 이 차이를 구분하면 문제 해결 시간을 크게 단축할 수 있습니다. 두 문제는 서로 완전히 다른 원인에서 비롯되기 때문입니다.

이 이벤트는 두 곳에서 추가로 기록됩니다. 데몬의 실시간 로그를 확인하십시오.

docker events --filter event=oom

그다음 재부팅 후에도 유지되는 커널 로그를 확인하십시오.

sudo dmesg -T | grep -i -E 'memory cgroup out of memory|killed process'

cgroup에 의한 종료는 Memory cgroup out of memory: Killed process 24713 (node)으로 시작하는 줄을 출력합니다. Memory cgroup 접두사가 없는 줄은 호스트 OOM을 의미하며, 이는 머신 자체의 RAM이 부족하다는 뜻입니다. 이는 제한 설정을 통해 방지해야 하는 장애이므로, 이 메시지가 보인다면 설정한 제한의 총합이 너무 높거나 제한이 없는 서비스가 존재한다는 신호입니다.

restart: unless-stopped를 사용하면 서비스가 종료된 지 1초 만에 docker compose ps에서 다시 시작되므로 OOM 루프가 숨겨질 수 있습니다. uptime 열과 재시작 횟수를 확인하고, 애플리케이션이 비정상 상태임을 알리는 헬스체크를 제한 설정과 함께 사용하여 컨테이너가 계속 죽는 상황을 실시간으로 감시하지 않아도 알 수 있게 하십시오.

Reservation은 힌트이며, limit가 규칙입니다

reservations.memory(이전 명칭 mem_reservation)는 소프트 플로어(soft floor)입니다. Docker는 이를 호스트에서 경합이 발생하거나 메모리가 부족할 때 활성화되는 소프트 제한으로 정의합니다. 이 설정은 컨테이너가 해당 값을 초과하는 것을 막지 않으며, 컨테이너가 메모리를 요청할 때 해당 메모리가 확보되어 있음을 보장하지도 않습니다. 단지 커널이 메모리를 회수할 때, reservation을 초과한 컨테이너를 우선적으로 고려하도록 유도할 뿐입니다.

따라서 reservation 자체만으로는 아무것도 보호하지 못합니다. 시스템 부하 상황에서 우선적으로 보호하고 싶은 서비스에 이 설정을 사용하고, 안전을 위해서는 limit에 의존하십시오. reservation을 limit보다 낮게 설정하십시오. 그렇지 않으면 컨테이너가 시작되지 않으며, Docker는 Minimum memory limit can not be less than memory reservation limit 오류와 함께 설정을 거부합니다.

스왑(Swap) 계정 관리의 실체

대부분의 VPS 이미지는 스왑 파일을 전혀 포함하지 않은 상태로 배포됩니다. swapon --show 및 free -h 명령을 실행하십시오. 스왑 총계가 0이라면 아래의 모든 스왑 관련 설정은 아무런 효과가 없으며, 메모리 제한은 순수하게 RAM 용량으로만 제한됩니다.

memswap_limit는 스왑의 양을 의미하지 않습니다. 이는 메모리와 스왑을 합친 총량입니다. mem_limit: 1g 및 memswap_limit: 2g을 설정하면 컨테이너는 1GB의 RAM과 1GB의 스왑을 할당받습니다. 두 값을 동일하게 설정하면 컨테이너는 스왑을 전혀 사용할 수 없습니다. mem_limit를 설정하고 memswap_limit을 설정하지 않으면, 컨테이너는 메모리 제한만큼의 스왑을 추가로 사용할 수 있습니다.

Ubuntu 24.04와 Debian 13은 기본적으로 cgroup v2를 사용하며, 이 환경에서는 스왑이 별도의 카운터(memory.swap.max)로 관리되므로 추가 설정 없이 작동합니다. Your kernel does not support swap limit capabilities라는 오래된 메시지는 swapaccount=1 옵션 없이 부팅된 cgroup v1 호스트에서 발생합니다. 해당 환경에서는 메모리 제한은 적용되지만 스왑 관련 설정은 무시됩니다.

스왑이 실제로 어떤 이점을 주는지 냉정하게 판단해야 합니다. 스왑은 메모리 누수가 발생하는 프로세스가 RAM을 채우는 것과 마찬가지로 스왑 공간도 동일하게 채우기 때문에, OOM(Out of Memory) 킬러의 작동을 늦출 뿐 발생 가능성을 낮추지는 않습니다. 한편, 공유 VPS 스토리지에서 스왑을 과도하게 사용하는 컨테이너는 서버 내의 다른 모든 서비스 속도를 저하시킵니다. 지연 시간에 민감한 서비스의 경우, 스왑 없이 정확한 메모리 제한을 설정하는 것이 더 빠르고 예측 가능한 방식으로 실패를 처리하게 합니다.

메모리 사용량이 실제보다 높게 보이는 이유

MEM USAGE 수치는 docker stats 내의 페이지 캐시를 포함하므로, 대용량 파일을 읽는 컨테이너는 제한치까지 메모리 사용량이 증가한 뒤 그 상태를 유지합니다. 이는 정상적인 동작이며 메모리 누수가 아닙니다. OOM killer가 호출되기 전에 정리된 캐시는 회수되기 때문입니다. 자체 호스팅 Jellyfin 미디어 서버와 같은 서비스가 정확히 이러한 이유로 항상 제한치에 근접한 메모리 사용량을 보입니다.

컨테이너 내부에서 메모리 수치를 캐시와 실제 워킹 세트로 구분하여 확인하십시오.

docker compose exec app grep -E '^(anon|file) ' /sys/fs/cgroup/memory.stat
docker compose exec app cat /sys/fs/cgroup/memory.events

anon는 삭제할 수 없는 워킹 세트인 익명 메모리입니다. file은 삭제 가능한 페이지 캐시입니다. 메모리 제한은 총합이 아닌 anon에 여유분을 더한 값으로 설정하십시오. memory.events 파일은 이 논란을 확실하게 종결합니다. oom_kill 카운터가 0보다 크면 커널이 컨테이너 시작 이후 무언가를 종료시켰음을 의미하며, max 카운터가 증가하고 있다면 컨테이너가 현재 제한치에 도달해 억제되고 있음을 의미합니다. 두 명령어 모두 이미지 내부에 셸과 coreutils가 필요하므로, distroless 또는 scratch 이미지에서는 실행되지 않습니다.

8GB VPS의 리소스 제한 설정

애플리케이션이 아닌 호스트 시스템부터 고려해야 합니다. 8GB VPS에서는 커널, Docker 데몬, sshd, journald 및 사용자 로그인 셸을 위해 약 1GB를 확보해 두십시오. 그러면 약 7GB를 할당할 수 있으며, 모든 컨테이너 제한의 합계는 이 수치 이내여야 합니다. 오버커밋(Overcommit)은 두 서비스가 동시에 최대 부하에 도달하는 날까지는 문제없이 작동할 수 있습니다.

8GB 서버에서 권장하는 리소스 분할 예시입니다.

  • 리버스 프록시: 128m 제한. 작은 프로세스이므로 이처럼 타이트한 제한을 두면 설정 재로드 시 발생하는 오류를 즉시 포착할 수 있습니다.
  • PostgreSQL: 2g 제한. 데이터베이스 설정에서 shared_buffers를 약 512MB로 설정하십시오.
  • 애플리케이션 컨테이너: 1g 제한.
  • 백그라운드 워커: 512m 제한.
  • 미디어 또는 파일 서비스: 2g 제한. 대부분 페이지 캐시로 사용됩니다.

이 수치를 자신의 스택에 그대로 복사하지 마십시오. 실제 부하 상태에서 하루 동안 서비스를 운영하며 docker stats을 관찰하고, 컨테이너별 최대 anon 값을 확인한 뒤 약 50% 정도의 여유 공간을 더하십시오. 제한을 너무 타이트하게 설정하면 정상적인 트래픽 급증 시에도 정상적인 서비스를 강제 종료하게 되므로, 제한이 없는 것보다 더 나쁜 결과를 초래합니다.

주의해야 할 함정이 하나 있습니다. 대부분의 런타임은 컨테이너 제한을 명시적으로 알려주지 않으면 이를 인식하지 못합니다. PostgreSQL은 컨테이너 제한을 초과하여 shared_buffers 및 work_mem를 할당하려다 강제 종료될 수 있습니다. JVM(Java virtual machine)은 호스트 RAM이 아닌 cgroup 제한에 맞춰 힙 크기를 조정하도록 -XX:MaxRAMPercentage=75 설정이 필요합니다. Node.js는 컨테이너 제한보다 낮은 값을 메가바이트 단위로 --max-old-space-size에 설정해야 합니다. 그렇지 않으면 가비지 컬렉터가 힙을 계속 확장하다가 커널에 의해 종료됩니다. Ollama도 마찬가지로 다른 설정이 필요합니다. num_ctx를 높이면 KV 캐시가 수백 메가바이트씩 증가하여 긴 프롬프트를 처리하는 도중에 컨테이너가 종료되기 때문입니다. cgroup은 협상하지 않습니다. 즉시 종료시킬 뿐입니다.

CPU 제한은 완전히 다르게 동작합니다

cpus: "1.5"는 단일 코어의 150%를 의미하며, CFS(Completely Fair Scheduler) 쿼터로 강제됩니다. 컨테이너는 모든 스레드에 걸쳐 100ms 주기마다 150ms의 CPU 시간을 할당받습니다. 할당량을 모두 소진하면 커널은 다음 주기까지 컨테이너를 대기시킵니다.

이 점이 중요한 차이점입니다. 메모리 제한을 초과한 컨테이너는 종료되지만, CPU 제한을 초과한 컨테이너는 스로틀링(throttling)이 걸린 채로 더 느리게 계속 동작합니다. 따라서 CPU 제한은 공격적으로 설정해도 안전하지만, 메모리 제한은 여유 공간이 필요합니다.

cpu_shares은 다른 도구입니다. CPU가 실제로 포화 상태일 때만 의미가 있는 상대적 가중치입니다. 1024와 512의 가중치를 가진 두 컨테이너는 바쁜 코어를 대략 2대 1로 나누어 사용하며, 유휴 상태인 서버에서는 어느 쪽도 제한받지 않습니다. 서비스의 중요도에 따라 순위를 매길 때는 가중치를 사용하고, 야간 트랜스코딩 작업이 웹 서버의 자원을 고갈시키지 않도록 하는 등 확실한 상한선이 필요할 때는 cpus를 사용하십시오.

FAQ

Docker Swarm 없이도 deploy.resources.limits가 작동합니까?

네, 작동합니다. Docker Compose와 docker compose 플러그인(현재 v5 버전)은 단일 호스트에서 docker compose up을 실행할 때 deploy.resources.limits 및 deploy.resources.reservations을 적용합니다. docker inspect --format '{{.HostConfig.Memory}}' <container>를 실행하여 이를 확인하십시오. 이 명령은 제한 값을 바이트 단위로 출력하며, 제한이 설정되지 않은 경우 0을 출력합니다. deploy 내부의 키 중 Swarm이 반드시 필요한 항목은 mode, placement, update_config, endpoint_mode뿐입니다.

Docker Compose에서 종료 코드 137은 무엇을 의미합니까?

메인 프로세스가 SIGKILL을 수신했음을 의미합니다. 137은 128에 시그널 9를 더한 값입니다. 커널의 OOM killer가 일반적인 원인이지만, 애플리케이션이 SIGTERM을 무시할 경우 종료 타임아웃으로 인해 동일한 코드가 발생하기도 합니다. docker inspect --format '{{.State.OOMKilled}} {{.State.ExitCode}}' <container>을 실행하여 두 경우를 구분하십시오. true 137은 메모리 부족으로 인한 종료이며, false 137은 그렇지 않습니다.

mem_limit와 deploy.resources.limits.memory 중 무엇을 설정해야 합니까?

둘 다 docker compose에서 작동합니다. deploy.resources.limits.memory은 현재 Compose Specification 형식이며 새 파일 작성 시 더 권장되는 방식입니다. 파일의 나머지 부분이 기존의 최상위 키를 사용 중이라면 mem_limit을 유지하십시오. 한 서비스에 두 설정을 모두 적용하면 파일 가독성만 떨어지므로, 하나를 선택하고 docker inspect로 결과를 확인하십시오.

컨테이너가 종료되지 않고 메모리 제한치에 도달한 채로 유지되는 이유는 무엇입니까?

docker stats의 사용량 수치에는 페이지 캐시가 포함되어 있습니다. 커널은 OOM kill을 트리거하는 대신 압박을 받을 때 이 캐시를 삭제합니다. docker compose exec <service> grep -E '^(anon|file) ' /sys/fs/cgroup/memory.stat를 실행하여 anon 값을 확인하십시오. 이는 회수할 수 없는 워킹 셋(working set)입니다. file 값은 높지만 anon 값이 낮다면, 이는 곧 종료될 컨테이너가 아니라 디스크 입출력을 수행 중인 컨테이너입니다.

8GB VPS에서 할당하지 않고 남겨두어야 할 RAM은 어느 정도입니까?

커널, Docker daemon, sshd, journald 및 사용자의 셸을 위해 약 1GB를 남겨두고, 나머지 7GB 이내로 모든 컨테이너 제한의 합계를 유지하십시오. 수치를 확정하기 전에 실제 부하 상태에서 하루 동안 컨테이너별 최대 anon 값을 모니터링하고, 전체 합계를 채워야 할 목표치가 아닌 예산으로 간주하십시오.