Multi-stage Builds para Imágenes Ligeras

Lectura
25 min~8 min lectura
CONCEPTO CLAVE: Los Multi-stage Builds permiten crear múltiples etapas de construcción en un solo Dockerfile, donde cada etapa puede copiar artefactos de la anterior, pero solo la etapa final se incluye en la imagen final. Esto reduce drásticamente el tamaño de las imágenes finales.

¿Qué son los Multi-stage Builds?

Los Multi-stage Builds son una característica de Docker que permite definir múltiples etapas dentro de un único Dockerfile. Cada etapa es esencialmente una imagen temporal que puede utilizar diferentes bases, instalar dependencias distintas y realizar tareas específicas. La magia radica en que solo la última etapa (o la que indiques explícitamente) se convierte en tu imagen final.

Esta técnica surge de una necesidad real: las imágenes de producción suelen incluir archivos de compilación, dependencias de desarrollo, herramientas de debugging y otros elementos que no son necesarios para ejecutar la aplicación, pero que incrementan significativamente el tamaño de la imagen.

El Problema: Imágenes Sobradas

Imagina que estás desarrollando una aplicación en Go. Para compilar tu programa necesitas:

  • El SDK completo de Go (que puede superar los 800MB)
  • Headers de sistema para compilación
  • Bibliotecas de desarrollo
  • Herramientas de build como gcc, make, etc.

Sin embargo, para ejecutar tu binario compilado de Go, solo necesitas el binario estático y potencialmente ninguna dependencia adicional si usas CGO_ENABLED=0. Tu imagen final termina pesando varios gigabytes cuando podría pesar apenas unos pocos megabytes.

📌 Los Multi-stage Builds resuelven este problema permitiendo separar el entorno de construcción del entorno de ejecución, manteniendo una sola fuente de verdad (un solo Dockerfile).

Sintaxis Básica

La sintaxis es intuitiva. Cada etapa comienza con FROM y se le puede asignar un nombre con AS nombre. Para copiar artefactos entre etapas, se usa COPY --from=nombre o COPY --from=0 (usando el índice).

# Etapa 1: Construcción
FROM node:18 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build

# Etapa 2: Producción
FROM node:18-slim AS production
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY package*.json .
CMD ["node", "dist/index.js"]

Ejemplo Práctico: Aplicación React

Veamos un ejemplo completo con una aplicación React que requiere compilación:

# Etapa de build
FROM node:18-alpine AS build
WORKDIR /app

# Copiar solo archivos de dependencia primero (optimización de caché)
COPY package*.json ./
RUN npm ci

# Copiar código fuente
COPY . .

# Construir la aplicación
RUN npm run build

# Etapa de producción
FROM nginx:alpine AS production

# Copiar los archivos construidos
COPY --from=build /app/build /usr/share/nginx/html

# Copiar configuración de nginx
COPY nginx.conf /etc/nginx/nginx.conf

EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
💡 Tip de optimización: Al copiar primero los archivos de package*.json y ejecutar npm ci, Docker puede cachear esa capa. Los cambios en el código fuente no invalidarán el caché de dependencias, acelerando significativamente los rebuilds.

Ejemplo Práctico: Aplicación Python

Para aplicaciones Python con dependencias complejas de compilación, los Multi-stage Builds son especialmente útiles:

# Etapa 1: Construir dependencias
FROM python:3.11-slim AS builder

# Instalar dependencias del sistema para compilar paquetes
RUN apt-get update && apt-get install -y --no-install-recommends \
    gcc \
    libpq-dev \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /app

# Instalar dependencias de Python
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt

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

# Crear usuario no root por seguridad
RUN groupadd --gid 1000 appgroup && \
    useradd --uid 1000 --gid appgroup --shell /bin/bash appuser

WORKDIR /app

# Copiar solo lo necesario
COPY --from=builder /root/.local /home/appuser/.local
COPY --from=builder /app ./app
COPY app/ ./app

# Cambiar propietario
RUN chown -R appuser:appgroup /app

USER appuser
ENV PATH=/home/appuser/.local/bin:$PATH

CMD ["python", "app/main.py"]
⚠️ Advertencia de seguridad: Siempre ejecuta tu aplicación como un usuario no root. Los contenedores se ejecutan por defecto como root, lo cual es un riesgo de seguridad. Crear un usuario específico y cambiar a él con USER es una práctica esencial.

Copiar Artefactos Específicos

No siempre necesitas copiar todo desde una etapa. Docker permite copiar archivos específicos:

# Copiar directorio completo
COPY --from=builder /app/dist ./dist

# Copiar archivo individual
COPY --from=builder /app/package.json ./package.json

# Copiar desde múltiples etapas
COPY --from=frontend /app/build ./public
COPY --from=api /app/dist ./api

Nombrar vs Índices

Tienes dos formas de referenciar etapas:

MétodoSintaxisEjemploUso recomendado
Índice--from=0, --from=1--from=0Etapas simples
Nombre--from=nombre--from=builderEtapas complejas
📌 Usa nombres descriptivos (builder, production, test) para mejor legibilidad y mantenimiento del código.

Selección de Etapa Final

Normalmente, la última etapa es la que se convierte en imagen final. Sin embargo, puedes especificar una etapa diferente:

# Crear múltiples imágenes para diferentes propósitos
FROM golang:1.21 AS builder
WORKDIR /app
COPY . .
RUN go build -o myapp

# Imagen de producción (la que se construye por defecto)
FROM alpine:3.18 AS production
COPY --from=builder /app/myapp /myapp
CMD ["/myapp"]

# Imagen de debugging (si la necesitas)
FROM docker:dind AS debug
COPY --from=builder /app/myapp /myapp

# Construir etapa específica
# docker build --target production -t myapp:prod .
# docker build --target debug -t myapp:debug .

Comparación de Tamaños

Veamos el impacto real con un ejemplo de aplicación Node.js:

MétodoTamaño aproximadoReducción
Sin Multi-stage (node:18 completo)~1.1 GB-
Con Multi-stage (node:18-slim)~180 MB83%
Multi-stage optimizado~25 MB97%
💡 La diferencia entre 1.1 GB y 25 MB no solo es estética. Imágenes más pequeñas significan:
  • Despliegues más rápidos
  • Menos consumo de almacenamiento
  • Transferencias más veloces
  • Menor superficie de ataque

Patrón Common Base

Para proyectos con múltiples servicios, puedes usar un patrón de base común:

# Base común
FROM node:18-alpine AS base
WORKDIR /app

# Dependencias comunes
FROM base AS deps
COPY package*.json ./
RUN npm ci

# Servicio 1
FROM deps AS api
COPY src/api ./src
RUN npm run build:api

# Servicio 2
FROM deps AS web
COPY src/web ./src
RUN npm run build:web

# Imágenes finales
FROM node:18-alpine AS api-production
COPY --from=api /app/dist /app/dist
CMD ["node", "dist/api/index.js"]

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

Buenas Prácticas

  1. Minimiza las capas: Combina comandos RUN cuando sea posible usando && y limpia cachés en la misma instrucción.
  2. Ordena las instrucciones: Pon lo que cambia menos primero para maximizar el uso de caché.
  3. Usa .dockerignore: Excluye node_modules, archivos de desarrollo y otros innecesarios.
  4. Prefiere imágenes oficiales minimalistas: alpine, slim, distroless.
  5. No installes herramientas de desarrollo en producción: git, curl, compiladores solo en etapas de build.
  6. Verifica tu imagen: Usa docker history para inspeccionar qué contiene cada capa.
# Instalar y limpiar en una sola capa
RUN apt-get update && \
    apt-get install -y --no-install-recommends \
        package1 \
        package2 \
    && rm -rf /var/lib/apt/lists/*

# Verificar contenido de la imagen
docker history myapp:latest
Ver más: Ejemplo completo con múltiples tecnologías

Un caso avanzado: una aplicación con frontend (React), backend (Node.js) y worker (Python):

# ============================================
# FRONTEND: React con Vite
# ============================================
FROM node:18-alpine AS frontend-builder
WORKDIR /frontend
COPY frontend/package*.json .
RUN npm ci
COPY frontend/ .
RUN npm run build

# ============================================
# BACKEND: Node.js API
# ============================================
FROM node:18-alpine AS backend-builder
WORKDIR /backend
COPY backend/package*.json .
RUN npm ci
COPY backend/ .
RUN npm run build

# ============================================
# WORKER: Python con Celery
# ============================================
FROM python:3.11-slim AS worker-builder
WORKDIR /worker
COPY worker/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY worker/ .

# ============================================
# IMÁGENES FINALES
# ============================================
FROM nginx:alpine AS frontend
COPY --from=frontend-builder /frontend/dist /usr/share/nginx/html

FROM node:18-alpine AS backend
WORKDIR /app
COPY --from=backend-builder /backend/dist ./dist
COPY --from=backend-builder /backend/node_modules ./node_modules
ENV NODE_ENV=production
CMD ["node", "dist/index.js"]

FROM python:3.11-slim AS worker
WORKDIR /app
COPY --from=worker-builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
COPY --from=worker-builder /app /app
CMD ["python", "-m", "celery", "-A", "tasks", "worker"]

Verificación y Debugging

Después de implementar Multi-stage Builds, verifica que tu imagen contenga solo lo necesario:

# Ver todas las capas
docker history myapp:latest

# Ver archivos en la imagen final
docker run --rm myapp:latest ls -la /

# Ver procesos en ejecución
docker run --rm myapp:latest ps aux

# Ver tamaño desglosado
docker image inspect myapp:latest --format='{{.Size}}'
⚠️ Si ves herramientas como curl, git, o compiladores en la imagen final, revisa tu Dockerfile. Estos archivos innecesarios aumentan el tamaño y la superficie de ataque.
Las imágenes pequeñas son imágenes seguras. Cada archivo innecesario es un vector potencial de ataque.

Resumen de Beneficios

BeneficioImpacto
Tamaño reducido80-95% menor que imágenes tradicionales
Seguridad mejoradaMenos superficie de ataque
Despliegues más rápidosMenos datos a transferir
Mejor cachingBuilds incrementales más eficientes
Mantenimiento sencilloUn solo Dockerfile por servicio
🧠 Quiz

¿Cuál es la principal ventaja de usar Multi-stage Builds en Docker?

  • A) Permiten usar múltiples archivos Dockerfile
  • B) Reducen el tamaño de la imagen final al separar el entorno de construcción del de producción
  • C) Aceleran la ejecución de contenedores
  • D) Mejoran la seguridad del host únicamente
✅ Respuesta correcta: B. Los Multi-stage Builds permiten crear imágenes con solo los artefactos necesarios para producción, excluyendo herramientas de compilación, dependencias de desarrollo y archivos temporales, lo que resulta en imágenes significativamente más pequeñas.
🧠 Quiz

¿Cómo se referencian los archivos de una etapa anterior llamada "builder" en un Multi-stage Build?

  • A) COPY builder:/app/dist ./dist
  • B) COPY --from=1 /app/dist ./dist
  • C) COPY --from=builder /app/dist ./dist
  • D) COPY builder:0 /app/dist ./dist
✅ Respuesta correcta: C. La sintaxis COPY --from=nombre permite copiar archivos desde una etapa nombrada, mientras que COPY --from=0 copiaría desde la primera etapa usando su índice.
CONCEPTO CLAVE: Los Multi-stage Builds son una herramienta fundamental para crear imágenes de producción optimizadas. Al separar claramente las fases de construcción y ejecución, puedes lograr reducciones de tamaño del 80-95%, mejorando seguridad, velocidad de despliegue y costos de infraestructura.