El material, cuya traducción publicamos hoy, está dedicado a la historia de cómo Airbnb optimiza las partes del servidor de las aplicaciones web con el ojo puesto en el uso cada vez mayor de las tecnologías de representación del servidor. En el transcurso de varios años, la compañía cambió gradualmente todo su front-end a una arquitectura
uniforme , según la cual las páginas web son estructuras jerárquicas de componentes React llenas de datos de su API. En particular, durante este proceso hubo un abandono sistemático de Ruby on Rails. De hecho, Airbnb planea cambiar a un nuevo servicio basado únicamente en Node.js, gracias al cual las páginas totalmente preparadas que se muestran en el servidor se entregarán a los navegadores de los usuarios. Este servicio generará la mayor parte del código HTML para todos los productos de Airbnb. El motor de renderizado en cuestión difiere de la mayoría de los servicios de back-end utilizados por la compañía debido al hecho de que no está escrito en Ruby o Java. Sin embargo, difiere de los servicios tradicionales Node.js altamente cargados, alrededor de los cuales se construyen los modelos mentales y las herramientas auxiliares utilizadas en Airbnb.

Plataforma Node.js
Pensando en la plataforma Node.js, puede imaginar cómo una determinada aplicación, construida teniendo en cuenta las capacidades de esta plataforma para el procesamiento de datos asíncrono, sirve de manera rápida y eficiente a cientos o miles de conexiones paralelas. El servicio extrae los datos que necesita de todas partes y los procesa un poco para satisfacer las necesidades de una gran cantidad de clientes. El propietario de dicha aplicación no tiene motivos para quejarse, confía en el modelo ligero de procesamiento de datos simultáneo que utiliza (en este material usamos la palabra "simultáneo" para transmitir el término "concurrente", para el término "paralelo" - "paralelo"). Ella resuelve perfectamente la tarea establecida para ella.
La representación del lado del servidor (SSR) cambia las ideas básicas que conducen a una visión similar del problema. Por lo tanto, la representación del servidor requiere muchos recursos informáticos. El código en el entorno Node.js se ejecuta en un solo hilo, como resultado, para resolver problemas computacionales (a diferencia de las tareas de E / S), el código se puede ejecutar simultáneamente, pero no en paralelo. La plataforma Node.js es capaz de manejar una gran cantidad de operaciones de E / S paralelas, sin embargo, cuando se trata de computación, la situación cambia.
Dado que cuando se aplica la representación del lado del servidor, la parte computacional de la tarea de procesamiento de solicitudes aumenta en comparación con la parte relacionada con la entrada / salida, simultáneamente las solicitudes entrantes afectarán la velocidad de respuesta del servidor debido al hecho de que compiten por los recursos del procesador. Cabe señalar que cuando se utiliza la representación asincrónica, la competencia por los recursos todavía está presente. El renderizado asincrónico resuelve la capacidad de respuesta de un proceso o navegador, pero no mejora la situación con demoras o concurrencia. En este artículo, nos centraremos en un modelo simple que incluye exclusivamente cargas computacionales. Si hablamos de una carga mixta, que incluye tanto operaciones de entrada / salida como de cálculo, las solicitudes entrantes simultáneamente aumentarán el retraso, pero teniendo en cuenta la ventaja de un mayor rendimiento del sistema.
Considere un comando de la forma
Promise.all([fn1, fn2])
. Si
fn1
o
fn1
son promesas resueltas por el subsistema de E / S, entonces durante la ejecución de este comando es posible lograr la ejecución paralela de las operaciones. Se ve así:
Ejecución paralela de operaciones mediante el subsistema de entrada / salida.Si
fn1
y
fn1
son tareas computacionales, se ejecutarán de la siguiente manera:
Tareas informáticasUna de las operaciones tendrá que esperar la finalización de la segunda operación, ya que solo hay un hilo en Node.js.
En el caso de la representación del servidor, este problema ocurre cuando el proceso del servidor tiene que procesar varias solicitudes simultáneas. El procesamiento de tales solicitudes se retrasará hasta que se procesen las solicitudes recibidas antes. Así es como se ve.
Procesando solicitudes concurrentesEn la práctica, el procesamiento de solicitudes a menudo consta de muchas fases asincrónicas, incluso si implican una carga computacional grave en el sistema. Esto puede conducir a una situación aún más difícil con la alternancia de tareas para procesar tales solicitudes.
Supongamos que nuestras consultas están compuestas por una cadena de tareas similar a esta:
renderPromise().then(out => formatResponsePromise(out)).then(body => res.send(body))
. Cuando un par de solicitudes llega al sistema, con un pequeño intervalo entre ellas, podemos observar la siguiente imagen.
Procesando solicitudes que llegan en un pequeño intervalo, el problema de la lucha por los recursos del procesadorEn este caso, se tarda aproximadamente el doble de tiempo en procesar cada solicitud que en procesar una solicitud individual. Con un aumento en el número de solicitudes procesadas simultáneamente, la situación empeora aún más.
Además, uno de los objetivos típicos de la implementación de SSR es la capacidad de usar el mismo código o un código muy similar tanto en el cliente como en el servidor. La seria diferencia entre estos entornos es que el entorno del cliente es esencialmente un entorno en el que opera un cliente, y los entornos de servidor, por su naturaleza, son entornos multicliente. Lo que funciona bien en el cliente, como singletones u otros enfoques para almacenar el estado global de la aplicación, conduce a errores, fugas de datos y, en general, a confusión, al procesar muchas solicitudes que llegan al servidor.
Estas características se convierten en problemas en una situación en la que necesita procesar múltiples solicitudes al mismo tiempo. Todo funciona normalmente con cargas más bajas en un entorno acogedor del entorno de desarrollo, que es utilizado por un cliente en la persona de un programador.
Esto lleva a una situación que es muy diferente de los ejemplos clásicos de la aplicación Node.js. Cabe señalar que utilizamos el tiempo de ejecución de JavaScript para el rico conjunto de bibliotecas disponibles en él, y debido al hecho de que es compatible con los navegadores, y no por el bien de su modelo para el procesamiento simultáneo de datos. En esta aplicación, el modelo asíncrono de procesamiento simultáneo de datos demuestra todos sus inconvenientes, no compensados por las ventajas, que son muy pocas o ninguna.
Tutoriales del proyecto Hypernova
Nuestro nuevo servicio de renderizado, Hyperloop, será el servicio principal con el que interactuarán los usuarios de Airbnb. Como resultado, su confiabilidad y rendimiento juegan un papel crucial para garantizar la conveniencia de trabajar con un recurso. Cuando introducimos Hyperloop en producción, tenemos en cuenta la experiencia que obtuvimos al trabajar con nuestro sistema de renderizado de servidores anterior:
Hypernova .
Hypernova no funciona como nuestro nuevo servicio. Este es un sistema de representación puro. Se llama desde nuestro servicio monolítico Rail, llamado Monorail, y solo devuelve fragmentos de HTML para componentes renderizados específicos. En muchos casos, este "fragmento" representa la mayor parte de la página, y Rails proporciona solo el diseño de la página. Con la tecnología heredada, partes de una página se pueden vincular mediante ERB. En cualquier caso, sin embargo, Hypernova no carga los datos necesarios para formar la página. Esta es la tarea de Rails.
Por lo tanto, Hyperloop e Hypernova tienen un rendimiento informático similar. Al mismo tiempo, Hypernova, como servicio de producción y procesando volúmenes significativos de tráfico, proporciona un buen campo para las pruebas, lo que lleva a comprender cómo se comportará el reemplazo de Hypernova en condiciones de combate.
Flujo de trabajo de HypernovaAsí es como funciona Hypernova. Las solicitudes de los usuarios llegan a nuestra aplicación principal de Rails, Monorail, que recopila las propiedades de los componentes React que deben mostrarse en una página y realiza una solicitud a Hypernova, pasando estas propiedades y nombres de componentes. Hypernova renderiza componentes con propiedades para generar el código HTML que debe devolverse a la aplicación Monorail, que luego incrusta este código en la plantilla de página y lo envía todo al cliente.
Enviar una página terminada a un clienteEn el caso de una emergencia (esto podría ser un error o el tiempo de espera de respuesta) en Hypernova, hay una opción alternativa, cuando se usa que los componentes y sus propiedades se incrustan en la página sin el HTML generado en el servidor, después de lo cual todo esto se envía al cliente y se procesa allí con suerte exitoso. Esto nos llevó al hecho de que no consideramos que el servicio Hypernova sea una parte crítica del sistema. Como resultado, podríamos permitir la aparición de un cierto número de fallas y situaciones en las que se activa un tiempo de espera. Al ajustar los tiempos de espera de solicitud, nosotros, en base a las observaciones, los establecemos en aproximadamente el nivel P95. Como resultado, no es sorprendente que el sistema haya funcionado con una tasa de respuesta de tiempo de espera base inferior al 5%.
En situaciones en las que el tráfico alcanzó valores máximos, pudimos ver que hasta el 40% de las solicitudes a Hypernova se cerraron por tiempos de espera en Monorail. En el lado de Hypernova, vimos picos de
BadRequestError: Request aborted
menor altura. Estos errores, además, existían en condiciones normales, mientras que en la operación normal, debido a la arquitectura de la solución, los errores restantes no eran particularmente notables.
Valores de tiempo de espera máximo (líneas rojas)Dado que nuestro sistema podría funcionar sin Hypernova, no prestamos mucha atención a estas características, se percibían más como problemas insignificantes, en lugar de serios problemas. Explicamos estos problemas por las características de la plataforma, porque el lanzamiento de la aplicación es lento debido a la operación de recolección de basura inicial bastante difícil, debido a las peculiaridades de la compilación de código y el almacenamiento en caché de datos, y por otras razones. Esperábamos que las nuevas versiones de React o Node incluirían mejoras de rendimiento que mitigarían las deficiencias del lento lanzamiento del servicio.
Sospeché que lo que estaba sucediendo era muy probablemente el resultado de un equilibrio de carga deficiente o la consecuencia de problemas en el despliegue de la solución, cuando se manifestaron retrasos crecientes debido a una carga computacional excesiva en los procesos. Agregué una capa auxiliar al sistema para registrar información sobre el número de solicitudes procesadas simultáneamente por procesos individuales, así como para registrar casos en los que el proceso recibió más de una solicitud de procesamiento.
Resultados de la investigaciónConsideramos que el inicio lento del servicio es el culpable de los retrasos, pero en realidad el problema fue causado por solicitudes paralelas que luchaban por el tiempo de CPU. Según los resultados de la medición, resultó que el tiempo empleado por la solicitud en anticipación de la finalización del procesamiento de otras solicitudes corresponde al tiempo dedicado a procesar la solicitud. Además, esto significó que un aumento en los retrasos debido al procesamiento simultáneo de solicitudes se ve igual que un aumento en los retrasos debido a un aumento en la complejidad computacional del código, lo que conduce a un aumento en la carga del sistema al procesar cada solicitud.
Esto, además, hizo más obvio que el
BadRequestError: Request aborted
no podía explicarse con confianza por un inicio lento del sistema. El error procedió del código de análisis del cuerpo de la solicitud y ocurrió cuando el cliente canceló la solicitud antes de que el servidor pudiera leer completamente el cuerpo de la solicitud. El cliente dejó de funcionar, cerró la conexión, privándonos de los datos necesarios para continuar procesando la solicitud. Es mucho más probable que esto ocurriera porque comenzamos a procesar la solicitud, después de eso el bucle de eventos resultó ser una representación bloqueada para otra solicitud, y luego volvimos a la tarea interrumpida para completarla, pero como resultado resultó que el cliente quien nos envió esta solicitud ya se ha desconectado, cancelando la solicitud. Además, los datos transmitidos en las solicitudes a Hypernova eran bastante voluminosos, en promedio, en la región de varios cientos de kilobytes, y esto, por supuesto, no contribuyó a mejorar la situación.

Un error causado al desconectar un cliente que no esperó una respuestaDecidimos abordar este problema utilizando un par de herramientas estándar con las que teníamos una experiencia considerable. Estamos hablando de un servidor proxy inverso (
nginx ) y un equilibrador de carga (
HAProxy ).
Proxying inversa y equilibrio de carga
Para aprovechar la arquitectura del procesador multinúcleo, ejecutamos varios procesos Hypernova utilizando el módulo de
clúster Node.js incorporado. Dado que estos procesos son independientes, podemos procesar simultáneamente las solicitudes entrantes.
Procesamiento paralelo de solicitudes que llegan simultáneamenteEl problema aquí es que cada proceso de nodo está completamente ocupado todo el tiempo que lleva procesar una solicitud, incluida la lectura del cuerpo de la solicitud enviada desde el cliente (el monorraíl desempeña su papel en este caso). Aunque podemos leer muchas consultas en un solo proceso al mismo tiempo, cuando se trata de renderizar, conduce a una alternancia de operaciones computacionales.
El uso de los recursos del proceso Node está vinculado a la velocidad del cliente y de la red.
Como solución a este problema, podemos considerar un servidor proxy inverso de almacenamiento en búfer, que nos permitirá mantener sesiones de comunicación con los clientes. La inspiración para esta idea fue el servidor web unicornio, que usamos para nuestras aplicaciones Rails.
Los principios declarados por unicornio explican perfectamente por qué esto es así. Para este propósito utilizamos nginx. Nginx lee la solicitud del cliente al búfer y pasa la solicitud al servidor de nodo solo después de que se haya leído completamente. Esta sesión de transferencia de datos se realiza en la máquina local, a través de la interfaz de bucle invertido o usando sockets de dominio Unix, y esto es mucho más rápido y más confiable que transferir datos entre computadoras separadas.
Nginx guarda las solicitudes y luego las envía al servidor NodeDebido al hecho de que nginx ahora se dedica a leer solicitudes, pudimos lograr una carga más uniforme de los procesos Node.
Proceso uniforme de carga usando nginxAdemás, utilizamos nginx para manejar algunas solicitudes que no requieren acceso a los procesos Node. La capa de detección y enrutamiento de nuestro servicio utiliza solicitudes
/ping
que no crean una gran carga en el sistema para verificar la comunicación entre los hosts. Procesar todo esto en nginx elimina una fuente significativa de carga de trabajo adicional (aunque pequeña) para Node.js.
La próxima mejora se refiere al equilibrio de carga. Necesitamos tomar decisiones informadas sobre la distribución de solicitudes entre procesos Node. El módulo de
cluster
distribuye las solicitudes de acuerdo con el algoritmo round-robin, en la mayoría de los casos con intentos de omitir los procesos que no responden a las solicitudes. Con este enfoque, cada proceso recibe una solicitud en orden de prioridad.
El módulo de
cluster
distribuye conexiones, no solicitudes, por lo que todo esto no funciona como lo necesitamos. La situación empeora aún más cuando se utilizan conexiones persistentes. Cualquier conexión permanente del cliente está vinculada a un único flujo de trabajo específico, lo que complica la distribución eficiente de las tareas.
El algoritmo round-robin es bueno cuando hay poca variabilidad en los retrasos de solicitud. Por ejemplo, en la situación ilustrada a continuación.
Algoritmo de round-robin y conexiones a través de las cuales se reciben solicitudes de forma estableEste algoritmo ya no es tan bueno cuando tiene que procesar solicitudes de diferentes tipos, para cuyo procesamiento pueden requerirse costos de tiempo completamente diferentes. La solicitud más reciente enviada a un proceso determinado se ve obligada a esperar la finalización del procesamiento de todas las solicitudes enviadas antes, incluso si hay otro proceso que tenga la capacidad de procesar dicha solicitud.
Carga de proceso desigualSi distribuye las consultas que se muestran arriba de manera más racional, obtendrá algo como lo que se muestra en la figura a continuación.
Distribución racional de solicitudes por hilosCon este enfoque, la espera se minimiza y es posible enviar respuestas a las solicitudes más rápido.
Esto se puede lograr colocando solicitudes en una cola y asignándolas a un proceso solo cuando no está ocupado procesando otra solicitud. Para este propósito usamos HAProxy.
HAProxy y balanceo de carga de procesoCuando utilizamos HAProxy para equilibrar la carga en Hypernova, eliminamos por completo los picos de tiempo de espera, así como los errores
BadRequestErrors
.
Las solicitudes simultáneas también fueron la causa principal de demoras durante la operación normal; este enfoque redujo tales demoras. Una de las consecuencias de esto fue que ahora solo el 2% de las solicitudes se cerraron por tiempo de espera, y no el 5%, con la misma configuración de tiempo de espera. El hecho de que pudimos pasar de una situación con un 40% de errores a una situación con un tiempo de espera que se activa en el 2% de los casos mostró que nos estamos moviendo en la dirección correcta. Como resultado, hoy nuestros usuarios ven la pantalla de carga del sitio web con mucha menos frecuencia. Cabe señalar que la estabilidad del sistema será de particular importancia para nosotros con la transición esperada a un nuevo sistema que no tiene el mismo mecanismo de respaldo que Hypernova.
Detalles sobre el sistema y su configuración
Para que todo esto funcione, debe configurar la aplicación nginx, HAProxy y Node. Aquí hay
un ejemplo de una aplicación similar que usa nginx y HAProxy, analizando cuál puede entender el dispositivo del sistema en cuestión. Este ejemplo se basa en el sistema que utilizamos en producción, pero está simplificado y modificado para que pueda ejecutarse en primer plano en nombre de un usuario sin privilegios. En producción, todo debe configurarse utilizando algún tipo de supervisor (usamos runit o, más a menudo, kubernetes).
La configuración nginx es bastante estándar, utiliza un servidor que escucha en el puerto 9000, configurado para las solicitudes de proxy al servidor HAProxy, que escucha en el puerto 9001 (en nuestra configuración, usamos sockets de dominio Unix).
Además, este servidor intercepta las solicitudes al punto final
/ping
para atender directamente las solicitudes destinadas a verificar la conectividad de la red. nginx ,
worker_processes
1, nginx — HAProxy Node-. , , , Hypernova, ( ). .
Node.js
cluster
. HAProxy,
cluster
, .
pool-hall . — , , ,
cluster
, .
pool-hall
, .
HAProxy , 9001 , 9002 9005. —
maxconn 1
, . . HAProxy ( 8999).
HAProxyHAProxy . ,
maxconn
.
static-rr
(static round-robin), , , . , round-robin, , , , , . , , . .
, , . ( ). , , , , . , , .
HAProxy
HAProxy. , , , . , , ( ) . , ,
cluster
. , .
ab
(Apache Benchmark) 10000 . - . :
ab -l -c <CONCURRENCY> -n 10000 http://<HOSTNAME>:9000/render
15 4- -,
ab
, . (
concurrency=5
), (
concurrency=13
), , (
concurrency=20
). , .
, -, . , . , , , , . , , , .
, — .
maxconn 1
, , .
HTTP TCP , , , . ,
maxconn
, . , , (, , ).
, , , , , , .
— , .
option redispatch
retries 3
, , , , , , . .
, - , . , . , , . 100 , 10 , , . , . ,
accept
.
, (
backlog ) , . SYN-ACK (
, , , ACK ). , , , , .
, , , , . , , 1.
maxconn
. 0 , , , , , . , . - , , .
abortonclose
, . ,
abortonclose
. nginx.
, , . ( ) , , , , , . HAProxy , , ( ). , , , HTML. , , . , , ( , , ). , , . , , , . HAProxy, MAINT HAProxy.
, , ,
server.close
Node.js , HAProxy , , , . , , , , , .
, ,
balance first
, (
worker1
) 15% , , ,
balance static-rr
. , «» . . (12 ), , , - . , , , «» «». .
, , Node
server.maxconnections
, ( , ), , , , . ,
maxconnection
, , , . JavaScript, ( ). , , , . , , , HAProxy Node , . , , .
, , , ,
.
Node.js . , , , -. Node.js . , , , , , , , nginx HAProxy.
, Airbnb , Node.js .
Estimados lectores! ¿Utiliza renderizado del lado del servidor en sus proyectos?