Введение: зачем ограничивать ресурсы сервисов в systemd
Лимит на сервис задаётся тремя строками в drop-in файле: CPUQuota=50%, MemoryMax=1G и IOWeight=50. После systemctl daemon-reload и перезапуска юнита значения уезжают в контроллеры cgroups v2, и дальше ядро само следит за их соблюдением. Сторонние демоны, патчи приложения и ulimit здесь не нужны.
Что происходит без лимитов, видно на двух типовых авариях. Первая: сервис с утечкой памяти за ночь выедает всю RAM, ядро запускает глобальный OOM killer и убивает процессы по своему выбору, часто не тот, который виноват. Вторая: ночной бэкап через rsync или restic занимает диск целиком, и PostgreSQL рядом начинает отдавать p99 в разы медленнее. В обоих случаях причина не в самом сервисе, а в отсутствии границ.
Лимиты закрывают три задачи: не дать одному юниту съесть CPU или память, поделить диск между конкурирующими задачами, получить предсказуемое поведение под нагрузкой. Сюда же попадает защита от каскадных отказов: если у сервиса есть жёсткий потолок по памяти, ядро убьёт только его, остальные продолжат работать.
Проверка версии cgroups и systemd
Инструкции ниже рассчитаны на cgroups v2 и systemd 244 или новее. Проверьте стек перед тем, как что-то менять:
stat -fc %T /sys/fs/cgroup systemctl --version uname -r ls /sys/fs/cgroup
Первая команда должна вернуть cgroup2fs. Любой другой вывод (например, tmpfs) означает гибридный режим с cgroups v1, и часть параметров не применится. Версия systemd в выводе второй команды должна быть не ниже 244: в старых сборках параметры IOWeight и часть настроек памяти для v2 отсутствуют.
Где v2 уже включён по умолчанию: Ubuntu 22.04 и новее, Debian 11 и новее, RHEL 9 и совместимые сборки, Fedora с 31-й версии. На Ubuntu 20.04, Debian 10 и RHEL 8 по умолчанию работает v1. Переключение делается параметром ядра:
# файл /etc/default/grub GRUB_CMDLINE_LINUX="... systemd.unified_cgroup_hierarchy=1" sudo update-grub sudo reboot
После перезагрузки повторите stat -fc %T /sys/fs/cgroup и убедитесь, что старые лимиты через blkio.throttle больше не используются. На практике быстрее поднять стенд с ядром 5.15 и systemd 249+, чем переводить legacy-хост, но если машина уже есть, переключение занимает один ребут. Для тестового полигона подойдёт облачный провайдер с современным ядром, например Timeweb Cloud, где cgroups v2 включён по умолчанию.
Основы cgroups v2 и systemd: как это работает
Systemd создаёт отдельную cgroup для каждого юнита и складывает их в иерархию slice. Параметры из unit-файла транслируются в файлы контроллеров, а те уже читает ядро. Путь для nginx выглядит так:
ls /sys/fs/cgroup/system.slice/nginx.service/ cgroup.controllers cpu.max cpu.stat memory.max memory.current memory.events io.max io.weight
Файлы доступны только для чтения (кроме тех, куда пишет systemd), но их удобно смотреть: там лежат реальные значения, которые видит ядро, а не то, что вы записали в конфиг. Дерево slice делится на три части: system.slice для системных сервисов, user.slice для пользовательских сессий, -.slice в корне. Лимиты можно вешать не только на юнит, но и на целый slice, и тогда они действуют на всех потомков. Приём удобен для группы бэкапов: создайте backup.slice, перечислите в юнитах Slice=backup.slice и задайте ограничения один раз.
Задать параметры можно двумя способами. Первый: systemctl set-property, он пишет drop-in и по умолчанию сохраняется между перезагрузками, с флагом --runtime правка уходит в /run и исчезает после ребута. Второй: свой drop-in файл в /etc/systemd/system/<unit>.d/, полностью под вашим контролем и под git.
Активируются лимиты после systemctl daemon-reload, а применяются к уже запущенному процессу после перезапуска юнита. Часть настроек подхватывается на лету, поведение зависит от контроллера, поэтому безопаснее перезапустить сервис.
Различия cgroups v1 и v2 в контексте systemd
В v1 контроллеры жили в разных иерархиях, и один процесс мог попадать сразу в несколько деревьев. В v2 иерархия одна, контроллеры включаются через cgroup.controllers и cgroup.subtree_control, а имена параметров стали единообразными:
| Задача | cgroups v1 | cgroups v2 |
|---|---|---|
| Квота CPU | cpu.cfs_quota_us + cpu.cfs_period_us | cpu.max |
| Вес CPU | cpu.shares | cpu.weight |
| Жёсткий лимит памяти | memory.limit_in_bytes | memory.max |
| Мягкий лимит памяти | memory.soft_limit_in_bytes | memory.high |
| Лимит ввода-вывода | blkio.throttle.read_bps_device | io.max |
В v2 появились PSI-метрики (pressure stall information) в /proc/pressure/cpu, /proc/pressure/memory и /proc/pressure/io. Они показывают, сколько времени задачи ждали ресурс, и это точнее средней загрузки. Отдельного дерева для blkio в v2 нет: относительный приоритет задаёт io.weight, абсолютные ограничения задаёт io.max.
Настройка лимитов CPU: CPUQuota и не только
CPUQuota задаёт жёсткий потолок процессорного времени в процентах от одного ядра: 50% это половина ядра, 200% это два ядра. Внутри cgroup значение превращается в пару квота/период, по умолчанию период равен 100000 мкс, то есть 100 мс.
CPUWeight работает иначе: это относительный вес при конкуренции, диапазон от 1 до 10000, по умолчанию 100. Если два сервиса делят ядра и у одного вес 200, а у другого 100, первый получит примерно вдвое больше процессорного времени. Когда CPU в избытке, вес ни на что не влияет.
AllowedCPUs прибивает процессы к конкретным ядрам, аналог taskset, но на уровне юнита. Приём полезен, когда нужно вывести шумный сервис с ядер, обслуживающих сетевые прерывания.
Временная установка CPUQuota через systemctl set-property
Для быстрой проверки гипотезы выставьте лимит без правки файлов:
sudo systemctl set-property --runtime nginx.service CPUQuota=50% systemctl show nginx.service -p CPUQuotaPerSecUSec systemctl status nginx.service --no-pager
Флаг --runtime означает, что правка живёт в /run/systemd/system.control и пропадёт после перезагрузки. Действующее значение проверяется в любой момент через systemctl show. Чтобы снять лимит, выполните ту же команду с пустым значением или перезапустите сервис после ребута.
Постоянная настройка через drop-in файл
Постоянный вариант надёжнее: файл видно в системе контроля версий, а рядом легко положить комментарий, зачем лимит поставлен.
sudo mkdir -p /etc/systemd/system/nginx.service.d sudo tee /etc/systemd/system/nginx.service.d/limits.conf <<'EOF' [Service] CPUQuota=50% CPUWeight=200 EOF sudo systemctl daemon-reload sudo systemctl restart nginx.service
Держите в голове разницу: CPUQuota жёсткий, CPUWeight только приоритет. Ставить оба сразу имеет смысл, когда сервис должен получать больше остальных при свободных ядрах, но не выходить за потолок. Значение 200% для четырёхъядерной машины означает два ядра, а не половину нагрузки.
Проверка фактического потребления CPU
После перезапуска убедитесь, что лимит реально работает:
cat /sys/fs/cgroup/system.slice/nginx.service/cpu.max # 50000 100000 cat /sys/fs/cgroup/system.slice/nginx.service/cpu.stat systemd-cgtop --order=cpu -d 2
Первое число в cpu.max это квота, второе период. Пара 50000 100000 означает ровно 50% одного ядра. Слово max вместо чисел говорит о том, что лимит не задан. В cpu.stat смотрите на nr_throttled: если счётчик растёт, ядро останавливало процессы, чтобы уложиться в квоту. Какие метрики стоит держать под наблюдением постоянно, разобрано в статье про практическую настройку производительности Linux-серверов под нагрузкой.
Управление памятью: MemoryMax, MemoryHigh и защита от OOM
MemoryMax это жёсткий потолок. Как только суммарное потребление группы упирается в него, ядро освобождает страницы, а если не выходит, убивает процесс внутри cgroup через OOM killer. Остальная система при этом остаётся живой, в этом главный плюс лимита.
MemoryHigh это мягкий порог. При его превышении ядро агрессивнее вытесняет страницы и тормозит группу, но никого не убивает. Рабочая схема: MemoryHigh ставят на 20-30% ниже MemoryMax, чтобы получить плавное торможение вместо мгновенного падения.
MemorySwapMax ограничивает swap для группы, значение 0 полностью запрещает сервису уходить в swap. Параметры MemoryMin и MemoryLow защищают страницы от вытеснения: их полезно ставить для базы данных, когда рядом работает кто-то прожорливый.
Установка MemoryMax и MemoryHigh
sudo systemctl set-property myservice.service MemoryMax=1G MemoryHigh=800M MemorySwapMax=0 systemctl show myservice.service -p MemoryMax -p MemoryHigh -p MemorySwapMax cat /sys/fs/cgroup/system.slice/myservice.service/memory.max cat /sys/fs/cgroup/system.slice/myservice.service/memory.current
Постоянный вариант через drop-in:
sudo tee /etc/systemd/system/myservice.service.d/memory.conf <<'EOF' [Service] MemoryAccounting=yes MemoryHigh=800M MemoryMax=1G MemorySwapMax=0 EOF sudo systemctl daemon-reload sudo systemctl restart myservice.service
MemoryAccounting в свежих systemd включён по умолчанию, но в старых сборках его прописывают явно, иначе memory.current и memory.events останутся пустыми. Суффиксы K, M, G в systemd считаются в двоичных единицах: 1G это 1073741824 байт, а не 10^9.
Диагностика OOM: как понять, что сервис убит из-за памяти
Первым делом смотрите счётчики внутри cgroup и логи ядра:
cat /sys/fs/cgroup/system.slice/myservice.service/memory.events # oom 3 # oom_kill 3 journalctl -u myservice.service --no-pager | grep -i -E "oom|killed" dmesg -T | grep -i -E "oom|killed process"
Если oom_kill больше нуля, сервис упирался в MemoryMax и ядро его убивало. В dmesg будет строка вида "Memory cgroup out of memory: Killed process" с указанием PID и объёма памяти. Дальше два пути: поднять MemoryMax, если потребление оправданно, или разбираться с утечкой. Промежуточный вариант, перевести жёсткий лимит в мягкий MemoryHigh и посмотреть, как сервис ведёт себя под торможением.
Ограничение дискового ввода-вывода: IOWeight и bandwidth
Контроллер io в cgroups v2 работает в двух режимах: относительный вес и жёсткие ограничения по скорости. Начните с веса, абсолютные лимиты включайте, когда нужно гарантировать полосу конкретному сервису.
Настройка IOWeight для приоритизации
sudo systemctl set-property backup.service IOWeight=50 systemctl show backup.service -p IOWeight cat /sys/fs/cgroup/system.slice/backup.service/io.weight
Диапазон от 1 до 10000, по умолчанию 100. Вес 50 означает, что при конкуренции за диск бэкап получит примерно вдвое меньше операций, чем сосед с весом 100. Вес работает только с планировщиком BFQ: проверьте cat /sys/block/sda/queue/scheduler. Если в выводе нет [bfq], включите его через udev-правило или параметр загрузки, иначе io.weight не даст эффекта.
Вес не спасает, когда сервис стартует и сразу забирает всю очередь диска. Тогда нужны абсолютные лимиты.
Жёсткие лимиты: IOReadBandwidthMax и IOWriteBandwidthMax
sudo tee /etc/systemd/system/backup.service.d/io.conf <<'EOF' [Service] IOAccounting=yes IOWeight=50 IOReadBandwidthMax=/dev/sda 100M IOWriteBandwidthMax=/dev/sda 50M IOWriteIOPSMax=/dev/sda 2000 EOF sudo systemctl daemon-reload sudo systemctl restart backup.service cat /sys/fs/cgroup/system.slice/backup.service/io.max # 8:0 rbps=104857600 wbps=52428800 wiops=2000
Лимит привязывается к конкретному блочному устройству. В io.max устройство обозначено парой major:minor, а не именем, поэтому в конфиге пишут путь /dev/sda, ядро само переводит его в 8:0. Значения bps указаны в байтах в секунду, поэтому 100M превращается в 104857600. Если сервис работает по NFS или через LVM, лимит ставьте на физическое устройство, а не на mapper, иначе цифры разойдутся.
Просмотр фактических лимитов и текущего потребления
Конфиг в drop-in это только намерение. Реальные значения читайте из systemd и из cgroup, в двух местах они иногда отличаются, если параметр переопределил slice.
Использование systemd-cgtop для мониторинга
systemd-cgtop -d 2 --order=cpu systemd-cgtop -m -d 5
Утилита показывает потребление по cgroup: CPU, память, число задач и операции ввода-вывода. Ключ --order=cpu сортирует вывод по нагрузке, -m оставляет только память. Этого достаточно, чтобы увидеть пожирателя, но не для истории: cgtop это снимок в реальном времени. Метрики с ретеншеном и пороговые значения собраны в статье про мониторинг производительности сервера.
Чтение параметров из /sys/fs/cgroup
systemctl show nginx.service -p CPUQuotaPerSecUSec -p MemoryMax -p MemoryHigh -p IOWeight -p MemoryCurrent -p CPUUsageNSec cat /sys/fs/cgroup/system.slice/nginx.service/cpu.max cat /sys/fs/cgroup/system.slice/nginx.service/memory.max cat /sys/fs/cgroup/system.slice/nginx.service/memory.current cat /sys/fs/cgroup/system.slice/nginx.service/io.max cat /sys/fs/cgroup/system.slice/nginx.service/io.stat
Следите за единицами: systemctl show возвращает время в микросекундах (CPUQuotaPerSecUSec=500000 это 50%), а память в байтах (1073741824 это 1 ГиБ). Если cpu.max содержит слово max, квота не задана. Если memory.max равен огромному числу вроде 9223372036854771712, лимит фактически отсутствует. Файл io.stat показывает реально прочитанные и записанные байты, по нему удобно сверять эффективность bandwidth-ограничений.
Безопасное применение настроек и откат
Порядок действий одинаков для любого параметра: сначала тестовый стенд, потом боевой сервер с окном обслуживания. Лимит по памяти, выставленный наугад, роняет сервис не сразу, а под пиком трафика, в самый неудобный момент.
Пошаговое применение через drop-in
# 1. Резервная копия действующей конфигурации sudo cp -a /etc/systemd/system/nginx.service.d /root/nginx-dropin-backup-$(date +%F) # 2. Каталог и файл лимитов sudo mkdir -p /etc/systemd/system/nginx.service.d sudo tee /etc/systemd/system/nginx.service.d/limits.conf <<'EOF' [Service] CPUQuota=50% MemoryHigh=800M MemoryMax=1G IOWeight=50 EOF # 3. Перечитать конфигурацию и перезапустить юнит sudo systemctl daemon-reload sudo systemctl restart nginx.service # 4. Проверить статус и логи systemctl status nginx.service --no-pager journalctl -u nginx.service -n 50 --no-pager
Если сервис не поднялся, смотрите journalctl: типичная причина опечатка в имени параметра, systemd игнорирует неизвестные ключи и пишет предупреждение при daemon-reload. Второй сценарий: лимит по памяти слишком мал для старта, процесс падает на инициализации. Держите окно обслуживания и план отката под рукой. Смежные ограничения, которые усиливают изоляцию, но требуют аккуратности при настройке, разобраны в материале про усиление systemd-сервисов через ProtectSystem, DynamicUser и SystemCallFilter.
Полный откат настроек
# Вариант 1: удалить свой drop-in sudo rm /etc/systemd/system/nginx.service.d/limits.conf sudo systemctl daemon-reload sudo systemctl restart nginx.service # Вариант 2: сбросить правки, сделанные set-property sudo systemctl revert nginx.service sudo systemctl daemon-reload sudo systemctl restart nginx.service
Команда systemctl revert убирает изменения, которые systemctl set-property оставил в /etc/systemd/system.control, и снимает маскировку юнита. Файлы, созданные вручную в /etc/systemd/system/<unit>.d/, она не трогает, их удаляют через rm. После любой правки проверяйте systemctl show и убеждайтесь, что значения вернулись к ожидаемым.
Для экспериментов есть безопасный инструмент: systemd-run создаёт временный юнит с лимитами и не трогает рабочие сервисы.
sudo systemd-run --unit=limit-test --slice=test.slice -p CPUQuota=20% -p MemoryMax=256M --wait /usr/bin/stress-ng --cpu 4 --vm 2 --vm-bytes 400M
Такой запуск покажет, как ведёт себя нагрузка под квотой, и не потребует ни drop-in, ни перезапуска продакшена.
Диагностика: что делать, если сервис упирается в лимит
Три симптома выглядят похоже и лечатся по-разному: сервис тормозит под квотой CPU, падает по памяти, ждёт диск. Различить их помогают счётчики внутри cgroup.
Как понять, что CPU throttling включился
cat /sys/fs/cgroup/system.slice/nginx.service/cpu.stat # nr_periods 41000 # nr_throttled 8200 # throttled_usec 45000000 cat /proc/pressure/cpu
Растущий nr_throttled вместе с throttled_usec означает, что ядро регулярно останавливает процессы, чтобы уложиться в квоту. Если сервис отдаёт запросы с задержкой, а CPUQuota стоит на уровне 50% при загрузке 100%, потолок и есть причина. Варианты по возрастанию риска: поднять CPUQuota, перейти на CPUWeight и дать сервису конкурировать на равных, вынести тяжёлые задачи в отдельный slice с собственным лимитом.
Что делать при OOM: увеличить MemoryMax или разбираться с утечкой
systemd-cgtop -m -d 2 cat /sys/fs/cgroup/system.slice/myservice.service/memory.events cat /sys/fs/cgroup/system.slice/myservice.service/memory.current journalctl -u myservice.service --no-pager | grep -i oom
Если memory.current упирается в memory.max, а oom_kill растёт, у сервиса нет запаса. Быстрый шаг: поднять MemoryMax на 30% и посмотреть, как ведёт себя потребление сутки. Линейный рост памяти означает утечку, и лимит только отсрочит падение. Второй шаг: поставить MemoryHigh ниже MemoryMax, чтобы получить торможение вместо убийства, и параллельно собирать профиль потребления.
Когда счётчики в норме, а сервис всё равно деградирует, ищите узкое место за пределами его cgroup: перегруженный диск, сеть, соседний юнит без лимитов. Алгоритм поиска такой причины описан в статье про поиск узкого места в системе без лишних затрат. Если логов много и нужно быстро вычленить аномалии, разбор выгрузки journalctl можно ускорить через LLM API, например с агрегатором моделей AiTunnel, который отдаёт доступ к GPT, Gemini и Claude через единый интерфейс.
Заключение
Минимальный набор для продакшена выглядит так: CPUQuota для защиты от CPU-жадных задач, MemoryHigh плюс MemoryMax для контроля памяти, IOWeight и bandwidth-лимиты для диска. Настраивайте через drop-in файлы, проверяйте результат командами systemctl show, systemd-cgtop и чтением /sys/fs/cgroup, а откатывайте удалением файла или через systemctl revert.
Перед правками на боевом сервере прогоняйте конфигурацию на стенде, снимайте резервную копию каталога с drop-in и держите окно обслуживания. Официальные детали по каждому параметру собраны в man systemd.resource-control, man systemd.slice и man systemd-cgtop; сверяйтесь с ними под свою версию systemd, потому что набор поддерживаемых ключей отличается между 244 и 255.