MAATRIX / Блог / Как systemd понимает, что сервис поднялся, и почему он в этом ошибается

Как systemd понимает, что сервис поднялся, и почему он в этом ошибается

MAATRIX

Сервис в systemctl status зелёный и подписан «active (running)», зависимый сервис после него стартует без ошибок — а приложение внутри ещё пятнадцать секунд подключается к базе и прогревает кэш, поэтому первые запросы к нему падают с connection refused. Дело не в баге systemd и не в кривом конфиге: systemd честно делает то, что вы ему сказали, — просто «запущен» и «готов» для него по умолчанию одно и то же событие, а для вашего приложения это два разных момента времени.

Что systemd на самом деле отслеживает

systemd не умеет заглядывать внутрь процесса и спрашивать «ты готов обслуживать запросы?». У него есть только несколько источников сигналов: код возврата fork()/exec(), факт существования PID в таблице процессов, код выхода процесса, а для части типов unit'ов — сообщение, которое процесс сам присылает по специальному сокету. Всё остальное — это интерпретация, которую задаёт директива Type= в секции [Service].

Когда вы пишете systemctl start myapp, происходит следующее: systemd форкает процесс, запускает в нём ExecStart, и дальше — в зависимости от Type — либо сразу считает unit активным, либо ждёт какого-то дополнительного события. Разница между типами — это разница именно в том, чего именно systemd ждёт, прежде чем перевести unit в состояние active (running) и разбудить всё, что от него зависит через After=/Requires=/Wants=.

Это тот же механизм, что отвечает за порядок запуска unit'ов на старте всей машины — если интересно, как systemd вообще разворачивает систему от первого процесса до multi-user.target, это отдельная тема, разобранная в статье про работу systemd при загрузке.

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

systemctl show myapp.service -p Type -p ExecMainPID -p ActiveState -p SubState

Если Type не указан явно в unit-файле, по умолчанию используется Type=simple — и это источник большей части сюрпризов, о которых пойдёт речь дальше.

Type=simple: готов — значит процесс запустился

Type=simple — это самый примитивный и самый частый вариант. Контракт простой: как только fork() и exec() из ExecStart= отработали успешно и процесс существует в системе, systemd немедленно помечает unit как active (running). Он не проверяет, открыл ли процесс порт, подключился ли к базе, дочитал ли конфиг — вообще ничего из происходящего внутри приложения его не интересует.

[Unit]
Description=My backend API
After=postgresql.service

[Service]
Type=simple
ExecStart=/usr/local/bin/myapp --config /etc/myapp/config.yaml
Restart=on-failure
User=myapp

[Install]
WantedBy=multi-user.target

С точки зрения systemd этот сервис «поднялся» в момент, когда ядро вернуло PID процессу myapp. Если внутри myapp есть тяжёлая инициализация — миграции схемы БД, прогрев кэша, загрузка модели, установление TLS-хендшейка с апстримом — всё это время процесс уже существует, но реально запросы принимать не может. Для systemd разницы нет: unit «активен» с первой миллисекунды жизни процесса.

Это не значит, что Type=simple плохой — для короткоживущих демонов без сложной инициализации (простой прокси, воркер, который сам переподключается при сбое) это ровно то, что нужно: минимум накладных расходов, никакой магии. Проблема начинается там, где от момента запуска процесса до момента реальной готовности проходит заметное время, а другие unit'ы должны стартовать строго после этой готовности.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Type=forking: ждём, пока уйдёт родитель, а не пока подготовится демон

Исторически многие Unix-демоны (классический sshd, старые сборки mysqld, nginx в режиме master/worker без --foreground) работают по схеме double-fork: родительский процесс делает свою инициализацию, форкает дочерний процесс-демон, а сам завершается. Type=forking — это способ сказать systemd: «не считай unit готовым, пока не завершится тот процесс, который ты запустил напрямую».

[Service]
Type=forking
PIDFile=/run/myapp/myapp.pid
ExecStart=/usr/local/bin/myapp-daemon --daemonize

Здесь важна деталь: systemd ждёт завершения именно родительского процесса, а не какого-либо признака готовности дочернего. Если PIDFile= указан, после выхода родителя systemd читает файл с PID и начинает следить за этим процессом как за основным для unit'а. Если демон в файле PID записал число раньше, чем реально начал слушать сокет — а так делают многие legacy-демоны, — ситуация оказывается той же самой, что и с Type=simple: unit «активен», а слушать соединения ещё нечему.

Type=forking решает узкую задачу — научить systemd работать со старым способом демонизации через двойной fork, — но никак не решает задачу «дождаться реальной готовности». Это две разные проблемы, которые легко перепутать, глядя на документацию по диагонали.

Type=notify: сервис сам говорит «я готов»

Единственный из трёх типов, где готовность определяет само приложение, а не эвристика systemd, — это Type=notify. Контракт здесь другой: процесс явно отправляет по unix-сокету, адрес которого передаётся через переменную окружения $NOTIFY_SOCKET, сообщение READY=1. Пока этого сообщения не было, unit остаётся в состоянии activating, и systemd не запускает то, что от него зависит.

[Service]
Type=notify
NotifyAccess=main
ExecStart=/usr/local/bin/myapp --config /etc/myapp/config.yaml
TimeoutStartSec=60

Со стороны приложения на C это вызов sd_notify(0, "READY=1") из libsystemd. Для Python есть пакет sdnotify, для Go — пакет github.com/coreos/go-systemd/daemon, во многих современных фреймворках и рантаймах (например, у части демонов на Rust и Go) поддержка встроена или подключается парой строк:

import sdnotify
n = sdnotify.SystemdNotifier()

# ... подключились к базе, прогрели кэш, забиндили сокет ...

n.notify("READY=1")

Это меняет саму природу проверки: вместо «процесс существует» проверяется «приложение утверждает, что готово», причём утверждение делает код, который знает о своей внутренней инициализации больше, чем systemd когда-либо сможет узнать снаружи. NotifyAccess=main ограничивает круг процессов, чьи уведомления принимаются, — по умолчанию (none) уведомления вообще игнорируются, что частая причина, почему Type=notify «не работает»: код шлёт READY=1, а в unit-файле забыли включить NotifyAccess.

Минус один: Type=notify требует, чтобы приложение явно это поддерживало. Для стороннего бинарника, который вы не пишете сами, добавить sd_notify невозможно — остаются либо Type=simple с внешней проверкой готовности, либо обёртка-скрипт, которая сама дожидается признаков готовности и потом шлёт уведомление за приложение (NotifyAccess=all и вызов systemd-notify --ready из соседнего процесса).

Почему это ломает зависимости между сервисами

Директивы After= и Requires= в systemd — это самое частое место, где путают «unit стартовал» и «unit готов». After=postgresql.service гарантирует только порядок запуска: systemd сначала переведёт postgresql в active, и только после этого начнёт запускать ваш unit. Она ничего не говорит о том, что PostgreSQL уже принимает подключения — только о том, что процесс postgres был создан и (если у unit'а Type=notify, как в современных сборках postgresql.service) прислал READY=1.

Проблема нарастает по цепочке. Возьмём типичную конфигурацию: backend на Type=simple с долгой инициализацией (подключение к БД, миграции, прогрев кэша) и nginx как reverse proxy перед ним с After=myapp.service. Последовательность событий:

  1. systemd запускает myapp, видит PID процесса и немедленно помечает unit как active.
  2. Внутри myapp начинается инициализация — она может занимать существенное время в зависимости от объёма миграций и размера кэша, который нужно прогреть.
  3. systemd, считая зависимость выполненной, запускает nginx.
  4. nginx поднимается быстро (у него самого обычно Type=forking или Type=simple с короткой инициализацией) и сразу начинает принимать входящие соединения.
  5. Первые запросы, пришедшие в это окно, nginx пытается проксировать на myapp, но порт приложения ещё не слушается — клиент получает 502 Bad Gateway или Connection refused.

Это окно закрывается само по себе, когда myapp наконец добирается до bind()/listen(), но само его существование — прямое следствие того, что After= описывает только порядок запуска unit'ов, а не готовность приложений внутри них. При параллельном запуске systemd (а он по умолчанию запускает независимые unit'ы параллельно, чтобы ускорить загрузку) окно может совпасть ещё и с ранней фазой загрузки самой системы, когда доступность сети или диска ещё не устаканилась — тогда к «раннему» проксированию добавляется ещё и нестабильность нижележащих ресурсов.

Похожая история с docker-compose, где depends_on без condition: service_healthy устроен ровно так же: контейнер БД считается готовым в момент старта процесса, а не в момент готовности принимать соединения — про это стоит почитать отдельно, если вы настраиваете healthcheck в Docker: логика та же самая, просто на уровне docker вместо systemd.

Как проверить готовность на практике и выбрать правильный Type

Первый шаг — не гадать, а посмотреть, что реально происходит. journalctl -u myapp.service -f покажет вывод приложения в реальном времени, а systemctl status myapp.service — текущее состояние с последними строчками лога. Если в момент, когда systemd уже показывает active (running), в логе видно «Connecting to database…» или «Warming up cache…» — это прямое подтверждение проблемы с типом сервиса.

# смотрим, что происходит в момент старта
journalctl -u myapp.service --since "5 min ago"

# проверяем, слушается ли порт приложением прямо сейчас
ss -tlnp | grep myapp

Если проблема проявляется не при штатном старте, а именно после перезагрузки — процесс вообще не поднимается или падает в первые секунды, — стоит сначала исключить более простые причины: незагруженный вовремя модуль, недоступный сетевой ресурс, конфликт с другим сервисом за один и тот же ресурс. Разбор похожего случая на примере СУБД есть в статье о том, почему MySQL не стартует после перезагрузки — там причина в готовности зависимостей, а не в самом типе unit'а, но диагностика начинается с тех же команд.

Дальше — варианты решения, от самого правильного до компромиссных:

Переход на Type=notify, если вы контролируете код приложения. Это единственный способ, при котором готовность определяет сама программа, а не эвристика. Требует добавить вызов sd_notify(READY=1) в момент, когда приложение действительно готово принимать трафик — обычно сразу после bind()/listen() и после того, как отработали все блокирующие шаги инициализации.

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

[Service]
Type=simple
ExecStart=/usr/local/bin/myapp
ExecStartPost=/usr/local/bin/wait-for-ready.sh
TimeoutStartSec=90
#!/bin/bash
# wait-for-ready.sh — блокирует ExecStartPost, пока приложение не ответит 200
for i in $(seq 1 30); do
  if curl -sf http://127.0.0.1:8080/healthz >/dev/null; then
    exit 0
  fi
  sleep 1
done
echo "myapp not ready after 30s" >&2
exit 1

Важный нюанс: ExecStartPost выполняется после того, как unit уже переходит в промежуточное состояние activating, и до ExecStartPost зависимые unit'ы не запускаются — то есть фактически это способ добавить внешнюю, скриптовую проверку готовности поверх Type=simple, не трогая код приложения.

Правка на стороне зависимого сервиса, если вы не управляете сервисом, от которого зависите (например, БД в managed-окружении или сторонний бинарник). Здесь решение переносится из systemd в приложение: nginx с proxy_next_upstream и ретраями, backend с логикой переподключения к БД при старте вместо падения с первой попытки, очередь сообщений вместо синхронного вызова. Это менее элегантно, но иногда единственный доступный вариант — вы не можете научить чужой процесс слать sd_notify, зато можете сделать своё приложение устойчивым к тому, что зависимость появится на пару секунд позже.

Отдельно стоит проверить TimeoutStartSec — по умолчанию в systemd это 90 секунд (значение DefaultTimeoutStartSec в systemd-system.conf), и если инициализация вашего приложения потенциально дольше, лучше явно увеличить таймаут в unit-файле, а не полагаться на глобальный дефолт: при Type=notify и Type=forking именно этот таймаут определяет, сколько systemd будет ждать сигнала готовности, прежде чем считать запуск сорвавшимся и, если настроен Restart=, попробовать заново.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Как быстро проверить, какой Type у уже работающего сервиса?

systemctl show <unit> -p Type покажет значение из unit-файла. Если директива не указана явно, будет simple — это значение по умолчанию.

Можно ли использовать Type=notify для стороннего бинарника, код которого не редактируется?

Напрямую нет — сервис должен сам вызывать sd_notify. Обходной путь — обёрточный скрипт-надзиратель, который запускает бинарник, дожидается признаков готовности (открытый порт, файл-маркер, ответ на healthcheck) и вызывает systemd-notify --ready от имени сервиса с NotifyAccess=all.

Почему systemd не проверяет готовность порта сам, автоматически?

Потому что «готовность» — понятие уровня приложения, а не уровня процесса. Открытый порт ещё не значит, что за ним отвечает работающая логика: сервис мог забиндить сокет и зависнуть на инициализации кэша уже после этого. Универсальной проверки, которая подошла бы всем приложениям, не существует — поэтому systemd оставляет эту ответственность либо явному протоколу (sd_notify), либо вам.

Влияет ли Restart=on-failure на эту проблему?

Нет напрямую — Restart= реагирует на то, что процесс завершился с ошибкой, а не на то, что зависимые сервисы стартовали слишком рано. Он полезен для устойчивости самого сервиса, но не решает проблему гонки при старте зависимостей.

Что делать, если сервис использует Type=forking, но PIDFile создаётся демоном раньше, чем он готов слушать порт?

Это тот же класс проблемы, что и с Type=simple: Type=forking синхронизируется с уходом родительского процесса, а не с готовностью демона. Решение то же — внешняя проверка через ExecStartPost или переход на Type=notify, если демон это поддерживает.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →