Хабрахабр

[Перевод] Серверный рендеринг в бессерверной среде

Автор материала, перевод которого мы публикуем, является одним из основателей проекта Webiny — бессерверной CMS, основанной на React, GraphQL и Node.js. Он говорит, что поддержка многоарендной бессерверной облачной платформы — это дело, которому свойственны особенные задачи. Написано уже много статей, в которых идёт речь о стандартных техниках оптимизации веб-проектов. Среди них — серверный рендеринг, использование технологий разработки прогрессивных веб-приложений, разные способы улучшения сборок приложений и многое другое. Эта статья, с одной стороны, похожа на другие, а с другой — от них отличается. Дело в том, что она посвящена оптимизации проектов, работающих в бессерверной среде.

Подготовка

Для того чтобы проводить измерения, которые помогут идентифицировать проблемы проекта, мы будем использовать webpagetest.org. С помощью этого ресурса мы будем выполнять запросы и собирать сведения о времени выполнения различных операций. Это позволит нам лучше понять то, что видят и ощущают пользователи, работая с проектом.

Это — очень важный показатель. Нас особенно интересует показатель «First view», то есть — то, сколько времени занимает загрузка сайта у пользователя, который посещает его впервые. Дело в том, что кэш браузера способен скрывать многие узкие места веб-проектов.

Показатели, отражающие особенности загрузки сайта — идентификация проблем

Взгляните на следующую диаграмму.

Анализ старых и новых показателей веб-проекта

Если присмотреться к этому показателю, то можно увидеть, что только для того, чтобы начать рендеринг страницы, в старой версии проекта требовалось почти 2 секунды. Здесь самым важным показателем можно признать «Time to Start Render» — время до начала рендеринга. Для того чтобы вывести страницу подобного приложения на экран, сначала надо загрузить объёмный JS-бандл (этот этап загрузки страницы отмечен на следующем рисунке как 1). Причина этого кроется в самой сущности одностраничных приложений (Single Page Application, SPA). И уже только после этого в окне браузера может что-то появиться. Потом этот бандл нужно обработать в главном потоке (2).

(1) Загрузка JS-бандла. (2) Ожидание обработки бандла в главном потоке

После того, как главный поток обработает JS-бандл, он выполняет несколько запросов к API Gateway. Однако это — лишь часть общей картины. Зрелище это не самое приятное. На этой стадии обработки страницы пользователь видит вращающийся индикатор загрузки. Вот раскадровка процесса загрузки страницы. При этом пользователь пока ещё не видел никакого содержимого страницы.

Загрузка страницы

А именно, он вынужден в течение 2 секунд смотреть на пустую страницу, а потом ещё секунду — на индикатор загрузки. Всё это говорит о том, что пользователь, посетивший такой сайт, испытывает не особенно приятные ощущения от работы с ним. Эти запросы необходимы для того, чтобы загрузить данные и, в результате, вывести на экран готовую страницу. Эта секунда добавляется ко времени подготовки страницы из-за того, что после загрузки и обработки JS-бандла выполняются запросы к API.

Загрузка страницы

Однако на проекты, работающие в бессерверной среде, влияет печально известная проблема «холодного старта». Если бы проект хостился на обычном VPS, то временные затраты на выполнение этих запросов к API были бы, в основном, предсказуемыми. Функции AWS Lambda являются частью VPC (Virtual Private Cloud, виртуальное частное облако). В случае с облачной платформой Webiny дело обстоит ещё хуже. Это значительно увеличивает время холодного старта функций. Это означает, что для каждого нового экземпляра такой функции нужно инициализировать ENI (Elastic Network Interface, эластичный сетевой интерфейс).

Вот некоторые временные показатели, касающиеся загрузки функций AWS Lambda внутри VPC и за пределами VPC.

Анализ загрузки функций AWS Lambda внутри VPC и за пределами VPC (изображение взято отсюда)

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

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

Задачи оптимизации

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

  • Улучшение скорости выполнения запросов к API или уменьшение числа запросов к API, которые блокируют рендеринг.
  • Уменьшение размера JS-бандла или перевод этого бандла в состав ресурсов, которые не являются необходимыми для вывода страницы.
  • Разблокировка главного потока.

Подходы к решению задач

Вот несколько подходов к решению задач, которые мы рассматривали:

  1. Оптимизация кода в расчёте на ускорение его выполнения. Этот подход требует больших усилий, он отличается высокой стоимостью. Выгоды, которые можно получить в результате подобной оптимизации, сомнительны.
  2. Увеличение объёма оперативной памяти, доступной функциям AWS Lambda. Сделать это легко, стоимость такого решения находится где-то между средней и высокой. От применения этого решения можно ожидать лишь небольших положительных эффектов.
  3. Применение какого-то другого способа решения задачи. Правда, в тот момент мы ещё не знали о том, что это за способ.

Мы, в итоге, выбрали третий пункт этого списка. Мы рассуждали так: «Что если нам совершенно не будут нужны запросы к API? Что если мы сможем обойтись совсем без JS-бандла? Это позволило бы нам решить все проблемы проекта».

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

Неудачная попытка

Webiny Cloud — это бессерверная инфраструктура, основанная на AWS Lambda, которая поддерживает сайты Webiny. Наша система умеет выявлять ботов. Когда оказывается, что запрос выполнен ботом, этот запрос перенаправляется экземпляру Puppeteer, который рендерит страницу, используя Chrome без пользовательского интерфейса. Боту передаётся уже готовый HTML-код страницы. Сделано это, в основном, по SEO-соображениям, из-за того, что многие боты не умеют выполнять JavaScript. Мы решили воспользоваться таким же подходом и для подготовки страниц, предназначенных для обычных пользователей.

Подобный подход хорошо показывает себя в окружениях, в которых нет поддержки JavaScript. Однако если попытаться отдавать предварительно отрендеренные страницы клиенту, браузер которого поддерживает JS, то страница выводится, но потом, после загрузки JS-файлов, React-компоненты просто не знают о том, куда им монтироваться. Это приводит к появлению целой кучи сообщений об ошибках в консоли. В результате нас подобное решение не устроило.

Знакомство с SSR

Сильная сторона серверного рендеринга (SSR, Server Side Rendering) заключается в том, что все запросы к API выполняются в пределах локальной сети. Так как они обрабатываются некоей системой или функцией, выполняющейся внутри VPC, для них нехарактерны задержки, возникающие при выполнении запросов из браузера к бэкенду ресурса. Хотя и при таком сценарии сохраняется проблема «холодного старта».

Дополнительным преимуществом использования SSR является то, что мы передаём клиенту такую HTML-версию страницы, при работе с которой после загрузки JS-файлов у компонентов React не возникает проблем с монтированием.

Мы, кроме того, можем, для вывода страницы, обойтись без обращений к API. И, наконец, нам не нужен JS-бандл очень большого размера. Бандл может быть загружен асинхронно и это не будет блокировать главный поток.

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

Вот как выглядит анализ сайта после применения серверного рендеринга.

Показатели сайта после применения серверного рендеринга

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

Проблема с TTFB

Здесь мы обсудим показатель TTFB (Time To First Byte, время до первого байта). Вот подробные сведения о первом запросе.

Сведения о первом запросе

Проблема тут заключается в том, что всё это, в среднем, занимает 1-2 секунды. Для обработки этого первого запроса нам нужно сделать следующее: запустить Node.js-сервер, выполнить серверный рендеринг, производя запросы к API и выполняя JS-код, после чего — вернуть клиенту итоговый результат.

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

Мы ведь всё это время говорим о бессерверной системе. Тут у вас может возникнуть вопрос по поводу слова «сервер». Мы, безусловно, пытались выполнять серверный рендеринг в функциях AWS Lambda. Откуда тут взялся этот «сервер»? Кроме того, сюда ещё добавляется и проблема «холодного старта», о которой мы уже говорили. Но оказалось, что это — очень ресурсозатратный процесс (в частности, нужно было очень сильно увеличить объём памяти для того чтобы получить больше процессорных ресурсов). В результате тогда идеальным решением было использование Node.js-сервера, который загружал бы материалы сайта и занимался бы их серверным рендерингом.

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

Загрузка страницы при использовании серверного рендеринга

5 секунд. Пользователь вынужден смотреть на пустую страницу в течение 2. Это печально.

У нас был HTML-снимок страницы, содержащий всё необходимое. Хотя, глядя на эти результаты, можно подумать, что мы совершенно ничего не добились, это, на самом деле, не так. При этом в ходе обработки страницы на клиенте не нужно было выполнять никаких запросов к API. Этот снимок был готов к работе с React. Все необходимые данные уже были внедрены в HTML.

В этот момент мы могли либо вложить больше времени в оптимизацию серверного рендеринга, либо просто кэшировать его результаты и отдавать клиентам снимок страницы из чего-то вроде кэша Redis. Единственной проблемой было то, что создание этого HTML-снимка занимало слишком много времени. Мы поступили именно так.

Кэширование результатов серверного рендеринга

После того, как пользователь посещает сайт Webiny, мы, в первую очередь, проверяем централизованный кэш Redis на предмет того, имеется ли там HTML-снимок страницы. Если это так — мы отдаём пользователю страницу из кэша. В среднем это снизило показатель TTFB до уровня 200-400 мс. Именно после внедрения кэша мы начали замечать значительные улучшения в производительности проекта.

Загрузка страницы при использовании серверного рендеринга и кэша

Даже пользователь, который посещает сайт впервые, видит содержимое страницы меньше чем через секунду.

Посмотрим на то, как теперь выглядит waterfall-диаграмма.

Показатели сайта после применения серверного рендеринга и кэширования

Именно здесь содержимое страницы оказывается полностью загруженным. Красная линия указывает на временную отметку, равную 800 мс. 3 с. Кроме того, тут можно видеть, что JS-бандлы оказываются загруженными примерно на отметке в 1. При этом для вывода страницы не нужно выполнять запросы к API и нагружать главный поток. Но это не влияет на то время, которое нужно пользователю для того, чтобы увидеть страницу.

Эти затраты времени и ресурсов требуются для того, чтобы страница стала бы «интерактивной». Обратит внимание на то, что временные показатели, касающиеся загрузки JS-бандла, выполнения запросов к API, выполнения операций в главном потоке, всё ещё играют важную роль в подготовке страницы к работе. Но это не играет никакой роли, во-первых, для ботов поисковых систем, а во-вторых — для формирования у пользователей ощущения «быстрой загрузки страницы».

Она, например, выводит в заголовке ссылку для доступа к учётной записи пользователя в том случае, если пользователь, который просматривает страницу, вошёл в систему. Предположим, что некая страница является «динамической». То есть — такая, которая выводится пользователям, не вошедшим в систему. После выполнения серверного рендеринга в браузер поступит страница общего назначения. Тут мы имеем дело с показателем TTI (Time To Interactive, время до первой интерактивности). Заголовок этой страницы изменится, отразив факт входа пользователя в систему, только после того, как будет загружен JS-бандл и будут выполнены обращения к API.

Исправление буквально одной строчки кода привело к тому, что показатель TTFB удалось снизить до уровня 50-90 мс. Через несколько недель мы обнаружили, что наш прокси-сервер не закрывает соединение с клиентом там, где это нужно, в том случае, если выполнение серверного рендеринга запускалось в виде фонового процесса. В результате сайт теперь начал выводиться в браузере примерно через 600 мс.

Однако перед нами встала ещё одна проблема…

Проблема инвалидации кэша

«В компьютерной науке есть только две сложные вещи: инвалидация кэша и именование сущностей».
Фил Карлтон

Как её решить? Инвалидация кэша — это, и правда, очень сложная задача. Это иногда будет приводить к тому, что страницы будут загружаться медленнее, чем обычно. Во-первых, можно часто обновлять кэш, задавая очень короткое время хранения кэшированных объектов (TTL, Time To Live, время жизни). Во-вторых, можно создать механизм инвалидации кэша, основанный на неких событиях.

Но мы, кроме того, реализовали возможность предоставления клиентам устаревших данных из кэша. В нашем случае данная проблема была решена с использованием очень маленького показателя TTL, равного 30 секундам. Благодаря этому мы избавились от проблем, вроде задержек и «холодного старта», которые свойственны для функций AWS Lambda. В то время, когда клиенты получают подобные данные, обновление кэша ведётся в фоновом режиме.

Пользователь посещает сайт Webiny. Вот как это работает. Если там имеется снимок страницы — мы отдаём его пользователю. Мы проверяем HTML-кэш. Мы же, передавая пользователю этот старый снимок за несколько сотен миллисекунд, параллельно запускаем задачу по созданию нового снимка и по обновлению кэша. Возраст снимка может быть равен даже нескольким дням. Поэтому нам не приходится, во время создания новых снимков, тратить время на холодный запуск функций. На выполнение этой задачи обычно уходит несколько секунд, так как мы создали механизм, благодаря которому у нас всегда есть некоторое количество заранее запущенных, готовых к работе функций AWS Lambda.

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

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

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

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

Итоги

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

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

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

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

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

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть