SSD Nodes Learn 8GB RAM — $66/год
Руководства Matt ConnorАвтор: Matt Connor · Обновлено 2026-08-01

Docker Compose: лимиты памяти без ошибки OOM

Настройте лимиты памяти и CPU в Docker Compose: разберите deploy.resources, mem_limit и код выхода 137, учтите swap и подберите размер для VPS.

Ограничение памяти Docker Compose

Ограничение памяти Docker Compose — это жесткий предел, который ядро Linux устанавливает для cgroup (контрольной группы — функции ядра для учета ресурсов набора процессов) одного контейнера. Укажите 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. Если в столбце ограничения указан полный объем RAM хоста, настройка не применилась. Остальная часть этого руководства не поможет, пока вы не исправите эту проблему. Если файл compose пока вам незнаком, ознакомьтесь с основами Docker Compose для VPS, на которых основана описанная здесь структура файла.

deploy.resources.limits или mem_limit: какой параметр применяется

Для одной и той же настройки существуют 2 варианта написания, поэтому здесь легко запутаться.

mem_limit, mem_reservation, memswap_limit, cpus и cpu_shares — это ключи службы верхнего уровня, унаследованные от старых форматов Compose-файлов. deploy.resources появился в схеме Swarm и теперь входит в Compose Specification — формат, который сегодня читает docker compose.

Оба варианта работают на одном узле. Compose V2, плагин docker compose, применяет deploy.resources.limits и deploy.resources.reservations при запуске docker compose up, даже если кластер Swarm отсутствует. Компоненты блока 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: чистый кэш страниц, затем страницы, которые можно выгрузить в swap. Если освобожденного места недостаточно, механизм OOM (out of memory) для cgroup выбирает процесс внутри контейнера и отправляет ему 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. 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 на хосте. Это значит, что на машине закончилась RAM. Именно этого должны предотвращать лимиты. Поэтому такая запись указывает, что сумма ваших лимитов слишком велика или некоторые службы вообще не имеют лимита.

При использовании restart: unless-stopped цикл OOM легко не заметить, поскольку через секунду после завершения служба снова появляется в docker compose ps. Проверьте столбец времени работы и счетчик перезапусков. Также свяжите лимит с проверкой работоспособности, которая сообщает, что приложение неисправно, чтобы контейнер, который постоянно завершается, был виден без постоянного наблюдения за ним.

Резервирование — это ориентир, а ограничение — правило

reservations.memory (старый параметр mem_reservation) задаёт мягкий нижний предел. Docker описывает его как мягкое ограничение, которое активируется, когда демон обнаруживает конкуренцию за ресурсы или нехватку памяти на хосте. Оно не препятствует контейнеру превысить это значение и не гарантирует, что память будет свободна, когда контейнер запросит её. Оно лишь задаёт ядру приоритет: сначала освобождать память у контейнеров, которые превышают своё резервирование.

Поэтому одно резервирование ничего не защищает. Используйте его, чтобы отметить службу, к которой следует относиться менее строго при нехватке ресурсов, а для безопасности полагайтесь на ограничение. Резервирование должно быть меньше ограничения, иначе контейнер не запустится: Docker отклонит конфигурацию с ошибкой Minimum memory limit can not be less than memory reservation limit.

Учёт swap без самообмана

В большинстве образов VPS вообще нет файла подкачки. Выполните swapon --show и free -h. Если общий объём swap равен нулю, все приведённые ниже параметры, связанные со 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, но не снижает вероятность такого завершения, поскольку процесс с утечкой памяти заполняет swap так же быстро, как RAM. При этом контейнер, который активно использует swap на общем хранилище VPS, замедляет работу всех остальных сервисов на этом сервере. Для приложений, чувствительных к задержкам, корректный лимит без swap обеспечивает более быстрое и предсказуемое завершение при нехватке памяти.

Почему использование памяти выглядит хуже, чем есть на самом деле

Показатель 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 выше нуля означает, что ядро с момента запуска контейнера завершило в нем какой-либо процесс из-за нехватки памяти, а рост счетчика max означает, что контейнер сейчас упирается в установленный предел. Для обеих команд в образе должны быть shell и coreutils, поэтому на образах distroless или scratch они завершаются с ошибкой.

Ограничения ресурсов на VPS с 8GB

Начинайте с хоста, а не с приложений. На VPS с 8GB оставьте около 1GB для ядра, Docker daemon, sshd, journald и собственной оболочки входа. Останется примерно 7GB для распределения, а сумма ограничений всех контейнеров должна быть меньше этого значения. Перераспределение ресурсов работает до того дня, когда два сервиса одновременно достигают пиковых значений.

Рабочий вариант распределения для сервера с 8GB:

  • Reverse proxy: ограничение 128m. Это небольшой процесс, и настолько жесткое ограничение сразу обнаруживает неконтролируемую перезагрузку конфигурации.
  • PostgreSQL: ограничение 2g, при этом shared_buffers в конфигурации базы данных должно быть установлено примерно в 512MB.
  • Контейнер приложения: ограничение 1g.
  • Фоновый обработчик: ограничение 512m.
  • Сервис мультимедиа или файловый сервис: ограничение 2g, большая часть которого будет использоваться под page cache.

Не копируйте эти значения в собственный стек. В течение дня запускайте сервисы под реальной нагрузкой, отслеживайте docker stats, определите пиковое значение anon для каждого контейнера и добавьте примерно половину этого значения в качестве запаса. Слишком жесткое ограничение хуже, чем отсутствие ограничения, потому что оно остановит исправно работающий сервис при обычном скачке сетевого трафика.

Есть одна важная особенность. Большинство сред выполнения не видит это ограничение, пока вы явно не сообщите им о нем. PostgreSQL без проблем установит значения shared_buffers и work_mem выше ограничения контейнера, после чего процесс будет остановлен. JVM (виртуальной машине Java) требуется -XX:MaxRAMPercentage=75, чтобы рассчитывать размер кучи по ограничению cgroup, а не по объему RAM хоста. Node.js требуется --max-old-space-size в мегабайтах. Это значение нужно установить ниже ограничения контейнера, иначе сборщик мусора позволит куче расти до вмешательства ядра. cgroup не согласовывает значения. Она останавливает процесс.

Ограничения CPU работают совершенно иначе

cpus: "1.5" означает 150% одного ядра, заданные как квота CFS (completely fair scheduler). Контейнер получает 150ms процессорного времени в каждом периоде длительностью 100ms. Это время распределяется между всеми его потоками. После его использования ядро заставляет контейнер ждать следующего периода.

В этом заключается важное различие. Контейнер, превысивший ограничение памяти, принудительно завершается. Контейнер, превысивший ограничение CPU, подвергается троттлингу и продолжает работу, но медленнее. Поэтому ограничение CPU можно задавать с небольшим запасом или достаточно жестко, а для ограничения памяти нужен запас.

cpu_shares — это другой инструмент: относительный вес, который имеет значение только при фактической загрузке CPU. Два контейнера со значениями shares 1024 и 512 делят загруженное ядро примерно в соотношении два к одному. На свободном сервере ни один из них не ограничивается. Используйте shares, чтобы расставить службы по приоритету, а 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 в ядре, но такой же код возникает при превышении времени ожидания завершения, если приложение игнорирует 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. Запустите docker compose exec <service> grep -E '^(anon|file) ' /sys/fs/cgroup/memory.stat и проверьте значение anon. Оно показывает рабочий набор, который нельзя освободить. Высокое значение file рядом с низким значением anon означает, что контейнер выполняет дисковые операции ввода-вывода, а не близок к завершению.

Сколько RAM следует оставить нераспределённой на VPS с 8GB?

Оставьте около 1GB для ядра, Docker daemon, sshd, journald и собственной оболочки. Затем поддерживайте сумму всех лимитов контейнеров ниже оставшихся 7GB. В течение дня при реальной нагрузке отслеживайте пиковое значение anon для каждого контейнера, прежде чем окончательно установить значения. Рассматривайте общий объём как бюджет, а не как целевой показатель, который нужно полностью использовать.