Москва: проверенные б/у ноутбуки с гарантией и проверкой перед оплатой
+7 906 722-94-45

Главная → Статьи → APScheduler и планировщики задач в Python

Планировщики в Python: APScheduler, cron и когда что выбирать

Как запускать задачи по расписанию: APScheduler (интервальные, cron-триггеры, персистентность), сравнение с системным cron и Celery Beat, пропущенные запуски, дубли при нескольких процессах.

Прямой ответ

Выбор планировщика — про то, где живёт расписание. APScheduler — библиотека внутри вашего процесса: удобен, когда задачи — код приложения (доступ к конфигу, подключению, ORM), а инфраструктуру поднимать не хочется; триггеры — interval/cron/date, хранилище задач — от памяти до PostgreSQL. Celery Beat — когда уже есть Celery: расписание живёт рядом с очередями, исполнение — на воркерах (переживает рестарт приложения). Системный cron — для ОС-задач и скриптов вне приложения (бэкапы, ротация; механика — в статье про cron). Главный проектный вопрос при этом не «какая библиотека», а два других: что делать с пропущенными запусками (misfire) и как не запустить задачу дважды, когда процессов несколько.

APScheduler: устройство и настройка

Компоненты: scheduler (BackgroundScheduler — в фоне потока; AsyncIOScheduler — на event loop; BlockingScheduler — отдельный процесс), триггеры (IntervalTrigger(minutes=15), CronTrigger(hour=8, minute=30, day_of_week="mon-fri") — полный cron-синтаксис с секундами, DateTrigger — разово), executors (потоки/процессы), job stores (MemoryJobStore — по умолчанию; SQLAlchemyJobStore/RedisJobStore — задачи переживают рестарт). Добавление задач — scheduler.add_job(fn, "cron", ...) или декоратор @scheduler.scheduled_job(...). Практика: явный id у каждой задачи (пересоздание при старте без дублей — replace_existing=True), логирование запусков/ошибок через логгер apscheduler (молча падающая задача по расписанию — классика незамеченных сбоев), таймауты внутри задач (повисшая задача держит executor).

Misfire и пропущенные запуски: главная тонкость

Процесс спал/лежал в момент срабатывания — запуск пропущен, и APScheduler решает по misfire_grace_time: задача моложе grace-секунд — выполнится поздно, старше — отменится (по умолчанию 1 секунда — «опоздал на две — не беги»). Политика выбирается по природе задачи: «собрать метрики каждые 5 минут» — пропуск допустим (короткий grace); «ночной биллинг» — обязан выполниться даже с опозданием (misfire_grace_time=None); «отправить уведомление в 9:00» — опоздание на час хуже отмены (короткий grace + алерт). Смежный параметр — coalesce=True (пять пропущенных запусков сливаются в один, а не выполняются пачкой при пробуждении) и max_instances (следующий запуск ждёт/отменяется, если предыдущий ещё работает — без него долгая задача порождает параллельные копии себя).

Несколько процессов: дубли — неизбежная проблема

APScheduler в каждом воркере uwicorn/gunicorn = задача выполняется N раз (каждый процесс считает себя единственным планировщиком). Решения: выделенный процесс-планировщик (один инстанс с шедулером, остальные без — стандарт для веб-приложений); распределённая блокировка вокруг задачи (Redis SET NX с TTL — выполняется один, остальные пропускают); персистентный job store сам по себе от дублей не спасает (каждый процесс читает то же расписание) — это про переживание рестарта, не про конкурентность. Для Celery-стека проблемы нет по конструкции: Beat — отдельный одиночный процесс, воркеры только исполняют (Beat в двух экземплярах — те же дубли; его держат одним). Честная проверка при деплое: «сколько экземпляров моего приложения запустят эту задачу» — вопрос, на который должен быть ответ «ровно один».

Сравнение с альтернативами

Celery Beat — если Celery уже есть: единая инфраструктура, исполнение изолировано от веб-процесса, ретраи/мониторинг из коробки (Celery-практика); минус — тяжёл для «трёх задач по расписанию» без очередей. Системный cron — нуль зависимостей, работает без Python-процесса, но задачи вне приложения (нет доступа к его конфигу/состоянию без плясок), логирование/ретраи — ваши, и в контейнерных деплоях cron неудобен (один процесс на контейнер). pg_cron — расписания вокруг данных прямо в PostgreSQL (чистки, агрегаты — без отдельного воркера). K8s CronJob — когда приложение в Kubernetes: запуск отдельного пода по расписанию, изоляция и ретраи средствами кластера. Лестница: внутри процесса → APScheduler; есть очереди → Beat; ОС/инфраструктура → cron/K8s CronJob; чистка данных → pg_cron.

Время и часовые пояса в расписаниях

Cron-триггер вычисляется в явно указанной зоне: CronTrigger(hour=9, timezone="Europe/Moscow") — «в 9 по Москве» круглый год, включая переходы DST; без timezone — локальная зона сервера (на сервере в UTC «утренние» задачи уедут; разбор зон — в статье про datetime). Храните зону расписания как данные (настройка, не константа) и различайте два класса задач: «календарные» (в 9:00 местного времени — cron-триггер в зоне) и «интервальные» (каждые 24 часа — interval, в UTC по построению). Летнее время — ежегодный источник «задача выполнилась дважды/ни разу» в самописных шедулерах; в APScheduler/Beat эту математику делает библиотека — ещё один аргумент против while True: sleep.

Практический сценарий: расписание в веб-приложении

Типовая задача: в FastAPI/Django-приложении нужно по ночам считать отчёты и раз в пять минут снимать метрики. Ошибочное решение — включить BackgroundScheduler при старте приложения: gunicorn поднимает четыре воркера, и отчёт считается четырежды. Правильная сборка: шедулер живёт в отдельном процессе (systemd-юнит, отдельный контейнер или management-команда), который импортирует тот же код приложения — конфигурацию, модели, сервисный слой, — но не поднимает HTTP-сервер. Задачи регистрируются с явными id и replace_existing=True, чтобы перезапуск процесса не плодил дубликаты; job store — PostgreSQL, тогда список задач виден в БД и переживает рестарты. Метрики-задача делается идемпотентной по времени окна («посчитали 12:00–12:05 — повтор не дублирует ряд»), а ночной отчёт получает misfire_grace_time в пару часов: если сервер обновлялся в 03:00, отчёт всё равно посчитается в 03:20. Логи каждого запуска — с именем задачи, длительностью и результатом; алерт настроен не на «задача упала» (её падение видно в логах), а на «задача не запускалась дольше двух периодов» — именно так выглядят тихие смерти планировщика, когда процесс жив, но шедулер внутри него умер.

FAQ

APScheduler 3.x или 4.x? Для стабильных проектов 3.x — проверенный API с полной документацией; 4.x — переработанная архитектура (отдельный scheduler-сервис, распределённость из коробки) — выбирайте по актуальному статусу релиза на момент старта проекта.

Как пережить деплой без пропуска задач? Per-store на PostgreSQL/Redis (задачи переживают рестарт) + misfire_grace_time под природу задачи + graceful shutdown (scheduler.shutdown(wait=True)) — короткие деплои проходят незамеченными, длинные — догоняются по политике misfire.

Нужны ли ретраи задачам по расписанию? Обычно нет в классическом смысле: следующий запуск по расписанию и есть ретрай (задача обязана быть идемпотентной). Ретраи нужны для «обязательных» разовых операций — и там это уже очередь с повторами, а не шедулер.

Как тестировать задачи по расписанию? Функцию задачи — как обычный код (юнит-тесты, моки времени через freezegun); конфигурацию расписания — ассертами на триггеры (задача зарегистрирована, час/зона верны); сквозной прогон — запуском job-функции напрямую, без ожидания реального срабатывания.

Источники: документация APScheduler, Celery Periodic Tasks, crontab(5) — ссылки в шапке.

FAQ

Что делать, если задача должна идти строго один раз, а процессов несколько? Распределённый лок вокруг тела задачи: Redis SET NX с TTL чуть больше максимального времени выполнения (или redlock при строгих требованиях). Первый взявший лок выполняет, остальные пропускают запуск молча; по завершении лок снимается. Это дешевле, чем выделенный процесс-планировщик, когда шедулер уже живёт в каждом воркере.

FAQ

Что делать, если задача должна идти строго один раз, а процессов несколько? Распределённый лок вокруг тела задачи: Redis SET NX с TTL чуть больше максимального времени выполнения. Первый взявший лок выполняет, остальные пропускают запуск молча; по завершении лок снимается. Это дешевле, чем выделенный процесс-планировщик, когда шедулер уже живёт в каждом воркере.