Как создать собственную systemd-службу и запускать её автоматически при загрузке
Unit-файл строка за строкой: Type, User, WorkingDirectory, Restart и network-online.target. С дословными текстами ошибок и доказательством, что служба действительно переживает перезагрузку.
Программа, которая после перезагрузки не поднимается сама, на сервере не служба, а риск. nohup, screen и tmux удерживают процесс живым ровно до тех пор, пока ничего не происходит. В этой статье собственный unit-файл разбирается строка за строкой, приводятся дословные тексты сообщений об ошибках и описывается, как выбраться, если служба застряла в карусели перезапусков.
Все команды выполняются от root. Проверено на Debian 13 (systemd 257), Debian 12 (252), Ubuntu 24.04 LTS (255) и Ubuntu 22.04 LTS (249). Там, где эти четыре системы расходятся, это указано отдельно.
Что даёт systemd-служба и чего не дают nohup и screen
Разница не в удобстве, а в ответственности. systemd запускает процесс при загрузке в заданном порядке, помещает его в отдельную cgroup, собирает stdout и stderr в журнал, перезапускает после падения и корректно завершает при выключении сигналом SIGTERM. Больнее всего потом не хватает именно cgroup: скрипт, запущенный через nohup, оставляет за собой осиротевшие дочерние процессы, а юнит вычищает свою группу целиком.
Подготовка: программа, пользователь и каталог
Прежде чем появится юнит, должно работать то, что он будет запускать. В качестве примера возьмём скрипт, который пишет в stdout. В этом и смысл: служба под systemd не ведёт лог-файл сама, она пишет в stdout, а systemd складывает вывод в журнал.
mkdir -p /opt/kh-demo
cat > /opt/kh-demo/run.sh <<'EOF'
#!/bin/bash
set -euo pipefail
while true; do
echo "kh-demo работает, $(date --iso-8601=seconds), PWD=$PWD, GREETING=${GREETING:-не задано}"
sleep 10
done
EOF
chmod +x /opt/kh-demo/run.sh
Дальше отдельный системный пользователь: --system выдаёт UID меньше 1000, /usr/sbin/nologin закрывает интерактивный вход.
useradd --system --no-create-home --home-dir /opt/kh-demo --shell /usr/sbin/nologin khdemo
id khdemo
chown -R root:khdemo /opt/kh-demo
chmod 750 /opt/kh-demo
Ключевая проверка перед написанием unit-файла: запускается ли программа от этого пользователя? Если её пропустить, потом придётся отлаживать systemd, хотя проблема сидит в самой программе.
timeout 3 runuser -u khdemo -- /opt/kh-demo/run.sh; echo "Exitcode $?"
Exitcode 124 здесь и есть желаемый результат: timeout прервал работающую программу. Любое другое значение означает, что скрипт завершился сам, и тогда виноват не systemd.
Unit-файл строка за строкой
Собственные юниты кладут в /etc/systemd/system/, а не в /lib/systemd/system/ или /usr/lib/systemd/system/: эти два каталога принадлежат менеджеру пакетов и будут перезаписаны при ближайшем apt upgrade.
cat > /etc/systemd/system/kh-demo.service <<'EOF'
[Unit]
Description=KernelHost Demo Worker
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=khdemo
Group=khdemo
WorkingDirectory=/opt/kh-demo
EnvironmentFile=-/etc/kh-demo.env
ExecStart=/opt/kh-demo/run.sh
Restart=on-failure
RestartSec=5s
TimeoutStopSec=20s
SyslogIdentifier=kh-demo
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
[Install]
WantedBy=multi-user.target
EOF
Секция [Unit]
Description= — это текст, который показывает systemctl status. Технического эффекта нет, но именно от него зависит, поймёте ли вы в три часа ночи, что именно упало. Одно предложение, а не одно слово.
After= задаёт только порядок, но не зависимость. After=network-online.target означает: если этот target и так стартует в текущей загрузке, дождись его. Это не означает, что он будет запущен.
Wants= действительно запрашивает его, причём как слабую зависимость. Если target не поднимется, служба всё равно стартует. Аналог Requires= утянул бы службу за собой в ошибку. Для большинства приложений правильный вариант это Wants=, потому что служба, которая вообще не стартует из-за сетевого тайм-аута, хуже той, которая недолго поработает вхолостую и вернётся благодаря Restart=.
Секция [Service]
User= и Group= задают, от чьего имени работает процесс. Без этих строк служба работает от root. В Debian и Ubuntu useradd по умолчанию создаёт одноимённую группу, поэтому Group=khdemo подходит.
WorkingDirectory= — рабочий каталог процесса. Без этого указания служба стартует в /. Любая программа, которая ищет конфигурации, шаблоны или плагины относительно текущего каталога, тут же ломается. Если указанный каталог не существует, запуск заканчивается кодом 200/CHDIR.
EnvironmentFile= подгружает переменные из файла. Ведущий минус перед путём делает файл необязательным. Без минуса запуск сорвётся, если файла нет. Внутри лежат простые строки KEY=VALUE, а не шелл. export перед ними ошибка, и PORT=$BASE_PORT подставлен не будет.
ExecStart= требует абсолютного пути к исполняемому файлу. Именно на этом правиле спотыкается большинство, поэтому ниже для него отдельный раздел.
TimeoutStopSec= ограничивает, сколько systemd ждёт после SIGTERM, прежде чем послать SIGKILL. По умолчанию это 90 секунд.
SyslogIdentifier= задаёт имя, под которым строки появляются в журнале. Без этой строки отправителем будет run.sh, что бесполезно при фильтрации.
NoNewPrivileges=true запрещает процессу и всем его потомкам получать права через setuid-бинарники. PrivateTmp=true выдаёт службе собственный /tmp. ProtectSystem=full монтирует /usr, /boot и /etc только для чтения. Уровень strict идёт дальше и требует тогда StateDirectory= или ReadWritePaths= для всего, куда нужна запись.
Секция [Install]
WantedBy=multi-user.target отвечает на вопрос, когда служба должна стартовать автоматически. multi-user.target — это обычный многопользовательский режим без графической оболочки, то есть то состояние, до которого доходит сервер. Секцию учитывает только команда systemctl enable, она же создаёт символьную ссылку. Если [Install] нет, enable прерывается с сообщением The unit files have no installation config (WantedBy=, RequiredBy=, Also=, Alias= settings in the [Install] section, and DefaultInstance= for template units). Такую службу можно запустить вручную, но после перезагрузки она уже никогда не вернётся.
Type=simple, Type=exec и Type=forking
Type= отвечает ровно на один вопрос: по какому признаку systemd понимает, что служба запустилась?
При Type=simple служба считается запущенной, как только процесс создан, то есть сразу после fork() и ещё до того, как программа начнёт выполняться. Это значение по умолчанию, и для почти всех современных программ, которые остаются на переднем плане, оно верное.
При Type=exec systemd дополнительно дожидается успешного execve(). Практическая разница существенная: с Type=simple команда systemctl start отрапортует об успехе даже тогда, когда бинарника вообще нет, а ошибка всплывёт только потом в журнале. С Type=exec в том же случае запуск падает сразу. Доступно начиная с systemd 240, то есть на всех четырёх дистрибутивах.
При Type=forking systemd исходит из того, что программа сама уходит в фон: запущенный процесс завершается, дочерний продолжает работу, и именно это завершение считается сигналом об успешном старте. Так ведёт себя классический Unix-демон. Кто выбирает этот тип, почти всегда вынужден добавить ещё PIDFile= с абсолютным путём, иначе systemd будет гадать, какой из оставшихся процессов главный.
Рекомендация однозначная: Type=forking только тогда, когда программу никакими средствами не удаётся удержать на переднем плане. Почти у любого софта для этого есть ключ, чаще всего --foreground, -D FOREGROUND, --no-daemon или daemon off; в конфигурации. Передний план плюс Type=simple дают более короткий юнит, логи попадают в журнал, а Restart= работает предсказуемо.
Типичная неверная комбинация: программа, которая сама уходит в фон, под Type=simple. systemd видит завершение стартового процесса, считает службу законченной и убивает потомков заодно. Симптом — Active: inactive (dead) сразу после systemctl start, который не сообщил ни о какой ошибке. Обратный случай, программа переднего плана под Type=forking, заставляет systemd ждать завершения, которое никогда не наступит: Job for foo.service failed because a timeout was exceeded, через 90 секунд.
Как правильно подключить After=network-online.target
Здесь почти любое руководство обрывается слишком рано. network.target означает лишь то, что подсистема управления сетью запущена, а не то, что IP-адрес уже настроен. Если нужен доступный адрес, например потому что программа привязывается к фиксированному IP, нужен network-online.target.
Но сам по себе этот target не достигается. Он требует, чтобы была включена подходящая служба ожидания, а она отличается в зависимости от того, кто управляет сетью:
systemctl list-unit-files 'systemd-networkd-wait-online.service' 'NetworkManager-wait-online.service' 'ifupdown-wait-online.service' --no-pager
На Ubuntu Server 22.04 и 24.04 сетью управляет netplan вместе с systemd-networkd, там активна systemd-networkd-wait-online.service и network-online.target имеет реальный смысл. В классической установке Debian с ifupdown служба ifupdown-wait-online.service хотя и существует, но не включена. Target тогда считается достигнутым мгновенно, и ожидание, на которое вы рассчитываете, просто не происходит.
На Debian 12 и 13 с ifupdown службу ожидания при необходимости включают один раз:
systemctl enable ifupdown-wait-online.service
Включать две службы ожидания одновременно плохая идея: каждая будет ждать свою подсистему управления сетью, и одна неизбежно упрётся в 90-секундный тайм-аут. Это самая частая причина того, что сервер вдруг грузится на полторы минуты дольше. Надёжнее любого порядка запуска всё равно программа, которая переживает отсутствие соединения при старте, в паре с Restart=on-failure.
Restart, RestartSec и ограничитель частых запусков
Restart=on-failure перезапускает службу при коде выхода, отличном от нуля, при сигнале вроде SIGSEGV и при срабатывании watchdog, но не после выхода с кодом 0 и не после ручного systemctl stop. Restart=always перезапускает вдобавок и после штатного завершения, и это самый быстрый способ построить себе бесконечный цикл.
Против этого у systemd есть встроенный тормоз: по умолчанию разрешено пять попыток запуска за десять секунд (StartLimitBurst=5, StartLimitIntervalSec=10s). После этого systemd сдаётся и сообщает:
kh-demo.service: Start request repeated too quickly.
kh-demo.service: Failed with result 'exit-code'.
Failed to start kh-demo.service - KernelHost Demo Worker.
Служба остаётся в состоянии failed и перестаёт реагировать даже на systemctl start, пока счётчик не сброшен:
systemctl reset-failed kh-demo.service
Две детали здесь регулярно стоят времени. Во-первых, StartLimitBurst= и StartLimitIntervalSec= относятся к секции [Unit], а не к [Service]. В неправильной секции они игнорируются, с предупреждением в журнале, но без ошибки. Во-вторых, ручные вызовы systemctl restart тоже считаются: кто при отладке перезапускает службу пять раз подряд, включает ограничитель собственными руками.
Практическое правило: StartLimitIntervalSec больше, чем RestartSec, умноженное на StartLimitBurst.
На Debian 13 и Ubuntu 24.04 дополнительно есть RestartSteps= и RestartMaxDelaySec= (начиная с systemd 254) для экспоненциально растущих пауз. На Debian 12 и Ubuntu 22.04 этих директив нет, и они просто игнорируются.
Включаем, запускаем и доказываем, что работает
Перед первым запуском стоит проверить синтаксис. Проверка находит опечатки в именах директив, неверно написанные секции и отсутствующие программы, ничего при этом не запуская:
systemd-analyze verify /etc/systemd/system/kh-demo.service
Пустой вывод означает, что всё в порядке. Опечатка вроде WorkingDirectiry= даёт, в зависимости от версии systemd, Unknown key name 'WorkingDirectiry' in section 'Service', ignoring или Unknown key 'WorkingDirectiry' in section [Service], ignoring. Именно здесь рождаются тихие ошибки: systemd игнорирует неизвестные ключи, служба стартует, но ведёт себя не так, как ожидалось.
systemctl daemon-reload
daemon-reload перечитывает unit-файлы, но ничего не перезапускает. Кто забудет про него, получит при следующем systemctl status: Warning: The unit file, source configuration file or drop-ins of kh-demo.service changed on disk. Run 'systemctl daemon-reload' to reload units. Порядок запомнить просто: сначала daemon-reload, потом restart.
systemctl enable kh-demo.service
systemctl start kh-demo.service
Оба шага сразу выполняет systemctl enable --now kh-demo.service.
Теперь та часть, которую большинство руководств опускает: доказательство, что всё действительно заработало, а не просто обошлось без ошибок. Четыре независимых подтверждения.
Первое: символьная ссылка существует. Она и есть весь механизм, стоящий за enable. Если её нет, после перезагрузки служба не поднимется, что бы ни показывал status прямо сейчас.
ls -l /etc/systemd/system/multi-user.target.wants/kh-demo.service
systemctl is-enabled kh-demo.service
Второе: состояние. Решающим является уточнение в скобках в строке Active:. active (running) значит, что процесс работает. active (exited) значит, что программа отработала и никого больше не осталось. Для постоянно работающей службы это ошибка, даже если рядом горит зелёное.
systemctl status kh-demo.service --no-pager
systemctl is-active kh-demo.service
Третье: журнал. Важно не только то, что строки приходят, но и то, что приходят правильные.
journalctl -u kh-demo.service -n 20 --no-pager
Читать в реальном времени: journalctl -u kh-demo.service -f. Только последняя загрузка: -b. Промежуток времени: --since "-1h". Подробнее об этом в статье journalctl: разбор логов в systemd.
Четвёртое: действующая конфигурация. Не файл, а то, что systemd из него сделал. Разница становится важной, как только в игру вступают drop-in-файлы.
systemctl show kh-demo.service -p ExecStart -p User -p WorkingDirectory -p Restart -p MainPID
systemctl cat kh-demo.service
Окончательным доказательством остаётся настоящая перезагрузка. После неё эта команда покажет, не потерялось ли что-нибудь по дороге:
systemctl list-units --type=service --state=failed --no-pager
Самые частые ошибки, дословно
Об ошибках запуска systemd сообщает собственными кодами выхода выше 200. Число в выводе status уже подсказывает, где искать.
| Код | Значение | Причина |
|---|---|---|
| 200/CHDIR | EXIT_CHDIR | WorkingDirectory= не существует или пользователь не может в него зайти |
| 203/EXEC | EXIT_EXEC | ExecStart= не найден, не является исполняемым или указан неверный интерпретатор |
| 216/GROUP | EXIT_GROUP | Group= не существует |
| 217/USER | EXIT_USER | User= не существует |
| 219/CGROUP | EXIT_CGROUP | не удалось создать cgroup |
| 238/STATE_DIRECTORY | EXIT_STATE_DIRECTORY | StateDirectory= уже существует, но с неправильным владельцем |
203/EXEC: неверный путь в ExecStart
В журнале это выглядит как строка вида kh-demo.service: Failed at step EXEC spawning /opt/kh-demo/run.sh: No such file or directory, за которой следует Main process exited, code=exited, status=203/EXEC.
No such file or directory вводит в заблуждение, потому что у этого сообщения три причины. Первая: файла действительно нет, чаще всего из-за опечатки или потому что бинарник лежит в /usr/local/bin, а не в /usr/bin. Вторая: не выставлен бит исполнения, тогда журнал сообщает Permission denied. Третья, самая коварная: файл исполняемый, но его строка shebang указывает в пустоту. Скрипт с #!/usr/bin/python падает на Debian 12 и новее именно так, потому что там существует только /usr/bin/python3. Ядро сообщает об отсутствии интерпретатора, а systemd передаёт это дальше как отсутствие самого скрипта.
Проверка занимает три секунды:
ls -l /opt/kh-demo/run.sh
head -n 1 /opt/kh-demo/run.sh
Относительные пути и PATH
ExecStart=node server.js не работает, в том числе и с заданным WorkingDirectory=. Ошибка при загрузке юнита звучит как Neither a valid executable name nor an absolute path. Абсолютной должна быть только первая запись, аргументы после неё могут оставаться относительными. Правильно так: ExecStart=/usr/bin/node server.js, где server.js разрешается относительно WorkingDirectory. Правильный путь выдаёт command -v:
command -v bash
Осторожно с менеджерами версий: под nvm команда вернёт путь вроде /root/.nvm/versions/node/v22.14.0/bin/node, которого для пользователя службы не существует. Среды выполнения для служб ставят системно, например через NodeSource или Adoptium Temurin.
Отсутствующие переменные окружения
Классика жанра: при интерактивном входе программа работает, а как служба нет. Причина в том, что systemd не запускает login-шелл. Ни /etc/profile, ни ~/.bashrc, ни ~/.profile не читаются, и всё, что задано там через export, в службе отсутствует. PATH системной службы — это фиксированный минимальный путь, обычно /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin.
Поэтому переменные прописывают в сам юнит или в отдельный файл:
cat > /etc/kh-demo.env <<'EOF'
GREETING=привет
LANG=ru_RU.UTF-8
EOF
chmod 640 /etc/kh-demo.env
chown root:khdemo /etc/kh-demo.env
systemctl restart kh-demo.service
journalctl -u kh-demo.service -n 5 --no-pager
Если в выводе теперь стоит GREETING=привет вместо GREETING=не задано, файл дошёл. Проверить это можно и напрямую:
systemctl show kh-demo.service -p Environment -p EnvironmentFiles
Три ловушки в таких файлах: подстановка команд через $(...) не выполняется, буквальный знак $ нужно писать как $$, а кавычки попадают внутрь значения, где им не место.
Unit not found
Failed to start kh-demo.service: Unit kh-demo.service not found. почти всегда означает одно из трёх: файл лежит не в том каталоге, у него неверное расширение (.services вместо .service) или забыт daemon-reload. Первые два случая проясняет ls -l /etc/systemd/system/.
Различия между Debian 13, Debian 12, Ubuntu 24.04 и 22.04
Основы, то есть [Unit], [Service], [Install], Type=simple, Restart=, User= и WorkingDirectory=, на всех четырёх системах одинаковы. Различия начинаются на периферии.
Доступные директивы. В Ubuntu 22.04 идёт systemd 249, в Debian 12 systemd 252, в Ubuntu 24.04 systemd 255, в Debian 13 systemd 257. Всё, что появилось начиная с 253, на двух более старых системах отсутствует: Type=notify-reload (253), а также RestartSteps=, RestartMaxDelaySec= и RestartMode=direct (254). Ошибкой это не считается, директивы просто игнорируются. Так и появляются юниты, которые на одной системе делают задуманное, а на другой без видимой причины что-то другое.
systemctl --version
Сеть. Ubuntu Server использует netplan вместе с systemd-networkd, Debian в стандартной установке ifupdown. Из-за этого network-online.target на Ubuntu осмыслен без дополнительных действий, а на Debian только после включения ifupdown-wait-online.service.
Пути к интерпретаторам и средам выполнения. Здесь возникает большинство ошибок 203/EXEC при переносе юнита между системами. Node.js в Debian 13 идёт версии 20.19, в Debian 12 версии 18.20, в Ubuntu 24.04 версии 18.19, в Ubuntu 22.04 версии 12.22. PHP это 8.4 на Debian 13, 8.2 на Debian 12, 8.3 на Ubuntu 24.04 и 8.1 на Ubuntu 22.04. У Java разрыв самый большой: Debian 13 предлагает только openjdk-21-jre-headless, Debian 12 только openjdk-17-jre-headless, а Ubuntu 24.04 и 22.04 сразу 8, 11, 17 и 21. Кто скопирует ExecStart=/usr/lib/jvm/java-17-openjdk-amd64/bin/java с Debian 12 на Debian 13, гарантированно получит 203/EXEC.
Базы данных. Debian не поставляет mysql-server, там всегда работает MariaDB. Юнит с After=mysql.service ждёт на Debian службу, которой не существует, и стартует вообще без задержки, молча и без предупреждения. Правильно там After=mariadb.service.
Изменение, откат, уборка
Собственные юниты меняют прямо в файле плюс daemon-reload. Для юнитов из пакета берут вместо этого drop-in-файлы, чтобы следующее обновление не смело правку:
systemctl edit kh-demo.service
Команда создаёт /etc/systemd/system/kh-demo.service.d/override.conf, где остаются только изменённые директивы. Особый случай: списочные директивы вроде ExecStart= не заменяются, а дополняются. Чтобы такую директиву перезаписать, список сначала нужно очистить:
ExecStart=
ExecStart=/opt/kh-demo/run.sh --verbose
systemctl revert kh-demo.service убирает все drop-in-файлы обратно. Полный демонтаж примера, ровно в таком порядке:
systemctl disable --now kh-demo.service
rm -f /etc/systemd/system/kh-demo.service
rm -rf /etc/systemd/system/kh-demo.service.d
systemctl daemon-reload
systemctl reset-failed
Внимание на последнем шаге: systemctl reset-failed без аргумента сбрасывает состояние ошибки у всех юнитов, а не только у демонстрационной службы. На системе, где в состоянии failed висят и другие службы, из вывода systemctl list-units --state=failed пропадут и их сообщения. Кто хочет прибраться только здесь, берёт точечную форму systemctl reset-failed kh-demo.service из раздела выше.
Кто удалит файл, не вызвав перед этим disable, оставит после себя битую символьную ссылку в /etc/systemd/system/multi-user.target.wants/, которая будет всплывать предупреждением при каждом daemon-reload. Убирают её командой find /etc/systemd/system -xtype l -delete. Правда, этот вызов удалит любую битую ссылку внутри /etc/systemd/system, то есть и остатки чужих служб, которые, возможно, ещё нужны. Безопаснее сначала посмотреть без -delete или сразу взять точечный вариант find /etc/systemd/system -xtype l -name 'kh-demo*' -delete. По желанию можно убрать ещё пользователя и файлы:
userdel khdemo
rm -rf /opt/kh-demo /etc/kh-demo.env
Абсолютный путь в ExecStart, подходящий Type, отдельный пользователь, заданный WorkingDirectory, переменные через EnvironmentFile=: у кого всё это на месте, у того служба переживает перезагрузку и в случае сбоя сама рассказывает, что произошло.
Частые вопросы
Нужно ли выполнять systemctl daemon-reload после каждого изменения unit-файла?
Что означает status=203/EXEC?
Почему служба не стартует после перезагрузки, хотя systemctl start работает?
Type=simple или Type=forking?
Почему служба не видит свои переменные окружения?
Служба перестала реагировать на systemctl start, что делать?
2026 KernelHost GmbH. Все права защищены. Эта инструкция охраняется авторским правом. Публикация на других сайтах, в том числе частично или в изменённом виде, без нашего письменного согласия не разрешается. Цитирование с указанием источника и активной ссылкой мы приветствуем.

