Как ограничить память контейнера в Docker Compose
Узнайте, как настроить deploy.resources для предотвращения OOM на VPS. Ограничение памяти защитит сервер от падения с кодом 137 при нехватке RAM и утечках в процессах контейнера.
Что делает ограничение памяти в Docker Compose
Ограничение памяти в Docker Compose — это жесткий лимит, который ядро Linux накладывает на cgroup (control group, механизм ядра для учета ресурсов группы процессов) конкретного контейнера. Установите deploy.resources.limits.memory для сервиса, и этот контейнер никогда не сможет использовать больше указанного объема. При попытке превысить лимит ядро завершает процесс внутри контейнера, и контейнер обычно завершается с кодом 137.
Это критически важно на VPS, где объем оперативной памяти фиксирован и нет свободных ресурсов хоста для заимствования. Один контейнер с утечкой памяти или некорректным запросом может занять все свободные страницы на сервере с 8GB RAM. В таком случае ядро принудительно завершит процесс, который посчитает наиболее «тяжелым» — зачастую это база данных или ваша 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. Если в столбце лимита указан весь объем оперативной памяти хоста, значит, настройка не применилась, и дальнейшие действия из этого руководства не помогут, пока лимит не будет установлен корректно. Если формат compose-файла для вас в новинку, основы Docker Compose для VPS описывают структуру файла, на которой строится данная конфигурация.
deploy.resources.limits или mem_limit: что использовать
Существуют два способа записи для одной и той же настройки, что вызывает путаницу.
mem_limit, mem_reservation, memswap_limit, cpus и cpu_shares — это ключи верхнего уровня для сервисов, унаследованные от старых форматов файлов Compose. deploy.resources появился в схеме Swarm и теперь является частью спецификации Compose, которую сегодня считывает docker compose.
Оба варианта работают на одном хосте. Compose V2, плагин docker compose, применяет deploy.resources.limits и deploy.resources.reservations при запуске docker compose up, даже если кластер Swarm отсутствует. Ключи, специфичные только для Swarm, находятся в блоке deploy: 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, поэтому 1.5 отображается как 1500000000. Значение 0 в любом поле означает, что ограничение не было установлено. Минимальный лимит памяти, который принимает Docker, составляет 6m; при меньшем значении контейнер отказывается запускаться.
Что происходит, когда контейнер достигает лимита
Контейнер не замедляется. Он завершается.
Когда процесс запрашивает страницу памяти, а cgroup уже достигла своего memory.max, ядро сначала пытается освободить всё возможное внутри этой cgroup: очищает page cache, затем вытесняет страницы в swap. Если освободить достаточно памяти не удаётся, 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-1true 137 означает завершение по OOM. false 137 означает, что SIGKILL был отправлен по другой причине, и обычно это docker compose stop, превысивший десятисекундный период ожидания, так как приложение проигнорировало SIGTERM. Это различие экономит часы работы, так как эти две проблемы не имеют ничего общего.
Событие фиксируется ещё в двух местах. Отслеживайте работу демона в реальном времени:
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 хоста, что означает, что оперативная память закончилась на самой машине. Это именно тот сбой, который должны предотвращать лимиты, поэтому его появление — признак того, что сумма ваших лимитов слишком высока или некоторые сервисы работают без ограничений.
При использовании restart: unless-stopped цикл OOM-завершений легко пропустить, так как сервис выглядит запущенным в docker compose ps через секунду после падения. Проверяйте столбец uptime и количество перезапусков, а также свяжите лимит с проверкой работоспособности, которая помечает приложение как неисправное, чтобы контейнер, который постоянно завершается, был заметен без вашего постоянного наблюдения.
Reservation — это рекомендация, а limit — это правило
reservations.memory (более старый mem_reservation) является мягким порогом. Docker описывает его как мягкое ограничение, которое активируется, когда демон обнаруживает нехватку ресурсов или дефицит памяти на хосте. Он не запрещает контейнеру потреблять больше памяти и не гарантирует, что память будет свободна в момент запроса. Он лишь задает приоритет ядру: в первую очередь освобождать память тех контейнеров, которые превысили свой reservation.
Таким образом, reservation сам по себе ничего не защищает. Используйте его, чтобы выделить сервис, который должен работать стабильно при высокой нагрузке, а для обеспечения безопасности полагайтесь на limit. Значение reservation должно быть меньше значения limit, иначе контейнер не запустится: Docker отклонит конфигурацию с ошибкой Minimum memory limit can not be less than memory reservation limit.
Учет swap, начистоту
Большинство образов VPS поставляются вообще без файла подкачки. Выполните swapon --show и free -h. Если общий объем swap равен нулю, все приведенные ниже настройки подкачки не будут иметь эффекта, а ваш лимит памяти станет жестким ограничением только для RAM.
memswap_limit — это не объем swap. Это сумма памяти и swap. При использовании mem_limit: 1g и memswap_limit: 2g контейнер получает 1GB RAM и 1GB swap. Если установить эти два значения равными, контейнер не получит swap вовсе. Установка mem_limit при не заданном memswap_limit позволяет контейнеру использовать swap в объеме, равном его лимиту памяти.
Ubuntu 24.04 и Debian 13 по умолчанию используют cgroup v2, где swap является отдельным счетчиком (memory.swap.max), поэтому все работает без дополнительной настройки. Старое сообщение Your kernel does not support swap limit capabilities появляется на хостах с cgroup v1, загруженных без swapaccount=1. В таких системах лимит памяти продолжает действовать, а часть, относящаяся к swap, игнорируется.
Будьте честны в том, что дает вам swap. Он делает OOM kill медленнее, но не менее вероятным, так как процесс с утечкой памяти заполняет swap так же охотно, как и RAM. В то же время контейнер, вызывающий thrashing swap на общем хранилище VPS, замедляет работу всех остальных сервисов на сервере. Для любых задач, чувствительных к задержкам, корректный лимит без swap позволяет системе завершиться быстрее и предсказуемее.
Почему использование памяти выглядит хуже, чем есть на самом деле
Показатель MEM USAGE в docker stats включает в себя page cache, поэтому контейнер, который считывает большие файлы, приближается к своему лимиту и остается на этом уровне. Это нормальное поведение, а не утечка, так как чистый кэш будет очищен до того, как сработает 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 — это page cache, который можно выгрузить. Рассчитывайте лимит исходя из anon плюс небольшой запас, а не из общего объема. Файл memory.events окончательно разрешает спор: счетчик oom_kill больше нуля означает, что ядро уже завершило какой-то процесс в этом контейнере с момента его запуска, а растущий счетчик max означает, что контейнер прямо сейчас удерживается на пределе своего лимита. Обе команды требуют наличия оболочки и coreutils внутри образа, поэтому они не сработают в образах distroless или scratch.
Ограничения ресурсов на VPS с 8 ГБ ОЗУ
Начинайте планирование с хоста, а не с приложений. На VPS с 8 ГБ ОЗУ выделите около 1 ГБ на нужды ядра, Docker daemon, sshd, journald и вашу сессию пользователя. Остается примерно 7 ГБ для распределения, и сумма лимитов всех контейнеров не должна превышать это значение. Переподписка ресурсов работает до тех пор, пока два сервиса не достигнут пиковой нагрузки одновременно.
Пример распределения ресурсов на сервере с 8 ГБ:
- Reverse proxy: лимит 128m. Это легкий процесс, и такой строгий лимит позволяет сразу обнаружить ошибки при перезагрузке конфигурации.
- PostgreSQL: лимит 2g, при этом
shared_buffersв конфигурации базы данных должен быть установлен на уровне около 512MB. - Контейнер приложения: лимит 1g.
- Фоновый обработчик (worker): лимит 512m.
- Сервис медиафайлов: лимит 2g, большая часть которого будет занята кэшем страниц (page cache).
Не копируйте эти значения в свой стек бездумно. Запустите сервисы под реальной нагрузкой на сутки, отслеживайте docker stats, возьмите пиковое значение anon для каждого контейнера и добавьте примерно 50% сверху в качестве запаса. Слишком жесткий лимит хуже, чем его отсутствие, так как он принудительно завершит работу исправного сервиса во время обычного скачка трафика.
Одна ловушка заслуживает отдельного внимания. Лимит не виден большинству сред выполнения, если вы не укажете его явно. PostgreSQL может попытаться выделить shared_buffers и work_mem сверх лимита контейнера, после чего будет завершен принудительно. JVM (Java virtual machine) требует -XX:MaxRAMPercentage=75, чтобы определять размер кучи (heap) исходя из лимита cgroup, а не объема ОЗУ хоста. Node.js требует --max-old-space-size в мегабайтах, установленного ниже лимита контейнера, иначе сборщик мусора позволит куче расти до тех пор, пока не вмешаются механизмы ядра. С Ollama ситуация аналогична, но с другим параметром, так как увеличение num_ctx приводит к росту KV-кэша на сотни мегабайт, из-за чего контейнер аварийно завершается в процессе обработки длинного запроса. Cgroup не ведет переговоров. Она просто завершает процесс.
Ограничения CPU работают совершенно иначе
cpus: "1.5" означает 150% одного ядра, что реализуется как квота CFS (completely fair scheduler). Контейнер получает 150 мс процессорного времени на каждые 100 мс периода, распределяемых между всеми его потоками. Когда это время исчерпано, ядро заставляет процесс ждать начала следующего периода.
В этом заключается важное различие. Контейнер, превысивший лимит памяти, завершается принудительно. Контейнер, превысивший лимит CPU, подвергается троттлингу и продолжает работу, но медленнее. Поэтому лимиты CPU можно устанавливать достаточно агрессивно, тогда как для лимитов памяти всегда требуется запас.
cpu_shares — это другой инструмент: относительный вес, который учитывается только при реальной загрузке процессоров. Два контейнера с весами 1024 и 512 делят нагруженное ядро примерно в пропорции два к одному, а на простаивающем сервере ни один из них не ограничивается. Используйте веса для приоритизации сервисов, а cpus применяйте, когда необходимо установить жесткий предел, например, чтобы ночная задача перекодирования не лишала ресурсов ваш веб-сервер.
FAQ
Работает ли deploy.resources.limits без Docker Swarm?
Да. Compose V2 применяет deploy.resources.limits и deploy.resources.reservations при запуске docker compose up на одном хосте. Проверьте это с помощью docker inspect --format '{{.HostConfig.Memory}}' <container>, которая выводит лимит в байтах или 0, если лимит не был задан. Ключи внутри deploy, которые действительно требуют Swarm — это mode, placement, update_config и endpoint_mode.
Что означает код завершения 137 в Docker Compose?
Это означает, что основной процесс получил 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, и его лучше использовать в новых файлах. Оставьте mem_limit, если остальная часть файла уже использует старые ключи верхнего уровня. Установка обоих параметров для одного сервиса лишь усложняет чтение файла, поэтому выберите один и проверьте результат через docker inspect.
Почему контейнер потребляет весь лимит памяти, но не завершается?
Показатель использования в docker stats включает кэш страниц, который ядро очищает при нехватке памяти, не вызывая OOM kill. Запустите docker compose exec <service> grep -E '^(anon|file) ' /sys/fs/cgroup/memory.stat и посмотрите значение anon — это рабочий набор памяти, который нельзя освободить. Высокое значение file при низком anon означает, что контейнер выполняет операции ввода-вывода, а не находится на грани завершения.
Сколько оперативной памяти оставить свободной на VPS с 8GB?
Оставьте около 1GB для ядра, Docker daemon, sshd, journald и вашей оболочки, а сумму лимитов всех контейнеров удерживайте в пределах оставшихся 7GB. В течение дня отслеживайте пиковое значение anon для каждого контейнера под реальной нагрузкой, прежде чем фиксировать лимиты. Относитесь к общему объему как к бюджету, а не как к цели, которую нужно полностью занять.