vegman.dev
Все статьи

Демо работает, прод ломается: три поломки, которых не видно на показе

2026-01-29кейс

Демо длится пятнадцать минут. Вы задаете боту три вопроса - он отвечает за секунду. Показываете рассылку отчетов - она уходит вовремя. Все довольны, работу принимают. А через неделю эксплуатации прилетает сообщение: «бот молчит». Не «упал с ошибкой» и не «врет», а именно молчит.

Я собирала Telegram-бота для отельной автоматизации: он отвечает сотрудникам по внутренней базе знаний и рассылает отчеты по расписанию. На демо он работал. В проде сломался тремя разными способами - и ни один из них на демо не воспроизводится в принципе. Не потому, что я плохо показывала, а потому что демо устроено так, что эти поломки внутри него почти невозможны.

Дальше - три класса поломок, механика каждой и то, что стоило написать в коде заранее.

Демо - это лучший случай, прод - это все остальные

У демо обычно совпадают три условия:

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

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

Поэтому демо проверяет ровно один сценарий - happy path. А ломается система на всех остальных.

flowchart TB
  U["Запрос пользователя"] --> C["Вызов модели"]
  C --> P{"Провайдер отвечает?"}
  P -->|"да"| OK["Ответ пользователю"]
  P -->|"ошибка"| E["Ошибка видна,<br/>обработана"]
  P -->|"молчит"| W1["Без таймаута:<br/>ожидание не кончается,<br/>бот не отвечает никому"]
  P -->|"молчит"| W2["С таймаутом 60 с:<br/>сдаемся сами,<br/>отвечаем пользователю"]
Молчание провайдера - не ошибка: без таймаута ожидание не кончается никогда

Поломка №1: вызов без таймаута висит вечно

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

Именно так у меня и было: запрос к языковой модели уходил без таймаута. На демо это невидимо - ответ приходит за секунды. В проде провайдер иногда не отвечал вовсе, и запрос висел бесконечно.

Деталь, которую новички обычно пропускают: плохо не то, что пользователь не получил ответ. Плохо то, что никто ничего не узнал. Нет ошибки - значит, нет записи в логе, нет сообщения «попробуйте еще раз», нет повторной попытки. Задача просто стоит и держит ресурсы процесса. Снаружи это выглядит как «бот молчит», и по такому описанию причину не выковыряешь.

Лечение простое: явный таймаут на каждый сетевой вызов. У нас - 60 секунд. Число не магическое: это примерно столько, сколько человек готов ждать ответа в чате (у вас может быть другое). Важно, что оно вообще есть: тогда «висит» превращается в «упало с понятной ошибкой», а с ошибкой уже можно работать - логировать, показывать пользователю, ретраить.

Тот же дефект - в скрипте выкладки

Забавно (и неприятно), но он всплыл там, где его не ждешь. Скрипт деплоя после старта сервиса опрашивал его: «ты поднялся?» - и ждал ответа без ограничения. Если сервис не отвечал, висел сам скрипт: выкладка не заканчивалась ни успехом, ни провалом. Добавили лимит: 5 секунд на попытку.

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

Поломка №2: тяжелый прогрев блокирует запуск

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

Ошибка была не в самом прогреве, а в том, где он стоял. Он выполнялся до запуска планировщика (у нас это рассылка отчетов) и до начала обработки сообщений. То есть пока прогрев не закончился, бот не был живым ни в каком смысле: ни ответы, ни расписание.

Пока все хорошо, разницы не видно: прогрев занял полминуты - никто не заметил. Но если прогрев подвисал (а он зависит от диска, памяти и внешних сервисов), бот не отвечал вообще и расписание не работало. Одна медленная операция на старте выключала обе функции сразу.

Лечение: прогрев вынесли в фоновую задачу после старта. Бот принимает сообщения сразу, планировщик стартует сразу, тяжелая часть догружается параллельно.

Да, это добавляет вопрос, на который придется ответить осознанно: что делать с запросом, который пришел до того, как прогрев закончился. Но это честный вопрос с понятным ответом - в отличие от «сервис не запустился, причина неизвестна».

flowchart TB
  subgraph WAS["Прогрев до старта"]
    W1["Старт"] --> W2["Прогрев: модель и база"] --> W3["Завис - расписание<br/>не работает,<br/>бот не отвечает"]
  end
  subgraph NOW["Прогрев в фоне"]
    N1["Старт"] --> N2["Расписание и прием<br/>сообщений"]
    N1 --> N3["Прогрев параллельно"]
    N3 --> N4["Вопрос: что делать<br/>с ранним запросом - <br/>честный и с ответом"]
  end
  WAS ==> NOW
Тот же прогрев, переставленный в фон: сервис принимает запросы сразу

Поломка №3: health check, который сам все роняет

Проверка здоровья - это простой запрос, на который сервис отвечает «я жив». Его регулярно дергает внешняя система (мониторинг, оркестратор, systemd - неважно), и по ответу решает, считать ли сервис работающим.

Мой health check запрашивал статистику базы знаний. Логика казалась здравой: проверить не «процесс существует», а «данные на месте, я действительно готов работать».

Проблема в том, что health check должен отвечать за миллисекунды и не зависеть ни от чего тяжелого. Как только база начинала подвисать, вместе с ней подвисала и проверка. И тут связь становится опасной: инструмент, который должен сообщать о состоянии системы, сам оказывается частью системы и разделяет ее проблемы. Внешняя система видит молчание и делает выводы - а выводы у нее обычно радикальные.

Лечение: проверку свели к дешевому вопросу «файл базы на месте». Никаких обращений внутрь базы, никакой статистики.

Разделение, которое стоит запомнить: health check отвечает на вопрос «меня можно спрашивать?», а не «все ли хорошо с данными». Второй вопрос тоже важен, но это работа мониторинга и метрик, а не ручки, от которой зависит жизнь сервиса.

Применить у себя

Что проверить у себя (без ожидания реальных пользователей)

Это можно сделать за один вечер.

  • Выпишите все места, где вы ходите наружу: к языковой модели, к базе, к чужому API, к своему же сервису из деплой-скрипта.
  • Для каждого места проверьте, задан ли таймаут явно. Не «по умолчанию наверняка есть» - откройте документацию и убедитесь. Отсутствие лимита по умолчанию встречается чаще, чем кажется.
  • Посмотрите, что происходит между стартом процесса и первым принятым запросом. Все на этом отрезке - потенциальная причина того, что сервис не поднимется целиком.
  • Откройте health check и посмотрите, к чему он обращается. Если он трогает базу, модель или сеть больше, чем на «файл существует», - упростите.
  • Проверьте деплой-скрипты тем же взглядом. Они тоже делают сетевые вызовы. И тоже умеют висеть.

Способ проверить все разом - подменить в тестовой среде адрес внешнего сервиса на такой, который принимает соединение и не отвечает. Не на несуществующий: тот честно вернет ошибку сразу, а вам нужно именно молчание. Если система при этом продолжает принимать сообщения, а зависший запрос через заданное время падает с понятной ошибкой в логе - вы все сделали правильно.

Как понять, что получилось

Критерий один (и он без цифр): любая внешняя зависимость может замолчать - и это не должно превращать ваш сервис в молчащий черный ящик.

Его можно разложить на три проверяемых пункта:

  • ни один запрос не ждет дольше заданного времени (у каждого сетевого вызова есть число секунд, которое вы можете назвать вслух)
  • сервис принимает запросы сразу после старта, даже если «тяжелая часть» еще не готова
  • health check отвечает одинаково быстро и когда все хорошо, и когда база тормозит.

Принцип, к которому я пришла после этих трех поломок, помещается в одну строку:

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

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