←

Docker multi-stage build optimizado: de 1.2GB a 180MB en produccion

Contexto

ulfblk-docker-prod genera Dockerfiles optimizados para produccion. El Dockerfile tipico de un proyecto Python con FastAPI pesa ~1.2GB porque incluye el compilador, headers de desarrollo, cache de pip y dependencias de build. En produccion no necesitas nada de eso. Menos capas, menos superficie de ataque, deploys mas rapidos.

Lo que aprendi

Multi-stage builds separan la construccion de la ejecucion. El builder compila todo, el runtime solo copia lo compilado.

Dockerfile multi-stage

# ============================================
# Stage 1: Builder - compila dependencias
# ============================================
FROM python:3.12-slim AS builder

WORKDIR /build

# Instalar dependencias de compilacion (solo en este stage)
RUN apt-get update && apt-get install -y --no-install-recommends \
    gcc \
    libpq-dev \
    && rm -rf /var/lib/apt/lists/*

# Copiar requirements primero (cache de Docker layers)
COPY requirements.txt .

# Compilar wheels en un directorio dedicado
RUN pip wheel \
    --no-cache-dir \
    --wheel-dir=/build/wheels \
    -r requirements.txt

# ============================================
# Stage 2: Runtime - imagen minima
# ============================================
FROM python:3.12-slim AS runtime

# Dependencias de runtime (solo libpq para psycopg2)
RUN apt-get update && apt-get install -y --no-install-recommends \
    libpq5 \
    curl \
    && rm -rf /var/lib/apt/lists/*

# Usuario no-root
RUN groupadd --gid 1000 appuser \
    && useradd --uid 1000 --gid 1000 --shell /bin/bash appuser

WORKDIR /app

# Copiar wheels pre-compilados del builder
COPY --from=builder /build/wheels /tmp/wheels

# Instalar sin compilar (ya estan los wheels)
RUN pip install \
    --no-cache-dir \
    --no-index \
    --find-links=/tmp/wheels \
    /tmp/wheels/*.whl \
    && rm -rf /tmp/wheels

# Copiar solo el codigo de la aplicacion
COPY --chown=appuser:appuser ./app ./app

# Cambiar a usuario no-root
USER appuser

# Health check
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
    CMD curl -f http://localhost:8000/health || exit 1

# Manejo de signals (SIGTERM para graceful shutdown)
STOPSIGNAL SIGTERM

EXPOSE 8000

CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

Comparacion de tamanos

REPOSITORY          TAG         SIZE
myapp-dev           latest      1.2GB    # Todo junto, imagen base full
myapp-builder       latest      890MB    # Stage 1 (descartado)
myapp-prod          latest      180MB    # Stage 2 (solo runtime)

La reduccion viene de tres fuentes: sin gcc/headers (~400MB), sin cache de pip (~200MB), sin herramientas de build (~400MB).

Hardening de seguridad

# Variables de entorno para produccion
ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    PIP_NO_CACHE_DIR=1 \
    PIP_DISABLE_PIP_VERSION_CHECK=1

# Filesystem read-only (complementar con --read-only en docker run)
# Solo /tmp es escribible
RUN chmod -R a-w /app

Docker Compose para produccion

services:
  api:
    build:
      context: .
      dockerfile: Dockerfile.prod
      target: runtime
    read_only: true
    tmpfs:
      - /tmp
    security_opt:
      - no-new-privileges:true
    deploy:
      resources:
        limits:
          memory: 512M
          cpus: "1.0"
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
      interval: 30s
      timeout: 5s
      retries: 3

Script de comparacion

"""Compara el tamano de la imagen antes y despues del multi-stage build."""
import subprocess
import json


def get_image_size(image_name: str) -> int:
    """Retorna el tamano de la imagen en bytes."""
    result = subprocess.run(
        ["docker", "image", "inspect", image_name, "--format", "{{.Size}}"],
        capture_output=True,
        text=True,
    )
    return int(result.stdout.strip())


def format_size(size_bytes: int) -> str:
    """Formatea bytes a unidad legible."""
    for unit in ["B", "KB", "MB", "GB"]:
        if size_bytes < 1024:
            return f"{size_bytes:.1f} {unit}"
        size_bytes /= 1024
    return f"{size_bytes:.1f} TB"


dev_size = get_image_size("myapp-dev")
prod_size = get_image_size("myapp-prod")
reduction = ((dev_size - prod_size) / dev_size) * 100

print(f"Dev:  {format_size(dev_size)}")
print(f"Prod: {format_size(prod_size)}")
print(f"Reduccion: {reduction:.0f}%")

Leccion clave

Las imagenes de produccion no deben tener dependencias de build. Cero gcc, cero pip cache, cero dev requirements. El multi-stage build lo hace trivial: un stage para compilar, otro para correr. Ademas, usuario no-root y filesystem read-only no cuestan nada y reducen la superficie de ataque significativamente.

Referencia