Что делает systemd, когда сервис отказывается останавливаться
Вы делаете systemctl stop myapp, а терминал зависает на строке «A stop job is running for...» и таймер тикает. Через какое-то время сервис всё-таки останавливается — но не потому что послушался, а потому что systemd его добил. Это не баг и не что-то экзотическое: так устроен штатный механизм остановки любого юнита, и понимание его логики избавляет от гадания, почему одни процессы гасятся мгновенно, а другие держат систему по минуте.
Содержание
- Что происходит по команде systemctl stop
- SIGTERM — вежливая просьба, а не команда
- TimeoutStopSec: сколько времени даёт systemd
- SIGKILL и cgroup: как гарантированно добивается вся группа процессов
- Проблема осиротевших процессов, которую решает cgroup
- Что это значит для настройки graceful shutdown приложения
- Как выглядит зависшая остановка на практике
Что происходит по команде systemctl stop
Когда вы просите остановить юнит, systemd не убивает процесс сразу. Он ставит job в очередь и проходит несколько шагов:
- Если в unit-файле задан
ExecStop=, systemd сначала выполняет эту команду (например, вызовnginx -s quitили API для graceful shutdown приложения). - Если
ExecStop=не задан или команда отработала, а процесс всё ещё жив, systemd посылает сигнал остановки — по умолчаниюSIGTERM— процессам юнита. - Запускается таймер
TimeoutStopSec. Пока он не истёк, systemd ждёт, что процесс завершится сам. - Если по истечении таймера процесс не завершился, systemd посылает
SIGKILL— и на этот раз ждать нечего, сигнал нельзя ни поймать, ни проигнорировать.
Это и есть двухступенчатый механизм остановки: сначала вежливая просьба с отсрочкой, потом безусловное принудительное завершение. Разница между этими двумя сигналами — не техническая деталь, а основа всей логики graceful shutdown в Linux.
SIGTERM — вежливая просьба, а не команда
SIGTERM (сигнал 15) — это запрос на завершение, который процесс может перехватить своим обработчиком. Именно на этом строится идея «мягкой» остановки: приложение получает сигнал, понимает, что его просят закрыться, и успевает сделать последние важные вещи — например, дописать буфер на диск, закрыть соединения с базой данных, отдать клиентам корректные ответы вместо оборванных TCP-соединений, снять себя с балансировщика.
Если процесс не установил обработчик SIGTERM, сработает поведение по умолчанию — процесс завершится немедленно. Но если обработчик есть и внутри него, например, зависшая операция (ожидание ответа от недоступной базы, блокировка на файле, дедлок в коде), процесс не завершится вовсе — просто «увидит» сигнал и продолжит висеть. systemd на этом этапе ничего не форсирует: он честно ждёт до конца таймера.
Важный нюанс: какому именно процессу отправляется SIGTERM, определяет параметр KillMode в секции [Service]. По умолчанию он равен control-group — и это меняет картину сильнее, чем кажется на первый взгляд (подробнее — в разделе про cgroup ниже).
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверTimeoutStopSec: сколько времени даёт systemd
TimeoutStopSec — это то, сколько времени systemd готово терпеть после отправки SIGTERM, прежде чем перейти к SIGKILL. Значение по умолчанию наследуется от DefaultTimeoutStopSec в systemd-system.conf (в большинстве дистрибутивов это 90 секунд), но для конкретного юнита его можно и нужно переопределять под реальные требования приложения.
Посмотреть текущее значение для юнита:
systemctl show myapp.service -p TimeoutStopSec
Переопределить его — через override, а не правкой файла в /usr/lib/systemd/system/ (он перезапишется при обновлении пакета):
systemctl edit myapp.service
[Service]
TimeoutStopSec=30
Здесь важно выбрать реалистичное значение. Слишком короткий таймаут превращает graceful shutdown в фикцию: приложение просто не успевает закончить начатое, и вы получаете SIGKILL при каждом деплое, теряя недописанные данные или разрывая клиентские сессии. Слишком длинный таймаут означает, что зависший процесс будет держать systemctl stop, перезапуск сервиса и, соответственно, деплой или откат — минуту, а то и полторы, пока не отработает SIGKILL.
Отдельно стоит TimeoutStopSec=infinity — systemd тогда никогда не пошлёт SIGKILL сам. Такое имеет смысл разве что для юнитов, где вы гарантированно управляете остановкой снаружи (например, через отдельный watchdog), но для обычного сервиса это скорее способ превратить зависший процесс в вечную проблему на сервере.
SIGKILL и cgroup: как гарантированно добивается вся группа процессов
Когда таймаут истёк, а процесс жив, systemd посылает SIGKILL (сигнал 9). Этот сигнал не доставляется в обработчик приложения — ядро просто немедленно завершает процесс на уровне планировщика, без шанса что-либо доделать. Именно поэтому SIGKILL называют последним средством: он гарантированно останавливает процесс (кроме одного исключения — см. раздел про диагностику ниже), но ценой полной потери контроля над тем, что происходило внутри в момент завершения.
Ключевой момент — кому именно отправляется этот SIGKILL. Ответ: не только главному процессу сервиса, а всем процессам, которые остались в control group (cgroup) этого юнита. Каждый systemd-юнит типа service при запуске получает собственную cgroup, и в неё автоматически попадает не только основной процесс, но и всё, что он породил — прямые дочерние процессы, процессы, порождённые ими, и так далее на любую глубину. Членство в cgroup наследуется при fork()/exec() и не теряется, даже если процесс попытается «отсоединиться» от родителя классическим двойным форком (double fork) — старым трюком демонизации.
Это и есть роль cgroup в остановке сервиса: она даёт systemd точный и полный список всех процессов юнита, независимо от того, как они устроены внутри. Финальная зачистка при истечении TimeoutStopSec идёт по всей cgroup целиком — так что от неё физически невозможно спрятаться, породив дочерний процесс и «отпустив» его в свободное плавание.
Проверить состав cgroup юнита можно так:
systemctl status myapp.service
● myapp.service - My Application
Active: active (running)
CGroup: /system.slice/myapp.service
├─1234 /usr/bin/myapp --config /etc/myapp/config.yml
├─1256 /usr/bin/myapp: worker 1
└─1257 /usr/bin/myapp: worker 2
Если приложение плодит воркеры или дочерние процессы, все они останутся видны в этом дереве до тех пор, пока юнит числится активным.
Проблема осиротевших процессов, которую решает cgroup
До повсеместного использования cgroup у init-систем (классического SysV init, ранних версий upstart) не было надёжного способа узнать, что именно породил сервис. Они отслеживали один PID — либо через pidfile, который писало само приложение, либо через PID процесса, которым была запущена команда старта. Если сервис форкался и «отвязывался» от родителя (например, классическим двойным форком для демонизации), новый процесс получал нового родителя — init (PID 1) — и переставал быть виден системе управления сервисами как часть исходного юнита. При остановке сервиса убивался только зарегистрированный PID, а осиротевший потомок продолжал жить сам по себе, занимая память и файловые дескрипторы, пока кто-то не находил его вручную через ps aux и не убивал руками.
Это тот же класс проблемы, что описан в статье про PID 1 в контейнере и потерянные сигналы: когда процесс с PID 1 не занимается корректной пересылкой сигналов и не подбирает завершившихся потомков (reap), система начинает накапливать зомби и потерянные процессы. У systemd на хосте эта проблема решена принципиально другим способом — не через дисциплину внутри процесса, а через границу cgroup, которую процесс не может покинуть самостоятельно. Даже если приложение внутри демонизируется, форкается на несколько уровней вглубь или намеренно пытается отвязаться от родителя, всё это остаётся в той же cgroup юнита. Отдельно от этого стоит проблема зомби-процессов — она не про побег из cgroup, а про то, что родитель не считал код возврата потомка; о ней подробно в статье зомби-процесс: почему не ест память, но опасен — и cgroup её не решает, это отдельный механизм.
Отдельно на поведение остановки влияет KillMode:
| KillMode | Кому уходит SIGTERM | Кому уходит финальный SIGKILL |
|---|---|---|
control-group (по умолчанию) | всем процессам в cgroup юнита | всем оставшимся процессам в cgroup |
mixed | только главному процессу | всем оставшимся процессам в cgroup |
process | только главному процессу | только главному процессу |
none | не отправляется | не отправляется |
process и none в документации systemd прямо не рекомендуются для обычных сервисов — они позволяют процессам «сбежать» из-под управления менеджером сервисов. Если у вашего приложения есть воркеры или дочерние процессы, которые должны гаситься вместе с ним, control-group — тот режим, который действительно это гарантирует.
Что это значит для настройки graceful shutdown приложения
Из механики выше следует несколько практических правил для тех, кто пишет или настраивает сервис под systemd.
Обрабатывайте SIGTERM в коде, а не полагайтесь на TimeoutStopSec. Таймаут — это подстраховка на случай, если что-то пошло не так, а не основной путь остановки. Пример минимального обработчика на Node.js:
process.on('SIGTERM', () => {
server.close(() => {
db.end();
process.exit(0);
});
});
И на Python:
import signal
import sys
def handle_sigterm(signum, frame):
stop_accepting_new_requests()
finish_in_flight_requests()
close_db_connections()
sys.exit(0)
signal.signal(signal.SIGTERM, handle_sigterm)
Считайте реальное время остановки и закладывайте его в TimeoutStopSec с запасом. Если приложению нужно 10-15 секунд, чтобы корректно дожать активные запросы и закрыть соединения с базой, ставьте TimeoutStopSec заметно больше — 30-45 секунд, а не оставляйте значение по умолчанию наугад. Для сервисов с длительными фоновыми задачами (импорт данных, обработка очереди) стоит либо увеличивать таймаут ещё сильнее, либо переносить логику остановки на уровень самой очереди — забирать job обратно, а не пытаться дождаться её конца при остановке процесса.
Не полагайтесь на то, что systemd убьёт «только лишнее». Если процесс форкает воркеров и не хочет, чтобы их убивали при остановке главного процесса (редкий, но встречающийся кейс — например, воркер должен доработать и сам выйти), придётся либо выносить воркеров в отдельный юнит, либо переопределять KillMode, отдавая себе отчёт, что тогда теряется гарантия зачистки всех потомков.
Учитывайте вложенные виртуализации. Если сервис сам запускает контейнер (Docker, Podman) или процесс внутри пространства имён, поведение аналогично: у контейнерного рантайма своя логика grace period до принудительного SIGKILL, и она работает по тому же принципу — сначала мягкий сигнал, потом безусловное убийство. Если интересно, как это устроено на уровне PID 1 внутри контейнера, — в статье про PID 1 в контейнере разобран именно этот механизм.
Для systemd-юнитов, которые управляют своим стартом сами (Type=forking, Type=notify), проверяйте, что стоп-логика синхронизирована. Если приложение уведомляет systemd о готовности через sd_notify, но не реализует симметричную логику для остановки, systemd всё равно будет ждать TimeoutStopSec и убивать по таймауту — просто потому что откуда системе знать, что процесс уже «внутренне» готов завершиться, если он не сказал об этом явно и не вышел.
Как выглядит зависшая остановка на практике
Если systemctl stop подвисает, полезно понимать, что можно увидеть в моменте. Пока идёт ожидание TimeoutStopSec, systemctl status покажет юнит в состоянии deactivating:
systemctl status myapp.service
● myapp.service - My Application
Active: deactivating (stop-sigterm) since ...
Журнал юнита обычно фиксирует момент отправки сигналов:
journalctl -u myapp.service -f
Если после этого в логе видно сообщение о том, что процесс не завершился вовремя и был убит принудительно — это и есть сработавший SIGKILL по истечении таймаута. Само сообщение точно формулируется по-разному в зависимости от версии systemd, поэтому ориентируйтесь на факт: юнит перешёл из deactivating в inactive не сразу после SIGTERM, а спустя интервал, близкий к TimeoutStopSec.
Есть один сценарий, где даже SIGKILL не поможет мгновенно — процесс в состоянии D (uninterruptible sleep), обычно ожидающий ввода-вывода на уровне ядра (например, завис на операции с диском или сетевой файловой системой). Такой процесс не реагирует ни на какие сигналы, пока сам не выйдет из этого состояния, и SIGKILL в этом случае будет «доставлен», но фактическое завершение произойдёт только тогда, когда завершится блокирующий его вызов ядра. Увидеть это состояние можно через:
ps -eo pid,stat,cmd | grep myapp
Буква D во втором столбце — признак именно такого зависания, и здесь проблема уже не в systemd и не в вашем обработчике сигналов, а в том, что застряло на уровне ядра или драйвера.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Что будет, если в unit-файле не задан ExecStop?
systemd сразу переходит к отправке SIGTERM (или другого сигнала, заданного через KillSignal=) процессам юнита, без промежуточной команды.
Можно ли поменять сигнал с SIGTERM на что-то другое?
Да, через KillSignal= в секции [Service] — некоторые приложения ожидают, например, SIGINT или SIGHUP для мягкой остановки.
Почему systemctl stop иногда завершается мгновенно, а иногда висит минуту-полторы?
Мгновенно — когда процесс сам корректно обработал SIGTERM и вышел. Долго — когда он не отреагировал за отведённое время и systemd досидел весь TimeoutStopSec, прежде чем послать SIGKILL.
Отличается ли поведение при перезапуске (systemctl restart) от остановки?
Нет, стадия остановки при restart идёт по тому же алгоритму — ExecStop, SIGTERM, таймаут, SIGKILL — просто следом сразу идёт запуск заново.
Что делать, если сервис регулярно приходится убивать по таймауту?
Это сигнал, что реальное время graceful shutdown приложения больше, чем заложенный TimeoutStopSec, либо в коде вообще нет корректного обработчика сигнала — стоит сначала добавить обработку SIGTERM, а уже потом при необходимости увеличивать таймаут.
Убивает ли SIGKILL процессы, порождённые сервисом через at, cron или nohup из его же скрипта?
Если такой процесс не был явно выведен из cgroup юнита (например, через systemd-run --scope в отдельный слайс), он останется в её составе и будет убит вместе с остальными при финальной зачистке.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →