Бегунов ЕгорСистемный администратор, DevOps
  1. Главная
  2. Блог
  3. Linux

systemd: управление службами Linux

Содержание

Попался мне сервер, где внутренний 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, по старой памяти :).