SSD Nodes Learn Hosting plans →
가이드 Matt Connor작성자 Matt Connor · 업데이트됨 2026-08-26

Docker Compose 메모리 제한 설정 및 OOM 방지 방법

Docker Compose에서 deploy.resources 및 mem_limit를 사용하여 컨테이너 메모리 사용량을 제한하십시오. 특정 컨테이너가 시스템 전체를 다운시키는 OOM 상황과 종료 코드 137 문제를 방지하는 정확한 설정 방법을 설명합니다.

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 용량이 표시된다면 설정이 적용되지 않은 것이며, 이 문제가 해결되기 전까지는 이후 가이드의 내용을 수행해도 소용이 없습니다. Docker Compose 파일 자체가 생소하다면 VPS를 위한 Docker Compose 기초에서 이 가이드의 기반이 되는 파일 구조를 확인하십시오.

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

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

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

두 방식 모두 단일 호스트에서 작동합니다. docker compose 플러그인인 Compose V2는 Swarm 클러스터가 없는 환경에서도 docker compose up을 실행하면 deploy.resources.limitsdeploy.resources.reservations를 적용합니다. deploy 블록 중 Swarm 전용 항목은 다른 키들입니다. mode, placement, update_configendpoint_modedocker stack deploy에는 의미가 있지만 docker compose up에서는 무시됩니다. 따라서 "deploy는 Swarm이 필요하다"는 일반적인 조언은 resources 하위 섹션에 대해서는 틀린 말이며, 이를 따르면 서비스에 아무런 제한이 설정되지 않습니다.

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

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

메모리 값은 바이트 단위이므로 1g1073741824로 출력됩니다. CPU는 나노 CPU 단위이므로 1.51500000000으로 출력됩니다. 필드에 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는 이를 호스트에서 경합이 발생하거나 메모리가 부족할 때 활성화되는 소프트 리미트(soft limit)로 정의합니다. 이 설정은 컨테이너가 해당 값을 초과하는 것을 막지 않으며, 컨테이너가 메모리를 요청할 때 해당 메모리가 확보되어 있음을 보장하지도 않습니다. 단지 커널이 메모리를 회수할 때, reservation을 초과한 컨테이너를 우선적으로 고려하도록 유도할 뿐입니다.

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

스왑(Swap) 계정 관리, 솔직하게 말하기

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

memswap_limit는 스왑의 양을 의미하지 않습니다. 이는 메모리와 스왑을 합친 총량을 의미합니다. mem_limit: 1gmemswap_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는 삭제할 수 없는 워킹 세트인 익명 메모리(anonymous memory)입니다. file은 삭제 가능한 페이지 캐시입니다. 메모리 제한은 전체 수치가 아닌 anon에 여유분을 더한 값으로 설정하십시오. memory.events 파일은 논란을 확실하게 종결합니다. oom_kill 카운터가 0보다 크면 컨테이너 시작 이후 커널이 무언가를 강제 종료했다는 의미이며, max 카운터가 증가하고 있다면 컨테이너가 현재 제한치에 도달해 압박을 받고 있다는 의미입니다. 두 명령어 모두 이미지 내부에 셸과 coreutils가 필요하므로, distroless 또는 scratch 이미지에서는 실행되지 않습니다.

8GB VPS의 리소스 제한 설정

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

8GB 서버에서 권장하는 분할 방식은 다음과 같습니다.

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

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

주의해야 할 함정이 하나 있습니다. 대부분의 런타임은 명시적으로 알려주지 않으면 제한을 인식하지 못합니다. PostgreSQL은 컨테이너 제한을 넘어서 shared_bufferswork_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 up을 실행하면 Compose V2가 deploy.resources.limitsdeploy.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 값을 확인하십시오. file 값은 높지만 anon 값이 낮다면, 이는 컨테이너가 곧 종료되는 것이 아니라 디스크 입출력을 수행 중인 상태입니다.

8GB RAM을 가진 VPS에서 얼마만큼의 메모리를 할당하지 않고 남겨두어야 합니까?

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