systemd unit-файлы: пишем сервисы с ограничением ресурсов, автоперезапуском и зависимостями
Практика написания unit-файлов для собственных сервисов на RHEL 7 / CentOS 7: cgroups, Restart=, зависимости, сравнение с SysV init.
systemd становится де-факто стандартом в RHEL 7, CentOS 7 и готовится к включению по умолчанию в Debian 8; сообщество разделилось в оценках
В январском посте про CentOS 7 в продакшне мы упоминали, что unit-файлы для пакетных сервисов идут из коробки, а проблемы начинаются с кастомными вещами. Пора разобрать эту тему отдельно - потому что к нам уже дважды приходили с одним и тем же запросом: «у нас Java-демон, раньше был init.d-скрипт, что делать».
Debian 8 ещё не вышел, но уже объявлено: там тоже будет systemd по умолчанию. Сообщество делится на два лагеря - одни считают systemd монолитным монстром нарушающим Unix-way, другие говорят что init.d-скрипты были адом без стандарта. Мы в этой дискуссии занимаем прагматичную позицию: на RHEL 7 / CentOS 7 systemd уже есть, и нам с ним работать.
Что не так с SysV init-скриптами
Для понимания зачем unit-файлы лучше, стоит вспомнить что именно раздражало в /etc/init.d/.
Нет стандарта на содержимое. Каждый init.d-скрипт - авторское произведение. Один разработчик добавляет --daemon, другой форкается через start-stop-daemon, третий пишет свой PID-файл в неожиданном месте. Разобрать незнакомый скрипт на продакшн-сервере в 2 ночи - то ещё удовольствие.
Нет встроенного контроля ресурсов. Хочешь ограничить память Java-процесса? Пишешь wrapper, ставишь ulimit или цепляешь cgroup руками. Каждый раз по-другому.
Нет нормального статуса и перезапуска. service foo status может возвращать «running» когда процесс завис. Автоперезапуск? Только если кто-то написал это в скрипте или поставил monit сверху.
Зависимости - комментарии, а не контракт. LSB-заголовки в # Required-Start: никто особо не проверяет при параллельном старте.
Unit-файл: базовый шаблон
Для кастомного сервиса создаём файл в /etc/systemd/system/. Вот шаблон который мы используем для Java-демонов и подобных процессов:
[Unit]
Description=My Application Service
After=network.target postgresql.service
Requires=postgresql.service
[Service]
Type=simple
User=appuser
Group=appgroup
WorkingDirectory=/opt/myapp
ExecStart=/usr/bin/java -jar /opt/myapp/app.jar
ExecStop=/bin/kill -TERM $MAINPID
Restart=on-failure
RestartSec=5s
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
Разберём ключевые моменты.
After= и Requires=. After= определяет порядок старта - наш сервис стартует после network.target и postgresql.service. Requires= означает жёсткую зависимость: если postgresql не поднялся, наш сервис тоже не стартует и будет это сообщать честно, а не молча падать через 30 секунд с криптичной ошибкой подключения.
Type=simple vs Type=forking. Это главная ловушка для тех кто переносит старые сервисы. simple - процесс остаётся на переднем плане, systemd считает его запущенным сразу. forking - systemd ждёт когда родительский процесс завершится и управление перейдёт к дочернему (классический unix-daemon). Если указать forking для процесса который не форкается - systemd подождёт, не дождётся и объявит сервис упавшим. Поймали это на стенде с одним агентом мониторинга, потеряли минут сорок.
Restart=on-failure. systemd сам перезапустит сервис если тот завершился с ненулевым кодом. RestartSec=5s - пауза перед перезапуском, чтобы не получить спираль смерти если приложение падает сразу при старте. Для критичных сервисов добавляем StartLimitBurst=5 и StartLimitInterval=60s - не более 5 перезапусков за минуту, потом systemd сдаётся и уведомляет об ошибке.
Ограничение ресурсов через cgroups
Вот где systemd реально выигрывает у SysV. Прямо в unit-файле, без дополнительных инструментов:
[Service]
MemoryLimit=512M
CPUQuota=50%
MemoryLimit=512M - если процесс превысит 512 МБ, ядро его убьёт. systemd это зафиксирует и при Restart=on-failure перезапустит. CPUQuota=50% - не более 50% одного ядра. Для фоновых задач которые не должны перегружать сервер это удобнее чем nice или ручная возня с cgroups.
Также есть LimitNOFILE= для лимита файловых дескрипторов - актуально для сетевых серверов, где стандартный ulimit часто забывают поднять и потом ловят «too many open files» в 3 часа ночи.
Посмотреть фактические cgroup-лимиты работающего сервиса:
systemctl show myapp.service | grep -i limit
После изменения unit-файла
Частая ошибка: отредактировали файл, сделали systemctl restart myapp и думаете что изменения применились. Нет. Сначала нужно:
systemctl daemon-reload
systemctl restart myapp.service
daemon-reload заставляет systemd перечитать все unit-файлы. Без него работает старая версия конфигурации. Мы добавили это в шпаргалку для команды - слишком часто люди редактировали unit, перезапускали сервис и удивлялись что ничего не поменялось.
Проверка и отладка
systemctl status myapp.service # статус + последние строки лога
journalctl -u myapp.service -f # живой хвост лога сервиса
journalctl -u myapp.service -n 50 # последние 50 строк
systemd-analyze verify /etc/systemd/system/myapp.service # проверить синтаксис
systemd-analyze verify особенно полезен - он поймает опечатки в именах директив до того как вы пойдёте рестартовать сервис на продакшне.
Интеграция с Ansible
В наш провизионирующий playbook unit-файлы идут через шаблон:
- name: deploy unit file
template:
src: myapp.service.j2
dest: /etc/systemd/system/myapp.service
notify:
- daemon-reload
- restart myapp
Handler daemon-reload вызывает systemctl daemon-reload перед рестартом. Ansible модуль service для systemd работает корректно - понимает enabled: yes как systemctl enable.
Что имеем в итоге
Три кастомных сервиса которые раньше жили на init.d-скриптах с monit для перезапуска - теперь unit-файлы по 20-30 строк без внешних зависимостей. Ограничение памяти встроено, перезапуск встроен, зависимости декларативны. systemctl status даёт реальный статус, а не «процесс по имени найден, значит running».
Порог входа чуть выше чем скопировать init.d-шаблон, но результат управляемее. Сообщество пусть спорит о философии - мы пишем unit-файлы.