Tutorial de introducción a Docker

Docker

En este tutorial voy a explicar todo lo que necesitas para aprender a usar Docker. Con Docker simplificarás mucho tanto tu desarrollo en local como tus despliegues en producción. Al final de este tutorial, sabrás crear contenedores, administrarlos y gestionarlos. Además de ver Docker, también veremos Docker Compose, una herramienta que te permitirá sincronizar todos tus contenedores a partir de archivos muy sencillos.

Requisitos

Antes de comenzar el tutorial, necesitarás ciertos conocimientos básicos de la terminal de comandos y de Node,.js. Si no los tienes, puedes consultar los siguientes tutoriales.

Y esto es todo. A continuación veremos una introducción a Docker y a la virtualización.

Introducción a Docker

Antes de comenzar, vamos a explicar ciertos conceptos, como cuáles son los tipos de virtualización que existen o qué es un contenedor.

Virtualización

La virtualización es un proceso de emulación en donde un sistema operativo anfitrión o host emula un sistema operativo cliente. Por ejemplo, podías emular Debian Linux sobre Ubuntu o Windows como anfitriones.

Existen tres tipos de virtualización, que son los siguientes:

  • Paravirtualización: Es un tipo de virtualización en donde un sistema operativo anfitrión entrega la mayor cantidad de acceso a su hardware al sistema cliente, por lo que apenas se virtualizan componentes hardware.
  • Virtualización parcial: Mediante este tipo de virtualización, solamente algunos componente hardware del cliente se virtualizan en el sistema anfitrión.
  • Virtualización completa: Mediante una virtualización completa, todos los componentes hardware del cliente son virtualizados por el anfitrión.

Docker no usa ninguno de estos tipos de virtualización, ya que usa directamente el Kernel del sistema anfitrión. Esto se traduce en un rendimiento muy superior a todas las alternativas de virtualización.

Qué es un contenedor

Un contenedor es una forma de empaquetar tus aplicaciones junto con todas las dependencias que tenga, incluyendo sus archivos de configuración.

Usando contenedores podremos generar un paquete que contendrá el código HTML, el código PHP, el código JS o el código de cualquier otro lenguaje de nuestras aplicaciones. Estos paquetes son portables, por lo que podremos compartirlos de una forma muy sencilla con otros desarrolladores o administradores. Se comparten mediante imágenes creadas a partir de los contenedores.

En comparación con una máquina virtual, un contenedor es muy ligero. Un imagen de un contenedor puede pesar unos pocos MB, mientras que una máquina virtual puede llegar a pesar varios GB.

Una máquina virtual necesita un Hardware, un Kernel y finalmente las aplicaciones que usarás de la máquina virtual. Este Kernel podría ser el de Linux o el de Windows. En el caso de Docker, únicamente se virtualiza la capa de aplicaciones, ya que el Kernel que se usa es el del propio sistema operativo anfitrión.

Por qué usar contenedores

Antes de usar contenedores, teníamos un problema, y es que un usuario puede estar usando Windows con la versión 18 de Node.js, mientras que otro podría estar usando la versión 20 de Node.js desde Linux. Esto mismo podría repetirse con MySQL u otra dependencia. Esto puede ocasionar conflictos, ya que ciertas funcionalidades pueden funcionar correctamente desde un entorno pero no desde otro. Además, este mismo esquema se repite para cualquier desarrollador que se una al equipo, siendo muy extraño que todos los desarrolladores compartan exactamente el mismo Stack.

La instalación de las dependencias en el mismo sistema operativo puede también ser muy diferente, por lo que en algún momento está claro que tendremos conflictos. Las aplicaciones tienen múltiples dependencias, por lo que muchas veces habrá alguna dependencia que difiera con la de otro desarrollador.

Afortunadamente, si usas contenedores, podrás automatizar todo este proceso mediante unos pocos comandos. Lo que haremos será generar un empaquetado que contenga el código de nuestra aplicación con independencia del lenguaje que usemos, así como Node.js, MySQL y los archivos de configuración o variables de entorno.

Tipos de contenedores

Los contenedores se almacenan en repositorios especializados en contenedores del mismo modo que almacenas tu código en GitHub. Existen dos tipos de repositorios para contenedores:

  • Repositorios públicos: Son repositorios de acceso público. El repositorio público más conocido es DockerHub, en donde encontrarás contenedores con Node.js, MySQL, Postgres, Python, Golang y más. También encontrarás contenedores con las diferentes distribuciones de Linux.
  • Repositorios privados: Son repositorios privados que no se comparten de forma pública, por lo que tendrás que obtener permiso de acceso.

Imágenes de los contenedores

Los contenedores se comparten mediante imágenes. Si por ejemplo necesitas un contenedor que contenga Alpine Linux, entonces te descargarás la imagen de Alpine Linux desde DockerHub o cualquier otro repositorio. Podrás poner a funcionar este contenedor un tu ordenador una vez descargado. Pero no solo esto, ya que además podrías tener otras imágenes tanto de Alpine Linux como de Ubuntu o de cualquier otra distribución funcionando a la vez y sin conflictos entre ellas

Una imagen es un empaquetado que contiene tanto las dependencias como el código necesario para que una aplicación funcione. La imagen es aquello que compartirás con tus compañeros de desarrollo. A partir de las imágenes, se crearán los contenedores en tu sistema. Mediante la ejecución de un comando mantendrás todas las dependencias funcionando en consonancia con el entorno.

Una imagen no es lo mismo que un contenedor. Las imágenes pueden existir sin un contenedor, mientras que los contenedores necesitan ejecutar una imagen. Un contenedor Docker es una aplicación o servicio de software autónomo y ejecutable. Por otro lado, una imagen de Docker es la plantilla cargada en el contenedor para ejecutarlo, como un conjunto de instrucciones. Almacenarás imágenes para compartirlas y reutilizarlas, pero crearás y destruirás contenedores continuamente.

Por otro lado, los contenedores son capas de imágenes en donde la capa inferior suele ser una distribución de Linux. La más usada es Alpine Linux por ser bastante ligera. Sobre esta imagen se montan más imágenes hasta llegar a la capa de aplicación, que puede incluir Node.js, Python, PHP, Laravel o cualquier otra aplicación. La gran ventaja de los contenedores es que su estructura de capas las hace muy ligeras.

Despliegue con contenedores

Cuando no usas contenedores, el equipo de operaciones debe obtener el código el equipo de desarrollo, asegurarse de que no existen conflictos entre las dependencias, revisar los archivos de configuración y levantar nuevos servidores si es necesario. Esto es muy propenso a acarrear problemas, ya que el equipo de desarrollo puede haber incluido código que funciona con una versión de una dependencia pero no con la versión de producción. Nadie sabrá la existencia del problema hasta que la aplicación esté en producción y falle.

Estos problemas no existen cuando usas contenedores, ya que los desarrolladores utilizan una imagen que contiene las mismas versiones de todas las dependencias necesarias. Esta imagen necesita únicamente que los desarrolladores tengan instalado el entorno de ejecución de Docker en su sistema.

Instalación de Docker

Vamos a ver cómo instalar la herramientas de escritorio de Docker, llamada Docker Desktop, que además también incluye otras herramientas como Docker Compose:

  • Docker Desktop: Docker Desktop es una máquina virtual que permite ejecutar contenedores y que funciona con Linux, estando optimizada para este sistema. La máquina virtual permite acceder tanto al sistema de archivos como a la red tanto interna como externa.
  • Docker Compose: Docker Compose es una herramienta de línea de comandos que, tal y como veremos más adelante, permite orquestrar contenedores.

En Windows, Docker funcionará de forma nativa gracias a WSL2. Se trata del subsistema de Linux para Windows, que viene activado por defecto en las versiones recientes de Windows. Si usas una versión antigua, entonces consulta el tutorial de activación de WSL en Windows.

Para instalar Docker accede a la página de instalación de Docker Desktop. Esta página mostrará todas las alternativas de instalación según tu sistema. En Windows se mostrará un enlace de descarga a la herramienta de instalación. Esto es por ejemplo lo que verás en Windows:

Si instalas Docker en MacOS, verás un enlace de descarga para Mac con Apple Chip. Docker Desktop tiene también paquetes precompilados para Debian, Fedora, Ubuntu o Arch. Lo único que necesitarás para instalar Docker son permisos de administrador.

Una vez instalado Docker, ejecuta el acceso directo creado para Docker Desktop en tu escritorio. Una vez iniciado, deberías poder ver algo así:

Desde aquí podrás ver accesos a los contenedores, imágenes, volúmenes y entornos. Sin embargo, en este tutorial usaremos la línea de comandos para casi todo.

Si accedes a Docker Hub podrás encontrar una librería de imágenes de contenedores creados por otros usuarios. Realmente, aquí encontrarás contenedores para todo aquello que puedas necesitar.

En Docker Hub podrás encontrar diferentes verisones para cada imagen, funcionando con diferentes sistemas o versiones. Por ejemplo, podrías querer usar un contenedor con la versión 5  de MySQL y otro con la versión 8. Además podrás ver los comandos necesarios para descargar cada imagen.

Gestión de Imágenes en Docker

Vamos a ver ahora los comandos necesarios para gestionar imágenes en Docker. Para ejecutarlos, deberás asegurarte de que Docker Desktop está funcionando. Para comenzar, debes iniciar la terminal de comandos. Si usas Windows, es recomendable que uses una terminal de Linux o Git Bash, por ejemplo.

Cómo ver una lista de imágenes

Para ver una lista de imágenes debes ejecutar el comando docker images, que mostrará un listado completo con todas las imágenes descargadas. Al principio la lista estará vacía. En mi caso, estas son las imágenes instaladas:

$ docker images
REPOSITORY   TAG       IMAGE ID       CREATED       SIZE
node         20        281da7fc7f6f   3 weeks ago   1.1GB
node         latest    1b9d5f3b36bf   3 weeks ago   1.1GB
mongo        latest    b8df2163f9aa   5 weeks ago   755MB
mysql        latest    a88c3e85e887   7 weeks ago   632MB

Cómo descargar una imagen

Para descargar una imagen, debes usar el comando docker pull IMAGEN, reemplazando IMAGEN por el nombre de la imagen a descargar. Por ejemplo, para descargar la imagen de Node.js usarías este comando:

$ docker pull node

Esto descargará la imagen de la última versión de Node.js por defecto. Tal y como podrás apreciar en la terminal, se descargarán varias capas, que son necesaras para la ejecución de Docker. Si estas capas son necesarias para la ejecución de otros contenedores, estas se descargarán una única vez, siendo así un proceso muy eficiente.

Si ahora ejecutas de nuevo el comando docker images, verás que se habrá descargado la imagen de Node, con la etiqueta latest. Verás también el identificador o ID de la imagen, la fecha de creación y el tamaño que ocupa.

Ahora vamos a descargar una versión específica de la imagen anterior, indicando una etiqueta con la versión. Para ello debes ejecutar el comando docker pull IMAGEN:VERSION, reemplazando IMAGEN por el nombre de la imagen a descargar y VERSION por la versión deseada. En este ejemplo, descargaremos la versión 20 de Node.js:

docker pull node:18

Ahora verás que la ejecución es muy rápida, ya que no se descargarán las capas ya descargadas.

Si quieres saber el nombre de imagen a descargar, así como las versiones disponibles, siempre puedes consultar Docker Hub. Por ejemplo, podrás ver que el nombre de la imagen de MySQL es myslql. Para decargar la última versión de MySQL, deberías usar este comando:

docker pull mysql

Para descargar la imagen de la última versión de MongoDB usarías este comando:

docker pull mongo

Cómo eliminar una imagen

Ahora vamos a ver cómo puedes eliminar las imágenes descargadas. Para ello usaremos el comando docker image rm IMAGEN:VERSION, reemplazando IMAGEN por el nombre de la imagen a eliminar y VERSION por la versión deseada. Por ejemplo, para eliminar la imagen de la versión 18 de Node.js usarías este comando:

docker image rm node:18

Si ahora ejecutas el comando docker images, verás que la imagen de la versión 18 de Node.js se ha eliminado.

Gestión de Contenedores en Docker

En este apartado veremos cómo crear contenedores a partir de imágenes, cómo eliminar contenedores, cómo gestionarlos y cómo conectarnos a ellos, entre otras cosas.

Cómo crear un contenedor

Para crear un contenedor a partir de una imagen debes usar el comando docker create IMAGEN, reemplazando IMAGEN por el nombre de la imagen.

Para comenzar, a modo de ejemplo, vamos a descargar la imagen de MongoDB usando este comando:

mongo pull mongo

Para crear un contenedor a partir de la imagen descargada, usarías este comando:

$ docker create mongo
dc941500655c1b452be85cbd2117e0032331a160be4671f3bd5b11cc4bbcf6b9

Se mostrará un identificador como resultado, que es el ID del contenedor creado. Lo necesitaremos para ejecutar el contenedor. También podemos usar el comando docker container create IMAGEN. De nuevo, reemplazando IMAGEN por el nombre de la imagen. El resultado será el mismo.

Cómo iniciar un contenedor

Una vez creado un contenedor, podrás iniciarlo usando su identificador mediante el comando docker start ID, reemplazando ID por el ID del contenedor que quieras iniciar.

Para iniciar el contenedor que hemos creado, usaremos este comando:

$ docker start dc941500655c1b452be85cbd2117e0032331a160be4671f3bd5b11cc4bbcf6b9
dc941500655c1b452be85cbd2117e0032331a160be4671f3bd5b11cc4bbcf6b9

Se devolverá de nuevo el identificador del contenedor creado.

Cómo ver una lista de contenedores

Para ver una lista con los contenedores creados, debes usar el comando docker ps. A continuación puedes ver el resultado, en donde verás el contenedor creado:

$ docker ps
CONTAINER ID   IMAGE     COMMAND                  CREATED         STATUS              PORTS       NAMES
dc941500655c   mongo     "docker-entrypoint.s…"   5 minutes ago   Up About a minute   27017/tcp   gallant_dubinsky

Tal y como ves, se mostrarán varios datos:

  • CONTAINER ID: Es un ID más corto. Se trata de otro identificador que puedes usar en lugar del más largo.
  • IMAGE: La imagen a partir de la cual se ha creado el contenedor, que en nuestro ejemplo es mongo.
  • COMMAND: El comando que ha usado el contenedor para ejecutarse.
  • CREATED: La fecha de creación del contenedor.
  • STATUS: El estado actual del contenedor, que en este caso nos indica que lleva activo un minuto.
  • PORTS: Los puertos que usa el contenedor, que en este caso es el puerto TCP estandarizado 27017 que usa MongoDB.
  • NAMES: El nombre que Docker le ha dado al contenedor y que podrás usar en lugar de su ID.

El comando docker ps mostrará la lista con los contenedores en ejecución, pero si quisieras ver incluso aquellos que no están en ejecución, tendrás que usar el comando docker ps -a.

Cómo parar un contenedor

Para parar la ejecución de un contenedor, debes usar el comando docker stop ID, reemplazando ID por el ID del contenedor que quieras parar.

Para frenar la ejecución del contenedor que hemos creado en nuestro ejemplo, usarás este comando:

$ docker stop dc941500655c
dc941500655c

Se mostrará como resultado el ID corto del contenedor que hemos parado. Si ejecutas el comando docker ps, verás que ya no hay ninguno en ejecución, aunque este siga creado.

Si ejecutas el comando docker ps -a verás que el contenedor ya no está en ejecución:

$ docker ps -a
CONTAINER ID   IMAGE     COMMAND                  CREATED          STATUS                     PORTS     NAMES
dc941500655c   mongo     "docker-entrypoint.s…"   15 minutes ago   Exited (0) 3 minutes ago             gallant_dubinsky

Aquí podemos ver que ya no hay puertos expuestos y que el contenedor se ha frenado hace tres minutos.

Cómo eliminar un contenedor

Para eliminar un contenedor tendrás que usar el comando docker rm ID, reemplazando ID por el ID del contenedor que quieras eliminar.

En este ejemplo eliminamos el contenedor que hemos creado usando su ID:

$ docker container rm dc941500655c
dc941500655c

También podrías usar su nombre para eliminarlo, así como para realizar cualquier otra gestión:

$ docker container rm gallant_dubinsky
gallant_dubinsky

Tal y como ves, usar nombres puede ser más cómodo. La asignación de nombres es aleatoria, pero también puedes decidir un nombre por ti mismo.

Cómo nombrar un contenedor

Puedes darle un nombre específico a un contenedor cuando lo creas. Para ello desbes usar el comando docker create --name NOMBRE IMAGEN, reemplazando IMAGEN por el nombre de la imagen y NOMBRE por el nombre que quieras darle al contenedor.

En este ejemplo creamos un contenedor de mongo con el nombre goku:

$ docker create --name goku mongo
349e70d5c1639fbe7e7e022b2b540e9b00aefe623bc77010bc4523540dadbda2

Desde ahora, ya podemos hacer referencia al contenedor usando el nombre goku. Vamos a iniciar el contenedor. Por ejemplo para iniciarlo, podemos usar este comando:

$ docker start goku
goku

Si usamos el comando docker ps, podemos ver el contenedor en ejecución:

$ docker ps
CONTAINER ID   IMAGE     COMMAND                  CREATED              STATUS          PORTS       NAMES
349e70d5c163   mongo     "docker-entrypoint.s…"   About a minute ago   Up 34 seconds   27017/tcp   goku

Cómo asignar un puerto a un contenedor

Los contenedores usan puertos, pero no se mapean por defecto con los puertos de nuestro sistema. Por ejemplo, si un contenedor de Docker como puede ser uno de Node.js usa el puerto 3000, no accederemos al servidor usando este puerto si no lo redireccionamos. Lo mismo ocurriría con el puerto 27017 de MongoDB.

Para asignar un puerto a un contenedor, usaremos el comando docker create -p:PUERTO_SISTEMA:PUERTO_CONTENEDOR IMAGEN, reemplazando IMAGEN por el nombre de la imagen, PUERTO_SISTEMA por el puerto de nuestro sistema y PUERTO_CONTENEDOR por el puerto del contenedor.

En este ejemplo vamos a crear un contenedor de mongo en el que redireccionaremos el puerto 27017 de nuestro sistema al puerto 27017 del contenedor. Además le daremos el nombre de bulma:

$ docker create -p27017:27017 --name bulma mongo
414215c8690cca5cc7f748e470df1fd5b888fd1c39cab4123fbde84c68472356

Ahora inicia el contenedor con el comando docker start bulma:

$ docker start bulma
bulma

Si ahora ejecutas el comando docker ps, verás que en el campo PORTS, se muestra la redirección de puertos que hemos asignado:

$ docker ps
CONTAINER ID   IMAGE     COMMAND                  CREATED              STATUS          PORTS                      NAMES
414215c8690c   mongo     "docker-entrypoint.s…"   About a minute ago   Up 43 seconds   0.0.0.0:27017->27017/tcp   bulma
349e70d5c163   mongo     "docker-entrypoint.s…"   10 minutes ago       Up 9 minutes    27017/tcp                  goku

Si no especificamos un puerto en nuestro sistema, Docker escogerá el puerto a usar arbitrariamente. En este ejemplo especificamos únicamente el puerto 27017 del contenedor y además le damos el nombre de vegeta:

$ docker create -p27017 --name vegeta mongo
238ae5dc3379e3f84827ebc123aad97aea15faf4b71907d1a6a92efc0864757a

Iniciaremos el contenedor mediante el comando docker start:

$ docker start vegeta
vegeta

Si ahora ejecutas el comando docker ps, verás que Docker ha decidido redirigir el puerto 51808 de nuestro sistema al puerto 27017 del contenedor:

$ docker ps
CONTAINER ID   IMAGE     COMMAND                  CREATED              STATUS          PORTS                      NAMES
238ae5dc3379   mongo     "docker-entrypoint.s…"   About a minute ago   Up 54 seconds   0.0.0.0:51808->27017/tcp   vegeta
414215c8690c   mongo     "docker-entrypoint.s…"   6 minutes ago        Up 5 minutes    0.0.0.0:27017->27017/tcp   bulma
349e70d5c163   mongo     "docker-entrypoint.s…"   15 minutes ago       Up 13 minutes   27017/tcp                  goku

Es recomendable que seas tu quien decida los puertos en lugar de Docker.

Antes de continuar, para la ejecución de los contenedores goku y vegeta con el comando docker stop y elimínalos con el comando docker rm:

Cómo ver el log de un contenedor

Para ver todo lo que ha sucedido con un contendor puedes consultar el log del mismo. Para ello puedes usar el comando docker logs ID, reemplazando ID por el identificador o el nombre del contenedor.

Para ver el log del contenedor bulma usarías este comando:

$ docker logs bulma

Los logs se muestran en formato JSON.

Para ver en tiempo real los logs de un contenedor, debes usar el flag --follow  para que el comando no te devuelva a la línea de comandos:

$ docker logs --follow bulma

Pulsa CTRL+C para cerrar el log.

El comando docker run usa por defecto la opción --follow.

Ejecución directa de imágenes con run

Existe un método alternativo que te permitirá ejecutar un contenedor sin la necesidad de descargar la imagen del contenedor. Para ello debes ejecutar el comando docker run:

docker run mongo

Se mostrarán por defecto los logs del contenedor. Pulsa CTRL+C para dejar de escuchar los logs.

Para ejecutar el comando sin que se escuchen los logs, debes agregar el flag -d:

docker run -d mongo

Si ejecutas docker ps podrás ver que el contenedor está en ejecución:

$ docker ps
CONTAINER ID   IMAGE     COMMAND                  CREATED          STATUS          PORTS       NAMES
aa43863f91f3   mongo     "docker-entrypoint.s…"   15 seconds ago   Up 14 seconds   27017/tcp   pedantic_shamir

Mediante el comando docker run también también podrás asignar puertos y nombres a tus contenedores:

docker run  --name radix -p27017:27017 -d mongo

Gestión de redes internas en Docker

Los contenedores de Docker se comunican entre sí mediante redes internas. Estas redes se gestionan mediante el comando docker network.

Muetra una lista de redes

Accede a la terminal y ejecuta el siguiente comando para listar todas las redes configuradas en Docker:

docker network ls

Verás varias redes preconfiguradas por defecto en Docker. Sin embargo, vamos a crear nuestra propia red.

Crea una red

Para crear una red usa el siguiente comando, reemplazando NOMBRERED por el nombre de la red a crear:

docker network create NOMBRERED

Vamos a crear una red a la que le daremos el nombre de mired:

docker network create mired

Si ahora ejecutas el comando docker network ls, deberías ver la nueva red en la lista.

Es importante destacar que el nombre de red asignado representará el host al que nos conectamos Docker.

Elimina una red

Para eliminar una red, debes usar este comando, reemplazando NOMBRERED por el nombre de la red a eliminar:

docker network rm NOMBRERED

Para eliminar la red mired, usaríamos este comando:

docker network rm mired

Gestión de aplicaciones con Docker

Vamos dockerizar esta aplicación de ejemplo. Sin embargo, primero veremos cómo conectarnos a un contenedor existente desde la misma.

Antes de continuar, clona la aplicación en un directorio de tu sistema y ejecuta el comando npm install para instalar sus dependencias.

Se trata de una aplicación muy sencilla que nos permite obtener un listado de platos de una base de datos creada con Mongo. Esto se lleva a cabo mediante una petición GET al endpoint /platos.

La aplicación también nos permitie crear platos mediante una petición POST el endpoint /platos, aceptando como valores el nombre y el tipo de plato, que podría ser primero, segundo o postre, por poner un ejemplo.

Crea tu aplicación

Vamos a describir la aplicación que hemos creado. Para empezar, hemos importado el framework Express y el ODM Mongoose, que simplificará el acceso y el trabajo con la base de datos NoSQL Mongo:

import express from 'express'
import mongoose from 'mongoose'

Luego hemos creado la aplicación Express y le hemos indicado que acepte JSON como entrada:

const app = express();
app.use(express.json());

Seguidamente hemos definido el esquema para el modelo Plato, que contiene los atributos nombre y tipo, ambos de tipo cadena.

const Plato = mongoose.model('Plato', new mongoose.Schema({
  tipo: String,
  tipo: String,
}))

Luego le indicamos a Mongoose que se conecte a la base de datos de Mongo, pasándole la URL de conexión como argumento:

mongoose.connect('mongodb://edu:testpass@localhost:27017/restaurante?authSource=admin')

En esta URL hemos indicado el usuario edu, el password testpass y el servidor y el puerto a los que conectarnos, que son localhost y 27017 respectivamente. Finalmente hemos indicado la base de datos a la que conectarnos que será restaurante, además del usuario que se conectará, que es el usuario admin.

El método GET /platos nos devuelve la lista completa de platos:

app.get('/platos', async (_req, res) => {
  console.log('Mostrando lista de platos...')
  const platos = await Plato.find();
  return res.send(platos)
})

El método POST /platos, nos permitirá crear un plato tras comprobar que tanto los valores nombre y tipo están presentes. Se devolverá un error si no es así o si ocurre algo inesperado:

app.post('/platos', async (req, res) => {
  try {
    console.log('Creando plato...');
    const { nombre, tipo } = req.body;

    if (!nombre || !tipo) {
      return res.status(400).send('Los campos nombre y tipo son obligatorios');
    }

    await Plato.create({ nombre, tipo }); // Utiliza las variables extraídas para crear el plato
    return res.send('Plato creado con éxito');
  } catch (error) {
    console.error('Error creando el plato:', error);
    return res.status(500).send('Ocurrió un error al crear el plato');
  }
});

Finalmente, hacemos que la aplicación escuche en el puerto 3000:

app.listen(3000, () => console.log('escuchando...'));

Este sería el código completo de la aplicación:

import express from 'express'
import mongoose from 'mongoose'

const app = express()
app.use(express.json());

const Plato = mongoose.model('Plato', new mongoose.Schema({
  nombre: String,
  tipo: String,
}))

mongoose.connect('mongodb://edu:testpass@localhost:27017/restaurante?authSource=admin')

app.get('/platos', async (_req, res) => {
  console.log('Mostrando lista de platos...')
  const platos = await Plato.find();
  return res.send(platos)
})


app.post('/platos', async (req, res) => {
  try {
    console.log('Creando plato...');
    const { nombre, tipo } = req.body;

    if (!nombre || !tipo) {
      return res.status(400).send('Los campos nombre y tipo son obligatorios');
    }

    await Plato.create({ nombre, tipo }); // Utiliza las variables extraídas para crear el plato
    return res.send('Plato creado con éxito');
  } catch (error) {
    console.error('Error creando el plato:', error);
    return res.status(500).send('Ocurrió un error al crear el plato');
  }
});

app.listen(3000, () => console.log('escuchando...'))

Conéctate a un contenedor

En lugar de instalar Docker en nuestro sistema local, vamos a usar la imagen de Mongo, así que buscaremos la imagen de Mongo en Docker Hub. Verás que existen instrucciones de configuración de Mongo. En concreto son necesarias estas dos variables de entorno:

  • MONGO_INITDB_ROOT_ USERNAME: Usuario con el que conectarse a la base datos.
  • MONGO_INITDB_ROOT_PASSWORD: La contraseña con la que conectarnos.

Has de saber que cada contenedor se configura de una forma diferente, por lo que las variables necesarias o pasos a seguir siempre serán diferentes.

Vamos a descargarnos la imagen de mongo. Para ello ejecuta este comando desde tu terminal de comandos:

docker pull mongo

Ahora vamos a crear el contenedor de mongo indicando tanto el puerto local como el de la imagen, así como el nombre del contenedor y las variables de entorno antes mencionadas, que se pasan mediante la opción -e. Finalmente indicamos la imagen a partir de la cual crear el contenedor, que será mongo:

docker create -p27017:27017 --name imongo -e MONGO_INITDB_ROOT_USERNAME=edu -e MONGO_INITDB_ROOT_PASSWORD=testpass mongo

Obtendremos un identificador tal que así como respuesta:

7d6bfd1e3043fee8b09ab36cbd12900d7081ef6a1ace93804340658b9b9d4cb6

Ahora vamos a iniciar el contenedor mediante el comando docker start:

docker start imongo

Si ejecutas el comando docker ps, podrás ver que el contenedor imongo está funcionando en el puerto 27017.

Ejecutando tu aplicación

Ahora vamos a ejecutar la aplicación que hemos creado. Para ello usa este comando:

node index.js

Deberías poder ver el mensaje escuchando... en pantalla.

Si accedes a la URL http://localhost:3000/platos, deberías ver un array vacío [] por pantalla, ya que todavía no hay platos creados.

Para crear un plato, tendremos que enviar una petición POST, para lo cual podemos usar una aplicación como Postman. En postman, realiza una petición de tipo POST a la URL http://localhost:3000/platos. Debes seleccionar raw como formato del body y seleccionar JSON. Luego pega este JSON en el body de la petición a modo de ejemplo:

{
    "nombre": "Helado",
    "tipo": "postre"
}

Al enviar la petición, deberías ver un mensaje indicando que el plato se ha creado con éxito. Aquí te dejo una captura de pantalla con la petición desde Postman:

Si ahora accedes de nuevo a la URL http://localhost:3000/platos desde tu navegador, deberías ver que ahora ya existe un plato creado:

http://localhost:3000/platos

Deberías ver algo así:

[
  {
    "_id": "660ebf4397f6130265cc20af",
    "nombre": "Helado",
    "tipo": "postre",
    "__v": 0
  }
]

Tal y como ves, hemos usado el contenedor de MongoDB desde nuestra aplicación. Ahora, el siguiente paso será Dockerizar tu aplicación.

Crea un contenedor para tu aplicación

Vamos a aprender a crear un contenedor para nuestra aplicación. El primer paso será crear un archivo llamado Dockerfile en la carpeta raíz de la aplicación. Este nombre de archivo es un estándar, así que no podrá tener otro nombre.

Lo primero que vamos a hacer en el archivo es indicar la imagen a partir de la cual queremos crear el contenedor, que en este caso será la versión 20 de node. Para ello usamos FROM seguido del nombre de la imagen y su versión:

FROM node:20

Ahora vamos a crear una carpeta en la cual incluir el código de nuestra aplicación. Para ello ejecutaremos el comando mkdir en el interior del contenedor. Para ejecutar comandos desde el archivo Dockerfile se usa la sentencia RUN:

RUN mkdir -p /home/app

Luego copiaremos el contenido del directorio en el que estamos actualmente al directorio que hemos creado mediante el comando COPY:

COPY . /home/app

El . indica el directorio en el que se encuentra el archivo Dockerfile. Desde aquí es desde donde se copiará el código de la aplicación. Por otro lado, el directorio /home/app, hace referencia al sistema de archivos del contenedor.

Luego vamos a exponer el puerto 3000 que usará nuestra aplicación mediante el comando EXPOSE:

EXPOSE 3000

Finalmente, indicamos el comando que ejecutará nuestra aplicación, además de sus argumentos. Podemos hacerlo mediante el comando CMD:

CMD ["node", "/home/app/index.js"]

Es importante que uses la ruta completa /home/app/index.js, ya que se supone que estamos en el directorio raíz del contenedor.

Como alternativa, vamos a usar el comando WORKDIR /home/app para situarnos en el directorio de la aplicación antes de iniciarla.

Este será el código completo del archivo Dockerfile que hemos creado:

FROM node:20

RUN mkdir -p /home/app

COPY . /home/app

WORKDIR /home/app
EXPOSE 3000

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

Gestiona la red de tu aplicación

Ya hemos terminado con el archivo Dockerfile, pero ahora tenemos un nuevo problema. El servidor localhost que hemos indicado en nuestra aplicación al conectarnos a Mongo, hacía referencia a nuestro sistema local, cuyo puerto estaba mapeado al contenedor.

El problema es que ahora localhost hace referencia al propio contenedor. Para ello debemos cambiar la ruta que usamos para conectarnos a Mongo tal que así:

mongoose.connect('mongodb://edu:testpass@imongo:27017/restaurante?authSource=admin')

Es decir, hemos reemplazado localhost por el nombre del contenedor en donde se encuentra Mongo, que es el contenedor imongo.

Ahora vamos recrear e nuevo el contenedor imongo que hemos creado antes, de forma que esté en la misma red que nuestra aplicación. Para ello, primero para la ejecución del contenedor con el comando docker stop imongo. Luego elimínalo con el comando docker rm imongo.

Después crea el contenedor de nuevo, pero indicándole la red mediate la opción --network:

docker create -p27017:27017 --name imongo --network mired -e MONGO_INITDB_ROOT_USERNAME=edu -e MONGO_INITDB_ROOT_PASSWORD=testpass mongo

Dockeriza tu aplicación

Hemos llegado al último paso, en el que crearemos la imagen de nuestra aplicación. Para ello usaremos el comando docker build.

El comando build creará la imagen a partir del archivo Dockerfile. El primer argumento será el nombre que queramos asignar a la imagen, junto a su etiqueta. El segundo argumento será la ruta donde se encuentre el proyecto junto con su archivo Dockerfile.

Vamos a crear la imagen platos, con versión 1 desde el directorio donde nos encontramos, que es el de la aplicación:

docker build -t platos:1 .

Una vez finalice el proceso, si ejecutas el comando docker images, podrás ver la imagen platos creada en tu sistema.

Ahora vamos a crear el contenedor de la aplicación, indicándole que use la red mired mediante la opción --network:

docker create -p3000:3000 --name iplatos --network mired platos:1

Hemos mapeado el puerto 3000 del contenedor al puerto 3000 de nuestro sistema. También le hemos dado el nombre de iplatos al contenedor, que hemos creado a patir de la versión 1 de la imagen platos.

Ahora inicia el contenedor de Mongo:

docker start imongo

Seguidamente inicia el contenedor de la aplicación:

docker start iplatos

Si ahora accedes a la ruta http://localhost:3000/platos deberías ver de nuevo un array vacío como respueta.

Ahora envía una la petición POST a la ruta http://localhost:3000/platos de antes con este body mediante Postman:

{
    "nombre": "Helado",
    "tipo": "postre"
}

Deberías obtener que el plato se ha creado con éxito como respuesta.

Si ejecutas el comando docker logs iplatos, deberías ver esto como resultado:

escuchando...
Mostrando lista de platos...
Creando plato...

Gestión con Docker Compose

Tal y como hemos visto, los pasos para dockerizar una aplicación y crear otros contenedores, asignando puertos, redes y creando variables de entorno, es un proceso muy tedioso. Sin embargo, existe una herramienta incluida con Docker que se llama Docker Compose que facilita todo este proceso.

Docker compose nos permite configurar los contenedores desde un archivo YAML con extensión .yml.

La configuración de Docker Compose se realiza en el archivo docker-compose.yml. Crea este archivo en el directorio raíz del proyecto.

La primera línea del archivo será la versión de Docker a usar, que en nuestro ejemplo será la 4.27:

version: "4.27"

Luego vamos a agregar nuestros contenedores mediante la sentencia services:

services:

A continuación vamos a agregar los nombre de los contenedores a usar, que serán los contenedores iplatos e imongo. Los contenedores deben indicarse tras una indentación, siguiendo el estándar YAML:

version: "4.27"
services:
  iplatos:
    // configuración de iplatos
  imongo:
    // configuración de imongo

Vamos a ver primero el archivo completo con la configuración de los contenedores y, seguidamente, vamos a explicar la configuración:

version: "4.27"
services:
  iplatos:
    build: .
    ports:
      - "3000:3000"
    links:
      - imongo
  imongo:
    image: mongo
    ports:
      - "27017:27017"
    environment:
      - MONGO_INITDB_ROOT_USERNAME=edu
      - MONGO_INITDB_ROOT_PASSWORD=testpass

En el contenedor iplatos, hemos indicado que este se creará a partir del archivo Dockerfile localizado en el directorio donde está el archivo docker-compose.yml. Para ello hemos usado la sentencia build: ..

Mediante la sentencia ports podemos mapear todos los contenedores de la aplicación. En este caso mapeamos únicamente el puerto 3000 del contenedor al puerto 3000 de nuestro sistema. Los puertos se escriben con comillas dobles.

Finalmente, mediante la sentencia links, definimos todos los contenedores de los que depende el contenedor iplatos. Los contenedores se indican sin comillas.

En cuanto al contenedor imongo, hemos usado la sentencia image para indicar la imagen a partir de cual crearlo, que es la imagen mongo.

Al igual que antes, hemos mapeamos el puerto 27017 del contenedor al puerto 27017 de nuestro sistema mediante la sentencia ports.

Luego indicamos las variables de entorno usando la sentencia environment. Hemos indicado las variables de entorno que necesita Mongo para su conexión.

Finalmente guardamos el archivo,

Si todavía tenías los contenedores iplatos e imongo en funcionamiento, para su ejecución mediante el comando docker stop y elimínalos con el comando docker rm.

Ahora ejecuta el comando docker compose up para iniciar la configuración del archivo docker-compose.yml y crear los contenedores:

docker compose up

Tras finalizar al proceso, la aplicación debería funcionar tal y como antes. Por defecto se mostrarán todos los logs.

Para frenar la ejecución de Docker Compose, pulsa CTRL+C.

Si ejecutas el comando docker ps -a, verás que se muestran los dos contenedores creados por Docker Compose.

Si quisieras eliminar estos contenedores rápidamente, podrás podrás usar el comando docker compose down:

docker compose down

Esto eliminará tanto los contenedores como la red creada por Docker Compose.

Gestión de Volúmenes con Docker

Los datos creados en el contenedores, como por ejemplo los datos de Mongo, no persistirán una vez se eliminen los contenedores. Esto no suele ser deseable, ya que habitualmente querrás que los datos de la base de datos persistan.

Para lograr esto existen los volúmenes. Los volúmenes permiten montar una carpeta del contenedor en el sistema operativo anfitrión, de forma que los datos persistan en tu sistema.

Existen tres tipos de volúmenes:

  • Volúmenes anónimos: Docker decidirá el mejor lugar para almacenar estos datos en tu sistema, con el inconveniento de que luego no podrás referenciar esta carpeta para que por ejemplo pueda ser usada por otro contenedor.
  • Volumen de anfitrión: En este caso, eres tú el que decides qué carpeta montar y dónde montarla.
  • Volumen con nombre: En este caso el volumen es como el anónimo, con la diferencia de que podrás referenciar este volumen cuando vayas a usarlo con otro contenedor, pudiendo así usarlo desde varias imágenes.

Vamos a asignar un volumen a nuestra aplicación. Para ello usaremos la sentencia volumes en nuestro archivo docker-compose.yml.

Tras la sentencia services, con el mismo nivel de indentación, debes agregar la sentencia volumes y luego una referencia. Le daremos el nombre de mongo-data.

volumes:
  mongo-data:

Aquí definiríamos todos los volúmenes que fuesen a usar nuestros contenedores.

Luego, en el interior del servicio imongo, definimos la sentencia volumes y referenciamos el volumen mongo-data seguido de dos puntos : y la ruta dentro del contenedor en donde va a ser montado el volumen mongo-data:

volumes:
      - mongo-data:/data/db

Hemos escogido este directorio porque Mongo guarda por defecto los datos en el directorio /data/db. Si usases MySQL usarías el directorio /var/lib/mysql y si usases PostgreSQL usarías el directorio /var/lib/postgresql/data.

Este sería el archivo docker-compose.yml final:

version: "4.27"
services:
  iplatos:
    build: .
    ports:
      - "3000:3000"
    links:
      - imongo
  imongo:
    image: mongo
    ports:
      - "27017:27017"
    environment:
      - MONGO_INITDB_ROOT_USERNAME=edu
      - MONGO_INITDB_ROOT_PASSWORD=testpass
    volumes:
      - mongo-data:/data/db

volumes:
  mongo-data:

Tras guardar el archivo, ejecuta de nuevo el comando docker compose up.

Seguidamente crear un nuevo plato mediante una petición POST a la ruta http://localhost:3000/platos de antes con este body mediante Postman:

{
    "nombre": "Helado",
    "tipo": "postre"
}

Ahora ejecuta el comando docker compose down para frenar los contenedores.

Si ahora ejecutas de nuevo el comando docker compose up y accedes al la ruta http://localhost:3000/platos desde tu navegador, podrás ver que el plato que hemos creado antes se ha guardado.

Entornos de desarrollo con Docker

Estamos trabajando en local. Sin embargo, habitualmente tendrás múltiples entornos de desarrollo con una configuración diferente. Esto requerirá la creación de más archivos Dockerfile y docker-compose.yml, de forma que exista uno para cada entorno.

Vamos a dar por hecho que los archivos Dockerfile y docker-compose.yml se usarán en producción.

Crea empezar, crea el archivo Dockerfile.dev y copia y pega los conteneidos de tu archivo Dockerfile en su interior.

Cuando trabajas en local e inicias una aplicación con el comando node, no se detectarán los cambios automáticamente en el archivos. Para ello se suele usar la herramienta nodemon. Si no la tienes instalada en tu sistema, puedes instalarla con este comando:

npm install -g nodemon

Sin embargo, lo ideal sería instalarla dentro del archivo Dockerfile para que se instale desde el contenedor. Para ello tendrías que agregar la siguiente línea al archivo Dockerfile.dev:

RUN npm i -g nodemon

Además, al ejecutar la aplicación, lo harías con el comando nodemon y no con node:

CMD ["nodemon", "index.js"]

Además, vamos a eliminar la línea COPY . /home/app, ya que a continuación vamos a agregar un volumen que cree un enlace simbólico en la ruta /home/app de forma que incluya el código de la aplicación.

Este sería el archivo Dockerfile.dev final:

FROM node:20

RUN npm i -g nodemon
RUN mkdir -p /home/app

WORKDIR /home/app

EXPOSE 3000

CMD ["nodemon", "index.js"]

A continuación crea el archivo docker-compose-dev.yml. Copia y pega en su interior el código del archivo docker-compose.yml.

Vamos a mostrar primero el resultado final del archivo para luego explicar los cambios:

version: "4.27"
services:
  iplatos:
    build:
      context: .
      dockerfile: Dockerfile.dev
    ports:
      - "3000:3000"
    links:
      - imongo
    volumes:
      - .:/home/app
  imongo:
    image: mongo
    ports:
      - "27017:27017"
    environment:
      - MONGO_INITDB_ROOT_USERNAME=edu
      - MONGO_INITDB_ROOT_PASSWORD=testpass
    volumes:
      - mongo-data:/data/db

volumes:
  mongo-data:

Comenzando por el contenedor iplatos, hemos expandido la sentencia build, que ahora constará de los atributos context y dockerfile.

Mediante al sentencia context: . indicamos el contexto o la aplicación en donde estamos trabajando.

Hemos indicado también que se use el archivo Dockerfile.dev mediante la sentencia dockerfile: Dockerfile.dev, de forma que no se use el archivo Dockerfile por defecto.

Además, hemos indicado un volumen anónimo en el contenedor iplatos. Mediante el . hemos indicado que la ruta actual es la que ha de ser montada en el volumen. Seguidamente hemos escrito dos puntos : y luego la ruta de destino /home/app dentro del contenedor.

El contenedor imongo lo vamos a dejar tal y como está.

Para ejecutar Docker Compose con la nueva configuración de desarrollo, debemos ejecutar el comando docker compose con el flag -f seguido del archivo docker-compose que se debe usar:

docker compose -f docker-compose-dev.yml up

Ahora, cada vez que realices cambios a tu archivo index.js, los cambios se verán reflejados al instante gracias a nodemon.

Esto ha sido todo. Espero que te haya sido útil.


Avatar de Edu Lazaro

Edu Lázaro: Ingeniero técnico en informática, actualmente trabajo como desarrollador web y programador de videojuegos.

👋 Hola! Soy Edu, me encanta crear cosas y he redactado esta guía. Si te ha resultado útil, el mayor favor que me podrías hacer es el de compatirla en Twitter 😊

Si quieres conocer mis proyectos, sígueme en Twitter.

Deja una respuesta

“- Hey, Doc. No tenemos suficiente carretera para ir a 140/h km. - ¿Carretera? A donde vamos, no necesitaremos carreteras.”