Содержание урока · 10 разделов
- Что происходит при SIGTERM
- Asynq graceful shutdown: Run против Start
- Дожим активных задач: Stop, Shutdown и ShutdownTimeout
- Остановка воркеров в k8s: лестница таймаутов
- Лестница таймаутов остановки
- Таймауты задачи против времени на дожим
- Чек-лист плавной остановки
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Новый релиз — это всегда убийство старого процесса: оркестратор шлёт SIGTERM, ждёт grace period и добивает упрямых SIGKILL. Если в этот момент воркер держит задачу, вопрос не в удобстве, а в данных: задача либо дожимается и закрывается, либо возвращается в очередь и исполняется заново — и повтор обязан быть безопасным, как договорились в восемнадцатом уроке. 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
GOsrv := 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 достаточно: остановка уже корректная.
Проверка: сигнал-пауза
Какой сигнал остановит забор новых задач, но оставит процесс жить?
Asynq graceful shutdown: Run против Start#
У запуска два режима, и выбор определяет, кто ловит сигналы. Run — самодостаточный: старт, ожидание сигнала, Stop и Shutdown внутри; возвращает ошибку только старта. Он рассчитан на выделенный воркер, где больше ничего нет. Start — фоновый: запускает те же горутины и немедленно возвращает управление. Он нужен, когда в одном процессе живёт ещё и HTTP-API — связка из двадцать первого урока: веб-сервер слушает порт, воркер обрабатывает задачи, и завершать их надо согласованно. С 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 под реальный хендлер
GOsrv := asynq.NewServer(
asynq.RedisClientOpt{Addr: "localhost:6379"},
asynq.Config{
Concurrency: 10,
ShutdownTimeout: 25 * time.Second,
},
)ShutdownTimeout — сколько сервер дожидается активных хендлеров перед тем, как снять попытку и вернуть задачу в очередь. Восемь секунд по умолчанию хватает письмам, но не платёжному хендлеру с внешними вызовами. Значение берётся чуть больше самого долгого штатного хендлера — и обязательно с запасом помещается в grace period оркестратора, иначе деплой оборвёт дожим.
Что станет с задачей, не уложившейся в дожим?
Прочитайте код и выберите, что он выведет:
// asynq.Config{ShutdownTimeout: 10 * time.Second}
// хендлер задачи исполняется 40 секунд
// процесс получил SIGTERM, дожим закончилсяКейс: 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 |
Таймауты задачи против времени на дожим#
Связка с одиннадцатым уроком здесь не теоретическая: 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. Что не так?
Соберите ручной graceful
Допишите запуск и остановку сервера для web+воркера в одном процессе.
Соберите main web+воркера
Расставьте строки: запуск, веб, сигнал, остановка в правильном порядке.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
kill -TSTP <pid> — недооценённый тумблер: сервер перестаёт брать новые задачи, дожимает активные, но процесс живёт и держит Redis. Останавливаете забор перед миграцией базы, ждёте в логах «All workers have finished», делаете операцию, затем перезапускаете процесс — очередь за это время копится и разбирается после старта. Для деплоя это не замена, для ручных работ — удобно. Удобно и для отладки: поставьте паузу, посмотрите состояние очередей через инспектор из пятнадцатого урока, затем снимите её перезапуском.
Карточки: плавная остановка
Плавная остановка собрана: сигналы обрабатывает сам asynq, Stop замораживает вход, Shutdown дожидается активных задач до ShutdownTimeout и возвращает неуложившиеся в очередь. Мини-эксперимент: поставьте задачу со сном на тридцать секунд, отправьте процессу SIGTERM и прочитайте логи по порядку — Stopping processor, дожим, Pushed task back to queue; поднимите воркер и увидите, что задача исполнилась с нуля. Формат прежний: код живёт локально с Go и Redis в Docker, песочница сайта показывает тематическую программу на чистом Go без Redis. Заведите себе деплойную привычку: после каждого выката сверяйте счётчики pending и retry в asynqmon до и после — ровные числа означают, что остановка воркеров настроена, а всплеск сразу подскажет, какой хендлер не влез в дожим. Дальше по курсу: двадцать четвёртый урок сведёт деплой-механику в чек-лист продакшена, а связка web+воркер через Start ждёт вас в двадцать первом. Одна репетиция SIGTERM локальным kill -TERM скажет о вашей настройке больше, чем любой чек-лист: дожим либо закрывается тихо, либо сразу называет хендлер, которому тесно.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Деплой шлёт 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 и исполняются заново — очередь гарантирует доставку, а не мгновенное завершение. Безопасность повтора обеспечивает идемпотентность хендлера из восемнадцатого урока, а не дожим.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Kafka в Go: продюсеры и консьюмеры на kafka-go
Курс Kafka для Go: топики и партиции, producer и consumer, группы, оффсеты, гарантии доставки, rebalance, Admin API и мониторинг. 25 уроков.
- Понимать топики, партиции, оффсеты и группы консьюмеров
- Писать продюсеры с ключами, батчами и ретраями
- Читать сообщения группой с коммитами оффсетов
- Выбирать гарантии: at-most-once, at-least-once