Node.js sobre Docker a pleno rendimiento en 4 pasos

Fernando Sadacontainers, contenedores, docker, IT

Todos, o casi todos los desarrolladores tienen sus lenguajes y frameworks favoritos, y Node.js es uno de los más aceptados en la comunidad. Node.js en Docker llevan tiempo siendo buenos conocidos, ya desde los primeros días en que estuvo accesible desde Docker Hub. Como formador de Docker, y gracias a Bret Fisher por sus ponencias en la DockerCon, así como varias entradas de Blog, voy a intentar desvelaros cómo sacar el máximo provecho de este framework y sus herramientas como npm, Yarn, y nodemon con Docker.

Hay mucha información sobre el uso de Node.js con Docker, pero gran parte de ella está desactualizada, y este post tiene como misión intentar ayudar a optimizar las posibles configuraciones para Node.js 10+ y Docker 18.09+.

¡Vamos a repasar 4 pasos para hacer que los contenedores Node.js vayan como la seda!

Ceñirse a la Distribución base actualizada (actual)

Si estás migrando aplicaciones de Node.js a contenedores, usa la imagen base del sistema operativo del host que tienes en producción a día de hoy. Después de eso, mi imagen base favorita es la edición oficial de node:Slim en lugar de node:alpine, que sigue siendo buena, pero suele ser más trabajosa de implementar y tiene sus limitaciones.

Una de las primeras preguntas que se hacen al poner una aplicación Node.js en Docker, es “¿Desde qué imagen base debo iniciar mi Docker Node.js?”

La versión slim y alpine son bastante más pequeñas que la imagen por defecto, como podemos observar.

Existen múltiples factores que influyen en esto, pero no se debe de priorizar el “tamaño de la imagen” a menos que se trate de dispositivos para IoT o de dispositivos embebidos en los que cada MB cuenta. En los últimos años, la imagen slim se ha reducido a 150 MB y funciona mejor en la gran mayoría de los escenarios. Alpine es una distribución mínima, que cuenta con apenas 75 MB. Sin embargo, el problema de Alpine, en este caso, es la escasez de binarios y librerías, por lo que tendremos que generar más líneas para completar nuestro Dockerfile, además de incrementar el nivel de esfuerzo para intercambiar gestores de paquetes (apt to apk), tratar casos extremos y trabajar con limitaciones de análisis de seguridad, lo que me inclina a desaconsejar la imagen de node:alpine para la mayoría de los casos de uso.

Al adoptar la tecnología de contenedores, la pretensión es reducir lo máximo posible los cambios en el código. Por ello, elegir la imagen base a la que están más acostumbrados tus devs y ops tiene muchos beneficios inesperados, así que hay que intentar seguirla cuando el caso nos lo permita, incluso si esto significa crear una imagen personalizada para CentOS, Ubuntu, etc.

Tratamiento de los módulos de Node

No es necesario reubicar los node_modules en los contenedores, siempre y cuando se sigan unas cuantas reglas para un desarrollo local adecuado. Una segunda opción es mover mode_modules hacia un directorio superior del Dockerfile y configurar el contenedor correctamente para proporcionar más flexibilidad. En contraprestación, es posible que no funcione con todos los frameworks de npm.

Todos los desarrolladores están acostumbrados a un mundo en el que no se escribe todo el código que se ejecuta en una aplicación, lo que significa que han que lidiar con las dependencias del framework de la aplicación. Una pregunta común es cómo tratar esas dependencias de código en contenedores cuando son un subdirectorio de la aplicación en cuestión. Los “bind mounts” realizados en local para el desarrollo pueden afectar a la aplicación de forma diferente si esas dependencias se diseñaron para ejecutarse en el sistema operativo del host y no en el sistema operativo del contenedor.

El núcleo de este problema en Node.js es que los node_modules pueden contener binarios compilados para el sistema operativo del host, y si es diferente al sistema operativo contenedor, se obtendrán errores al intentar ejecutar la aplicación cuando se despliegue desde el host de desarrollo. Hay que tener en cuenta que, si alguien es desarrollador de Linux puro y desarrolla en Linux x64 para Linux x64, este problema con los “bind-mounts” no suele ser un problema.

Para Node.js se plantean dos enfoques, que vienen cada uno con sus propios beneficios y limitaciones:

Solución A: Mantenlo simple

No hace falta mover los “node_modules”. Seguirán en el subdirectorio predeterminado de la aplicación en el contenedor, pero esto significa que se debe evitar que los “node_modules” creados en su host local se utilicen en el contenedor durante el desarrollo.

Este método es el preferido para realizar desarrollos puros con Docker. Funciona muy bien con algunas reglas que hay que seguir para el desarrollo local:

  1. Desarrolla sólo dentro del contenedor. ¿Por qué? Básicamente, no se deben mezclar los “node_modules” de nuestra máquina con los “node_modules” del contenedor. En macOS y Windows, Docker Desktop monta el código en la capa del Sistema Operativo, y esto puede causar problemas con los binarios que se han instalado con npm para el sistema operativo del host, que no se pueden ejecutar en el sistema operativo del contenedor.
  2. Ejecutar todos los comandos de npm a través de docker-compose. Esto significa que el npm install inicial del proyecto debería ser ahora una instrucción con el siguiente aspecto docker-compose run <service name> npm install

Solución B: Mover los módulos del contenedor y ocultar los módulos de host

Reubicar los “node_modules” en la ruta del archivo en el Dockerfile para que pueda desarrollar Node.js dentro y fuera del contenedor, y de esta forma, las dependencias no chocarán cuando se cambie entre el desarrollo nativo en el host y el desarrollo basado en Docker.

Dado que Node.js está diseñado para ejecutarse en múltiples sistemas operativos y arquitecturas, es posible que no se desee desarrollar siempre en contenedores. Si se pretende buscar la flexibilidad en el desarrollo / ejecución de la aplicación Node.js directamente en el host, y otras veces desplegar en un contenedor local, entonces esta es la solución es la más adecuada.

En este caso se necesita un “node_module” en el host que esté construido para ese sistema operativo, y un “node_module” diferente en el contenedor para Linux.

Se pueden observar, en grandes rasgos, las líneas básicas que necesitará para mover “node_modules” a las rutas correctas para este caso.

Las reglas para esta solución incluyen:

  1. Mover los “node_modules” a un directorio superior de la imagen del contenedor. js siempre busca “node_modules” en un subdirectorio, pero si falta, subirá por la ruta del directorio hasta que encuentre uno.
  2. Para evitar que el subdirectorio de los “node_modules” del host aparezca en el contenedor, se puede utilizar una solución provisional llamada “empty bind-mount” para evitar que los “node_modules” del host se utilicen en el contenedor. Un ejemplo de un YAML del repositorio de Bret Frisher donde se vería de la siguiente manera.
  3. Esta solución funciona en la mayoría de código de js, pero algunos frameworks y proyectos más grandes pueden codificar los “node_modules” como un subdirectorio, lo que descartará esta solución como válida.

Para ambas soluciones, hay que recordar siempre añadir “node_modules” al archivo.dockerignore  para que nunca se generen accidentalmente las imágenes con “modules” del host. Siempre se querrá que los “builds” creados ejecuten una instalación de npm dentro de la imagen.

Utilizar el usuario de Node: tiene menos privilegios.

Todas las imágenes oficiales de Node.js tienen un usuario de Linux añadido en la imagen de flujo ascendente llamado node. Este usuario no es el que se usa por defecto, lo que significa que tu aplicación Node.js se ejecutará en el contenedor por defecto como root. Sin embargo, esto no supone un gran problema, ya que sigue aislado en ese contenedor, pero deberías habilitarlo en todos tus proyectos en los que no necesites que Node se ejecute como root. Sólo tienes que añadir una nueva línea en tu Dockerfile: USER node

Aquí hay algunas directrices para usarlo:

  1. La ubicación en el Dockerfile es bastante importante. Habría que añadir la línea de USER después de los comandos apt/yum/apk, y normalmente antes de instalar los comandos npm.
  2. No afecta a todos los comandos, como COPY, que tiene su propia sintaxis para controlar el propietario de los archivos que se copian.
  3. Un detalle a tener en cuenta es que siempre se puede cambiar de nuevo a USER root si es necesario. En los Dockerfiles más complejos esto será necesario.
  4. Los permisos pueden ser complicados durante el desarrollo porque ahora estará haciendo cosas en el contenedor como usuario no root por defecto. La manera de evitar esto es hacer cosas como instalar npm diciéndole a Docker que quiere ejecutar esos comandos únicos como root: docker-compose run -u root npm install

No utilizar gestores de procesos en producción

Excepto para el desarrollo local, no hay que enredar los comandos de arranque de su aplicación de Node con nada. No se debe utilizar npm, nodemon, etc. Hay que intentar que el CMD o ENTRYPOINT del Dockerfile tenga un aspecto parecido a [“node”, “start_file.js”] y resultará más fácil administrar y reemplazar los contenedores (si fuera necesario).

Nodemon y otros “monitorizador de archivos” son necesarios en el desarrollo, pero uno de los mayores logros para la adopción de Docker con aplicaciones Node.js es que Docker se hace cargo del trabajo de lo que solíamos usar pm2, nodemon, forever, y systemd for en los servidores.

Docker, Swarm, y Kubernetes harán el trabajo de hacer los healthchecks y reiniciar o recrear el contenedor si falla. A su vez, es ahora trabajo de los orquestadores escalar el número de réplicas de nuestras aplicaciones, que utilizábamos para integrar herramientas como pm2. Hay que recordar que, Node.js sigue siendo “single-threaded” en la mayoría de los casos, por lo que incluso en un solo servidor es probable que se deseen desplegar múltiples réplicas de contenedores para aprovechar las ventajas de las múltiples CPU’s de la infraestructura de elección.

Un gran ejemplo lo podemos encontrar en el repositorio de Bret Fisher donde muestra cómo usar Node directamente en un Dockerfile, o bien construir una imagen en varias etapas con docker build –target <stage name>, o anular el CMD en la creación de un archivo YAML.

Inicializar node directamente en los Dockerfiles

No está recomendado utilizar npm para iniciar las aplicaciones en un Dockerfile.

Se recomienda llamar al binario de node directamente, en gran parte debido al “Problema PID 1” donde se puede encontrar cierta confusión y desinformación sobre cómo tratar este problema en las aplicaciones de Node.js. Para aclarar la confusión en la “blog-esfera”, no siempre se necesita una herramienta “init” entre Docker y Node.js y, probablemente, se debería dedicar más tiempo a pensar en cómo la aplicación realiza un apagado ordenado.

Node.js acepta y reenvía señales como SIGINT y SIGTERM desde el sistema operativo, lo cual es importante para el correcto apagado de la aplicación. Node.js deja decidir a la aplicación cómo manejar esas señales, lo que significa que, si se escribe código o se usa un módulo para manejarlas, la aplicación no se apagará correctamente. Ignorará esas señales y luego será eliminada por Docker o Kubernetes después de un tiempo de espera (el Docker por defecto es de 10 segundos, Kubernetes de 30 segundos.) Esto importará mucho más una vez que se tenga una aplicación HTTP en producción, la cual tenga que asegurarse de que no se caigan las conexiones cuando se quieran actualizar las aplicaciones.

Si se utilizan otras aplicaciones para iniciar Node.js por nosotros, como por ejemplo npm, a menudo inutilizan dichos “flags”. npm no enviará esas señales a la aplicación, así que es mejor dejarlas fuera de los ENTRYPOINT y CMD de los Dockerfiles. Esto también tiene la ventaja de tener un binario menos en el contenedor. Otra ventaja es que permite ver en el Dockerfile exactamente lo que la aplicación hará cuando se ejecute el contenedor, en lugar de tener que comprobar el paquete “.json” para verificar el comando de inicio.

Para aquellos que conocen las opciones de inicio, como docker run –init  o el uso de tini en el Dockerfile, son buenas opciones de copia de seguridad cuando no se puede cambiar el código de la aplicación, pero es una solución mucho mejor para escribir código y gestionar el manejo adecuado de las señales de apagado, en caso de que se cierre la aplicación correctamente. En el siguiente enlace, Bret Fischer deja algunos ejemplos sobre el uso de señales de apagado, que pueden servir de guía.

¿Eso es todo?

No. Estas son algunas de las preocupaciones con las que muchos de los equipos de Node.js tratan, y hay muchas otras consideraciones que van de la mano. Temas como las “multistage builds”, los proxies HTTP, el rendimiento de la instalación de npm, los Healthchecks, el escaneo de vulnerabilidades, el logging, los tests durante la creación de imágenes o las configuraciones a realizar con microservicios y Docker-Compose.