Привет!
Сегодня хочется поговорить про масштабирование SSR приложений.
С SSR есть две проблемы - React и Node.js, которые без сомнения одновременно являются и сильными сторонами подхода (общий код, отличный DX, легко поддерживать frontend разработчикам, etc.)
Рендеринг сложного приложения на сервере в HTML строку занимает достаточно много времени, от 10ms до 100ms в хуших случаях, и происходит одной тяжелой синхронной задачей (renderToString
). И новые API для потокового рендеринга (renderToPipeableStream
) не решают проблему, об этом позже. Есть бенчмарки, например https://github.com/BuilderIO/framework-benchmarks#ssr-throughput-reqsecond, в которых многие фреймворки опережают React в разы.
Нода работает в одном потоке, и достаточно капризная к нагрузкам (хотя тут мне особо не с чем сравнивать, нет сравнимого опыта с другими платформами). Один поток означает что любая синхронная задача делает приложение в это время не отзывчивым - event loop будет загружен, оно не cможет принимать новые запросы, отвечать на уже принятые. Одни из побочных эффектов у загруженного приложения - долгие ответы на запросы за метриками или health checks.
Также это означает, что синхронные задачи будут выполнены по очереди. Допустим, в приложение пришло 20 запросов, рендер страницы занимает 50ms - итого первый запрос получит ответ через 50ms, а последний через 1000ms, что уже не приемлемо долго (а в реальном приложение большую часть времени ответа занимают запросы в сторонние API, тут мы намеренно это игнорируем для простых расчетов).
Для сохранения адекватного времени придется горизонтально масштабировать наше приложение, поднимая такое количество инстансов приложения, при котором на текущий RPS время ответа на условном 95 перцентиле соответствует ожиданиям. Если среднее время рендеринга 50ms (сразу берем плохой сценарий), и мы хотим отвечать максимум за 300ms, на один инстанс должно быть не больше 4-6 RPS нагрузки, что очень мало, так как и ресурсов на этот под мы должны выделить достаточно.
По поводу ресурсов. Кажется, что для однопоточной ноды не нужно больше 1 CPU (или 1000m - milliCPU - в терминах Kubernetess), но есть еще Garbage Collector, который умеет работать в отдельных потоках - и кажется оптимально выделять 1100m на один инстанс, тогда GC сможет работать не влияя на производительность основного потока.
Тут приходит другая проблема - низкая утилизация выделенных ресурсов, так как мы должны оставлять поды не перегруженными, по той же причине - latency. Это очень актуально в условиях нехватки ресурсов и дорогого железа (текущие реалии наверное во всем мире).
Но можем ли мы выделить меньше одного CPU? К сожалению, нет, так как это напрямую начнет ухудшать тайминги ответа приложения. При выделении меньше 1000m на под в k8s, на синхронные задачи начнет влиять CPU throttling, пример проблемы:
Очень хорошо про троттлинг рассказано в статье https://web.archive.org/web/20220317223152/https://amixr.io/blog/what-wed-do-to-save-from-the-well-known-k8s-incident/
Так что насчет streaming rendering?
Если есть возможность перестроить архитектуру приложения так, что бы отдавать контент по частям, или хотя бы содержимое head в начале - это отлично, может дать хороший буст к Time To First Byte и различным метрикам отрисовки.
Но у стримов есть свой оверхэд, renderToPipeableStream
работает сейчас процентов на 10-20 минимум медленнее чем renderToString
- и условные 100ms разбитые на задачи по 5ms в сумме все так же загружают наши приложения, и так же страдают от троттлинга.