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

В Airbnb мы потратили годы на неуклонную миграцию всего кода Frontend на согласованную архитектуру, где целые веб-страницы написаны в виде иерархии компонентов React, наполненных данными из нашего API. Роль Ruby on Rails в обеспечении доступа браузера к сети с каждым днем ​​уменьшается. Фактически, скоро мы перейдем к новой услуге, которая будет предоставлять полностью сформированные веб-страницы, отрендеренные сервером, полностью на Node.js. Эта служба будет отображать большую часть HTML для всего продукта Airbnb. Этот механизм визуализации отличается от большинства серверных служб, которые мы запускаем, тем, что он написан не на Ruby или Java. Но он также отличается от обычного сервиса Node.js с интенсивным вводом-выводом, на котором построены наши ментальные модели и общие инструменты.

Когда вы думаете о Node.js, вы представляете себе, как ваше высокоасинхронное приложение эффективно и эффективно обслуживает сотни или тысячи соединений одновременно. Ваш сервис собирает данные со всего города и применяет самую легкую обработку, чтобы сделать их приемлемыми для многих, многих клиентов. Возможно, вы имеете дело с множеством долгоживущих соединений WebSocket. Вы довольны и уверены в своей облегченной модели параллелизма, которая идеально подходит для этой задачи.

Рендеринг на стороне сервера (SSR) разрушает предположение, которое привело к этому видению. Это ресурсоемкие вычисления. Код пользователя в Node.js выполняется в одном потоке, поэтому для вычислительных операций (в отличие от операций ввода-вывода) вы можете выполнять их одновременно, но не параллельно. Node.js может обрабатывать большие объемы асинхронного ввода-вывода параллельно, но имеет ограничения на вычисления. Поскольку вычислительная часть запроса увеличивается по сравнению с вводом-выводом, одновременные запросы будут иметь увеличивающееся влияние на задержку из-за конкуренции за ЦП¹.

Рассмотрим Promise.all([fn1, fn2]). Если fn1 или fn2 - это обещания, которые разрешаются вводом-выводом, вы можете добиться параллелизма следующим образом:

Если fn1 и fn2 вычисляются, они будут выполняться следующим образом:

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

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

На практике запросы часто состоят из множества различных асинхронных фаз, даже если по-прежнему в основном выполняются вычисления. Это может привести к еще худшему перемежению. Если наш запрос состоит из цепочки типа renderPromise().then(out => formatResponsePromise(out)).then(body => res.send(body)), мы могли бы иметь чередование запросов, например

В этом случае оба запроса занимают вдвое больше времени. Эта проблема усугубляется по мере увеличения параллелизма.

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

Обе эти проблемы становятся проблемой только при одновременном выполнении. Часто все работает нормально при более низких уровнях нагрузки или в удобной единой среде вашей среды разработки.

Это приводит к ситуации, которая сильно отличается от канонических примеров приложений Node. Мы используем среду выполнения JavaScript для поддержки ее библиотеки и характеристик браузера, а не для ее модели параллелизма. В этом приложении модель асинхронного параллелизма накладывает все свои затраты на отсутствие или некоторые из своих преимуществ.

Уроки Hypernova

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

Hypernova работает иначе, чем наша новая служба. Это чистый рендерер. Он вызывается из нашего монолита Rails, Monorail, и возвращает только фрагменты HTML для определенных визуализированных компонентов. Во многих случаях фрагмент составляет большую часть страницы, а Rails предоставляет только внешний макет. В устаревших случаях части страницы можно сшить вместе с помощью ERB. В любом случае, однако, Hypernova не обрабатывает собственные данные. Данные предоставлены Rails.

При этом Hyperloop и Hypernova имеют схожие рабочие характеристики в том, что касается вычислений, и как сервис, работающий в производственной среде со значительным трафиком, Hypernova обеспечивает хорошую тестовую площадку для понимания того, как ее замена будет вести себя в производственной среде.

Запросы поступают от пользователя к нашему основному приложению Rails, Monorail, которое собирает воедино реквизиты для компонентов React, которые он хочет отобразить на любой заданной странице, и отправляет запрос с этими реквизитами и именами компонентов в Hypernova. Hypernova визуализирует компоненты с реквизитами для генерации HTML для возврата в Monorail, который затем встраивает его в шаблон страницы и отправляет все обратно клиенту.

В случае сбоя (либо из-за ошибки, либо из-за тайм-аута) рендеринга Hypernova резервным вариантом является встраивание компонентов и их свойств на страницу без визуализированного HTML, позволяя им (надеюсь) успешно визуализироваться клиентом. Это заставило нас рассматривать Hypernova как необязательную зависимость, и мы можем терпеть некоторое количество тайм-аутов и сбоев. Мы установили тайм-ауты для вызова примерно на уровне p95 службы в то время, когда мы настраивали значения. Неудивительно, что мы работали с базовым показателем тайм-аутов чуть ниже 5%.

При развертывании во время пиковых ежедневных нагрузок трафика мы увидим, что до 40% запросов к Hypernova задерживаются в монорельсовой железной дороге. От Hypernova мы увидим всплески частоты ошибок меньшей величины - 7_ при развертывании. Эти ошибки также существовали с базовой скоростью, которая довольно эффективно скрывала все другие ошибки приложения / кодирования.

В качестве необязательной зависимости это поведение не было приоритетным и считалось большим раздражением. Мы были разумно удовлетворены, объяснив таймауты и ошибки результатом медленного запуска, такого как более дорогой начальный сборщик мусора при запуске, отсутствие JIT, заполнение кешей, повторение сплайнов и т. Д. Была надежда, что новые выпуски React или Node обеспечит достаточное повышение производительности, чтобы уменьшить медленный запуск.

Я подозревал, что это могло быть результатом плохой балансировки нагрузки или проблем с емкостью во время развертывания, когда мы наблюдали увеличенную задержку, потому что мы выполняли несколько практически 100% вычислительных запросов одновременно в одном и том же процессе. Я добавил промежуточное ПО для регистрации количества запросов, одновременно обрабатываемых любым заданным процессом, а также для регистрации любых случаев, когда мы выполняли более одного запроса за раз.

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

Также становилось все более очевидным, что ошибку BadRequestError: Request aborted нельзя было легко объяснить общей медленной скоростью запуска. Ошибка исходит от парсера тела и, в частности, происходит в том случае, если клиент прервал запрос до того, как сервер смог полностью прочитать тело запроса. Клиент отказывается и закрывает соединение, забирая драгоценные данные, необходимые для обработки запроса. Гораздо более вероятно, что это произойдет, потому что мы начали обрабатывать запрос, затем наш цикл обработки событий был заблокирован рендерингом другого запроса, а затем вернулся к завершению с того места, где мы были прерваны, только для того, чтобы обнаружить, что клиент ушел. Полезные данные запросов для Hypernova также довольно велики - в среднем несколько сотен килобайт, что не улучшает их.

Мы решили решить эту проблему, используя два готовых компонента, с которыми у нас уже был большой опыт работы: обратный прокси (nginx) и балансировщик нагрузки (haproxy).

Обратное проксирование и балансировка нагрузки

Чтобы воспользоваться преимуществами нескольких ядер ЦП, присутствующих в наших экземплярах Hypernova, мы запускаем несколько процессов Hypernova через встроенный в Node.js модуль cluster. Поскольку это независимые процессы, мы можем обрабатывать параллельные запросы параллельно.

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

Решение2 - использовать обратный прокси-сервер с буферизацией для взаимодействия с клиентами. Для этого мы используем nginx. Nginx считывает запрос от клиента в буфер и передает полный запрос на сервер узла только после того, как он был полностью прочитан. Эта передача происходит локально на машине через loopback или сокеты домена unix, которые быстрее и надежнее, чем связь между машинами.

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

Мы также используем nginx для обработки некоторых запросов, даже не обращаясь к процессам Node.js. Наш уровень обнаружения и маршрутизации сервисов использует недорогие запросы к /ping для проверки связи между хостами. Обработка этого полностью в nginx устраняет существенный источник (хотя и дешевый) пропускной способности для процессов Node.js.

Следующая часть - это балансировка нагрузки. Нам нужно принимать разумные решения о том, какие процессы Node.js должны получать какие запросы. Модуль кластера распределяет запросы по циклическому алгоритму³, где каждому процессу по очереди выдается запрос⁴. Циклический перебор удобен при низкой дисперсии задержки запроса, например:

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

Лучшее распределение этих запросов выглядело бы так:

Поскольку это сводит к минимуму время ожидания и позволяет быстрее возвращать ответы.

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

Когда мы внедрили это для Hypernova, мы полностью устранили всплески тайм-аутов при развертывании, а также BadRequestErrors. Одновременные запросы также были основным фактором высокой процентильной задержки во время нормальной работы, поэтому это также уменьшало эту задержку. Одним из последствий этого является то, что мы перешли от базового значения тайм-аута, равного 5%, до тайм-аута, равного 2%, с тем же настроенным тайм-аутом. Переход с 40% отказов во время развертывания до 2% кажется победой. Сегодня пользователи гораздо реже сталкиваются с пустым экраном загрузки. Завтра стабильность за счет развертывания будет критичной для нашего нового средства визуализации, в котором нет резервной копии ошибок Hypernova.

Детали и конфигурация

Чтобы настроить это, необходимо настроить nginx, haproxy и наше приложение узла. Я подготовил образец приложения узла с конфигурациями nginx и haproxy, который можно использовать для понимания этой настройки. Эти конфигурации основаны на том, что мы запускаем в производственной среде, но были упрощены и изменены для работы на переднем плане от имени непривилегированного пользователя. В производственной среде все должно быть настроено с помощью выбранного вами диспетчера процессов (мы используем runit или, все чаще, kubernetes).

Конфигурация nginx довольно стандартна: сервер, прослушивающий порт 9000, настроен на прокси-запросы к haproxy, прослушивающему порт 9001 (в нашей настройке мы используем сокеты домена Unix). Он также перехватывает /ping конечную точку для непосредственного обслуживания проверок подключения. Отклонение от нашей внутренней стандартной конфигурации nginx заключается в том, что мы уменьшили worker_processes до 1, поскольку одного процесса nginx более чем достаточно, чтобы насытить наш единственный процесс haproxy и приложение узла. Мы также используем большие буферы запросов и ответов, поскольку свойства наших компонентов для Hypernova могут быть довольно большими (сотни килобайт). Размер буферов следует определять в соответствии с вашими собственными размерами запросов / ответов.

Модуль узла cluster обрабатывает как балансировку нагрузки, так и порождение процессов. Чтобы переключиться на HAProxy для балансировки нагрузки, нам пришлось создать замену для частей управления процессами cluster. Это объединилось как пул-холл, который несколько более категорично относится к поддержанию пула рабочих процессов, чем cluster, но полностью исключен из игры по балансировке нагрузки. Пример приложения демонстрирует использование pool-hall для запуска четырех рабочих процессов, каждый из которых прослушивает свой порт.

Конфигурация HAProxy настраивает прокси-сервер, прослушивающий порт 9001, который направляет трафик четырем рабочим процессам, прослушивающим порты с 9002 по 9005. Самый важный параметр - maxconn 1 для каждого из рабочих. Это ограничивает каждого воркера обработкой одного запроса за раз. Это можно увидеть на странице статистики HAProxy (которая настроена для работы на порту 8999).

HAProxy отслеживает, сколько соединений открыто в данный момент между ним и каждым воркером. У него есть ограничение, настроенное через maxconn. Маршрутизация установлена ​​на static-rr (статический циклический перебор), поэтому обычно каждый работник получает запрос по очереди. С установленным лимитом маршрутизация продолжается с циклическим перебором, но пропускает всех рабочих, которые в настоящее время достигли своего лимита запросов. Если ни один из воркеров не ниже их лимита подключения, запрос ставится в очередь и будет отправлен тому воркеру, который станет доступным первым. Это то, что нам нужно.

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

HAProxy Deep Dive (Глубокое погружение в HAProxy)

Многое требовалось от конфигурации HAProxy, работающей именно так, как мы этого хотели. Это не принесло бы нам особой пользы, если бы оно не обрабатывало ограничение одновременных запросов или постановку в очередь так, как мы ожидали. Также было важно понять, как обрабатываются (или не обрабатываются) различные типы сбоев. Нам нужно было развить уверенность в том, что это подходящая замена существующей cluster установке. Чтобы убедиться в этом, мы провели серию тестов.

Общая форма тестов заключалась в использовании ab (Apache Benchmark) для выполнения 10 000 запросов на различных уровнях параллелизма, например

ab -l -c <CONCURRENCY> -n 10000 http://<HOSTNAME>:9000/render

В нашей конфигурации использовалось 15 рабочих процессов вместо 4 в примере приложения, и мы запускали ab в отдельном экземпляре от экземпляра, на котором запущено приложение, чтобы избежать помех между тестом и тестируемой системой. Мы запускали тесты при низкой нагрузке (параллелизм = 5), высокой нагрузке (параллелизм = 13) и загрузке очереди (параллелизм = 20). Загрузка очереди гарантирует, что haproxy всегда запускает очередь.

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

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

Эти тесты помогли сформировать нашу конфигурацию, а также наше понимание того, как все это будет работать.

При нормальной работе maxconn 1 работал точно так, как ожидалось, ограничивая каждый процесс обработкой одного запроса за раз. Мы не настраиваем проверки работоспособности HTTP или TCP на серверных ВМ, поскольку мы обнаружили, что это вызывает больше путаницы, чем того стоит. Кажется, что проверки работоспособности не учитывают maxconn, хотя я не проверял это в коде. Наше ожидаемое поведение состоит в том, что процесс либо исправен и может обслуживать, либо не прослушивает и немедленно выдаст ошибку соединения (есть одно серьезное исключение). Мы обнаружили, что эти проверки работоспособности недостаточно контролируемы, чтобы быть полезными в нашем случае, и решили избежать непредсказуемости дублирования режимов проверки работоспособности.

Ошибки подключения - это то, с чем мы можем работать. Мы устанавливаем option redispatch и retries 3, что позволяет отправлять запросы, которые получают ошибку подключения, на другой сервер, который, будем надеяться, будет более быстрым. Отказ в подключении приходит немедленно, что позволяет нам продолжать наш бизнес.

Это применимо только к соединениям, которые отклонены, потому что мы не слушаем. Тайм-аут подключения не особенно полезен, поскольку мы имеем дело с локальной сетью. Изначально мы ожидали, что сможем установить низкий тайм-аут соединения, чтобы защититься от рабочих, попавших в бесконечный цикл. Мы установили тайм-аут 100 мс и были удивлены, когда время ожидания наших запросов истекло через 10 секунд, что было тайм-аутом клиент / сервер, установленным в то время, хотя управление никогда не возвращалось в цикл событий, чтобы принять соединение. Это объясняется тем, что ядро ​​обрабатывает установление соединения с точки зрения клиента до того, как сервер вызовет accept.

Интересно отметить, что даже установка невыполненной работы не приводит к тому, что соединение не устанавливается, поскольку длина невыполненной работы оценивается после того, как сервер отвечает SYN-ACK (и фактически реализуется / обрабатывается сервером, отбрасывающим ответ ACK обратно из клиент ). Одним из важных следствий этого является то, что запросы, для которых были установлены соединения, не могут быть повторно отправлены / повторены, поскольку у нас нет способа узнать, обработал ли сервер запрос или нет.

Еще один интересный результат наших тестов на процессах, попавших в бесконечные вычислительные циклы, заключался в том, что тайм-ауты клиент / сервер допускают некоторое неожиданное поведение. Когда запрос был отправлен процессу, который заставляет его войти в бесконечный цикл, счетчик соединений серверной части устанавливается равным 1. С maxconn он делает то, что мы хотим, и предотвращает попадание любых других запросов в яму tar. Счетчик подключений уменьшается до 0 после истечения тайм-аута клиент / сервер, что позволяет нарушить нашу гарантию «один-на-один-один», а также обрекает на неудачу следующий плохой запрос. Когда клиент закрывает соединение из-за тайм-аута или каприза, счетчик соединений не учитывается, и наша маршрутизация продолжает работать. Установка abortonclose приводит к уменьшению количества подключений, как только клиент закрывается. Учитывая это, лучший способ действий - установить высокое значение для этих таймаутов и выключить abortonclose. Более жесткие таймауты могут быть установлены на стороне клиента или nginx.

Мы также обнаружили довольно уродливый аттрактор, который применяется в случаях высокой нагрузки. Если рабочий процесс дает сбой (что должно происходить очень редко), когда у сервера есть стабильная очередь, запросы будут проверяться на этом бэкэнде, но не сможет подключиться, поскольку процесс не прослушивает. Затем HAProxy будет перенаправлен на следующий бэкэнд с открытым слотом подключения, который будет только бэкэндом, который ранее вышел из строя (так как все остальные бэкэнд фактически заняты работой). Это быстро пропустит повторные попытки и приведет к неудачному запросу, потому что ошибки соединения выполняются намного быстрее, чем рендеринг HTML. Процесс будет повторяться для остальных запросов, пока очередь не будет полностью опустошена. Это плохо, но это смягчается редкостью сбоев процессов, редкостью работы устойчивых очередей (если вы постоянно в очереди, вы недостаточно подготовлены) и, в нашем конкретном случае, тем фактом, что этот аттрактор сбоев также привлекаем наши проверки работоспособности службы обнаружения, быстро помечаем весь экземпляр как неисправный и не отвечающий требованиям для новых запросов. Это не очень хорошо, но сводит к минимуму опасность. В будущем с этим можно справиться за счет более глубокой интеграции HAProxy, когда процесс супервизора наблюдает за выходом процесса и отмечает его как MAINT через сокет haproxy stats.

Еще одно изменение, которое стоит отметить, заключается в том, что server.close в узле ожидает завершения существующих запросов, но что-либо в очереди HAProxy завершится ошибкой, поскольку сервер не знает, что нужно ждать запросов, которые он еще не получил. Обеспечение достаточного времени слива между моментом, когда экземпляр перестает получать запросы и когда он запускает процесс перезапуска сервера, в большинстве случаев должно покрыть это.

Мы также обнаружили, что настройка balance first, которая направляет большую часть трафика на первого доступного воркера по порядку (в основном насыщая worker1), снижает задержку в нашем приложении на 15% как при синтетической, так и при производственной нагрузке по сравнению с balance static-rr. Этот эффект был долгим, и его нельзя было легко объяснить разогревом. Это продолжалось несколько часов после развертывания. Производительность снижалась в течение более длительных периодов времени (12 часов), вероятно, из-за утечек памяти в горячем процессе. Это также было менее устойчиво к скачкам трафика, поскольку холодные процессы были очень холодными. У нас до сих пор нет убедительного объяснения этому.

Наконец, настройка узла server.maxConnections казалась здесь полезной (определенно помогала мне), но мы обнаружили, что на самом деле она не очень полезна и иногда вызывала ошибки. Этот параметр запрещает серверу принимать более maxConnections, закрывая любые новые дескрипторы после того, как увидит, что оно превышает предел. Эта проверка применяется в JavaScript, поэтому она не защищает от случая бесконечного цикла (мы правильно прервем запрос, как только вернемся в цикл событий… подождите). Мы также видели ошибки подключения, вызванные этим при нормальной работе, даже несмотря на то, что мы не видели других свидетельств выполнения нескольких запросов. Мы подозреваем, что это небольшая проблема с синхронизацией или разница во мнениях между haproxy и Node о том, когда начинается и заканчивается соединение. Желание иметь гарантию взаимного исключения на стороне приложения является хорошим, поскольку это позволяет разработчикам безопасно использовать одиночные экземпляры или другое глобальное состояние. С этим можно справиться, реализовав очередь для каждого процесса в качестве промежуточного программного обеспечения.

Заключение

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

Мы вкладываем большие средства в создание интерфейса мирового класса на Airbnb. Если вам понравилось это читать и вы думали, что это интересный вызов, мы всегда ищем талантливых и любопытных людей, чтобы они присоединились к команде. Мы будем рады услышать от вас!

Благодарим Брайана Вулфа, Джо Ленсиони и Адама Нири за рецензирование и повторение этого сообщения.

Сноски

  1. При асинхронном рендеринге сохраняется конкуренция за ресурсы. Асинхронный рендеринг обращается к быстродействию процесса или браузера, но не к параллелизму или задержке. Это сообщение в блоге будет посвящено простой модели рабочих нагрузок, связанных с чистыми вычислениями. При смешанных рабочих нагрузках ввода-вывода и вычислений параллелизм запросов увеличивает задержку, но с преимуществом повышения пропускной способности.
  2. Вдохновленный веб-сервером-единорогом, который мы используем для обслуживания наших приложений Rails. Философия единорога особенно хорошо формулирует причину.
  3. В основном, с некоторой попыткой обойти не отвечающие процессы
  4. cluster распределяет соединения, а не запросы, поэтому при использовании постоянных соединений это ведет себя иначе, что еще хуже. Любое постоянное соединение от клиента привязано к одному конкретному рабочему процессу, что еще больше затрудняет эффективное распределение работы.