Docker Compose: обмеження пам’яті без OOM
Налаштуйте memory і CPU для Docker Compose, щоб контейнер не поклав VPS: deploy.resources, mem_limit, код 137, swap і розрахунок лімітів.
Що робить обмеження пам’яті Docker Compose
Обмеження пам’яті Docker Compose — це жорстка межа, яку ядро Linux встановлює для cgroup (control group, функції ядра для обліку ресурсів групи процесів) одного контейнера. Установіть deploy.resources.limits.memory для сервісу, і цей контейнер не зможе використати більше пам’яті, ніж указано. Коли контейнер намагається перевищити межу, ядро завершує процес усередині контейнера, а контейнер зазвичай завершує роботу з кодом 137.
Це особливо важливо на VPS, де обсяг RAM фіксований і немає вільної пам’яті хоста для запозичення. Один контейнер із витоком пам’яті або невдалим запитом може зайняти кожну вільну сторінку на сервері з 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: що застосовується
Для тієї самої концепції існують два варіанти запису, тому тут легко заплутатися.
mem_limit, mem_reservation, memswap_limit, cpus і cpu_shares — це ключі сервісу верхнього рівня, успадковані зі старіших форматів Compose-файлів. deploy.resources походить зі схеми Swarm і тепер входить до Compose Specification — формату, який сьогодні читає docker compose.
Обидва варіанти працюють на одному хості. Docker Compose, плагін docker compose, застосовує deploy.resources.limits і deploy.resources.reservations під час виконання docker compose up без кластера Swarm. Зараз плагін має гілку v5 (на момент перевірки 2026-10-10 останньою стабільною версією була v5.6.0). Він перейшов безпосередньо з v2 на v5, пропустивши версії 3 і 4, щоб уникнути плутанини зі старими версіями формату Compose-файлів. Жоден випуск v5 не змінював спосіб обробки обмежень ресурсів. Орієнтовані на Swarm частини блоку deploy — це інші ключі, зокрема placement, update_config і endpoint_mode. Вони описують планування та розгортання в кластері вузлів для docker stack deploy. Тому поширена порада, що «для deploy потрібен Swarm», неправильна для підрозділу resources. Якщо її дотримуватися, сервіси взагалі не матимуть обмежень.
У межах одного проєкту використовуйте один варіант запису. Зазначати mem_limit: 512m і deploy.resources.limits.memory: 1g для того самого сервісу — не лише складно для читання: Compose Specification вимагає, щоб у разі встановлення mem_limit він узгоджувався з limits.memory, але не визначає, яке значення має перевагу, якщо вони відрізняються. Незалежно від вибраного варіанта перевірте, які значення фактично застосував daemon:
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: чистий page cache, а потім сторінки, які можна перемістити у swap. Якщо цього недостатньо, 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-1true 137 означає OOM kill. false 137 зазвичай означає, що SIGKILL надіслав інший процес (програма також може навмисно завершитися з кодом 137, але це трапляється рідко). Зазвичай причина — docker compose stop досяг свого десятисекундного періоду очікування, оскільки застосунок проігнорував SIGTERM. Це розрізнення заощаджує години, оскільки ці дві проблеми не мають нічого спільного.
Подію також записують ще у двох місцях. Переглядайте журнал daemon у реальному часі:
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). Цей рядок означає, що один контейнер досяг власного ліміту. Він може з’явитися, навіть якщо на хості ще достатньо вільної RAM. Рядок без префікса Memory cgroup означає OOM на хості, тобто на машині закінчилася RAM. Саме цього мають не допускати ліміти. Якщо ви бачите такий рядок, зазвичай це означає, що сума лімітів завелика, деякі сервіси взагалі не мають лімітів або щось поза Docker використовує більше пам’яті, ніж залишений вами запас.
За наявності restart: unless-stopped цикл OOM складно помітити, оскільки через секунду після завершення сервіс знову має статус up у docker compose ps. Перевірте стовпець uptime і лічильник перезапусків. Також додайте до ліміту healthcheck, який повідомляє, що застосунок має статус unhealthy, щоб контейнер, який постійно завершується, було видно без постійного спостереження за ним.
Резервація є підказкою, а ліміт — правилом
reservations.memory (старіший mem_reservation) — це нижня межа з м’яким застосуванням. Docker описує її як м’який ліміт, який активується, коли daemon виявляє конкуренцію за ресурси або низький обсяг пам’яті на хості. Він не забороняє контейнеру перевищувати це значення і не гарантує, що пам’ять буде вільною, коли контейнер її запросить. Він лише схиляє kernel спочатку звільняти пам’ять у контейнерів, які перевищили свою резервацію.
Отже, сама по собі резервація нічого не захищає. Використовуйте її, щоб позначити сервіс, до якого слід ставитися пріоритетно під час нестачі ресурсів, а для безпеки покладайтеся на ліміт. Резервація має бути меншою за ліміт, інакше контейнер не запуститься: Docker відхилить конфігурацію з Minimum memory limit can not be less than memory reservation limit.
Облік swap, без прикрас
У більшості образів VPS взагалі немає swap-файлу. Виконайте 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 kill, а лише сповільнює його, оскільки процес із витоком пам’яті так само швидко заповнює swap, як і RAM. Водночас контейнер, який активно використовує swap на спільному сховищі VPS, уповільнює всі інші сервіси на цьому сервері. Для сервісів, чутливих до затримки, коректно встановлений ліміт без swap дає змогу швидше й передбачуваніше завершити роботу в разі перевищення ліміту.
Чому використання пам’яті виглядає гіршим, ніж є насправді
У Linux показник MEM USAGE у docker stats віднімає лише неактивний page cache (inactive_file у cgroup v2), тому нещодавно використаний cache усе ще враховується. Контейнер, який читає великі файли, наближається до свого ліміту й залишається на цьому рівні. Це нормально і не є витоком пам’яті, оскільки clean cache звільняється до того, як буде викликано OOM killer. Сервіс на кшталт self-hosted медіасервера Jellyfin саме з цієї причини постійно виглядатиме близьким до граничного значення.
Розділіть цей показник на cache і фактичний working set усередині контейнера:
docker compose exec app grep -E '^(anon|file) ' /sys/fs/cgroup/memory.stat
docker compose exec app cat /sys/fs/cgroup/memory.eventsanon — це anonymous memory, тобто working set, який не можна вивільнити. file — це page cache, який можна вивільнити. Встановлюйте ліміт на основі anon із запасом, а не на основі загального обсягу. Файл memory.events остаточно прояснює ситуацію: значення лічильника oom_kill більше за нуль означає, що kernel знищив якийсь процес у цьому контейнері від моменту його запуску, а зростання лічильника max означає, що контейнер зараз утримується на своєму граничному значенні. Для обох команд потрібні shell і coreutils усередині image, тому вони не працюють у distroless або scratch image.
Обмеження ресурсів на VPS із 8GB
Починайте з хоста, а не із застосунків. На VPS із 8GB залиште приблизно 1GB для ядра, Docker daemon, sshd, journald і власної login shell. Залишається близько 7GB для розподілу, і сума лімітів усіх контейнерів має бути меншою за це значення. Надмірне виділення ресурсів працює доти, доки одного дня два сервіси не досягнуть пікового навантаження одночасно.
Практичний розподіл для сервера з 8GB:
- Reverse proxy: ліміт 128m. Це невеликий процес, і такий жорсткий ліміт одразу виявляє неконтрольоване перезавантаження конфігурації.
- PostgreSQL: ліміт 2g, а
shared_buffersу конфігурації бази даних встановіть приблизно на 512MB. - Application container: ліміт 1g.
- Background worker: ліміт 512m.
- Media or file service: ліміт 2g, більша частина якого припадатиме на page cache.
Не копіюйте ці значення у власний стек. Протягом доби запускайте сервіси під реальним навантаженням, стежте за docker stats, визначте пікове значення anon для кожного контейнера та додайте приблизно половину цього значення як запас. Надто малий ліміт гірший за відсутність ліміту, оскільки він завершує роботу справного сервісу під час звичайного стрибка мережевого трафіку.
Окремо зверніть увагу на одну типову проблему. Деякі runtime ігнорують ліміт, доки ви явно не повідомите їм про нього. Runtime, які читають ліміт, можуть визначати власні розміри не так, як ви очікуєте. PostgreSQL без проблем встановлює shared_buffers і work_mem вище за ліміт контейнера, після чого процес завершується. Сучасний JVM (Java virtual machine) за замовчуванням виявляє ліміт cgroup, але збірки HotSpot, зокрема OpenJDK, виділяють для heap лише 25 відсотків цього ліміту. Тому -XX:MaxRAMPercentage=75 зазвичай використовують, щоб дозволити heap використовувати більшу частину ліміту, залишивши пам’ять для даних поза heap. Починаючи з version 12, Node.js визначає default heap на основі ліміту cgroup: приблизно половина ліміту контейнера зі стабілізацією близько 2GB. Тому встановлюйте --max-old-space-size у мегабайтах лише тоді, коли потрібна інша верхня межа. Вона має бути нижчою за ліміт контейнера, щоб garbage collector встигав виконати роботу до втручання ядра. В Ollama діє та сама схема, але використовується інший параметр, оскільки збільшення num_ctx розширює KV cache на сотні мегабайтів, і контейнер завершується під час обробки довгого prompt. cgroup не веде переговорів. Вона завершує процес.
Обмеження CPU працюють зовсім інакше
cpus: "1.5" означає 150% одного ядра. Обмеження застосовується як квота CFS (completely fair scheduler). Контейнер отримує 150ms процесорного часу в кожному періоді тривалістю 100ms. Цей час розподіляється між усіма його потоками. Коли контейнер використовує квоту, kernel змушує його чекати до початку наступного періоду.
Це важлива відмінність. Якщо контейнер перевищує memory limit, його завершує kernel. Якщо контейнер перевищує CPU limit, його throttling обмежує, але він продовжує працювати повільніше. Тому CPU limit безпечно встановлювати жорстко, а memory limit потребує запасу.
cpu_shares — це інший інструмент: відносна вага, яка має значення лише тоді, коли CPU фактично повністю завантажені. Два контейнери зі значеннями shares 1024 і 512 розподілять завантажене ядро приблизно у співвідношенні два до одного. На сервері без навантаження жоден із них не буде обмежений. Використовуйте shares, щоб визначити пріоритет сервісів, а cpus — коли потрібна реальна верхня межа, наприклад щоб нічне завдання транскодування не позбавляло ваш web server процесорного часу.
FAQ
Чи працює deploy.resources.limits без Docker Swarm?
Так. Docker Compose, плагін docker compose (тепер гілка v5), застосовує deploy.resources.limits і deploy.resources.reservations, коли ви запускаєте docker compose up на одному хості. Перевірте це за допомогою docker inspect --format '{{.HostConfig.Memory}}' <container>. Команда виводить ліміт у байтах і показує 0, якщо ліміт не застосовано. Ключі всередині deploy, розроблені з урахуванням Swarm, належать до кластерних параметрів, зокрема placement, update_config і endpoint_mode.
Що означає код завершення 137 у Docker Compose?
Майже завжди це означає, що головний процес отримав SIGKILL, оскільки 137 — це 128 плюс сигнал 9. Програма також може навмисно завершитися з кодом 137, але це трапляється рідко. Найчастіша причина — kernel 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, якщо решта файлу вже використовує старі ключі верхнього рівня. Compose Specification вимагає узгодженості між цими параметрами, якщо задано обидва, тому виберіть один варіант і перевірте результат за допомогою docker inspect.
Чому мій контейнер використовує весь ліміт пам’яті, але не завершується?
Показник використання в docker stats також враховує нещодавно використаний page cache. Під тиском пам’яті kernel звільняє цей кеш, не запускаючи OOM kill. Виконайте docker compose exec <service> grep -E '^(anon|file) ' /sys/fs/cgroup/memory.stat і перегляньте значення anon. Це робочий набір пам’яті, який не можна звільнити. Високе значення file поруч із низьким значенням anon означає, що контейнер активно виконує disk input/output, а не те, що він ось-ось завершиться.
Скільки RAM слід залишити невиділеною на VPS із 8GB?
Залиште приблизно 1GB для kernel, Docker daemon, sshd, journald і власної shell-сесії. Сума лімітів усіх контейнерів не повинна перевищувати решту 7GB. Під реальним навантаженням протягом доби відстежуйте пікове значення anon для кожного контейнера, перш ніж остаточно визначити ліміти. Розглядайте загальний обсяг як бюджет, а не як ціль, яку потрібно повністю використати.