Привет! Меня зовут Олег Драпеза, я работаю техлидом в Тинькофф в команде Coretech Frontend. Мой основной проект — SSR мета-фреймворк tramvai, на котором работают несколько десятков фронтовых приложений Тинькофф.

Сегодня хочется поговорить про масштабирование SSR-приложений. С SSR есть две проблемы: React и Node.js. Они же — сильные стороны подхода, потому что предоставляют отличный DX, общий код и хорошие возможности для поддержки frontend-разработчиками. Разберемся, с какими сложностями мы можем столкнуться при использовании React и Node.js и как с ними быть. 

Основные проблемы

Минусы React. Рендеринг сложного приложения на сервере в HTML-строку занимает много времени: в худшем случае от 10 до 100 миллисекунд. Происходит это в виде одной тяжелой синхронной задачи (renderToString). Новые API для потокового рендеринга (renderToPipeableStream) не решают проблему, но об этом мы поговорим позже. Есть бенчмарки, в которых многие фреймворки опережают React в разы. На Github можно посмотреть, какие именно.

Минусы Node.js. Платформа работает в одном потоке и достаточно капризна к нагрузкам. Один поток означает, что любая синхронная задача делает приложение в это время не отзывчивым. Event loop будет загружен, и он не cможет ни принимать новые запросы, ни отвечать на уже принятые. Еще одна особенность загруженного приложения — долгие ответы на запросы за метриками или health checks.

Также это означает, что синхронные задачи будут выполняться по очереди. Допустим, в приложение пришло 20 запросов, а рендер страницы занимает 50 миллисекунд. Значит, первый запрос получит ответ через 50 миллисекунд, а последний — через 1000 миллисекунд, что уже неприемлемо долго. В реальном приложении большую часть времени ответа занимают запросы в сторонние API, но мы намеренно проигнорируем это для простых расчетов. 

Про время ответа. Для сохранения адекватного времени придется горизонтально масштабировать приложение, поднимая такое количество инстансов, при котором на текущий RPS время ответа на условном 95-м перцентиле соответствует ожиданиям. Если среднее время рендеринга — 50 миллисекунд (сразу берем плохой сценарий), а мы хотим отвечать максимум за 300 миллисекунд, на один инстанс должно быть не больше 4—6 RPS нагрузки. Это очень мало, так как и ресурсов на этот под мы должны выделить достаточно.

Про ресурсы. Кажется, что для однопоточной ноды не нужно больше одного CPU. Или, в терминах Kubernetess, 1000m (milliCPU). Но еще есть Garbage Collector, который умеет работать в отдельных потоках, и кажется оптимальным выделять 1100m на один инстанс. Тогда GC сможет работать, не влияя на производительность основного потока.

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

Но можем ли мы выделить меньше одного CPU? К сожалению, нет, так как это начнет ухудшать тайминги ответа приложения. При выделении меньше 1000m на под в k8s на синхронные задачи начинает влиять CPU throttling. Пример проблемы:

  1. Выделяем 400m CPU.

  2. Запускаем задачу в 200 миллисекунд процессорного времени.

  3. В течение каждых 100 миллисекунд Kubernetess выделит на задачу 40 миллисекунд.

  4. В итоге таска выполнится за 440 миллисекунд.

Очень хорошо про троттлинг рассказано в статье по ссылке.

Потоковый рендеринг. Так что насчет streaming rendering? Если есть возможность перестроить архитектуру приложения так, чтобы отдавать контент по частям или хотя бы содержимое head в начале, — это отлично. И может дать хороший буст к Time To First Byte и различным метрикам отрисовки. 

Но у стримов есть свой оверхэд. renderToPipeableStream сейчас работает на 10—20% медленнее, чем renderToString. И условные 100 миллисекунд, разбитые на задачи по 5 миллисекунд, все также загружают наши приложения и также страдают от троттлинга.

Вернемся к примеру с 20 запросами и рендером по 50 миллисекунд. При потоковом рендеринге может не быть быстрых и медленных запросов: если все запросы пришли одновременно, ответ будет формироваться параллельно. Итого на каждый запрос будет примерно одинаково плохое время ответа — от 900 до 1000 миллисекунд. Тут уже вам решать, какое поведение лучше для пользователей.

Возможные оптимизации

Мы разобрали основные проблемы и теперь можем обсудить способы их решения.

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

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

Но есть кейсы, когда это не поможет. Например:

  • DDoS;

  • большие рекламные кампании;

  • отсутствие свободного железа. 

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

Рассмотрим ряд возможных оптимизаций, некоторые из которых отлично работают вместе:

  • Static Site Generation;

  • Rendering at the Edge;

  • микросервисы;

  • оптимизация кода;

  • кэширование компонентов;

  • кэширование запросов;

  • Rate Limiting;

  • Fallback кэш-страниц;

  • Client-side rendering fallback;

  • кластеризация и воркеры. 

На эту тему есть классная статья. И еще одна — менее практическая, но тоже интересная. 

Static Site Generation. SSG — оптимальный вариант рендеринга. На этапе сборки или в рантайме мы генерируем и кэшируем странички, отдаем эту статику через CDN и можем держать несравнимо большие нагрузки.

Конечно, есть минус, и очень большой: пострадает любая динамика, персонализация и A/B-тесты. Я еще не встречал проектов без такой динамики, SSG максимум подходил для отдельных страниц. Кажется, все это справедливо и по отношению к Incremental Static Regeneration у Next.js.

Rendering at the Edge. Рендер приложения с помощью функций Edge или Lambda поближе к пользователю — отличная возможность улучшить тайминги ответа, если фреймворк и особенности вашего деплоя это позволяют.

Даже без этого может быть полезно закрыть приложение за CDN. На примере Microsoft Bing видно, что контент будет ближе к пользователю, а между CDN и сервером приложения будет гарантировано хорошее и стабильное соединение. 

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

Другие плюсы и минусы дробления на сервисы рассматривать не буду, так как они к нашей теме не относятся.

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

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

  • Тяжелая регулярка. Мне попалась одна на 100+ миллисекунд работы.

  • Многократно повторяемые действия. Например, парсинг User-Agent, 2—3 миллисекунды на каждый вызов, несколько вызовов на запрос с одним аргументом, возможность сохранять в LRU-кэше.

  • Сложные компоненты. Например, на сайте Тинькофф есть микрофронты Header и Footer, общие по всем приложениям. Мы избавились от лишних тегов и компонентов и немного улучшили время рендеринга буквально всех страниц на сайте, а это десятки разных приложений.

Кэширование компонентов. В теории можно кэшировать результат рендеринга отдельных компонентов и шарить его между запросами. Делать это можно либо какими-то хаками с dangerouslySetInnerHTML, либо кастомными ReactDOMServer-имплементациями.

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

Кэширование запросов. Must have! Кэшировать надо все, что возможно, — как правило, это GET-запросы без какой-либо персонализации. Именно запросы сильнее всего замедляют время ответа приложения.

Для этого отлично подходит LRU-кэш. Кстати, у нас есть свой форк очень быстрого node-lru-cache, из которого убрали все лишнее.

Remote-кэши, например Redis, не используем, чтобы не усложнять деплой и не тратить лишние ресурсы. Обычно памяти более чем хватает на кэши в памяти. 

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

Rate Limiting. Лучший способ не давать приложению умирать — снижение нагрузки. Есть разные способы и алгоритмы, но все они сводятся к тому, что в приложение приходит только часть запросов, а остальные встают в очередь. И это происходит где-нибудь уровнем выше, например на балансере.

Оказывается, это можно сделать и на уровне своего приложения. Это дает очень большую гибкость, потому что именно на уровне приложения мы можем понять, как именно оно себя чувствует. Встроенные API ноды и кастомный код могут дать нам много информации, например лаг event loop, объем потребляемой памяти, затраченное на ответы время.

На Github можно найти плагин для fastify, который умеет ограничить нагрузку на приложение. А для tramvai-приложений мы разработали свой Request Limiter. Он очень крутой и позволяет приложениям не умирать под любыми нагрузками в принципе. Вот как он работает:

  • Ограничивает количество одновременно обрабатываемых запросов.

  • Проверяет текущий лаг event loop. Если он низкий — увеличивает лимит, если высокий — уменьшает.

  • Запросы сверх лимита попадают в очередь, если размер очереди ограничен — сразу отвечает ошибкой 429.

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

Ошибка 429 — тоже не оптимальный вариант ответа, но это можно улучшить. На уровне балансера приложения можно обрабатывать ошибки 5xx и 429 и отдавать что-нибудь полезное. Об этом дальше.

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

Чтобы страницу можно было сохранить в кэш, надо соблюдать ряд условий:

  • запрос должен быть без cookies и любой персонализации (например, кастомные заголовки);

  • не нужно кэшировать ответы 5xx, а вот насчет 404 и редиректов можно подумать;

  • ключ кэша должен учитывать URL, метод запроса, тип устройства (десктоп/мобилка) и другие специфичные для вас условия.

Кто может делать такой запрос:

  • отдельный сервис — краулер, который будет регулярно ходить по страницам приложений;

  • сами приложения могут сохранять ответы, подходящие под условия выше, или генерировать фоновые запросы при пустом кэше.

В итоге у нас будет фаллбэк HTML и переключатель на этот фаллбэк. Схему можно усложнять дальше — хранить кэш удаленно, а не в памяти приложения, балансера или краулера.

Client-side rendering fallback. Одна из самых надежных фич, которую используют наши приложения, — генерация одной HTML-заглушки с помощью SSG-механизма фреймворка tramvai.

Красота этого подхода в том, что CSR-заглушка может работать на любом URL приложения. Главное, чтобы на ней были базовые скрипты и стили, а остальное приложение и так умеет загружать, как при SPA-переходах.

Тот же механизм на балансере, на ошибках 5xx и 429: если не нашел фаллбэк-кэш, может отдавать CSR-фаллбэк. Лучше всего использовать это на закрытых авторизацией приложениях, чтобы не проседало SEO. Главное, что приложение остается работоспособным. 

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

  • Fork процесса перед рендером — child_process.fork создает отдельный процесс, шарить память нельзя.

  • Worker_thread позволяет шарить память частично. Небольшой набор объектов, а остальное надо сериализовать

  • Cluster-модуль, где у нас master-процесс будет работать как request limiter. Но лучше ли это, чем балансировка на уровне Kubernetes?

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

Заключение

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

Огромное спасибо всем коллегам, кто дебажил, профилировал, изучал, разрабатывал и мониторил все эти оптимизации на разных приложениях Тинькофф! Вы о-о-очень крутые!

И спасибо за внимание!