Гайды

Холодный старт приложения: как убрать первые секунды ожидания

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

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

Разделить их можно за минуту, и с этого стоит начинать всегда.

#Что понадобится

  • Возможность запускать приложение с замером времени по этапам.
  • Инструмент для повторяющихся запросов (curl в цикле подойдёт).
  • Известная величина: сколько именно длится «долго». Без числа разговор бессмысленный.
  • Работающая выкладка: перезапускать приложение вам придётся много раз, и если каждый перезапуск — это отдельное приключение, начните с маршрута о том, как поднять свой ИИ-сервис.

#Шаг 1. Разделить два случая

# три запроса подряд после перезапуска
for i in 1 2 3; do
  curl -s -o /dev/null -w '%{time_total}\n' http://127.0.0.1:8000/timeline
done

Читаем результат:

Что видно Диагноз Куда идти
4.2 / 0.3 / 0.3 холодный старт шаги 2–4
4.2 / 4.1 / 4.0 медленный запрос это другая задача
4.2 / 0.3 / 3.9 что-то истекает шаг 5

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

#Шаг 2. Разложить старт на этапы и померить каждый

Общая цифра «стартует 14 секунд» бесполезна. Нужна разбивка.

import time
t0 = time.perf_counter()

import app.web.main as m
t_import = time.perf_counter()

application = m.create_app()
t_build = time.perf_counter()

print(f"импорт      {t_import - t0:.2f}s")
print(f"сборка app  {t_build - t_import:.2f}s")

Типовое распределение и что за ним стоит:

Этап Что обычно занимает
Импорт модулей тяжёлые библиотеки на верхнем уровне
Сборка приложения регистрация маршрутов, разбор шаблонов
Схема базы проверка журнала миграций
Первое соединение с базой прагмы, открытие файла
Первый запрос компиляция шаблона, прогрев кэшей
Первый вызов модели загрузка весов — самое дорогое

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

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

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

#Шаг 3. Убрать работу со старта

Три приёма по убыванию отдачи.

Отложенный импорт. Тяжёлое — внутрь функции, где оно нужно.

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

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

#Шаг 4. Прогреть после старта, а не до

Прогрев — это ровно та работа, которую вы убрали со старта, выполненная после того, как приложение начало отвечать.

@asynccontextmanager
async def lifespan(app):
    async def _warm() -> None:
        await asyncio.sleep(1)          # дать серверу начать принимать запросы
        async with get_connection() as conn:
            await conn.execute("SELECT 1")
        templates.get_template("timeline.html")

    task = asyncio.create_task(_warm())  # НЕ ждём его здесь
    yield
    task.cancel()

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

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

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

#Шаг 5. Разобраться, что истекает

Если медленным оказывается не первый запрос, а каждый десятый, ищите то, что протухает.

  • Кэш с коротким временем жизни. Проверьте, не пересчитывается ли на каждом промахе что-то дорогое.
  • Соединения, закрывающиеся по простою. Первое обращение после паузы платит за установку заново.
  • Модель, выгруженная из памяти. Локальные среды выполнения выгружают веса после простоя; интервал обычно настраивается.
  • Кэш файловой системы. Файл, который перестал быть горячим, читается с диска.

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

Правило, выведенное оттуда же: в шаблонах не должно быть обращений к базе. Ни одного. Всё нужное для отрисовки готовится до неё.

#Проверка результата

# 1. время старта до готовности принимать запросы
time ( .venv/bin/python -c "import app.web.main as m; m.create_app()" )

# 2. время до первого успешного ответа после перезапуска
systemctl restart persona
until curl -sf -o /dev/null http://127.0.0.1:8000/healthz; do :; done
# засеките, сколько шёл цикл

# 3. первый запрос против последующих
for i in 1 2 3 4 5; do
  curl -s -o /dev/null -w '%{time_total}\n' http://127.0.0.1:8000/timeline
done

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

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

#Что может пойти не так

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

Ускорили старт, замедлили работу. Слишком агрессивная ленивая инициализация. Первый пользователь после каждого выпуска платит за всех.

Первый запрос быстрый, страница медленная. Тогда дело не в сервере: посмотрите, что происходит в браузере. Генерация стилей в браузере, некэшируемая статика, отсутствие версионирования адресов — типовые виновники; схема — в статье про PWA и кэш сервис-воркера.

Медленно стало после роста объёма данных. Это не холодный старт, а другая задача: запрос, который перестал попадать в индекс. Где у базы реальные пределы и что мерить — в статье про SQLite и FTS5 на миллионах строк.

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

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

#Сколько секунд старта считать нормой?

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

#Помогает ли увеличить число рабочих процессов?

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

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

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

#Как отличить медленный старт от медленной сети?

Замерить с самой машины по локальному адресу и снаружи. Совпало — дело в приложении. Расходится — дело в сети или в прокси; там свои настройки, которые могут добавлять секунды.

#Нужно ли оптимизировать старт, если сервис работает месяцами?

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

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

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

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