АйТи Фреш
Главная / Статьи / DevOps и автоматизация
DevOps и автоматизация

Почему Type=exec не ждёт, пока приложение откроет порт, и как правильно объявить готовность через Type=notify

Автор: Семёнов Евгений Сергеевич, директор ООО «АйТи-Фреш» · · ~26 мин чтения
Почему Type=exec не ждёт, пока приложение откроет порт, и как правильно объявить готовность через Type=notify
Иллюстрация к статье «Почему Type=exec не ждёт, пока приложение откроет порт, и как правильно объявить готовность через Type=notify».

Порядок в юнитах прописан, After= стоит, зависимости выставлены — и всё равно после ребута зависимая служба падает с «connection refused», потому что порт ещё не открыт. Это не баг systemd и не «гонка, с которой ничего не сделать». Это ровно то, что вы сами и написали: Type=simple считает службу запущенной после fork(), Type=exec — после execve(), и ни один из них ничего не знает про инициализацию вашего приложения. Разбираю, чем это лечится по-настоящему (Type=notify и READY=1), какие обходные пути работают, когда исходников нет, и какие тайм-ауты обязательно надо выставить, чтобы «правильный» юнит не начал падать по-новому.

After= — это про порядок, а не про готовность

Разберём терминологию, потому что 90 % проблем именно тут. Директива After= говорит менеджеру служб: «не начинай запускать этот юнит, пока предыдущий не перешёл в active». Всё. Вопрос в том, в какой момент systemd считает предыдущий юнит перешедшим в active — а это определяется вовсе не готовностью приложения, а значением Type=.

Смотрите, что говорит systemd.service(5) дословно. Type=simple (значение по умолчанию, если не указан ни Type=, ни BusName=): «менеджер служб считает юнит запущенным сразу после того, как основной процесс службы был порождён через fork()». То есть даже не после запуска бинарника — после порождения процесса. Если ExecStart= указывает на несуществующий файл, юнит при Type=simple всё равно на мгновение станет «успешно запущенным», а ошибка вылезет «слишком поздно». Type=exec идёт на один шаг дальше: «юнит считается запущенным сразу после того, как основной бинарник службы был выполнен». В NEWS systemd формулировка ещё жёстче: simple продолжает с последующими задачами сразу после возврата fork(), а exec не двинется дальше, пока и fork(), и execve() в процессе службы не завершатся успешно.

Прочитайте это ещё раз: execve() успешно завершился — значит, ядро загрузило образ и передало управление точке входа программы. На этот момент ваше приложение не прочитало конфиг, не подключилось к базе, не накатило миграции, не прогрело кэш и — главное — не вызвало listen() на своём порту. А systemd уже сказал «active» и запустил зависимый воркер, который тут же получил ECONNREFUSED. Между execve() и первым принятым соединением у нормального прикладного сервиса проходит от долей секунды до минут. У Java-приложения с прогревом это легко 40–90 секунд.

И тут кроется вторая половина ловушки: почти всегда порт открывается последним. Разработчики не зря так делают — нет смысла принимать трафик, пока не готовы зависимости. Именно поэтому «дождаться процесса» и «дождаться сервиса» — это два разных события, разнесённые во времени тем сильнее, чем сложнее приложение.

Если в юните нет строки Type=, у вас Type=simple. Это самое частое, что я вижу в самописных .service-файлах: человек искренне уверен, что «после After= сервис уже работает», а systemd там ждал ровно один системный вызов fork().
Порядок действий: After= — это про порядок, а не про готовность — схема
Порядок действий: After= — это про порядок, а не про готовность. Открыть схему в полном размере

Разбор из практики: 40 минут простоя обмена после планового ребута

Коучинг-центр «Личный рост Плюс», 18 рабочих мест: коучи, администраторы ресепшена, пара маркетологов. Одна виртуальная машина у хостинг-провайдера, Ubuntu 24.04.3 LTS, systemd 255. Связка простая: PostgreSQL 16, самописный сервис онлайн-записи booking-api (Python 3.12 + aiohttp, слушает 127.0.0.1:8081), фронт nginx и воркер reminder-worker, который при старте синхронно запрашивает у booking-api расписание на сутки и рассылает клиентам напоминания о сессиях. Всё это жило без жалоб, потому что сервер перезагружали редко, а после перезагрузки администратор руками проверял, «поднялось ли».

Ночью прошло обновление ядра с автоматической перезагрузкой. Утром: nginx отдаёт 502 на странице записи, reminder-worker в состоянии failed, напоминания о сессиях на 10:00 никому не ушли. Открываю журнал и вижу классику:

$ systemctl status reminder-worker.service
  Active: failed (Result: start-limit-hit) since Tue 2026-08-18 03:41:09 MSK

$ journalctl -u reminder-worker -b --no-pager | tail -20
reminder-worker[1287]: ConnectionRefusedError: [Errno 111] Connect call failed ('127.0.0.1', 8081)
systemd[1]: reminder-worker.service: Main process exited, code=exited, status=1/FAILURE
systemd[1]: reminder-worker.service: Scheduled restart job, restart counter is at 5.
systemd[1]: reminder-worker.service: Start request repeated too quickly.
systemd[1]: reminder-worker.service: Failed with result 'start-limit-hit'.

В юните reminder-worker.service честно стояло After=booking-api.service и Requires=booking-api.service. А в booking-api.service не было ни одной строки Type=, то есть работал Type=simple. Я замерил, сколько сервис записи реально поднимается: alembic-миграции 1,8 с, подключение к PostgreSQL и прогрев кэша расписания 11 коучей и примерно 4,5 тысячи клиентских карточек 6–8 с, и только потом aiohttp вызывает listen(). Итого 8–10 секунд от execve() до открытого порта. Воркер стартовал через 0,3 секунды после fork() сервиса записи и, естественно, порта не находил.

Добила ситуацию связка Restart=on-failure с дефолтными параметрами. RestartSec= по умолчанию 100 мс, DefaultStartLimitBurst= равен 5, DefaultStartLimitIntervalSec= равен 10 с. Воркер сделал пять попыток примерно за полторы секунды, упёрся в лимит и встал с start-limit-hit. Дальше systemd его не трогал, и сервис ждал человека. Запись и напоминания стояли 40 минут, пока администратор не позвонила: два клиента не пришли на утренние сессии, а сайт записи показывал ошибку. Механизм, придуманный, чтобы «само поднялось», здесь гарантировал, что само не поднимется.

Что сделали: перевели booking-api на Type=notify (около 15 строк в коде приложения, ниже покажу), выставили TimeoutStartSec=180, у воркера поставили RestartSec=5s, а на сервис записи добавили WatchdogSec=30 вместе с периодическим WATCHDOG=1 в коде. После правки воркер стартует через 9,6 с после запуска booking-api, ровно когда порт уже принимает соединения. Проверили тремя подряд перезагрузками и одним systemctl restart booking-api в рабочее время. С тех пор (полтора месяца, четыре перезагрузки на обновлениях ядра) ручных вмешательств не было.

Главный признак, что вы попали в эту историю: сервис падает только после ребута или после массового restart, а руками через минуту стартует идеально. Значит, ломается не конфиг, а момент, который вы приняли за готовность.
Почему Type=exec не ждёт, пока приложение откроет порт, и как правильно объявить готовность через Type=notify — схема
Схема к статье. Открыть схему в полном размере

Type=exec: что он реально чинит, а что нет

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

Из-за этого Type=exec ловит целый класс ошибок, которые при Type=simple выглядят как «сервис запустился и почему-то сразу умер»: несуществующий бинарник, несуществующий User=, недоступный WorkingDirectory=, невозможность применить namespace-ограничения (ProtectSystem=, PrivateTmp= и прочее). Всё это происходит между fork() и execve() — и при simple менеджер этого не видит. Кстати, в NEWS к версии 240 разработчики объявили намерение перевести systemd-run на Type=exec по умолчанию для transient-служб и сразу оговорили риск: systemd-run тогда будет блокироваться на NSS-вызовах между fork() и execve() (например, при поиске пользователя из User=), и в таких случаях советуют явно указывать -p Type=simple. Если для вас это важно, не полагайтесь на дефолт вашей версии: укажите --service-type=exec явно (ключ systemd-run -S подразумевает его сам).

Но повторю: после успешного execve() ваше приложение только начало выполняться. Никакой информации о том, прочитало ли оно конфиг и открыло ли сокет, у systemd нет и быть не может. Поэтому моя рекомендация простая и непопулярная у любителей «одной правильной настройки»: Type=exec ставьте по умолчанию во все свои юниты вместо simple — это бесплатно и даёт вменяемую диагностику; но проблему готовности он не решает и решать не должен.

Спорный момент, скажу честно: часть коллег считает переход на exec бессмысленным «косметическим» изменением. Формально они правы — поведение работающей службы не меняется. Но я видел слишком много инцидентов, где юнит месяцами числился active при отсутствующем бинарнике после неудачного деплоя.
Памятка: Type=exec: что он реально чинит, а что нет — схема
Памятка: Type=exec: что он реально чинит, а что нет. Открыть схему в полном размере

Type=notify: единственный честный способ сказать «я готов»

Формулировка из мануала: поведение notify похоже на exec, но ожидается, что служба отправит уведомление READY=1 через sd_notify(3) или эквивалентный вызов, когда закончит запуск. И дальше ключевое: systemd продолжит запуск последующих юнитов только после получения этого сообщения. Всё, это ровно то, чего вы хотели, когда писали After=.

Механика простая до неприличия. systemd кладёт в окружение процесса переменную $NOTIFY_SOCKET с путём к AF_UNIX-сокету (путь начинается с «/» для обычного сокета или с «@» для абстрактного пространства имён Linux). Приложение шлёт туда датаграмму с текстом READY=1. Никакой библиотеки для этого не требуется — вот полная реализация на голом Python без python3-systemd:

import os, socket

def sd_notify(state: str) -> bool:
    addr = os.environ.get("NOTIFY_SOCKET")
    if not addr:
        return False                      # запущены не под systemd
    if addr[0] == "@":                    # абстрактное пространство имён
        addr = "\0" + addr[1:]
    with socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM | socket.SOCK_CLOEXEC) as s:
        s.connect(addr)
        s.sendall(state.encode("utf-8"))
    return True

# вызывать РОВНО ПОСЛЕ того, как listen-сокет открыт и healthcheck отвечает:
sd_notify("READY=1\nSTATUS=listening on 127.0.0.1:8081")

Юнит при этом выглядит так. Обратите внимание на NotifyAccess=. Вообще по умолчанию он равен none (уведомления игнорируются), но для Type=notify, Type=notify-reload и при заданном WatchdogSec= systemd.service(5) неявно выставляет main: принимаются сообщения только от главного процесса службы. Значение exec добавляет управляющие процессы из Exec*=-команд (ExecStartPost=, ExecReload= и т. п.), значение all разрешает любой процесс в cgroup службы. Если READY=1 шлёт дочерний процесс или вызванный из обёртки systemd-notify, при main сообщение будет молча отброшено.

[Unit]
Description=Online booking API
Wants=network-online.target
After=network-online.target postgresql.service

[Service]
Type=notify
NotifyAccess=main
ExecStart=/opt/booking/venv/bin/python -m booking_api
TimeoutStartSec=180
WatchdogSec=30
Restart=on-failure
RestartSec=5s

[Install]
WantedBy=multi-user.target

Через тот же сокет ходят и другие полезные сообщения: STATUS= (произвольная строка, видна в systemctl status), WATCHDOG=1 (сторожевой пинг при включённом WatchdogSec=), EXTEND_TIMEOUT_USEC= (продлить текущий тайм-аут, если инициализация честно долгая, появился в 236), STOPPING=1 и RELOADING=1. Начиная с версии 253 есть отдельный Type=notify-reload: при перезагрузке конфигурации главному процессу шлётся SIGHUP (или сигнал из ReloadSignal=), а служба обязана ответить RELOADING=1 вместе с MONOTONIC_USEC= и затем прислать READY=1 по завершении. Свежая деталь, которую стоит держать в голове при обновлении: в NEWS к systemd 262 записано, что службы Type=notify-reload на момент отправки READY=1 обязаны перехватывать или блокировать сигнал из ReloadSignal=, иначе запуск падает с ошибкой протокола. Раньше такие службы стартовали, а потом умирали на первой же перезагрузке конфига от действия сигнала по умолчанию.

Раз уж в юните стоит WatchdogSec=30, приложение обязано регулярно слать WATCHDOG=1, иначе через 30 секунд после старта systemd убьёт его сигналом SIGABRT (или тем, что задан в WatchdogSignal=) и при Restart=on-failure перезапустит. Значение тайм-аута приходит в переменной WATCHDOG_USEC, а sd_watchdog_enabled(3) рекомендует пинговать с интервалом в половину этого времени. В asyncio-приложении пинг из основного цикла событий заодно проверяет, что цикл не завис. Долгий прогрев кэша при этом стоит обернуть в EXTEND_TIMEOUT_USEC=:

import asyncio, os

async def watchdog_loop() -> None:
    usec = int(os.environ.get("WATCHDOG_USEC", "0"))
    pid = os.environ.get("WATCHDOG_PID")
    if not usec or (pid and int(pid) != os.getpid()):
        return                            # watchdog не включён для этого процесса
    interval = usec / 1_000_000 / 2       # половина WatchdogSec=
    while True:
        sd_notify("WATCHDOG=1")
        await asyncio.sleep(interval)

async def warm_cache() -> None:
    sd_notify("EXTEND_TIMEOUT_USEC=60000000\nSTATUS=warming schedule cache")  # +60 с
    ...
READY=1 надо слать после того, как порт открыт и минимальный self-check прошёл, а не в конце функции main() и не «примерно там». Если отправить его раньше готовности — вы получите ту же самую гонку, только теперь с ощущением, что «мы всё сделали правильно».

Когда исходников нет: три рабочих обходных пути

Реальность такая, что чужое проприетарное приложение вы под sd_notify не перепишете. Здесь есть три подхода, и я их применяю именно в этом порядке приоритета.

Первый и лучший — socket-активация. Слушающий сокет открывает сам systemd, ещё до запуска демона; всё, что приходит в него, ставится в очередь ядром. Зависимая служба соединяется и просто ждёт accept() вместо ECONNREFUSED. Это снимает гонку целиком, а не маскирует её. Минус — приложение должно уметь принимать готовый файловый дескриптор (протокол LISTEN_FDS) либо, в самом простом варианте, вы ставите перед ним systemd-socket-proxyd, который принимает сокет от systemd и проксирует соединения на реальный порт приложения. Для systemd-совместимых демонов (а их много больше, чем принято думать) хватает пары файлов:

# /etc/systemd/system/booking-api.socket
[Unit]
Description=Online booking API socket

[Socket]
ListenStream=127.0.0.1:8081

[Install]
WantedBy=sockets.target

Второй — гейт в ExecStartPost=. Мануал прямо говорит: выполнение ExecStartPost= учитывается для целей упорядочивания Before=/After=. То есть пока команда из ExecStartPost= не завершилась, юнит остаётся в состоянии activating и зависимые службы честно ждут. Это самый дешёвый способ прикрутить готовность к чужому демону. Только проверяйте не порт, а реальный health-эндпоинт: открытый сокет не означает, что приложение отвечает.

[Service]
Type=exec
ExecStart=/opt/vendor/bin/appd --foreground
ExecStartPost=/usr/bin/timeout 150 sh -c 'until curl -fsS -o /dev/null http://127.0.0.1:8081/healthz; do sleep 1; done'
TimeoutStartSec=180

Третий — отдельный юнит-барьер на Type=oneshot с RemainAfterExit=yes. Он ничего не запускает, а только ждёт готовности и переходит в active. Зависимые службы вешаются на него, а не на исходный демон. Способ более многословный, зато появляется явная, видимая в systemctl list-units точка «сервис готов», которую удобно мониторить и на которую можно повесить несколько потребителей сразу. Четвёртый вариант — скрипт-обёртка под Type=notify. Сразу предупрежу о типичной ошибке: ExecStartPost=/usr/bin/systemd-notify --ready в юните с Type=notify не работает в принципе. По systemd.service(5) команды ExecStartPost= для notify запускаются только после получения READY=1, то есть уведомление, которое должно разблокировать запуск, само ждёт этого запуска, и служба гарантированно падает по TimeoutStartSec=. Правильно сделать главным процессом обёртку: она запускает демон в фоне, дожидается health-эндпоинта и сама шлёт READY=1.

#!/bin/bash
# /usr/local/bin/appd-ready.sh
set -u
/opt/vendor/bin/appd --foreground &
APP=$!
for i in $(seq 1 150); do
    if curl -fsS -o /dev/null http://127.0.0.1:8081/healthz; then
        systemd-notify --ready --status="appd answers /healthz"
        wait "$APP"
        exit $?
    fi
    kill -0 "$APP" 2>/dev/null || exit 1   # демон умер при старте
    sleep 1
done
exit 1
[Service]
Type=notify
NotifyAccess=all
ExecStart=/usr/local/bin/appd-ready.sh
TimeoutStartSec=180

Почему NotifyAccess=all. Сообщение шлёт не главный процесс (bash), а его потомок systemd-notify. По systemd-notify(1) утилита сначала пытается отправить уведомление от имени родителя, то есть оболочки, но это получается только при достаточных привилегиях. Если служба работает под непривилегированным User=, сообщение уходит с PID самого systemd-notify, и при NotifyAccess=main его отбросят. Вторая ловушка — гонка атрибуции: короткоживущий отправитель может завершиться раньше, чем PID 1 разберёт датаграмму, и сообщение проигнорируют даже при all. Современный systemd-notify по умолчанию ждёт, пока менеджер обработает сообщение, поэтому не добавляйте ему --no-block. Главным процессом службы при такой схеме остаётся bash, поэтому KillMode= не трогайте: при остановке режим control-group по умолчанию завершит и сам демон.

Не пишите `ExecStartPre=/bin/sleep 30`. Я встречаю это в каждой третьей инфраструктуре, которую принимаю на обслуживание. Оно работает ровно до первого медленного диска, первого нового релиза приложения и первого ребута под нагрузкой — а потом отказ выглядит абсолютно случайным, и на его поиск уходит полдня.

Тайм-ауты и рестарт-петли: как «правильный» юнит ломается заново

Перевели службу на Type=notify — поздравляю, вы завели новую точку отказа. Теперь если READY=1 не придёт, служба не просто будет считаться не готовой: мануал говорит однозначно — если демон не сигнализирует о завершении запуска в отведённое время, служба считается провалившейся и будет остановлена. Всё это время юнит висит в состоянии activating, а зависимые службы ждут. Что именно произойдёт по тайм-ауту, задаёт TimeoutStartFailureMode=: по умолчанию terminate (KillSignal=, затем FinalKillSignal= через TimeoutStopSec=), abort шлёт WatchdogSignal= и даёт получить дамп памяти зависшего процесса, kill убивает сразу. Если главный процесс завершится, так и не прислав READY=1, провал будет засчитан немедленно, без ожидания тайм-аута. Отведённое время — это TimeoutStartSec=, который по умолчанию равен DefaultTimeoutStartSec= из systemd-system.conf(5), а он равен 90 секундам и в системном, и в пользовательском менеджере. Исключение — Type=oneshot, там тайм-аут по умолчанию отключён.

Практический вывод: замерьте реальное время холодного старта в самом медленном сценарии (после перезагрузки, с непрогретым page cache, под нагрузкой на диск) и поставьте TimeoutStartSec= с запасом хотя бы вдвое. Если приложение умеет, лучше не увеличивать общий тайм-аут, а слать EXTEND_TIMEOUT_USEC= по ходу инициализации. Правило из мануала: первое такое сообщение должно прийти до истечения TimeoutStartSec=, а каждое следующее — до истечения предыдущего продления, пока служба не пришлёт READY=1. Тогда зависшая служба всё равно умрёт быстро, а честно долгая доедет до конца. Ставить TimeoutStartSec=infinity я не советую: сервис, зависший в activating навсегда, блокирует всю цепочку зависимостей и при этом никуда не рапортует.

Дальше — рестарты. Дефолты у systemd агрессивные: RestartSec=100 мс, StartLimitBurst=5, StartLimitIntervalSec=10 с. Служба, которая падает мгновенно, за полторы секунды исчерпывает лимит и встаёт в failed до прихода человека. Для зависимых сервисов я ставлю RestartSec=5s и настраиваю лимит под реальный цикл перезапуска: StartLimitIntervalSec= и StartLimitBurst= задаются в секции [Unit], а не [Service]. Считайте честно: при RestartSec=5s и старте за 5–10 секунд одна попытка занимает 10–15 секунд, так что StartLimitBurst=10 при StartLimitIntervalSec=300 даёт службе около двух минут непрерывных попыток. Если нужно, чтобы она пережила пятиминутный сбой базы, поднимайте RestartSec= или Burst, а не отключайте лимит нулём.

И включите watchdog там, где приложение поддерживает WATCHDOG=1. WatchdogSec= по умолчанию 0, то есть выключен. С ним вы получаете контроль не только запуска, но и живости: зависший (а не упавший) процесс получит SIGABRT и будет перезапущен, а не станет вечно числиться active. Проверить, что всё применилось, проще всего так:

systemd-analyze verify /etc/systemd/system/booking-api.service
systemctl show -p Type,NotifyAccess,TimeoutStartUSec,WatchdogUSec,RestartUSec booking-api.service
systemd-analyze critical-chain reminder-worker.service
journalctl -u booking-api -b -o short-monotonic --no-pager | head -40

Вывод short-monotonic тут ключевой: он показывает секунды от старта ядра и позволяет глазами увидеть разрыв между «Starting Online booking API» и «Started Online booking API». Если разрыв близок к нулю, а приложение поднимается десять секунд — вы всё ещё на Type=simple, как бы красиво ни выглядел остальной юнит.

Проверяйте изменения не через `systemctl restart`, а настоящей перезагрузкой сервера. Разница принципиальна: при рестарте сервиса база, кэши и диск уже прогреты, и гонка, из-за которой всё и падает после ребута, просто не воспроизводится.

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

Порядок действий, который я отрабатываю на приёмке любой Linux-инфраструктуры. Он занимает от получаса до половины дня на стенд и снимает большую часть «загадочных» отказов после перезагрузки.

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

systemctl list-units --type=service --all --no-legend --plain \
  | awk '{print $1}' \
  | xargs -r -n1 systemctl show -p Id,Type,NotifyAccess,TimeoutStartUSec --value 2>/dev/null \
  | paste - - - -

# кто на кого ссылается по After= — быстро находит цепочки, где готовность важна
grep -RHn -E '^(After|Requires|BindsTo)=' /etc/systemd/system/*.service

Второе — по каждой найденной цепочке решить, есть ли доступ к коду. Есть — Type=notify и READY=1 после открытия сокета, это правильный ответ и он окупается сразу. Нет — ExecStartPost= с опросом health-эндпоинта под timeout, а если потребителей несколько, лучше отдельный oneshot-барьер. Socket-активацию беру, когда демон её уже поддерживает: переписывать чужое приложение ради неё смысла нет.

На что можно спокойно забить. Не трогайте дистрибутивные юниты — PostgreSQL, nginx, sshd и прочие давно живут с правильным Type=, и правки там вам только сломают обновление пакета (если очень надо — делайте drop-in через systemctl edit, а не редактируйте файл в /lib). Не гонитесь за Type=notify-reload, если у вас нет реального сценария горячей перезагрузки конфигурации: по NEWS к версии 262 там появилось жёсткое требование к обработке сигнала, и вы получите отказ старта на ровном месте. И не пытайтесь выстроить готовность через Restart=always с коротким RestartSec=: это не решение задачи, это её маскировка, которая один раз обязательно совпадёт со StartLimitBurst= и оставит сервис лежать до утра.

И последнее, что я всегда прошу сделать клиента: поставьте плановый тестовый ребут раз в квартал в рабочее время, когда все на местах. Пять минут запланированного простоя стоят несравнимо дешевле, чем сорок минут незапланированного в тот момент, когда клиентам должны уйти утренние напоминания о сессиях.

Практический вывод в одну строку: systemd умеет ждать готовность приложения ровно настолько, насколько приложение готово об этой готовности сообщить. Всё остальное — договорённости на честном слове.
Порядок действий: Что делать в первую очередь, а на что можно забить — схема
Порядок действий: Что делать в первую очередь, а на что можно забить. Открыть схему в полном размере

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

Чем Type=exec отличается от Type=simple на практике?

Только моментом, когда юнит считается запущенным. При simple это возврат fork(), при exec — успешное завершение execve(). Практическая польза exec — ошибки подготовительной фазы (нет бинарника, нет пользователя из User=, недоступен WorkingDirectory=) наконец-то становятся видны как отказ запуска юнита, а не как загадочная мгновенная смерть службы. На готовность приложения exec не влияет никак. Type=exec появился в systemd 240.

Обязательно ли писать NotifyAccess=main при Type=notify?

Нет. По systemd.service(5), при Type=notify отсутствующий или равный none NotifyAccess= принудительно выставляется в main. Явно указывать полезно для читаемости. Менять значение нужно, если READY=1 шлёт не главный процесс: exec разрешает управляющие процессы из Exec*=-команд, all разрешает любой процесс cgroup службы, например systemd-notify, вызванный из скрипта-обёртки под непривилегированным пользователем.

Что будет, если приложение так и не пришлёт READY=1?

Служба будет висеть в состоянии activating до истечения TimeoutStartSec=, после чего systemd сочтёт её провалившейся и остановит способом из TimeoutStartFailureMode= (по умолчанию terminate). Без явной настройки это DefaultTimeoutStartSec= — 90 секунд. Если главный процесс завершится без READY=1, провал засчитывается сразу. Зависимые по After= юниты всё это время ждут, а при Requires= потом не запустятся вовсе. Поэтому переход на Type=notify без замера реального времени старта меняет одну проблему на другую.

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

Напрямую нет — протокол должен поддерживать само приложение. Рабочие обходные пути: socket-активация (systemd сам открывает порт до запуска демона), гейт в ExecStartPost= с опросом health-эндпоинта под timeout при Type=exec (выполнение ExecStartPost= учитывается для Before=/After=), отдельный юнит-барьер Type=oneshot с RemainAfterExit=yes или скрипт-обёртка под Type=notify с NotifyAccess=all, которая сама шлёт systemd-notify --ready после ответа health-эндпоинта.

Почему сервис уходит в start-limit-hit и не поднимается сам?

Из-за дефолтов: RestartSec=100 мс, StartLimitBurst=5, StartLimitIntervalSec=10 с. Служба, падающая мгновенно, исчерпывает лимит примерно за полторы секунды и остаётся в failed, пока её не запустят вручную или не выполнят systemctl reset-failed. Лечится не отключением лимита, а устранением причины (ожидание готовности) плюс адекватным RestartSec=5s и окном StartLimitIntervalSec= в секции [Unit], рассчитанным под реальный цикл перезапуска.

Стоит ли переводить службы на Type=notify-reload?

Только если у вас реально есть сценарий горячей перезагрузки конфигурации. Тип появился в systemd 253, служба обязана прислать RELOADING=1 с MONOTONIC_USEC=, а затем READY=1. С версии 262 добавилось жёсткое требование: на момент отправки READY=1 служба должна перехватывать или блокировать сигнал из ReloadSignal=, иначе запуск падает с ошибкой протокола. Для обычного «стартанул и работает» достаточно Type=notify.

Почему systemd-notify --ready в ExecStartPost= не помогает при Type=notify?

Потому что для Type=notify команды ExecStartPost= запускаются только после получения READY=1. Уведомление, которое должно завершить запуск, само ждёт завершения запуска, и служба падает по TimeoutStartSec=. Отправлять READY=1 должен главный процесс: само приложение или скрипт-обёртка, которая запускает демон, дожидается health-эндпоинта и вызывает systemd-notify --ready (при NotifyAccess=all).

Столкнулись с похожей задачей? Обращайтесь — решим

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

Возьмёмся и за разовую задачу, и за постоянное обслуживание. Первичная консультация — бесплатно и без обязательств.

📞 +7 903 729-62-41 💬 MAX: +7 903 729-62-41 ✈ Telegram @ITfresh_Boss

С уважением, Семёнов Евгений Сергеевич, директор «АйТи Фреш» — IT-аутсорсинг для компаний до 50 рабочих мест, 15+ лет практики

Источники

© ООО «АйТи-Фреш» · Москва · Все статьи