Docker Compose: обмеження пам’яті без помилки OOM
Дізнайтеся, як налаштувати memory і CPU для Docker Compose, уникнути OOM на VPS, розібратися з кодом 137 та перевірити swap і потрібний обсяг RAM.
Що робить обмеження пам’яті 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 file. 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, файл буде складно читати. Замість того щоб припускати, яке значення застосувалося, запитайте 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. Якщо звільненого місця недостатньо, OOM (out of memory) killer cgroup вибирає процес усередині контейнера й надсилає йому 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 надіслав інший компонент. Зазвичай причина в тому, що docker compose stop досяг свого десятисекундного періоду очікування, оскільки застосунок проігнорував SIGTERM. Це розрізнення заощаджує години діагностики, адже ці дві проблеми не пов’язані між собою.
Подію також записують у двох інших місцях. Спочатку перегляньте журнал daemon у реальному часі:
docker events --filter event=oomПотім прочитайте журнал ядра. Це запис, який зберігається після перезапуску:
sudo dmesg -T | grep -i -E 'memory cgroup out of memory|killed process'Завершення cgroup через OOM виводить рядок, що починається з Memory cgroup out of memory: Killed process 24713 (node). Рядок без префікса Memory cgroup означає OOM на host, тобто на машині закінчилася RAM. Саме цього мають не допускати ліміти. Якщо ви бачите такий рядок, сума встановлених лімітів зависока або деякі сервіси взагалі не мають лімітів.
З restart: unless-stopped цикл OOM легко не помітити, оскільки через секунду після завершення сервіс знову виглядає активним у docker compose ps. Перевірте стовпець uptime і лічильник перезапусків. Також поєднайте ліміт із healthcheck, який повідомляє, що застосунок має статус unhealthy, щоб контейнер, який постійно завершує роботу, було видно без постійного спостереження за ним.
Резервація — це орієнтир, а ліміт — правило
reservations.memory (старіший параметр mem_reservation) — це м’яка нижня межа. Docker описує її як м’який ліміт, який активується, коли daemon виявляє дефіцит ресурсів або низький рівень пам’яті на host. Цей параметр не забороняє контейнеру перевищувати його і не гарантує, що пам’ять буде вільною, коли контейнер її запитає. Він лише схиляє kernel спочатку звільняти пам’ять із контейнерів, які перевищили свою reservation.
Отже, сама reservation нічого не захищає. Використовуйте її, щоб позначити сервіс, до якого потрібно ставитися пріоритетно під час дефіциту ресурсів, а для безпеки покладайтеся на limit. Значення reservation має бути меншим за limit, інакше контейнер не запуститься: 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 завершує роботу швидше та передбачуваніше.
Чому використання пам’яті виглядає гіршим, ніж є насправді
Показник MEM USAGE у docker stats містить кеш сторінок, тому контейнер, який читає великі файли, наближається до свого ліміту й залишається на цьому рівні. Це нормально, а не витік пам’яті, оскільки чистий кеш вивільняється ще до виклику OOM killer. Сервіс на кшталт self-hosted медіасервера 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, більше за нуль, означає, що kernel знищив щось у цьому контейнері після його запуску, а зростання лічильника max означає, що контейнер зараз утримується на своєму ліміті. Для обох команд потрібні shell і coreutils усередині image, тому вони не працюють в image distroless або scratch.
Обмеження ресурсів на VPS із 8GB
Починайте з хоста, а не із застосунків. На VPS із 8GB залиште приблизно 1GB для ядра, Docker daemon, sshd, journald і власної оболонки входу. Залишиться близько 7GB для розподілу, і сума лімітів усіх контейнерів має бути меншою за це значення. Надмірне виділення ресурсів працює доти, доки два сервіси одночасно не досягнуть пікових значень.
Практичний розподіл для сервера з 8GB:
- Reverse proxy: ліміт 128m. Це невеликий процес, і такий жорсткий ліміт одразу виявляє неконтрольоване перезавантаження конфігурації.
- PostgreSQL: ліміт 2g, а
shared_buffersу конфігурації бази даних встановіть приблизно на 512MB. - Application container: ліміт 1g.
- Background worker: ліміт 512m.
- Media або file service: ліміт 2g, більшу частину якого займе page cache.
Не копіюйте ці значення у власний стек. Протягом дня запускайте сервіси під реальним навантаженням, моніторте docker stats, визначте пікове значення anon для кожного контейнера та додайте приблизно половину цього значення як запас. Надто жорсткий ліміт гірший за відсутність ліміту, оскільки під час звичайного стрибка мережевого трафіку він зупиняє справний сервіс.
Окремо варто згадати одну проблему. Більшість runtime не враховує ліміт, доки ви явно не повідомите їм про нього. PostgreSQL без проблем збільшить shared_buffers і work_mem понад ліміт контейнера, після чого процес буде зупинено. JVM (Java virtual machine) потребує -XX:MaxRAMPercentage=75, щоб визначати розмір heap за лімітом cgroup, а не за обсягом RAM хоста. Node.js потребує --max-old-space-size у мегабайтах. Встановіть його нижче за ліміт контейнера, інакше його garbage collector збільшуватиме heap, доки не втрутиться ядро. В Ollama ситуація така сама, але використовується інший параметр: збільшення num_ctx збільшує KV cache на сотні мегабайтів, і контейнер зупиняється посеред обробки довгого prompt. cgroup не домовляється. Він зупиняє процес.
Обмеження CPU працюють зовсім інакше
cpus: "1.5" означає 150% одного ядра, обмежених квотою CFS (completely fair scheduler). Контейнер отримує 150ms процесорного часу в кожному 100ms періоді. Цей час розподіляється між усіма його потоками. Коли квоту вичерпано, kernel змушує контейнер чекати на початок наступного періоду.
У цьому полягає ключова відмінність. Контейнер, який перевищує ліміт пам’яті, завершується примусово. Контейнер, який перевищує ліміт CPU, throttling-иться й продовжує працювати повільніше. Тому ліміт CPU можна встановлювати із запасом у бік агресивніших значень, а для ліміту пам’яті потрібен headroom.
cpu_shares — це інший інструмент: відносна weight, яка має значення лише тоді, коли CPU фактично перевантажені. Два контейнери зі значеннями shares 1024 і 512 розподіляють завантажене ядро приблизно у співвідношенні два до одного. На idle-сервері жоден із них не має обмежень. Використовуйте shares, щоб визначити пріоритет сервісів, а cpus — коли потрібна реальна верхня межа, наприклад щоб nightly transcode job не позбавляла web server процесорного часу.
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 Specification і кращий варіант за замовчуванням для нового файлу. Залиште mem_limit, якщо в решті файлу вже використовуються старі ключі верхнього рівня. Встановлення обох параметрів для одного сервісу лише ускладнює читання файлу, тому виберіть один варіант і перевірте результат за допомогою docker inspect.
Чому мій контейнер використовує весь ліміт пам’яті, але не завершується?
Показник використання в docker stats містить page cache. За нестачі ресурсів ядро звільняє цей кеш, а не запускає OOM kill. Запустіть docker compose exec <service> grep -E '^(anon|file) ' /sys/fs/cgroup/memory.stat і перегляньте значення anon. Це робочий набір даних, який неможливо звільнити. Високе значення file разом із низьким значенням anon означає, що контейнер виконує дискові операції введення-виведення, а не перебуває на межі завершення.
Скільки RAM слід залишити нерозподіленою на VPS із 8GB?
Залиште близько 1GB для ядра, Docker daemon, sshd, journald і власної shell-сесії. Потім підтримуйте суму лімітів усіх контейнерів нижче за решту 7GB. Протягом доби під реальним навантаженням відстежуйте пікове значення anon для кожного контейнера, перш ніж остаточно визначити ліміти. Розглядайте загальний обсяг як бюджет, а не як ціль, яку потрібно повністю використати.