Содержание
Попался мне сервер, где внутренний API запускался из screen. После ночного ребута он, разумеется, не поднялся. Переписал запуск на unit за пятнадцать минут.
С тех пор на каждом новом сервере первым делом ищу процессы, запущенные через screen, nohup или cron с @reboot. Всё это не переживает падения процесса и держится на честном слове.
systemd сейчас PID 1 почти везде. Вопрос не в том, нужен ли он, а в том, пользуетесь ли вы чем-то кроме systemctl restart. Дальше — unit-файлы, таймеры вместо cron, hardening и диагностика.
Что понадобится для systemd
Требования и подготовка
Debian 12 или 13, Ubuntu 22.04 и новее. systemd там уже работает как init. В Docker-контейнерах его обычно нет, там не ищем.
Проверяем, кто у нас PID 1, и какая версия:
ps -p 1 -o comm=
systemctl --version | head -1
Версия важна. Директивы добавляются от релиза к релизу, а старый systemd незнакомую строку просто пропускает. Служба запустится, в журнале будет warning Unknown key name, а защита, которую вы прописали, работать не будет. Особенно это касается Ubuntu 18.04 и Debian 10, которые до сих пор где-то живут.
Ещё пригодится man systemd.directives. Там собраны все директивы со ссылкой на нужный man. Быстрее, чем гуглить.
Установка и подготовка среды
Установка пакетов / компонентов
Сам systemd ставить не нужно, он уже стоит. А вот компоненты вокруг него в Debian и Ubuntu разнесены по отдельным пакетам. Если чего-то не хватает — доставляем:
apt install systemd-timesyncd systemd-resolved systemd-coredump systemd-container
systemd-container нужен ради machinectl и nspawn, systemd-coredump — чтобы дампы упавших процессов не терялись. Что из этого уже стоит, смотрим через dpkg -l 'systemd*'. На Ubuntu часть компонентов есть из коробки, на минимальном Debian обычно меньше.
Второе, что я делаю на любом новом сервере — постоянный журнал. journald пишет на диск, только если есть каталог /var/log/journal. Нет каталога — журнал живёт в /run и пропадает при ребуте. Ровно тогда, когда он нужнее всего.
mkdir -p /var/log/journal
systemd-tmpfiles --create --prefix /var/log/journal
systemctl restart systemd-journald
И пользователь под службу. Запускать свой код от root — плохая привычка, даже если это «временно».
useradd --system --no-create-home --shell /usr/sbin/nologin reportapi
Можно обойтись без этого через DynamicUser=yes, systemd сам создаст временного пользователя. Но если служба пишет файлы, которые потом читает кто-то ещё, статический пользователь проще.
Базовая настройка systemd
Основные параметры
Unit-файлы лежат в трёх местах. /usr/lib/systemd/system — файлы из пакетов. /etc/systemd/system — ваши. /run/systemd/system — runtime, живут до ребута. Приоритет у /etc.
Главное правило: пакетные unit-файлы не трогаем. Следующее обновление молча вернёт всё как было. Для правок есть drop-in:
systemctl edit nginx.service
Откроется пустой override.conf в /etc/systemd/system/nginx.service.d/. Пишете туда только то, что меняете. Если нужно переопределить ExecStart, сначала пустая строка ExecStart=, потом новая. Иначе systemd решит, что команд теперь две, и для обычной службы откажется стартовать.
Из директив чаще всего нужны:
Type— как systemd понимает, что служба запустилась.execдля обычного процесса,forkingдля старых демонов,notifyесли приложение умеет sd_notify,oneshotдля скриптов.RestartиRestartSec— поднимать ли процесс после падения и через сколько.User,Group,WorkingDirectory,EnvironmentFile— от кого, откуда и с какими переменными.AfterиWants/Requires— порядок и зависимости. Это разные вещи, к ним вернёмся в ошибках.WantedByв секции[Install]— к какому target цепляться приenable.
Type=exec я ставлю вместо simple везде, где версия позволяет. Разница вроде бы в мелочи. simple считает службу запущенной сразу после fork. Если бинарника нет, systemctl start отработает без ошибки, а падение увидите только в статусе. exec дождётся запуска бинарника и честно вернёт ошибку.
Основная конфигурация
Пример конфигурации
Вернёмся к тому API. Python, uvicorn, слушает localhost, снаружи nginx. PostgreSQL на этом же хосте. Unit получился такой:
# /etc/systemd/system/reportapi.service
[Unit]
Description=Report API
Wants=network-online.target
After=network-online.target postgresql.service
StartLimitIntervalSec=300
StartLimitBurst=5
[Service]
Type=exec
User=reportapi
Group=reportapi
WorkingDirectory=/opt/reportapi
EnvironmentFile=/etc/reportapi/env
ExecStart=/opt/reportapi/venv/bin/uvicorn app:app --host 127.0.0.1 --port 8080
Restart=on-failure
RestartSec=5
TimeoutStopSec=20
[Install]
WantedBy=multi-user.target
After=postgresql.service без Requires — осознанно. С Requires рестарт базы потащит за собой и API, а мне это не нужно.
StartLimit* живут в секции [Unit]. Здесь я разрешаю пять падений за пять минут. Дальше служба уходит в failed и ждёт человека. Бесконечный restart loop, который за ночь забьёт журнал, мне не нужен.
Секреты — в /etc/reportapi/env с правами 600 и владельцем root. Файл читает сам systemd до того, как сбросить привилегии. Пользователю службы доступ к нему не нужен.
Запускаем:
systemctl daemon-reload
systemctl enable --now reportapi.service
Пока разбирался с API отчёта ещё обнаружил один сервис — ночной бэкап базы Postgresql. Раньше это была строчка в crontab. Если сервер в 02:30 был выключен, бэкапа просто не будет, и никто об этом не узнает. Таймер это умеет.
# /etc/systemd/system/db-backup.service
[Unit]
Description=Nightly PostgreSQL dump
[Service]
Type=oneshot
User=postgres
ExecStart=/usr/local/bin/db-backup.sh
# /etc/systemd/system/db-backup.timer
[Unit]
Description=Run db-backup nightly
[Timer]
OnCalendar=*-*-* 02:30:00
RandomizedDelaySec=10min
Persistent=true
[Install]
WantedBy=timers.target
Persistent=true — если момент запуска пропущен, таймер отработает сразу после загрузки. RandomizedDelaySec размазывает старт, чтобы двадцать серверов не ломились в одно хранилище в одну секунду. Включается именно таймер, не служба:
systemctl enable --now db-backup.timer
Бонусом получаем то, чего в cron не было никогда. Вывод скрипта лежит в журнале, код возврата виден в systemctl status. Упавший бэкап становится failed unit, и его ловит мониторинг.
Когда реально сработает расписание, можно проверить заранее:
systemd-analyze calendar "*-*-* 02:30:00"
Проверка работоспособности
Проверка и тестирование
Перед daemon-reload прогоняю unit через verify. Он ловит опечатки в директивах и несуществующие пути в ExecStart:
systemd-analyze verify /etc/systemd/system/reportapi.service
Дальше стандартный набор, его все знают: status, is-active, is-enabled. Из менее очевидного — systemctl --failed, одной командой видно всё, что лежит на сервере. И счётчик перезапусков:
systemctl show reportapi -p NRestarts -p ActiveEnterTimestamp
Служба в статусе active ещё ничего не значит. Если NRestarts за сутки вырос до сорока, она падает каждые полчаса и тихо поднимается. Этот счётчик я забираю в Zabbix и вешаю на него триггер.
Проверить Restart тоже надо, и тут есть нюанс. systemctl kill по умолчанию шлёт SIGTERM, а для Restart=on-failure это чистое завершение. Служба просто остановится и не поднимется. Для теста убиваем жёстко:
systemctl kill -s SIGKILL reportapi
sleep 6 && systemctl status reportapi --no-pager
И последний тест, который ничем не заменить — перезагрузка. Порядок старта и зависимости по-настоящему проверяются только ребутом. Лучше на стенде, но хотя бы в окно обслуживания.
После ребута смотрю, кто тормозил загрузку:
systemd-analyze critical-chain reportapi.service
systemd-analyze blame | head -15
Логи и диагностика
systemctl status показывает последние десять строк журнала. Для диагностики этого мало. Мой рабочий набор:
journalctl -u reportapi -b # всё с текущей загрузки
journalctl -u reportapi -f # в реальном времени
journalctl -u reportapi --since "30 min ago" -p warning
journalctl -u reportapi -b -1 # предыдущая загрузка
-b -1 не раз выручал, когда сервер перезагрузился ночью и надо понять, что было до этого. Без постоянного журнала, про который я писал выше, команда ничего не покажет.
Когда служба не стартует, первым делом смотрю код в строке Main PID ... status=. Самые частые:
203/EXEC— бинарник не найден или нет прав на исполнение. Часто просто кривой путь к venv.200/CHDIR— нет каталога изWorkingDirectory.217/USER— нет пользователя изUser=.226/NAMESPACE— hardening ссылается на каталог, которого не существует.
За журналом тоже надо следить. Без ограничений он спокойно съедает гигабайты:
journalctl --disk-usage
journalctl --vacuum-size=1G
Лимит лучше прописать сразу: SystemMaxUse=2G в /etc/systemd/journald.conf.d/size.conf.
Безопасность и резервное копирование
По умолчанию служба видит всю файловую систему и может почти всё, что может её пользователь. Посмотреть, насколько всё плохо, можно одной командой:
systemd-analyze security reportapi.service
Для свежего unit без hardening она выдаст оценку в районе 9 и пометку UNSAFE. Шкала обратная: чем меньше, тем лучше.
Защиту я не пишу в основной unit, а выношу в отдельный drop-in. Так её проще откатить, если что-то сломалось:
# /etc/systemd/system/reportapi.service.d/hardening.conf
[Service]
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
CapabilityBoundingSet=
StateDirectory=reportapi
LogsDirectory=reportapi
ProtectSystem=strict делает всю систему read-only для службы. Писать она сможет только в каталоги из StateDirectory и LogsDirectory. systemd сам создаст /var/lib/reportapi и /var/log/reportapi с нужным владельцем. Если приложению нужен ещё какой-то путь — ReadWritePaths=.
Добавляю директивы не пачкой, а по две-три. После каждой — рестарт и журнал. Когда вылезает 226/NAMESPACE или приложение ругается на read-only, сразу понятно, что именно его сломало. Оценка заметно поползла вниз, а работе API это никак не помешало.
Про секреты ещё одно. Не пишите пароли прямо в Environment= внутри unit. Unit-файлы читаются всеми, а systemctl show выдаст значение любому пользователю на сервере. EnvironmentFile с правами 600 надёжнее. На свежих версиях есть LoadCredential=, это ещё лучше, но приложение должно уметь читать секрет из файла.
Теперь резервное копирование. Сам systemd бэкапить не надо, его вернёт пакетный менеджер. Бэкапить надо то, что вы написали руками:
/etc/systemdцеликом, вместе с drop-in каталогами и симлинками в*.wants— это и есть информация о том, что включено;- env-файлы служб вроде
/etc/reportapi.
tar czf /backup/systemd-$(hostname)-$(date +%F).tgz /etc/systemd /etc/reportapi
systemctl list-unit-files --state=enabled > /backup/enabled-units-$(hostname).txt
Список включённых unit-ов лишним не будет. Когда поднимаешь сервер на новом железе, это самый быстрый способ понять, что должно работать. Хотя по-хорошему unit-файлы должны жить в git и раскатываться Ansible. Тогда бэкап — это просто репозиторий. Но тут у меня нету Ansible.
Журнал в бэкап я не кладу. Его лучше сразу отправлять на центральный сервер (если таковой у вас есть): через ForwardToSyslog в rsyslog, через systemd-journal-upload или агентом вашей системы сбора логов.
Типичные ошибки
Все эти ошибки я видел живьём. Часть из них сделал сам.
Забыли daemon-reload. Правите unit, делаете restart, а служба работает по-старому. systemctl об этом предупреждает, но warning легко пролистать. Привычка простая: поменял файл — daemon-reload. systemctl edit делает его сам.
After без Wants. After= только задаёт порядок. Если зависимость никто не запускает, порядок ни на что не влияет. И наоборот, Wants= без After= стартует обе службы параллельно.
network.target вместо network-online.target. network.target не значит, что сеть поднялась. Служба, которая биндится на конкретный IP, при загрузке падает с Cannot assign requested address, а руками потом стартует нормально. Нужен network-online.target в паре Wants и After. Плюс должна быть включена служба ожидания сети: NetworkManager-wait-online или systemd-networkd-wait-online.
Не тот Type. Демон сам уходит в фон, а в unit стоит simple или exec. systemd видит, что основной процесс завершился, считает службу остановленной и прибивает остатки. Выглядит как «стартует и сразу умирает». Обратная ситуация — forking без форка. systemd ждёт до таймаута, потом убивает.
Окружение из bashrc. Руками скрипт работает, из systemd — нет. Служба стартует с чистым окружением, без вашего PATH и переменных из профиля. Всё нужное — в Environment= или EnvironmentFile=, пути — полные.
Shell в ExecStart. ExecStart не shell. Пайпы, >, && и ~ там не работают. Нужна логика — пишите скрипт. Можно обернуть в /bin/bash -c '...', но это уже костыль.
start request repeated too quickly. Служба упала пять раз подряд и упёрлась в StartLimitBurst. Сначала читаем журнал и чиним причину. Потом systemctl reset-failed reportapi и старт. Без reset-failed придётся ждать, пока истечёт интервал.
Таймер в UTC. Сервер в UTC, а расписание вы писали по Москве. Бэкап стартует в 05:30, прямо под утреннюю нагрузку. Проверяйте timedatectl или пишите зону явно: OnCalendar=*-*-* 02:30:00 Europe/Moscow.
Masked unit. Unit is masked — кто-то сделал systemctl mask, и unit теперь ссылка на /dev/null. unmask возвращает всё назад. Обычно маскируют после миграции, а потом забывают.
Практические рекомендации
Обновление и обслуживание
После обновления дистрибутива первым делом запускаю systemd-delta. Она показывает все unit-ы, которые вы переопределили или расширили drop-in-ами. Если пакет сменил поведение службы, а ваш override остался старым, это видно сразу.
systemd-delta --type=overridden,extended
После обновления библиотек службы продолжают работать со старыми версиями в памяти. Какие именно — показывает needrestart. В Ubuntu он стоит из коробки и сам спрашивает после apt upgrade, в Debian его надо поставить. Список без перезапуска — needrestart -r l. Перезапускаю по нему, а не всё подряд.
Сам systemd после обновления пакета перечитывает себя через daemon-reexec, это делают скрипты пакета. Но ребут после крупного обновления я всё равно планирую. Заодно и проверка, что всё поднимается само.
Ещё несколько привычек, которые экономят время:
- Одинаковые воркеры — через шаблон
worker@.serviceс%iв параметрах. Потомsystemctl enable --now worker@{1..4}, и никакого копипаста. KillMode=processне ставить без веской причины. Он оставляет дочерние процессы жить после stop, и потом вы ищете, кто держит порт.- Если приложение умеет sd_notify, используйте
Type=notifyиWatchdogSec=. Зависшую, но живую службу systemd перезапустит сам. - В мониторинг —
systemctl --failedиNRestartsпо важным службам. Шаблон Systemd by Zabbix agent 2 по умолчанию находит вообще все unit-ы, фильтр по именам стоит настроить сразу. - Unit-файлы — в git. Правка руками на проде без коммита рано или поздно заканчивается тем, что на двух серверах (в лучшем случае) одна служба работает по-разному.
А screen с того сервера я так и не удалил. Иногда держу в нём journalctl -f, по старой памяти :).