Планировщик задач в Go-сервисе: чек-лист перед продакшеном
Встроенный cron-подобный планировщик в Go-сервисе — удобная штука, пока не начинаются дубли, потерянные задачи и утечки горутин. Разбираем конкретные точки, которые стоит проверить до деплоя, а не после первого инцидента.

Зачем отдельный чек-лист
Типичный путь: в сервис на Go добавляют периодические задачи — отправку отчётов, очистку устаревших записей, синхронизацию с внешним API. Берут time.Ticker или библиотеку вроде robfig/cron/v3, пишут пару функций, запускают в горутине — и на стейджинге всё работает. Проблемы начинаются, когда сервис масштабируется до нескольких реплик, задача падает посреди ночи, а горутина молча утекает. Этот чек-лист — набор конкретных проверок, каждая из которых закрывает реальный сценарий отказа.
Что нужно проверить до начала
Прежде чем идти по списку, определитесь с контекстом:
- Сколько реплик сервиса будет в проде — одна или несколько?
- Задачи идемпотентны или повторный запуск ломает данные?
- Есть ли внешнее хранилище состояния (PostgreSQL, Redis) или сервис stateless?
- Какой допустимый интервал «молчания» задачи, прежде чем это инцидент?
Ответы влияют на то, какие пункты чек-листа критичны, а какие можно отложить.
Чек-лист: 10 пунктов
1. Graceful shutdown и контекст отмены
Каждая задача должна принимать context.Context и реагировать на его отмену. При завершении сервиса (SIGTERM от оркестратора) планировщик получает сигнал, перестаёт запускать новые задачи и ждёт завершения текущих. Без этого Kubernetes убьёт процесс через terminationGracePeriodSeconds, и задача оборвётся на середине.
Проверка: отправьте SIGTERM во время выполнения длинной задачи. Задача должна завершиться корректно, а в логах — запись о graceful shutdown, а не о kill.
2. Защита от дублей при нескольких репликах
Если реплик больше одной, каждая запустит свой экземпляр планировщика. Два варианта решения:
- Distributed lock — перед запуском задача захватывает блокировку в Redis (SET NX с TTL) или PostgreSQL (advisory lock). Не получила — пропускает итерацию.
- Leader election — только одна реплика запускает планировщик. В Kubernetes для этого используют Lease-объекты через
client-go/tools/leaderelection.
Первый вариант проще, второй надёжнее при большом числе задач. Без одного из них вы получите двойную отправку писем, двойное списание — всё, что не должно случаться дважды.
3. Идемпотентность задачи
Даже с блокировкой сетевой сбой может привести к повторному запуску. Каждая задача должна быть безопасна при повторе: использовать уникальные ключи операций, проверять состояние перед действием, не полагаться на «запустится ровно один раз».
4. Таймаут на выполнение
Задача без таймаута — бомба замедленного действия. Внешний API завис, запрос к базе ушёл в долгий lock wait — горутина висит бесконечно, а следующая итерация может породить ещё одну. Оборачивайте контекст через context.WithTimeout и выбирайте таймаут исходя из максимально допустимого времени выполнения, а не среднего.
5. Ограничение параллельности
Если интервал задачи — минута, а выполнение иногда занимает две, вторая итерация стартует параллельно первой. Решения:
- Пропускать итерацию, если предыдущая не завершилась (в
robfig/cron/v3этоcron.WithChain(cron.SkipIfStillRunning(...))). - Использовать семафор — буферизованный канал или
golang.org/x/sync/semaphore.
Какой вариант выбрать — зависит от того, допустим ли пропуск. Для очистки мусора — допустим. Для синхронизации данных с жёстким SLA — нет, и тогда стоит пересмотреть интервал или вынести задачу в отдельный воркер.
6. Обработка паник
Паника в горутине убивает весь процесс, а не только задачу. Оборачивайте запуск задачи в recover. Библиотека robfig/cron/v3 позволяет навесить middleware через cron.Recover(logger), но убедитесь, что recover-обёртка логирует стектрейс, а не глотает его молча.
7. Логирование и трейсинг
Каждый запуск задачи — отдельный trace ID или хотя бы пара полей в structured-логе: имя задачи, время старта, время завершения, результат (ok / error / skipped / panic). Без этого при разборе инцидента вы не отличите «задача не запустилась» от «задача запустилась и упала».
8. Метрики и алерты
Минимальный набор метрик для Prometheus или аналога:
| Метрика | Тип | Зачем |
|---|---|---|
| task_runs_total | counter | Сколько раз задача запускалась, с label status (ok, error, skipped) |
| task_duration_seconds | histogram | Время выполнения — видеть деградацию |
| task_last_success_timestamp | gauge | Когда последний раз задача завершилась успешно — основа для алерта «задача не отрабатывала N минут» |
Алерт на task_last_success_timestamp — самый важный. Без него задача может молча ломаться неделями.
9. Конфигурация интервалов снаружи
Захардкоженный интервал в коде — это редеплой ради смены расписания. Выносите cron-выражения или интервалы в переменные окружения или конфиг-файл. Дополнительно полезен флаг «отключить задачу» без передеплоя — через переменную или feature toggle. Это спасает, когда задача начала вредить и нужно остановить её за секунды, а не за минуты CI/CD-пайплайна.
10. Тестирование расписания отдельно от логики
Логику задачи оформляйте как обычную функцию с зависимостями через интерфейсы. Тестируйте её юнит-тестами. Интеграцию с планировщиком проверяйте отдельно: запустите планировщик с коротким интервалом, убедитесь, что задача вызывается нужное число раз, и остановите. Не тестируйте бизнес-логику через ожидание таймера — это медленно и нестабильно.
Сводная таблица: что и когда критично
| Пункт | Одна реплика | Несколько реплик | Задача не идемпотентна |
|---|---|---|---|
| Graceful shutdown | Критично | Критично | Критично |
| Distributed lock / leader | Не нужно | Критично | Критично |
| Идемпотентность | Желательно | Критично | — |
| Таймаут | Критично | Критично | Критично |
| Ограничение параллельности | Критично | Критично | Критично |
| Recover от паник | Критично | Критично | Критично |
| Логирование | Критично | Критично | Критично |
| Метрики и алерты | Желательно | Критично | Критично |
| Конфигурация снаружи | Желательно | Желательно | Критично |
| Тесты расписания | Желательно | Желательно | Желательно |
Планировщик задач — одна из тех частей системы, которые не ломаются на стейджинге. Они ломаются в три часа ночи в проде, когда реплик стало две вместо одной, а задача решила, что она единственная.
Типичные ошибки и как они выглядят
- Горутина утекла, память растёт. Задача запускается в горутине, но контекст не пробрасывается. Внешний вызов зависает — горутина живёт вечно. Каждую итерацию появляется новая. Через сутки — OOM kill. Лечится таймаутом на контексте.
- Двойная отправка уведомлений после скейлинга. Утром добавили вторую реплику, к вечеру пользователи получили два одинаковых письма. Лечится distributed lock или leader election.
- Задача упала — никто не узнал. Ошибка логируется на уровне debug (или не логируется вовсе), метрик нет. Данные рассинхронизировались на три дня, прежде чем заметили. Лечится алертом на
task_last_success_timestamp. - Паника в задаче убила сервис. Один nil pointer в обработке ответа от API — и весь pod перезапускается, теряя все текущие HTTP-запросы. Лечится recover-обёрткой.
Advisory lock* — механизм PostgreSQL, позволяющий захватить именованную блокировку на уровне сессии или транзакции. В отличие от блокировки строки таблицы, advisory lock не привязан к конкретным данным и не мешает обычным запросам. Используется для координации между процессами, в том числе — для защиты от параллельного запуска задач.
Что дальше
После прохождения чек-листа стоит решить, не перерос ли ваш набор задач встроенный планировщик. Если задач больше десяти, нужны ретраи с бэкоффом, приоритеты и персистентная очередь — смотрите в сторону отдельных систем: очередей задач на базе PostgreSQL или Redis, внешних планировщиков вроде Temporal. Но для трёх-пяти периодических задач внутренний планировщик с пройденным чек-листом — вполне рабочее решение. Главное — не забыть про этот чек-лист до деплоя, а не вспомнить о нём после первого ночного алерта.
Тендерный специалист 44-ФЗ / 223-ФЗ: Разбор PDF-документации закупки, поиск «заточек» под поставщика, чек-лист заявки.
Спросить за 20 ₽Источники: robfig/cron — Go cron library, документация и API, Kubernetes: CronJob и управление периодическими задачами
Частые вопросы
Можно ли использовать обычный crontab вместо встроенного планировщика?
Что лучше — distributed lock на Redis или advisory lock в PostgreSQL?
Как быть, если задача выполняется дольше интервала?
А если у меня другая версия Go или библиотеки — чек-лист всё ещё актуален?
Как протестировать graceful shutdown локально?
Что делать, если задача начала вредить в проде и нужно срочно остановить?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.