---
title: "Graceful shutdown планировщика"
description: "Graceful shutdown cron: сигнал SIGTERM, Stop с дедлайном, отмена корневого контекста и дожим задач, порядок остановки сервиса с планировщиком внутри."
course: "Фоновые задачи в Go: планировщик на robfig/cron"
url: "https://golangmaster.ru/courses/cron/lessons/19-graceful-cron/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

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

*Курс «Фоновые задачи в Go: планировщик на robfig/cron», урок 19 из 25. ~18 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/cron/lessons/19-graceful-cron/*

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

Сценарий для старта: деплой в три часа ночи, новая версия контейнера приходит за старой. Оркестратор шлёт SIGTERM, ждёт отведённые секунды и убивает процесс. Планировщик жил без обработки сигнала: задача ночной выгрузки была посреди записи большого файла — она обрывается на полуслове вместе с процессом, на диске остаётся половина CSV без итоговой строки, а инкрементальный курсор не продвинулся. Утром отчёт собирается из битого файла и дублирует половину записей. База Stop() из [урока про Start и Stop](/courses/cron/lessons/09-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() вызван, пока задача ночной выгрузки ещё работает. Что произойдёт?

- Задача будет отменена: Stop закрывает её контекст
- Stop заблокируется до завершения задачи и вернёт результат
- ✅ Новые тики не начнутся, а Stop вернёт контекст, закрытый после завершения живых задач
- Stop убьёт горутину задачи, чтобы не мешать остановке

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

### Остановка задач go: контекст против Stop

Остановка задач go держится на разделении ролей, которое стоит проговорить отдельно. Stop отвечает только за расписание: после него ни одна запись не сработает заново. Отмена текущей работы — дело контекста: корневой контекст задач отменяется явно, и каждая задача, которая слушает Done, сворачивается на ближайшей границе шага. Отсюда порядок из кода выше: сначала cancel корневого контекста, потом Stop — между этими вызовами новая задача стартовать может, но контекст у неё уже отменён, и она выйдет сразу, если дисциплина контекста соблюдена. Задача, которая не слушает контекст, останавливается только вместе с процессом: сетевой вызов без дедлайна, длинный цикл без проверки Done — это неконтролируемые хвосты shutdown. Механику отмены и дедлайнов на запусках разобрал [урок про контекст задач](/courses/cron/lessons/10-kontekst-zadachi/), а поведение select при отмене — [урок про select](/courses/go-advanced/lessons/06-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() проверяется на границе шага — задача выходит между страницами, не бросая полуфайл. После перезапуска выгрузка продолжит с сохранённой страницы: идемпотентность превращает обрыв в паузу.

### Проверка: дедлайн истёк

**Вопрос:** Дедлайн дожима истёк, а задача всё ещё пишет большой файл. Как корректно завершить процесс?

- Ждать дальше: оркестратор подождёт, сколько потребуется
- ✅ Записать в лог незавершённую задачу и выходить: после таймаута оркестратора процесс всё равно будет убит
- Вызвать панику в задаче, чтобы Recover её погасил
- Увеличить дедлайн прямо во время ожидания и продолжить ждать

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

### Дедлайн: сколько времени давать задачам

Дедлайн дожима выбирается от противного: он обязан быть меньше таймаута оркестратора, иначе дожим закончится ножом. При terminationGracePeriodSeconds в 30 секунд коду остаётся 25 и меньше: секунды уходят на закрытие HTTP-сервера, соединений с базой, запись финальных метрик. Под каждый деплой проверяются реальные цифры: docker stop -t, systemd TimeoutStopSec, таймауты балансировщика, если процесс живёт не в контейнере. Вторая граница — фактические длительности задач: гистограмма из [урока про метрики](/courses/cron/lessons/18-metriki-zadach/) показывает p95 каждого тика, и дедлайн обязан покрывать типичный тик, а не рекорд. Если типичный тик не влезает в grace, задача меняется: SkipIfStillRunning не даст копить очередь догоняющих запусков, чекпоинты разобьют работу на шаги, между которыми можно выйти. Дедлайн — не константа: при росте данных его пересматривают вместе с расписанием.

### Что напечатает отмена?

```go
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
```

**Что выведет программа?**

- ✅ прервано: context canceled
- дожили
- пустой вывод: горутина зависла в select
- паника: отмена уже закрытого контекста

cancel закрывает канал Done, select мгновенно срабатывает на первом готовом случае, Err возвращает context canceled. Второй случай — таймер на час — останется висеть до срабатывания, но горутину это не блокирует: она закрыла done и вышла. Повторный cancel паники не даёт: отмена идемпотентна.

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

| Кто останавливает | Что присылает | Сколько даёт | Что делать вам |
| --- | --- | --- | --- |
| 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

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

```go
ctx, stop := signal.___(context.Background(), os.Interrupt, syscall.SIGTERM)

done := c.___() // новые тики не начинаются
select {
case <-done.Done():
    log.Println("тики завершились")
case <-time.___(25 * time.Second):
    log.Println("дедлайн истёк")
}
```

Подсказка: Контекст сигналов — NotifyContext; метод планировщика — Stop; таймер дожима — time.After.

Правильные ответы в пропусках: `NotifyContext`, `Stop`, `After`

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

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

Правильный порядок строк:

1. `<-ctx.Done() // сигнал получен`
2. `httpSrv.Shutdown(context.Background())`
3. `cancelJobs() // отмена корневого контекста задач`
4. `done := c.Stop()`
5. `select { case <-done.Done(): log.Println("готово") case <-time.After(25 * time.Second): log.Println("дедлайн") }`
6. `log.Println("выход из main")`

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

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

> **Где запускать примеры.** Сигналы операционной системы в песочнице не воспроизвести — пакеты os/signal и syscall там не работают, да и сигналов у браузерного процесса нет. Механику дожима она показывает полностью: select, каналы, time.After и time.Duration в ней есть, и каркас из предикта переписывается один в один — канал закрытия вместо Done. Полный shutdown с сигналом проверяется локально из модуля второго урока: запустите go run, пошлите kill -TERM процессу и посмотрите порядок строк в логе, затем попробуйте второй сигнал.

Graceful shutdown планировщика — это договорённость о времени и порядок действий: сигнал через NotifyContext открывает процедуру, отмена корневого контекста сворачивает текущую работу на границах шагов, Stop запрещает новые тики и отдаёт контекст завершения, select с time.After превращает ожидание в дожим с дедлайном, который обязан быть меньше таймаута оркестратора. Задачи, не влезающие в дедлайн, не дожимаются, а останавливаются посреди через чекпоинты — обрыв становится паузой. Дальше по маршруту: [утечки горутин](/courses/cron/lessons/20-utechki-gorutin/) — что остаётся в процессе после жёстких остановок и как их найти, [итоговый проект](/courses/cron/lessons/25-itogovyj-proekt/) — сервис задач, где shutdown собран целиком. Если возвращались с пропуска — [Start и Stop](/courses/cron/lessons/09-start-stop/) дают базу жизненного цикла, [контекст задач](/courses/cron/lessons/10-kontekst-zadachi/) — отмену внутри тиков, а [graceful shutdown для WebSocket](/courses/websocket/lessons/16-graceful-ws/) показывает ту же схему для соединений. Полный маршрут — [на странице курса](/courses/cron/). В домашнем задании — обработчик сигнала, дедлайн из конфига и задача с чекпоинтом.

## Коротко: что запомнить

- 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 секунд.

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

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