Как ограничить ресурсы CPU и RAM для процесса в systemd
Узнайте, как настроить MemoryMax, CPUQuota и TasksMax в systemd для контроля потребления ресурсов. Разбираем, почему сервис может игнорировать лимиты и вызывать ошибку Unit not found.
Ограничение памяти и процессора для процесса через 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 хуже, чем на обычном ноутбуке. Хранилище часто является сетевым или общим, поэтому каждый сбой (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 для всех остальных сервисов на сервере. Лимиты определяют, кто «платит» за нехватку ресурсов, но они не могут создать дополнительную емкость.
Проверка работы VPS с использованием cgroup v2
stat -fc %T /sys/fs/cgroupcgroup2fs — это унифицированная иерархия, необходимая для всех настроек, описанных ниже. tmpfs означает, что система загрузилась с использованием старой структуры v1, в которой MemoryHigh= и MemorySwapMax= отсутствуют, а поведение OOM для отдельных юнитов отличается. В Ubuntu 22.04 и более поздних версиях, а также в Debian 11 и более поздних версиях, v2 используется по умолчанию. Старые образы или ядра, загруженные с параметром systemd.unified_cgroup_hierarchy=0, используют v1.
В cgroup v2 systemd включает учет памяти для каждого юнита по умолчанию, поэтому данные уже доступны:
systemd-cgtop -mЭта команда выводит список cgroups, отсортированный по потреблению памяти. Это самый быстрый способ ответить на вопрос «что потребляет ресурсы сервера», пока система еще отвечает на запросы. Если сервер новый, сначала выполните настройку учетной записи и межсетевого экрана, описанную в первые десять минут на новом VPS.
MemoryHigh ограничивает производительность, MemoryMax завершает процессы.
Разница между этими двумя настройками памяти определяет характер сбоя.
MemoryHigh=— это мягкий лимит. При его превышении ядро начинает агрессивно освобождать память из этой cgroup и намеренно замедляет выделение ресурсов. Использование памяти может превысить это значение, при этом процессы не завершаются.MemoryMax=— это жесткий лимит. Если запрос на выделение памяти не может быть удовлетворен в рамках этого лимита, внутри cgroup срабатывает OOM killer, который завершает один из процессов этого юнита.
Вторая часть — главная причина использовать MemoryMax= для любого сомнительного ПО. Без лимита нехватка памяти становится проблемой всей системы, и глобальный OOM killer выбирает жертву по oom_score, что чаще всего означает самый «тяжелый» процесс. Обычно это база данных, а не скрипт с утечкой памяти. При наличии лимита завершение процесса происходит внутри того юнита, который вызвал проблему.
Устанавливайте оба параметра, при этом MemoryHigh= должен быть на 20–30 процентов ниже MemoryMax=. Этот разрыв служит предупреждающей зоной: медленная утечка пересекает High, и сервис начинает работать медленно, а резкий скачок потребления проходит через Max, что приводит к немедленному завершению процесса.
Процентные значения рассчитываются от общего объема физической памяти. Таким образом, MemoryMax=25% на сервере с 4 GB RAM составляет 1 GB и будет сохранять эту пропорцию даже после изменения тарифного плана. MemorySwapMax=0 полностью запрещает юниту использовать swap, что превращает долгое «зависание» в быстрое и очевидное завершение процесса.
Лимит требует настройки политики перезапуска, иначе после завершения процесса сервис просто останется в остановленном состоянии.
[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 предотвращает циклическое создание процессов
TasksMax= — это количество процессов и потоков, которое может содержать юнит. Потоки также учитываются, поэтому сервисам на Java или Go требуется больший запас, чем может показаться при просмотре списка процессов. Это наиболее экономичный способ защиты от скрипта, который создает процессы в цикле, так как попытка fork завершается ошибкой внутри юнита, а не исчерпанием идентификаторов процессов (PID) во всей системе.
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 daemon в качестве жертвы. Это разница между исправлением системы и её перезагрузкой через панель управления. Данная настройка лишь меняет выбор ядра, но не сокращает время зависания системы.
Контейнеры работают в собственных 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= — это жесткий лимит: запрос на выделение памяти, который невозможно удовлетворить в рамках этого ограничения, вызывает OOM killer внутри cgroup этого юнита. В результате завершается процесс, вызвавший проблему, а не самый «тяжелый» процесс в системе. Устанавливайте 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 и установите 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, чтобы запустить задачу в фоновом режиме с выводом логов в журнал.