RE:NODE

VDS15 мин чтения

Службы systemd для ваших приложений: unit-файлы и journalctl

Превратите приложение в службу, которая переживает перезагрузки и сбои: unit-файлы, Type и Restart, файлы окружения, journalctl, защита, шаблонные unit-ы и таймеры.

0 прочтений

Приложение, запущенное из SSH-сессии, умирает вместе с сессией. Приложение, запущенное через nohup ... &, переживает сессию и умирает при перезагрузке. Приложение внутри screen или tmux переживает и то и другое - до того дня, когда кто-нибудь закроет не то окно, - и при этом не оставляет логов, которые кто-то сможет найти. Unit-файл systemd - это двенадцать строк текста, которые решают все три проблемы сразу: процесс стартует при загрузке, перезапускается после сбоя, работает от нужного пользователя с нужным окружением и отправляет всё, что печатает, в журнал, по которому можно делать запросы по времени и по уровню важности.

Это та часть самостоятельного администрирования сервера, которая окупается быстрее всего, и она действительно небольшая. Ниже - рабочий unit-файл, затем каждая директива в нём, о которой стоит знать, затем две вещи, на которых всё реально ломается - лимит запусков и файл окружения, - а после них логи, защита, несколько экземпляров одной службы и таймеры как замена cron.

Что даёт systemd, чего не дают tmux и nohup#

  • Он запускается при загрузке, ещё до того, как кто-либо вошёл в систему, в заданном порядке относительно сети и файловых систем, которые ему нужны.
  • Он перезапускает службу после сбоя с задержкой, которую вы задаёте, и с лимитом, чтобы окончательно сломанная служба не крутилась вхолостую.
  • Он владеет всем деревом процессов. Всё, что порождает служба, живёт в одной контрольной группе, поэтому остановка службы останавливает и дочерние процессы, а не оставляет сирот, которые держат порт.
  • Он запускает службу от выбранного пользователя с выбранными рабочим каталогом, umask и окружением, и ничто из этого не зависит от того, чья оболочка её запустила.
  • Он собирает вывод в одном месте. Всё, что записано в стандартный вывод или стандартный поток ошибок, попадает в журнал с пометкой unit-а, доступно для запросов по времени и ротируется без написания файла logrotate.
  • Он умеет сбрасывать привилегии и сужать поверхность ядра десятком однострочных опций, а это самая дешёвая защита из всех существующих.

Чего он не умеет - так это знать, работает ли ваше приложение. Зависший процесс, который больше не отвечает на запросы, вполне устраивает systemd. Проверка живости - это health check плюс что-то, что на него реагирует, и это другая работа: про прикладную половину рассказано в статье корректное завершение и health check.

Ваш первый unit-файл#

/etc/systemd/system/notes.service
[Unit]Description=Notes web applicationDocumentation=https://example.com/docsAfter=network-online.target postgresql.serviceWants=network-online.target[Service]Type=simpleUser=notesGroup=notesWorkingDirectory=/srv/notesEnvironmentFile=-/etc/notes/notes.envEnvironment=NODE_ENV=productionExecStart=/usr/bin/node /srv/notes/server.jsRestart=on-failureRestartSec=5TimeoutStopSec=30[Install]WantedBy=multi-user.target
bash
$ sudo systemctl daemon-reload$ sudo systemctl enable --now notes$ systemctl status notes$ journalctl -u notes -f

daemon-reload после каждой правки, всегда: systemd кэширует unit-файлы и иначе будет запускать старый, пока вы читаете новый. enable создаёт символическую ссылку, которая запускает службу при загрузке; --now ещё и запускает её сразу. Это два разных понятия, и systemctl start без enable - классическая причина того, что служба, которая «работает», пропадает после перезагрузки.

В этом файле есть три детали, в которых легко ошибиться. ExecStart обязан быть абсолютным путём: node не найдётся, а /usr/bin/node найдётся. Команда не проходит через оболочку, поэтому конвейеры, шаблоны с *, && и подстановка переменных не работают; если они нужны, оберните команду в /bin/sh -c '...' осознанно. И unit-ы, помещённые в /etc/systemd/system/, переопределяют одноимённые unit-ы из пакетов в /usr/lib/systemd/system/, поэтому ваши собственные службы принадлежат первому каталогу, а unit-ы из пакетов нужно менять через drop-in, а не править напрямую.

Механизм drop-in стоит освоить в первый же день:

bash
$ sudo systemctl edit notes      # writes /etc/systemd/system/notes.service.d/override.conf$ systemctl cat notes            # shows the unit plus every drop-in, merged

Директивы, которые имеют значение#

ДирективаСекцияЧто делает
After= / Before=[Unit]Только порядок запуска. Ничего не подтягивает
Wants= / Requires=[Unit]Подтягивает другой unit. Requires ещё и падает вместе с ним
Type=[Service]Как systemd решает, что служба запустилась
ExecStart=[Service]Абсолютный путь плюс аргументы, без оболочки
ExecStartPre=[Service]Выполняется первой; ошибка прерывает запуск
ExecStop=[Service]Необязательна. Без неё посылается сигнал
User= / Group=[Service]Сброс привилегий. Никогда не оставляйте сетевую службу под root
WorkingDirectory=[Service]Собственный каталог процесса
EnvironmentFile=[Service]Файл со строками KEY=value. Префикс - делает его необязательным
Restart=[Service]См. следующий раздел
RestartSec=[Service]Задержка перед перезапуском. По умолчанию 100 мс
TimeoutStopSec=[Service]Сколько может длиться корректная остановка до SIGKILL
KillSignal=[Service]По умолчанию SIGTERM. Некоторому ПО нужен SIGINT
MemoryMax= / CPUQuota=[Service]Лимиты контрольной группы, например 1G и 150%
WantedBy=[Install]Какая цель его запускает. Почти всегда multi-user.target

Пара директив порядка - та, которую понимают неправильно чаще всего. After=postgresql.service говорит: «если запускаются оба, запускай меня вторым». PostgreSQL она не запускает. Wants=postgresql.service запускает его и идёт дальше, даже если он не поднялся; Requires= запускает его и отказывается стартовать, если он не поднялся. Для большинства зависимостей используйте Wants вместе с After, а Requires оставьте для случаев, когда работа без другого unit-а действительно бессмысленна.

Про After=network-online.target тоже нужно сказать отдельно, потому что network.target - это не то, что нужно большинству. network.target означает, что сетевой стек поднимается; network-online.target означает, что адрес действительно настроен, и работает это только если вы ещё и перечислили его в Wants=, а служба wait-online вашего дистрибутива включена. Если ваша служба при запуске привязывается к конкретному публичному адресу, нужен второй вариант. Если она привязывается к 0.0.0.0, хватит первого.

KillSignal=SIGINT выглядит загадочно, пока вы не начнёте хостить игру. Некоторые игровые серверы сохраняют мир по SIGINT и просто умирают по SIGTERM, так что unit с сигналом по умолчанию теряет всё, что было после последнего автосохранения, при каждом перезапуске. Выясните, чего ждёт ваше ПО, прежде чем полагаться на кнопку перезапуска.

Type, Restart и лимит запусков#

Type= сообщает systemd, когда служба считается запущенной, а от этого зависит, когда зависимым unit-ам разрешено стартовать.

TypeКогда использовать
simpleПроцесс работает на переднем плане. Значение по умолчанию, подходит большинству приложений
execКак simple, но запуск завершается ошибкой, если бинарный файл не удалось выполнить
forkingПрограмма сама уходит в демоны. Нужен PIDFile=
oneshotСкрипт, который отрабатывает и завершается. Сочетайте с RemainAfterExit=yes
notifyПрограмма вызывает sd_notify, когда действительно готова

Если приложение писали вы, запускайте его на переднем плане и используйте simple или exec. Не добавляйте флаг демонизации ради forking: это больше движущихся частей без всякой выгоды, а современные менеджеры процессов все рассчитывают на передний план.

У Restart= больше значений, чем используют на практике, и два из них покрывают почти всё:

ЗначениеПоведение
noНикогда не перезапускать. Значение по умолчанию
on-failureПерезапускать при ненулевом коде выхода, сигнале, таймауте. Не при чистом завершении
alwaysПерезапускать при любом исходе, включая чистое завершение
on-abnormalТолько сигналы, таймауты и сбои watchdog

on-failure - для всего, что может законно завершиться; always - для сервера, который не должен завершаться вообще. Ставьте рядом RestartSec=5, потому что значение по умолчанию в 100 миллисекунд превращает цикл падений в холостой цикл.

Теперь ловушка. systemd отказывается перезапускать службу чаще, чем StartLimitBurst раз за StartLimitIntervalSec - по умолчанию пять запусков за десять секунд, - и, упёршись в этот лимит, вообще перестаёт пытаться и сообщает:

code
notes.service: Start request repeated too quickly.notes.service: Failed with result 'start-limit-hit'.

Это systemd поступает правильно: служба, которая мгновенно умирает пять раз подряд, сломана, а не невезуча. Удивляет другое: после этого служба остаётся мёртвой, пока не вмешается человек, даже когда причину уже устранили. Лечение - sudo systemctl reset-failed notes и затем запуск, а настройка находится в [Unit]:

ini
[Unit]StartLimitIntervalSec=60StartLimitBurst=5

Обычно правильнее расширять окно, а не увеличивать количество запусков. Если ваша служба падает за десять секунд, то пять падений уже растягиваются на пятьдесят секунд, и интервал по умолчанию никогда не срабатывает - поэтому сообщение появляется в основном у служб, которые падают из-за отсутствующего файла быстрее чем за секунду. Тот же шаблон в управляемой среде и то, что с ним делает хостер, разобраны в статье почему ваш игровой сервер постоянно перезапускается.

Переменные окружения и секреты#

Есть три способа передать конфигурацию в unit, и они накладываются друг на друга.

ini
Environment=NODE_ENV=productionEnvironment="GREETING=hello there"EnvironmentFile=-/etc/notes/notes.env

Environment= - для несекретных значений, которые вас не смущает видеть в unit-файле, читаемом всеми. EnvironmentFile= указывает на файл со строками KEY=value, и именно там место секретам, потому что владельцем файла может быть root с правами 0600, а сам unit при этом остаётся читаемым.

bash
$ sudo install -d -m 0750 -o root -g notes /etc/notes$ sudo install -m 0640 -o root -g notes /dev/null /etc/notes/notes.env$ sudo nano /etc/notes/notes.env
/etc/notes/notes.env
DATABASE_URL=postgres://notes:secret@127.0.0.1:5432/notesSESSION_SECRET=a-long-random-stringPORT=3000

Четыре правила для этого файла, и каждое нарушают хотя бы раз:

  • Никакого `export`. Это не shell-скрипт. export FOO=bar задаёт переменную, которая буквально называется export FOO.
  • Никакой подстановки оболочки. PATH=$PATH:/opt/bin сохранит четыре символа $PATH, а не ваш путь.
  • Кавычки убираются, так что FOO="bar" даёт bar. Это значит, что значение, которому кавычки действительно нужны, требует осторожности.
  • Ведущий `-` в EnvironmentFile=-/etc/notes/notes.env делает отсутствие файла некритичным. Без него опечатка в пути вообще не даёт службе запуститься - а для файла с паролем от базы данных это иногда именно то, чего вы хотите.

Проверяйте, что служба на самом деле получила, а не предполагайте:

bash
$ systemctl show notes -p Environment$ sudo cat /proc/$(systemctl show notes -p MainPID --value)/environ | tr '\0' '\n'

Вторая команда - ещё и повод подумать о том, кто может читать окружение процесса на общей машине. Более широкий вопрос, включая то, что вообще не должно быть переменной окружения, разобран в статье переменные окружения и секреты.

journalctl: куда делся вывод#

Всё, что служба пишет в стандартный вывод или стандартный поток ошибок, попадает в журнал с пометкой имени unit-а. Ни перенаправлений, ни лог-файла, ни настройки logrotate.

bash
$ journalctl -u notes                      # everything, oldest first$ journalctl -u notes -f                   # follow, like tail -f$ journalctl -u notes -n 200 --no-pager    # last 200 lines, printable$ journalctl -u notes --since "1 hour ago"$ journalctl -u notes --since "2026-09-20 18:00" --until "2026-09-20 19:00"$ journalctl -u notes -p err               # errors and worse only$ journalctl -u notes -b                   # this boot only$ journalctl -u notes -o cat               # message text, no timestamps

Фильтр -p принимает уровни важности syslog, так что -p warning даёт предупреждения и всё серьёзнее. -b -1 даёт предыдущую загрузку - так вы выясняете, что происходило непосредственно перед незапланированной перезагрузкой.

Одно, что стоит проверить на свежем сервере: переживает ли журнал перезагрузку вообще. В нескольких дистрибутивах он хранится в /run и исчезает при перезапуске машины, а это делает разбор случившегося невозможным.

bash
$ journalctl --disk-usage$ sudo mkdir -p /var/log/journal$ sudo systemd-tmpfiles --create --prefix /var/log/journal$ sudo systemctl restart systemd-journald

Затем ограничьте его, потому что журнал без границ у шумной службы - это медленный способ забить диск:

/etc/systemd/journald.conf
Storage=persistentSystemMaxUse=500MMaxRetentionSec=1month

sudo journalctl --vacuum-time=14d сразу вычищает существующий журнал. А если вы видите в логе Suppressed N messages from notes.service, это ограничитель частоты journald - по умолчанию несколько тысяч сообщений за короткое окно, - и он означает, что ваша служба слишком шумная, а не что journald сломан. О том, что вообще стоит туда писать, рассказано в статье логи, которые стоит хранить.

Ограничиваем службу#

Каждая из этих опций - одна строка, и большинство ничего не стоят. Добавляйте их по несколько штук и проверяйте, потому что пара из них действительно ломает реальное ПО.

ini
NoNewPrivileges=yesPrivateTmp=yesProtectSystem=strictProtectHome=yesReadWritePaths=/srv/notes/uploadsProtectKernelTunables=yesProtectKernelModules=yesProtectControlGroups=yesRestrictAddressFamilies=AF_INET AF_INET6 AF_UNIXLockPersonality=yes

ProtectSystem=strict монтирует всю файловую систему для этой службы только на чтение, кроме /dev, /proc и /sys, так что каждый каталог, в который она законно пишет, нужно перечислить в ReadWritePaths=. Это самая ценная строка здесь и та, что вероятнее всего в первый раз даст сбой при запуске, и в этом её смысл: она заставляет вас знать, что именно ваше приложение пишет.

Ещё две вещи, о которых стоит знать. DynamicUser=yes создаёт и уничтожает системного пользователя вокруг службы, так что вам не приходится им управлять, и сочетается с StateDirectory=notes, который сам создаёт /var/lib/notes с правильным владельцем. А AmbientCapabilities=CAP_NET_BIND_SERVICE позволяет непривилегированной службе занять порт 80 или 443, не работая от root, - хотя за обратным прокси nginx она вам вовсе не нужна.

Осторожнее всего нужно с MemoryDenyWriteExecute=yes: она ломает любую среду выполнения с JIT-компилятором. Node, современная Java и несколько расширений Python откажутся запускаться. Измеряйте результат того, что выбрали:

bash
$ systemd-analyze security notes.service

Команда печатает строку на каждую настройку и общую оценку уязвимости. Относитесь к ней как к чек-листу, а не как к цели: переход от «unsafe» к «medium» - это почти вся доступная польза, а последние несколько пунктов обычно обходятся дороже, чем дают.

Шаблонные unit-ы и таймеры#

Шаблонный unit запускает много копий одной конфигурации. Имя файла оканчивается на @, а %i заменяется тем, что стоит после @ при запуске.

/etc/systemd/system/valheim@.service
[Unit]Description=Valheim server (%i)After=network-online.targetWants=network-online.target[Service]Type=simpleUser=gamesWorkingDirectory=/srv/valheim/%iEnvironmentFile=/etc/valheim/%i.envExecStart=/srv/valheim/%i/valheim_server.x86_64 -nographics -batchmodeKillSignal=SIGINTTimeoutStopSec=90Restart=on-failureRestartSec=10[Install]WantedBy=multi-user.target
bash
$ sudo systemctl enable --now valheim@midgard$ sudo systemctl enable --now valheim@testworld$ journalctl -u valheim@midgard -f

Два сервера, один unit-файл, отдельные файлы окружения, отдельные каталоги, отдельные логи. KillSignal=SIGINT и длинный TimeoutStopSec стоят здесь потому, что это игровой сервер, который сохраняется при остановке, и ему нужно дать закончить. Полная версия этого шаблона, включая порты и пользователей, - в статье несколько игровых серверов на одном VDS.

Таймеры заменяют cron, и довод в пользу перехода не эстетический. Таймер - это два файла: служба oneshot и таймер, который её запускает, - и служба получает всё описанное выше: пользователя, файл окружения, лимиты ресурсов, защиту и вывод в журнале вместо письма, которое никто не читает.

/etc/systemd/system/db-backup.service
[Unit]Description=Nightly database dump[Service]Type=oneshotUser=postgresExecStart=/usr/local/bin/db-backup.sh
/etc/systemd/system/db-backup.timer
[Unit]Description=Run the database dump at 04:30[Timer]OnCalendar=*-*-* 04:30:00Persistent=trueRandomizedDelaySec=300[Install]WantedBy=timers.target
bash
$ sudo systemctl enable --now db-backup.timer$ systemctl list-timers --all$ systemd-analyze calendar "*-*-* 04:30:00"$ sudo systemctl start db-backup.service     # run it now, on demand

Persistent=true - это то, чего нет у cron: если машина была выключена в 04:30, задание выполнится один раз вскоре после её включения, а не будет молча пропущено. RandomizedDelaySec разводит несколько машин, чтобы они не обращались к одному и тому же хранилищу бэкапов одновременно. А systemd-analyze calendar показывает, когда выражение сработает в следующий раз, а это лучше, чем ждать до завтра и узнавать, что вы записали его неверно. Если вам привычнее синтаксис cron, пять его полей разобраны в статье cron-выражения простыми словами, и учтите, что таймер хорош ровно настолько, насколько хорош скрипт, который он запускает, поэтому прочитайте бэкапы, которые действительно восстанавливаются, прежде чем доверять любому из двух.

Если служба не запускается#

bash
$ systemctl status notes          # state, last lines, exit code$ journalctl -u notes -n 50       # the actual error$ systemctl cat notes             # the unit as systemd sees it, plus drop-ins$ systemd-analyze verify /etc/systemd/system/notes.service$ systemctl list-units --failed   # everything broken on this box

`status=203/EXEC`. systemd не смог выполнить ExecStart. Неверный путь, файл не исполняемый или скрипт без shebang либо с неверным. Выполните ту же команду вручную от пользователя службы.

`status=200/CHDIR`. WorkingDirectory не существует, или пользователь службы не может в него войти.

`Start request repeated too quickly`. Лимит запусков. Устраните настоящую проблему, затем systemctl reset-failed.

Вручную запускается, при загрузке падает. Зависимость не была готова. Добавьте After= для того, что ей нужно, и Wants=network-online.target, если она привязывается к конкретному адресу.

Работает, но ничего не может записать. Либо ProtectSystem=strict без соответствующего ReadWritePaths=, либо обычные права владельца файлов. Журнал назовёт путь.

Переменные окружения пусты. Путь к файлу неверный, а вы поставили префикс -, так что сбой тихий. Проверьте через systemctl show notes -p Environment.

В оболочке работает, как служба - нет. Почти всегда дело в окружении: у вашей login-оболочки есть PATH, HOME, языковые настройки и, возможно, менеджер версий, которых у службы нет. Задавайте нужное явно в unit-е, а не наследуйте по случайности.

Правки unit-а ничего не меняют. Вы забыли daemon-reload.

На управляемых тарифах RE:NODE вам ничего из этого писать не нужно: панель сама запускает и останавливает процесс, наблюдатель каждые две минуты проверяет, не ушёл ли сервер в офлайн и не откатился ли его uptime назад, а три незапрошенных перезапуска за час выводят предупреждение и автоматически открывают тикет. Вкладка Schedules принимает cron-выражение и выполняет упорядоченные задачи - консольную команду, backup, действие с питанием, - и это управляемый эквивалент таймера выше. На VDS у вас есть root, и то же поведение вы собираете сами из деталей этой статьи: работы больше, а контроля заметно больше. Честное сравнение - в статье что выбрать: VDS или игровую панель, а что делать до всего остального, описано в статье первый час на новом VDS.

FAQ#

Нужны ли мне ещё pm2 или supervisor?

Чтобы держать процесс живым - нет: systemd делает это лучше, на более низком уровне, сразу с логами и зависимостями. Менеджеры процессов по-прежнему оправданы возможностями, которых у systemd нет, например запуском нескольких экземпляров приложения на Node по ядрам с перезагрузкой без простоя. Использовать оба сразу - распространённая и легко устранимая ошибка; пересечение разобрано в статье pm2 или панель хостинга.

Стоит ли запускать службы от root?

Нет. Создайте системного пользователя без login-оболочки, задайте User= в unit-е и дайте ему право записи только в те каталоги, которые ему нужны. Если службе необходим низкий порт, используйте AmbientCapabilities=CAP_NET_BIND_SERVICE или поставьте перед ней прокси, вместо того чтобы хвататься за root.

В чём разница между systemctl start и systemctl enable?

start запускает службу сейчас. enable заставляет её стартовать при загрузке. Они независимы, и служба, которую вы запустили, но не включили, пропадёт после следующей перезагрузки. enable --now делает и то и другое.

Можно ли запускать контейнеры Docker через systemd?

Да, и это разумный шаблон, когда контейнер должен стартовать после смонтированной файловой системы или в определённом порядке. Напишите unit, который запускает docker compose up -d с Type=oneshot и RemainAfterExit=yes, либо для простого случая используйте собственные политики перезапуска Docker - о них рассказано в статье Docker на VDS: с чего начать.

Почему мои логи пропадают после перезагрузки?

На некоторых дистрибутивах журнал непостоянный и хранится в /run. Создайте /var/log/journal, задайте Storage=persistent в journald.conf, перезапустите systemd-journald и ограничьте размер, чтобы он не мог забить диск.

Как запустить службу от своего пользователя без root?

systemctl --user управляет unit-ами из ~/.config/systemd/user/. По умолчанию они останавливаются, когда вы выходите из системы, поэтому один раз выполните loginctl enable-linger yourname, чтобы они продолжали работать. Это удобно для личных инструментов и не годится для всего, от чего зависит аптайм машины.


Комментарии

Полностью анонимно: без аккаунта, без почты, без cookie. Мы храним имя, которое вы ввели, текст и время - больше ничего. Количество ссылок ограничено, разметка не отображается.

0/2000