Привет, Хабр!

В этой статье — история запуска Telegram Mini App, куда за трое суток пришло 100.000 реальных пользователей.

Покажу, как мы масштабировали Node.js приложения на многоядерных серверах, увеличивали RPS в 10 раз, боролись с N+1 проблемой в MongoDB и снижали нагрузку на CPU. А ещё расскажу как мы быстро настроили мониторинг через Grafana, подключили Cloudflare и интегрировали Sentry. Поделюсь практическими инсайтами о том, на что стоит обращать внимание в первую очередь, и как эти инструменты помогли нам оперативно находить узкие места и устранять сбои в реальном времени. Всё, о чём будет в этой статье, основано на том, что действительно сработало. Кроме того, расскажу, какие моменты мы упустили до запуска.

Это разбор с цифрами, графиками и практическими выводами. Он может сэкономить вам время, нервы и деньги, если вы готовитесь к запуску Telegram Mini App или просто работаете с Node.js-приложениями, которые могут оказаться под серьёзной нагрузкой.

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

Первая часть про подготовку к запуску доступна здесь.

Про автора

Меня зовут Женя, сейчас я работаю разработчиком в Google. В стартапах был в роли CTO более 4 лет, а всего в IT — уже больше 9 лет, преимущественно в крупных компаниях и в области веб-разработки. И мне невероятно интересно с помощью технологий и людей реализовывать классные проекты, помогать бизнесу достигать целей и делать полезные сервисы для людей. А ещё я веду свой Telegram канал про IT, продукты и технологии.

Что за приложение?

Это мини-приложение для Telegram на футбольную тематику.

С технической стороны всё устроено так: фронтенд на Next.js, бэкенд — несколько сервисов на Nest.js, база данных MongoDB, всё развёрнуто в Docker контейнерах с Docker Compose и деплоится через GitLab CI.

Пятничный релиз

Казалось, сделали всё, что нужно: прогнали нагрузочное тестирование, настроили кластеризацию, мониторинг, защитились от DDoS, настроили Rate Limit. Эта подготовка действительно была очень полезной — без неё было бы тяжело после запуска. Но, как это часто бывает, самое интересное началось уже потом.

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

Классика: экстренные фиксы, мелкие доработки, постоянные проверки. К двум ночи у нас появилось ощущение, что теперь всё работает стабильно.

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

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

Первый день в проде и первые проблемы

Утро субботы выдалось тяжелым. В 10 утра вся команда уже сидела перед мониторами, отслеживая показатели в Grafana, Sentry, Cloudflare и Google Analytics.

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

Крупная маркетинговая активность была запланирована на 16:00 — именно в это время ожидался значительный приток пользователей в приложение. А вечером, когда начнётся эль-классико, нагрузка должна была вырасти ещё сильнее.

У нас оставалось несколько часов до 16:00, и мы хотели использовать это время, чтобы выловить и исправить возможные проблемы. Важно было убедиться, что когда пользователи пойдут в приложение массово, у них не возникнет никаких сложностей.

И тут мы заметили кое-что интересное.

iOS, Safari и 401

Мы увидели большое количество ошибок 401 в Sentry. Выглядело это очень странно, как будто у некоторых пользователей на iOS в какой-то момент исчезал токен авторизации в локальном хранилище. Как следствие, они совершенно не могли пользоваться приложением.

401 ошибки на iOS
401 ошибки на iOS
График ошибок
График ошибок
График числа пользователей задетых этой проблемой
График числа пользователей задетых этой проблемой
 Разбивка по браузерам
Разбивка по браузерам

Что интересно, проблема проявлялась только у пользователей с последними версиями iOS и исключительно в Safari. Идей, почему это происходит, было немного. Мы перепроверили все участки кода, где теоретически могла происходить очистка хранилища с нашей стороны — ничего подозрительного не нашли.

Дальше начался тяжёлый дебаг прямо в проде: добавляли логи, отслеживали поведение. В итоге стало понятно, что примерно у 10% пользователей на iOS в какой-то момент просто исчезают куки и localStorage. iOS внезапно полностью очищал локальное хранилище — почему именно, осталось загадкой. Аналогичная история была и с куками.

В итоге мы решили переписать логику хранения авторизационного токена и других важных данных: теперь всё это хранится в Store приложения, без использования localStorage и cookies. Это оказалось самым надёжным вариантом на данный момент.

Так что самую массовую и неприятную проблему на старте мы закрыли за несколько часов.

Как мы отправили пользователей в ThrottlerModule

К 16:00 мы уже были на низком старте — вот-вот должна была начаться ключевая маркетинговая активность с призывом зайти в приложение. Мы ожидали резкий скачок трафика — не менее 5 тысяч пользователей за ближайшие 5–10 минут.

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

После этого произошло примерно следующее:

?
?

Через несколько секунд после публикации поста приложение уже совершенно не открывалось. Нагрузка в Grafana была в норме, упора в железо точно не было. В Cloudflare видели резкий наплыв трафика, ожидаемо.

Однако число ошибок в Sentry просто улетело в космос:

Около 3 тысяч ошибок за несколько секунд
Около 3 тысяч ошибок за несколько секунд
Выглядели они так
Выглядели они так

Мы сразу же поняли в чём дело. Маркетинговый промо-пуш мгновенно удалили, трафик начал снижаться. Мы срочно полезли в бэкенд и увидели, что у нас почти на всех эндпоинтах оказался включен ThrottlerModule от Nest.js.

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

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

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

Через несколько минут мы повторно запустили маркетинговую активность. В этот раз всё прошло как надо: приложение выдержало нагрузку, и буквально за считанные минуты пришло около 10 000 пользователей.

И всё-таки — падение

Наступал глубокий вечер, приближалось время начала матча.

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

И вот в 23:10 вышел мощный анонс — пост, запускающий основную волну. Буквально за считанные секунды в приложение попытались зайти около 15 000 человек.

Через несколько секунд после этого приложение перестало отвечать и не открывалось нескольких минут.

Не открывающееся приложение на пике активности
Не открывающееся приложение на пике активности

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

Нагрузка на фронтенд сервер в первый день с пиком в 23:10
Нагрузка на фронтенд сервер в первый день с пиком в 23:10
Нагрузка на бэкенд сервер в первый день с пиком в 23:10
Нагрузка на бэкенд сервер в первый день с пиком в 23:10

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

  1. Фронтенд работал не в режиме статической генерации (SSG). То есть при навигации каждого пользователя Next.js заново рендерил страницы на сервере, даже если они были одинаковые. Это очень неэффективно и сильно нагружало CPU.

  2. Статические ресурсы не кэшировались. Примерно половина всех запросов приходилась на статику, и Next.js каждый раз заново оптимизировал одни и те же изображения для каждого пользователя — опять же, серьёзная нагрузка на процессор. Более подробно про кэширование статики я писал выше.

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

Далее нагрузка начала спадать и уже через 2 минуты доступ к приложению у пользователей восстановился.

Второй день в проде и умирающий бэкенд

Утро второго дня началось так же, как вчерашнее - графики в Grafana, Sentry, Cloudflare. На первый взгляд всё выглядело спокойно, но что-то было не так.

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

 Сравнение потребления CPU бэкенд сервером за 2 дня, видна динамика
Сравнение потребления CPU бэкенд сервером за 2 дня, видна динамика

Тревожные симптомы

Быстро стало понятно, что что-то серьёзно не в порядке. Зависимость нагрузки от количества пользователей выглядела катастрофически:

  • 100 пользователей — 6% CPU

  • 200 пользователей — 30% CPU

  • 300 пользователей — 60% CPU

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

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

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

 Нагрузка снизилась до нуля при выключении трафика
Нагрузка снизилась до нуля при выключении трафика

Несмотря на это, мы предприняли ряд оптимизаций:

  • Заменили PM2 на масштабирование через Docker Compose

  • Перезагрузили сервер

  • Мигрировали с Express на Fastify в Nest.js

Всё это не дало никакого эффекта.

Момент осознания

Спасением стало подключение Sentry к бэкенду с настройкой профайлинга. И вот тут мы увидели картину, от которой стало не по себе: из-за роста количества данных на один API-запрос приходилось около 300 запросов в базу данных.

Классические N+1 запросы во всей красе. Можно было про это подумать заранее, можно было предусмотреть — но мы упустили этот момент.

Хорошая новость была в том, что мы точно поняли где именно проблема.

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

Виновником оказались конструкции вида:

 findOne в цикле
findOne в цикле

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

Решение оказалось простое:

 Избавились от findOne в цикле, заменив на findMany
Избавились от findOne в цикле, заменив на findMany

Пайплайн #666

В 4 утра был залит Merge Request с фиксом. Пайплайн номер 666 — мистика какая-то, но мы решили принять это как добрый знак.

 МР с фиксом
МР с фиксом

Выводы второго дня

Во-первых, никогда не делать findOne в цикле по данным, количество которых может динамически расти. Это классическая ошибка, которую мы просто проморгали.

Во-вторых, всегда настраивать мониторинг базы данных с самого начала. Если бы у нас были графики количества запросов к MongoDB, мы бы заметили проблему гораздо раньше.

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

Правда, мы оставляли 1% вероятности на то, что проблема крылась где-то ещё, и наш фикс не решит её полностью.

Третий день, полёт нормальный?

Утро. Кофе, дашборды, Grafana, Sentry — стандартный набор. Смотрим метрики и видим: вчерашняя проблема с ростом нагрузки на CPU — решена. Можно выдохнуть.

 Нагрузка на бэкенд сервер за 3 дня
Нагрузка на бэкенд сервер за 3 дня

С уверенностью можно сказать, что у нас один запрос на некоторые эндпоинты вызывал от 100 до 300 запросов в БД, и это число росло постоянно, по мере роста числа пользователей и данных в БД.

После ночного рефакторинга — findOne в циклах были заменены на findMany. Количество запросов к БД сократилось в сотни раз. Вчерашняя проблема больше не актуальна.

Можно расслабиться?

Как бы не так. Sentry подкинул новую головоломку: деградация производительности на другом эндпоинте.

Картинка неутешительная: каждый час p50 растёт примерно на 100 мс!

 Рост p50 +100 мс в час
Рост p50 +100 мс в час

Мы быстро выяснили, что проблема снова в N+1 запросах.

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

Мы оптимизировали процесс, переписав сбор статистики с использованием агрегирующего запроса через Aggregation Pipeline. Дополнительно был добавлен индекс для ускорения выборки, что значительно повысило производительность выборки.

В результате:

  • Количество запросов к БД сократилось в разы.

  • Нагрузка на процессор упала примерно на 70%.

  • Эндпоинт стал работать заметно быстрее, даже при росте числа матчей и ставок.

Графики после фикса радуют глаз:

 Радикальное снижение времени выполнения запроса
Радикальное снижение времени выполнения запроса
 Сильное уменьшение нагрузки на CPU бэкенд
Сильное уменьшение нагрузки на CPU бэкенд

И тут мы впервые увидели на графике характерные всплески — пятиминутные крон-джобы наконец-то стали заметны на фоне общего спокойствия.

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

Что было наиболее полезно?

  1. Нагрузочное тестирование и горизонтальное масштабирование.

    Это позволило понять наши лимиты, а горизонтальное масштабирование позволило эффективно использовать многоядерные машины. Без этого мы бы точно не смогли выдерживать такие нагрузки. Для Node.js приложений это критично.

  2. Sentry.

    Это был наш локатор багов и ошибок. Без Sentry мы бы половину проблем искали бы неделями. Особенно в первый день, когда iOS массово сыпала 401, а потом — когда на бэке всплыли N+1 запросы. Sentry реально экономит много времени.

  3. Grafana + Prometheus.

    Сделали всю нагрузку очень наглядной и позволили быстро реагировать.

  4. Cloudflare.

    Это не только про защиту от DDoS и Rate Limit (который реально спас от пары неприятных ситуаций), но и про удобную аналитику трафика и кэширование. После включения кэширования картинок и статики нагрузка на фронтенд сервер просто рухнула вниз. Без Cloudflare запуск был бы гораздо более нервным.

Что мы забыли сделать до запуска (и пожалели)

  1. SSG для Next.js

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

  2. Кэш статики (особенно картинок).

    В первые дни половина CPU съедалась на оптимизацию одних и тех же картинок для каждого пользователя. Простое правило кэширования в Cloudflare — и сервер сразу задышал. Лучше сделать это до запуска, чем потом ловить тайм-ауты на ровном месте.

  3. Мониторинг базы данных.

    Мы долго жили без нормальных графиков по MongoDB. Когда наконец подключили MongoDB Profiler Exporter, сразу увидели, где у нас самые тяжёлые запросы и характер нагрузки на БД. Если бы сделали это заранее — сэкономили бы кучу времени на поиски.

  4. Настройка хранения метрик в Prometheus.

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

  5. N+1 запросы.

    Если бы сразу подумали о потенциальных объёмах данных и сделали агрегацию на стороне базы — избежали бы ночных фиксов.

  6. Кэширование тяжёлых запросов.

    Кэшировать результаты тяжёлых вычислений — must-have для любого масштабируемого проекта. Это мы сделать не успели, и видим тут большую возможность для ещё большего снижения нагрузки.

Финал

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

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

P.S.

Если вам близки темы про продукты, людей и технологии — буду рад видеть вас в своём Telegram-канале. Там делюсь короткими выжимками, полезными наблюдениями и опытом, который накапливается в процессе запуска и развития проектов. Там уже опубликовано краткое резюме этой статьи, а ещё там уже есть гайд на то, как составить крутое резюме айтишнику и шаблон резюме, который помог мне пройти HR фильтры Google, Boeing, Visa и Revolut.

Спасибо, что дочитали!

Буду рад вашим вопросам и комментариям — и до встречи в следующих постах.

Комментарии (13)


  1. php7
    07.06.2025 14:54

    Вы меня извините, но что первая часть, что вторая - какая-то дичь


    1. fuman
      07.06.2025 14:54

      у всех пэхэпэшников так искусно и аргументировано получается выражать свое мнение?


  1. tot0ro
    07.06.2025 14:54

    Меня зовут Женя, сейчас я работаю разработчиком в Google.

    Во-первых, никогда не делать findOne в цикле по данным, количество которых может динамически расти. Это классическая ошибка, которую мы просто проморгали.

    Судя по тексту вы проморгали все классические ошибки, интересно только одно как вы собес в гугл проходили.


    1. markelov69
      07.06.2025 14:54

      Судя по тексту вы проморгали все классические ошибки, интересно только одно как вы собес в гугл проходили.

      Так да, это очевидно. Я ещё к первой его статье написал что он обманывает про опыт, про 9 лет в разработке, про Google то понятное дело 10000% враньё, ну может 1 год джуном отработал в реале и это первый проект с нуля.


    1. mav3riq Автор
      07.06.2025 14:54

      Не все, на самом деле :) В статье я поделился самыми крупными факапами, кому-то это точно будет полезно.

      Все эти факапы мы очень быстро решили за несколько дней и первый запуск прошел для нас с большим успехом.

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

      Ожидаемо, что когда делишься факапами — это обязательно привлечёт критику. Ну что ж, я знал на что иду)

      Если у вас нигде никогда не бывает факапов, даже самых элементарных, то я вам завидую белой завистью!


      1. cyber_lapti
        07.06.2025 14:54

        так в гугле знают что вы у них работаете то @markelov69@tot0ro


      1. tot0ro
        07.06.2025 14:54

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

        Интересно что же вы там тестировали тогда.


        1. tot0ro
          07.06.2025 14:54

          Они могут быть, тут ошибка в том что человек заявил о наличии большого опыта и знаний а на деле их не видно


  1. tuxi
    07.06.2025 14:54

    Статические ресурсы не кэшировались. Примерно половина всех запросов приходилась на статику, и Next.js каждый раз заново оптимизировал одни и те же изображения для каждого пользователя — опять же, серьёзная нагрузка на процессор. Более подробно про кэширование статики я писал выше.

    Ну ёшкин кот. Базовые факапы. Если делают обработку/генерацию новой статики из референсной, то сразу же делают кеширование и механизмы обновления.

    "Защита от DDOS" - вкратце как делали? А то как-то мимоходом, обыденно написано.


  1. AnywayMax
    07.06.2025 14:54

    Слушай, статью про мини‑приложения реально интересно читать, но ты её так завалил жаргоном, что народ будет ломать голову над каждым термином. Объясни N+1 проблему как то, что на каждую связанную запись уходит отдельный запрос и всё начинает тормозить. Вместо «увеличили RPS в 10 раз» скажи, что разбили базу на шарды, подняли кластеры и RPS вырос с 500 до 5000 запросов в секунду. Мониторинг через Grafana представь как панель приборов в машине, где видишь температуру сервера и обороты запросов и сразу понимаешь, когда нужно вмешаться. Разбей статью на разделы масштабирования, мониторинга и интеграций, в каждом упомяни инструменты и ключевые метрики, чтобы не пришлось скроллить в поисках сути. И не забывай, кому ты пишешь: новичку нужны подробные объяснения, профи ждут короткие шпаргалки без лишних англицизмов. Тогда текст станет понятнее и реально поможет читателю разобраться.


    1. mav3riq Автор
      07.06.2025 14:54

      Спасибо! Дельный совет, приму на вооружение.


  1. mSnus
    07.06.2025 14:54

    Так а чем и как вы делали нагрузочное тестирование, если реально у вас под нагрузкой всё так посыпалось?


  1. sedovmax
    07.06.2025 14:54

    Что вы использовали для привличения стольких юзеров одномоментно?