Как мы пилили серверный рендеринг и что из этого вышло
Всем привет! На протяжении года мы переходим на React и задумались о том, как бы сделать так, чтобы наши пользователи не ждали клиентской шаблонизации, а видели страницу как можно быстрее. С этой целью решили делать серверный рендеринг (SSR — Server Side Rendering) и оптимизировать SEO, ведь не все поисковые движки умеют исполнять JS, а те, которые умеют, тратят время на исполнение, а время краулинга каждого сайта ограничено.
Напомню, что серверный рендеринг — это выполнение JavaScript-кода на стороне сервера, чтобы отдать клиенту уже готовый HTML. Это влияет на воспринимаемую пользователем производительность, особенно на слабых машинах и при медленном интернете. Нет необходимости дожидаться пока скачается, распарсится и выполнится JS. Браузеру остается только отрисовать HTML сразу, не дожидаясь JSa, пользователь уже может читать контент. Таким образом сокращается фаза пассивного ожидания. Браузеру после рендера останется пройтись по готовому DOM, проверить, что он совпадает с тем, что отрендерилось
на клиенте, и добавить слушателей событий (event listeners). Такой процесс называется гидрацией. Если в процессе гидрации произойдёт расхождение контента от сервера и сгенерированного браузером, получим предупреждение в консоли и лишний ререндер на клиенте. Такого быть не должно, надо следить за тем, чтобы результат работы серверного и клиентского рендеринга совпадали. Если они расходятся, то к этому следует отнестись как багу, так как это сводит на нет преимущества серверного рендеринга. В случае если какой-то элемент должен расходиться, надо добавить ему suppressHydrationWarning={true}
.
Помимо этого есть один нюанс: на сервере нет window
. Код, который обращается к нему, должен выполняться в lifecycle методах, не вызываемых на стороне сервера. То есть, нельзя использовать window
в UNSAFE_componentWillMount () или, в случае с хуками, uselayouteffect.
По сути, процесс серверного рендеринга сводится к тому, чтобы получить initialState с бэкенда, прогнать его через renderToString()
, забрать на выходе готовый initialState и HTML, и отдать на клиент.
В hh.ru походы из клиентского JS разрешены только в api gateway на питоне. Это нужно для безопасности и балансировки нагрузки. Питон уже ходит в нужные бэкенды за данными, подготавливает их и отдает браузеру. Node.js используем только для серверного рендеринга. Соответственно, после подготовки данных питону необходим дополнительный поход в node, ожидание результата и передача ответа клиенту.
Для начала нужно было выбрать сервер для работы с HTTP. Остановились на koa. Понравился современный синтаксис с await
. Модульность — это легкие middleware, которые при необходимости ставятся отдельно или легко пишутся самостоятельно. Сам по себе сервер легкий и быстрый. Да и написан koa той же командой разработчиков, что пишут express, подкупает их опыт.
После того как научились раскатывать наш сервис, написали простейший код на KOA, который умел отдавать 200, и залили это на прод. Выглядело это так:
const Koa = require('koa');
const app = new Koa();
const SERVER_PORT = 9400;
app.use(async (ctx) => {
ctx.body = 'Hello World';
});
app.listen(SERVER_PORT);
В hh.ru все сервисы живут в docker контейнерах. Перед первым релизом необходимо написать ansible плейкбуки, с помощью которых сервис раскатывается в продакшен окружении и на тестовых стендах. У каждого разработчика и тестировщика свое тестовое окружение, максимально похожее на прод. На написание плейбуков мы потратили больше всего времени и сил. Так получилось из-за того, что делали это два фронтендера, и это первый сервис на ноде в hh.ru. Нам пришлось разбираться с тем, как переключать сервис в режим разработки, делать это параллельно с сервисом, для которого происходит рендеринг. Поставлять файлы в контейнер. Запускать голый сервер, чтобы докер контейнер стартовал, не дожидаясь сборки. Собирать и пересобирать сервер вместе с использующим его сервисом. Определить, сколько нам нужно оперативки.
В режиме разработки предусмотрели возможность автоматической пересборки и последующего рестарта сервиса при изменении файлов, входящих в итоговый билд. Ноде нужно перезапуститься, чтобы подгрузить исполняемый код. За изменениями и сборкой следит webpack. Webpack нужен для конвертации ESM в common CommonJS. Для рестарта взяли nodemon, который смотрит за собранными файлами.
Далее научили сервер маршрутизации. Для корректной балансировки необходимо знать, какие инстансы сервера живы. Чтобы это проверить, эксплуатационный heart beat раз в несколько секунд ходит на /status
и ожидает получить 200 в ответ. В случае если сервер не отвечает более заданного в конфиге количества раз, он удаляется из балансировки. Это оказалось простой задачей, пара строк и готов роутинг:
export default async function(ctx, next) {
if (routeMap[ctx.request.path]) {
routeMap[ctx.request.path](ctx);
} else {
ctx.throw(NOT_FOUND, getStatusText(NOT_FOUND));
}
next();
}
И отвечаем 200 на нужном урле:
export default (ctx) => {
ctx.status = 200;
ctx.body = '200';
};
После этого сделали примитивный сервер, который отдавал state в и готовый HTML.
Понадобилось контролировать, как работает сервер. Для этого нужно прикрутить логирование и мониторинг. Логи пишутся не в JSON, а чтобы поддержать формат логов остальных наших сервисов, преимущественно Java. По бенчмаркам был выбран log4js — он быстрый, легко настраивается и пишет в нужном нам формате. Общий формат логов необходим для упрощения поддержки мониторинга — не надо писать лишние регулярки для разбора логов. Помимо логов мы еще пишем ошибки в sentry. Код логеров приводить не буду, он очень простой, в основном, там настройки.
Потом необходимо было предусмотреть graceful shutdown — когда серверу становится плохо, или когда катится релиз, сервер не принимает больше входящих подключений, но выполняет все висящие на нем запросы. Для ноды есть множество готовых решений. Взяли http-graceful-shutdown, все, что нужно было сделать — это обернуть вызов gracefulShutdown(app.listen(SERVER_PORT))
На этом моменте получили production-ready решение. Чтобы проверить, как он работает, включили серверный рендеринг для 5% пользователей на одной странице. Посмотрели метрики, поняли, что ощутимо улучшили FMP для мобильных телефонов, для десктопов значение практически не изменилось. Начали тестировать производительность, выяснили, что один сервер держит ~20 RPS (джавистов очень развеселил этот факт). Выяснили причины, почему это так:
-
Одна из основных проблем оказалась в том, что собирали без NODE_ENV=production (выставляли нужный нам ENV для серверного билда). В этом случае реакт отдает не продакшен сборку, которая работает примерно на 30% медленнее.
-
Подняли версию ноды с 8 до 10, получили еще порядка 20–25% производительности.
-
То, что мы оставляли напоследок — запуск ноды на нескольких ядрах. Мы подозревали, что это очень сложно, но тут тоже все оказалось весьма прозаично. В ноде есть встроенный механизм — cluster. Он позволяет запустить необходимое количество независимых процессов, в том числе мастер-процесс, который раскидывает им задачи.
if (cluster.isMaster) {
cluster.on('exit', (worker, exitCode) => {
if (exitCode !== SUCCESS) {
cluster.fork();
}
});
for (let i = 0; i < serverConfig.cpuCores; i++) {
cluster.fork();
}
} else {
runApp();
}
В этом коде запускается мастер-процесс, стартуют процессы по количеству выделенных под сервер CPU. Если один из чайлд процессов падает — код выхода не равен 0
(мы сами выключили сервер), мастер-процесс его перезапускает.
И производительность возрастает примерно на количество выделенных под сервер CPU.
Как я писал выше, больше всего времени потратили на написание изначальных плейбуков — порядка 3 недель. На написание всего SSR ушло еще порядка 2 недель, и еще около месяца мы потихоньку доводили его до ума. Все это делалось силами 2-х фронтов, без энтерпрайз опыта node js. Не бойтесь делать SSR, главное — не забудьте указать NODE_ENV=production
, ничего сложного в этом нет. Пользователи и SEO скажут вам спасибо.