Creación de un archivo Docker para una aplicación NodeJS desde cero

En este post, crearemos un Dockerfile eficiente para una aplicación NodeJS desde cero. Docker y NodeJS son dos tecnologías interesantes que han surgido en esta década.

· 9 min de lectura
Creación de un archivo Docker para una aplicación NodeJS desde cero

Si no sabes mucho sobre Docker, aquí tienes un post que puede ayudarte a empezar. Juntas, estas dos tecnologías pueden ayudarte a construir aplicaciones web usando NodeJS y desplegarlas en contenedores Docker. Principalmente, al acoplar tus aplicaciones NodeJS, puedes desplegar estas aplicaciones de manera eficiente en cualquier plataforma que soporte Docker.

Creación de una aplicación NodeJS


Para servir nuestro ejemplo, crearemos una aplicación NodeJS muy básica. Esto es porque nuestra intención principal es entender la parte de Docker. Por lo tanto, simplemente queremos una aplicación NodeJS que pueda recibir peticiones entrantes y devolver una respuesta.

El framework Express nos permite construir una aplicación de este tipo con bastante facilidad.

Crearemos el directorio de nuestro proyecto y lo llamaremos docker-nodejs-app. Dentro de esta carpeta, crearemos un archivo llamado index.js.

const express = require('express');

const app = express();

app.get('/', (req, res) => {
    res.send("Hello World from Docker NodeJS App")
})

app.listen(8080, () => {
    console.log("Listening to requests on Port 8080")
})

Además, creamos un archivo package.json en la misma carpeta. Esencialmente, este archivo especifica las dependencias que requiere nuestra aplicación NodeJS.

{
    "dependencies": {
        "express":"*"
    },
    "scripts": {
        "start":"node index.js"
    }
}

A continuación se muestra la estructura de nuestro proyecto.

.
├── index.js
└── package.json

Con esto, podemos decir que hemos terminado con nuestra aplicación NodeJS. Como puedes ver, es una aplicación muy sencilla que utiliza el framework Express para exponer un endpoint. Cada vez que pulsemos ese endpoint raíz, obtendremos el mensaje Hello World.

Además, al final configuramos nuestra aplicación para que escuche el puerto 8080.

Entender cómo se ejecuta realmente una aplicación NodeJS
Ahora que nuestra aplicación NodeJS está lista, vamos a entender cómo funciona realmente. Esta comprensión nos ayudará primordialmente a diseñar un Dockerfile eficiente para la aplicación NodeJS.

A continuación se muestra una ilustración para demostrar cómo se puede iniciar una aplicación NodeJS típica.

Como se muestra arriba, una aplicación NodeJS típica requiere dos pasos:

PASO 1: Instalar las dependencias para la aplicación. Para ello, debemos emitir el comando npm install.

PASO 2: Una vez instaladas las dependencias, podemos iniciar la aplicación. Para ello, el comando es npm run start.

Con esto, ahora podemos ver claramente que si necesitamos crear un Dockerfile para la aplicación NodeJS, definitivamente tenemos que cuidar estos dos pasos.

Dockerfile para la aplicación NodeJS


Ahora que tenemos claro lo que hay que hacer, vamos a empezar el proceso de Dockerización de nuestra aplicación NodeJS. En otras palabras, vamos a armar un Dockerfile para nuestra aplicación.

Para ello, creamos un nuevo archivo llamado Dockerfile (sin extensión) en la raíz de nuestro proyecto docker-nodejs-app.

Además, para entender mejor las cosas, vamos a repasar el proceso de forma iterativa.

Preparando un Dockerfile mínimo para una aplicación NodeJS


Como primera prueba, a continuación está nuestro Dockerfile.

#Specify a base image
FROM node:alpine

#Install dependencies
RUN npm install 

#Default command
CMD ["npm","start"]

Aquí, primero obtenemos una imagen base llamada node:alpine. Esta es una imagen docker ligera de node que tiene node y npm ya instalados. A continuación, ejecutamos npm install. Y por último, adjuntamos el comando npm start a la imagen.

Podemos construir una imagen Docker Nodejs a partir de este Dockerfile usando el siguiente comando en el nivel de proyecto raíz. Recuerda el punto al final. Este punto especifica el contexto de construcción para nuestra imagen. En este caso, el contexto de construcción es nuestro directorio de proyecto actual.

docker build .

Sin embargo, obtendremos un mensaje de error como el siguiente. Elementalmente, dice que el proceso de imagen de construcción de Docker no pudo encontrar el archivo package.json. A pesar de que el archivo estaba presente en nuestro proyecto.

Entendamos por qué apareció este mensaje de error y cómo podemos corregirlo.

Arreglando el Dockerfile


Para arreglar el Dockerfile, primero debemos entender lo que está sucediendo detrás de las escenas con nuestro primer intento.

La siguiente ilustración puede ayudar a aclarar las cosas.

El diagrama anterior muestra que inicialmente sólo tenemos la imagen base node:alpine. Tiene una instantánea particular del sistema de archivos. Cuando esta imagen base se utiliza para crear un contenedor Docker a través del proceso de construcción, se crea una nueva imagen donde se copia el sistema de archivos de la imagen base.

Sin embargo, como se puede ver, no hay rastro del archivo package.json dentro del contenedor. Esto se debe a que la imagen base node:alpine no sabe nada de este archivo. Este archivo se encuentra actualmente en nuestro disco duro y no está conectado a la imagen base.

Por lo tanto, para solucionar este problema, tenemos que copiar los archivos de nuestro proyecto en el contenedor como uno de los pasos en el Dockerfile. Teniendo esto en cuenta, ahora podemos actualizar el Dockerfile como se indica a continuación:

#Specify a base image
FROM node:alpine

#Copy the project
COPY ./ ./

#Install dependencies
RUN npm install 

#Default command
CMD ["npm","start"]

Fíjate en la sentencia COPY que hemos añadido. Básicamente, toma dos argumentos. El primer argumento (./) es la ruta de la carpeta en nuestro escritorio relativa al contexto de construcción. El segundo argumento (./) es la ubicación donde queremos copiar en nuestro contenedor. En este caso, es la ruta raíz del contenedor.

Podemos volver a ejecutar el proceso de construcción de Docker con el siguiente comando. Esta vez, también etiquetaremos la imagen. El etiquetado se puede hacer fácilmente usando -t flag.

docker build -t progressivecoder/docker-nodejs-app .

Esta vez, nuestra imagen se construirá con éxito. Es posible que veas un par de advertencias en el proceso, pero se pueden ignorar.

Ahora también podemos ejecutar el contenedor utilizando la etiqueta de imagen.

docker run progressivecoder/docker-nodejs-app

Si todo va bien, veremos la siguiente entrada de registro que muestra que nuestra aplicación NodeJS se inicia con éxito.

Sin embargo, al intentar acceder a http://localhost:8080, obtendremos un error.

Configuración de la asignación de puertos


A pesar de que la imagen Docker fue construida con éxito, todavía no pudimos acceder a nuestro servidor en http://localhost:8080. Claramente, todavía hay algo que falta.

La siguiente ilustración puede ayudar a arrojar algo de luz sobre el issue.

Hacemos una petición a través de nuestro navegador a la red localhost de nuestro ordenador. Por defecto, ningún tráfico que llegue a nuestro ordenador se dirige al contenedor. El contenedor esencialmente tiene su propio conjunto aislado de puertos.

Para establecer la conexión entre nuestro localhost (o cualquier otra máquina) y el puerto del contenedor, necesitamos configurar un mapeo de puertos. En otras palabras, este mapeo de puertos especificaría cómo la solicitud entrante debe ser reenviada al puerto del contenedor apropiado.

Para especificar este mapeo de puertos, tenemos que cambiar el comando que estamos usando para ejecutar nuestros contenedores. A continuación se muestra el comando:

docker run -p 8080:8080 progressivecoder/docker-nodejs-app

Aquí estamos usando -p flag para asignar el puerto 8080 de nuestro localhost al puerto 8080 del contenedor.

El contenedor arranca de nuevo. Y ahora podemos ver que nuestro mensaje de Hola Mundo se ha mostrado con éxito en el navegador. Ahora tenemos un contenedor Docker NodeJS en funcionamiento que es capaz de servir las peticiones entrantes.

NOTA: Esta regla sólo es aplicable para el tráfico entrante. Nuestros contenedores siguen siendo capaces de comunicarse con el exterior. Acabamos de verlo en acción cuando el comando npm install fue capaz de sacar las dependencias de internet.

Especificar el directorio de trabajo correcto para el contenedor

Aunque ahora tenemos un contenedor Docker NodeJS en funcionamiento y también podemos acceder al endpoint único, nos queda una cosa más. Para ver esa cosa, vamos a iniciar nuestro contenedor de nuevo. Sin embargo, esta vez intentaremos acceder al shell del contenedor utilizando el siguiente comando.

docker run -it progressivecoder/docker-nodejs-app sh

Esto iniciará el contenedor en modo interactivo y también nos proporcionará una interfaz de línea de comandos. A continuación, accedemos al sistema de archivos mediante el comando ls.

Como puedes ver arriba, los archivos que hemos creado en nuestro proyecto (package.json, index.js entre otros) están todos presentes en el directorio raíz.

Esta no es una configuración ideal. En este caso funciona bien. Sin embargo, este tipo de configuración puede dar lugar a problemas si tenemos archivos o carpetas con nombres similares en nuestro proyecto.

Por ejemplo, nuestra aplicación NodeJS puede fácilmente tener una carpeta llamada lib. Con esta configuración, podríamos sobrescribir accidentalmente las carpetas existentes de la imagen base.

Para solucionar esto, añadiremos otro comando al Dockerfile de nuestro proyecto. A continuación se muestra el Dockerfile actualizado.

#Specify a base image
FROM node:alpine

#Specify a working directory
WORKDIR /usr/app

#Copy the project
COPY ./ ./

#Install dependencies
RUN npm install 

#Default command
CMD ["npm","start"]

Este nuevo comando Docker WORKDIR especificará un directorio de trabajo para nuestro proyecto dentro del contenedor. Cualquier paso posterior en el Dockerfile ocurrirá en relación con el directorio de trabajo especificado.

Ahora podemos volver a construir una nueva imagen utilizando el comando docker build. A continuación, ejecutamos el comando docker run en modo interactivo con acceso al shell. Esta vez, entraremos directamente en el directorio de trabajo. Al inspeccionar el sistema de archivos, deberíamos ver los archivos de nuestro proyecto.

Evitar las construcciones innecesarias y minimizar el reventón de la caché
Ya casi hemos terminado con nuestro Dockerfile para la aplicación NodeJS. Sin embargo, todavía queda una pequeña cosa que puede hacer que nuestro proceso de Dockerfile sea más eficiente.

Para demostrarlo, haremos un pequeño cambio en el código de nuestra aplicación. Básicamente, cambiaremos ligeramente el mensaje de Hola Mundo.

const express = require('express');

const app = express();

app.get('/', (req, res) => {
    res.send("Hello World Again from Docker NodeJS App")
})

app.listen(8080, () => {
    console.log("Listening to requests on Port 8080")
})

A continuación, construiremos una nueva imagen Docker utilizando el comando docker build. Si observamos los registros del proceso de construcción de Docker (Paso 4/5), podemos ver que nuestras dependencias se están descargando de nuevo. Sin embargo, no hemos cambiado nada a nivel de dependencias. Y sin embargo, el proceso de construcción de Docker está reconstruyendo todo desde cero y no está utilizando la caché.

En el paso 3, Docker build copia nuestro archivo de proyecto y ve que ha modificado el index.js. Por lo tanto, todos los pasos futuros tienen que ser ejecutados de nuevo sin utilizar la caché.

Esta no es una situación ideal para proyectos grandes con una tonelada de dependencias. Cada vez, el proceso de construcción de Docker npm va a tomar un montón de tiempo, incluso si simplemente estamos haciendo algunos cambios menores en el código.

Para mejorar las cosas, podemos hacer una pequeña modificación en nuestro Dockerfile. Básicamente, podemos copiar los archivos de nuestro proyecto en etapas.

Vea el siguiente Dockerfile para comprobar las modificaciones. Primero, copiamos el archivo package.json. Luego, ejecutamos npm install. Por último, copiamos los archivos restantes.

#Specify a base image
FROM node:alpine

#Specify a working directory
WORKDIR /usr/app

#Copy the dependencies file
COPY ./package.json ./

#Install dependencies
RUN npm install 

#Copy remaining files
COPY ./ ./

#Default command
CMD ["npm","start"]

Después de este cambio, podemos construir la imagen Docker de NodeJS una vez más usando el comando docker build. Luego, hacemos otro cambio menor en nuestro archivo index.js. Y luego construimos de nuevo para ver nuestras modificaciones en acción. Esta vez, podemos ver que la instalación de npm no ocurrió de nuevo.

Como podemos ver en la captura de pantalla anterior, todos los pasos hasta el Paso 5 están usando la caché. Esto se debe a que no hay ningún cambio en nuestro proyecto hasta ese paso. De esta manera evitamos construcciones innecesarias y también minimizamos el uso de la caché.

Conclusión


Con esto, hemos conseguido crear con éxito un Dockerfile para la aplicación NodeJS.

Hemos dockerizado la aplicación NodeJS con un montón de optimizaciones y mejores prácticas. En el proceso, también hemos aprendido algunos errores comunes y hemos hecho algunos ajustes para evitarlos. Ahora, tenemos un gran ejemplo de Dockerfile que puede ser extendido para futuras necesidades también.

El código de esta aplicación está disponible en Github como referencia.

Plataforma de cursos gratis sobre programación