Как ограничить CPU и RAM для процесса через systemd
Настройте параметры MemoryMax, MemoryHigh и CPUQuota в systemd unit для защиты VPS. Узнайте, как избежать зависаний системы и правильно проверить работу cgroup v2 через systemctl.
Ограничение памяти и процессора для процесса через systemd drop-in
Вы можете ограничить потребление памяти и процессора процессом на Linux VPS, добавив несколько строк в юнит, который запускает этот процесс. MemoryMax= — это жесткий лимит памяти. CPUQuota= — это лимит процессорного времени. Оба параметра принудительно применяются через cgroup v2 (control groups, версия 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 принимает соединение, но приглашение командной строки не появляется. Машина работает и загружена, но эта работа бесполезна.
Вот механизм этого явления, так как он неочевиден. Когда свободной памяти становится мало, ядро начинает вытеснять страницы вместо выделения новых. Дешевле всего вытеснять страницы, привязанные к файлам, а в page cache находится исполняемый код всех запущенных программ. В результате ядро вытесняет страницы с кодом sshd, и следующая инструкция, которую выполняет sshd, вызывает page fault, требующий считывания этих байтов с диска. Каждый процесс в итоге ожидает диск, а не выполняется. Одни и те же страницы постоянно выгружаются и загружаются обратно — этот процесс называется thrashing.
Две причины делают ситуацию на VPS хуже, чем на обычном ноутбуке. Хранилище часто является сетевым или общим, поэтому каждый сбой (page fault) занимает больше миллисекунд, чем на локальном NVMe-накопителе. Кроме того, ядро не измеряет время, оно измеряет сбои: пока процесс вытеснения страниц возвращает хотя бы одну страницу, пусть и очень медленно, ядро считает, что прогресс есть, и не вызывает OOM (out of memory) killer. Сервер может находиться в таком состоянии много минут, прежде чем что-либо будет принудительно завершено.
Вы можете наблюдать за этим процессом. Ядро экспортирует информацию о задержках (pressure stall information, PSI) в Linux 4.20 и более новых версиях:
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% времени каждая готовая к выполнению задача на сервере была заблокирована в ожидании операций с памятью, поэтому полезная работа не выполнялась. У исправного сервера значения full близки к нулю. Значения выше 10 ощущаются пользователем как замедление, а 40 и более — это состояние, которое описывают как «зависание».
Именно поэтому лимит сам по себе не является гарантией. Юнит, удерживаемый в рамках MemoryHigh=, подвергается троттлингу вместо завершения, поэтому он остается активным, но работает медленно, и ничто его не перезапускает, так как с точки зрения systemd сбоя не было. Юнит с лимитом, которому разрешено использовать swap, генерирует операции чтения и записи, которые учитываются для этого юнита, но обслуживаются общим устройством, поэтому он может повысить /proc/pressure/io для всех остальных сервисов на сервере. Лимиты определяют, кто «платит» за нехватку ресурсов, но они не могут создать дополнительную емкость.
Проверка использования cgroup v2 на VPS
stat -fc %T /sys/fs/cgroupstat -fc %T /sys/fs/cgroup/
cgroup2fs — это унифицированная иерархия, необходимая для всех настроек, описанных ниже. tmpfs означает, что система загрузилась с использованием старой структуры v1, в которой MemoryHigh= и MemorySwapMax= отсутствуют, а поведение OOM для отдельных юнитов отличается. В Ubuntu 22.04 и более новых версиях, а также в Debian 11 и более новых, v2 используется по умолчанию. Старые образы или ядра, загруженные с параметром systemd.unified_cgroup_hierarchy=0, используют v1.
В cgroup v2 systemd включает учет памяти для каждого юнита по умолчанию, поэтому данные уже доступны:
systemd-cgtop -msystemd-cgtop
Эта команда выводит список cgroup, отсортированный по потреблению памяти. Это самый быстрый способ ответить на вопрос «что потребляет ресурсы сервера», пока система еще отвечает на запросы. Если сервер новый, сначала выполните настройку учетной записи и межсетевого экрана, описанную в первые десять минут работы на новом VPS.
MemoryHigh ограничивает, MemoryMax завершает процессы.
Разница между этими двумя настройками памяти определяет, как именно будет выглядеть сбой.
MemoryHigh=— это мягкое ограничение (soft cap). При его превышении ядро начинает агрессивно освобождать память из этой cgroup и намеренно замедляет выделение ресурсов. Использование памяти может превысить это значение, при этом процессы не завершаются.MemoryMax=— это жесткое ограничение (hard cap). Если запрос на выделение памяти не может быть удовлетворен в рамках этого лимита, OOM killer запускается внутри этой cgroup и завершает один из процессов данного юнита.
Вторая часть — основная причина устанавливать MemoryMax= для любого сомнительного ПО. Без ограничения нехватка памяти становится проблемой всей системы, и глобальный OOM killer выбирает жертву по oom_score, что чаще всего означает самый «тяжелый» процесс. Обычно это ваша база данных, а не скрипт с утечкой памяти. С ограничением завершение процесса происходит внутри того юнита, который вызвал проблему.
Устанавливайте оба параметра, при этом MemoryHigh= должен быть примерно на 20–30 процентов ниже MemoryMax=. Этот промежуток служит предупреждающей зоной: медленная утечка пересекает High, и сервис начинает работать медленно, в то время как резкий скачок потребления сразу достигает Max, что приводит к завершению процесса.
Процентные значения рассчитываются относительно установленной физической памяти, поэтому MemoryMax=25% на тарифе с 4 GB RAM составит 1 GB и останется четвертью объема системы даже после смены тарифа. MemorySwapMax=0 полностью запрещает юниту использовать swap, что превращает долгое «зависание» в быстрое и очевидное завершение процесса.
Некоторые сервисы позволяют заранее определить потребление ресурсов вместо его измерения: юнит Ollama рассчитывает размер KV-кэша на основе заданного вами контекстного окна, поэтому ознакомьтесь с тем, сколько RAM требует увеличение num_ctx перед тем, как устанавливать лимит для такого юнита.
Для ограничения необходимо настроить политику перезапуска, иначе после завершения процесса вы останетесь с остановленным сервисом.
[Unit]
StartLimitIntervalSec=300
StartLimitBurst=5
[Service]
Restart=on-failure
RestartSec=5sStartLimit* должен находиться в [Unit], а Restart= — в [Service]. Если поместить их не в ту секцию, systemd проигнорирует настройки. Пять перезапусков за пять минут указывают на утечку, а не на кратковременный сбой, поэтому после этого systemd прекращает попытки и оставляет юнит в состоянии failed. Это именно то состояние, которое вы хотите обнаружить позже, вместо бесконечного цикла перезапусков, скрывающего проблему.
Ограничение CPU с помощью CPUQuota или распределение через CPUWeight
CPUQuota= задает процент времени, доступного на одном ядре CPU. CPUQuota=50% — это половина одного ядра. CPUQuota=200% эквивалентно двум ядрам, которые юнит может распределять между любым количеством потоков по своему усмотрению. На тарифе с 2 vCPU значение CPUQuota=200% задействует всю мощность машины.
CPUWeight= — более предпочтительный вариант по умолчанию для большинства сервисов. Это относительная доля в диапазоне от 1 до 10000, значение по умолчанию в ядре — 100. Оно вступает в силу только при наличии конкуренции за ресурсы: задача резервного копирования с CPUWeight=20 уступит веб-серверу со значением 100 при высокой нагрузке, но при этом сможет использовать всю мощность сервера, пока он простаивает. Жесткая квота приводит к потере этой неиспользуемой емкости.
Трезво оценивайте пользу от ограничения CPU. Процесс, интенсивно использующий CPU, редко приводит к зависанию Linux, так как планировщик продолжает распределять время между всеми задачами. Именно нехватка памяти выводит сервер из строя. Используйте CPUQuota=, когда вам нужен предсказуемый потолок производительности, например, для процесса сборки или агента, который в противном случае будет работать на пределе возможностей в течение часа. Оценка размера такой нагрузки — отдельный вопрос, который рассматривается в сколько RAM и CPU нужно VPS для агента разработки.
Если CPU отображается как загруженный, хотя ваши процессы не выполняют активных задач, причина может находиться на стороне гипервизора. Это CPU steal time от шумного соседа, и никакие установленные вами квоты не смогут это изменить.
Параметр TasksMax предотвращает fork-бомбы
TasksMax= определяет количество процессов и потоков, которые может содержать юнит. Учитываются все потоки, поэтому сервисам на Java или Go требуется больший запас, чем может показаться при просмотре списка процессов. Это самый простой способ защиты от скрипта, который создает процессы в цикле: fork завершается ошибкой внутри юнита, а не исчерпанием идентификаторов процессов во всей системе.
TasksMax=128Когда юнит достигает лимита, ядро записывает в лог строку с указанием cgroup:
cgroup: fork rejected by pids controller in /system.slice/myapp.serviceСама программа обычно сообщает об ошибке fork: retry: Resource temporarily unavailable. Проверьте значения, применяемые менеджером по умолчанию, с помощью systemctl show -p DefaultTasksMax.
Ограничение разовой задачи с помощью systemd-run
Вам не нужно создавать файл юнита для использования этих возможностей. systemd-run создает временный юнит для выполнения одной команды.
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. Вывод остается на экране, а ограничения снимаются после завершения работы команды. Любое свойство из systemd.resource-control работает после -p.
Для длительной задачи уберите --scope и присвойте ей имя. Она будет выполняться в фоновом режиме как временный сервис с записью логов в journal:
sudo systemd-run --unit=nightly-import -p MemoryMax=1G -p CPUWeight=20 ./import-data.sh
journalctl -u nightly-import -fТе же параметры работают с --user, если вы не являетесь root, однако ваш пользовательский менеджер имеет только те контроллеры, которые были ему делегированы, поэтому свойство может быть отклонено. Если это произошло, запустите команду с sudo. Когда задача требует постоянного размещения, настройки можно перенести без изменений в настоящий юнит: см. запуск скрипта как сервиса и таймера systemd.
Честный ответ на вопрос о swap
Swap меняет характер сбоя, а не предотвращает его.
Без swap утечка памяти достигает предела, и процесс завершается за секунды. Авария происходит быстро, она заметна, и её легко проанализировать в журнале событий. При наличии swap ядро выгружает неиспользуемые анонимные страницы на диск, выигрывая время. Если потребление памяти процессом стабилизируется, swap вас спасет. Если же это бесконтрольная утечка, swap превращает пятисекундный сбой в двадцатиминутное «зависание». Зависание хуже: при упавшем процессе у вас остается доступ к работающей оболочке, а при «шторме» дисковых операций (thrashing) система перестает отвечать.
swapon --show
free -hРазумный компромисс для небольшого VPS: используйте небольшой swap-файл для страниц, которые выделяются один раз и больше не используются, и установите MemorySwapMax=0 для тех юнитов, которыми вы готовы пожертвовать. Важные сервисы сохраняют доступ к swap. Непредсказуемые процессы быстро упираются в лимит и перезапускаются.
Снижение vm.swappiness — неэффективный метод, и важно понимать почему. Этот параметр лишь меняет баланс между вытеснением page cache и выгрузкой анонимных страниц в swap, при этом оба действия в дальнейшем потребуют обращения к диску. Это меняет лишь то, какие именно страницы будут вызывать «шторм», но не предотвращает его возникновение.
Ранний OOM-демон завершает процессы до возникновения зависания
Ядро ожидает полного исчерпания ресурсов для освобождения памяти, и на небольшом VPS этот период ожидания — именно то время, когда вы теряете доступ к машине. Два демона пользовательского пространства закрывают эту проблему, самостоятельно отслеживая состояние памяти и завершая процессы раньше.
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 и информацию о задержках (PSI) для мониторинга и принятия корректирующих мер до того, как OOM произойдет на уровне ядра». Он воздействует на целые cgroups, а не на отдельные процессы, поэтому завершает юнит целиком, а не случайный дочерний процесс. Юниты подключаются к нему через ManagedOOMMemoryPressure=kill или ManagedOOMSwap=kill, а пороговые значения задаются в /etc/systemd/oomd.conf.
systemctl status systemd-oomd
oomctloomctl выводит список того, что отслеживается в данный момент; на серверных образах это часто ничего, так как настройка требует явного включения для каждого юнита. Выберите один демон и остановитесь на нем. Использование обоих означает, что два инструмента будут конкурировать в выборе «жертвы», и причину любого завершения процесса станет сложнее восстановить.
Какой юнит был виновником?
Начните с ядра, так как оно фиксирует каждое завершение процесса, которое инициирует.
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. Имя в скобках следует рассматривать как подозрительное. Это жертва, которую выбрало ядро, а ядро выбирает самый «тяжелый» процесс, что не всегда совпадает с процессом, вызвавшим нехватку памяти.
Завершение процесса из-за лимита 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 означает, что один юнит достиг установленного вами 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 подсчитывает, сколько раз юнит превышал MemoryHigh= и подвергался троттлингу. max подсчитывает, как часто он достигал жесткого лимита, а oom_kill — сколько процессов было фактически завершено. Большое значение high при oom_kill 0 — это тот самый «тихий» случай, описанный ранее: сервис работает, замедлился до минимума и никому не сообщил об ошибке. memory.peak (Linux 5.19 и новее) содержит максимальный объем использования, достигнутый cgroup; это число следует использовать для настройки MemoryMax=. Оба файла сбрасываются при перезапуске юнита, так как systemd создает cgroup заново.
В основе всего этого лежит одно предварительное условие. Если /var/log/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, показывающий больше, чем текущая загрузка, означает, что история теперь сохраняется, поэтому journalctl -k -b -1 может показать вам сообщения ядра из загрузки, которая завершилась сбоем.
Отправная точка для небольшого VPS
На тарифе с 2 GB оперативной памяти оставьте от 300 до 400 MB для ядра и страничного кэша. Не допускайте, чтобы сумма лимитов всех юнитов достигала полных 2 GB, так как потребление всех сервисов может достичь пика одновременно. Выделите основному сервису наибольшую долю, а для второстепенных задач установите жесткие ограничения.
[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-демон в качестве жертвы. Это разница между исправлением системы и её перезагрузкой через панель управления. Данная настройка лишь меняет выбор ядра при нехватке памяти, но не сокращает время зависания системы.
Контейнеры работают в собственных cgroups, которые создаются средой выполнения контейнеров, а не вашими unit-файлами, поэтому ограничение в docker.service не распространяется на отдельный контейнер. Аналоги параметров MemoryMax= и CPUQuota= для контейнеров описаны в настройке лимитов памяти и CPU в Docker Compose.
FAQ
Почему мой VPS завис, а не завершил процесс, потребляющий все ресурсы?
Потому что ядро оценивает прогресс по тому, возвращает ли подсистема очистки памяти страницы, а не по времени, которое на это уходит. При нехватке памяти ядро вытесняет page cache, включая исполняемые страницы запущенных программ, а затем считывает их обратно при выполнении следующей инструкции. Все процессы ожидают завершения операций с диском, и формально ни один запрос на выделение памяти не завершился ошибкой, поэтому OOM killer не активируется. Проверяйте /proc/pressure/memory в момент инцидента: значение full avg10 выше 40 означает, что почти ни одна задача не получила процессорное время за последние десять секунд. Пользовательский демон, такой как earlyoom, завершает процессы до того, как система перейдет в это состояние.
В чем разница между MemoryHigh и MemoryMax?
MemoryHigh= — это мягкий лимит, который включает троттлинг. Ядро активно очищает память юнита и замедляет выделение ресурсов, но использование может превысить это значение, и процессы не будут завершены. MemoryMax= — это жесткий лимит: если запрос на выделение памяти не может быть удовлетворен в рамках этого ограничения, внутри cgroup данного юнита вызывается OOM killer. В результате завершается именно тот процесс, который вызвал проблему, а не самый «тяжелый» процесс в системе. Устанавливайте MemoryHigh= ниже MemoryMax= и рассматривайте разницу между ними как зону предупреждения.
Как узнать, какой сервис был завершен OOM killer?
Выполните journalctl -k --grep "Killed process" --since "2 hours ago". Строка, начинающаяся с Memory cgroup out of memory, означает, что юнит достиг собственного лимита MemoryMax=, тогда как обычное сообщение Out of memory указывает на нехватку памяти во всей системе. Затем выполните journalctl -u <unit> -n 50 и найдите Failed with result 'oom-kill'. Если директория /var/log/journal на вашем сервере отсутствует, значит, журнал хранился в оперативной памяти и данные были утеряны при перезагрузке; создайте эту директорию до следующего инцидента.
Стоит ли добавлять swap на небольшой VPS?
Небольшой файл подкачки помогает выгрузить «холодные» страницы, которые были выделены один раз и больше не используются. Он не поможет при работе процесса, вышедшего из-под контроля: swap лишь отсрочит завершение процесса и заменит кратковременный сбой длительным зависанием, при котором вы не сможете даже войти в систему для исправления ситуации. Используйте swap умеренно и установите MemorySwapMax=0 для юнитов, которыми вы готовы пожертвовать: так они достигнут своего лимита и быстро перезапустятся, в то время как важные сервисы сохранят свой swap.
Можно ли ограничить команду без создания файла юнита?
Да. sudo systemd-run --scope -p MemoryMax=1G -p CPUQuota=50% ./script.sh запускает команду в вашем терминале внутри временного scope с заданными ограничениями, которые исчезают после завершения работы команды. Все свойства из systemd.resource-control доступны после -p, поэтому MemorySwapMax=, TasksMax= и CPUWeight= также работают в этом режиме. Уберите --scope и добавьте --unit=name, чтобы запустить задачу в фоновом режиме с выводом логов в journal.