Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 19 из 25 ~18 мин +20 XP

Graceful shutdown планировщика

Graceful shutdown cron — процедура остановки, при которой сервис по сигналу перестаёт принимать новую работу, даёт текущим тикам дожить до конца и выходит сам, а не от ножа оркестратора. У robfig/cron v3 для этого есть всё необходимое: Stop не пускает новые тики и отдаёт контекст завершения живых задач, а отмена внутри задач берётся на корневой контекст из урока про контекст. Недостающая половина — сигналы: SIGTERM от системы контейнеров, дедлайн, за который нужно уложиться, и дожим с таймером. Разберём полный порядок остановки сервиса с планировщиком внутри, выбор длительности дедлайна под ограничения оркестратора и честный ответ на вопрос, что делать с задачей, которая в дедлайн не уложилась.

Содержание урока · 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

GO
func 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 разрешает задачам стартовать вплоть до самого выхода.

Задача, которая умеет останавливаться посреди

GO
func 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

Кто останавливает сервис и сколько даёт#

Кто останавливаетЧто присылаетСколько даётЧто делать вам
KubernetesSIGTERM, затем SIGKILLterminationGracePeriodSeconds, обычно 30сдожим укладывать в 25с и меньше
docker stopSIGTERM, затем SIGKILL10с по умолчанию, флаг -t меняетзадавать -t явно и проверять в CI
systemdSIGTERMTimeoutStopSec, по умолчанию 90ссверять юнит-файл с дедлайном кода
терминалSIGINT по Ctrl+Cпока разработчик не нажмёт ещё разтот же обработчик, что для SIGTERM
ядроSIGKILLперехват невозможенне рассчитывать на дожим вообще

Спорное: дожимать тик любой ценой#

Спорная привычка, которую стоит оспорить: считать дожим обязанностью сервиса перед каждой задачей. Если типичный тик выгрузки длится 40 минут, а весь grace — 25 секунд, никакой дожим не спасёт: задача обречена обрываться на каждом деплое, и единственный вопрос — насколько грязно. Зрелая постановка другая: задача, которая не влезает в дедлайн, обязана уметь останавливаться посреди — чекпоинты между шагами, идемпотентная запись, повторное чтение после перезапуска. Тогда обрыв превращается в паузу, и дожим нужен лишь коротким задачам, которые реально успевают. Зеркальный вариант спорного — задача, которую лучше не начинать: если после сигнала времени заведомо не хватает даже на первый шаг, честнее отказаться от запуска и записать пропуск, чем начинать заведомо обречённую работу. Обе стороны спора сходятся в одном: длительности задач — данные, а не ощущения, и решение о дожиме принимается по гистограмме из предыдущего урока, а не по принципу «главное — доделать».

Чеклист остановки планировщика#

  • signal.NotifyContext слушает SIGINT и SIGTERM с самого старта процесса.
  • После сигнала: закрыть входящие потоки работы, отменить корневой контекст задач, затем Stop.
  • Дожим живых тиков — select по контексту Stop и time.After с дедлайном из конфига.
  • Дедлайн меньше таймаута оркестратора: разница покрывает HTTP, базу и финальную запись метрик.
  • Задачи проверяют ctx.Err() на границах шагов и пишут чекпоинты, чтобы обрыв был паузой.
  • Недожатые тики фиксируются в логе с именем задачи: утром по ним ищут, что доделать.

Соберите каркас graceful shutdown

Впишите создание контекста сигналов, метод планировщика и функцию дедлайна в дожиме.

ctx, stop := signal.(context.Background(), os.Interrupt, syscall.SIGTERM) done := c.() // новые тики не начинаются select { case <-done.Done(): log.Println("тики завершились") case <-time.(25 * time.Second): log.Println("дедлайн истёк") }

Расставьте последовательность остановки

Расставьте шаги shutdown в правильном порядке: от пришедшего сигнала до выхода из процесса.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Карточки: остановка планировщика

Термин

signal.NotifyContext

нажмите, чтобы перевернуть
Определение

Связка контекста с сигналами: ctx закрывается при первом SIGINT или SIGTERM, stop снимает обработку. После stop повторный сигнал завершает процесс по умолчанию.

1 / 6

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 — разница должна быть положительной. Затем уменьшите дедлайн ниже длительности задачи и убедитесь, что лог фиксирует недожатый тик по имени. Финал — проверьте второй сигнал: после первого процесс обязан завершиться мгновенно.

Личные заметкиПусто
Хранятся только в вашем браузере (Cron · Graceful shutdown планировщика)

Вопросы и ответы по уроку

Почему Stop не отменяет запущенные задачи?

Семантика Stop — остановка расписания: после вызова новые тики не начинаются, а живые дорабатывают. Отмена — отдельный механизм: корневой контекст задач, который вы отменяете явно. Разделение позволяет выбрать каждой задаче собственную стратегию завершения: дожить до конца, свернуться на границе шага или прервать сетевой вызов.

Какой дедлайн дожима выбрать?

Из таймаута инфраструктуры: terminationGracePeriodSeconds в Kubernetes, флаг -t у docker stop, TimeoutStopSec в systemd. Вычтите запас на закрытие HTTP-сервера, соединений с базой и финальную запись метрик — остаток и есть дедлайн кода. Для типовых 30 секунд Kubernetes на дожим остаётся 20-25 секунд.

Что делать с задачей, которая длится дольше дедлайна?

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

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Следующая ступень учебного пути

Slog25 уроков~9 чпрактика25 уроков уже доступны

Логирование в Go: структурные логи на slog

Курс по slog в Go: уровни и атрибуты, JSON-логи, свой Handler, производительность, секреты и прод. 25 уроков с примерами и проектом.

  • Логировать структурированно: уровни, ключи-атрибуты, группы
  • Выбирать формат вывода: TextHandler, JSONHandler, свой Handler
  • Мигрировать со старого пакета log без переписывания всей кодовой базы
  • Держать производительность: Enabled, предвычисление, горячий путь