¿Cuál es la mejor manera de aumentar sin problemas la concurrencia en el servicio Node.js utilizado en la producción? Esta es una pregunta que mi equipo necesitaba responder hace un par de meses.
Hemos lanzado contenedores de 4000 nodos (o "trabajadores"), que aseguran la operación de nuestro servicio de integración con los bancos. El servicio fue diseñado originalmente para que cada trabajador estuviera diseñado para procesar solo una solicitud a la vez. Esto redujo el impacto en el sistema de aquellas operaciones que podrían
bloquear inesperadamente
el ciclo de eventos y nos permitió ignorar las diferencias en el uso de recursos por parte de varias operaciones similares. Pero, dado que nuestras capacidades se limitaron a la ejecución simultánea de solo 4,000 solicitudes, el sistema no se pudo escalar adecuadamente. La velocidad de respuesta a la mayoría de las solicitudes no dependía de la capacidad del equipo, sino de las capacidades de la red. Por lo tanto, podríamos mejorar el sistema y reducir el costo de su soporte si pudiéramos encontrar una manera de procesar de manera confiable las solicitudes en paralelo.

Después de estudiar este tema, no pudimos encontrar una buena guía que discutiera la transición de la "falta de paralelismo" en Node.js a un "alto nivel de paralelismo". Como resultado, desarrollamos nuestra propia estrategia de migración, que se basó en una planificación cuidadosa, buenas herramientas, herramientas de monitoreo y una buena dosis de depuración. Como resultado, logramos aumentar el nivel de paralelismo de nuestro sistema en 30 veces. Esto equivale a reducir el costo de mantenimiento del sistema en aproximadamente 300 mil dólares al año.
Este material está dedicado a la historia de cómo aumentamos la productividad y la eficacia de nuestros trabajadores de Node.js, y sobre lo que aprendimos al ir de esta manera.
¿Por qué decidimos invertir en paralelismo?
Puede parecer sorprendente que hayamos crecido a tales dimensiones sin el uso del paralelismo. ¿Cómo surgió? Solo el 10% de las operaciones de procesamiento de datos realizadas por las herramientas Plaid son iniciadas por usuarios que están sentados en las computadoras y han conectado sus cuentas a la aplicación. Todo lo demás son transacciones para actualizar periódicamente las transacciones que se realizan sin la presencia del usuario. La lógica se agregó al sistema de equilibrio de carga que utilizamos para garantizar que las solicitudes realizadas por los usuarios tengan prioridad sobre las solicitudes de actualización de transacciones. Esto nos permitió manejar estallidos de actividad de las operaciones de acceso a la API en 1000% o incluso más. Esto se realizó a través de transacciones destinadas a actualizar los datos.
Aunque este esquema de compromiso había estado funcionando durante mucho tiempo, era posible discernir varios momentos desagradables en él. Sabíamos que, al final, podrían afectar negativamente la confiabilidad del servicio.
- Los picos de las solicitudes de API provenientes de clientes eran cada vez más altos. Nos preocupaba que un gran aumento en la actividad pudiera agotar nuestras capacidades de procesamiento de consultas.
- El repentino aumento de los retrasos en el cumplimiento de las solicitudes a los bancos también condujo a una disminución en la capacidad de los trabajadores. Debido al hecho de que los bancos utilizan una variedad de soluciones de infraestructura, establecemos tiempos de espera muy conservadores para las solicitudes salientes. Como resultado, podría llevar varios minutos completar la operación de carga de ciertos datos. Si sucediera que los retrasos en la realización de muchas solicitudes a los bancos aumentarían repentinamente en gran medida, resultaría que muchos trabajadores simplemente estarían atrapados esperando respuestas.
- La implementación en ECS se ha vuelto demasiado lenta, y aunque hemos mejorado la velocidad de implementación del sistema, no queríamos continuar aumentando el tamaño del clúster.
Decidimos que la mejor manera de lidiar con los cuellos de botella de la aplicación y aumentar la confiabilidad del sistema es aumentar el nivel de paralelismo en el procesamiento de solicitudes. Además, esperábamos que, como efecto secundario, esto nos permitiera reducir los costos de infraestructura y ayudar a implementar mejores herramientas para monitorear el sistema. Tanto eso como otro en el futuro darían fruto.
Cómo presentamos actualizaciones, cuidando la confiabilidad
▍Herramientas y monitoreo
Tenemos nuestro propio equilibrador de carga, que redirige las solicitudes a los trabajadores de Node.js. Cada trabajador ejecuta un servidor gRPC utilizado para procesar solicitudes. El trabajador usa Redis para decirle al equilibrador de carga que está disponible. Esto significa que agregar paralelismo al sistema se reduce simplemente a cambiar algunas líneas de código. Es decir, el trabajador, en lugar de volverse inaccesible después de que se le hizo la solicitud, debe informar que está disponible hasta que se encuentre ocupado procesando las N solicitudes que le llegaron (cada una de ellas representado por su propio objeto Promise).
Es cierto, de hecho, no todo es tan simple. Al implementar actualizaciones del sistema, siempre consideramos que nuestro objetivo principal es mantener su confiabilidad. Por lo tanto, no podríamos simplemente tomar y, guiados por algo como el principio de YOLO, poner el sistema en modo de procesamiento de consultas paralelas. Esperábamos que tal actualización del sistema sería especialmente arriesgada. El hecho es que esto tendría un efecto impredecible en el uso del procesador, la memoria y los retrasos en la realización de tareas. Dado que el
motor V8 utilizado en Node.js maneja las tareas en el bucle de eventos, nuestra principal preocupación era que podría resultar que estamos haciendo demasiado trabajo en el bucle de eventos y así reducir el rendimiento del sistema.
Para mitigar estos riesgos, incluso antes de que el primer trabajador paralelo entrara en producción, nos aseguramos de la disponibilidad de las siguientes herramientas y herramientas de monitoreo en el sistema:
- La pila ELK ya utilizada por nosotros nos proporcionó una cantidad suficiente de información registrada, que podría ser útil para descubrir rápidamente lo que está sucediendo en el sistema.
- Hemos agregado varias métricas de Prometheus al sistema. Incluyendo lo siguiente:
- Tamaño de almacenamiento dinámico V8 obtenido mediante
process.memoryUsage()
. - Información sobre operaciones de recolección de basura utilizando el paquete gc-stats .
- Datos sobre el tiempo necesario para completar las tareas, agrupados por tipo de operaciones relacionadas con la integración con bancos y por nivel de concurrencia. Necesitábamos esto para medir de manera confiable cómo la concurrencia afecta el rendimiento del sistema.
- Creamos el panel de control de Grafana , diseñado para estudiar el grado de impacto de la concurrencia en el sistema.
- Para nosotros, la capacidad de cambiar el comportamiento de la aplicación sin la necesidad de volver a implementar el servicio era extremadamente importante. Por lo tanto, creamos un conjunto de indicadores LaunchDarkly diseñados para controlar varios parámetros. Con este enfoque, la selección de los parámetros de los trabajadores, calculados para que alcanzaran el máximo nivel de paralelismo, nos permitió realizar experimentos rápidamente y encontrar los mejores parámetros, dedicando unos minutos a esto.
- Para descubrir cómo varias partes de la aplicación cargan el procesador, hemos incorporado las herramientas de recopilación de datos del servicio de producción, sobre la base de qué diagramas de llama se construyeron.
- Utilizamos el paquete 0x porque las herramientas de Node.js eran fáciles de integrar en nuestro servicio y porque la visualización final de los datos HTML respaldaba la búsqueda y nos proporcionaba un buen nivel de detalle.
- Agregamos un modo de creación de perfiles al sistema cuando el trabajador comenzó con el paquete 0x activado y, al salir, grabó los datos finales en S3. Luego podríamos descargar los registros que necesitamos de S3 y verlos localmente usando un comando del formulario
0x --visualize-only ./flamegraph
. - Nosotros, en un cierto período de tiempo, comenzamos a crear perfiles para un solo trabajador. La creación de perfiles aumenta el consumo de recursos y reduce la productividad, por lo que nos gustaría limitar estos efectos negativos a un solo trabajador.
▍ Iniciar implementación
Después de completar la preparación preliminar, creamos un nuevo grupo de ECS para "trabajadores paralelos". Estos fueron los trabajadores que utilizaron las banderas LaunchDarkly para establecer dinámicamente su nivel máximo de paralelismo.
Nuestro plan de implementación del sistema incluyó una redirección por fases del creciente volumen de tráfico desde el clúster antiguo al nuevo. Durante esto, íbamos a monitorear de cerca el rendimiento del nuevo clúster. En cada nivel de carga, planeamos aumentar el nivel de paralelismo de cada trabajador, llevándolo al valor máximo en el que no hubo aumento en la duración de las tareas o el deterioro de otros indicadores. Si estuviéramos en problemas, podríamos, en unos segundos, redirigir dinámicamente el tráfico al clúster anterior.
Como era de esperar, nos encontramos con algunos problemas difíciles. Necesitábamos investigarlos y eliminarlos para garantizar el correcto funcionamiento del sistema actualizado. Aquí es donde comenzó la diversión.
Expandir, explorar, repetir
▍ Aumento del tamaño de almacenamiento dinámico máximo de Node.js
Cuando comenzamos a implementar el nuevo sistema, comenzamos a recibir notificaciones de finalización de tareas con un código de salida distinto de cero. Bueno, ¿qué puedo decir? Un comienzo prometedor. Luego enterramos en Kibana y encontramos el registro necesario:
FATAL ERROR: CALL_AND_RETRY_LAST Allocation failed - Javascript heap out of memory 1: node::Abort() 2: node::FatalException(v8::Isolate*, v8::Local, v8::Local) 3: v8::internal::V8::FatalProcessOutOfMemory(char const*, bool) 4: v8::internal::Factory::NewFixedArray(int, v8::internal::PretenureFlag)
Era una reminiscencia de los efectos de las pérdidas de memoria que ya habíamos encontrado cuando el proceso finalizó inesperadamente, dando un mensaje de error similar. Esto parecía bastante esperado: un aumento en el nivel de paralelismo conduce a un aumento en el nivel de uso de la memoria.
Sugerimos que aumentar el tamaño máximo del montón Node.js, que está configurado en 1.7 GB por defecto, puede ayudar a resolver este problema. Luego comenzamos a ejecutar Node.js, configurando el tamaño máximo de
--max-old-space-size=6144
dinámico en 6 GB (usando el indicador de línea de comando
--max-old-space-size=6144
). Este fue el mayor valor adecuado para nuestras instancias EC2. Para nuestro deleite, tal movimiento nos permitió hacer frente al error anterior que ocurre en la producción.
▍ Identificación del cuello de botella de memoria
Después de resolver el problema con la asignación de memoria, comenzamos a encontrar un rendimiento deficiente de las tareas en trabajadores paralelos. Al mismo tiempo, uno de los gráficos en el panel de control atrajo inmediatamente nuestra atención. Este fue un informe sobre cómo los procesos de trabajo en paralelo usan un grupo.
Uso del montónAlgunas de las curvas de este gráfico aumentaron continuamente, hasta que se convirtieron, al nivel del tamaño máximo de almacenamiento dinámico, en líneas casi horizontales. Realmente no nos gustó.
Utilizamos las métricas del sistema en Prometheus para eliminar fugas de un descriptor de archivo o socket de red de las causas de dicho comportamiento del sistema. Nuestra suposición más apropiada era que la recolección de basura no se realizaba para objetos viejos con la frecuencia suficiente. Esto podría llevar al hecho de que a medida que se procesan las tareas, el trabajador acumularía más y más memoria asignada para objetos ya innecesarios. Asumimos que el funcionamiento del sistema, durante el cual se degrada su rendimiento, se ve así:
- El trabajador recibe una nueva tarea y realiza ciertas acciones.
- En el proceso de ejecución de la tarea, se asigna memoria en el montón para objetos.
- Debido al hecho de que una determinada operación con la que trabajan según el principio de "hecho y olvidado" (entonces aún no estaba claro cuál) está incompleta, las referencias a los objetos se guardan incluso después de que se complete la tarea.
- La recolección de basura se ralentiza debido al hecho de que el V8 tiene que escanear un número creciente de objetos en el montón.
- Dado que V8 implementa un sistema de recolección de basura que funciona de acuerdo con el esquema de detener el mundo (detener el programa durante la recolección de basura), las nuevas tareas inevitablemente recibirán menos tiempo de procesador, lo que reduce el rendimiento del trabajador.
Comenzamos a buscar en nuestro código las operaciones que se realizan sobre la base del principio de "hecho y olvidado". También se les llama "promesas flotantes" ("promesa flotante"). Era simple: era suficiente para encontrar las líneas en las que se deshabilitaba la regla de interfaz sin
promesas flotantes . Un método atrajo nuestra atención. Hizo una llamada a
compressAndUploadDebuggingPayload
sin esperar los resultados. Parecía que esa llamada podría continuar fácilmente durante mucho tiempo incluso después de que se completara el procesamiento de la tarea.
const postTaskDebugging = async (data: TypedData) => { const payload = await generateDebuggingPayload(data);
Queríamos probar la hipótesis de que tales promesas flotantes eran la principal fuente de problemas. Si no cumple con estos desafíos, que no afectaron el correcto funcionamiento del sistema, ¿podemos mejorar la velocidad de las tareas? Así es como se veía la información de uso del montón después de que temporalmente nos
postTaskDebugging
llamadas
postTaskDebugging
a la
postTaskDebugging
.
Usar el montón después de deshabilitar postTaskDebuggingResultó! Ahora el nivel de utilización del montón en trabajadores paralelos se mantiene estable durante un largo período de tiempo.
Hubo una sensación de que en el sistema, a medida que se completaban las tareas, las "deudas" de
compressAndUploadDebuggingPayload
llamadas
compressAndUploadDebuggingPayload
cargar cargas de carga se acumulaban gradualmente. Si el trabajador recibió tareas más rápido de lo que pudo "pagar" estas "deudas", entonces los objetos bajo los cuales se asignó la memoria no estaban sujetos a operaciones de recolección de basura. Esto llevó a llenar el montón hasta la parte superior, que consideramos anteriormente, analizando el gráfico anterior.
Comenzamos a preguntarnos qué hizo que estas promesas flotantes fueran tan lentas. No queríamos eliminar por completo
compressAndUploadDebuggingPayload
del código, ya que esta llamada era extremadamente importante para que nuestros ingenieros pudieran depurar las tareas de producción en sus máquinas locales. Desde un punto de vista técnico, podríamos resolver el problema esperando los resultados de esta llamada y después de completar la tarea, deshaciéndonos de la promesa flotante. Pero esto aumentaría enormemente el tiempo de ejecución de cada tarea que estamos procesando.
Habiendo decidido que usaríamos una solución al problema solo como último recurso, comenzamos a pensar en optimizar el código. ¿Cómo acelerar esta operación?
▍Cuelgue el cuello de botella S3
La lógica de
compressAndUploadDebuggingPayload
fácil de entender. Aquí comprimimos los datos de depuración, y pueden ser bastante grandes, ya que incluyen el tráfico de red. Luego cargamos los datos comprimidos a S3.
export const compressAndUploadDebuggingPayload = async ( logger: Logger, data: any, ) => { const compressionStart = Date.now(); const base64CompressedData = await streamToString( bfj.streamify(data) .pipe(zlib.createDeflate()) .pipe(new b64.Encoder()), ); logger.trace('finished compressing data', { compression_time_ms: Date.now() - compressionStart, ); const uploadStart = Date.now(); s3Client.upload({ Body: base64CompressedData, Bucket: bucket, Key: key, }); logger.trace('finished uploading data', { upload_time_ms: Date.now() - uploadStart, ); }
De los registros de Kibana, quedó claro que descargar datos a S3, incluso si su volumen es pequeño, lleva mucho tiempo. Inicialmente no pensamos que los sockets pudieran convertirse en un cuello de botella en el sistema, ya que el agente HTTPS estándar de Node.js establece el parámetro
maxSockets en
Infinity
. Sin embargo, al final, leemos la documentación de AWS en Node.js y encontramos algo sorprendente para nosotros: el cliente S3 reduce el valor del parámetro
maxSockets
a
50
. No hace falta decir que este comportamiento no puede llamarse intuitivo.
Dado que llevamos al trabajador a un estado en el que, en modo competitivo, se realizaron más de 50 tareas, el paso de descarga se convirtió en un cuello de botella: preveía la espera del lanzamiento del socket para cargar datos en S3. Mejoramos el tiempo de carga de datos al realizar el siguiente cambio en el código de inicialización del cliente S3:
const s3Client = new AWS.S3({ httpOptions: { agent: new https.Agent({
▍ Acelerar la serialización JSON
Las mejoras del código S3 han ralentizado el crecimiento del tamaño de almacenamiento dinámico, pero no han llevado a una solución completa al problema. Hubo otra molestia obvia: según nuestras métricas, el paso de compresión de datos en el código anterior duró una vez 4 minutos. Fue mucho más largo que el tiempo habitual de finalización de la tarea, que es de 4 segundos. Sin creer lo que veíamos, sin entender cómo esto puede tomar 4 minutos, decidimos usar puntos de referencia locales y optimizar el bloqueo lento del código.
La compresión de datos consta de tres etapas (aquí, para limitar el uso de memoria, se utilizan
secuencias Node.js). Es decir, en la primera etapa, se generan datos de cadena JSON, en la segunda, los datos se comprimen usando zlib, en la tercera, se convierten a codificación base64. Sospechamos que la fuente de los problemas podría ser la biblioteca de terceros que utilizamos para generar cadenas JSON:
bfj . Escribimos un script que examina el rendimiento de diferentes bibliotecas para generar datos de cadena JSON utilizando flujos (el código correspondiente se puede encontrar
aquí ). Resultó que el paquete Big Friendly JSON que estábamos usando no era para nada amigable. Solo mire los resultados de un par de mediciones obtenidas durante el experimento:
benchBFJ*100: 67652.616ms benchJSONStream*100: 14094.825ms
Resultados asombrosos. Incluso en una prueba simple, el paquete bfj resultó ser 5 veces más lento que el otro paquete, JSONStream. Al descubrir esto, cambiamos rápidamente bfj a
JSONStream e inmediatamente vimos un aumento significativo en el rendimiento.
▍ Reducir el tiempo requerido para la recolección de basura
Después de resolver los problemas con la memoria, comenzamos a prestar atención a la diferencia de tiempo requerida para procesar tareas del mismo tipo entre trabajadores regulares y paralelos. Esta comparación fue completamente legítima, de acuerdo con sus resultados podríamos juzgar la efectividad del nuevo sistema. Entonces, si la proporción entre trabajadores regulares y paralelos fuera aproximadamente 1, esto nos daría la confianza de que podemos redirigir el tráfico de manera segura a estos trabajadores. Pero durante los primeros lanzamientos del sistema, el gráfico correspondiente en el panel de control de Grafana se parecía al que se muestra a continuación.
La relación del tiempo de ejecución de las tareas por parte de trabajadores convencionales y paralelosTenga en cuenta que a veces el indicador está en la región de 8: 1, y esto a pesar del hecho de que el nivel promedio de paralelización de tareas es relativamente bajo y está en la región de 30. Sabíamos que las tareas que estamos resolviendo con respecto a la interacción con los bancos no crean carga pesada en procesadores. También sabíamos que nuestros contenedores "paralelos" no estaban limitados de ninguna manera. Sin saber dónde buscar la causa del problema, fuimos a leer materiales sobre cómo optimizar los proyectos de Node.js. A pesar del pequeño número de tales artículos, encontramos
este material, que trata con el logro de 600 mil conexiones competitivas de socket web en Node.js.
En particular,
--nouse-idle-notification
nuestra atención sobre el uso del
--nouse-idle-notification
. ¿Pueden nuestros procesos Node.js pasar tanto tiempo recolectando basura? Aquí, por cierto, el paquete gc-stats nos dio la oportunidad de ver el tiempo promedio dedicado a la recolección de basura.
Análisis del tiempo dedicado a la recolección de basura.Había una sensación de que nuestros procesos pasaban aproximadamente el 30% del tiempo recolectando basura usando el algoritmo Scavenge. Aquí no vamos a describir los detalles técnicos con respecto a los diversos tipos de recolección de basura en Node.js. Si está interesado en este tema, eche un vistazo a
este material. La esencia del algoritmo Scavenge es que la recolección de basura a menudo se inicia para borrar la memoria ocupada por pequeños objetos en el montón de Node.js llamado "nuevo espacio".
Entonces, resultó que en nuestros procesos Node.js la recolección de basura comienza con demasiada frecuencia. ¿Puedo desactivar la recolección de basura V8 y ejecutarla yo mismo? ¿Hay alguna manera de
reducir la frecuencia de una llamada de recolección de basura? Resultó que lo primero de lo anterior no se puede hacer, pero lo último, ¡es posible! Simplemente podemos aumentar el tamaño del área de "espacio nuevo" al aumentar el límite del área de "espacio semi" en Node.js usando el indicador de línea de comando
--max-semi-space-size=1024
. Esto le permite realizar más operaciones de asignación de memoria para objetos de corta duración hasta que el V8 comience la recolección de basura. Como resultado, la frecuencia de lanzamiento de tales operaciones disminuye.
Resultados de optimización de recolección de basuraOtra victoria! El aumento en el área de "nuevo espacio" condujo a una reducción significativa en la cantidad de tiempo dedicado a la recolección de basura utilizando el algoritmo Scavenge, del 30% al 2%.
▍Optimizar la utilización del procesador
Después de realizar todo este trabajo, el resultado nos vino bien. Las tareas realizadas en trabajadores paralelos, con una paralelización del trabajo de 20 veces, funcionaban casi tan rápido como las que se realizaban por separado en trabajadores separados. Nos parecía que habíamos superado todos los cuellos de botella, pero aún no sabíamos exactamente qué operaciones ralentizaron el sistema en la producción. Como no había más lugares en el sistema que obviamente necesitaran optimización, decidimos estudiar cómo los trabajadores usan los recursos del procesador.
En base a los datos recopilados en uno de nuestros trabajadores paralelos, se creó un horario ardiente. Teníamos una visualización ordenada a nuestra disposición, con la que podíamos trabajar en la máquina local. Sí, aquí hay un detalle interesante: el tamaño de estos datos fue de 60 MB. Esto es lo que vimos al buscar el
logger
palabras en el ardiente gráfico 0x.
Análisis de datos con herramientas 0xLas áreas verde azuladas resaltadas en las columnas indican que al menos el 15% del tiempo del procesador se dedicó a generar el registro del trabajador. Como resultado, pudimos reducir este tiempo en un 75%. Es cierto que la historia de cómo lo hicimos se basa en un artículo separado. (Sugerencia: utilizamos expresiones regulares e hicimos mucho trabajo con las propiedades).
Después de esta optimización, pudimos procesar simultáneamente hasta 30 tareas en un trabajador sin dañar el rendimiento del sistema.
Resumen
El cambio a trabajadores paralelos ha reducido los costos anuales de EC2 en aproximadamente 300 mil dólares y ha simplificado enormemente la arquitectura del sistema. Ahora usamos en producción unos 30 veces menos contenedores que antes. Nuestro sistema es más resistente a los retrasos en el procesamiento de las solicitudes salientes y a las solicitudes API máximas que provienen de los usuarios.
Mientras paralelizamos nuestro servicio de integración con los bancos, aprendimos muchas cosas nuevas:
- Nunca subestimes la importancia de tener métricas de sistema de bajo nivel. La capacidad de monitorear los datos relacionados con la recolección de basura y el uso de la memoria nos ha brindado una ayuda tremenda para implementar el sistema y finalizarlo.
- Los gráficos en llamas son una gran herramienta. Ahora que hemos aprendido cómo usarlos, podemos identificar fácilmente nuevos cuellos de botella en el sistema con su ayuda.
- Comprender los mecanismos de tiempo de ejecución de Node.js nos permitió escribir un código mejor. Por ejemplo, sabiendo cómo V8 asigna memoria para los objetos y cómo funciona la recolección de basura, vimos el punto de usar la técnica de reutilización de objetos lo más ampliamente posible. A veces, para comprender mejor todo esto, debe trabajar directamente con V8 o experimentar con los indicadores de línea de comando de Node.js.
- , .
maxSocket
, Node.js, , , , AWS Node.js . , , , .
Estimados lectores! Node.js-?
