Содержание урока · 9 разделов
Сценарий для старта: деплой в три часа ночи, новая версия контейнера приходит за старой. Оркестратор шлёт SIGTERM, ждёт отведённые секунды и убивает процесс. Планировщик жил без обработки сигнала: задача ночной выгрузки была посреди записи большого файла — она обрывается на полуслове вместе с процессом, на диске остаётся половина CSV без итоговой строки, а инкрементальный курсор не продвинулся. Утром отчёт собирается из битого файла и дублирует половину записей. База Stop() из урока про Start и Stop здесь не спасла бы сама по себе: её никто не вызвал, потому что сигнал никто не слушал. А если бы и вызвал — без отмены контекста задача продолжала бы писать, пока её не убили принудительно. Обе половины — сигнал и отмена — и складываются в graceful shutdown: договорённость между сервисом и оркестратором о том, сколько времени даётся на прощание и что сервис успевает сделать за это время.
Graceful shutdown cron: сигнал, дедлайн, дожим#
Graceful shutdown cron начинается с сигнала. Стандартная библиотека даёт связку signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM): она возвращает контекст, который закрывается при первом пришедшем сигнале, и функцию stop, снимающую обработку. SIGTERM присылает docker stop и Kubernetes перед убийством пода, SIGINT — Ctrl+C в терминале; оба стоит слушать сразу. SIGKILL в эту схему не вписывается: ядро доставляет его без участия процесса, перехватить такой сигнал нельзя — это защита механизма, а не ограничение библиотеки. Kubernetes шлёт SIGTERM и через terminationGracePeriodSeconds, по умолчанию 30 секунд, убивает процесс; docker stop держит 10 секунд, если не указан флаг -t; systemd ждёт TimeoutStopSec. На каждый из этих таймаутов опирается ваш код: graceful shutdown — это гонка между дедлайном оркестратора и вашим дожимом, и выигрывать её должен дожим. Дальше — как выглядит полный обработчик и что происходит по шагам.
Обработка сигнала и дожим в main
GOfunc main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
c := cron.New(cron.WithChain(cron.Recover(logger)))
c.AddFunc("*/15 * * * *", pullOrders)
c.Start()
<-ctx.Done() // пришёл сигнал: SIGINT или SIGTERM
stop() // снять обработку: повторный сигнал убьёт процесс
cancelJobs() // отмена корневого контекста задач (урок 10)
done := c.Stop() // новые тики больше не начинаются
select {
case <-done.Done():
log.Println("все запущенные тики завершились")
case <-time.After(25 * time.Second):
log.Println("дедлайн дожима истёк, выходим")
}
}Первый сигнал закрывает контекст NotifyContext, и main выходит из ожидания. stop() снимает собственную обработку, поэтому второй сигнал завершит процесс мгновенно — так дежурный сможет принудительно остановить зависший сервис. Stop() возвращает контекст, который закрывается после завершения всех живых тиков; select с time.After превращает это ожидание в дожим с дедлайном. Вызов cancelJobs из урока про контекст отменяет корневой контекст задач — ниже станет ясно, почему он до Stop.
Проверка: что именно делает Stop
c.Stop() вызван, пока задача ночной выгрузки ещё работает. Что произойдёт?
Остановка задач go: контекст против Stop#
Остановка задач go держится на разделении ролей, которое стоит проговорить отдельно. Stop отвечает только за расписание: после него ни одна запись не сработает заново. Отмена текущей работы — дело контекста: корневой контекст задач отменяется явно, и каждая задача, которая слушает Done, сворачивается на ближайшей границе шага. Отсюда порядок из кода выше: сначала cancel корневого контекста, потом Stop — между этими вызовами новая задача стартовать может, но контекст у неё уже отменён, и она выйдет сразу, если дисциплина контекста соблюдена. Задача, которая не слушает контекст, останавливается только вместе с процессом: сетевой вызов без дедлайна, длинный цикл без проверки Done — это неконтролируемые хвосты shutdown. Механику отмены и дедлайнов на запусках разобрал урок про контекст задач, а поведение select при отмене — урок про select; здесь важен вывод: Stop без контекста останавливает расписание, но не работу, и наоборот — контекст без Stop разрешает задачам стартовать вплоть до самого выхода.
Задача, которая умеет останавливаться посреди
GOfunc pullOrders(ctx context.Context) {
for page := 1; ; page++ {
orders, err := api.Fetch(ctx, page)
if err != nil {
log.Printf("страница %d: %v", page, err)
return
}
if len(orders) == 0 {
return
}
save(orders)
checkpoint(page)
if ctx.Err() != nil {
log.Println("остановка по отмене, чекпоинт на странице", page)
return
}
}
}Контекст уходит в сетевой вызов: отмена прерывает и ожидание ответа, не только цикл. После каждой сохранённой страницы пишется чекпоинт, а ctx.Err() проверяется на границе шага — задача выходит между страницами, не бросая полуфайл. После перезапуска выгрузка продолжит с сохранённой страницы: идемпотентность превращает обрыв в паузу.
Проверка: дедлайн истёк
Дедлайн дожима истёк, а задача всё ещё пишет большой файл. Как корректно завершить процесс?
Дедлайн: сколько времени давать задачам#
Дедлайн дожима выбирается от противного: он обязан быть меньше таймаута оркестратора, иначе дожим закончится ножом. При terminationGracePeriodSeconds в 30 секунд коду остаётся 25 и меньше: секунды уходят на закрытие HTTP-сервера, соединений с базой, запись финальных метрик. Под каждый деплой проверяются реальные цифры: docker stop -t, systemd TimeoutStopSec, таймауты балансировщика, если процесс живёт не в контейнере. Вторая граница — фактические длительности задач: гистограмма из урока про метрики показывает p95 каждого тика, и дедлайн обязан покрывать типичный тик, а не рекорд. Если типичный тик не влезает в grace, задача меняется: SkipIfStillRunning не даст копить очередь догоняющих запусков, чекпоинты разобьют работу на шаги, между которыми можно выйти. Дедлайн — не константа: при росте данных его пересматривают вместе с расписанием.
Что напечатает отмена?
Прочитайте код и выберите, что он выведет:
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
select {
case <-ctx.Done():
fmt.Println("прервано:", ctx.Err())
case <-time.After(time.Hour):
fmt.Println("дожили")
}
close(done)
}()
cancel()
<-doneКто останавливает сервис и сколько даёт#
| Кто останавливает | Что присылает | Сколько даёт | Что делать вам |
|---|---|---|---|
| Kubernetes | SIGTERM, затем SIGKILL | terminationGracePeriodSeconds, обычно 30с | дожим укладывать в 25с и меньше |
| docker stop | SIGTERM, затем SIGKILL | 10с по умолчанию, флаг -t меняет | задавать -t явно и проверять в CI |
| systemd | SIGTERM | TimeoutStopSec, по умолчанию 90с | сверять юнит-файл с дедлайном кода |
| терминал | SIGINT по Ctrl+C | пока разработчик не нажмёт ещё раз | тот же обработчик, что для SIGTERM |
| ядро | SIGKILL | перехват невозможен | не рассчитывать на дожим вообще |
Кейс: команда заложила в код дожим на 30 секунд, не заглянув в настройки деплоя. Docker стоял с дефолтными 10 секундами: после сигнала сервис начал неторопливое завершение, через десять секунд получил SIGKILL посреди записи — ровно тот сценарий, ради которого shutdown и писали. Проверка стоит минуты: docker inspect показывает StopTimeout, манифест Kubernetes — terminationGracePeriodSeconds, юнит systemd — TimeoutStopSec. Правило простое: дедлайн в коде вычисляется из таймаута инфраструктуры минус запас на закрытие HTTP и соединений, а не назначается по ощущению. Вторая половина того же кейса: после смены оркестратора цифры поменялись, а код нет — дедлайн выносится в конфиг и проверяется тестом, который сравнивает его с ограничением среды.
Спорное: дожимать тик любой ценой#
Спорная привычка, которую стоит оспорить: считать дожим обязанностью сервиса перед каждой задачей. Если типичный тик выгрузки длится 40 минут, а весь grace — 25 секунд, никакой дожим не спасёт: задача обречена обрываться на каждом деплое, и единственный вопрос — насколько грязно. Зрелая постановка другая: задача, которая не влезает в дедлайн, обязана уметь останавливаться посреди — чекпоинты между шагами, идемпотентная запись, повторное чтение после перезапуска. Тогда обрыв превращается в паузу, и дожим нужен лишь коротким задачам, которые реально успевают. Зеркальный вариант спорного — задача, которую лучше не начинать: если после сигнала времени заведомо не хватает даже на первый шаг, честнее отказаться от запуска и записать пропуск, чем начинать заведомо обречённую работу. Обе стороны спора сходятся в одном: длительности задач — данные, а не ощущения, и решение о дожиме принимается по гистограмме из предыдущего урока, а не по принципу «главное — доделать».
Чеклист остановки планировщика#
- signal.NotifyContext слушает SIGINT и SIGTERM с самого старта процесса.
- После сигнала: закрыть входящие потоки работы, отменить корневой контекст задач, затем Stop.
- Дожим живых тиков — select по контексту Stop и time.After с дедлайном из конфига.
- Дедлайн меньше таймаута оркестратора: разница покрывает HTTP, базу и финальную запись метрик.
- Задачи проверяют ctx.Err() на границах шагов и пишут чекпоинты, чтобы обрыв был паузой.
- Недожатые тики фиксируются в логе с именем задачи: утром по ним ищут, что доделать.
Соберите каркас graceful shutdown
Впишите создание контекста сигналов, метод планировщика и функцию дедлайна в дожиме.
Расставьте последовательность остановки
Расставьте шаги shutdown в правильном порядке: от пришедшего сигнала до выхода из процесса.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: остановка планировщика
Сигналы операционной системы в песочнице не воспроизвести — пакеты os/signal и syscall там не работают, да и сигналов у браузерного процесса нет. Механику дожима она показывает полностью: select, каналы, time.After и time.Duration в ней есть, и каркас из предикта переписывается один в один — канал закрытия вместо Done. Полный shutdown с сигналом проверяется локально из модуля второго урока: запустите go run, пошлите kill -TERM процессу и посмотрите порядок строк в логе, затем попробуйте второй сигнал.
Graceful shutdown планировщика — это договорённость о времени и порядок действий: сигнал через NotifyContext открывает процедуру, отмена корневого контекста сворачивает текущую работу на границах шагов, Stop запрещает новые тики и отдаёт контекст завершения, select с time.After превращает ожидание в дожим с дедлайном, который обязан быть меньше таймаута оркестратора. Задачи, не влезающие в дедлайн, не дожимаются, а останавливаются посреди через чекпоинты — обрыв становится паузой. Дальше по маршруту: утечки горутин — что остаётся в процессе после жёстких остановок и как их найти, итоговый проект — сервис задач, где shutdown собран целиком. Если возвращались с пропуска — Start и Stop дают базу жизненного цикла, контекст задач — отмену внутри тиков, а graceful shutdown для WebSocket показывает ту же схему для соединений. Полный маршрут — на странице курса. В домашнем задании — обработчик сигнала, дедлайн из конфига и задача с чекпоинтом.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- SIGTERM перехватывается через signal.NotifyContext; SIGKILL перехватить нельзя — на него рассчитывает только инфраструктура.
- Stop запрещает новые тики и возвращает контекст завершения живых задач, но не отменяет их.
- Отмену текущей работы делает корневой контекст задач; задача без проверки Done останавливается только с процессом.
- Дожим — select по контексту Stop и time.After; дедлайн меньше таймаута оркестратора на запас закрытия.
- Задача, не влезающая в дедлайн, останавливается посреди через чекпоинты: обрыв превращается в паузу.
- Дедлайн выносится в конфиг и сверяется с docker, Kubernetes и systemd при каждом изменении инфраструктуры.
Домашнее задание
Соберите полный shutdown в main: NotifyContext на SIGINT и SIGTERM, закрытие входящих потоков, отмена корневого контекста задач, Stop и дожим с дедлайном из конфига. Заведите длинную задачу с чекпоинтами между шагами и проверьте вручную: kill -TERM во время тика, затем рестарт — работа продолжается с чекпоинта, битых файлов нет. Сравните дедлайн кода с таймаутом вашего окружения: docker inspect, манифест Kubernetes или юнит systemd — разница должна быть положительной. Затем уменьшите дедлайн ниже длительности задачи и убедитесь, что лог фиксирует недожатый тик по имени. Финал — проверьте второй сигнал: после первого процесс обязан завершиться мгновенно.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему Stop не отменяет запущенные задачи?
Семантика Stop — остановка расписания: после вызова новые тики не начинаются, а живые дорабатывают. Отмена — отдельный механизм: корневой контекст задач, который вы отменяете явно. Разделение позволяет выбрать каждой задаче собственную стратегию завершения: дожить до конца, свернуться на границе шага или прервать сетевой вызов.
Какой дедлайн дожима выбрать?
Из таймаута инфраструктуры: terminationGracePeriodSeconds в Kubernetes, флаг -t у docker stop, TimeoutStopSec в systemd. Вычтите запас на закрытие HTTP-сервера, соединений с базой и финальную запись метрик — остаток и есть дедлайн кода. Для типовых 30 секунд Kubernetes на дожим остаётся 20-25 секунд.
Что делать с задачей, которая длится дольше дедлайна?
Не дожимать, а проектировать остановку посреди: чекпоинты между шагами, идемпотентная запись, продолжение после перезапуска. Дедлайн покрывает короткие задачи, а длинная при каждом деплое выходит с сохранённого чекпоинта. Фактические длительности подскажет гистограмма из урока про метрики.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Логирование в Go: структурные логи на slog
Курс по slog в Go: уровни и атрибуты, JSON-логи, свой Handler, производительность, секреты и прод. 25 уроков с примерами и проектом.
- Логировать структурированно: уровни, ключи-атрибуты, группы
- Выбирать формат вывода: TextHandler, JSONHandler, свой Handler
- Мигрировать со старого пакета log без переписывания всей кодовой базы
- Держать производительность: Enabled, предвычисление, горячий путь