Периодические задачи в Django: разбор на живом проекте
Celery beat молча дублировал списание остатка пакета при двух воркерах, а cron на сервере переживал деплой, но не переживал масштабирование. Разбираем, как устроен планировщик на практике и где он ломается.

Раз в сутки биллинг списывал абонентскую плату. Схема была простая: celery beat раз в 24 часа кладёт задачу в очередь, воркер её берёт и обходит активные подписки. Всё работало полгода, пока инфраструктуру не масштабировали до двух подов с beat внутри каждого. В одно утро часть пользователей увидела двойное списание. Планировщик сработал дважды, потому что копий планировщика стало две. Разбираем на этом и соседних случаях, как периодические задачи в Django (5.x) ведут себя в реальной эксплуатации и где именно рвётся.
Речь только про запуск по расписанию: сам код задачи, идемпотентность и мониторинг очереди — смежные темы, здесь не разворачиваем. Фокус — кто и как решает, что задачу пора запустить.
Четыре способа запускать код по расписанию
В Django нет встроенного планировщика. Есть management-команды, которые кто-то должен вызвать снаружи или изнутри. Отсюда четыре рабочих подхода, у каждого своя цена.
1. Системный cron + management-команда
Пишете команду manage.py, в crontab сервера добавляете строку. Django инициализируется на каждый запуск с нуля.
# myapp/management/commands/charge_daily.py
from django.core.management.base import BaseCommand
from billing.services import charge_active_subscriptions
class Command(BaseCommand):
help = "Списывает абонплату по активным подпискам"
def handle(self, *args, **options):
# count — сколько списаний прошло, для лога
count = charge_active_subscriptions()
self.stdout.write(f"Charged: {count}")
# crontab на сервере, каждый день в 03:00
0 3 * * * /srv/app/venv/bin/python /srv/app/manage.py charge_daily >> /var/log/charge.log 2>&1
Плюс: планировщик один на машину, дублей нет, пока сервер один. Минус: расписание живёт вне репозитория и вне деплоя. Перенесли контейнер — забыли crontab. В Kubernetes системного cron нет вовсе.
2. Celery beat
Отдельный процесс celery beat по расписанию кладёт задачи в брокер (Redis, RabbitMQ), воркеры их разбирают. Расписание описывается в настройках или через django-celery-beat в базе.
# settings.py, celery 5.x
from celery.schedules import crontab
CELERY_BEAT_SCHEDULE = {
"charge-daily": {
"task": "billing.tasks.charge_active_subscriptions",
"schedule": crontab(hour=3, minute=0),
},
}
Плюс: расписание в коде, ретраи, распределённые воркеры. Главный минус и причина инцидента из лида: процесс beat должен быть строго один на весь кластер. Запустили два — получили двойные задачи. Beat не координируется сам между репликами.
3. Внешний планировщик оркестратора (Kubernetes CronJob)
Расписание описывается в манифесте, оркестратор по нему поднимает под, который выполняет management-команду и умирает. Свежая инициализация Django на каждый запуск.
# k8s CronJob, schedule в синтаксисе cron
spec:
schedule: "0 3 * * *"
concurrencyPolicy: Forbid # не запускать, пока крутится предыдущий
jobTemplate:
spec:
template:
spec:
containers:
- name: charge
image: myapp:latest
command: ["python", "manage.py", "charge_daily"]
restartPolicy: Never
Плюс: расписание версионируется, concurrencyPolicy: Forbid гарантирует отсутствие параллельных запусков, ресурсы под задачу выделяются на время выполнения. Минус: нужен оркестратор и настроенный CI, для одиночного VPS это оверинжиниринг.
4. APScheduler внутри процесса приложения
Планировщик живёт в памяти того же процесса, что и приложение. Подходит для одиночного long-running процесса, но так же, как beat, множится вместе с репликами. В типичной вебовой связке (несколько gunicorn-воркеров) — источник тех же дублей, только незаметнее. Для распределённого прода не рекомендую и подробно не разбираю.
Сравнение по тому, что реально болит
| Критерий | Системный cron | Celery beat | K8s CronJob |
|---|---|---|---|
| Расписание в репозитории | Нет | Да | Да |
| Защита от дублей из коробки | Один на сервер | Нет, следить самому | concurrencyPolicy |
| Ретраи задачи | Нет | Да | backoffLimit |
| Нужна доп. инфраструктура | Нет | Брокер + процесс beat | Оркестратор |
| Стоимость простоя памяти | Ноль между запусками | Воркеры и beat висят всегда | Ноль между запусками |
Где именно порвалось у нас
Инцидент был не в коде задачи, а в её идемпотентности при повторном запуске. Beat задублировался — и это вскрыло, что charge_active_subscriptions не защищена от повторного вызова за те же сутки. Правильная реакция — не только оставить один beat, но и сделать задачу безопасной к повтору.
Планировщик рано или поздно запустит задачу дважды: реплика, ретрай после таймаута, ручной перезапуск после сбоя. Если двойной запуск ломает данные — проблема в задаче, а не в планировщике.
Защита сводится к тому, чтобы факт списания за конкретный день фиксировался в базе с уникальным ограничением, а сама вставка была первой операцией.
# billing/models.py
from django.db import models
class DailyCharge(models.Model):
subscription = models.ForeignKey("Subscription", on_delete=models.CASCADE)
charge_date = models.DateField()
class Meta:
# повторное списание за тот же день просто не создастся
constraints = [
models.UniqueConstraint(
fields=["subscription", "charge_date"],
name="uniq_charge_per_day",
)
]
# billing/services.py
from django.db import IntegrityError, transaction
from datetime import date
def charge_one(subscription):
try:
with transaction.atomic():
# если запись за сегодня уже есть — вылетит IntegrityError
DailyCharge.objects.create(
subscription=subscription, charge_date=date.today()
)
subscription.balance -= subscription.price
subscription.save(update_fields=["balance"])
return True
except IntegrityError:
# уже списано сегодня, повтор пропускаем молча
return False
Теперь два beat, ретрай или ручной запуск дадут максимум одно списание в сутки. Планировщик стал источником срабатываний, а не источником данных.
Когда что выбирать
- Один сервер, две-три задачи, нет очередей — системный cron с management-командой. Не тащите Celery ради одной ночной выгрузки.
- Уже есть Celery и брокер под фоновые задачи — beat, но с жёстким правилом: ровно один процесс beat, вынесенный из реплик приложения в отдельный деплой с одной репликой.
- Kubernetes — CronJob с
concurrencyPolicy: Forbid. Расписание в манифесте, никакого beat для редких задач. - APScheduler в процессе — только для одиночного демона без реплик и без веб-воркеров. В вебе не используйте.
Частые ошибки, которые видно в проде
- Beat запущен внутри тех же подов, что и приложение. При масштабировании планировщиков становится столько же, сколько реплик. Выносите beat в отдельный деплой.
- Задача ходит в базу, но не идемпотентна. Один сбой брокера с ретраем — и дубли. Уникальное ограничение дешевле разбирательства с клиентами.
- Крон в UTC, а бизнес-логика в местном времени. Проверьте
TIME_ZONEв settings и часовой пояс сервера. "03:00" в двух местах может оказаться разным временем. - Долгая задача перекрывает следующий запуск. Без
concurrencyPolicy: Forbidили блокировки два экземпляра одной задачи топчут друг друга.
Что настроить дальше
После выбора планировщика повесьте мониторинг на факт выполнения, а не только на сам запуск: задача может стартовать и молча упасть. Смотрите две вещи в проде — время последнего успешного завершения каждой периодической задачи и разницу между запланированным и фактическим временем старта. Растущее отставание beat или очередь непринятых CronJob — первый сигнал, что планировщик перестаёт справляться. Идемпотентность и мониторинг очереди Celery — темы для отдельного разбора.
Задачи: математика, физика, химия: Разбор задачи по шагам с проверкой и похожей задачей
Спросить за 10 ₽Источники: Celery — Periodic Tasks (документация), Kubernetes — CronJob
Частые вопросы
У меня Celery другой версии, синтаксис crontab отличается?
Запустил два процесса beat и получил дубли — как быстро понять, что дело именно в этом?
Можно ли обойтись только cron без Celery?
Почему нельзя запускать APScheduler прямо в приложении на проде?
Задача запустилась, но не отработала до конца — как это поймать?
Как избежать двойного списания, если задача всё же запустится дважды?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.