Чтобы повысить производительность страниц фронтенда на основе React, в Yelp используют рендеринг на стороне сервера. После ряда инцидентов на продакшне в начале 2021 года, когда из шаблонов на основе Python в React было перенесено много страниц, стало понятно, что существующая система серверного рендеринга не масштабируется. Материалом о том, как решалась проблема, делимся к старту курса по Fullstack-разработке на Python.
До конца года мы перестраивали эту систему таким образом, чтобы повысить устойчивость, снизить затраты и повысить наблюдаемость для занятых функционалом команд.
Введение
Что такое «серверный рендеринг»?
Серверный рендеринг — это метод повышения производительности систем шаблонизации JavaScript (таких как React). Вместо ожидания загрузки пакета JavaScript и рендеринга страницы на основе его содержимого на стороне клиента, мы отображаем HTML этой страницы на стороне сервера и, как только HTML загружен, на стороне клиента прикрепляем динамические крючки.
В этом подходе увеличенный размер передаваемых данных жертвуется в пользу увеличенной скорости рендеринга, ведь наши серверы обычно быстрее клиентского компьютера. На практике обнаружено, что это значительно улучшает временные составляющие скорости загрузки основного контента.
Статус-кво
Мы подготавливаем компоненты для серверного рендеринга, собирая их вместе с функцией точки входа и любыми другими зависимостями в отдельном JS-файле. После в точке входа используется ReactDOMServer, в котором принимаются пропсы компонента и создаётся отображаемый HTML-код. Эти пакеты серверного рендеринга как часть процесса непрерывной интеграции подгружаются в S3.
В старой системе серверного рендеринга при запуске загружалась и инициализировалась последняя версия каждого пакета, после чего она была готова к отображению любой страницы без ожидания в S3 по критическому пути.
Затем, в зависимости от входящего запроса, выбиралась и вызывалась соответствующая функция точки входа. Такой подход сопряжён с рядом проблем:
При загрузке и инициализации каждого пакета значительно увеличивалось время запуска сервиса, что затрудняло быстрое реагирование на события масштабирования.
В связи что в сервисе осуществлялся контроль за всеми пакетами, требовался большой объём памяти. Каждый раз при горизонтальном масштабировании и развёртывании нового экземпляра сервиса нам приходилось выделять память, равную в сумме использованию исходного кода каждого пакета и времени выполнения. Кроме того, все пакеты обслуживались из одного и того же экземпляра, что затрудняло измерение характеристик производительности одного пакета.
Если бы новая версия пакета подгружалась между перезапусками сервиса, то у сервиса не было бы её копии. Мы решили эту проблему, динамически загружая недостающие пакеты в случае необходимости.
А ещё, чтобы в памяти не хранилось слишком много динамических пакетов одновременно, использовали алгоритм кеширования с вытеснением значений, которые не запрашивались дольше всего.
Старая система основывалась на сервисе Hypernova от Airbnb. В блоге Airbnb вышла статья о проблемах с Hypernova. Основная проблема заключается в том, что при отображении компонентов блокируется цикл событий, это может привести к неожиданным сбоям нескольких API на Node.
С подобной проблемой столкнулись и мы: блокировка цикла событий привела к нарушению функциональности времени ожидания ответа на HTTP-запросы Node, из-за чего значительно возросли величины задержки при обработке запросов, когда система уже была перегружена.
Любая система серверного рендеринга должна быть способна минимизировать последствия блокировки цикла событий, возникающей при отображении компонентов.
Эти проблемы обострились в начале 2021 года, когда количество пакетов серверного рендеринга в Yelp продолжало расти:
На запуск стало уходить так много времени, что в Kubernetes начали помечать экземпляры как неработоспособные и автоматически перезапускать их, лишая возможности когда-либо стать работоспособными.
Из-за огромного размера динамической памяти сервиса возникли значительные проблемы со сборкой мусора. К концу срока службы старой системы под неё выделялось почти 12 Гб пространства в «куче».
В одном из экспериментов мы определили, что не можем обслуживать > 50 запросов в секунду из-за потерь времени, ушедшего на сборку мусора.
Переполнение кеша динамическими пакетами из-за частого их вытеснения и повторной инициализации стало причиной большой нагрузки на процессор, что стало влиять на другие сервисы, работающие на той же машине.
Все эти проблемы привели к снижению производительности на фронтенде Yelp и нескольким инцидентам.
Цели перестраивания системы
Разобравшись с этими инцидентами, мы приступили к перестройке системы серверного рендеринга. В качестве целей выбрали стабильность, наблюдаемость и простоту. Новая система должна функционировать и масштабироваться без особого ручного вмешательства.
В ней должна быть обеспечена беспроблемная наблюдаемость — как для групп обслуживания инфраструктуры, так и для занимающихся функционалом команд, которым принадлежат пакеты. Дизайн новой системы должен быть прост для понимания будущими разработчиками.
Кроме того, мы выбрали ряд конкретных функциональных целей:
Минимизация последствий блокировки цикла событий, чтобы обеспечить корректную работу такого механизма, как время ожидания ответа на запросы.
Сегментирование, или разделение экземпляров сервиса на пакеты, чтобы под каждый пакет выделялись свои уникальные ресурсы. При этом сокращается общий объём требуемых ресурсов и упрощается наблюдаемость системы в том, что касается производительности конкретных пакетов.
Мгновенное отклонение запросов, быстрая обработка которых не представляется возможной. Если мы знаем, что на выполнение запроса уйдёт много времени, то должны обеспечить немедленное возвращение системы к рендерингу на стороне клиента, а не наблюдать, как при серверном рендеринге истекает время ожидания. Это обеспечивает максимально быстрое взаимодействие с пользовательским интерфейсом.
Реализация
Выбор языка
Когда пришло время реализовывать сервис серверного рендеринга, мы выбирали из нескольких языков, включая Python и Rust. С точки зрения внутренней экосистемы был бы идеален Python.
Но мы посчитали, что биндинги V8 для Python не достигли состояния эксплуатационной готовности: чтобы использовать их в серверном рендеринге, требуются значительные инвестиции.
Затем мы оценили Rust. В нём есть качественные биндинги V8, которые уже применяются в популярных, пригодных для промышленной эксплуатации проектах, таких как Deno.
Однако во всех наших пакетах серверного рендеринга используется API среды выполнения Node, который не является частью простого V8, поэтому для поддержки серверного рендеринга нам пришлось бы повторно реализовать его значительные части. Это, а также отсутствие в целом поддержки Rust в экосистеме разработчиков Yelp не дало нам им воспользоваться.
В итоге мы решили переписать сервис серверного рендеринга в Node, потому что там есть V8 VM API, который позволяет разработчикам запускать JS в изолированных контекстах V8, имеет высококачественную поддержку в экосистеме разработчиков Yelp и делает возможным повторное использование кода из других внутренних сервисов Node, благодаря чему работы над реализацией остаётся меньше.
Алгоритм
Сервис серверного рендеринга состоит из основного потока и множества рабочих потоков. Рабочие потоки Node отличаются от потоков ОС тем, что каждый поток имеет собственный цикл событий и память нельзя просто разделить между потоками.
Когда HTTP-запрос поступает в основной поток, происходит следующее:
Проверяется, должен ли запрос отклоняться сразу исходя из «фактора времени ожидания». Сейчас в этот фактор входит среднее время выполнения рендеринга и текущий размер очереди, но он может включать и другие показатели, такие как загрузка процессора и пропускная способность.
Запрос добавляется в очередь пула рабочих потоков рендеринга.
Когда запрос поступает в рабочий поток, происходит следующее:
Выполняется серверный рендеринг. При этом цикл событий блокируется, но тем не менее это допустимо, так как в рабочем потоке обрабатывается только один запрос. Пока с циклом событий работает процессор, цикл событий не должен использоваться больше нигде.
Отображаемый HTML-код возвращается в основной поток.
Когда из рабочего потока в основной поступает ответ, отображаемый HTML-код возвращается клиенту.
В этом подходе для соответствия нашим требованиям даются две важные гарантии:
Цикл событий никогда не блокируется в главном потоке веб-сервера.
Этот цикл никогда не требуется, пока он заблокирован в рабочем потоке.
Описанную выше функциональность мы взяли из сторонней библиотеки Piscina. Она позволяет контролировать пулы потоков благодаря поддержке таких функций, как очередь задач, отмена задач и многих других функций. Для работы веб-сервера основного потока мы выбрали Fastify за его высокую производительность и удобство для разработчиков.
Вот сервер на Fastify:
const workerPool = new Piscina({...});
app.post('/batch', opts, async (request, reply) => {
if (
Math.min(avgRunTime.movingAverage(), RENDER_TIMEOUT_MSECS) * (workerPool.queueSize + 1) >
RENDER_TIMEOUT_MSECS
) {
// Request is not expected to complete in time.
throw app.httpErrors.tooManyRequests();
}
try {
const start = performance.now();
currentPendingTasks += 1;
const resp = await workerPool.run(...);
const stop = performance.now();
const runTime = resp.duration;
const waitTime = stop - start - runTime;
avgRunTime.push(Date.now(), runTime);
reply.send({
results: resp,
});
} catch (e) {
// Error handling code
} finally {
currentPendingTasks -= 1;
}
});
Масштабирование
Для горизонтального масштабирования — автомасштабирование
Сервис серверного рендеринга построена на PaaSTA с механизмами автоматического масштабирования «из коробки». Мы решили создать сигнал настраиваемого автомасштабирования, используя пул рабочих потоков:
Math.min(currentPendingTasks, WORKER_COUNT) / WORKER_COUNT;
Чтобы вносить изменения в горизонтальное масштабирование, это значение сравнивается с нашим целевым использованием (заданным значением) в пределах движущегося временного окна.
Мы обнаружили, что с помощью этого сигнала нагрузка на каждый рабочий поток поддерживается в более работоспособном и лучше подготовленном состоянии, чем при базовом масштабировании использования процессора контейнерами. Так все запросы обслуживаются за разумное время без перегрузки рабочих потоков или чрезмерного масштабирования сервиса.
Для вертикального масштабирования — автонастройка
Yelp состоит из множества страниц с разной нагрузкой трафика, поэтому сегменты сервиса серверного рендеринга, поддерживающие эти страницы, имеют совершенно разные требования к ресурсам.
Вместо того чтобы для каждого сегмента службы серверного рендеринга определять ресурсы статически, мы воспользовались их динамической автонастройкой: автоматически регулировали ресурсы контейнера, такие как процессоры и память сегментов с течением времени.
Эти два механизма масштабирования гарантируют каждому сегменту необходимые ему экземпляры и ресурсы независимо от того, насколько мало или много трафика он получает. Главное преимущество — это беспроблемная и при этом экономически эффективная работа сервиса серверного рендеринга на различных страницах.
Достижения
Переписав с помощью Piscina и Fastify сервис серверного рендеринга, мы смогли избежать проблемы блокировки цикла событий, которой была подвержена предыдущая реализация.
Снизив затраты на облачные вычисления, из сочетания подхода разделения на сегменты с улучшенными сигналами масштабирования мы смогли получить больше производительности. Вот конкретные улучшения:
Среднее сокращение на 125 мс p99 при серверном рендеринге пакета.
За счёт уменьшения количества инициализируемых при загрузке пакетов сервис запускается быстрее: с нескольких минут в старой системе до нескольких секунд в новой.
За счёт использования фактора настраиваемого масштабирования и более эффективной настройки ресурсов для каждого сегмента снижены затраты на облачные вычисления. Теперь это до трети от затрат предыдущей системы.
Улучшена наблюдаемость, ведь теперь каждый сегмент задействуется в рендеринге только одного пакета. Это позволяет командам быстрее понимать, где именно что-то идёт не так.
Создана система с более широкими возможностями расширения, позволяющая проводить дальнейшие улучшения, например профилирование процессора и поддержка исходных карт пакетов.
А мы поможем вам прокачать навыки или освоить профессию, которая будет востребована в любое время:
Выбрать другую востребованную профессию.
Краткий каталог курсов и профессий
Data Science и Machine Learning
Python, веб-разработка
Мобильная разработка
Java и C#
От основ — в глубину
А также