---
title: "Плавная остановка воркеров"
description: "Asynq graceful shutdown: остановка воркеров при SIGTERM, Run против Start, дожим активных задач, ShutdownTimeout и возврат задач в очередь. Деплой без потерь."
course: "Asynq в Go: очередь фоновых задач на Redis"
url: "https://golangmaster.ru/courses/asynq/lessons/19-graceful-shutdown/"
published: "2026-09-19"
updated: "2026-09-23"
minutes: 18
---

# Плавная остановка воркеров

*Курс «Asynq в Go: очередь фоновых задач на Redis», урок 19 из 25. ~18 минут. Обновлено 23.09.2026. Источник: https://golangmaster.ru/courses/asynq/lessons/19-graceful-shutdown/*

Деплой — это SIGTERM старому поду, а в воркерах в этот момент живут задачи. Asynq graceful shutdown: перестать брать новое, дожать активное, вернуть недоделанное в очередь. Разберём Run против Start, дожим с ShutdownTimeout и остановку воркеров, при которой деплой не теряет задачи.

Новый релиз — это всегда убийство старого процесса: оркестратор шлёт SIGTERM, ждёт grace period и добивает упрямых SIGKILL. Если в этот момент воркер держит задачу, вопрос не в удобстве, а в данных: задача либо дожимается и закрывается, либо возвращается в очередь и исполняется заново — и повтор обязан быть безопасным, как договорились в [восемнадцатом уроке](/courses/asynq/lessons/18-idempotentnost/). Asynq обрабатывает остановку сам: ловит сигналы, перестаёт забирать новые задачи, дожидается активных хендлеров и возвращает в очередь неуложившиеся. Но цифры — зона ваша: ShutdownTimeout, таймауты задач и grace period оркестратора складываются в лестницу, и ошибка в ней превращает плавную остановку в kill -9 посреди транзакции. В этом уроке — механика остановки по шагам: что происходит при сигнале, чем Run отличается от Start, как устроен дожим активных задач и как рассчитать время, после которого деплой проходит без потерь и без лишних повторов.

### Что происходит при SIGTERM

Сигнальный обработчик asynq подписан на три сигнала: SIGTERM, SIGINT и SIGTSTP. TERM и INT — путь завершения: сначала вызывается Stop, затем Shutdown, после чего Run возвращает управление, и main завершается. TSTP — путь паузы: только Stop, процесс продолжает жить и держать соединения, о нём отдельно ниже. Stop переводит сервер в состояние stopped: процессор прекращает вытаскивать новые задачи из очередей — очередь копит их до следующего запуска или следующего пода. Активные хендлеры не прерываются: они продолжают работать до своего конца или до истечения лимита дожима, о котором дальше. Логи честно показывают каждый шаг: «Stopping processor» на входе, «Processor stopped» после, затем «Starting graceful shutdown» и «Waiting for all workers to finish...». Обратите внимание, что остановка двухфазная сознательно: сначала очередь замораживается на входе, потом разбирается остаток — так новые задачи не приходят, пока живые воркеры заняты дожимом старых. Практический вывод для локальной отладки: Ctrl+C в терминале — это SIGINT, и он запускает тот же полный цикл, что и деплойный TERM, поэтому остановку воркера можно репетировать без оркестратора — просто следите за логами. Если же процесс не реагирует на сигнал вовсе, проверьте, не запущен ли он через Start без вашего обработчика: в этом режиме asynq сигналы не ловит, и процесс умирает без всякого дожима.

### Минимальный main с Run

```go
srv := asynq.NewServer(
    asynq.RedisClientOpt{Addr: "localhost:6379"},
    asynq.Config{Concurrency: 10},
)

mux := asynq.NewServeMux()
mux.HandleFunc(tasks.TypeWelcomeEmail, tasks.HandleWelcomeEmail)

if err := srv.Run(mux); err != nil {
    log.Fatal(err)
}
```

Run запускает сервер и блокируется до сигнала: TERM или INT запускают Stop и graceful Shutdown, после чего функция возвращает управление и main завершается. Ошибки старта — недоступный Redis, битый конфиг — возвращаются из Run сразу, поэтому log.Fatal ловит именно проблемы запуска. Для выделенного воркера этого main достаточно: остановка уже корректная.

### Проверка: сигнал-пауза

**Вопрос:** Какой сигнал остановит забор новых задач, но оставит процесс жить?

- SIGTERM: штатный сигнал деплоя
- ✅ SIGTSTP: только Stop, без Shutdown
- SIGKILL: мягкая заморозка процесса
- SIGINT: Ctrl+C останавливает только забор

Из трёх подписанных сигналов только TSTP ведёт к одной фазе Stop: новые задачи не берутся, активные дожимаются, а процесс продолжает жить — Shutdown не вызывается. TERM и INT запускают полный цикл до выхода, а SIGKILL вообще не доходит до обработчика: ядро убивает процесс молча.

### Asynq graceful shutdown: Run против Start

У запуска два режима, и выбор определяет, кто ловит сигналы. Run — самодостаточный: старт, ожидание сигнала, Stop и Shutdown внутри; возвращает ошибку только старта. Он рассчитан на выделенный воркер, где больше ничего нет. Start — фоновый: запускает те же горутины и немедленно возвращает управление. Он нужен, когда в одном процессе живёт ещё и HTTP-API — связка из [двадцать первого урока](/courses/asynq/lessons/21-zadachi-v-web/): веб-сервер слушает порт, воркер обрабатывает задачи, и завершать их надо согласованно. С Start обработчик сигналов пишете вы: signal.Notify по SIGTERM, затем srv.Stop() — дожим активных задач, затем srv.Shutdown() и только после него остановка HTTP-сервера. Порядок важен: Shutdown обязан вызываться перед выходом — Stop лишь останавливает забор, и процесс без Shutdown не завершится корректно. Учтите и состояние сервера: уже закрытый сервер вернёт ErrServerClosed из повторных Start и Run — проверяйте errors.Is, если в вашей архитектуре сервер может останавливаться дважды. Для простого воркера Run покрывает всё; Start — инструмент для процессов, где у остановки есть хозяин.

### Дожим активных задач: Stop, Shutdown и ShutdownTimeout

Дожим — сердце плавной остановки. После Stop новые задачи не берутся, но активные хендлеры продолжают работать; Shutdown запускает для них таймер. Длительность задаёт ShutdownTimeout в конфиге сервера: если поле не задано или ноль, работает дефолт — восемь секунд. До истечения таймера всё развивается штатно: хендлер успел и вернул nil — задача закрывается успешной; вернул ошибку — обычный ретрай из шестого урока. Если же попытка не уложилась в ShutdownTimeout, процессор снимает её силой: контекст хендлера отменяется, а задача возвращается в pending — в логах это «Pushed task id back to queue». Никакой потери нет: задача подхватится другим воркером — скорее всего, свежеподнятым подом нового релиза. Но исполнение начнётся с нуля, и всё, что попытка успела сделать до отмены, обязано быть безопасным для повтора — восемнадцатый урок здесь обязательная база. Отсюда же понятна цена больших значений: ShutdownTimeout — верхняя граница времени деплоя, и каждый неуложившийся хендлер тормозит выкатку, сколько бы вас ни просили «подождать, сейчас доделает». Полезно знать и границы: дожим дожидается только тех, кто уже работает. Задачи, которые процессор ещё не успел вытянуть, остаются в очереди нетронутыми — для них остановка не видна. Поэтому прирост pending после деплоя — хороший датчик здоровья лестницы таймаутов: ноль после каждого выката значит, что числа подобраны верно, а постоянный рост — что дожим короче реальных хендлеров.

### ShutdownTimeout под реальный хендлер

```go
srv := asynq.NewServer(
    asynq.RedisClientOpt{Addr: "localhost:6379"},
    asynq.Config{
        Concurrency:     10,
        ShutdownTimeout: 25 * time.Second,
    },
)
```

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

### Что станет с задачей, не уложившейся в дожим?

```go
// asynq.Config{ShutdownTimeout: 10 * time.Second}
// хендлер задачи исполняется 40 секунд
// процесс получил SIGTERM, дожим закончился
```

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

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

Дожим конечен по построению: по истечении ShutdownTimeout попытка снимается, контекст отменяется, задача возвращается в pending — и подхватится следующим воркером. Процесс ждать весь хендлер не будет. Архив здесь ни при чём: туда попадают задачи с исчерпанными ретраями, а эта попытка просто не успела.

> **Живое место: grace period короче дожима.** Кейс: ShutdownTimeout выставили 60 секунд, а terminationGracePeriodSeconds в манифесте остался дефолтным — тридцать. На деплое k8s досчитал до тридцати и послал SIGKILL посреди дожима: хендлеры умерли вместе с процессом, задачи вернулись в очередь только через lease и recoverer — позже и грязнее, чем вернул бы их сам Shutdown. Если-то правило: если дожим длиннее grace period, эффект равен kill -9. Держите лестницу «таймаут задачи ≤ ShutdownTimeout с запасом ≤ terminationGracePeriodSeconds» и меняйте оба числа одновременно, а не по отдельности.

### Остановка воркеров в k8s: лестница таймаутов

В k8s и systemd остановка воркеров — рутина каждого деплоя, и она строится по лестнице из трёх чисел. Нижняя ступень — таймаут задачи из одиннадцатого урока: потолок длительности одной попытки, он же обещание «длиннее этого задача не живёт». Средняя — ShutdownTimeout: сколько ваш сервер готов дожидаться активных хендлеров. Верхняя — terminationGracePeriodSeconds в манифесте пода или в юните systemd: сколько оркестратор ждёт после SIGTERM, прежде чем слать SIGKILL. Каждая ступень обязана помещаться в предыдущую с запасом: таймаут задачи больше ShutdownTimeout не имеет смысла — попытка всё равно снимется на дожиме; ShutdownTimeout вплотную к grace period оставляет процессу ноль секунд на собственное завершение — соединения с Redis закроются криво. Приятно, что несколько реплик деплоятся независимо: старый под останавливается и возвращает недоделанное в очередь, новый поднимается и разбирает её — снаружи это выглядит как непрерывная работа. Отдельно упомяну preStop-хуки: они не обязательны, asynq всё делает по сигналу, но полезны, когда перед остановкой надо дать балансировщику исключить под из ротации.

### Лестница таймаутов остановки

| Параметр | Где живёт | Роль в остановке |
| --- | --- | --- |
| Timeout задачи | опция постановки, одиннадцатый урок | потолок длительности одной попытки |
| ShutdownTimeout | asynq.Config сервера | сколько дожидаться активных хендлеров перед снятием попытки |
| terminationGracePeriodSeconds | манифест k8s или юнит systemd | сколько оркестратор ждёт от SIGTERM до SIGKILL |
| Lease воркера | внутренний механизм asynq | воркер умер жёстко — задачу вернёт recoverer |

### Таймауты задачи против времени на дожим

Связка с [одиннадцатым уроком](/courses/asynq/lessons/11-tajmauty-dedlajny/) здесь не теоретическая: Timeout(10*time.Minute) у задачи превращает дефолтный дожим в фарс — восемь секунд против десяти минут, попытка снимется почти наверняка и вернётся в очередь почти пустой. Варианта три. Первый — дробить длинную работу на шаги и проверять ctx.Done() между ними: хендлер, который между шагами смотрит на контекст, снимается вежливо, на границе атомарных шагов, и идемпотентность из восемнадцатого урока закрывает последствия. Второй — наращивать ShutdownTimeout до самого долгого штатного хендлера и следить, чтобы grace period по-прежнему покрывал его с запасом; цена — медленный деплой, потому что остановка ждёт именно этот хендлер. Третий — принять повторы как данность: короткий дожим, безопасные хендлеры, задача вернётся и доделается. Выбор зависит от цены повтора: для письма сгодится третий, для списания денег — только первый или второй. Что точно не работает — игнорировать вопрос: по умолчанию любой хендлер длиннее восьми секунд деплой будет рвать.

### Чек-лист плавной остановки

- SIGTERM обрабатывается сам: Run ловит TERM, INT и TSTP — свой обработчик нужен только с Start.
- Stop прекращает забор новых задач, активные дожимаются: очередь копит остальное.
- Stop не завершает сервер: перед выходом обязателен Shutdown.
- ShutdownTimeout покрывает самый долгий штатный хендлер; восемь секунд по умолчанию — отправная точка.
- Лестница чисел: таймаут задачи ≤ ShutdownTimeout с запасом ≤ terminationGracePeriodSeconds.
- Неуложившиеся задачи возвращаются в pending и исполняются заново: повторы должны быть безопасны.
- Длинные хендлеры либо дробятся по ctx.Done(), либо получают увеличенный дожим — игнорировать нельзя.

### Проверка: Stop без Shutdown

**Вопрос:** В коде со Start по сигналу вызвали только srv.Stop() и завершили main. Что не так?

- Ничего: Stop полностью останавливает сервер
- ✅ Сервер не завершён: Stop только снимает забор задач, нужен ещё Shutdown
- Stop вернёт ошибку ErrServerClosed
- Активные задачи будут убиты мгновенно

Stop переводит сервер в stopped и останавливает забор, но фоновые горутины и соединение с Redis живут до Shutdown. Документация предупреждает прямо: Stop does not shutdown the server — перед выходом вызывайте Shutdown, иначе завершение не будет корректным.

### Соберите ручной graceful

Допишите запуск и остановку сервера для web+воркера в одном процессе.

```go
srv.___(mux) // неблокирующий запуск

// по SIGTERM из signal.Notify:
srv.___() // прекратить забор новых задач
srv.___() // дожать активных и закрыться
```

Подсказка: Запуск без блокировки — Start, снятие забора — Stop, финальное завершение — Shutdown.

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

### Соберите main web+воркера

Расставьте строки: запуск, веб, сигнал, остановка в правильном порядке.

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

1. `srv := asynq.NewServer(opt, asynq.Config{Concurrency: 10})`
2. `srv.Start(mux)`
3. `go httpSrv.ListenAndServe()`
4. `// SIGTERM: канал сигналов сработал`
5. `srv.Stop() // не берём новые задачи`
6. `srv.Shutdown() // дожим и закрытие воркеров`
7. `httpSrv.Shutdown(ctx) // остановить веб последним`
8. `log.Print("корректно завершено")`

> **SIGTSTP: пауза для ручных операций.** kill -TSTP <pid> — недооценённый тумблер: сервер перестаёт брать новые задачи, дожимает активные, но процесс живёт и держит Redis. Останавливаете забор перед миграцией базы, ждёте в логах «All workers have finished», делаете операцию, затем перезапускаете процесс — очередь за это время копится и разбирается после старта. Для деплоя это не замена, для ручных работ — удобно. Удобно и для отладки: поставьте паузу, посмотрите состояние очередей через инспектор из пятнадцатого урока, затем снимите её перезапуском.

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

- **Run(mux)** — Блокирующий запуск: сервер стартует и ждёт сигнала; TERM и INT запускают Stop и Shutdown. Ошибки старта возвращаются сразу.
- **Start(mux)** — Неблокирующий запуск для web+worker в одном процессе: управление возвращается сразу, сигналы и Shutdown — на вашей стороне.
- **Stop()** — Прекратить забор новых задач, активные дожать. Процесс не завершает: за остановкой обязан последовать Shutdown.
- **Shutdown()** — Graceful завершение: дожидается активных воркеров до ShutdownTimeout (дефолт 8 секунд), неуложившиеся попытки снимаются, задачи возвращаются в pending.
- **SIGTSTP** — Сигнал «только Stop»: забор остановлен, процесс живёт. TERM и INT — полный цикл до завершения.
- **terminationGracePeriodSeconds** — Grace period k8s между SIGTERM и SIGKILL. ShutdownTimeout обязан помещаться в него с запасом.

Плавная остановка собрана: сигналы обрабатывает сам asynq, Stop замораживает вход, Shutdown дожидается активных задач до ShutdownTimeout и возвращает неуложившиеся в очередь. Мини-эксперимент: поставьте задачу со сном на тридцать секунд, отправьте процессу SIGTERM и прочитайте логи по порядку — Stopping processor, дожим, Pushed task back to queue; поднимите воркер и увидите, что задача исполнилась с нуля. Формат прежний: код живёт локально с Go и Redis в Docker, песочница сайта показывает тематическую программу на чистом Go без Redis. Заведите себе деплойную привычку: после каждого выката сверяйте счётчики pending и retry в asynqmon до и после — ровные числа означают, что остановка воркеров настроена, а всплеск сразу подскажет, какой хендлер не влез в дожим. Дальше по курсу: [двадцать четвёртый урок](/courses/asynq/lessons/24-grabli-prod/) сведёт деплой-механику в чек-лист продакшена, а связка web+воркер через Start ждёт вас в двадцать первом. Одна репетиция SIGTERM локальным kill -TERM скажет о вашей настройке больше, чем любой чек-лист: дожим либо закрывается тихо, либо сразу называет хендлер, которому тесно.

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

- Деплой шлёт SIGTERM: asynq ловит TERM, INT и TSTP сам — обработчик сигналов нужен только при Start.
- Stop прекращает забор новых задач; активные хендлеры продолжают работать.
- Run блокируется до сигнала и делает всё сам; Start возвращается сразу — для web+worker в одном процессе.
- Stop не завершает сервер: перед выходом обязателен Shutdown.
- Shutdown дожидается активных задач до ShutdownTimeout — по умолчанию восемь секунд.
- Неуложившиеся попытки снимаются, задачи возвращаются в pending и исполняются заново с нуля.
- Лестница чисел: таймаут задачи ≤ ShutdownTimeout с запасом ≤ grace period оркестратора.
- Возврат в очередь — это повтор: хендлеры обязаны быть идемпотентными.

## Домашнее задание

Соберите деплой-эксперимент: задача со сном на тридцать секунд, ShutdownTimeout десять секунд, затем kill -TERM процессу. Проследите в логах полный цикл: Stopping processor, дожим, возврат задачи в очередь, выход. Поднимите воркер и убедитесь, что задача исполнилась заново с нуля. Потом поднимите ShutdownTimeout до сорока пяти секунд и убедитесь, что та же задача дожимается до успеха без повтора. В финале перепишите main на Start с собственным signal.Notify: по SIGTERM — Stop, затем Shutdown — это каркас для связки с HTTP-сервером из двадцать первого урока.

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

### Как понять, что дожим проходит штатно, а не рвёт задачи?

Смотрите логи и счётчики. Нормальный цикл остановки заканчивается в логах фразой «All workers have finished» — хендлеры уложились сами. Если после каждого деплоя в очереди всплеск pending, дожим короче хендлеров: сверяйте ShutdownTimeout с самым долгим штатным хендлером и держите лестницу таймаутов целиком.

### Нужно ли при деплое ждать опустошения очереди?

Нет, и обычно это даже вредно: очередь — штатный буфер, задачи спокойно подхватит новый под. Ждать опустошения имеет смысл только при несовместимых миграциях, когда новый релиз не умеет читать старые payload: тогда либо воркер понимает оба формата, либо очередь разбирается вручную через инспектор из пятнадцатого урока. В остальных случаях плавная остановка с дожимом закрывает вопрос целиком.

### Нужно ли писать свой обработчик SIGTERM?

Только если сервер запущен через Start — например, рядом с HTTP-API: тогда по сигналу вызываются Stop и Shutdown вручную. С Run ничего писать не нужно: библиотека подписывается на TERM, INT и TSTP сама и проводит полный цикл остановки. Свой signal.Notify поверх Run конфликтовать не будет, но и пользы не даст.

### Что произойдёт с задачей, если процесс убили SIGKILL?

Никакого graceful: ядро убивает процесс без обработчиков. Задача остаётся в active до истечения lease, затем recoverer вернёт её в retry — или в архив при исчерпании попыток. Это медленный и грубый путь против дожима, поэтому grace period оркестратора и ShutdownTimeout подбираются так, чтобы до SIGKILL дело не доходило.

### Гарантирует ли дожим, что все задачи завершатся?

Нет, и это осознанно: ShutdownTimeout конечен, иначе зависший хендлер блокировал бы деплой навсегда. Неуложившиеся попытки снимаются, задачи возвращаются в pending и исполняются заново — очередь гарантирует доставку, а не мгновенное завершение. Безопасность повтора обеспечивает идемпотентность хендлера из восемнадцатого урока, а не дожим.
