Depuración de fugas de memoria en aplicaciones Node.js

Publicado: 2022-03-11

Una vez conduje un Audi con un motor V8 biturbo en su interior y su desempeño fue increíble. Conducía a alrededor de 140 MPH en la autopista IL-80 cerca de Chicago a las 3 a. m. cuando no había nadie en la carretera. Desde entonces, el término "V8" se ha asociado con un alto rendimiento para mí.

Node.js es una plataforma construida sobre el motor JavaScript V8 de Chrome para crear fácilmente aplicaciones de red rápidas y escalables.

Aunque el V8 de Audi es muy poderoso, todavía está limitado con la capacidad de su tanque de gasolina. Lo mismo ocurre con el V8 de Google, el motor de JavaScript detrás de Node.js. Su rendimiento es increíble y hay muchas razones por las que Node.js funciona bien para muchos casos de uso, pero siempre está limitado por el tamaño del almacenamiento dinámico. Cuando necesite procesar más solicitudes en su aplicación Node.js, tiene dos opciones: escalar verticalmente o escalar horizontalmente. El escalado horizontal significa que debe ejecutar más instancias de aplicaciones simultáneas. Cuando se hace bien, termina siendo capaz de atender más solicitudes. El escalado vertical significa que debe mejorar el uso y el rendimiento de la memoria de su aplicación o aumentar los recursos disponibles para la instancia de su aplicación.

Depuración de fugas de memoria en aplicaciones Node.js

Depuración de fugas de memoria en aplicaciones Node.js
Pío

Recientemente me pidieron que trabajara en una aplicación Node.js para uno de mis clientes de Toptal para solucionar un problema de pérdida de memoria. La aplicación, un servidor API, estaba destinada a poder procesar cientos de miles de solicitudes cada minuto. La aplicación original ocupaba casi 600 MB de RAM y, por lo tanto, decidimos tomar los puntos finales de la API activa y volver a implementarlos. Los gastos generales se vuelven muy costosos cuando necesita atender muchas solicitudes.

Para la nueva API, elegimos restaurar con el controlador MongoDB nativo y Kue para trabajos en segundo plano. Suena como una pila muy ligera, ¿verdad? No exactamente. Durante la carga máxima, una nueva instancia de aplicación podría consumir hasta 270 MB de RAM. Por lo tanto, mi sueño de tener dos instancias de aplicación por 1X Heroku Dyno se desvaneció.

Arsenal de depuración de fugas de memoria de Node.js

Memwatch

Si busca "cómo encontrar una fuga en el nodo", la primera herramienta que probablemente encontrará es memwatch . El paquete original fue abandonado hace mucho tiempo y ya no se mantiene. Sin embargo, puede encontrar fácilmente versiones más nuevas en la lista de bifurcaciones de GitHub para el repositorio. Este módulo es útil porque puede emitir eventos de fuga si ve que el montón crece durante 5 recolecciones de basura consecutivas.

volcado de pila

Gran herramienta que permite a los desarrolladores de Node.js tomar instantáneas del montón e inspeccionarlas más tarde con las herramientas para desarrolladores de Chrome.

inspector de nodos

Incluso una alternativa más útil al volcado de almacenamiento dinámico, porque le permite conectarse a una aplicación en ejecución, realizar un volcado de almacenamiento dinámico e incluso depurarlo y volver a compilarlo sobre la marcha.

Tomando el "inspector de nodos" para un giro

Desafortunadamente, no podrá conectarse a las aplicaciones de producción que se ejecutan en Heroku, ya que no permite que se envíen señales a los procesos en ejecución. Sin embargo, Heroku no es la única plataforma de alojamiento.

Para experimentar el inspector de nodos en acción, escribiremos una aplicación Node.js simple usando restify y pondremos una pequeña fuente de pérdida de memoria dentro de ella. Todos los experimentos aquí se realizan con Node.js v0.12.7, que se compiló contra V8 v3.28.71.19.

 var restify = require('restify'); var server = restify.createServer(); var tasks = []; server.pre(function(req, res, next) { tasks.push(function() { return req.headers; }); // Synchronously get user from session, maybe jwt token req.user = { id: 1, username: 'Leaky Master', }; return next(); }); server.get('/', function(req, res, next) { res.send('Hi ' + req.user.username); return next(); }); server.listen(3000, function() { console.log('%s listening at %s', server.name, server.url); });

La aplicación aquí es muy simple y tiene una fuga muy obvia. Las tareas de la matriz crecerían a lo largo de la vida útil de la aplicación, lo que provocaría una ralentización y, finalmente, un bloqueo. El problema es que no solo estamos filtrando el cierre, sino también objetos de solicitud completos.

GC en V8 emplea una estrategia de detener el mundo, por lo tanto, significa que cuantos más objetos tenga en la memoria, más tiempo llevará recolectar basura. En el registro a continuación, puede ver claramente que al comienzo de la vida útil de la aplicación, tomaría un promedio de 20 ms recolectar la basura, pero unos cientos de miles de solicitudes más tarde demoran alrededor de 230 ms. Las personas que intentan acceder a nuestra aplicación ahora tendrían que esperar 230 ms debido a GC. También puede ver que GC se invoca cada pocos segundos, lo que significa que cada pocos segundos los usuarios experimentarán problemas para acceder a nuestra aplicación. Y la demora crecerá hasta que la aplicación se bloquee.

 [28093] 7644 ms: Mark-sweep 10.9 (48.5) -> 10.9 (48.5) MB, 25.0 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. [28093] 7717 ms: Mark-sweep 10.9 (48.5) -> 10.9 (48.5) MB, 18.0 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. [28093] 7866 ms: Mark-sweep 11.0 (48.5) -> 10.9 (48.5) MB, 23.2 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. [28093] 8001 ms: Mark-sweep 11.0 (48.5) -> 10.9 (48.5) MB, 18.4 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. ... [28093] 633891 ms: Mark-sweep 235.7 (290.5) -> 235.7 (290.5) MB, 357.3 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. [28093] 635672 ms: Mark-sweep 235.7 (290.5) -> 235.7 (290.5) MB, 331.5 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested]. [28093] 637508 ms: Mark-sweep 235.7 (290.5) -> 235.7 (290.5) MB, 357.2 ms [HeapObjectsMap::UpdateHeapObjectsMap] [GC in old space requested].

Estas líneas de registro se imprimen cuando se inicia una aplicación Node.js con el indicador –trace_gc :

 node --trace_gc app.js

Supongamos que ya hemos iniciado nuestra aplicación Node.js con esta bandera. Antes de conectar la aplicación con el inspector de nodos, debemos enviarle la señal SIGUSR1 al proceso en ejecución. Si ejecuta Node.js en el clúster, asegúrese de conectarse a uno de los procesos esclavos.

 kill -SIGUSR1 $pid # Replace $pid with the actual process ID

Al hacer esto, estamos haciendo que la aplicación Node.js (V8 para ser precisos) entre en modo de depuración. En este modo, la aplicación abre automáticamente el puerto 5858 con el protocolo de depuración V8.

Nuestro siguiente paso es ejecutar el inspector de nodos, que se conectará a la interfaz de depuración de la aplicación en ejecución y abrirá otra interfaz web en el puerto 8080.

 $ node-inspector Node Inspector v0.12.2 Visit http://127.0.0.1:8080/?ws=127.0.0.1:8080&port=5858 to start debugging.

En caso de que la aplicación se esté ejecutando en producción y tenga un firewall, podemos hacer un túnel del puerto remoto 8080 a localhost:

 ssh -L 8080:localhost:8080 [email protected]

Ahora puede abrir su navegador web Chrome y obtener acceso completo a las herramientas de desarrollo de Chrome adjuntas a su aplicación de producción remota. Desafortunadamente, Chrome Developer Tools no funcionará en otros navegadores.

¡Encontremos una fuga!

Las fugas de memoria en V8 no son fugas de memoria reales como las conocemos por las aplicaciones C/C++. En JavaScript, las variables no desaparecen en el vacío, simplemente se “olvidan”. Nuestro objetivo es encontrar estas variables olvidadas y recordarles que Dobby es gratis.

Dentro de Chrome Developer Tools tenemos acceso a múltiples perfiladores. Estamos particularmente interesados ​​en las asignaciones de almacenamiento dinámico de registros , que se ejecutan y toman varias instantáneas de almacenamiento dinámico a lo largo del tiempo. Esto nos da una idea clara de los objetos que se están filtrando.

Comience a registrar asignaciones de montón y simulemos 50 usuarios simultáneos en nuestra página de inicio usando Apache Benchmark.

Captura de pantalla

 ab -c 50 -n 1000000 -k http://example.com/

Antes de tomar nuevas instantáneas, V8 realizaría una recolección de basura de barrido de marcas, por lo que definitivamente sabemos que no hay basura antigua en la instantánea.

Reparar la fuga sobre la marcha

Después de recopilar instantáneas de asignación de montón durante un período de 3 minutos , terminamos con algo como lo siguiente:

Captura de pantalla

Podemos ver claramente que hay algunas matrices gigantes, muchos objetos IncomingMessage, ReadableState, ServerResponse y Domain también en el montón. Tratemos de analizar el origen de la fuga.

Al seleccionar la diferencia de pila en el gráfico de 20 a 40, solo veremos los objetos que se agregaron después de 20 desde que inició el generador de perfiles. De esta manera podría excluir todos los datos normales.

Tomando nota de cuántos objetos de cada tipo hay en el sistema, ampliamos el filtro de 20 s a 1 min. Podemos ver que las matrices, ya bastante gigantescas, siguen creciendo. Debajo de "(matriz)" podemos ver que hay muchos objetos "(propiedades del objeto)" con la misma distancia. Esos objetos son la fuente de nuestra pérdida de memoria.

También podemos ver que los objetos "(cierre)" también crecen rápidamente.

También podría ser útil mirar las cuerdas. Debajo de la lista de cadenas hay muchas frases de "Hola, Leaky Master". Esos podrían darnos alguna pista también.

En nuestro caso sabemos que la cadena ”Hi Leaky Master” solo se pudo ensamblar bajo la ruta “GET /”.

Si abre la ruta de los retenedores, verá que se hace referencia a esta cadena de alguna manera a través de req , luego se crea un contexto y todo esto se agrega a una gran variedad de cierres.

Captura de pantalla

Entonces, en este punto, sabemos que tenemos una especie de variedad gigantesca de cierres. De hecho, vayamos y demos un nombre a todos nuestros cierres en tiempo real en la pestaña de fuentes.

Captura de pantalla

Una vez que hayamos terminado de editar el código, podemos presionar CTRL+S para guardar y volver a compilar el código sobre la marcha.

Ahora grabemos otra instantánea de asignaciones de montón y veamos qué cierres están ocupando la memoria.

Está claro que SomeKindOfClojure() es nuestro villano. Ahora podemos ver que los cierres SomeKindOfClojure() se están agregando a algunas tareas con nombre de matriz en el espacio global.

Es fácil ver que esta matriz es simplemente inútil. Podemos comentarlo. Pero, ¿cómo liberamos de memoria la memoria ya ocupada? Muy fácil, solo asignamos una matriz vacía a las tareas y con la siguiente solicitud se anulará y la memoria se liberará después del próximo evento de GC.

Captura de pantalla

¡Dobby es libre!

La vida de la basura en V8

Bueno, V8 JS no tiene pérdidas de memoria, solo variables olvidadas.

Bueno, V8 JS no tiene pérdidas de memoria, solo variables olvidadas.
Pío

El montón V8 se divide en varios espacios diferentes:

  • Nuevo Espacio : Este espacio es relativamente pequeño y tiene un tamaño de entre 1MB y 8MB. La mayoría de los objetos se asignan aquí.
  • Old Pointer Space : tiene objetos que pueden tener punteros a otros objetos. Si el objeto sobrevive lo suficiente en New Space, se promociona a Old Pointer Space.
  • Espacio de datos antiguo : contiene solo datos sin procesar como cadenas, números en caja y matrices de dobles sin caja. Los objetos que han sobrevivido a GC en el Nuevo Espacio durante el tiempo suficiente también se mueven aquí.
  • Espacio de objetos grandes : los objetos que son demasiado grandes para caber en otros espacios se crean en este espacio. Cada objeto tiene su propia región mmap 'ed en la memoria
  • Espacio de código : contiene el código ensamblador generado por el compilador JIT.
  • Espacio de celda, espacio de celda de propiedad, espacio de mapa : este espacio contiene Cell s, PropertyCell s y Map s. Esto se utiliza para simplificar la recolección de basura.

Cada espacio se compone de páginas. Una página es una región de memoria asignada desde el sistema operativo con mmap. Cada página siempre tiene un tamaño de 1 MB, excepto las páginas en el espacio de objetos grandes.

V8 tiene dos mecanismos integrados de recolección de basura: Scavenge, Mark-Sweep y Mark-Compact.

Scavenge es una técnica de recolección de basura muy rápida y opera con objetos en New Space . Scavenge es la implementación del Algoritmo de Cheney. La idea es muy simple, New Space se divide en dos semi-espacios iguales: To-Space y From-Space. Scavenge GC ocurre cuando To-Space está lleno. Simplemente intercambia espacios de origen y destino y copia todos los objetos activos en el espacio de destino o los promueve a uno de los espacios antiguos si sobrevivieron a dos robos, y luego se borra por completo del espacio. Los barridos son muy rápidos, sin embargo, tienen la sobrecarga de mantener un montón de tamaño doble y copiar constantemente objetos en la memoria. La razón para usar carroñeros es que la mayoría de los objetos mueren jóvenes.

Mark-Sweep & Mark-Compact es otro tipo de recolector de basura que se usa en V8. El otro nombre es recolector de basura completo. Marca todos los nodos vivos, luego barre todos los nodos muertos y desfragmenta la memoria.

Sugerencias de rendimiento y depuración de GC

Si bien para las aplicaciones web, el alto rendimiento puede no ser un problema tan grande, aún querrá evitar las fugas a toda costa. Durante la fase de marcado en GC completo, la aplicación se detiene hasta que se completa la recolección de elementos no utilizados. Esto significa que cuantos más objetos tenga en el montón, más tiempo llevará realizar la GC y más tiempo tendrán que esperar los usuarios.

Siempre dé nombres a los cierres y funciones

Es mucho más fácil inspeccionar montones y rastros de pila cuando todos sus cierres y funciones tienen nombres.

 db.query('GIVE THEM ALL', function GiveThemAllAName(error, data) { ... })

Evite objetos grandes en funciones calientes

Idealmente, desea evitar objetos grandes dentro de las funciones activas para que todos los datos quepan en New Space . Todas las operaciones vinculadas a la CPU y la memoria deben ejecutarse en segundo plano. Evite también los disparadores de desoptimización para funciones activas, la función activa optimizada usa menos memoria que las no optimizadas.

Las funciones calientes deben optimizarse

Las funciones activas que se ejecutan más rápido pero también consumen menos memoria hacen que GC se ejecute con menos frecuencia. V8 proporciona algunas herramientas de depuración útiles para detectar funciones no optimizadas o funciones desoptimizadas.

Evite el polimorfismo para IC en funciones activas

Los cachés en línea (IC) se utilizan para acelerar la ejecución de algunos fragmentos de código, ya sea almacenando en caché el acceso a la propiedad del objeto obj.key o alguna función simple.

 function x(a, b) { return a + b; } x(1, 2); // monomorphic x(1, “string”); // polymorphic, level 2 x(3.14, 1); // polymorphic, level 3

Cuando x(a,b) se ejecuta por primera vez, V8 crea un IC monomórfico. Cuando llama a x por segunda vez, V8 borra el IC anterior y crea un nuevo IC polimórfico que admite ambos tipos de operandos, enteros y cadenas. Cuando llama a IC por tercera vez, V8 repite el mismo procedimiento y crea otro IC polimórfico de nivel 3.

Sin embargo, hay una limitación. Después de que el nivel de IC llega a 5 (podría cambiarse con el indicador –max_inlining_levels ), la función se vuelve megamórfica y ya no se considera optimizable.

Es intuitivamente comprensible que las funciones monomórficas se ejecuten más rápido y también tengan una huella de memoria más pequeña.

No agregue archivos grandes a la memoria

Este es obvio y bien conocido. Si tiene archivos grandes para procesar, por ejemplo, un archivo CSV grande, léalo línea por línea y procéselo en pequeños fragmentos en lugar de cargar todo el archivo en la memoria. Hay casos bastante raros en los que una sola línea de csv sería más grande que 1 mb, lo que le permitiría colocarla en New Space .

No bloquee el hilo del servidor principal

Si tiene alguna API activa que tarda algún tiempo en procesarse, como una API para cambiar el tamaño de las imágenes, muévala a un hilo separado o conviértala en un trabajo en segundo plano. Las operaciones intensivas de la CPU bloquearían el hilo principal obligando a todos los demás clientes a esperar y seguir enviando solicitudes. Los datos de solicitud sin procesar se acumularían en la memoria, lo que obligaría a que el GC completo tarde más tiempo en finalizar.

No crees datos innecesarios

Una vez tuve una experiencia extraña con Restify. Si envía unos cientos de miles de solicitudes a una URL no válida, la memoria de la aplicación crecerá rápidamente hasta cien megabytes hasta que se active un GC completo unos segundos más tarde, que es cuando todo volverá a la normalidad. Resulta que para cada URL no válida, restify genera un nuevo objeto de error que incluye rastros de pila largos. Esto obligó a que los objetos recién creados se asignaran en el Espacio de objetos grandes en lugar de en el Espacio nuevo .

Tener acceso a dichos datos podría ser muy útil durante el desarrollo, pero obviamente no es necesario en la producción. Por lo tanto, la regla es simple: no genere datos a menos que ciertamente los necesite.

Conoce tus herramientas

Por último, pero ciertamente no menos importante, es conocer sus herramientas. Hay varios depuradores, detectores de fugas y generadores de gráficos de uso. Todas esas herramientas pueden ayudarlo a hacer que su software sea más rápido y eficiente.

Conclusión

Comprender cómo funciona la recolección de basura y el optimizador de código de V8 es clave para el rendimiento de la aplicación. V8 compila JavaScript para ensamblaje nativo y, en algunos casos, un código bien escrito podría lograr un rendimiento comparable con las aplicaciones compiladas por GCC.

Y por si te lo estás preguntando, la nueva aplicación API para mi cliente Toptal, aunque tiene margen de mejora, ¡está funcionando muy bien!

Joyent lanzó recientemente una nueva versión de Node.js que utiliza una de las últimas versiones de V8. Algunas aplicaciones escritas para Node.js v0.12.x pueden no ser compatibles con la nueva versión v4.x. Sin embargo, las aplicaciones experimentarán una gran mejora en el rendimiento y el uso de la memoria dentro de la nueva versión de Node.js.