Приложение, запущенное из SSH-сессии, умирает вместе с сессией. Приложение, запущенное через nohup ... &, переживает сессию и умирает при перезагрузке. Приложение внутри screen или tmux переживает и то и другое - до того дня, когда кто-нибудь закроет не то окно, - и при этом не оставляет логов, которые кто-то сможет найти. Unit-файл systemd - это двенадцать строк текста, которые решают все три проблемы сразу: процесс стартует при загрузке, перезапускается после сбоя, работает от нужного пользователя с нужным окружением и отправляет всё, что печатает, в журнал, по которому можно делать запросы по времени и по уровню важности.
Это та часть самостоятельного администрирования сервера, которая окупается быстрее всего, и она действительно небольшая. Ниже - рабочий unit-файл, затем каждая директива в нём, о которой стоит знать, затем две вещи, на которых всё реально ломается - лимит запусков и файл окружения, - а после них логи, защита, несколько экземпляров одной службы и таймеры как замена cron.
Что даёт systemd, чего не дают tmux и nohup#
- Он запускается при загрузке, ещё до того, как кто-либо вошёл в систему, в заданном порядке относительно сети и файловых систем, которые ему нужны.
- Он перезапускает службу после сбоя с задержкой, которую вы задаёте, и с лимитом, чтобы окончательно сломанная служба не крутилась вхолостую.
- Он владеет всем деревом процессов. Всё, что порождает служба, живёт в одной контрольной группе, поэтому остановка службы останавливает и дочерние процессы, а не оставляет сирот, которые держат порт.
- Он запускает службу от выбранного пользователя с выбранными рабочим каталогом, umask и окружением, и ничто из этого не зависит от того, чья оболочка её запустила.
- Он собирает вывод в одном месте. Всё, что записано в стандартный вывод или стандартный поток ошибок, попадает в журнал с пометкой unit-а, доступно для запросов по времени и ротируется без написания файла logrotate.
- Он умеет сбрасывать привилегии и сужать поверхность ядра десятком однострочных опций, а это самая дешёвая защита из всех существующих.
Чего он не умеет - так это знать, работает ли ваше приложение. Зависший процесс, который больше не отвечает на запросы, вполне устраивает systemd. Проверка живости - это health check плюс что-то, что на него реагирует, и это другая работа: про прикладную половину рассказано в статье корректное завершение и health check.
Ваш первый unit-файл#
[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$ sudo systemctl daemon-reload$ sudo systemctl enable --now notes$ systemctl status notes$ journalctl -u notes -fdaemon-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 стоит освоить в первый же день:
$ 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 - по умолчанию пять запусков за десять секунд, - и, упёршись в этот лимит, вообще перестаёт пытаться и сообщает:
notes.service: Start request repeated too quickly.notes.service: Failed with result 'start-limit-hit'.Это systemd поступает правильно: служба, которая мгновенно умирает пять раз подряд, сломана, а не невезуча. Удивляет другое: после этого служба остаётся мёртвой, пока не вмешается человек, даже когда причину уже устранили. Лечение - sudo systemctl reset-failed notes и затем запуск, а настройка находится в [Unit]:
[Unit]StartLimitIntervalSec=60StartLimitBurst=5Обычно правильнее расширять окно, а не увеличивать количество запусков. Если ваша служба падает за десять секунд, то пять падений уже растягиваются на пятьдесят секунд, и интервал по умолчанию никогда не срабатывает - поэтому сообщение появляется в основном у служб, которые падают из-за отсутствующего файла быстрее чем за секунду. Тот же шаблон в управляемой среде и то, что с ним делает хостер, разобраны в статье почему ваш игровой сервер постоянно перезапускается.
Переменные окружения и секреты#
Есть три способа передать конфигурацию в unit, и они накладываются друг на друга.
Environment=NODE_ENV=productionEnvironment="GREETING=hello there"EnvironmentFile=-/etc/notes/notes.envEnvironment= - для несекретных значений, которые вас не смущает видеть в unit-файле, читаемом всеми. EnvironmentFile= указывает на файл со строками KEY=value, и именно там место секретам, потому что владельцем файла может быть root с правами 0600, а сам unit при этом остаётся читаемым.
$ 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.envDATABASE_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делает отсутствие файла некритичным. Без него опечатка в пути вообще не даёт службе запуститься - а для файла с паролем от базы данных это иногда именно то, чего вы хотите.
Проверяйте, что служба на самом деле получила, а не предполагайте:
$ systemctl show notes -p Environment$ sudo cat /proc/$(systemctl show notes -p MainPID --value)/environ | tr '\0' '\n'Вторая команда - ещё и повод подумать о том, кто может читать окружение процесса на общей машине. Более широкий вопрос, включая то, что вообще не должно быть переменной окружения, разобран в статье переменные окружения и секреты.
journalctl: куда делся вывод#
Всё, что служба пишет в стандартный вывод или стандартный поток ошибок, попадает в журнал с пометкой имени unit-а. Ни перенаправлений, ни лог-файла, ни настройки logrotate.
$ 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 и исчезает при перезапуске машины, а это делает разбор случившегося невозможным.
$ journalctl --disk-usage$ sudo mkdir -p /var/log/journal$ sudo systemd-tmpfiles --create --prefix /var/log/journal$ sudo systemctl restart systemd-journaldЗатем ограничьте его, потому что журнал без границ у шумной службы - это медленный способ забить диск:
Storage=persistentSystemMaxUse=500MMaxRetentionSec=1monthsudo journalctl --vacuum-time=14d сразу вычищает существующий журнал. А если вы видите в логе Suppressed N messages from notes.service, это ограничитель частоты journald - по умолчанию несколько тысяч сообщений за короткое окно, - и он означает, что ваша служба слишком шумная, а не что journald сломан. О том, что вообще стоит туда писать, рассказано в статье логи, которые стоит хранить.
Ограничиваем службу#
Каждая из этих опций - одна строка, и большинство ничего не стоят. Добавляйте их по несколько штук и проверяйте, потому что пара из них действительно ломает реальное ПО.
NoNewPrivileges=yesPrivateTmp=yesProtectSystem=strictProtectHome=yesReadWritePaths=/srv/notes/uploadsProtectKernelTunables=yesProtectKernelModules=yesProtectControlGroups=yesRestrictAddressFamilies=AF_INET AF_INET6 AF_UNIXLockPersonality=yesProtectSystem=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 откажутся запускаться. Измеряйте результат того, что выбрали:
$ systemd-analyze security notes.serviceКоманда печатает строку на каждую настройку и общую оценку уязвимости. Относитесь к ней как к чек-листу, а не как к цели: переход от «unsafe» к «medium» - это почти вся доступная польза, а последние несколько пунктов обычно обходятся дороже, чем дают.
Шаблонные unit-ы и таймеры#
Шаблонный unit запускает много копий одной конфигурации. Имя файла оканчивается на @, а %i заменяется тем, что стоит после @ при запуске.
[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$ sudo systemctl enable --now valheim@midgard$ sudo systemctl enable --now valheim@testworld$ journalctl -u valheim@midgard -fДва сервера, один unit-файл, отдельные файлы окружения, отдельные каталоги, отдельные логи. KillSignal=SIGINT и длинный TimeoutStopSec стоят здесь потому, что это игровой сервер, который сохраняется при остановке, и ему нужно дать закончить. Полная версия этого шаблона, включая порты и пользователей, - в статье несколько игровых серверов на одном VDS.
Таймеры заменяют cron, и довод в пользу перехода не эстетический. Таймер - это два файла: служба oneshot и таймер, который её запускает, - и служба получает всё описанное выше: пользователя, файл окружения, лимиты ресурсов, защиту и вывод в журнале вместо письма, которое никто не читает.
[Unit]Description=Nightly database dump[Service]Type=oneshotUser=postgresExecStart=/usr/local/bin/db-backup.sh[Unit]Description=Run the database dump at 04:30[Timer]OnCalendar=*-*-* 04:30:00Persistent=trueRandomizedDelaySec=300[Install]WantedBy=timers.target$ 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 demandPersistent=true - это то, чего нет у cron: если машина была выключена в 04:30, задание выполнится один раз вскоре после её включения, а не будет молча пропущено. RandomizedDelaySec разводит несколько машин, чтобы они не обращались к одному и тому же хранилищу бэкапов одновременно. А systemd-analyze calendar показывает, когда выражение сработает в следующий раз, а это лучше, чем ждать до завтра и узнавать, что вы записали его неверно. Если вам привычнее синтаксис cron, пять его полей разобраны в статье cron-выражения простыми словами, и учтите, что таймер хорош ровно настолько, насколько хорош скрипт, который он запускает, поэтому прочитайте бэкапы, которые действительно восстанавливаются, прежде чем доверять любому из двух.
Если служба не запускается#
$ 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. Мы храним имя, которое вы ввели, текст и время - больше ничего. Количество ссылок ограничено, разметка не отображается.