Як обмежити пам’ять і CPU процесу в systemd
Налаштуйте MemoryHigh, MemoryMax, CPUQuota і TasksMax для unit systemd, щоб процес не зависав на VPS, та перевірте запис про OOM kill після завершення.
Обмеження пам’яті та CPU процесу за допомогою drop-in для systemd
Обмежити використання пам’яті та CPU процесом на Linux VPS можна, додавши кілька рядків до unit, який запускає цей процес. MemoryMax= — жорстке обмеження пам’яті. CPUQuota= — обмеження процесорного часу. Обидва обмеження застосовує cgroup v2 (control groups, version 2) — функція ядра, яку systemd уже використовує для обліку кожного сервісу на сервері.
sudo systemctl edit myapp.serviceЦя команда відкриває drop-in-файл із інструкціями в коментарях. Додайте наведені нижче рядки перед ними:
[Service]
MemoryHigh=512M
MemoryMax=768M
MemorySwapMax=0
CPUQuota=80%
TasksMax=128sudo systemctl daemon-reload
sudo systemctl restart myapp.service
systemctl show myapp.service -p MemoryHigh -p MemoryMax -p CPUQuotaPerSecUSec -p TasksMaxsystemctl show має вивести ваші значення в одиницях ядра: MemoryMax=805306368 і CPUQuotaPerSecUSec=800ms. Якщо команда виводить MemoryMax=infinity, drop-in не завантажено. Перевірте, що файл збережено за шляхом /etc/systemd/system/myapp.service.d/override.conf і що він починається із заголовка [Service]. Якщо рядок налаштування не має перед собою секції, systemd записує в журнал Assignment outside of section. Ignoring. і запускає сервіс без будь-яких обмежень.
У решті цього посібника описано, як вибрати ці значення та які проблеми все ще можуть виникати після їх встановлення.
Чому некерований процес зависає на VPS, навіть не заповнюючи його
Процес, який досягає жорсткого обмеження пам’яті, завершується приблизно за секунду, після чого сервіс перезапускається. Це сприятливий сценарій. Поганий сценарій — коли нічого не завершується: сервер відповідає на ping, SSH приймає підключення, але запрошення shell так і не з’являється. Машина працює, але зайнята, і жодна з цих операцій не дає корисного результату.
Ось як це працює. Коли вільної пам’яті стає мало, kernel звільняє сторінки замість виділення нових. Найдешевше звільняти сторінки, пов’язані з файлами, а page cache містить виконуваний код усіх запущених процесів. Тому kernel витісняє сторінки коду sshd, і наступна інструкція, яку виконує sshd, спричиняє page fault. Щоб продовжити роботу, потрібно знову прочитати ці байти зі сховища. У результаті кожен процес чекає на диск замість виконання. Ті самі сторінки постійно витісняються та завантажуються назад. Цей стан називається thrashing.
На VPS це має гірші наслідки, ніж на ноутбуці, з двох причин. Сховище часто підключене через мережу або спільно використовується, тому кожен page fault додає більше мілісекунд, ніж локальний NVMe-пристрій. Крім того, kernel вимірює не час, а помилки: доки reclaim продовжує повертати сторінки, хоч би й повільно, kernel вважає, що робота триває, і не запускає out of memory (OOM) killer. Сервер може перебувати в такому стані багато хвилин, перш ніж буде завершено якийсь процес.
Цей стан можна спостерігати. Linux 4.20 і новіші версії експортують pressure stall information (PSI):
cat /proc/pressure/memory
cat /proc/pressure/iosome avg10=63.72 avg60=41.02 avg300=12.33 total=13729481
full avg10=48.15 avg60=30.44 avg300=8.90 total=9114233Важливим є рядок full. Значення full avg10=48.15 означає, що протягом останніх десяти секунд у 48% часу всі runnable tasks на сервері були заблоковані в очікуванні операцій із пам’яттю, тому жодне завдання не виконувалося. На справному сервері значення full близьке до нуля. Значення понад 10 відчувається як повільна робота, а 40 або більше — це стан, який зазвичай описують як зависання.
Саме тому саме обмеження не є гарантією. Unit, для якого встановлено MemoryHigh=, throttled замість завершення. Він залишається запущеним і повільним, а systemd його не перезапускає, оскільки з його погляду unit не завершився з помилкою. Якщо для unit із таким обмеженням дозволено swap, він генерує операції читання та запису. Ці операції обліковуються для цього unit, але обслуговуються одним спільним пристроєм, тому можуть підвищити /proc/pressure/io для всіх інших сервісів на сервері. Обмеження визначають, хто несе витрати через нестачу ресурсів, але не створюють додаткової ємності.
Перевірте, що ваш VPS працює з cgroup v2
stat -fc %T /sys/fs/cgroupcgroup2fs — це уніфікована ієрархія, яка потрібна для всіх наведених нижче параметрів. tmpfs означає, що система завантажила старішу структуру v1, у якій MemoryHigh= і MemorySwapMax= не існують, а поведінка OOM для окремих unit відрізняється. Ubuntu 22.04 і новіші версії, а також Debian 11 і новіші версії використовують v2 за замовчуванням. Старий образ або ядро, завантажене з параметром systemd.unified_cgroup_hierarchy=0, не використовує v2.
У системах із cgroup v2 systemd за замовчуванням вмикає облік пам’яті для кожного unit, тому потрібні значення вже доступні:
systemd-cgtop -mЦя команда виводить cgroup, відсортовані за використанням пам’яті. Це найшвидший спосіб з’ясувати, який процес споживає ресурси сервера, поки сервер ще відповідає. Якщо сервер новий, спочатку виконайте дії з обліковим записом і firewall, описані в розділі перші десять хвилин на новому VPS.
MemoryHigh обмежує навантаження. MemoryMax завершує процеси.
Різниця між цими двома параметрами пам’яті визначає наслідки збою.
MemoryHigh=— це м’яке обмеження. Після його досягнення ядро агресивно звільняє пам’ять цієї cgroup і навмисно сповільнює подальше виділення пам’яті. Використання пам’яті все одно може перевищити це значення, але процеси не завершуються.MemoryMax=— це жорстке обмеження. Якщо запит на виділення пам’яті не можна виконати в межах цього обмеження, OOM killer запускається всередині цієї cgroup і завершує один із власних процесів цього unit.
Саме тому для всього, чому ви не довіряєте повністю, потрібно встановлювати MemoryMax=. Без обмеження нестача пам’яті стає проблемою всього сервера, а глобальний OOM killer вибирає процес за oom_score, що здебільшого означає найбільший процес. Найбільшим процесом зазвичай є база даних, а не скрипт, який спричинив витік. Якщо встановити обмеження, завершення процесу відбувається всередині unit, який спричинив проблему.
Встановіть обидва параметри, задавши MemoryHigh= приблизно на 20–30 відсотків нижче за MemoryMax=. Різниця між ними є зоною попередження: повільний витік перевищує High і проявляється як уповільнення сервісу, а раптовий сплеск одразу перевищує Max, після чого процес завершується.
Відсоткові значення розраховуються від обсягу встановленої фізичної пам’яті, тому MemoryMax=25% у тарифі на 4 GB становить 1 GB і після збільшення тарифу все одно залишає за unit чверть пам’яті сервера. MemorySwapMax=0 повністю забороняє цьому unit використовувати swap, перетворюючи тривале уповільнення на швидке й очевидне завершення процесу.
Деякі сервіси дають змогу заздалегідь визначити обсяг пам’яті, який вони використовуватимуть, замість вимірювання фактичного споживання. Unit Ollama розраховує KV cache на основі заданого вами розміру контексту, тому перед вибором обмеження для нього прочитайте скільки RAM потребує збільшення num_ctx.
Поруч з обмеженням потрібно налаштувати політику перезапуску. Інакше після завершення процесу сервіс просто залишиться зупиненим.
[Unit]
StartLimitIntervalSec=300
StartLimitBurst=5
[Service]
Restart=on-failure
RestartSec=5sStartLimit* потрібно вказувати в [Unit], а Restart= — у [Service]. Якщо вказати будь-який із них не в тому розділі, systemd його проігнорує. П’ять перезапусків за п’ять хвилин свідчать про витік, а не про короткочасний збій. Після цього systemd припиняє спроби й залишає unit у стані failed. Саме цей стан ви зможете знайти пізніше, на відміну від циклу аварійних перезапусків, який приховує проблему.
Обмежуйте CPU за допомогою CPUQuota або розподіляйте процесорний час за допомогою CPUWeight
CPUQuota= використовує певний відсоток часу, доступного на одному CPU. CPUQuota=50% — це половина одного ядра. CPUQuota=200% еквівалентне двом ядрам, між якими unit може розподіляти навантаження довільно. У тарифному плані з 2 vCPU CPUQuota=200% — це весь доступний сервер.
CPUWeight= зазвичай є кращим значенням за замовчуванням для більшості сервісів. Це відносна частка від 1 до 10000, а значення ядра за замовчуванням — 100. Вона впливає лише тоді, коли процеси конкурують за CPU: за навантаження завдання резервного копіювання з CPUWeight=20 поступається вебсерверу зі значенням 100, але під час простою сервера все одно може використовувати весь доступний CPU. Жорстке обмеження в такому разі марнує вільну потужність.
Реалістично оцінюйте, що дає обмеження CPU. Процес, обмежений продуктивністю CPU, рідко блокує Linux, оскільки планувальник продовжує надавати процесорний час усім процесам. Сервер найчастіше виводить із ладу нестача пам’яті. Використовуйте CPUQuota=, якщо потрібна передбачувана верхня межа, наприклад для збірки або агента, який інакше працював би на повній потужності протягом години. Вибір розміру для такого навантаження — окреме питання, розглянуте в матеріалі скільки RAM і CPU потрібно VPS для coding agent.
Якщо CPU показує високе навантаження, хоча жоден із ваших процесів майже не використовує ресурси, причина може бути на іншому боці hypervisor. Це час CPU steal через шумного сусіда, і жодне встановлене вами обмеження не змінить ситуацію.
TasksMax зупиняє цикл fork
TasksMax= — це максимальна кількість процесів і потоків, які може містити unit. Потоки також враховуються, тому сервіс Java або Go потребує більшого запасу, ніж випливає зі списку процесів. Це найпростіший захист від скрипта, який виконує fork у циклі, оскільки fork завершується помилкою всередині unit, а не призводить до вичерпання ідентифікаторів процесів на сервері.
TasksMax=128Коли unit досягає ліміту, ядро записує в журнал рядок із назвою cgroup:
cgroup: fork rejected by pids controller in /system.slice/myapp.serviceСама програма зазвичай повідомляє fork: retry: Resource temporarily unavailable. Перевірте значення, яке manager застосовує за замовчуванням, за допомогою systemctl show -p DefaultTasksMax.
Обмеження одноразового завдання за допомогою systemd-run
Для цього не потрібен unit-файл. systemd-run створює тимчасовий unit для однієї команди.
sudo systemd-run --scope -p MemoryMax=1G -p MemorySwapMax=0 -p CPUQuota=50% -p TasksMax=64 ./import-data.sh--scope запускає команду у вашому терміналі після виведення Running scope as unit: run-r7c1a....scope. Вивід залишається на екрані, а обмеження зникають після завершення команди. Після -p працює будь-яка властивість із systemd.resource-control.
Для тривалого завдання приберіть --scope і вкажіть йому ім’я. Тоді воно працюватиме у фоновому режимі як тимчасовий сервіс, а журнали записуватимуться до journal:
sudo systemd-run --unit=nightly-import -p MemoryMax=1G -p CPUWeight=20 ./import-data.sh
journalctl -u nightly-import -fТі самі параметри працюють із --user, якщо ви не root. Однак user manager має лише ті контролери, які йому делеговано, тому властивість може бути відхилена. Якщо це станеться, запустіть команду з sudo. Коли завдання отримує постійний unit, ці параметри без змін переносяться до нього: див. запуск скрипту як systemd-сервісу та таймера.
Чесна відповідь на запитання про swap
Swap змінює перебіг збою, а не запобігає йому.
Без swap витік пам’яті досягає ліміту, і за кілька секунд щось завершує роботу. Збій гучний, короткий і його легко проаналізувати в журналі згодом. Із swap kernel записує неактивні анонімні сторінки на диск і виграє час. Якщо процес мав стабілізуватися, swap рятує ситуацію. Якщо процес працює безконтрольно, swap перетворює п’ятисекундний збій на двадцятихвилинне зависання. Це гірше, оскільки після завершення процесу у вас залишається робоча shell, а система, що безперервно переміщує сторінки між RAM і диском, — ні.
swapon --show
free -hПрактичний варіант для невеликого VPS: залиште помірний swap file для сторінок, які виділяються один раз і більше не використовуються, а для unit, якими ви готові пожертвувати, задайте MemorySwapMax=0. Важливі сервіси зберігають доступ до swap. Непередбачувані процеси швидко досягають ліміту та перезапускаються.
Зменшення vm.swappiness — слабкий інструмент, і важливо розуміти чому. Воно лише зміщує баланс між видаленням page cache і переміщенням анонімних сторінок у swap, а обидві операції згодом потребують читання з диска. Воно змінює сторінки, які спричиняють thrashing, але не визначає, чи буде система thrashing.
Ранній OOM-демон завершує процеси до зависання
Ядро чекає, доки відновлення пам’яті повністю не завершиться невдачею. На невеликому VPS саме під час цього очікування сервер може стати недоступним. Два userspace-демони контролюють пам’ять самостійно та завершують процеси раніше.
earlyoom контролює доступну пам’ять і вільний swap та завершує процес із найвищим балом, коли будь-який із показників опускається нижче порогового значення.
sudo apt install earlyoom
systemctl status earlyoomПакет для Debian та Ubuntu запускає сервіс під час встановлення. Його параметри зберігаються у /etc/default/earlyoom:
EARLYOOM_ARGS="-m 5,2 -s 5,2 --avoid '^(sshd|systemd)$' --prefer '^(node|python3)$'"-m PERCENT задає мінімальний обсяг доступної пам’яті, а -s PERCENT — мінімальний обсяг вільного swap; за замовчуванням обидва значення становлять 10 відсотків. Друге число в кожній парі задає поріг SIGKILL: earlyoom надсилає SIGTERM, коли показник опускається нижче першого значення, а нижче другого — SIGKILL. Друге значення за замовчуванням становить половину першого. Застосуйте зміни за допомогою sudo systemctl restart earlyoom. Перегляньте journalctl -u earlyoom, щоб дізнатися, який процес було завершено та скільки пам’яті він утримував.
systemd-oomd — інший варіант. На його сторінці довідки зазначено: "системний сервіс, який використовує cgroups-v2 і pressure stall information (PSI), щоб контролювати стан системи та вживати коригувальних заходів до виникнення OOM у просторі ядра". Він працює з цілими cgroups, а не з окремими процесами, тому завершує unit, а не випадковий дочірній процес. Unit може явно підключитися за допомогою ManagedOOMMemoryPressure=kill або ManagedOOMSwap=kill, а пороги зберігаються у /etc/systemd/oomd.conf.
systemctl status systemd-oomd
oomctloomctl виводить список об’єктів, які він наразі контролює. На серверному образі цей список часто порожній, оскільки параметр потрібно вмикати окремо для кожного unit. Виберіть один демон і використовуйте лише його. Одночасний запуск обох демонов означає, що вони незалежно змагатимуться за вибір процесу для завершення, а причину завершення буде складніше встановити.
Який unit відповідав за це?
Почніть із kernel, оскільки він записує кожне виконане ним завершення процесу.
journalctl -k --grep "Killed process" --since "2 hours ago"Завершення процесу глобальним OOM killer має такий вигляд:
Out of memory: Killed process 4127 (node) total-vm:2731084kB, anon-rss:1874232kB, file-rss:0kB, shmem-rss:0kB, UID:1000 pgtables:4212kB oom_score_adj:0anon-rss — це обсяг пам’яті, який процес утримував у RAM на момент завершення. У цьому випадку — близько 1.8 GB. Читайте ім’я в дужках критично. Це процес, який kernel обрав як жертву. Kernel обирає найбільший процес, але він не завжди є причиною нестачі пам’яті.
Завершення процесу через ліміт cgroup має інший префікс. Звіт над ним містить назву cgroup, яка досягла власної межі:
Memory cgroup out of memory: Killed process 8811 (python3) total-vm:1044320kB, anon-rss:769112kB, file-rss:0kB, shmem-rss:0kB, UID:998 pgtables:1720kB oom_score_adj:0Цей префікс містить більшу частину діагностики. Memory cgroup out of memory означає, що один unit досяг заданого вами MemoryMax=, а решта системи працювала нормально. Звичайний Out of memory означає, що пам’ять закінчилася на всій машині. Отже, обмеження або не задані, або разом встановлені надто високими.
Потім перевірте, що побачив systemd:
systemctl status myapp.service
journalctl -u myapp.service -n 50myapp.service: A process of this unit has been killed by the OOM killer.
myapp.service: Main process exited, code=killed, status=9/KILL
myapp.service: Failed with result 'oom-kill'.systemctl status повідомляє те саме одним рядком, як Active: failed (Result: oom-kill).
Лічильники cgroup — це третє джерело інформації. Лише вони фіксують throttling, який взагалі не створює рядка в журналі:
cat /sys/fs/cgroup/system.slice/myapp.service/memory.events
cat /sys/fs/cgroup/system.slice/myapp.service/memory.peaklow 0
high 4213
max 118
oom 12
oom_kill 12high рахує, скільки разів unit перевищував MemoryHigh= і був обмежений. max рахує, скільки разів unit досягав жорсткої межі, а oom_kill — кількість фактично завершених процесів. Велике значення high разом із oom_kill 0 — це описаний раніше непомітний випадок: сервіс працює, але дуже сповільнений і не повідомив про збій. memory.peak (Linux 5.19 і новіші версії) містить максимальне використання, якого досягла cgroup. Саме на це значення потрібно орієнтуватися під час визначення MemoryMax=. Обидва файли скидаються після перезапуску unit, оскільки systemd створює cgroup знову.
Для всього цього потрібна одна базова умова. Якщо /var/log/journal не існує, journal зберігається в RAM, і після перезавантаження, потрібного для відновлення системи, кожен його рядок буде втрачено.
sudo mkdir -p /var/log/journal
sudo systemd-tmpfiles --create --prefix /var/log/journal
sudo systemctl restart systemd-journald
journalctl --list-bootsjournalctl --list-boots зі значенням, більшим за поточний boot, означає, що історія тепер зберігається. Тому journalctl -k -b -1 може показати повідомлення kernel із boot, під час якого сталася помилка.
Початкова конфігурація для невеликого VPS
На тарифі з 2 GB залиште 300–400 MB для ядра та page cache. Не встановлюйте обмеження так, щоб їхня сума дорівнювала повним 2 GB, оскільки кожен unit може досягти пікового споживання одночасно. Сервісу, який має найбільше значення, виділіть найбільшу частку. Для всіх інших сервісів встановіть обмеження із запасом.
[Service]
MemoryHigh=256M
MemoryMax=384M
MemorySwapMax=0
CPUWeight=20
TasksMax=64
Restart=on-failure
RestartSec=5sМожливість підключитися до сервера виправдовує ще одне налаштування. OOMScoreAdjust=-500 у drop-in для ssh.service значно зменшує ймовірність того, що глобальний OOM killer вибере ваш SSH daemon як процес для завершення. Це може бути різницею між виправленням сервера та його перезавантаженням через control panel. Це налаштування змінює лише вибір процесу ядром. Воно не скорочує тривалість зависання.
Контейнери працюють у власних cgroups. Їх створює container runtime, а не ваші unit files. Тому обмеження для docker.service не стає обмеженням для окремого контейнера. Еквіваленти MemoryMax= і CPUQuota= для окремих контейнерів описано в розділі налаштування обмежень пам’яті та CPU у Docker Compose.
FAQ
Чому мій VPS завис, замість того щоб завершити процес, який споживав надмірно багато ресурсів?
Тому що ядро оцінює прогрес за тим, чи повертає reclaim сторінки, а не за тривалістю цієї операції. Коли пам’яті недостатньо, воно витісняє page cache, зокрема сторінки виконуваного коду запущених програм, а потім зчитує їх назад під час виконання наступної інструкції. Усі операції очікують на сховище, але жоден запит на виділення пам’яті формально не завершився помилкою, тому OOM killer не запускається. Перевірте /proc/pressure/memory, поки це відбувається: значення full avg10 понад 40 означає, що за останні десять секунд майже жодне завдання не отримало часу CPU. Userspace daemon, наприклад earlyoom, завершує процеси до того, як система досягне такого стану.
У чому різниця між MemoryHigh і MemoryMax?
MemoryHigh= — це м’яке обмеження, яке знижує швидкість роботи. Ядро активно звільняє пам’ять, виділену unit, і сповільнює нові операції виділення, але споживання може перевищити це значення, і жоден процес не завершується. MemoryMax= — це жорстке обмеження: якщо операцію виділення неможливо виконати в його межах, OOM killer запускається всередині власної cgroup цього unit. У результаті завершується процес, який спричинив проблему, а не найбільший процес у системі. Установіть MemoryHigh= нижче за MemoryMax= і використовуйте проміжок між ними як зону попередження.
Як визначити, який сервіс завершив OOM killer?
Виконайте journalctl -k --grep "Killed process" --since "2 hours ago". Рядок, що починається з Memory cgroup out of memory, означає, що один unit досяг власного MemoryMax=, а звичайний Out of memory означає, що пам’ять закінчилася в усій системі. Потім виконайте journalctl -u <unit> -n 50 і знайдіть Failed with result 'oom-kill'. Якщо /var/log/journal відсутній на сервері, journal зберігався в RAM, тому дані було втрачено під час перезавантаження. Створіть цей каталог до наступного інциденту.
Чи потрібно додавати swap до невеликого VPS?
Невеликий swap-файл допомагає для неактивних сторінок, які виділяються один раз і більше не використовуються. Для процесу, який споживає надмірно багато ресурсів, swap не допомагає: він лише відкладає завершення процесу та перетворює короткочасний збій на тривале зависання, під час якого ви не зможете увійти в систему для виправлення проблеми. Використовуйте помірний обсяг swap і встановіть MemorySwapMax=0 для unit, роботу яких можна перервати. Тоді вони швидко досягатимуть свого обмеження й перезапускатимуться, а важливі сервіси й надалі зможуть використовувати свій swap.
Чи можна обмежити команду без написання unit-файлу?
Так. sudo systemd-run --scope -p MemoryMax=1G -p CPUQuota=50% ./script.sh запускає команду у вашому терміналі всередині transient scope із цими обмеженнями, а після завершення команди обмеження зникають. Усі властивості з systemd.resource-control доступні після -p, тому там також працюють MemorySwapMax=, TasksMax= і CPUWeight=. Приберіть --scope і додайте --unit=name, щоб запустити завдання у фоновому режимі та записувати його вивід у journal.