¿Cómo Dockerizar una aplicación?

por | Nov 25, 2024

De aprendiz a maestro de la contenedorización

Imagina que eres el héroe de tu mundo. Al igual que Aang, el protagonista de El último maestro aire, tienes en tus manos un poder inmenso que debes usar para salvarlo: Docker. Pero, a pesar de todo su potencial, todavía no sabes cómo controlarlo del todo.

En tu primer intento por manejarlo, las cosas no salieron bien. Contenedores caóticos, aplicaciones fallando y una infraestructura a punto de colapsar. Pero al igual que Aang, no te rendiste, él practicó sin cesar, aprendiendo de cada error, hasta que un día, ¡Logró salvar su mundo!

Docker ya no será un misterio para tí, con este blog aprenderás a controlar todo su poder y serás capaz de llevarlo al próximo nivel. Serás capaz de crear, gestionar y desplegar aplicaciones de una forma que antes parecía inalcanzable.

Hoy te voy a mostrar cómo tú también puedes dominar Docker, empezando por contenerizar tu aplicación, crear imágenes propias y publicarlas en un registro. Prepárate para ser el héroe que tu empresa necesita.

¿Qué es una imagen de Docker y por qué es importante?

En Docker, una imagen es como una receta que contiene todo lo necesario para que una aplicación funcione (Para obtener más información sobre qué es una imagen de Docker, visita nuestro blog anterior) : desde el código fuente hasta las dependencias y la configuración. Cuando ejecutas una imagen, Docker la utiliza para crear un contenedor que ejecuta tu aplicación en un entorno aislado y reproducible.

Las imágenes se crean a partir de un archivo llamado Dockerfile, el cual contiene instrucciones paso a paso para que Docker pueda construir la imagen. 

Veamos un ejemplo sencillo de Dockerfile que crea una imagen para una aplicación que simplemente imprime “Hello World”:

FROM busybox
ENTRYPOINT ["echo"]
CMD ["hello world"]

Aquí, la instrucción FROM indica la imagen base que estamos utilizando, en este caso, busybox, una imagen ligera de Linux. Luego, ENTRYPOINT define el comando principal que se ejecutará al iniciar el contenedor, mientras que CMD establece los argumentos por defecto que se pasarán al comando.

El archivo Dockerfile permite crear una imagen a partir de la cual podemos generar un contenedor
El archivo Dockerfile permite crear una imagen a partir de la cual podemos generar un contenedor

Entendiendo las instrucciones de un Dockerfile

Si bien el ejemplo anterior es muy básico, Docker ofrece una gran flexibilidad mediante las diferentes instrucciones que puedes usar en un Dockerfile.

A continuación, repasaremos algunas de las más importantes para que puedas sacar el máximo provecho.

FROM

Es la primera instrucción que debe aparecer en cualquier Dockerfile. Le dice a Docker qué imagen base utilizar, y puedes especificar una versión concreta si lo deseas.

Por ejemplo:

FROM node:17.4.0-alpine3.14

Si lo prefieres, puedes empezar desde cero con la imagen scratch, perfecta si quieres construir algo desde la base.

ARG y ENV: Variables en tu construcción

Docker tiene dos maneras principales de definir variables: ARG y ENV. Aunque ambas permiten personalizar la construcción de tu imagen, hay diferencias clave:

ARG (Argumentos)

  • Está disponible únicamente durante el proceso de construcción de la imagen.
  • Se usa principalmente para la personalización de la construcción, permitiendo flexibilidad entre diferentes builds.
  • Como por ejemplo: especificar versiones de dependencias o rutas de instalación.

ENV (Variables de entorno)

  • Persiste tanto en la construcción de la imagen como en la ejecución del contenedor.
  • Se emplea en la configuración de la aplicación en tiempo de ejecución.
  • Como por ejemplo: definir URLs de bases de datos o modos de ejecución (desarrollo, producción).

Aquí te compartimos un ejemplo práctico:

ARG NODE_ENV=development
ENV NODE_ENV=$NODE_ENV

Aquí, NODE_ENV se define inicialmente con ARG, pero luego se usa en ENV para ser accesible cuando el contenedor esté en marcha.

Diferencias entre ARG y ENV
Diferencias entre ARG y ENV

COPY y ADD: Copiando archivos en el contenedor

Las instrucciones COPY y ADD permiten mover archivos desde tu sistema local al contenedor. La principal diferencia entre ambas es que ADD puede manejar archivos comprimidos y URL, mientras que COPY solo mueve archivos desde una ruta local.

Por ejemplo:

COPY . /app
ADD myApp.tar.gz /app

En este caso:

  • COPY . /app copia todos los archivos y directorios del directorio actual (de tu máquina) al directorio /app dentro del contenedor
  • ADD descomprime el archivo myApp.tar.gz en el mismo lugar.

RUN, CMD y ENTRYPOINT: Ejecutando comandos

Docker nos ofrece tres maneras principales de ejecutar comandos:

  • RUN ejecuta comandos durante la construcción de la imagen. Por ejemplo, instalar dependencias o compilar código.
  • CMD y ENTRYPOINT se usan para definir qué comando se ejecutará cuando el contenedor se inicie.

Por ejemplo:

RUN npm install
CMD ["npm", "start"]
ENTRYPOINT ["npm", "start"]

Aunque CMD puede ser reemplazado al iniciar el contenedor, ENTRYPOINT siempre se ejecuta, asegurando que el comando principal de tu aplicación se mantenga intacto.

Exponiendo puertos y volúmenes

Cuando creas una imagen que necesita exponer puertos, como en una aplicación web, debes usar la instrucción EXPOSE para indicar qué puertos debe escuchar Docker:

EXPOSE 8080

De igual manera, si tu aplicación necesita almacenar datos persistentes, puedes usar VOLUME para declarar qué directorios del contenedor serán montados en tu máquina host:

VOLUME /data

Esto asegura que los datos que generes en el contenedor no se pierdan cuando el contenedor se detenga o elimine.

WORKDIR

La instrucción WORKDIR define el directorio en el que se ejecutarán las siguientes instrucciones del Dockerfile. Si el directorio no existe, Docker lo creará automáticamente.

Esto es útil para evitar tener que especificar escribir rutas absolutas cada vez que copies archivos o ejecutes comandos.

Por ejemplo:

WORKDIR /app
COPY . /app
RUN npm install

Aquí, WORKDIR asegura que tanto la instrucción COPY como RUN se ejecuten dentro del directorio /app.

LABEL

Con LABEL, puedes agregar metadatos a una imagen de Docker en forma de pares clave-valor. Esto puede ser útil para identificar la imagen, su versión, el creador o cualquier otra información relevante.

Por ejemplo:

LABEL maintainer="tu.nombre@ejemplo.com"
LABEL version="1.0"
LABEL description="Una aplicación Docker simple"

Estos metadatos no afectan al funcionamiento del contenedor, pero son útiles para documentación o para herramientas que analizan las imágenes.

USER

Por defecto, Docker ejecuta las imágenes como el usuario root, lo cual puede ser un riesgo de seguridad en algunos casos. La instrucción USER permite especificar el usuario que ejecutará los comandos y el contenedor.

Por ejemplo:

USER node
RUN npm install

Aquí, el comando npm install se ejecutará bajo el usuario node en lugar de root.

ONBUILD

La instrucción ONBUILD permite definir comandos que se ejecutarán automáticamente cuando otra imagen use la tuya como base. Esto es especialmente útil cuando estás creando imágenes base que otros desarrolladores utilizarán.

Por ejemplo:

ONBUILD COPY . /app
ONBUILD RUN npm install

Si alguien usa esta imagen como base en su Dockerfile, las instrucciones COPY y RUN se ejecutarán automáticamente cuando construyan su imagen.

STOPSIGNAL

STOPSIGNAL le indica a Docker qué señal enviar para detener el contenedor de manera ordenada. Por defecto, Docker usa la señal SIGTERM, pero puedes especificar otra señal si tu aplicación lo requiere.

Por ejemplo:

STOPSIGNAL SIGQUIT

Esto envía la señal SIGQUIT en lugar de SIGTERM cuando se detiene el contenedor, permitiendo que el proceso gestione el cierre correctamente.

HEALTHCHECK

La instrucción HEALTHCHECK permite definir un comando que Docker ejecutará periódicamente para comprobar si el contenedor sigue funcionando correctamente. Si la verificación falla, Docker marcará el contenedor como «unhealthy«, lo que puede activar alertas o desencadenar acciones automáticas en sistemas de orquestación.

Por ejemplo:

HEALTHCHECK --interval=30s --timeout=10s --retries=3 CMD curl -f http://localhost/ || exit 1

Aquí, Docker verifica cada 30 segundos si el servicio web dentro del contenedor responde correctamente. Si no lo hace, el contenedor se marcará como no saludable después de 3 intentos fallidos.

SHELL: Usando un shell personalizado

Por defecto, Docker usa /bin/sh como shell para ejecutar los comandos, pero con la instrucción SHELL, puedes especificar un shell diferente.

Por ejemplo, si prefieres usar Bash:

SHELL ["/bin/bash", "-c"]

Esto asegura que las siguientes instrucciones se ejecuten en Bash en lugar de en /bin/sh.

Recordando el concepto de capas

Para refrescarte la memoria debes saber que una capa corresponde a una etapa en la creación de una imagen y contiene un conjunto de archivos generados durante esa etapa. Cada vez que Docker procesa ciertas instrucciones en el Dockerfile, se genera una nueva capa, que se acumula sobre las anteriores.

Cada capa de la imagen contiene archivos
Cada capa de la imagen contiene archivos

Las instrucciones que vimos, como FROM, COPY, RUN y CMD, son las que crean estas capas. Cuando se ejecuta una de estas instrucciones, Docker genera una nueva capa y la integra en la imagen.

Docker utiliza un sistema de archivos que fusiona todas estas capas en una única vista coherente. Este sistema se conoce como «Union File System» o sistema de archivos unificado, y es lo que permite que múltiples capas coexistan y funcionen como si fueran una sola.

Docker utiliza el "Union FS" o sistema de archivos unificado para combinar varios archivos, lo que permite que las capas sean ligeras y reutilizables
Docker utiliza el «Union FS» o sistema de archivos unificado para combinar varios archivos, lo que permite que las capas sean ligeras y reutilizables

La importancia de la caché en la construcción de imágenes

Imagina que en cada intento de dominar tu poder, tuvieras que empezar desde cero. ¡Sería agotador!

Por eso Docker implementa la caché, que permite evitar la reconstrucción de capas que no han cambiado, acelerando el proceso de construcción. Para las instrucciones COPY, RUN y CMD, Docker verifica si los archivos asociados a esas capas han cambiado desde la última construcción. Si no hay cambios, Docker reutiliza la capa de la caché.

Ten cuidado: si una capa necesita ser reconstruida, todas las siguientes también se reconstruirán.


Esto es especialmente importante cuando ordenas las instrucciones en tu Dockerfile. La clave para mantener una estructura eficiente está en diseñarlo de tal manera que minimices las reconstrucciones innecesarias.

Si una capa modificada es reconstruida, las capas siguientes también serán reconstruidas.

Dockerizando una aplicación Node.js

Vamos a poner en práctica todo esto con una aplicación sencilla en Node.js utilizando el framework Fastify. El objetivo es centrarnos en la creación de un Dockerfile eficiente y aplicar buenas prácticas en el proceso.

Comencemos por instalar Fastify:

npm install fastify

Luego, creamos un archivo server.js con el siguiente código:

const Fastify = require('fastify')
 
const fastify = Fastify({ logger: true })
 
fastify.get('/', (request, reply) => {
  reply.send({ hello: 'world' })
})
 
fastify.listen(3000, '0.0.0.0', (err) => {
  if (err) {
    fastify.log.error(err)
    process.exit(1)
  }
})

Añadimos un script start en el archivo package.json:

{
  "scripts": {
    "start": "node server.js"
  }
}

Creando el Dockerfile

Vamos ahora a construir el Dockerfile para nuestra aplicación, paso a paso.

Imagen base

La primera instrucción de cualquier Dockerfile es FROM, que define la imagen base. Aunque es tentador utilizar latest o lts, estas etiquetas pueden cambiar y romper la compatibilidad de nuestra aplicación con futuras versiones. En su lugar, especificamos una versión fija:

FROM node:16.14.0-alpine3.14

De esta manera, nos aseguramos de que siempre utilizamos la misma versión de Node.js, incluso si lanzan nuevas actualizaciones.

Optimización para producción

Para asegurarnos de que nuestra aplicación esté optimizada para entornos de producción, definimos la variable de entorno NODE_ENV con el valor production. Aunque Fastify no depende de esto, es una buena práctica general:

ENV NODE_ENV production

Creación del directorio de trabajo

El siguiente paso es definir el directorio donde vivirá nuestra aplicación dentro del contenedor:

WORKDIR /usr/src/app

Si la aplicación necesita crear archivos en este directorio, debemos asegurarnos de que el usuario tenga los permisos correctos. Podemos cambiar el propietario y/o el grupo del directorio con el comando chown node ./:

RUN chown node:node ./

O bien, podemos cambiar el usuario que ejecuta las siguientes instrucciones:

USER node:node

Este método es más eficiente ya que evita crear una nueva capa.

Instalación de programas externos

Para nuestra aplicación, instalaremos una pequeña herramienta llamada tini, que nos ayudará a gestionar correctamente el proceso de nuestro contenedor:

RUN apk add --no-cache tini

Copia de los archivos package.json

Copiamos los archivos package.json y package-lock.json para instalar las dependencias. Esto también ayuda a optimizar el uso de la caché de Docker, ya que estos archivos suelen cambiar con menos frecuencia que el código fuente:

COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force

Copia del código fuente

Finalmente, copiamos el resto del código fuente al contenedor:

COPY . .

Para garantizar que los archivos tengan los permisos correctos, podemos usar la opción --chown en el comando COPY:

COPY --chown=node:node . .

Principio de menor privilegio

Siguiendo el principio de menor privilegio, ejecutamos la aplicación con un usuario no root, como el usuario node que proporciona la imagen base de Node.js:

USER node

Exposición de puertos

Nuestra aplicación escuchará en el puerto 3000, por lo que es necesario exponerlo:

EXPOSE 3000

Comando de inicio

El comando ENTRYPOINT define el proceso que se ejecutará cuando se inicie el contenedor. Aquí utilizamos tini para asegurarnos de que Node.js se maneje correctamente como proceso principal:

ENTRYPOINT ["/sbin/tini", "--", "node", "server.js"]

Construcción de la imagen mediante el Dockerfile

Nuestro Dockerfile completo se vería así:

FROM node:16.14.0-alpine3.14
 
ENV NODE_ENV production
 
WORKDIR /usr/src/app
 
RUN apk add --no-cache tini
 
COPY  package*.json ./
RUN npm ci --only=production && npm cache clean --force
 
COPY . .
 
USER node
EXPOSE 3000
 
ENTRYPOINT ["/sbin/tini", "--" , "node", "server.js"]

Para crear la imagen, usamos la siguiente línea de comando:

docker image build -t fastify_example .

Y finalmente, lanzamos el contenedor:

docker container run --rm -p 3000:3000 fastify_example

Haz una solicitud GET a http://localhost:3000 y deberías ver la siguiente respuesta:

{ "hello": "world" }

¡Felicidades!

Has creado tu primera aplicación Node.js usando Docker, y has dado un paso más en tu camino hacia convertirte en un maestro de los contenedores.

Utilización de Multi-Stage

El tamaño de las imágenes Docker puede ser un problema significativo si no manejamos correctamente las capas y las instrucciones en el Dockerfile. Cada instrucción COPY, RUN, y CMD crea una nueva capa, lo que puede aumentar el tamaño de la imagen final. Por eso, una buena práctica es mantener las imágenes lo más pequeñas posible, limitando y optimizando el uso de estas instrucciones.

A menudo, necesitamos instalar herramientas para construir, compilar o probar nuestra aplicación. Sin embargo, estas herramientas no son necesarias para la ejecución final y podrían aumentar innecesariamente el tamaño de la imagen. Aunque podríamos eliminarlas con una instrucción RUN, esto añadiría una capa adicional y complicaría la imagen.

Aquí es donde entran los multi-stage builds. Este enfoque permite tener un solo Dockerfile con múltiples instrucciones FROM, donde cada FROM representa una etapa de construcción. Puedes copiar datos entre etapas usando la instrucción COPY con la opción --from.

Vamos a ilustrar esto creando una aplicación React.

Primero, creamos la aplicación React:

npx create-react-app react_docker

Nuestra aplicación React necesita Node.js solo para la construcción (usando npm run build), por lo que la imagen final solo debería contener el resultado de la construcción y un servidor web, como Nginx.

Creamos un Dockerfile utilizando multi-stage de la siguiente manera:

# Stage 1: Construcción
FROM node:16.14.0-alpine3.14 AS builder

WORKDIR /usr/src/app

# Copiamos los archivos package.json y package-lock.json
COPY package*.json ./

# Instalamos las dependencias y limpiamos la caché de npm
RUN npm ci && npm cache clean --force

# Copiamos el resto de los archivos de la aplicación
COPY . .

# Ejecutamos el comando para construir la aplicación
RUN npm run build

# Stage 2: Imagen final usando el contenido construido en la etapa 1
FROM nginx:1.21.6-alpine

WORKDIR /usr/share/nginx/html

# Eliminamos los datos estáticos existentes de Nginx
RUN rm -rf ./*

# Copiamos la aplicación React construida en la etapa 1 al directorio de Nginx
COPY --from=builder /usr/src/app/build .

# Iniciamos Nginx
ENTRYPOINT ["nginx", "-g", "daemon off;"]

En este Dockerfile, definimos dos etapas:

  1. Build: Construimos la aplicación React.
  2. Final: Creamos una imagen ligera con Nginx que solo contiene los archivos construidos.

Para construir la imagen, usamos:

docker image build -t react_multistage .

Y para crear y ejecutar el contenedor:

docker container run --rm -p 8080:80 react_multistage

Accede a http://localhost:8080 para verificar que todo funcione correctamente. Deberías ver tu aplicación React desplegada.

Nuestra aplicación React ha sido dockerizada usando Multi-stage
Nuestra aplicación ha sido dockerizada usando el Multi-stage

Nota: No intentes modificar el archivo src/App.js directamente en el contenedor. La imagen solo contiene los archivos construidos, no el código fuente.

Publicación de la Imagen

Antes de finalizar, veremos cómo publicar nuestra imagen de Fastify en Docker Hub. Primero, asegúrate de tener una cuenta en Docker Hub si aún no tienes una.

La página para crear una cuenta en Docker Hub es muy simple
La página para crear una cuenta en Docker Hub es muy simple

Una vez creado tu cuenta, conéctate a Docker Hub desde tu máquina con:

docker login

Luego, etiqueta tu imagen para prepararla para la publicación:

docker tag fastify_example arkerone/fastify_example:1.0.0

Asegúrate de reemplazar arkerone con tu nombre de usuario en Docker Hub. Finalmente, publica la imagen:

docker push arkerone/fastify_example:1.0.0
Nuestra imagen está publicada correctamente en Docker Hub

Nuestra imagen está publicada correctamente en Docker Hub.

Y eso es todo. Ahora tu imagen está disponible en Docker Hub para que la puedas compartir o usar en otros entornos.

En Conclusión

Hoy has aprendido a optimizar tus imágenes Docker utilizando multi-stage builds y a publicar tus imágenes en Docker Hub. Tal como Aang aprendió a dominar los cuatro elementos, la clave para manejar Docker es la práctica constante y la experimentación con diferentes configuraciones.

Como bien dijo el tío Iroh: 

“No importa cuán grande sea el reto, con entrenamiento y voluntad todo se puede superar”.

Sigue practicando, y pronto dominarás Docker como un verdadero maestro de la dockerización.