Una guía práctica para lidiar con pérdidas de memoria en Node.js

Las pérdidas de memoria son similares a las entidades parásitas en una aplicación. Penetran silenciosamente en el sistema, al principio sin causar ningún daño. Pero si la fuga resulta ser lo suficientemente fuerte, puede llevar la aplicación al desastre. Por ejemplo, para reducir la velocidad fuertemente o simplemente para "matarlo". El autor del artículo, cuya traducción publicamos hoy, sugiere hablar sobre pérdidas de memoria en JavaScript. En particular, hablaremos sobre la administración de memoria en JavaScript, cómo identificar pérdidas de memoria en aplicaciones reales y cómo lidiar con pérdidas de memoria.





¿Qué es una pérdida de memoria?


Una pérdida de memoria es, en un sentido amplio, una pieza de memoria asignada a una aplicación que esta aplicación ya no necesita, pero que no puede devolverse al sistema operativo para su uso futuro. En otras palabras, es un bloque de memoria que es capturado por la aplicación sin la intención de usar esta memoria en el futuro.

Gestión de la memoria


La administración de memoria es un mecanismo para asignar memoria del sistema a una aplicación que la necesita, y un mecanismo para devolver memoria innecesaria al sistema operativo. Hay muchos enfoques para la gestión de la memoria. El enfoque utilizado depende del lenguaje de programación utilizado. Aquí hay una descripción general de varios enfoques comunes para la administración de memoria:

  • . . . , . C C++. , , malloc free, .
  • . , , , . , , , . , , , , . . — JavaScript, , JVM (Java, Scala, Kotlin), Golang, Python, Ruby .
  • Aplicación del concepto de propiedad de la memoria. Con este enfoque, cada variable debe tener su propio propietario. Tan pronto como el propietario está fuera del alcance, el valor en la variable se destruye, liberando memoria. Esta idea se usa en Rust.

Existen otros enfoques para la administración de memoria utilizados en diferentes lenguajes de programación. Por ejemplo, C ++ 11 usa el lenguaje RAII , mientras que Swift usa el mecanismo ARC . Pero hablar de eso está más allá del alcance de este artículo. Para comparar los métodos anteriores de administración de memoria, para comprender sus ventajas y desventajas, necesitamos un artículo separado.

JavaScript, un lenguaje sin el cual los programadores web no pueden imaginar su trabajo, utiliza la idea de recolección de basura. Por lo tanto, hablaremos más sobre cómo funciona este mecanismo.

Recolección de basura JavaScript


Como ya se mencionó, JavaScript es un lenguaje que utiliza el concepto de recolección de basura. Durante la operación de los programas JS, se lanza periódicamente un mecanismo llamado recolector de basura. Él descubre a qué partes de la memoria asignada se puede acceder desde el código de la aplicación. Es decir, a qué variables se hace referencia. Si el recolector de basura descubre que ya no se accede a un trozo de memoria desde el código de la aplicación, libera esta memoria. El enfoque anterior se puede implementar utilizando dos algoritmos principales. El primero es el llamado algoritmo Mark and Sweep. Se usa en JavaScript. El segundo es el recuento de referencias. Se usa en Python y PHP.


Fases Mark (marcar) y Sweep (limpiar) del

algoritmo Mark and Sweep Al implementar el algoritmo de marcado,windowprimero se creauna lista de nodos raíz representados por variables de entorno globales (este es un objeto en el navegador), y luego el árbol resultante se rastrea de los nodos raíz a hoja marcados con todos En el camino se encontraron objetos. Se libera memoria en el montón que está ocupado por objetos sin etiquetar.

Pérdidas de memoria en aplicaciones Node.js


Hasta la fecha, hemos analizado suficientes conceptos teóricos relacionados con fugas de memoria y recolección de basura. Entonces, estamos listos para ver cómo se ve todo en aplicaciones reales. En esta sección, escribiremos un servidor Node.js que tenga una pérdida de memoria. Intentaremos identificar esta fuga utilizando varias herramientas y luego la eliminaremos.

▍ Familiaridad con un código que tiene una pérdida de memoria


Para fines de demostración, escribí un servidor Express que tiene una ruta de pérdida de memoria. Vamos a depurar este servidor.

const express = require('express')

const app = express();
const port = 3000;

const leaks = [];

app.get('/bloatMyServer', (req, res) => {
  const redundantObj = {
    memory: "leaked",
    joke: "meta"
  };

  [...Array(10000)].map(i => leaks.push(redundantObj));

  res.status(200).send({size: leaks.length})
});

app.listen(port, () => console.log(`Example app listening on port ${port}!`));

Hay una matriz leaksque está fuera del alcance del código de procesamiento de solicitud de API. Como resultado, cada vez que se ejecuta el código correspondiente, simplemente se agregan nuevos elementos a la matriz. La matriz nunca se borra. Dado que el enlace a esta matriz no desaparece después de salir del controlador de solicitudes, el recolector de basura nunca libera la memoria que utiliza.

▍Pérdida de memoria de llamada


Aquí llegamos a lo más interesante. Se han escrito muchos artículos sobre cómo, utilizando node --inspect, para depurar fugas de memoria del servidor, después de llenar el servidor con solicitudes utilizando algo como artillería . Pero este enfoque tiene un inconveniente importante. Imagine que tiene un servidor API que tiene miles de puntos finales. Cada uno de ellos toma muchos parámetros, el código particular que se llamará depende de las características de los cuales. Como resultado, en condiciones reales, si el desarrollador no sabe dónde se encuentra la pérdida de memoria, tendrá que acceder a cada API muchas veces utilizando todas las combinaciones posibles de parámetros para llenar la memoria. En cuanto a mí, no es fácil hacerlo. La solución a este problema, sin embargo, se facilita mediante el uso de algo comogoreplay : un sistema que le permite grabar y "reproducir" el tráfico real.

Para hacer frente a nuestro problema, vamos a depurar en producción. Es decir, permitiremos que nuestro servidor desborde memoria durante su uso real (ya que recibe una variedad de solicitudes de API). Y después de encontrar un aumento sospechoso en la cantidad de memoria asignada, realizaremos la depuración.

▍ Descarga del montón


Para comprender qué es un volcado de almacenamiento dinámico, primero necesitamos descubrir el significado del concepto de almacenamiento dinámico. Si describe este concepto de la manera más simple posible, resulta que el montón es el lugar donde cae todo lo que se asigna a la memoria. Todo esto está en el montón hasta que el recolector de basura elimine todo lo que se considere innecesario. Un volcado de almacenamiento dinámico es una instantánea del estado actual del almacenamiento dinámico. El volcado contiene todas las variables internas y variables declaradas por el programador. Representa toda la memoria asignada en el montón en el momento en que se recibió el volcado.

Como resultado, si pudiéramos comparar de alguna manera el volcado del montón del servidor que acaba de comenzar con el volcado del montón del servidor, que se ha estado ejecutando durante mucho tiempo y desbordando memoria, podríamos identificar objetos sospechosos que la aplicación no necesita, pero que el recolector de basura no elimina.

Antes de continuar la conversación, hablemos sobre cómo crear volcados de almacenamiento dinámico. Para resolver este problema, utilizaremos el paquete de almacenamiento dinámico npm , que le permite obtener mediante programación un volcado del almacenamiento dinámico del servidor.

Instala el paquete:

npm i heapdump

Haremos algunos cambios en el código del servidor que nos permitirán usar este paquete:

const express = require('express');
const heapdump = require("heapdump");

const app = express();
const port = 3000;

const leaks = [];

app.get('/bloatMyServer', (req, res) => {
  const redundantObj = {
    memory: "leaked",
    joke: "meta"
  };

  [...Array(10000)].map(i => leaks.push(redundantObj));

  res.status(200).send({size: leaks.length})
});

app.get('/heapdump', (req, res) => {
  heapdump.writeSnapshot(`heapDump-${Date.now()}.heapsnapshot`, (err, filename) => {
    console.log("Heap dump of a bloated server written to", filename);

    res.status(200).send({msg: "successfully took a heap dump"})
  });
});

app.listen(port, () => {
  heapdump.writeSnapshot(`heapDumpAtServerStart.heapsnapshot`, (err, filename) => {
    console.log("Heap dump of a fresh server written to", filename);
  });
});

Aquí usamos este paquete para volcar un servidor recién lanzado. También creamos una API /heapdumpdiseñada para crear un montón al acceder a ella. Pasaremos a esta API en el momento en que nos demos cuenta de que el servidor comenzó a consumir demasiada memoria.

Si su servidor se está ejecutando en un clúster de Kubernetes, no podrá, sin esfuerzos adicionales, recurrir a ese mismo pod cuyo servidor se está ejecutando y que consume demasiada memoria. Para hacer esto, puede usar el reenvío de puertos . Además, dado que no tendrá acceso al sistema de archivos que necesita para descargar los archivos de volcado, sería mejor cargar estos archivos en el almacenamiento externo en la nube (como S3).

▍ Detección de pérdida de memoria


Y ahora, el servidor está implementado. Él ha estado trabajando por varios días. Recibe muchas solicitudes (en nuestro caso, solo solicitudes del mismo tipo) y prestamos atención al aumento en la cantidad de memoria consumida por el servidor. Se puede detectar una pérdida de memoria utilizando herramientas de monitoreo como Express Status Monitor , Clinic , Prometheus . Después de eso, llamamos a la API para volcar el montón. Este volcado contendrá todos los objetos que el recolector de basura no pudo eliminar.

Así es como se ve la consulta para crear un volcado:

curl --location --request GET 'http://localhost:3000/heapdump'

Cuando se crea un volcado de montón, el recolector de basura se ve obligado a ejecutarse. Como resultado, no necesitamos preocuparnos por aquellos objetos que el recolector de basura puede eliminar en el futuro, pero que todavía están en el montón. Es decir, sobre los objetos cuando se trabaja con los que no se producen pérdidas de memoria.

Después de tener ambos volcados a nuestra disposición (un volcado de un servidor recién lanzado y un volcado de un servidor que ha funcionado durante algún tiempo), podemos comenzar a compararlos.

Obtener un volcado de memoria es una operación de bloqueo que requiere mucha memoria para completarse. Por lo tanto, debe llevarse a cabo con precaución. Puede leer más sobre los posibles problemas encontrados durante esta operación aquí .

Inicie Chrome y presione la tecla.F12. Esto conducirá al descubrimiento de herramientas para desarrolladores. Aquí debe ir a la pestaña Memoryy cargar ambas instantáneas de memoria.


Descarga de la memoria vuelca en la pestaña Memoria de las herramientas para desarrolladores de Chrome

Después de descargar los dos instantáneas, es necesario el cambioperspectiveaComparisony haga clic en la instantánea de la memoria del servidor que trabajó durante algún tiempo.


Comience a comparar instantáneas

Aquí podemos analizar la columnaConstructory buscar objetos que el recolector de basura no pueda eliminar. La mayoría de estos objetos estarán representados por enlaces internos que usan los nodos. Aquí es útil usar un truco, que consiste en ordenar la lista por campoAlloc. Size. Esto encontrará rápidamente los objetos que usan más memoria. Si expande el bloque(array)y luego(object elements), puede ver una matriz queleakscontiene una gran cantidad de objetos que no se pueden eliminar con el recolector de basura.


Análisis de una matriz sospechosa

Esta técnica nos permitirá ir a la matrizleaksy comprender que es la operación incorrecta con ella la que causa una pérdida de memoria.

▍Fija la pérdida de memoria


Ahora que sabemos que el "culpable" es una matriz leaks, podemos analizar el código y descubrir que el problema es que la matriz se declara fuera del controlador de solicitudes. Como resultado, resulta que el enlace a él nunca se elimina. Para solucionar este problema es bastante simple: simplemente transfiera la declaración de la matriz al controlador:

app.get('/bloatMyServer', (req, res) => {
  const redundantObj = {
    memory: "leaked",
    joke: "meta"
  };

  const leaks = [];

  [...Array(10000)].map(i => leaks.push(redundantObj));

  res.status(200).send({size: leaks.length})
});

Para verificar la efectividad de las medidas tomadas, es suficiente repetir los pasos anteriores y volver a comparar las imágenes del montón.

Resumen


Las pérdidas de memoria ocurren en diferentes idiomas. En particular, en aquellos que usan mecanismos de recolección de basura. Por ejemplo, en JavaScript. Por lo general, no es difícil solucionar una fuga; las verdaderas dificultades surgen solo cuando la busca.

En este artículo, se familiarizó con los conceptos básicos de la administración de memoria y cómo se organiza la administración de memoria en diferentes idiomas. Aquí reproducimos un escenario real de una pérdida de memoria y describimos un método para la resolución de problemas.

¡Queridos lectores! ¿Ha encontrado pérdidas de memoria en sus proyectos web?


All Articles