Al ejecutar aplicaciones Node.js en contenedores Docker, la configuración de memoria tradicional no siempre funciona como se esperaba. El material, cuya traducción publicamos hoy, está dedicado a encontrar la respuesta a la pregunta de por qué es así. También proporcionará recomendaciones prácticas para administrar la memoria disponible para las aplicaciones Node.js que se ejecutan en contenedores.

Revisión de recomendaciones
Supongamos que una aplicación Node.js se ejecuta en un contenedor con un límite de memoria establecido. Si estamos hablando de Docker, entonces la opción
--memory
podría usarse para establecer este límite. Algo similar es posible cuando se trabaja con sistemas de orquestación de contenedores. En este caso, se recomienda que al iniciar la aplicación Node.js, use la
--max-old-space-size
. Esto le permite informar a la plataforma sobre la cantidad de memoria disponible y también tener en cuenta el hecho de que esta cantidad debe ser inferior al límite establecido a nivel de contenedor.
Cuando la aplicación Node.js se ejecuta dentro del contenedor, configure la capacidad de la memoria disponible de acuerdo con el valor máximo del uso de memoria activa por parte de la aplicación. Esto se hace si se pueden configurar los límites de memoria del contenedor.
Ahora hablemos sobre el problema de usar memoria en contenedores con más detalle.
Docker Memory Limit
Por defecto, los contenedores no tienen límites de recursos y pueden usar tanta memoria como el sistema operativo les permita. El comando
docker run
tiene opciones de línea de comandos que le permiten establecer límites con respecto al uso de la memoria o los recursos del procesador.
El comando de lanzamiento del contenedor podría verse así:
docker run --memory <x><y> --interactive --tty <imagename> bash
Tenga en cuenta lo siguiente:
x
es el límite de la cantidad de memoria disponible para el contenedor, expresado en unidades de y
.y
puede tomar el valor b
(bytes), k
(kilobytes), m
(megabytes), g
(gigabytes).
Aquí hay un ejemplo de un comando de inicio de contenedor:
docker run --memory 1000000b --interactive --tty <imagename> bash
Aquí, el límite de memoria se establece en
1000000
bytes.
Para verificar el límite de memoria establecido en el nivel del contenedor, puede, en el contenedor, ejecutar el siguiente comando:
cat /sys/fs/cgroup/memory/memory.limit_in_bytes
Hablemos sobre el comportamiento del sistema al especificar el límite de memoria de la aplicación Node.js usando la
--max-old-space-size
. En este caso, este límite de memoria corresponderá al límite establecido en el nivel del contenedor.
Lo que se llama "espacio antiguo" en el nombre de la clave es uno de los fragmentos del montón controlado por V8 (el lugar donde se colocan los objetos JavaScript "antiguos"). Esta tecla, si no entra en los detalles que tocamos a continuación, controla el tamaño máximo de almacenamiento dinámico. Los detalles sobre los modificadores de línea de comandos de Node.js se pueden encontrar
aquí .
En general, cuando una aplicación intenta usar más memoria de la que está disponible en el contenedor, su operación finaliza.
En el siguiente ejemplo (el archivo de la aplicación se llama
test-fatal-error.js
), los objetos
MyRecord
se colocan en la matriz de la
list
, con un intervalo de 10 milisegundos. Esto conduce a un crecimiento incontrolado del montón, simulando una pérdida de memoria.
'use strict'; const list = []; setInterval(()=> { const record = new MyRecord(); list.push(record); },10); function MyRecord() { var x='hii'; this.name = x.repeat(10000000); this.id = x.repeat(10000000); this.account = x.repeat(10000000); } setInterval(()=> { console.log(process.memoryUsage()) },100);
Tenga en cuenta que todos los ejemplos de programas que discutiremos aquí se colocan en la imagen de Docker, que se puede descargar desde Docker Hub:
docker pull ravali1906/dockermemory
Puede usar esta imagen para experimentos independientes.
Además, puede empaquetar la aplicación en un contenedor Docker, recopilar la imagen y ejecutarla con el límite de memoria:
docker run --memory 512m --interactive --tty ravali1906/dockermemory bash
Aquí
ravali1906/dockermemory
es el nombre de la imagen.
Ahora puede iniciar la aplicación especificando un límite de memoria que exceda el límite del contenedor:
$ node --max_old_space_size=1024 test-fatal-error.js { rss: 550498304, heapTotal: 1090719744, heapUsed: 1030627104, external: 8272 } Killed
Aquí, el
--max_old_space_size
representa el límite de memoria indicado en megabytes. El método
process.memoryUsage()
proporciona información sobre el uso de la memoria. Los valores se expresan en bytes.
La aplicación en algún momento se termina por la fuerza. Esto sucede cuando la cantidad de memoria utilizada por él cruza un cierto borde. ¿Qué es esta frontera? ¿De qué limitaciones en la cantidad de memoria podemos hablar?
El comportamiento esperado de una aplicación que se ejecuta con la clave es - max-old-space-size
De forma predeterminada, el tamaño máximo de almacenamiento dinámico en Node.js (hasta la versión 11.x) es de 700 MB en plataformas de 32 bits y 1400 MB en plataformas de 64 bits. Puede leer sobre cómo establecer estos valores
aquí .
En teoría, si usa la
--max-old-space-size
para
--max-old-space-size
límite de memoria que excede el límite de memoria del contenedor, puede esperar que la aplicación sea terminada por el mecanismo de seguridad del kernel del kernel OOM Killer de Linux.
En realidad, esto puede no suceder.
El comportamiento real de la aplicación que se ejecuta con la clave es max-old-space-size
La aplicación, inmediatamente después del lanzamiento, no asigna toda la memoria cuyo límite se especifica usando
--max-old-space-size
. El tamaño del montón de JavaScript depende de las necesidades de la aplicación. Puede juzgar cuánta memoria usa la aplicación en función del valor del campo
heapUsed
del objeto devuelto por el método
process.memoryUsage()
. De hecho, estamos hablando de la memoria asignada en el montón para objetos.
Como resultado, concluimos que la aplicación finalizará por la fuerza si el tamaño de
--memory
dinámico es mayor que el límite establecido por la tecla
--memory
cuando se inicia el contenedor.
Pero en realidad esto tampoco puede suceder.
Al perfilar aplicaciones de Node.js que consumen muchos recursos y se ejecutan en contenedores con un límite de memoria dado, se pueden observar los siguientes patrones:
- OOM Killer se activa mucho más tarde que el momento en que los
heapUsed
heapTotal
y heapUsed
son significativamente más altos que los límites de memoria. - OOM Killer no responde a exceder los límites.
Una explicación del comportamiento de las aplicaciones Node.js en contenedores
Un contenedor supervisa un indicador importante de las aplicaciones que se ejecutan en él. Este es
RSS (tamaño de conjunto residente). Este indicador representa una parte de la memoria virtual de la aplicación.
Además, es una pieza de memoria que se asigna a la aplicación.
Pero eso no es todo. RSS es parte de la memoria activa asignada a la aplicación.
No toda la memoria asignada a una aplicación puede estar activa. El hecho es que la "memoria asignada" no está necesariamente asignada físicamente hasta que el proceso comienza a usarla realmente. Además, en respuesta a las solicitudes de asignación de memoria de otros procesos, el sistema operativo puede volcar partes inactivas de la memoria de la aplicación en el archivo de página y transferir el espacio liberado a otros procesos. Y cuando la aplicación vuelva a necesitar estos fragmentos de memoria, se tomarán del archivo de intercambio y se devolverán a la memoria física.
La métrica RSS indica la cantidad de memoria activa y disponible para la aplicación en su espacio de direcciones. Es él quien influye en la decisión sobre el cierre forzado de la aplicación.
Evidencia
▍ Ejemplo No. 1. Una aplicación que asigna memoria para un búfer
El siguiente ejemplo,
buffer_example.js
, muestra un programa que asigna memoria para un buffer:
const buf = Buffer.alloc(+process.argv[2] * 1024 * 1024) console.log(Math.round(buf.length / (1024 * 1024))) console.log(Math.round(process.memoryUsage().rss / (1024 * 1024)))
Para que la cantidad de memoria asignada por el programa exceda el límite establecido cuando se inició el contenedor, primero ejecute el contenedor con el siguiente comando:
docker run --memory 1024m --interactive --tty ravali1906/dockermemory bash
Después de eso, ejecute el programa:
$ node buffer_example 2000 2000 16
Como puede ver, el sistema no completó el programa, aunque la memoria asignada por el programa excede el límite del contenedor. Esto sucedió debido al hecho de que el programa no funciona con toda la memoria asignada. RSS es muy pequeño, no excede el límite de memoria del contenedor.
▍ Ejemplo No. 2. Aplicación que llena el búfer con datos
En el siguiente ejemplo,
buffer_example_fill.js
, la memoria no solo se asigna, sino que también se llena de datos:
const buf = Buffer.alloc(+process.argv[2] * 1024 * 1024,'x') console.log(Math.round(buf.length / (1024 * 1024))) console.log(Math.round(process.memoryUsage().rss / (1024 * 1024)))
Ejecute el contenedor:
docker run --memory 1024m --interactive --tty ravali1906/dockermemory bash
Después de eso, ejecute la aplicación:
$ node buffer_example_fill.js 2000 2000 984
Aparentemente, ¡incluso ahora la aplicación no termina! Por qué El hecho es que cuando la cantidad de memoria activa alcanza el límite establecido cuando se inició el contenedor, y hay espacio en el archivo de página, algunas de las páginas antiguas en la memoria de proceso se mueven al archivo de página. La memoria liberada está disponible para el mismo proceso. De manera predeterminada, Docker asigna un espacio para el archivo de intercambio igual al límite de memoria establecido con el indicador
--memory
. Dado esto, podemos decir que el proceso tiene 2 GB de memoria: 1 GB en la memoria activa y 1 GB en el archivo de página. Es decir, debido a que la aplicación puede usar su propia memoria, cuyo contenido se mueve temporalmente al archivo de la página, el tamaño del índice RSS está dentro del límite del contenedor. Como resultado, la aplicación continúa funcionando.
▍ Ejemplo No. 3. Una aplicación que llena un búfer con datos que se ejecutan en un contenedor que no usa un archivo de página
Aquí está el código con el que experimentaremos aquí (este es el mismo archivo
buffer_example_fill.js
):
const buf = Buffer.alloc(+process.argv[2] * 1024 * 1024,'x') console.log(Math.round(buf.length / (1024 * 1024))) console.log(Math.round(process.memoryUsage().rss / (1024 * 1024)))
Esta vez, ejecute el contenedor, configurando explícitamente las características de trabajar con el archivo de intercambio:
docker run --memory 1024m --memory-swap=1024m --memory-swappiness=0 --interactive --tty ravali1906/dockermemory bash
Inicia la aplicación:
$ node buffer_example_fill.js 2000 Killed
Ver el mensaje ¿
Killed
? Cuando el valor de la clave
--memory-swap
es igual al
--memory
clave
--memory
, esto le dice al contenedor que no debe usar el archivo de
--memory
. Además, de manera predeterminada, el núcleo del sistema operativo en el que se ejecuta el contenedor en sí puede volcar una cierta cantidad de páginas de memoria anónimas utilizadas por el contenedor en el archivo de página.
--memory-swappiness
en
0
, deshabilitamos esta función. Como resultado, resulta que el archivo de paginación no se usa dentro del contenedor. El proceso finaliza cuando la métrica RSS excede el límite de memoria del contenedor.
Recomendaciones generales
Cuando las aplicaciones Node.js se
--max-old-space-size
con la
--max-old-space-size
, cuyo valor excede el límite de memoria establecido cuando se inició el contenedor, puede parecer que Node.js "no está prestando atención" al límite del contenedor. Pero, como se puede ver en los ejemplos anteriores, la razón obvia de este comportamiento es el hecho de que la aplicación simplemente no utiliza todo el volumen de
--max-old-space-size
dinámico especificado con el
--max-old-space-size
.
Recuerde que la aplicación no siempre se comportará igual si usa más memoria de la que está disponible en el contenedor. Por qué El hecho es que la memoria activa del proceso (RSS) está influenciada por muchos factores externos que la aplicación en sí no puede influir. Dependen de la carga del sistema y de las características del entorno. Por ejemplo, estas son características de la aplicación en sí, el nivel de paralelismo en el sistema, las características del planificador del sistema operativo, las características del recolector de basura, etc. Además, estos factores, de un lanzamiento a otro, pueden cambiar.
Recomendaciones para establecer el tamaño del montón Node.js para aquellos casos en los que puede controlar esta opción, pero no con restricciones de memoria a nivel de contenedor
- Ejecute la aplicación mínima Node.js en el contenedor y mida el tamaño RSS estático (en mi caso, para Node.js 10.x, esto es aproximadamente 20 Mb).
- El montón Node.js contiene no solo old_space, sino también otros (como new_space, code_space, etc.). Por lo tanto, si tiene en cuenta la configuración estándar de la plataforma, debe esperar que el programa necesite unos 20 MB más de memoria. Si la configuración estándar ha cambiado, estos cambios también deben tenerse en cuenta.
- Ahora necesitamos restar el valor obtenido (supongamos que será de 40 MB) de la cantidad de memoria disponible en el contenedor. Lo que queda es un valor que, sin temor a que la
--max-old-space-size
del programa se quede sin memoria, puede especificarse como el valor clave: --max-old-space-size
.
Recomendaciones para establecer límites de memoria de contenedor para casos en los que este parámetro se puede controlar, pero los parámetros de la aplicación Node.js no son
- Ejecute la aplicación en modos que le permitan conocer los valores máximos de la memoria que consume.
- Analizar el puntaje RSS. En particular, aquí, junto con el método
process.memoryUsage()
, el comando top
Linux puede ser útil. - Siempre que en el contenedor en el que se planea ejecutar la aplicación, nada más que no se ejecutará, el valor obtenido se puede usar como el límite de memoria del contenedor. Para estar seguro, se recomienda aumentarlo al menos en un 10%.
Resumen
En Node.js 12.x, algunos de los problemas discutidos aquí se resuelven ajustando adaptativamente el tamaño del almacenamiento dinámico, que se realiza de acuerdo con la cantidad de RAM disponible. Este mecanismo también funciona cuando se ejecutan aplicaciones Node.js en contenedores. Pero la configuración puede diferir de la configuración predeterminada. Esto, por ejemplo, ocurre cuando se
--max_old_space_size
tecla
--max_old_space_size
al iniciar la aplicación. Para tales casos, todo lo anterior sigue siendo relevante. Esto sugiere que cualquiera que ejecute aplicaciones Node.js en contenedores debe ser cuidadoso y responsable con respecto a la configuración de la memoria. Además, el conocimiento de los límites estándar en el uso de la memoria, que es bastante conservador, puede mejorar el rendimiento de la aplicación cambiando deliberadamente estos límites.
Estimados lectores! ¿Se ha quedado sin problemas de memoria al ejecutar aplicaciones Node.js en contenedores Docker?

