Skip to main content

10 buenas prácticas para contenerizar aplicaciones web de Node.js con Docker

feature cheat sheet

15 de septiembre de 2022

0 minutos de lectura

¿Buscas buenas prácticas para crear imágenes de Docker de Node.js para tus aplicaciones web? ¡Llegaste al lugar indicado!

Este artículo ofrece pautas listas para producción para crear imágenes de Docker de Node.js optimizadas y seguras. Te resultará útil independientemente de la aplicación de Node.js que quieras crear. Este artículo te será útil si:

  • tu objetivo es crear una aplicación frontend con las capacidades de renderizado del lado del servidor (SSR) de Node.js para React.

  • buscas consejos para crear correctamente una imagen de Docker de Node.js para tus microservicios que ejecutan Fastify, NestJS u otros frameworks de aplicaciones.

¿Por qué escribimos esta guía sobre cómo contenerizar aplicaciones web de Node.js con Docker?

Puede parecer otro artículo más sobre cómo crear imágenes de Docker para aplicaciones de Node.js, pero muchos ejemplos que hemos visto en blogs son muy básicos y solo buscan enseñarte los fundamentos para ejecutar una aplicación con una imagen de Docker de Node.js, sin considerar cuidadosamente la seguridad ni las buenas prácticas para crear imágenes de Docker de Node.js.

Aprenderemos paso a paso a contenerizar aplicaciones web de Node.js: empezaremos con un Dockerfile simple y funcional, entenderemos los problemas y las vulnerabilidades de cada instrucción del Dockerfile y luego los corregiremos.

Una imagen sencilla de Docker para Node.js

La mayoría de los artículos de blog que hemos visto empiezan y terminan con instrucciones básicas de Dockerfile como las siguientes para crear imágenes de Docker de Node.js:

FROM node
WORKDIR /usr/src/app
COPY . /usr/src/app
RUN npm install
CMD "npm" "start"

Copia eso en un archivo llamado Dockerfile, y luego compílalo y ejecútalo.

$ docker build . -t nodejs-tutorial
$ docker run -p 3000:3000 nodejs-tutorial

Es simple y funciona.

¿El único problema? Está lleno de errores y malas prácticas para crear imágenes de Docker de Node.js. Evítalo a toda costa.

Empecemos a mejorar este Dockerfile para crear aplicaciones web de Node.js optimizadas con Docker.

Puedes seguir este tutorial clonando este repositorio.

Sigue estos 10 pasos para crear aplicaciones web de Node.js optimizadas con Docker:

  1. Usa etiquetas explícitas y deterministas para la imagen base de Docker

  2. Instala solo dependencias de producción en la imagen de Docker de Node.js

  3. Optimiza las herramientas de Node.js para producción

  4. No ejecutes contenedores como root

  5. Termina de forma segura las aplicaciones web de Node.js en Docker

  6. Apagado ordenado de tus aplicaciones web de Node.js

  7. Encuentra y corrige vulnerabilidades de seguridad en tu imagen de Docker de Node.js

  8. Usa compilaciones de varias etapas

  9. Evita incluir archivos innecesarios en tus imágenes de Docker de Node.js

  10. Monta secretos en la imagen de compilación de Docker

1. Usa etiquetas explícitas y deterministas para la imagen base de Docker

Puede parecer obvio crear tu imagen a partir de la imagen de Docker node, pero ¿qué estás descargando realmente al crearla? Las imágenes de Docker siempre se identifican mediante etiquetas y, si no especificas una, se usa la etiqueta predeterminada :latest.

Así que, de hecho, al especificar lo siguiente en tu Dockerfile, siempre compilas la versión más reciente de la imagen de Docker creada por el grupo de trabajo de Docker de Node.js:

FROM node

Estas son las desventajas de crear una imagen a partir de la imagen predeterminada node:

  1. Las compilaciones de imágenes de Docker son inconsistentes. Así como usamos lockfiles para obtener un comportamiento determinista de npm install cada vez que instalamos paquetes de npm, también queremos que las compilaciones de imágenes de Docker sean deterministas. Si compilamos la imagen desde node —lo que, en la práctica, significa usar la etiqueta node:latest—, cada compilación descargará una imagen de Docker de node recién creada. No queremos introducir este tipo de comportamiento no determinista.

  2. La imagen de Docker de node se basa en un sistema operativo completo, repleto de bibliotecas y herramientas que quizá necesites o no para ejecutar tu aplicación web de Node.js. Esto tiene dos desventajas. En primer lugar, una imagen más grande implica una descarga más pesada y, además de aumentar los requisitos de almacenamiento, requiere más tiempo para descargar y volver a compilar la imagen. En segundo lugar, podrías introducir en la imagen vulnerabilidades de seguridad que existan en cualquiera de esas bibliotecas y herramientas.

De hecho, la imagen de Docker node es bastante grande e incluye cientos de vulnerabilidades de seguridad de distintos tipos y niveles de gravedad. Si la usas, partirás de una base con 642 vulnerabilidades de seguridad y cientos de megabytes de datos de imagen que se descargan en cada extracción y compilación.

Gráfico de barras que compara las vulnerabilidades en imágenes de contenedor oficiales entre 2018 y 2019 para node, postgres, nginx, httpd, mongo, mysql, couchbase, memcached y

Estas son las recomendaciones para crear mejores imágenes de Docker:

  1. Usa imágenes de Docker pequeñas: así reducirás la huella de software de la imagen y, por lo tanto, las posibles vías de vulnerabilidad. Además, su menor tamaño agilizará el proceso de compilación.

  2. Usa el digest de la imagen de Docker, que es el hash SHA256 estático de la imagen. Esto garantiza que las compilaciones de imágenes de Docker basadas en la imagen base sean deterministas.

Escribimos un artículo completo sobre cómo elegir la mejor imagen de Node.js para Docker. En él se explican las razones por las que elegir una distribución Debian slim actualizada con una versión de Node.js con soporte a largo plazo es la opción ideal.

La imagen de Docker de Node.js que recomendamos usar es:

FROM node:20.9.0-bullseye-slim

Esta etiqueta de imagen de Docker de Node.js usa una versión específica del entorno de ejecución de Node.js (20.9.0), que corresponde a la versión más reciente con soporte a largo plazo. Usa la variante de imagen bullseye, que corresponde a la versión estable actual de Debian 11 y tiene una fecha de fin de vida suficientemente lejana. Por último, usa la variante de imagen slim para reducir la huella de software del sistema operativo, lo que da como resultado una imagen de menos de 200 MB, incluido el entorno de ejecución y las herramientas de Node.js.

Dicho esto, una práctica desinformada común que encontrarás en tutoriales y guías es recomendar la siguiente instrucción de Docker para una imagen base:

FROM node:alpine

Estos artículos recomiendan usar la imagen Alpine de Docker para Node.js, pero ¿realmente es ideal? En general, lo hacen porque se atribuye a la imagen Alpine de Docker para Node.js una menor huella de software. Sin embargo, presenta diferencias importantes en otros aspectos, por lo que no es una imagen base óptima para los entornos de ejecución de aplicaciones de Node.js en producción.

¿Qué es Node Alpine?

Node.js Alpine es una compilación no oficial de una imagen de contenedor de Docker que mantiene el equipo de Docker de Node.js. La imagen de Node.js incluye el sistema operativo Alpine, que usa las herramientas mínimas de software busybox y la implementación de la biblioteca C musl. Estas dos características de la imagen Alpine de Node.js hacen que el equipo de Node.js no la admita oficialmente. Además, muchos escáneres de vulnerabilidades de seguridad no pueden detectar fácilmente los artefactos de software ni los entornos de ejecución en las imágenes Alpine de Node.js, lo que dificulta los esfuerzos por proteger tus imágenes de contenedor.

Aunque uses la etiqueta de imagen Alpine de Node.js, una instrucción de imagen base con un alias de palabra puede descargar nuevas compilaciones de esa etiqueta, porque las etiquetas de las imágenes de Docker son mutables. Puedes encontrar el hash SHA256 en Docker Hub para esta etiqueta de Node.js o, después de descargar esta imagen localmente, ejecutar el siguiente comando y buscar el campo Digest en el resultado:

$ docker pull node:20.9.0-bullseye-slim
20.9.0-bullseye-slim: Pulling from library/node
ca426296fe92: Pull complete
0d5f60f923bb: Pull complete
cc6fa81c4559: Pull complete
ec5e8e3b63b3: Pull complete
ca7cb04b0758: Pull complete
Digest: sha256:330fa0342b6ad2cbdab30ac44195660af5a1f298cc499d8cbdf7496b02ea17d8
Status: Downloaded newer image for node:20.9.0-bullseye-slim
docker.io/library/node:20.9.0-bullseye-slim

Otra forma de encontrar el hash SHA256 es ejecutar el siguiente comando:

$ docker images --digests
REPOSITORY                                   TAG                     DIGEST                                                                    IMAGE ID       CREATED         SIZE
node                                         20.9.0-bullseye-slim    sha256:330fa0342b6ad2cbdab30ac44195660af5a1f298cc499d8cbdf7496b02ea17d8   9ea15fe618bd   7 days ago      200MB

Ahora podemos actualizar el Dockerfile para esta imagen de Docker de Node.js de la siguiente manera:

FROM node@sha256:330fa0342b6ad2cbdab30ac44195660af5a1f298cc499d8cbdf7496b02ea17d8
WORKDIR /usr/src/app
COPY . /usr/src/app
RUN npm install
CMD "npm" "start"

Sin embargo, el Dockerfile anterior solo especifica el nombre de la imagen de Docker de Node.js, sin una etiqueta de imagen. Esto crea ambigüedad sobre la etiqueta exacta que se está usando: no es legible, es difícil de mantener y no ofrece una buena experiencia para desarrolladores.

Corrijámoslo actualizando el Dockerfile y proporcionando la etiqueta completa de la imagen base para la versión de Node.js que corresponde a ese hash SHA256:

FROM node:20.9.0-bullseye-slim@sha256:330fa0342b6ad2cbdab30ac44195660af5a1f298cc499d8cbdf7496b02ea17d8
WORKDIR /usr/src/app
COPY . /usr/src/app
RUN npm install
CMD "npm" "start"

Usar el digest de la imagen de Docker garantiza una imagen determinista, pero puede resultar confuso o contraproducente para algunas herramientas de análisis de imágenes que quizá no sepan interpretarlo. Por eso, es preferible usar una versión explícita del entorno de ejecución de Node.js, como 20.9.0. Aunque, en teoría, sea mutable y se pueda reemplazar, en la práctica, si necesita actualizaciones de seguridad u otras, se publicarán en una nueva versión, como 20.9.1. Por lo tanto, es razonable asumir que las compilaciones serán deterministas.

Por lo tanto, en esta etapa, el Dockerfile final que proponemos es el siguiente:

FROM node:16.17.0-bullseye-slim
WORKDIR /usr/src/app
COPY . /usr/src/app
RUN npm install
CMD "npm" "start"

Lee más consejos y buenas prácticas para crear imágenes de contenedor seguras.

2. Instala solo dependencias de producción en la imagen de Docker de Node.js

La siguiente instrucción de Dockerfile instala todas las dependencias en el contenedor, incluidas las devDependencies, que no son necesarias para que la aplicación funcione. Esto agrega un riesgo de seguridad innecesario debido a los paquetes usados como dependencias de desarrollo y aumenta el tamaño de la imagen sin necesidad.

RUN npm install

Si seguiste mi guía anterior sobre 10 buenas prácticas de seguridad para npm, sabes que debes aplicar compilaciones deterministas con npm ci. Así evitas sorpresas en un flujo de integración continua (CI), ya que el proceso se detiene si hay alguna diferencia con el archivo de bloqueo.

Al crear una imagen de Docker para producción, queremos asegurarnos de instalar únicamente las dependencias de producción de manera determinista. Esto nos lleva a la siguiente recomendación sobre las buenas prácticas para instalar dependencias de npm en una imagen de contenedor:

RUN npm ci --only=production

En esta etapa, el Dockerfile actualizado queda así:

FROM node:20.9.0-bullseye-slim
WORKDIR /usr/src/app
COPY . /usr/src/app
RUN npm ci --only=production
CMD "npm" "start"

Lee nuestro artículo sobre dependencias de software para obtener más información.

3. Optimiza las herramientas de Node.js para producción

Al crear tu imagen de Docker de Node.js para producción, debes asegurarte de que todos los frameworks y bibliotecas usen la configuración óptima para el rendimiento y la seguridad.

Esto nos lleva a agregar la siguiente instrucción al Dockerfile:

ENV NODE_ENV production

A primera vista, parece redundante, ya que en la etapa de npm install especificamos que solo queremos dependencias de producción. Entonces, ¿por qué es necesario?

Los desarrolladores suelen asociar la configuración de la variable de entorno NODE_ENV=production con la instalación de dependencias de producción. Sin embargo, esta configuración también tiene otros efectos que debemos tener en cuenta.

Algunos frameworks y bibliotecas solo activan la configuración optimizada para producción si se establece la variable de entorno NODE_ENV en production. Dejando de lado nuestra opinión sobre si es una buena o mala práctica por parte de los frameworks, es importante saberlo.

Por ejemplo, la documentación de Express explica la importancia de configurar esta variable de entorno para habilitar optimizaciones relacionadas con el rendimiento y la seguridad:

Documentación de Express que destaca las ventajas de NODE_ENV="production", como plantillas en caché, CSS en caché y errores menos detallados.

El impacto de la variable NODE_ENV en el rendimiento puede ser muy significativo.

El equipo de Dynatrace publicó un artículo de blog que detalla los efectos drásticos de omitir NODE_ENV en tus aplicaciones de Express.

Es posible que muchas de las otras bibliotecas de las que dependes también esperen que se configure esta variable, así que debemos incluirla en nuestro Dockerfile.

Con la variable de entorno NODE_ENV incorporada, el Dockerfile actualizado debería quedar así:

FROM node:20.9.0-bullseye-slim
ENV NODE_ENV production
WORKDIR /usr/src/app
COPY . /usr/src/app
RUN npm ci --only=production
CMD "npm" "start"

4. No ejecutes contenedores como root

El principio de privilegio mínimo es un principio de seguridad que se remonta a los primeros días de Unix. Siempre debemos seguirlo al ejecutar nuestras aplicaciones web de Node.js en contenedores.

La evaluación de amenazas es bastante sencilla: si un atacante logra comprometer la aplicación web de una forma que le permita realizar una inyección de comandos o un recorrido de rutas de directorio, esas acciones se ejecutarán con el usuario propietario del proceso de la aplicación. Si ese proceso se ejecuta como root, el atacante podrá hacer prácticamente cualquier cosa dentro del contenedor, incluso intentar escapar del contenedor o escalar privilegios. ¿Por qué correríamos ese riesgo? Tienes razón: no lo haremos.

Repite conmigo: «¡Los amigos no dejan que sus amigos ejecuten contenedores como root!»

La imagen oficial de Docker node, así como sus variantes, como alpine, incluye un usuario con los privilegios mínimos que lleva el mismo nombre: node. Sin embargo, no basta con ejecutar el proceso como node. Por ejemplo, lo siguiente no es lo ideal para el funcionamiento de la aplicación:

USER node
CMD "npm" "start"

La razón es que la directiva USER de Dockerfile solo garantiza que el proceso pertenezca al usuario node. ¿Qué pasa con todos los archivos que copiamos antes con la instrucción COPY? Pertenecen a root. Así funciona Docker de forma predeterminada.

La forma completa y correcta de reducir los privilegios es la siguiente; también muestra las prácticas actualizadas de Dockerfile que hemos visto hasta ahora:

FROM node:20.9.0-bullseye-slim
ENV NODE_ENV production
WORKDIR /usr/src/app
COPY --chown=node:node . /usr/src/app
RUN npm ci --only=production
USER node
CMD "npm" "start"

5. Termina de forma segura las aplicaciones web de Node.js en Docker

Uno de los errores más comunes que veo en blogs y artículos sobre cómo contenerizar aplicaciones Node.js para ejecutarlas en contenedores Docker es la forma en que inician el proceso. Todos los siguientes casos y sus variantes son malas prácticas que debes evitar:

  • CMD “npm” “start”

  • CMD [“yarn”, “start”]

  • CMD “node” “server.js”

  • CMD “start-app.sh”

¡Veámoslo en detalle! Te explicaré cada una de las formas incorrectas de iniciar los procesos y por qué debes evitarlas.

Los siguientes puntos son clave para comprender el contexto necesario para ejecutar y terminar correctamente las aplicaciones Node.js en Docker:

  1. Un motor de orquestación, como Docker Swarm, Kubernetes o incluso el propio motor de Docker, necesita una forma de enviar señales al proceso dentro del contenedor. En general, son señales para terminar una aplicación, como SIGTERM y SIGKILL.

  2. Es posible que el proceso se ejecute de forma indirecta y, en ese caso, no siempre se garantiza que reciba estas señales.

  3. El kernel de Linux trata los procesos que se ejecutan como ID de proceso 1 (PID) de forma distinta a los demás.

Con esta información, empecemos a analizar las formas de iniciar el proceso de un contenedor, comenzando con el ejemplo del Dockerfile que estamos creando:

CMD "npm" "start"

Aquí hay dos aspectos a tener en cuenta. En primer lugar, ejecutamos la aplicación node de forma indirecta al invocar directamente el cliente npm. ¿Quién puede asegurar que la CLI de npm reenvía todos los eventos al entorno de ejecución de node? En realidad, no lo hace, y podemos comprobarlo fácilmente.

Asegúrate de que tu aplicación Node.js tenga un controlador de eventos para la señal SIGHUP que escriba en la consola cada vez que envíes un evento. Un ejemplo sencillo de código sería el siguiente:

function handle(signal) {
   console.log(`*^!@4=> Received event: ${signal}`)
}
process.on('SIGHUP', handle)

Luego ejecuta el contenedor y, cuando esté en funcionamiento, envíale específicamente la señal SIGHUP mediante la CLI de docker y la opción especial de línea de comandos --signal:

$ docker kill --signal=SIGHUP elastic_archimedes

No pasó nada, ¿verdad? Eso se debe a que el cliente npm no reenvía ninguna señal al proceso node que inició.

El otro aspecto que hay que tener en cuenta son las distintas formas de especificar la directiva CMD en el Dockerfile. Hay dos y no son iguales:

  1. la notación de formato shell, en la que el contenedor inicia un intérprete de comandos que envuelve el proceso. En esos casos, es posible que el shell no reenvíe correctamente las señales al proceso.

  2. la notación de formato exec, que inicia un proceso directamente, sin envolverlo en un shell. Se especifica mediante la notación de arreglo JSON, por ejemplo: CMD [“npm”, “start”]. Las señales que se envían al contenedor se envían directamente al proceso.

Con esta información, queremos mejorar la directiva de ejecución de procesos de nuestro Dockerfile de la siguiente manera:

CMD ["node", "server.js"]

Ahora invocamos directamente el proceso node, lo que garantiza que reciba todas las señales que se le envían, sin que un intérprete de comandos lo envuelva.

Sin embargo, esto presenta otro problema.

Cuando los procesos se ejecutan como PID 1, asumen algunas de las responsabilidades de un sistema init, que normalmente se encarga de inicializar un sistema operativo y sus procesos. El kernel trata al PID 1 de forma distinta a los demás identificadores de proceso. Debido a este tratamiento especial del kernel, si un proceso en ejecución no tiene configurado un controlador para la señal SIGTERM, no se invocará el comportamiento alternativo predeterminado que lo termina.

Para citar la recomendación del grupo de trabajo de Node.js Docker: “Node.js no se diseñó para ejecutarse como PID 1, lo que provoca un comportamiento inesperado dentro de Docker. Por ejemplo, un proceso Node.js que se ejecuta como PID 1 no responderá a SIGINT (CTRL-C) ni a señales similares”.

La solución es usar una herramienta que actúe como un proceso init: se invoca con PID 1 y luego inicia nuestra aplicación Node.js como otro proceso, a la vez que garantiza que todas las señales se reenvíen a ese proceso Node.js. Si es posible, queremos que la herramienta ocupe el menor espacio posible para evitar el riesgo de agregar vulnerabilidades de seguridad a la imagen del contenedor.

Una de las herramientas que usamos en Snyk es dumb-init, porque está enlazada de forma estática y ocupa poco espacio. Así la configuraremos:

RUN apt-get update && apt-get install -y --no-install-recommends dumb-init
CMD ["dumb-init", "node", "server.js"]

Esto nos lleva al siguiente Dockerfile actualizado. Verás que colocamos la instalación del paquete dumb-init justo después de la declaración de la imagen para aprovechar el almacenamiento en caché de capas de Docker:

FROM node:20.9.0-bullseye-slim
RUN RUN apt-get update && apt-get install -y --no-install-recommends dumb-init
ENV NODE_ENV production
WORKDIR /usr/src/app
COPY --chown=node:node . .
RUN npm ci --only=production
USER node
CMD ["dumb-init", "node", "server.js"]

Cuando usamos la instrucción RUN de Docker para agregar software, como hicimos con RUN apt-get update && apt-get install, dejamos información en la imagen de Docker. Para limpiar después de ejecutar este comando y mantener una imagen de Docker más pequeña, podemos ampliarlo de la siguiente manera:

FROM node:20.9.0-bullseye-slim
RUN apt-get update && apt-get install -y --no-install-recommends dumb-init
ENV NODE_ENV production
WORKDIR /usr/src/app
COPY --chown=node:node . .
RUN npm ci --only=production
USER node
CMD ["dumb-init", "node", "server.js"]

Incluso podemos mejorar lo anterior usando ENTRYPOINT en el Dockerfile para configurar dumb-init y proporcionarle el proceso Node.js que debe ejecutar como argumento de línea de comandos. Para hacerlo, actualiza la entrada anterior del Dockerfile de la siguiente manera:

FROM node:16.17.0-bullseye-slim
RUN apt-get update && apt-get install -y --no-install-recommends dumb-init
ENV NODE_ENV production
WORKDIR /usr/src/app
COPY --chown=node:node . .
RUN npm ci --only=production
USER node
ENTRYPOINT ["/usr/bin/dumb-init", "--"]
CMD ["node", "server.js"]

Consejo: es aún mejor instalar la herramienta dumb-init en una etapa de compilación anterior y luego copiar el archivo resultante /usr/bin/dumb-init a la imagen final del contenedor para mantenerla limpia. Más adelante en esta guía aprenderemos sobre las compilaciones de Docker en varias etapas.

Dato útil: los comandos docker kill y docker stop solo envían señales al proceso del contenedor con PID 1. Si ejecutas un script de shell que inicia tu aplicación Node.js, ten en cuenta que una instancia de shell, como /bin/sh, por ejemplo, no reenvía señales a los procesos secundarios. Esto significa que tu aplicación nunca recibirá un SIGTERM.

6. Apagado ordenado de tus aplicaciones web de Node.js

Ya que hablamos de las señales de proceso que terminan las aplicaciones, asegurémonos de apagarlas correctamente y de forma ordenada, sin afectar a los usuarios.

Cuando una aplicación Node.js recibe una señal de interrupción, también conocida como SIGINT o CTRL+C, el proceso se termina abruptamente, a menos que se hayan configurado controladores de eventos para gestionarla de otra forma. Esto significa que los clientes conectados a una aplicación web se desconectarán de inmediato. Ahora imagina cientos de contenedores web de Node.js orquestados por Kubernetes, que se crean y se eliminan según sea necesario para escalar o gestionar errores. No es la mejor experiencia de usuario.

Puedes simular este problema fácilmente. Este es un ejemplo de una aplicación web estándar de Fastify con un endpoint que incluye un retraso de 60 segundos en la respuesta:

fastify.get('/delayed', async (request, reply) => {
 const SECONDS_DELAY = 60000
 await new Promise(resolve => {
     setTimeout(() => resolve(), SECONDS_DELAY)
 })
 return { hello: 'delayed world' }
})

const start = async () => {
 try {
   await fastify.listen(PORT, HOST)
   console.log(`*^!@4=> Process id: ${process.pid}`)
 } catch (err) {
   fastify.log.error(err)
   process.exit(1)
 }
}

start()

Ejecuta esta aplicación y, una vez que esté funcionando, envía una solicitud HTTP sencilla a este endpoint:

$ time curl https://localhost:3000/delayed

Presiona CTRL+C en la ventana de la consola de Node.js que está en ejecución y verás que la solicitud de curl se interrumpe abruptamente. Esto simula lo mismo que experimentarían tus usuarios cuando se eliminan los contenedores.

Para ofrecer una mejor experiencia, podemos hacer lo siguiente:

  1. Configurar un controlador de eventos para las distintas señales de terminación, como SIGINT y SIGTERM.

  2. El controlador espera a que se completen las operaciones de limpieza, como las conexiones a bases de datos, las solicitudes HTTP en curso y otras.

  3. Luego, el controlador termina el proceso Node.js.

Con Fastify, en particular, podemos hacer que nuestro controlador invoque fastify.close(), que devuelve una promesa que esperaremos. Fastify también se encargará de responder a cada nueva conexión con el código de estado HTTP 503 para indicar que la aplicación no está disponible.

Agreguemos nuestro controlador de eventos:

async function closeGracefully(signal) {
   console.log(`*^!@4=> Received signal to terminate: ${signal}`)

   await fastify.close()
   // await db.close() if we have a db connection in this app
   // await other things we should cleanup nicely
   process.kill(process.pid, signal);
}
process.once('SIGINT', closeGracefully)
process.once('SIGTERM', closeGracefully)

Es cierto que esto tiene más que ver con las aplicaciones web en general que con Dockerfile, pero es aún más importante en entornos orquestados.

7. Detecta y corrige vulnerabilidades de seguridad en tu imagen Docker de Node.js

¿Recuerdas que hablamos de la importancia de usar imágenes base pequeñas de Docker para nuestras aplicaciones Node.js? Pongamos a prueba esta recomendación.

Usaré la CLI de Snyk para analizar nuestra imagen de Docker. Puedes crear una cuenta gratuita de Snyk aquí.

$ npm install -g snyk
$ snyk auth
$ snyk container test node:20.9.0-bullseye-slim --file=Dockerfile

El primer comando instala la CLI de Snyk; después, inicia sesión rápidamente desde la línea de comandos para obtener una clave de API y, luego, podemos analizar el contenedor en busca de problemas de seguridad. Este es el resultado:

Organization:      lirantal
Package manager:   deb
Target file:       Dockerfile
Project name:      docker-image|node
Docker image:      node:20.9.0-bullseye-slim
Platform:          linux/arm64
Base image:        node:lts-bullseye-slim
Licenses:          enabled

Tested 97 dependencies for known issues, found 44 issues.

According to our scan, you are currently using the most secure version of the selected base image

Snyk detectó 97 dependencias del sistema operativo, incluido el ejecutable del entorno de ejecución de Node.js, y no encontró ninguna versión vulnerable de este entorno. Sin embargo, algunos programas de la imagen del contenedor tienen 44 vulnerabilidades de seguridad. 43 de estas vulnerabilidades son de baja gravedad y una es una vulnerabilidad crítica relacionada con la biblioteca zlib:

✗ Low severity vulnerability found in apt/libapt-pkg6.0
  Description: Improper Verification of Cryptographic Signature
  Info: https://snyk.io/vuln/SNYK-DEBIAN11-APT-522585
  Introduced through: apt/libapt-pkg6.0@2.2.4, apt@2.2.4
  From: apt/libapt-pkg6.0@2.2.4
  From: apt@2.2.4 > apt/libapt-pkg6.0@2.2.4
  From: apt@2.2.4
  Image layer: Introduced by your base image (node:lts-bullseye-slim)

✗ Critical severity vulnerability found in zlib/zlib1g
  Description: Out-of-bounds Write
  Info: https://snyk.io/vuln/SNYK-DEBIAN11-ZLIB-2976151
  Introduced through: meta-common-packages@meta
  From: meta-common-packages@meta > zlib/zlib1g@1:1.2.11.dfsg-2+deb11u1
  Image layer: Introduced by your base image (node:lts-bullseye-slim)
  Fixed in: 1:1.2.11.dfsg-2+deb11u2

Cómo corregir las vulnerabilidades de las imágenes de Docker

Una forma rápida y eficaz de mantener actualizado el software seguro de tu imagen de Docker es volver a compilarla. Para obtener estas actualizaciones, dependerías de la imagen base de Docker upstream que uses. Otra opción es instalar explícitamente actualizaciones del sistema operativo para los paquetes, incluidas las correcciones de seguridad.

Con la imagen oficial de Node.js para Docker, es posible que el equipo tarde en publicar actualizaciones de la imagen, por lo que volver a compilar la imagen de Docker de Node.js 20.9.0-bullseye-slim o lts-bullseye-slim no será eficaz. La otra opción es administrar tu propia imagen base con software actualizado de Debian. En nuestro Dockerfile, podemos hacerlo de la siguiente manera:

RUN apt-get update && apt-get upgrade -y

Ejecutemos el análisis de seguridad de Snyk después de compilar la imagen de Docker de Node.js con la instrucción RUN que acabamos de agregar:

✗ Low severity vulnerability found in apt/libapt-pkg6.0
  Description: Improper Verification of Cryptographic Signature
  Info: https://snyk.io/vuln/SNYK-DEBIAN11-APT-522585
  Introduced through: apt/libapt-pkg6.0@2.2.4, apt@2.2.4
  From: apt/libapt-pkg6.0@2.2.4
  From: apt@2.2.4 > apt/libapt-pkg6.0@2.2.4
  From: apt@2.2.4
  Image layer: Introduced by your base image (node:20.9.0-bullseye-slim)
…
Tested 98 dependencies for known issues, found 43 issues.
According to our scan, you are currently using the most secure version of the selected base image

Se agregó una dependencia más del sistema operativo (98 en vez de 97), pero ahora las 43 vulnerabilidades de seguridad que afectan a esta imagen de Docker de Node.js son de baja gravedad y corregimos la vulnerabilidad crítica de seguridad de zlib. ¡Es un excelente resultado!

¿Qué pasaría si hubiéramos usado la directiva de imagen base FROM node? Mejor aún, supongamos que hubieras usado una imagen base de Docker más específica para Node.js, como esta:

FROM node:14.2.0-slim
…

✗ High severity vulnerability found in node
  Description: Memory Corruption
  Info: https://snyk.io/vuln/SNYK-UPSTREAM-NODE-570870
  Introduced through: node@14.2.0
  From: node@14.2.0
  Introduced by your base image (node:14.2.0-slim)
  Fixed in: 14.4.0

✗ High severity vulnerability found in node
  Description: Denial of Service (DoS)
  Info: https://snyk.io/vuln/SNYK-UPSTREAM-NODE-674659
  Introduced through: node@14.2.0
  From: node@14.2.0
  Introduced by your base image (node:14.2.0-slim)
  Fixed in: 14.11.0

Organization:      snyk-demo-567
Package manager:   deb
Target file:       Dockerfile
Project name:      docker-image|node
Docker image:      node:14.2.0-slim
Platform:          linux/amd64
Base image:        node:14.2.0-slim

Tested 78 dependencies for known issues, found 82 issues.

Base Image        Vulnerabilities  Severity
node:14.2.0-slim  82               23 high, 11 medium, 48 low

Recommendations for base image upgrade:

Minor upgrades
Base Image         Vulnerabilities  Severity
node:14.15.1-slim  71               17 high, 7 medium, 47 low

Major upgrades
Base Image        Vulnerabilities  Severity
node:15.4.0-slim  71               17 high, 7 medium, 47 low

Alternative image types
Base Image                 Vulnerabilities  Severity
node:14.15.1-buster-slim   55               12 high, 4 medium, 39 low
node:14.15.3-stretch-slim  71               17 high, 7 medium, 47 low

Aunque podría parecer suficiente usar una versión específica del entorno de ejecución de Node.js, como FROM node:14.2.0-slim, porque especificaste una versión concreta (14.2.0) y usaste una imagen de contenedor pequeña (gracias a la etiqueta de imagen slim), Snyk puede detectar vulnerabilidades de seguridad en dos fuentes principales:

  1. El propio entorno de ejecución de Node.js. ¿Notaste las dos vulnerabilidades de seguridad principales del informe anterior? Son problemas de seguridad conocidos públicamente en el entorno de ejecución de Node.js. La solución inmediata es actualizar a una versión más reciente de Node.js. Snyk te indica cuál es esa versión y cuál corrige el problema: la 14.11.0, como puedes ver en el resultado.

  2. Las herramientas y bibliotecas instaladas en esta imagen base de Debian, como glibc, bzip2, gcc, perl, bash, tar, libcrypt y otras. Aunque estas versiones vulnerables del contenedor quizá no representen una amenaza inmediata, ¿para qué tenerlas si no las usamos?

¿Lo mejor del informe de la CLI de Snyk? Snyk también recomienda otras imágenes base a las que puedes cambiar, para que no tengas que resolverlo por tu cuenta. Encontrar imágenes alternativas puede llevar mucho tiempo, y Snyk te ahorra ese trabajo.

En esta etapa, recomiendo lo siguiente:

  1. Si administras tus imágenes de Docker en un registro, como Docker Hub o Artifactory, puedes importarlas a Snyk fácilmente para que la plataforma detecte estas vulnerabilidades. Además, recibirás recomendaciones en la interfaz de usuario de Snyk y podrás supervisar continuamente tus imágenes de Docker para detectar nuevas vulnerabilidades de seguridad.

  2. Usa la CLI de Snyk en tu automatización de CI. La CLI es muy flexible, y precisamente por eso la creamos: para que puedas aplicarla a cualquiera de tus flujos de trabajo personalizados. También tenemos Snyk for GitHub Actions, si te interesa.

Para conocer más formas de administrar las vulnerabilidades en imágenes de contenedor, consulta nuestra guía de seguridad de contenedores.

8. Usa compilaciones de varias etapas

Las compilaciones de varias etapas son una excelente forma de pasar de un Dockerfile sencillo, pero potencialmente propenso a errores, a pasos separados para compilar una imagen de Docker y así evitar que se filtre información confidencial. Además, podemos usar una imagen base de Docker más grande para instalar las dependencias y compilar los paquetes npm nativos que sean necesarios, y luego copiar todos estos archivos a una imagen base pequeña para producción, como nuestro ejemplo con Alpine.

Evita las filtraciones de información confidencial

El caso de uso para evitar la filtración de información confidencial es más común de lo que crees.

Si creas imágenes de Docker para el trabajo, es muy probable que también mantengas paquetes npm privados. En ese caso, seguramente necesitaste encontrar una forma de poner el secreto NPM_TOKEN a disposición de npm install.

Este es un ejemplo de lo que digo:

FROM node:20.9.0-bullseye-slim

RUN apt-get update && apt-get install -y --no-install-recommends dumb-init
ENV NODE_ENV production
ENV NPM_TOKEN 1234
WORKDIR /usr/src/app
COPY --chown=node:node . .
#RUN npm ci --only=production
RUN echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > .npmrc && \
   npm ci --only=production
USER node
CMD ["dumb-init", "node", "server.js"]

Sin embargo, al hacerlo, el archivo .npmrc queda con el token secreto de npm dentro de la imagen de Docker. Podrías intentar mejorarlo al eliminarlo después, así:

RUN echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > .npmrc && \
   npm ci --only=production
RUN rm -rf .npmrc

Sin embargo, ahora el archivo .npmrc está disponible en otra capa de la imagen de Docker. Si esta imagen de Docker es pública o alguien puede acceder a ella de alguna manera, tu token queda comprometido. Una mejor solución sería la siguiente:

RUN echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > .npmrc && \
   npm ci --only=production; \
   rm -rf .npmrc

El problema ahora es que el Dockerfile en sí debe tratarse como un recurso secreto, porque contiene el token secreto de npm.

Por suerte, Docker permite pasar argumentos al proceso de compilación:

ARG NPM_TOKEN
RUN echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > .npmrc && \
   npm ci --only=production; \
   rm -rf .npmrc

Y luego lo compilamos así:

$ docker build . -t nodejs-tutorial --build-arg NPM_TOKEN=1234

Sé que pensabas que ya habíamos terminado, pero lamento decepcionarte.

Así es la seguridad: a veces, lo que parece obvio resulta ser otro escollo.

¿Cuál es el problema ahora, te preguntarás? Los argumentos de compilación que se pasan a Docker de esa manera se guardan en el registro del historial. Veámoslo con nuestros propios ojos. Ejecuta este comando:

$ docker history nodejs-tutorial

que muestra lo siguiente:

IMAGE          CREATED              CREATED BY                                      SIZE      COMMENT
b4c2c78acaba   About a minute ago   CMD ["dumb-init" "node" "server.js"]            0B        buildkit.dockerfile.v0
<missing>      About a minute ago   USER node                                       0B        buildkit.dockerfile.v0
<missing>      About a minute ago   RUN |1 NPM_TOKEN=1234 /bin/sh -c echo "//reg…   5.71MB    buildkit.dockerfile.v0
<missing>      About a minute ago   ARG NPM_TOKEN                                   0B        buildkit.dockerfile.v0
<missing>      About a minute ago   COPY . . # buildkit                             15.3kB    buildkit.dockerfile.v0
<missing>      About a minute ago   WORKDIR /usr/src/app                            0B        buildkit.dockerfile.v0
<missing>      About a minute ago   ENV NODE_ENV=production                         0B        buildkit.dockerfile.v0

¿Viste ahí el token secreto de npm? A eso me refiero.

Hay una excelente forma de administrar secretos para la imagen de contenedor, pero este es el momento de presentar las compilaciones de varias etapas como medida para mitigar este problema y mostrar cómo crear imágenes mínimas.

Introducción a las compilaciones de varias etapas para imágenes de Docker de Node.js

Al igual que el principio de separación de responsabilidades en el desarrollo de software, aplicaremos las mismas ideas para crear nuestras imágenes de Docker de Node.js. Tendremos una imagen para compilar todo lo que necesita la aplicación de Node.js para ejecutarse; en el mundo de Node.js, esto significa instalar paquetes npm y compilar módulos npm nativos si es necesario. Esa será nuestra primera etapa.

La segunda imagen de Docker, que representa la segunda etapa de la compilación de Docker, será la imagen de Docker para producción. Esta segunda y última etapa es la imagen que optimizamos y publicamos en un registro, si tenemos uno. La primera imagen, a la que llamaremos imagen de build, se descarta y queda como una imagen huérfana en el host de Docker donde se compiló, hasta que se limpia.

Este es el Dockerfile actualizado que refleja nuestro avance hasta ahora, pero separado en dos etapas:

__# --------------> The build image__
FROM node:latest AS build
RUN apt-get update && apt-get install -y --no-install-recommends dumb-init
ARG NPM_TOKEN
WORKDIR /usr/src/app
COPY package*.json /usr/src/app/
RUN echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > .npmrc && \
   npm ci --only=production && \
   rm -f .npmrc

__# --------------> The production image__
FROM node:20.9.0-bullseye-slim

ENV NODE_ENV production
COPY --from=build /usr/bin/dumb-init /usr/bin/dumb-init
USER node
WORKDIR /usr/src/app
COPY --chown=node:node --from=build /usr/src/app/node_modules /usr/src/app/node_modules
COPY --chown=node:node . /usr/src/app
CMD ["dumb-init", "node", "server.js"]

Como puedes ver, elegí una imagen más grande para la etapa de build porque podría necesitar herramientas como gcc(GNU Compiler Collection) para compilar paquetes npm nativos o para otras tareas.

En la segunda etapa, hay una notación especial para la directiva COPY que copia la carpeta node_modules/ de la imagen de Docker de compilación a esta nueva imagen base para producción.

Además, ¿ves ahora el NPM_TOKEN que se pasa como argumento de compilación a la imagen intermedia de Docker build? Ya no aparece en la salida del comando docker history nodejs-tutorial porque no existe en nuestra imagen de Docker para producción.

9. Evita los archivos innecesarios en tus imágenes de Docker de Node.js

Tienes un archivo .gitignore para evitar llenar el repositorio de Git con archivos innecesarios y posiblemente confidenciales, ¿verdad? Lo mismo aplica a las imágenes de Docker.

¿Qué es un archivo de exclusión de Docker?

Docker tiene un archivo .dockerignore que garantiza que no se envíen al daemon de Docker los archivos que coincidan con los patrones glob que contiene. Esta lista de archivos te dará una idea de lo que podrías incluir en tu imagen de Docker y que, idealmente, querríamos evitar: .dockerignore- node_modules- npm-debug.log- Dockerfile- .git- .gitignore

Como puedes ver, es muy importante excluir node_modules/, porque si no lo hubiéramos ignorado, la versión sencilla del Dockerfile con la que empezamos habría copiado tal cual la carpeta local node_modules/ al contenedor.

FROM node:20.9.0-bullseye-slim
WORKDIR /usr/src/app
COPY . /usr/src/app
RUN npm install
CMD "npm" "start"

De hecho, tener un archivo .dockerignore es aún más importante cuando se usan compilaciones de Docker de varias etapas. Para refrescar tu memoria, así se ve la segunda etapa de la compilación de Docker:

__# --------------> The production image__
FROM node:20.9.0-bullseye-slim

ENV NODE_ENV production
COPY --from=build /usr/bin/dumb-init /usr/bin/dumb-init
USER node
WORKDIR /usr/src/app
COPY --chown=node:node --from=build /usr/src/app/node_modules /usr/src/app/node_modules
COPY --chown=node:node . /usr/src/app
CMD ["dumb-init", "node", "server.js"]

La importancia de tener un archivo .dockerignore es que, cuando ejecutamos COPY . /usr/src/app en la segunda etapa del Dockerfile, también copiamos cualquier carpeta node_modules/ local a la imagen de Docker. Eso no se debe hacer, ya que podríamos copiar código fuente modificado dentro de node_modules/.

Además, como usamos el comodín COPY ., podríamos copiar a la imagen de Docker archivos confidenciales con credenciales o configuraciones locales.

En resumen, un archivo .dockerignore permite:

  • Excluir de la imagen de Docker las copias potencialmente modificadas de node_modules/.

  • Evitar que se expongan secretos, como las credenciales de los archivos .env o aws.json, al impedir que terminen en la imagen de Docker de Node.js.

  • Ayuda a acelerar las compilaciones de Docker porque ignora archivos que, de otro modo, invalidarían la caché. Por ejemplo, si se modificara un archivo de registro o un archivo de configuración del entorno local, se invalidaría la caché de la imagen de Docker en esa capa donde se copia el directorio local.

10. Monta secretos en la imagen de compilación de Docker

Un aspecto que debes tener en cuenta sobre el archivo .dockerignore es que funciona como una opción de todo o nada y no se puede activar o desactivar por etapa en una compilación de Docker de varias etapas.

¿Por qué es importante? Lo ideal sería usar el archivo .npmrc en la etapa de compilación, ya que podríamos necesitarlo porque incluye un token secreto de npm para acceder a paquetes npm privados. Quizás también necesite una configuración específica de proxy o registro para descargar paquetes.

Esto significa que tiene sentido tener el archivo .npmrc disponible en la etapa de build; sin embargo, no lo necesitamos en absoluto en la segunda etapa, la imagen para producción, ni queremos que esté ahí, ya que podría incluir información confidencial, como el token secreto de npm.

Una forma de mitigar esta limitación de .dockerignore es montar un sistema de archivos local que esté disponible para la etapa de compilación, pero hay una alternativa mejor.

Docker admite una capacidad relativamente nueva llamada Docker secrets, ideal para el caso en que necesitamos usar .npmrc. Así funciona:

  • Al ejecutar el comando docker build, especificaremos argumentos de línea de comandos que definen un nuevo ID de secreto y hacen referencia a un archivo como origen del secreto.

  • En el Dockerfile, agregaremos indicadores a la directiva RUN para instalar npm de producción. Estos montan el archivo al que hace referencia el ID del secreto en la ubicación de destino: el archivo local .npmrc, que es donde queremos tenerlo disponible.

  • El archivo .npmrc se monta como secreto y nunca se copia a la imagen de Docker.

  • Por último, no olvidemos agregar el archivo .npmrc al contenido del archivo .dockerignore para asegurarnos de que no llegue a ninguna de las imágenes, ni a la de compilación ni a la de producción.

Veamos cómo funciona todo en conjunto. Primero, el archivo .dockerignore actualizado:

.dockerignore
node_modules
npm-debug.log
Dockerfile
.git
.gitignore
.npmrc

Luego, el Dockerfile completo, con la directiva RUN actualizada para instalar paquetes npm y especificar el punto de montaje de .npmrc:

__# --------------> The build image__
FROM node:latest AS build
RUN apt-get update && apt-get install -y --no-install-recommends dumb-init
WORKDIR /usr/src/app
COPY package*.json /usr/src/app/
RUN --mount=type=secret,mode=0644,id=npmrc,target=/usr/src/app/.npmrc npm ci --only=production

__# --------------> The production image__
FROM node:20.9.0-bullseye-slim

ENV NODE_ENV production
COPY --from=build /usr/bin/dumb-init /usr/bin/dumb-init
USER node
WORKDIR /usr/src/app
COPY --chown=node:node --from=build /usr/src/app/node_modules /usr/src/app/node_modules
COPY --chown=node:node . /usr/src/app
CMD ["dumb-init", "node", "server.js"]

Y, por último, el comando que compila la imagen de Docker de Node.js:

$ docker build . -t nodejs-tutorial --secret id=npmrc,src=.npmrc

Nota: Docker secrets es una función nueva de Docker. Si usas una versión anterior, quizás tengas que habilitar BuildKit de la siguiente manera:

$ DOCKER_BUILDKIT=1 docker build . -t nodejs-tutorial --build-arg NPM_TOKEN=1234 --secret id=npmrc,src=.npmrc

Resumen

Llegaste hasta el final y creaste una imagen base de Docker de Node.js optimizada. ¡Excelente trabajo!

Con este último paso concluye toda la guía para contenerizar aplicaciones web de Node.js en Docker, teniendo en cuenta las optimizaciones de rendimiento y seguridad necesarias para garantizar que creemos imágenes de Docker de Node.js listas para producción.

Te recomiendo mucho consultar estos recursos adicionales:

Una vez que crees imágenes base de Docker seguras y de alto rendimiento para tus aplicaciones de Node.js, encuentra y corrige las vulnerabilidades de tus contenedores con una cuenta gratuita de Snyk.

Seguridad de contenedores centrada en los desarrolladores

Snyk encuentra y corrige automáticamente vulnerabilidades en imágenes de contenedores y cargas de trabajo de Kubernetes.