Multi-stage Builds para Imágenes Eficientes

Lectura
20 min~7 min lectura

Multi-stage Builds para Imágenes Eficientes

Bienvenido a esta lección sobre Multi-stage Builds, una técnica fundamental que todo desarrollador Docker debería dominar para crear imágenes más pequeñas, seguras y eficientes.

¿Qué son los Multi-stage Builds?

Los Multi-stage Builds son una característica de Docker que permite utilizar múltiples etapas (stages) en un mismo archivo Dockerfile. Cada etapa puede utilizar una imagen base diferente, y únicamente los archivos necesarios se copian de una etapa a otra, descartando todo lo demás.

CONCEPTO CLAVE: Los Multi-stage Builds funcionan como un proceso de construcción en cadena donde cada etapa prepara artefactos específicos. La imagen final contiene solo lo estrictamente necesario para ejecutar la aplicación, sin herramientas de compilación, dependencias de desarrollo ni archivos temporales.

El Problema: Imágenes Monolíticas

Imagina una aplicación Node.js típica. Sin Multi-stage Builds, tu Dockerfile probablemente se vería así:

FROM node:18

WORKDIR /app

# Copiamos todo el código fuente
COPY . .

# Instalamos TODAS las dependencias (incluyendo devDependencies)
RUN npm install

# Compilamos la aplicación
RUN npm run build

# Ejecutamos
CMD ["node", "dist/index.js"]

Este enfoque parece funcionar, pero tiene problemas serios:

  • Tamaño excesivo: La imagen puede superar los 800MB-1GB
  • Superficie de ataque expandida: Más paquetes = más vulnerabilidades potenciales
  • Tiempo de despliegue lento: Imágenes grandes tardan más en Push/Pull
  • Secrets expuestos: Tokens y claves pueden quedar en capas intermedias
⚠️ ADVERTENCIA: Una imagen Node.js con todas las devDependencies puede ser hasta 5-10 veces más pesada que la versión producción. Esto afecta directamente tus costos de infraestructura y tiempos de CI/CD.

La Solución: Multi-stage Builds en Acción

Veamos cómo transformar el Dockerfile anterior usando Multi-stage Builds:

# ============================================
# ETAPA 1: Construcción (Builder Stage)
# ============================================
FROM node:18 AS builder

WORKDIR /app

# Copiamos primero package.json para cachear dependencias
COPY package*.json ./

# Instalamos SOLO dependencias de producción
RUN npm ci --only=production

# Copiamos el código fuente
COPY . .

# Compilamos la aplicación
RUN npm run build

# ============================================
# ETAPA 2: Producción (Production Stage)
# ============================================
FROM node:18-slim AS production

# Creamos un usuario no-root por seguridad
RUN addgroup -g 1001 -S nodejs && \
    adduser -S nextjs -u 1001

WORKDIR /app

# Copiamos SOLO lo necesario del builder
COPY --from=builder --chown=nextjs:nodejs /app/dist ./dist
COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nextjs:nodejs /app/package.json ./package.json

# Cambiamos al usuario no-root
USER nextjs

EXPOSE 3000

CMD ["node", "dist/index.js"]
  1. Etapa 1 (builder): Utiliza la imagen completa de Node.js con todas las herramientas de desarrollo
  2. Etapa 2 (production): Utiliza node:18-slim, una imagen mínima de ~120MB
  3. COPY --from=builder: Copia exclusivamente los archivos compilados y node_modules de producción
  4. USER nextjs: Cambia a un usuario no-root por razones de seguridad

Comparativa de Resultados

Aspecto Dockerfile Tradicional Multi-stage Build
Tamaño de imagen ~950 MB ~180 MB
Dependencias de compilación Incluidas Descartadas
Usuario root Sí (por defecto) No (usuario personalizado)
Caché de capas Parcial Optimizada
Tiempo de Push/Pull ~5-10 min ~1-2 min
💡 CONSEJO PRÁCTICO: Siempre usa node:18-slim o node:18-alpine para producción. La diferencia entre node:18 (970MB) y node:18-slim (180MB) es dramática en escenarios reales.

Multi-stage Builds con Diferentes Lenguajes

Ejemplo: Aplicación Go

Go es famoso por poder compilar binarios estáticos autocontenidos. Los Multi-stage Builds permiten crear imágenes increíblemente pequeñas:

# Etapa 1: Compilación
FROM golang:1.21-alpine AS builder

WORKDIR /build

# Instalar herramientas necesarias
RUN apk add --no-cache git

# Copiar y descargar dependencias
COPY go.mod go.sum ./
RUN go mod download

# Copiar código fuente
COPY . .

# Compilar binario estático (sin CGO)
ENV CGO_ENABLED=0
RUN go build -ldflags='-w -s' -o main .

# Etapa 2: Imagen final mínima
FROM scratch

# Copiar证书 si necesitas HTTPS
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/

# Copiar el binario compilado
COPY --from=builder /build/main /main

ENTRYPOINT ["/main"]
📌 NOTA: Usando FROM scratch, la imagen final puede ser de apenas ~15-20 MB para una aplicación Go compilada. No tiene sistema operativo, solo el binario y lo mínimo indispensable.

Ejemplo: Aplicación Python

# Etapa 1: Builder
FROM python:3.11-slim AS builder

WORKDIR /app

# Instalar pip-tools y crear entorno virtual
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"

# Instalar solo dependencias de producción
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/opt/venv -r requirements.txt

# Etapa 2: Producción
FROM python:3.11-slim AS production

# Copiar el entorno virtual del builder
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"

WORKDIR /app
COPY . .

# Usuario no-root
RUN useradd --create-home appuser && \
    chown -R appuser:appuser /app
USER appuser

CMD ["python", "app.py"]

Patrones Avanzados

Múltiples Etapas con Nombres

Puedes nombrar cada etapa y referenciarlas específicamente:

FROM node:18 AS frontend-builder
# ... build frontend ...

FROM maven:3.9-eclipse-temurin-17 AS backend-builder
# ... build backend ...

FROM nginx:alpine AS web-server
COPY --from=frontend-builder /app/dist /usr/share/nginx/html

FROM openjdk:17-slim AS api-server
COPY --from=backend-builder /app/target/app.jar /app/
CMD ["java", "-jar", "/app/app.jar"]

Etapas Intermedias para Tests

Ver más: Ejecutar Tests en Pipeline de Build
# Builder stage
FROM node:18 AS builder
WORKDIR /app
COPY . .
RUN npm ci

# Test stage
FROM builder AS tester
RUN npm test || exit 1

# Production stage (solo se ejecuta si tests pasan)
FROM node:18-slim AS production
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
CMD ["node", "dist/index.js"]

Este patrón es excelente para CI/CD: si los tests fallan, la construcción se detiene automáticamente antes de generar la imagen de producción.

Mejores Prácticas

📌 MEJORES PRÁCTICAS CLAVE
  1. Ordena las capas por frecuencia de cambio: Copia primero archivos que cambian poco (package.json, requirements.txt)
  2. Usa --chown en Producción: Siempre asigna propiedad correcta a los archivos copiados
  3. Minimiza el número de etapas: Más etapas = más complejidad. Usa las necesarias.
  4. Limita el uso de --mount: Para caché de build (Maven, npm), usa --mount=type=cache
  5. Valida con dive: Herramienta para inspeccionar qué contiene realmente tu imagen final

Optimización con BuildKit Cache

# Habilitar BuildKit para caché de dependencias
# Set DOCKER_BUILDKIT=1

FROM node:18 AS builder
WORKDIR /app

# Montar caché de npm
RUN --mount=type=cache,target=/root/.npm \
    npm ci

# Montar caché de build
RUN --mount=type=cache,target=/app/node_modules \
    npm run build
💡 CONSEJO: Activa BuildKit por defecto añadiendo a tu ~/.docker/config.json:
{
  "features": {
    "buildkit": true
  }
}
Esto habilita caché entre builds y acelera significativamente reconstrucciones incrementales.

Depuración de Multi-stage Builds

Si necesitas inspeccionar una etapa específica durante el desarrollo:

# Construir solo hasta la etapa 'builder'
docker build --target builder -t my-app:debug .

# Abrir shell interactivo en esa etapa
docker run -it my-app:debug /bin/sh
⚠️ IMPORTANTE: Al usar --target, Docker construye todas las etapas anteriores necesarias pero se detiene en la etapa especificada. Esto es invaluable para depurar problemas de compilación sin generar la imagen completa.

Resumen y Próximos Pasos

Los Multi-stage Builds son una de las características más poderosas de Docker moderno. No solo reducen el tamaño de las imágenes, sino que mejoran la seguridad, aceleran los despliegues y hacen más eficiente todo el pipeline de desarrollo.

En esta lección aprendiste:

  • ✅ Qué son los Multi-stage Builds y por qué importan
  • ✅ Cómo transformar un Dockerfile tradicional en uno eficiente
  • ✅ Ejemplos prácticos con Node.js, Go y Python
  • ✅ Patrones avanzados y mejores prácticas
  • ✅ Técnicas de depuración para desarrollo

Recursos Adicionales

Ver más: Herramientas Recomendadas
  • dive: Analiza el contenido de tus imágenes Docker
  • docker-slim: Automatiza la reducción de imágenes
  • hadolint: Linter para Dockerfiles con reglas de mejores prácticas
  • buildpacks: Construye imágenes sin Dockerfiles
🧠 Quiz

¿Cuál es el beneficio principal de usar Multi-stage Builds en Docker?

  • A) Aumentar la velocidad de construcción del código
  • B) Reducir el tamaño de la imagen final manteniendo solo lo esencial
  • C) Facilitar la escritura de tests automatizados
  • D) Mejorar el rendimiento de la aplicación en producción
Respuesta: B. Los Multi-stage Builds permiten crear imágenes finales más pequeñas al copiar únicamente los artefactos necesarios de etapas intermedias, descartando herramientas de compilación, dependencias de desarrollo y archivos temporales.
🧠 Quiz

¿Qué instrucción de Dockerfile permite copiar archivos desde una etapa específica a otra?

  • A) COPY --from=stage-name
  • B) FROM --stage=stage-name
  • C) SELECT --from stage-name
  • D) EXTRACT --source=stage-name
Respuesta: A. La instrucción COPY --from=stage-name copia artefactos específicos desde una etapa intermedia. También puedes usar números: COPY --from=0 para la primera etapa.