Background tasks в FastAPI: когда хватает встроенного, а когда брать Celery
В FastAPI есть встроенный BackgroundTasks. Простой, удобный, всегда под рукой. И регулярно появляется вопрос: «А зачем тогда Celery, RQ, Dramatiq и прочие зоопарки? Можно ведь и так». Можно. До определённого момента.
Я расскажу, где встроенные background tasks справляются отлично, а где они начинают подводить, и как понять, что пора брать настоящий очередной воркер.
Что такое BackgroundTasks в FastAPI
Это маленький класс, который позволяет добавить функцию для выполнения после возврата ответа клиенту. Выглядит так:
from fastapi import BackgroundTasks, FastAPI
app = FastAPI()
def write_log(message: str):
with open("log.txt", "a") as f:
f.write(message + "\n")
@app.post("/items/")
async def create_item(item_id: int, background_tasks: BackgroundTasks):
background_tasks.add_task(write_log, f"item {item_id} created")
return {"ok": True}
Клиент получает 200 моментально, а запись в лог идёт после. Магии нет — это просто Starlette-овский механизм, который выполняет колбэки в том же процессе, где обработался запрос.
И вот это «в том же процессе» — главная вещь, которую надо понять про встроенные таски.
Где BackgroundTasks хороши
Встроенные таски отлично закрывают сценарии, где у тебя есть короткая операция, которую не нужно дожидаться, и потеря которой не критична:
- Логи и метрики, которые можно догнать асинхронно. Не критично, если один лог потеряется при рестарте пода.
- Быстрые уведомления: написать в чат, послать webhook, дёрнуть аналитику.
- Инвалидация кэша после записи. Не упало — хорошо, упало — следующий запрос обновит.
- Отправка одного письма после регистрации. Если упадёт — пользователь нажмёт «отправить ещё раз».
Работает быстро, не требует инфраструктуры, не плодит зависимости. Когда задача занимает миллисекунды и потеря приемлема — это идеальный инструмент.
Где BackgroundTasks начинают подводить
Проблем несколько, и все они связаны с тем, что таска живёт в процессе обработчика запроса.
Проблема 1: рестарт убивает таски. Перезапуск пода во время выполнения таски — и она просто исчезает. Никаких retry, никакой персистентности. В одной команде у нас на этом сгорело несколько часов: уведомления о платежах терялись каждый раз при деплое.
Проблема 2: тяжёлые таски блокируют воркер. Uvicorn держит ограниченное число воркеров (обычно по числу CPU). Если ты накидал в BackgroundTasks что-то на 30 секунд, этот воркер занят 30 секунд. На пиковой нагрузке это превращается в очередь, и часть запросов просто не получает свободного воркера.
Проблема 3: нет видимости. Сколько задач сейчас в работе? Сколько упало? Сколько висит? В BackgroundTasks ответ один — никто не знает. Логи внутри таски — это всё, что у тебя есть.
Проблема 4: нет ретраев и приоритетов. Если внешний сервис временно недоступен, ты можешь добавить retry внутрь таски руками. Но сделать это надёжно (с экспоненциальной задержкой, мёртвой очередью, лимитом попыток) — это уже Celery, только написанная плохо.
Проблема 5: одна задача — один pod. BackgroundTasks выполняется на том же поде, который принял запрос. Если этот pod сильно загружен — таска ждёт. Распределить нагрузку по флоту воркеров нельзя.
Когда переходить на Celery (или его аналог)
Простой эвристический тест. Поднимаешь руку, если ответ «да»:
- Задача выполняется дольше 5 секунд.
- Потеря задачи — это деньги, поломанный UX или жалоба пользователя.
- Задачи разной важности и есть смысл в приоритетах.
- Хочется ретраев с задержкой.
- Нужно расписание (cron-подобные задачи).
- Нужно видеть очередь, метрики, упавшие таски.
- Задачи могут идти параллельно с большой нагрузкой, и хочется отдельный пул воркеров.
Если хоть одна рука — это кандидат на полноценный воркер. Чаще всего это Celery, иногда Dramatiq (попроще), иногда RQ (на Redis, минимум зависимостей), иногда arq (асинхронный, специально под FastAPI).
Что взять, если Celery
Celery — старый, проверенный, с кучей готового. Минусы: тяжёлый, конфигурируется небыстро, его сериализация по умолчанию — pickle, что не очень.
Базовая интеграция с FastAPI:
# tasks.py
from celery import Celery
celery_app = Celery(
"myapp",
broker="redis://redis:6379/0",
backend="redis://redis:6379/1",
)
@celery_app.task(bind=True, max_retries=3)
def send_notification(self, user_id: int, message: str):
try:
notify(user_id, message)
except TransientError as exc:
raise self.retry(exc=exc, countdown=2 ** self.request.retries)
# main.py
from fastapi import FastAPI
from .tasks import send_notification
app = FastAPI()
@app.post("/notify")
async def notify_user(user_id: int, message: str):
send_notification.delay(user_id, message)
return {"queued": True}
Запускаешь воркер отдельно: celery -A tasks worker -l info. У него свой пул, своя загрузка, своё мониторинг.
На больших проектах добавляешь flower для визуализации, celery beat для расписания, отдельные очереди для разных типов задач. Получается полноценная подсистема со всеми плюсами и минусами зрелого инструмента.
Когда взять arq
Если стек уже на async и Celery кажется монстром, посмотри на arq. Это лёгкий очередной воркер на Redis, написанный одним из контрибьюторов FastAPI. Преимущества: чистый async, минимум магии, хорошо ложится на привычную архитектуру.
from arq.connections import RedisSettings
async def send_email(ctx, user_id: int, subject: str):
# обычная async-функция
...
class WorkerSettings:
functions = [send_email]
redis_settings = RedisSettings(host="redis")
Запуск: arq worker.WorkerSettings. Из эндпоинта:
await app.state.arq_pool.enqueue_job("send_email", user_id, subject)
У arq меньше фич, чем у Celery, но и меньше веса. Для команды на 5–7 человек, у которой пара десятков типов фоновых задач, его хватает за глаза.
Гибридный подход
Никто не запрещает совмещать. У нас на одном из сервисов так и сделано:
BackgroundTasks— для совсем коротких побочных эффектов: метрики, логи, мелкие webhook.- arq — для всего, что важно сохранить и где нужны ретраи: уведомления, отчёты, синхронизации.
- Celery beat — для расписания (ежедневные дайджесты, ночные перерасчёты).
Ничего страшного в смешении нет, главное — у каждой задачи понятный дом. Не надо «ну на это ещё сделаем фоном через BackgroundTasks, авось не упадёт».
Что в итоге
BackgroundTasks — отличный инструмент для своего класса задач. Не выкидывай Celery в первый день, но и не лезь в Celery ради «отправить лог в файл». Главный критерий — что будет, если эта задача не выполнится. Если ничего страшного — встроенные таски справятся. Если страшное — бери очередной воркер с персистентностью и ретраями.
И помни про процесс: BackgroundTasks гоняются на том же поде, что и обработчики API. На пиковой нагрузке это самый недооцененный источник деградации latency. На графиках видно: как только начинает расти время фоновых тасок — следом подскакивает p95 на API. Если видишь такое — пора выносить.