Docker Compose 메모리 제한으로 OOM 막는 법
Docker Compose에서 deploy.resources와 mem_limit을 설정해 컨테이너 하나의 메모리 독점을 막습니다. exit 137, swap, CPU 제한과 VPS 용량별 설정 기준을 설명합니다.
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-streamMEM 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의 일부입니다.
두 방식 모두 단일 호스트에서 작동합니다. Compose V2인 docker compose plugin은 Swarm cluster가 없어도 docker compose up을 실행할 때 deploy.resources.limits 및 deploy.resources.reservations를 적용합니다. deploy 블록에서 Swarm 전용인 다른 항목은 다음 키입니다. mode, placement, update_config 및 endpoint_mode는 docker stack deploy에서 의미가 있으며 docker compose up에서는 무시됩니다. 따라서 "deploy에는 Swarm이 필요하다"는 일반적인 조언은 resources subsection에는 적용되지 않습니다. 이 조언을 따르면 서비스에 제한이 전혀 설정되지 않습니다.
프로젝트마다 하나의 표기법만 선택합니다. 같은 서비스에 mem_limit: 512m과 deploy.resources.limits.memory: 1g를 함께 작성하면 파일을 한눈에 읽을 수 없게 됩니다. 어떤 값이 적용되었는지 추측하지 말고 daemon에 확인합니다.
docker inspect --format '{{.HostConfig.Memory}} {{.HostConfig.MemoryReservation}} {{.HostConfig.NanoCpus}}' app-1메모리 값은 bytes 단위이므로 1g은 1073741824로 출력됩니다. CPU는 nano CPUs 단위이므로 1.5는 1500000000으로 출력됩니다. 어떤 필드에서든 0는 제한이 설정되지 않았다는 의미입니다. Docker가 허용하는 가장 작은 메모리 제한은 6m이며, 이보다 낮게 설정하면 container가 시작되지 않습니다.
컨테이너가 제한에 도달하면 발생하는 일
컨테이너가 느려지는 것이 아닙니다. 종료됩니다.
프로세스가 페이지를 요청했는데 cgroup이 이미 memory.max에 도달한 경우, 커널은 먼저 해당 cgroup 내부에서 회수할 수 있는 항목을 회수합니다. 먼저 clean page cache를 회수하고, 그다음 swap으로 이동할 수 있는 페이지를 회수합니다. 메모리 회수로 충분한 공간이 확보되지 않으면 cgroup OOM(out of memory) killer가 컨테이너 내부의 프로세스를 선택하고 SIGKILL을 보냅니다. 컨테이너의 PID 1을 종료하면 컨테이너도 종료됩니다. Exit code 137은 단순히 128에 signal 9를 더한 값입니다. 따라서 137은 모든 SIGKILL의 흔적일 뿐이며, 그 자체로 OOM을 입증하지는 않습니다.
docker compose ps -a
docker inspect --format '{{.State.OOMKilled}} {{.State.ExitCode}}' app-1true 137은 OOM kill입니다. false 137은 다른 무언가가 SIGKILL을 보냈다는 의미입니다. 일반적인 원인은 앱이 SIGTERM을 무시하여 docker compose stop이 10초의 grace period에 도달하는 것입니다. 이 구분을 알면 두 문제가 전혀 관련이 없다는 것을 확인할 수 있으므로 많은 시간을 절약할 수 있습니다.
이 이벤트가 기록되는 위치가 2곳 더 있습니다. daemon 로그를 실시간으로 확인합니다.
docker events --filter event=oom그다음 재시작 후에도 남아 있는 기록인 kernel log를 확인합니다.
sudo dmesg -T | grep -i -E 'memory cgroup out of memory|killed process'cgroup kill은 Memory cgroup out of memory: Killed process 24713 (node)으로 시작하는 줄을 출력합니다. Memory cgroup 접두사가 없는 줄은 host OOM입니다. 이는 시스템 자체의 RAM이 모두 소진되었다는 뜻입니다. 제한은 이러한 장애를 방지하기 위한 것이므로, 이 줄이 보인다면 설정한 제한의 합계가 너무 높거나 일부 서비스에 제한이 전혀 없다는 의미입니다.
restart: unless-stopped을 사용하면 서비스가 종료된 지 1초 후 docker compose ps에서 다시 실행 중으로 보이므로 OOM loop가 잘 드러나지 않습니다. uptime 열과 restart count를 확인하고, 제한을 앱을 unhealthy로 보고하는 healthcheck와 함께 설정합니다. 그러면 계속 종료되는 컨테이너를 직접 지켜보지 않아도 확인할 수 있습니다.
Reservation은 힌트이고 limit이 규칙입니다
reservations.memory (이전의 mem_reservation)은(는) 소프트 하한입니다. Docker는 이를 daemon이 호스트의 메모리 경합 또는 메모리 부족을 감지할 때 적용되는 소프트 limit으로 설명합니다. 이 값은 컨테이너가 이를 초과하는 것을 막지 않으며, 컨테이너가 메모리를 요청할 때 해당 메모리가 사용 가능하다고 보장하지도 않습니다. 커널이 reservation을 초과한 컨테이너에서 메모리를 우선 회수하도록 유도할 뿐입니다.
따라서 reservation만으로는 아무것도 보호하지 못합니다. 부하가 발생했을 때 우선적으로 보호할 service를 표시하는 용도로 사용하고, 안전성은 limit에 의존합니다. reservation은 limit보다 낮게 설정해야 합니다. 그렇지 않으면 컨테이너가 시작되지 않습니다. Docker는 Minimum memory limit can not be less than memory reservation limit을(를) 반환하며 config를 거부합니다.
스왑을 정확히 계산하기
대부분의 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 호스트에서 발생합니다. 이러한 호스트에서는 메모리 제한이 계속 적용되지만 스왑 부분은 무시됩니다.
스왑의 효과를 정확히 이해해야 합니다. 스왑은 OOM kill을 발생하기 어렵게 만드는 것이 아니라 느리게 만듭니다. 메모리 누수가 발생하는 프로세스는 RAM을 채우는 것처럼 스왑도 계속 채우기 때문입니다. 또한 공유 VPS 스토리지에서 스왑을 과도하게 사용하는 컨테이너는 해당 서버의 다른 모든 서비스도 느리게 만듭니다. 지연 시간에 민감한 작업에서는 스왑 없이 올바른 제한을 설정하는 편이 더 빠르고 예측 가능하게 실패합니다.
메모리 사용량이 실제보다 나빠 보이는 이유
docker stats의 MEM USAGE 수치에는 페이지 캐시가 포함됩니다. 따라서 대용량 파일을 읽는 컨테이너는 제한값에 가까워진 후 그 상태를 유지합니다. 이는 정상이며 메모리 누수가 아닙니다. 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.eventsanon는 삭제할 수 없는 익명 메모리이며, 실제 작업 집합입니다. file는 회수할 수 있는 페이지 캐시입니다. 제한값은 전체 사용량이 아니라 anon에 여유분을 더한 값을 기준으로 설정합니다. memory.events 파일을 확인하면 이 문제를 명확히 판단할 수 있습니다. oom_kill 카운터가 0보다 크면 컨테이너가 시작된 후 커널이 이 컨테이너의 프로세스를 종료한 것입니다. max 카운터가 증가하면 컨테이너가 현재 한도에 도달한 상태입니다. 두 명령 모두 이미지 내부에 shell과 coreutils가 있어야 하므로 distroless 이미지 또는 scratch 이미지에서는 실패합니다.
8GB VPS의 크기 제한
애플리케이션이 아니라 호스트부터 시작합니다. 8GB VPS에서는 kernel, Docker daemon, sshd, journald 및 자체 login shell에 약 1GB를 남겨 둡니다. 그러면 할당할 수 있는 메모리는 약 7GB이며, 모든 container limit의 합계는 이보다 작아야 합니다. 과도한 할당은 두 서비스가 동시에 최대 사용량에 도달하는 날까지는 작동합니다.
8GB 서버에서 사용할 수 있는 분할 예시는 다음과 같습니다.
- Reverse proxy: 128m limit입니다. 작은 프로세스이므로 이처럼 엄격한 limit를 설정하면 잘못된 설정으로 인한 반복적인 reload를 즉시 감지할 수 있습니다.
- PostgreSQL: 2g limit이며, database config에서
shared_buffers를 약 512MB로 설정합니다. - Application container: 1g limit입니다.
- Background worker: 512m limit입니다.
- Media 또는 file service: 2g limit이며, 이 중 대부분은 page cache로 사용됩니다.
이 수치를 자체 stack에 그대로 적용하지 마십시오. 하루 동안 실제 부하에서 서비스를 실행하고 docker stats를 모니터링합니다. 각 container의 최대 anon 값을 확인한 다음, 여기에 대략 절반을 추가 여유 공간으로 더합니다. limit를 지나치게 낮게 설정하면 limit가 없는 것보다 문제가 큽니다. 정상적인 traffic spike 중에 정상 서비스가 종료되기 때문입니다.
별도로 설명해야 할 함정이 하나 있습니다. 대부분의 runtime은 limit를 알려 주지 않으면 이를 인식하지 못합니다. PostgreSQL은 shared_buffers 및 work_mem의 크기를 container limit보다 크게 설정한 뒤 종료될 수 있습니다. JVM (Java virtual machine)은 host RAM이 아니라 cgroup limit를 기준으로 heap 크기를 설정하도록 -XX:MaxRAMPercentage=75이 필요합니다. Node.js는 --max-old-space-size을 메가바이트 단위로 설정해야 하며, 이 값은 container limit보다 작아야 합니다. 그렇지 않으면 garbage collector가 kernel이 개입할 때까지 heap을 계속 확장합니다. cgroup은 협상하지 않습니다. cgroup은 프로세스를 종료합니다.
CPU 제한은 완전히 다르게 작동합니다
cpus: "1.5"은 CFS (completely fair scheduler) 할당량으로 적용되는 코어 1개의 150%를 의미합니다. 컨테이너는 100ms 주기마다 150ms의 CPU 시간을 사용하며, 이 시간은 모든 스레드가 공유합니다. 이 시간을 모두 사용하면 커널은 다음 주기가 시작될 때까지 컨테이너를 대기시킵니다.
이 차이가 중요합니다. 메모리 제한을 초과한 컨테이너는 종료됩니다. CPU 제한을 초과한 컨테이너는 조절되며 더 느리게 계속 실행됩니다. 따라서 CPU 제한은 여유를 적게 두고 적극적으로 설정해도 안전하지만, 메모리 제한에는 여유가 필요합니다.
cpu_shares은 다른 용도의 도구입니다. CPU가 실제로 포화된 경우에만 적용되는 상대적 가중치입니다. shares가 1024인 컨테이너와 512인 컨테이너는 사용량이 많은 코어를 대략 2 대 1로 나눠 사용합니다. 유휴 상태인 시스템에서는 어느 컨테이너도 제한되지 않습니다. shares는 서비스의 중요도 순위를 정하는 데 사용합니다. 실제 상한이 필요할 때는 cpus을 사용합니다. 예를 들어 야간 transcode 작업이 웹 서버의 CPU를 고갈시키지 않도록 할 수 있습니다.
FAQ
Docker Swarm 없이 deploy.resources.limits가 작동합니까?
예. 단일 호스트에서 docker compose up을 실행하면 Compose V2가 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 값을 확인한 후 수치를 확정합니다. 전체 용량은 모두 채울 목표가 아니라 예산으로 취급합니다.