Как выбрать Type в systemd для корректной работы службы
Юнит systemd показывает статус active, но демон завершился? Узнайте, как правильно настроить параметры simple, forking, notify и oneshot, чтобы избежать ошибок при отслеживании PID.
Почему systemd сообщает, что юнит активен, хотя процесс завершился
Юнит службы systemd остается в состоянии active, пока жив процесс, который systemd считает основным. Параметр Type= в секции [Service] определяет, какой именно это процесс. Если выбрать неверное значение, systemd будет отслеживать оболочку-обертку или кратковременный родительский процесс, в то время как нужный вам демон внутри того же юнита завершится. Юнит сообщает корректные данные о процессе, который ему было поручено отслеживать.
Изменение политики перезапуска здесь не поможет. Restart= срабатывает только после завершения основного процесса, поэтому Restart=always никогда не активируется, пока основной PID (идентификатор процесса) принадлежит чему-то, что все еще выполняется. Сначала исправьте Type=. Решение о том, что именно systemd предпримет после фактического завершения основного процесса, принимается отдельно и описано в руководстве по Restart= и RestartSec=.
За что отвечает параметр Type=
Каждое значение Type= одновременно определяет два аспекта. Первый — когда systemd считает юнит запущенным, второй — какой процесс является основным.
Первый ответ управляет порядком запуска. Юнит, который ссылается на ваш в параметре After=, будет ожидать, пока systemd не отметит ваш юнит как запущенный. Если Type= сообщает о готовности слишком рано, зависимые юниты могут попытаться запуститься до того, как ваш сервис будет готов принимать запросы.
Второй ответ управляет мониторингом. systemd помещает каждый процесс, порожденный юнитом, в cgroup (control group). Это функция ядра, позволяющая группировать процессы для ограничения ресурсов и их совместного завершения. Именно через cgroup работает механизм systemctl stop: по умолчанию KillMode= имеет значение control-group, поэтому при остановке юнита сигнал отправляется всем процессам внутри группы. Понятие основного PID (main PID) более узкое. Это конкретный процесс, завершение которого считается завершением всего юнита, а его код выхода становится результатом работы юнита. Путаница часто возникает из-за того, что cgroup ошибочно принимают за основной PID.
Тип Type=simple сообщает о запуске до выполнения бинарного файла
Type=simple является значением по умолчанию, если задан ExecStart=, а Type= и BusName= отсутствуют. systemd создает процесс, сразу считает юнит запущенным и назначает этот процесс основным PID. Последующие юниты начинают работу немедленно, еще до того, как бинарный файл сервиса был выполнен.
Эта деталь объясняет распространенную неожиданность. Опечатка в пути ExecStart= все равно приводит к успешному выполнению задачи запуска, а ошибка возникает чуть позже, когда выполнение не удается. systemd фиксирует этот случай с кодом завершения 203, который в его собственной таблице называется EXEC и определяется как невозможность выполнить бинарный файл сервиса. Таким образом, успешное завершение systemctl start без ошибок не доказывает, что ваш бинарный файл существует.
Используйте simple для программ, которые остаются на переднем плане и не переводят себя в фоновый режим. Это применимо к большинству современных демонов и практически ко всему, что вы пишете самостоятельно.
Type=exec ожидает фактического запуска программы
Type=exec — это simple с одним дополнительным шагом. systemd считает юнит запущенным только после того, как успешно завершились и fork, и выполнение бинарного файла. Отсутствующий бинарный файл или User=, который невозможно разрешить, теперь приводят к сбою самой задачи запуска, вместо того чтобы сообщать об успехе и завершаться с ошибкой чуть позже.
Type=exec появился в systemd 240, поэтому он есть во всех актуальных серверных дистрибутивах. По состоянию на август 2026 года Ubuntu 24.04 поставляется с systemd 255, а Debian 13 — с systemd 257. Проверьте свою версию с помощью systemctl --version.
Цена этого — один дополнительный шаг синхронизации при запуске. Выигрыш — достоверный статус завершения от systemctl start. Для программ, работающих в foreground, отдавайте предпочтение exec, а не simple.
Type=forking и проблема потери основного PID
Type=forking сообщает systemd, что процесс, указанный в ExecStart=, создаст дочерний процесс (fork), а затем намеренно завершится. systemd ожидает завершения этого первого процесса и только после этого считает юнит запущенным. Оставшийся дочерний процесс и является демоном. Эта практика берет начало из эпохи SysV, когда после завершения работы init-скрипта никто не контролировал демона, а PID-файл был единственным способом отследить запущенный процесс — это ограничение лежит в основе того, почему systemd заменил init-скрипты.
Сложность заключается в идентификации. Процесс, который запустил systemd, завершился, поэтому systemd должен определить, какой из оставшихся процессов является основным. Установите PIDFile= в значение пути к файлу, в который демон записывает PID (обычно это путь внутри /run), и systemd прочитает PID из него. systemd также проверяет, относится ли PID из этого файла к процессу, который уже принадлежит данному сервису, поэтому устаревший файл, указывающий на посторонний процесс, будет отклонен, а не принят на веру.
Если не указать PIDFile=, применяется GuessMainPID=, значение которого по умолчанию — yes. Такое предположение надежно только в том случае, если сервис работает как единственный процесс. В руководстве прямо указано ограничение: если демон состоит из нескольких процессов, предположение может оказаться неверным, и механизм обнаружения сбоев перестанет работать. Юнит также может получить основной PID, равный 0, что означает, что у systemd нет процесса для контроля.
Большинство демонов, использующих fork, имеют флаг для запуска в foreground. Используйте этот флаг в Type=exec и удалите строку PIDFile=. Меньшее количество компонентов означает меньше способов потерять PID.
Type=oneshot для задач, которые завершаются
Type=oneshot предполагает, что процесс запустится и завершит работу. systemd считает юнит запущенным только после того, как процесс завершится, что делает oneshot подходящим вариантом для всего, чего должны дожидаться другие юниты. Это также подразумеваемое значение по умолчанию, если в юните не указаны ни Type=, ни ExecStart=.
Для oneshot характерны две особенности. Это единственный тип, который допускает использование нескольких строк ExecStart=, и эти строки выполняются последовательно. Тайм-аут запуска для него по умолчанию отключен, поэтому oneshot, который завис, будет ждать бесконечно, если вы не настроите TimeoutStartSec= самостоятельно.
После завершения процесса юнит возвращается в состояние inactive. RemainAfterExit=yes сохраняет его в состоянии active, при этом сам процесс уже не выполняется. Это намеренная реализация поведения, описанного в начале страницы, и она корректна, когда задача юнита состояла в том, чтобы подготовить окружение, а не поддерживать работу процесса: например, загрузка набора правил firewall или запуск стека контейнеров. Это шаблон, лежащий в основе стека Docker Compose, который восстанавливается после перезагрузки, где юнит выполняет команду compose, завершается и остается активным, так как запущенные им контейнеры продолжают работать. Юнит типа oneshot также используется при срабатывании расписания, что является второй частью запуска задачи по таймеру systemd вместо cron.
Тип Type=notify позволяет сервису сообщить о своей готовности
Type=notify переносит принятие решения на сторону самого сервиса. systemd удерживает задачу запуска открытой до тех пор, пока процесс не отправит READY=1 через Unix-сокет, путь к которому он получает в переменной окружения NOTIFY_SOCKET. Интерфейс на языке C — это sd_notify(3), и многие серверы уже поддерживают его.
Это точный ответ на вопрос «запущен ли сервис». simple и exec сообщают о запуске до того, как сервис успел прочитать конфигурацию или открыть слушающий сокет, поэтому зависимый юнит может запуститься слишком рано и получить ошибку при первом соединении. notify сообщает о запуске в тот момент, когда сам сервис подтверждает свою готовность.
systemd принимает это сообщение только от главного процесса, что и означает NotifyAccess=main, а Type=notify подразумевает это. Если сообщение поступает от дочернего или вспомогательного процесса, установите NotifyAccess=all. Shell-скрипт может вызвать systemd-notify --ready, но он выполняется как отдельный недолговечный процесс, поэтому ему требуется NotifyAccess=all, и systemd может не суметь сопоставить сообщение, отправитель которого уже завершил работу. Сервис, который самостоятельно реализует протокол, работает надежнее.
Стоит знать о двух связанных настройках. Type=notify-reload, доступная начиная с systemd 253, распространяет это же подтверждение на перезагрузки, поэтому systemctl reload завершается только тогда, когда сервис сообщает о завершении перезагрузки, а не сразу после отправки сигнала. WatchdogSec= заставляет уведомляющий сервис отправлять сообщение о поддержании активности (keep-alive) через определенные интервалы, и systemd считает пропуск крайнего срока сбоем.
Type=dbus и Type=idle
Type=dbus ожидает, пока служба не зарегистрирует имя в D-Bus — шине сообщений, которую системные и десктопные службы используют для взаимодействия друг с другом. Этот тип требует наличия BusName= и становится значением по умолчанию, как только задан параметр BusName=. Используйте его только для служб, которые действительно регистрируют имя в шине.
Type=idle работает аналогично simple, но откладывает запуск программы до тех пор, пока не будут выполнены все поставленные в очередь задачи, с ограничением по времени в пять секунд. Этот тип существует для того, чтобы вывод в консоль при загрузке не перемешивался с сообщениями о статусе служб. Он не является инструментом управления порядком запуска и не должен использоваться в обычных службах.
Почему скрипт-обертка приводит к тому, что systemd отслеживает неверный PID
Вот структура, которая вызывает описанную проблему.
[Service]
Type=simple
ExecStart=/opt/app/run.sh#!/bin/bash
export APP_ENV=production
/opt/app/bin/server --config /etc/app.yaml &
/opt/app/bin/exporter --port 9101systemd регистрирует оболочку (shell) в качестве основного PID. Оболочка продолжает работать, пока exporter выполняется на переднем плане. Если server завершается, оболочка этого не замечает, поэтому основной PID остается активным, юнит по-прежнему находится в состоянии active, и Restart= не на что реагировать. Оба процесса все это время остаются в cgroup юнита, поэтому systemctl stop по-прежнему корректно выполняет очистку. Нарушается именно процесс контроля (supervision), а не очистка.
Решение зависит от того, сколько долгоживущих процессов на самом деле содержит юнит.
Если процесс один, замените им оболочку.
#!/bin/bash
export APP_ENV=production
exec /opt/app/bin/server --config /etc/app.yamlexec заменяет оболочку указанной программой и сохраняет тот же PID, поэтому PID, который записал systemd, теперь принадлежит демону. Еще лучше — удалите обертку. Environment= и EnvironmentFile= позволяют передать переменные, а ExecStartPre= — выполнить этап настройки, чтобы systemd мог запускать демон напрямую и знать его PID по определению.
Если процессов два, ни один PID не представляет юнит целиком. Разделите их на два юнита и настройте порядок запуска с помощью After= и Wants=. Один юнит на один процесс — это конфигурация, которую systemd контролирует эффективно, и это единственный способ задать для каждого процесса собственное поведение при перезапуске.
Что меняет ExitType=cgroup
ExitType= был добавлен в systemd 250. Значение по умолчанию — main: юнит считается остановленным, когда завершается основной процесс. При ExitType=cgroup юнит считается запущенным до тех пор, пока жив любой процесс в его cgroup.
[Service]
Type=simple
ExitType=cgroup
ExecStart=/opt/app/launcherЭто решает одну конкретную проблему. При использовании ExitType=main systemd считает юнит остановленным и завершает оставшиеся процессы, если запускающий скрипт (launcher) выполняет свою работу и завершается. С ExitType=cgroup юнит отслеживает всю группу целиком.
Важно понимать, чего это не решает. ExitType=cgroup поддерживает активность юнита, пока жив хотя бы один процесс, поэтому юнит, управляющий двумя демонами, останется активным после падения одного из них. Это исправляет ситуацию с запускающими скриптами. Это не превращает один юнит в супервизор для нескольких независимых процессов. ExitType= также нельзя использовать совместно с Type=oneshot.
Cgroup — это место, где также учитывается потребление ресурсов, поэтому ограничения, такие как MemoryMax= и CPUQuota=, применяются ко всем процессам, порожденным юнитом, независимо от того, что Type= говорит об основном PID. Эта сторона вопроса описана в ограничении памяти и CPU сервиса средствами systemd.
Как найти процесс, за которым на самом деле следит systemd
Выполняйте эти действия последовательно для юнита, который вы отлаживаете. Сначала прочитайте то, что загрузил systemd, затем — то, что он отслеживает, и сравните это с таблицей процессов.
systemctl cat app.service
systemctl show -p Type,ExitType,GuessMainPID,PIDFile,MainPID,RemainAfterExit app.servicesystemctl cat выводит файл юнита вместе со всеми применяемыми к нему drop-in файлами, поэтому вы читаете то, что загрузил systemd, а не тот файл, который, как вам кажется, вы редактировали. systemctl show выводит эффективные значения, включая значения по умолчанию, которые вы не прописывали. Обратите внимание на значение MainPID перед тем, как двигаться дальше.
systemd-cgls --unit=app.service
ps -o pid,ppid,stat,etime,args -p "$(systemctl show -p MainPID --value app.service)"systemd-cgls перечисляет все процессы в cgroup юнита. Строка ps описывает единственный процесс, который контролирует systemd. Изучите их вместе. MainPID равный 0 означает, что у systemd нет процесса для отслеживания. MainPID, который указывает на оболочку (shell), в то время как в cgroup находится ваш демон, означает случай с оберткой (wrapper), описанный выше. Если в cgroup больше процессов, чем вы ожидали, значит, задействован лаунчер или форкающий демон.
systemctl status app.service
journalctl -u app.service -bsystemctl status выводит строку состояния и дерево cgroup вместе, поэтому часто отвечает на оба вопроса сразу. journalctl -u, ограниченный текущей загрузкой с помощью -b, показывает события запуска и остановки, которые systemd записал для юнита, вместе с кодами завершения, которые он зафиксировал. Если демон пишет в собственный лог-файл вместо journal, прочитайте также этот файл, так как systemd может записать только то, что до него дошло.
Когда вы меняете Type=, выполните перезагрузку конфигурации и перезапуск.
systemd-analyze verify /etc/systemd/system/app.service
sudo systemctl daemon-reload
sudo systemctl restart app.servicesystemd-analyze verify анализирует файл и сообщает о настройках, которые он не может принять. daemon-reload заставляет systemd повторно считать файлы юнитов с диска. Измененный Type= не применяется к уже запущенному юниту, поэтому перезапуск обязателен, а не опционален.
Затем протестируйте изменения. Возьмите PID процесса, который вас интересует, из systemd-cgls и завершите его командой kill. Сразу после этого выполните systemctl is-active app.service. Если Type= настроен верно, юнит перейдет из активного состояния. Если он остается активным, значит, systemd все еще следит за чем-то другим.
Какой тип сервиса systemd Type= следует использовать
- Программа, которая остаётся в интерактивном режиме (foreground):
Type=exec. - Программа, поддерживающая уведомление о готовности:
Type=notify, а такжеnotify-reload, если она подтверждает и перезагрузки. - Демон, который принудительно переходит в фоновый режим:
Type=forkingс использованиемPIDFile=, либо его переключатель для работы в интерактивном режиме с помощьюType=exec. - Скрипт, который выполняет работу и завершается:
Type=oneshot, плюсRemainAfterExit=yes, если целью было сохранение состояния. - Запускающий процесс, который завершается, пока его дочерние процессы продолжают работу:
Type=simpleс использованиемExitType=cgroup.
Если вы не уверены, какой тип требуется стороннему демону, сначала изучите его unit-файл из пакета. Выполнение systemctl cat для юнита, предоставленного дистрибутивом, покажет Type=, выбранный разработчиками, и этот выбор был протестирован большим количеством пользователей, чем ваш собственный.
FAQ
Почему мой systemd-юнит остается активным, когда процесс завершился?
Потому что процесс, который systemd считает основным, все еще жив. systemd отслеживает один PID для сервиса, выбранный согласно Type=, а не все процессы в cgroup юнита. Обертка-скрипт, запущенная через Type=simple, — типичная причина: оболочка является основным PID, поэтому юнит остается активным, когда демон, запущенный оболочкой в фоновом режиме, завершается. Выполните systemctl show -p MainPID app.service, затем выведите список cgroup юнита с помощью systemd-cgls --unit=app.service и сравните их.
В чем разница между Type=simple и Type=exec?
Type=simple считает юнит запущенным, как только systemd создал процесс, еще до выполнения бинарного файла. Поэтому неверный путь в ExecStart= все равно приведет к успешному завершению задачи запуска, за которым последует сбой. Type=exec ожидает успешного выполнения, поэтому сбой будет зафиксирован самой задачей запуска. Оба типа считают один и тот же процесс основным PID. Type=exec требует systemd версии 240 или новее.
Нужен ли мне по-прежнему PIDFile= при Type=forking?
Да, если демон его создает. Без него systemd переходит к GuessMainPID=, что является лишь предположением и надежно работает только для сервиса, который сводится к одному процессу. Когда предположение неверно или невозможно, обнаружение сбоев и автоматический перезапуск для этого юнита перестают работать. Укажите в PIDFile= точный путь, куда демон записывает файл, обычно это каталог под /run.
Когда следует использовать RemainAfterExit=yes?
Когда цель юнита — изменить состояние системы, а не поддерживать работу процесса. Юнит Type=oneshot, который загружает правила брандмауэра или запускает стек контейнеров, завершается сразу после выполнения своей работы. Без RemainAfterExit=yes юнит переходит в неактивное состояние, из-за чего systemctl stop нечего останавливать и невозможно выполнить очистку ExecStop=. С этой директивой юнит остается активным без запущенных процессов, что и требуется в данном случае.
Требует ли изменение Type= выполнения daemon-reload?
Да, а также перезапуска юнита. systemctl daemon-reload заставляет systemd перечитать файлы юнитов на диске, но запущенный экземпляр сохраняет Type=, с которыми он был запущен. Выполните sudo systemctl daemon-reload, а затем sudo systemctl restart app.service перед тестированием, иначе вы по-прежнему будете наблюдать старое поведение системы контроля.