# Optimizando Dockerfiles: 7 Principios para Imágenes Eficientes, Rápidas y Seguras

CesarLead 9 min read
Optimizando Dockerfiles: 7 Principios para Imágenes Eficientes, Rápidas y Seguras
Tabla de Contenidos

1. El Problema: Muchachones, escribir un Dockerfile que “funcione” es facil, cualquiera puede escribir comandos RUN, COPY y CMD hasta que la aplicación se ejecute. Sin embargo, este enfoque suele producir imágenes enormes, lentas de construir e inseguras para producción.

Asi que no, nos conformaremos con “funcional” mis bros. Vamos a desglosar 7 principios fundamentales que transforman un Dockerfile básico en un artefacto de software optimizado para producción. Veremos el “porqué” detrás de cada técnica, el “cómo” implementarla con ejemplos claros (para Java y JavaScript) y, lo más importante, cómo verificar el impacto de cada optimización muchachones.


2. Los 7 Principios de Optimización

Principio 1: El Arte del Cacheo de Capas (El Orden Importa)

Cono, este es, quizás, el error más común y el que más tiempo cuesta en los pipelines de CI/CD.

  • El Porqué: Docker construye imágenes en capas. Cada instrucción (RUN, COPY, ADD) en tu Dockerfile crea una nueva capa. Docker es inteligente: si una instrucción no ha cambiado y las capas anteriores tampoco han cambiado, reutiliza la capa de la caché en lugar de volver a ejecutarla.

  • El Problema: Si copias todo tu código fuente (COPY . .) antes de instalar las dependencias (RUN npm install), cualquier cambio en un archivo README.md invalidará la caché y forzará a Docker a reinstalar todas las dependencias, una y otra vez.

  • El Cómo (La Solución): Ordena tus instrucciones de la menos frecuente a la más frecuente en cambiar.

Ejemplo (Node.js / JavaScript):

# MAL: Lento e ineficiente!!!
FROM node:18-alpine
WORKDIR /app
# 1. Copiar todo muchachon. Si cambias CUALQUIER archivo,
# la caché se invalida aquí.
COPY . .
# 2. Se ejecuta CADA VEZ que cambias un archivo.
RUN npm install
CMD ["node", "index.js"]
# BIEN: Rápido y eficiente, como debe ser.
FROM node:18-alpine
WORKDIR /app
# 1. Copia solo los archivos de definición de dependencias.
COPY package.json package-lock.json ./
# 2. Instala dependencias. Esta capa solo se reconstruye
# si package.json o package-lock.json cambian.
RUN npm install
# 3. Copia el resto del código. Esto es lo que cambia
# más a menudo.
COPY . .
CMD ["node", "index.js"]
  • Verificación: Ejecuta docker build . dos veces seguidas con la versión “BIEN”. La segunda vez, verás que los pasos 1 y 2 usan la caché (---> Using cache), haciendo que el build sea casi instantáneo. Ahora, haz un pequeño cambio en tu index.js y vuelve a construir. Verás que los pasos 1 y 2 siguen usando la caché, y solo el paso COPY . . se vuelve a ejecutar y no jode hahaha.

Principio 2: Builds Multi-Etapa (Imágenes Ligeras)

Este es el principio más poderoso para reducir drásticamente el tamaño de tu imagen final.

  • Porqué: Necesitas muchas herramientas para construir tu aplicación (compiladores, SDKs, dependencias de desarrollo como maven o node-sass), pero no las necesitas para ejecutarla.

  • El Problema: Un Dockerfile de una sola etapa empaqueta todo (el JDK completo, el código fuente, los archivos .jar, los node_modules) en la imagen final, creando “bloat” (relleno innecesario).

  • El Cómo (La Solución): Usa un Dockerfile multi-etapa.

    1. Etapa “Builder”: Una primera imagen (ej. maven o node) que compila el código y genera el artefacto (ej. un .jar o una carpeta build).

    2. Etapa “Production”: Una segunda imagen, muy ligera (ej. openjdk:17-slim o nginx:alpine), que solo copia el artefacto final de la etapa “Builder”.

Ejemplo (Java / Spring Boot):

# MAL: Imagen pesada (fácilmente +1.2 GB)
FROM maven:3.8-openjdk-17
WORKDIR /app
COPY . .
# Ejecuta la compilación, descargando todo Maven
RUN mvn package
# La imagen final contiene el JDK, Maven,
# el código fuente y el .jar
CMD ["java", "-jar", "target/mi-app-0.0.1.jar"]
# BIEN: Imagen ligera (fácilmente < 250 MB)
# --- Etapa 1: El Constructor ---
# Usamos la imagen completa de Maven para construir el .jar
FROM maven:3.8-openjdk-17 AS builder
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline
COPY src ./src
RUN mvn package
# --- Etapa 2: La Producción ---
# Usamos una imagen JRE (Java Runtime) mínima.
FROM openjdk:17-slim-bullseye
WORKDIR /app
# Copiamos ÚNICAMENTE el .jar de la etapa "builder"
COPY --from=builder /app/target/mi-app-0.0.1.jar ./app.jar
# La imagen final solo tiene el JRE y app.jar
CMD ["java", "-jar", "app.jar"]

Ejemplo (JavaScript / React):

# BIEN: Multi-etapa para React
# --- Etapa 1: El Constructor ---
FROM node:18-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install
COPY . .
# Genera los archivos estáticos en /app/build
RUN npm run build
# --- Etapa 2: La Producción ---
# Usamos Nginx para servir archivos estáticos. No hay Node.js
FROM nginx:1.25-alpine
# Copia solo los archivos estáticos construidos
COPY --from=builder /app/build /usr/share/nginx/html
# Nginx se encarga del CMD por defecto
  • Verificación (Métrica): Ejecuta docker build -t mi-app-java-v1 . (con el primer Dockerfile) y docker build -t mi-app-java-v2 . (con el segundo). Luego ejecuta docker images. Verás una diferencia de tamaño abismal. La imagen v1 puede pesar 1.2 GB mientras que la v2 pesará ~240 MB.

Principio 3: Consolidar Capas RUN (Reduciendo el Desorden)

  • Porqué: Cada instrucción RUN crea una capa. Si instalas curl en una capa y luego limpias la caché de apt en otra capa RUN, la limpieza es inútil. La primera capa ya contiene los archivos de caché y no se puede modificar.

  • El Cómo (La Solución): Combina comandos RUN lógicamente usando && (AND lógico). Lo más importante: limpia en la misma capa en la que instalas.

Ejemplo (Instalando paquetes):

# MAL: Capas innecesarias y "bloat"
FROM debian:bullseye-slim
RUN apt-get update
RUN apt-get install -y curl
RUN apt-get install -y git
# Esta limpieza no sirve de nada, las capas
# anteriores ya contienen la caché.
RUN apt-get clean
RUN rm -rf /var/lib/apt/lists/*
# BIEN: Una sola capa, limpia y eficiente
FROM debian:bullseye-slim
RUN apt-get update && \
apt-get install -y \
curl \
git \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
  • Verificación (Métrica): Construye ambas imágenes. Luego, usa docker history mi-imagen-mala y docker history mi-imagen-buena. Verás que la versión “mala” tiene múltiples capas RUN (algunas de varios MB) mientras que la “buena” tiene una sola capa optimizada.

Principio 4: El Contexto es Todo (Usa .dockerignore)

  • Porqué: Cuando ejecutas docker build ., el . (el contexto) envía todo lo que hay en tu carpeta al daemon de Docker muchachones, esto puede incluir tu carpeta .git (cientos de MB), node_modules/ (miles de archivos), logs, archivos .env con secretos, osea un alocura…

  • El Problema: Un contexto de build grande ralentiza el inicio del docker build (especialmente el paso COPY . .) e introduce riesgos de seguridad si copias secretos accidentalmente.

  • El Cómo (La Solución): Crea un archivo .dockerignore en la raíz de tu proyecto (la misma sintaxis que .gitignore).

Ejemplo (.dockerignore):

# Ignorar dependencias locales
node_modules
/target
# Ignorar control de versiones
.git
.gitignore
# Ignorar secretos y logs
.env
*.log
npm-debug.log
# Ignorar archivos de Docker
Dockerfile
.dockerignore
  • Verificación: Ejecuta docker build . --progress=plain sin un .dockerignore. Observa la salida de “context”. Verás que envía gigabytes de datos. Ahora, añade el .dockerignore y vuelve a ejecutar el comando. Verás que el contexto se transfiere instantáneamente.

Principio 5: Fija tus Dependencias (Evita latest)

  • Porqué: Necesitas “Builds Reproducibles”. Si tu Dockerfile usa FROM node:latest, la imagen que construyes hoy puede ser completamente diferente a la que construyas mañana cuando latest apunte a una nueva versión mayor.

  • El Problema: Esto rompe la fiabilidad. Un build que funciona en tu máquina puede fallar en CI/CD o en producción días después sin que hayas cambiado una sola línea de código.

  • El Cómo (La Solución): Sé explícito. Fija (pin) siempre tus versiones.

    • Malo: node:latest, openjdk

    • Aceptable: node:18, openjdk:17 (fija la versión mayor, pero permite parches)

    • Bueno: node:18.19.0, openjdk:17.0.9 (fija la versión exacta)

    • Cono Mejor: node:18.19.0-alpine, openjdk:17.0.9-slim-bullseye (fija la versión Y la base del SO)

Nota: alpine vs slim

  • alpine (ej. node:18-alpine): Usa musl libc en lugar de glibc. Son imágenes extremadamente pequeñas.
  • slim (ej. python:3.11-slim): Usa Debian “slim”, que es más grande que Alpine pero usa glibc estándar.
  • Recomendación: Empieza con slim. Usa alpine solo si el tamaño es el factor más crítico y has verificado que tus dependencias (especialmente las que compilan código C) son compatibles con musl.

Principio 6: HEALTHCHECK (Monitoreo y Auto-Reparación)

  • Porqué: Que un contenedor esté “Running” (ejecutándose) no significa que esté “Healthy” (saludable). Tu aplicación Java puede haberse quedado sin memoria (OOM) pero el proceso JVM sigue vivo, o tu servidor Node.js puede haberse colgado en un bucle infinito sin crashear.

  • El Problema: Sin un HEALTHCHECK, Docker (y orquestadores como Kubernetes o Swarm) no tienen idea de que tu app está rota y seguirán enviándole tráfico.

  • El Cómo (La Solución): Define una instrucción HEALTHCHECK que Docker usará para verificar si tu aplicación realmente funciona.

Ejemplo (HTTP para una API): Si tu app expone un endpoint /health (común en Spring Boot Actuator o Terminus en Node.js):

HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD curl -f http://localhost:8080/health || exit 1
  • --interval=30s: Revisa cada 30 segundos.
  • --timeout=5s: Falla si la respuesta tarda más de 5s.
  • --start-period=15s: No empieces a revisar hasta 15s después de iniciar (da tiempo a la app a arrancar).
  • --retries=3: Marca el contenedor como “unhealthy” solo después de 3 fallos seguidos.

Ejemplo (Proceso para un Worker): Si tu app no es un servidor web (ej. un worker Java o un script):

# Verifica si el proceso 'java' sigue en ejecución
HEALTHCHECK --interval=1m --timeout=10s --retries=3 \
CMD pgrep java || exit 1
  • Verificación: Ejecuta docker ps. Verás que el estado de tu contenedor ahora incluye su estado de salud (ej. (healthy) o (unhealthy)).

Principio 7: Seguridad Básica (No seas root)

  • Porqué: Por defecto, los contenedores se ejecutan como el usuario root. Esto viola el “Principio de Menor Privilegio”. Si un atacante logra explotar una vulnerabilidad en tu aplicación, obtiene acceso root dentro del contenedor, facilitando la escalada de privilegios.

  • El Cómo (La Solución): Crea un usuario no-root y úsalo.

Ejemplo:

FROM node:18-alpine
WORKDIR /app
# 1. Crear un usuario y grupo no-root
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
# ... (Copia package.json, npm install, etc.) ...
COPY . .
# 2. Cambiar la propiedad de los archivos al nuevo usuario
RUN chown -R appuser:appgroup /app
# 3. Cambiar al usuario no-root
USER appuser
CMD ["node", "index.js"]
  • Verificación: Inicia tu contenedor y entra en él: docker exec -it mi-contenedor sh. Ejecuta el comando whoami. Deberías ver appuser, no root.

Bueno muchachones hemos pasado de un Dockerfile “funcional” a uno optimizado para producción. Estas técnicas no son “trucos”, son prácticas de ingeniería fundamentales. Aplicar estos siete principios hará que las imágenes sean más pequeñas, se construyan más rápido en los pipelines de CI/CD y sean significativamente más seguras y resilientes en producción, si que a practicar, exprimentando con distintas configuraciones…

Foto Cesar Fernandez

¿Lo rompiste? ¿Lo mejoraste?

Gracias por llegar hasta el final. Escribo estos posts para organizar mis propias ideas y, con suerte, para ahorrarle a alguien más el dolor de cabeza que yo ya pasé. Me encuentras en LinkedIn o puedes ver más de mi trabajo en GitHub.


Más Artículos