open atlas
↑ К треку
Go с нуля до senior GO · 13 · 02

Демоны и сигналы: без форк-танца, контракт SIGTERM и signal.NotifyContext как единый путь shutdown

Демон не форкается: жизненным циклом владеет systemd или контейнерный рантайм. SIGTERM — перестать принимать и дренировать до SIGKILL по TimeoutStopSec; SIGHUP — атомарная перезагрузка конфига; signal.NotifyContext объединяет shutdown; второй сигнал — немедленный выход.

GO Senior ◷ 17 min
Уровень
ОсновыJuniorMiddleSenior

Консьюмер очереди портил собственный файл состояния на каждом деплое, и месяц этого никто не замечал, потому что путь восстановления заметал следы — до дня, когда не смог. Демон разгребал локальную дисковую очередь, и его автор подключил signal.Notify(ch, syscall.SIGTERM) с горутиной, которая логировала «shutdown requested» и… продолжала работать. Graceful shutdown был в TODO. Вот в чём жестокость сигнального API: в момент вызова Notify Go отключает дефолтное поведение «умереть по SIGTERM» — недописанный обработчик не просто не выключался чисто, он делал процесс неубиваемым ничем, кроме SIGKILL. И каждый деплой Kubernetes слал SIGTERM, демон печатал бодрую строчку и продолжал писать, kubelet пережидал свои 30 секунд grace-периода, и SIGKILL прилетал посреди записи в файл очереди. Рваная запись, несовпадение чексуммы, crash-loop на следующем старте. Постмортем нашёл дымящуюся строку лога — «shutdown requested» — напечатанную за тридцать секунд до каждой порчи. Признание, которое никто не читал.

Демон — не ты, демон — супервизор

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

Классический ритуал юниксовой демонизации — fork, setsid для побега от управляющего терминала, ещё один fork, chdir в /, закрыть все дескрипторы, записать pidfile — решал проблему восьмидесятых: пережить logout администратора, запустившего тебя из шелла. Go-программа не должна исполнять его никогда, по двум причинам. Практическая: многопоточный рантайм не может безопасно форкаться без exec — после fork в многопоточном процессе ребёнку легальны только async-signal-safe операции, а рантайм Go — что угодно, только не это. Архитектурная: выигрывать больше нечего. systemd с Type=simple или контейнерный рантайм и есть демонизатор — он отвязывает тебя от терминала, владеет твоей группой процессов, перехватывает вывод, перезапускает при сбое и сам ведёт учёт твоего PID. Pidfile — легаси по той же причине: он существовал, чтобы init-скрипт мог найти самофоркнувшийся процесс; супервизор, который тебя запустил, и так точно знает, кто ты. Твой бинарник просто работает на переднем плане и логирует в stderr — journald или сборщик логов контейнера проставит метки, проиндексирует и отротирует.

Сигнальный контракт

Сигналы — словарь супервизора, и каждый из них — обещание, которое надо сдержать. Когда в постмортеме видишь корруцию данных или boot-loop на деплое — в девяти случаях из десяти одно из этих обещаний было нарушено:

  • SIGTERM — graceful-остановка. В контракте три пункта: перестать принимать новую работу, дренировать незавершённую, выйти с кодом 0 — и всё это до того, как кончится терпение супервизора: TimeoutStopSec в systemd (по умолчанию 90 с), terminationGracePeriodSeconds в Kubernetes (по умолчанию 30 с). За дедлайном приходит SIGKILL, который ни один процесс не может перехватить, заблокировать или отложить.
  • SIGINT — та же остановка, интерактивно. Ctrl-C доставляет его группе процессов переднего плана; обрабатывай его идентично SIGTERM, чтобы локальный запуск вёл себя как прод.
  • SIGHUP — перезагрузка конфигурации. Исторически «терминал повешен», и его дефолтное действие до сих пор завершает процесс — поэтому необработанный SIGHUP убивает программы при закрытии терминала. Под супервизором управляющего терминала нет, и сигнал — свободная недвижимость: конвенция — перечитать конфиг и применить атомарно.
  • SIGKILL и SIGSTOP — не твои. Неперехватываемы по замыслу; существование SIGKILL — причина, по которой у контракта SIGTERM есть дедлайн.

Вместе эти четыре сигнала определяют полный словарь жизненного цикла — пропусти любой, и ты либо неостановим вежливо, либо не можешь перезагрузить конфиг без аварии. И механизм, который привёл к истории из Hook: signal.Notify не наблюдает сигнал, а забирает его — рантайм отключает дефолтную диспозицию каждого зарегистрированного сигнала. Зарегистрированный, но проигнорированный SIGTERM даёт процесс, который вообще нельзя остановить вежливо.

Викторина

Демон вызывает signal.Notify(ch, syscall.SIGTERM), но ни одна горутина не делает с каналом ничего, кроме логирования. Оператор выполняет kill по PID. Что произойдёт?

signal.NotifyContext: shutdown — это просто отмена

Весь твой сервис уже говорит на одном языке отмены — context. signal.NotifyContext переводит сигналы на него, и «логика shutdown» схлопывается в ту же пропагацию, что ты строил в уроках про HTTP и жизненный цикл:

func main() { os.Exit(run()) }

func run() int {
	ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
	defer stop()

	srv := newServer()
	go srv.ListenAndServe()          // обслуживает, пока не позовут Shutdown

	<-ctx.Done()                     // первый SIGTERM/SIGINT приземляется здесь
	stop()                           // вернуть дефолтную диспозицию: второй сигнал убивает СРАЗУ

	drainCtx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
	defer cancel()
	if err := srv.Shutdown(drainCtx); err != nil {
		return 1                     // бюджет дренажа сорван — выходим грязно и громко
	}
	return 0
}

Две механики заслуживают внимательного чтения. Первая — бюджет дренажа: значение WithTimeout должно влезать в дедлайн супервизора с запасом — 25 с при 30-секундном grace-периоде Kubernetes, оставляя время на то, чтобы SIGTERM вообще долетел после снятия эндпоинта. Бюджет дренажа больше grace-периода — вежливая фикция, заканчивающаяся SIGKILL. Вторая — дисциплина двойного сигнала: вызов stop() сразу после первого сигнала снимает регистрацию, и следующий SIGTERM или ctrl-C попадает в восстановленную дефолтную диспозицию — мгновенная смерть. Это фича, а не баг: аварийный люк оператора на случай, когда дренаж завис, и каждый воспитанный демон его поставляет. Один сигнал — аккуратно. Два — немедленно.

Викторина

Почему в паттерне NotifyContext stop() вызывают сразу после срабатывания ctx.Done(), ещё до начала дренажа?

Перезагрузка по SIGHUP: перечитать, провалидировать, подменить

Перезагрузка — отдельный канал от shutdown, и её контракт транзакционен: собери весь новый конфиг, провалидируй и только потом публикуй — через atomic.Pointer[Config], тот же паттерн подмены из урока про конфигурацию. Читатели делают Load() указателя на каждый запрос и никогда не видят полуприменённого состояния. Какой сбой это убивает: мутацию живой структуры конфига поле за полем, пока запросы её читают, — гонку данных в одежде фичи. А когда новый файл не проходит валидацию, ответ — продолжать работать на старом конфиге и громко логировать: демон, умирающий на плохой перезагрузке, превращает опечатку в аварию.

hup := make(chan os.Signal, 1)
signal.Notify(hup, syscall.SIGHUP)
go func() {
	for range hup {
		next, err := loadAndValidate(path)
		if err != nil { log.Printf("reload rejected: %v", err); continue }
		current.Store(next)          // atomic.Pointer[Config]: читатели видят старое или новое, целиком
	}
}()

systemd-юнит, честно

[Service]
Type=simple
ExecStart=/usr/local/bin/queued
Restart=on-failure
TimeoutStopSec=30
WatchdogSec=30

Type=simple говорит прямо: демонизатор — супервизор; твой процесс просто работает. Restart=on-failure перезапускает падения, но не чистые выходы — always для процессов, которые не должны останавливаться намеренно никогда. TimeoutStopSec — дедлайн SIGTERM, против которого ты дренируешь. WatchdogSec заслуживает честной рамки: с библиотекой go-systemd ты шлёшь READY=1 через sd_notify на старте и периодически WATCHDOG=1; пропустишь интервал — systemd убьёт и перезапустит. Это страховка от дедлока — она ловит заклинивший главный цикл, и не более. Классический способ заставить её лгать: пинговать из выделенной горутины, которая остаётся здоровой, пока настоящий рабочий цикл в дедлоке. Шли пинг из цикла, чья живость тебя реально волнует, — иначе ты застраховал чужой дом.

Почему это работает

Зачем форк-танец вообще существовал и почему он исчез? Процесс, запущенный из шелла, принадлежал сессии этого терминала: logout посылал SIGHUP и убивал его. Двойной fork плюс setsid создавали бессессионного сироту, усыновлённого init, — демонство через искусство побега. Супервизоры перевернули модель: systemd запускает твой процесс уже отвязанным, уже усыновлённым, уже с логированием. Танец теперь активно вредит — самофоркающийся сервис путает учёт процессов супервизора (systemd приходится угадывать твой главный PID), а в Go сам fork небезопасен под многопоточным рантаймом. Правильное количество кода демонизации в современном Go-сервисе — ноль строк.

Вспомните перед уходом
  1. 01
    Сформулируй контракт SIGTERM, его дедлайны и что пустой обработчик signal.Notify делает с процессом.
  2. 02
    Разбери паттерн signal.NotifyContext: бюджет дренажа, дисциплину двойного сигнала и где здесь SIGHUP.
Итог

Ритуал fork-setsid-fork мёртв, а Go и не мог исполнить его безопасно: многопоточный рантайм не должен форкаться без exec, и супервизор — systemd Type=simple или контейнерный рантайм — уже даёт всё, что танец покупал: отвязку, усыновление, политику перезапуска, перехват логов, учёт PID. Pidfile — бухгалтерия мира без супервизоров. Что остаётся по-настоящему твоим — сигнальный контракт. SIGTERM открывает дедлайн — 90 секунд под systemd по умолчанию, 30 под Kubernetes, — внутри которого ты перестаёшь принимать, дренируешь незавершённую работу и выходишь чисто, потому что за дедлайном следует неперехватываемый SIGKILL. SIGINT — то же обещание, данное интерактивно. SIGHUP означает перезагрузку: пересобери конфигурацию, провалидируй, подмени атомарным указателем, чтобы ни один читатель не увидел полуприменённого состояния, и переживи плохой файл, оставшись на старом конфиге. Помни, что signal.Notify забирает сигналы, а не наблюдает их — зарегистрированный SIGTERM с пустым обработчиком создаёт неубиваемый процесс и историю о порче данных. signal.NotifyContext сворачивает всё это в дерево контекстов, которое у сервиса уже есть; вызови stop() после первого сигнала, чтобы второй убивал немедленно, — аварийный люк оператора. В юнит-файле Restart=on-failure обрабатывает падения, TimeoutStopSec — дедлайн, против которого ты дренируешь, а WatchdogSec с sd_notify — честная страховка от дедлока лишь тогда, когда пинг живости идёт из цикла, который действительно важен. Логируй в stderr; journald сделает остальное. Теперь, когда читаешь постмортем и видишь «shutdown requested» за тридцать секунд до каждой порчи, — ты знаешь: кто-то вызвал Notify и оставил TODO на месте.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.