Технологии

Архитектура веб-приложения на FastAPI: что масштабируется а что нет

Разбор болезней разросшегося приложения на примере проекта с более чем тысячей зарегистрированных маршрутов: фоновые задачи внутри веб-процесса, затенение маршрутов порядком регистрации и обработчик, в котором лежит вся логика.

FastAPI не мешает писать плохо. Это его сильная сторона на первой неделе и главная проблема на второй год: приложение растёт линейно, а некоторые издержки — нет.

Ниже — три конкретные болезни, каждая с наблюдаемым симптомом и с числом из реального проекта. На момент написания в Persona зарегистрировано больше тысячи маршрутов, разложенных по нескольким сотням модулей. Эти числа приводятся не как достижение, а как контекст: всё описанное ниже проявляется именно на таком масштабе, а начинается гораздо раньше.

#Болезнь первая: фоновые задачи внутри веб-процесса

Самое естественное решение на старте — запустить фоновую работу прямо в приложении: подписаться на событие запуска и завести цикл. Никакого брокера, никакого второго процесса, всё в одном месте.

Так и было. Около сорока фоновых воркеров жили внутри веб-процесса: захват, распознавание текста, эмбеддинги, ретеншн, планировщики. Каждый обновлял свою отметку живости раз в несколько секунд.

Симптом, с которым это пришло: «сайт подвисает». Не падает, не отдаёт ошибки — именно подвисает, эпизодически. Процессор при этом не загружен.

Настоящая причина оказалась не в веб-части. Сорок воркеров создавали постоянный поток записи в базу, а один медленный вызов языковой модели удерживал транзакцию записи всё время своего ожидания. SQLite допускает одного писателя, остальные вставали в очередь — а без настроенного времени ожидания вообще получали отказ немедленно. Разбор механики — в статье про WAL и конкурентный доступ в SQLite.

Лечилось это тремя вещами, и только одна из них была про базу:

  1. время ожидания на соединениях, чтобы писатели вставали в очередь, а не умирали;
  2. явное начало транзакции записи, чтобы не упираться в повышение блокировки посередине;
  3. правило: никаких сетевых и модельных вызовов внутри транзакции. Тяжёлое считается до или после.

Третий пункт — архитектурный, и он важнее первых двух.

#Признаки, что фоновые задачи пора выносить из веб-процесса

  • Время ответа веб-части зависит от того, что сейчас делает фон.
  • Перезапуск ради выкладки останавливает обработку данных, а не только приём запросов.
  • Фоновая задача упала — вы узнали об этом по косвенным признакам через неделю.
  • Понадобилось «выключить фон, оставив веб» — и для этого нет переключателя.
  • Потребление памяти процесса растёт из-за задачи, которая к запросам отношения не имеет.
  • Одна тяжёлая задача блокирует остальные, потому что все они в одном цикле событий.

Промежуточное решение, которое дёшево и работает: режим, в котором фон полностью выключен. У нас это одна переменная окружения, и именно она делает возможным контейнерный режим — в контейнере захватывать нечего, воркерам там делать нечего, и они не запускаются. Про этот режим — в гайде про запуск Persona в контейнере.

Полное решение — отдельный процесс с собственным управлением жизненным циклом; на Linux это отдельный unit, разобранный в гайде про автозапуск через systemd.

#Болезнь вторая: затенение маршрутов порядком регистрации

Эта ошибка не даёт ошибки. Она даёт 404 или чужой ответ на маршруте, который в коде есть, написан правильно и покрыт тестом — если тест дёргает функцию напрямую, а не приложение целиком.

Механика: маршруты проверяются в порядке регистрации, первый совпавший выигрывает.

@router.get("/notes/{note_id}")     # зарегистрирован первым
async def get_note(note_id: str): ...

@router.get("/notes/new")           # недостижим: "new" совпал с {note_id}
async def new_note(): ...

Второй обработчик не будет вызван никогда. Запрос /notes/new попадёт в первый с note_id="new", и дальше поведение зависит от того, насколько аккуратно первый обрабатывает мусор на входе: в лучшем случае 404, в худшем — ошибка сервера или чужие данные.

На нескольких сотнях модулей маршрутов это перестаёт быть теоретической проблемой. У нас так оказались недостижимы три маршрута, и обнаружились они не тестами, а перебором страниц вручную.

Что с этим делать:

  • Статические сегменты регистрировать раньше параметризованных. Всегда, без исключений.
  • Ограничивать параметры путей — типом или явным шаблоном. Параметр, объявленный как целое число, не перехватит /notes/new, и проблема исчезает сама.
  • Тестировать через приложение целиком, а не вызовом функции обработчика. Тест, который зовёт функцию, проверяет функцию, а не маршрутизацию.
  • Держать проверку, которая перебирает зарегистрированные маршруты и падает на дубликатах и подозрительных перекрытиях.

Последний пункт стоит развернуть, потому что он относится к обеим болезням.

#Проверка архитектуры автоматическим тестом

Приём, который окупился больше всего: тест, который следит за формой приложения, а не за поведением конкретной ручки.

Два числа, зафиксированных в таком тесте у нас:

  • бюджет зарегистрированных маршрутов. Число известно и записано; превышение валит сборку. Это не борьба с ростом — бюджет поднимается осознанно, вместе с комментарием, какая работа его подняла. Смысл в том, что маршрут не появляется случайно;
  • бюджет времени холодного импорта. Сборка приложения отдельным процессом должна укладываться в известный потолок. Импорт, случайно потянувший тяжёлую библиотеку, ловится сразу, а не через полгода на жалобе «долго стартует». Что вообще составляет время старта и как его мерить по этапам — в гайде про холодный старт приложения.

Оба теста дешёвые и оба ловят целый класс ошибок, который иначе обнаруживается пользователями.

#Болезнь третья: обработчик, в котором лежит всё

Классика: функция маршрута на несколько сотен строк, где вперемешку разбор запроса, обращения к базе, вызов модели, форматирование ответа и запись в лог.

Симптомы у неё узнаваемые: логику нельзя вызвать из фоновой задачи, не подделав объект запроса; её нельзя протестировать без поднятия приложения; одинаковая проверка прав скопирована в пяти местах и в двух из них отличается.

Разделение слоёв, к которому приходят все и по-разному называют:

маршрут (routes/)      разбор запроса, коды ответов, шаблон.
                       НИЧЕГО про предметную область.
        ↓
служба (services/)     вся логика. Не знает про HTTP.
                       Вызывается и из маршрута, и из фоновой задачи, и из CLI.
        ↓
хранилище (storage/)   запросы к базе, транзакции.
                       Не знает ни про HTTP, ни про предметную область.

Полезное правило, по которому проверяется соблюдение: в модуле службы не должно быть импортов из веб-фреймворка. Если появился — логика утекла не туда. Это проверяется тем же архитектурным тестом, что и бюджеты выше.

Второе правило, более прикладное: если логику нельзя вызвать из командной строки, она в неправильном месте. Возможность выполнить ту же операцию скриптом бесценна при разборе инцидентов — а для разбора инцидентов нужны ещё и логи, из которых видно, что происходило; минимальный набор полей — в статье про структурированные логи.

#Что масштабируется, а что нет

Растёт Как ведёт себя
Число маршрутов нормально, если по модулям и с проверкой на дубликаты
Число модулей нормально; больно только при циклических импортах
Число фоновых задач плохо внутри веб-процесса
Размер одного обработчика плохо всегда
Число зависимостей плохо для времени старта
Число шаблонов нормально; узкое место — не они

Строка про зависимости — та, что удивляет. Время холодного старта складывается не из вашего кода, а из импортов: одна тяжёлая библиотека, подтянутая на верхнем уровне модуля, добавляет к старту секунды. Лечится отложенным импортом внутри функции — приём, который выглядит неаккуратно и почти всегда правильный.

#Где интуиция подводит

«Асинхронность делает всё быстрее». Она делает быстрее ожидание ввода-вывода. Синхронный вызов, случайно оказавшийся в асинхронном обработчике, блокирует весь цикл событий, то есть все запросы разом. Симптом — «иногда всё встаёт на секунду».

«Много маршрутов — плохо». Само по себе нет. Плохо — маршруты, о существовании которых никто не знает, и маршруты, недостижимые из-за порядка регистрации.

«Сначала разделим на микросервисы». Для личного сервиса это обмен одной проблемы на пять новых: сеть между частями, согласованность, развёртывание, отладка, мониторинг. Разделение фоновых задач и веб-части даёт девяносто процентов пользы за пять процентов сложности.

«Тесты покрывают, значит маршрут работает». Только если тест ходит через приложение. Тест, зовущий функцию обработчика напрямую, о маршрутизации не знает ничего.

#Частые вопросы

#Когда пора выносить фоновые задачи в отдельный процесс?

По первому из признаков в списке выше, а не по числу задач. Самый ранний сигнал — время ответа веб-части начинает зависеть от того, что делает фон. Второй — вам понадобилось выключить фон, не выключая веб.

#Как организовать несколько сотен модулей маршрутов?

По предметным областям, не по типам. Каждый модуль объявляет свой набор маршрутов, регистрация идёт в одном месте, а порядок регистрации фиксируется тестом. Плоская структура из двухсот файлов работает лучше, чем глубокая иерархия — искать проще.

#Нужен ли брокер очередей?

Для личного сервиса обычно нет. Отдельный процесс с циклом и записью состояния в ту же базу решает задачу и не добавляет ещё одного участника, который может лежать. Брокер оправдан, когда задачи должны переживать перезапуск и распределяться между машинами.

#Как понять, что тормозит — приложение или база?

Замерить время ожидания блокировки записи отдельно от времени выполнения запроса. Если ожидание существенно, вы упёрлись в единственного писателя, и оптимизация запросов ничего не даст. Пока этих двух чисел нет, любой разговор о производительности — гадание.

#Стоит ли отказываться от серверного рендеринга ради отдельного фронтенда?

Не обязательно. Отказ от сборки фронтенда — сознательный размен со своей границей применимости; аргументы обеих сторон разобраны в статье про интерфейс без сборки на HTMX и Alpine.

#С чего начать наводить порядок в уже разросшемся приложении?

С двух дешёвых тестов: бюджет маршрутов и бюджет времени холодного импорта. Они не требуют рефакторинга, ставятся за час и сразу перестают пускать новые проблемы того же класса. Всё остальное разбирается постепенно — и вместе с эксплуатацией, о которой в статье о том, как поднять свой ИИ-сервис.

Хотите ИИ, который помнит вас?

Persona держит контекст между чатами и работает на вашем железе — если развернуть её у себя и подключить локальную модель.

Создать аккаунт