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.
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.
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
yENTRYPOINT
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.
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.
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.
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:
Build
: Construimos la aplicación React.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.
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.
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
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.