lenec ru

← все посты

Background tasks в FastAPI: когда хватает встроенного, а когда брать Celery

11K

В 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. Если видишь такое — пора выносить.

Комментарии 1

  • Игорь Лебедев

    Из практики: BackgroundTasks хорошо работает, пока процесс живой и не получает SIGTERM. У нас при rolling update терялись отправки писем — задача висела на in-memory очереди и уезжала вместе с подом. После того, как 0.5% писем перестали доходить, перешли на arq + Redis. Brрker лучше Celery когда задачи короткие и нужен async-стек целиком.

Войдите, чтобы оставить комментарий.