Módulo 9 · Despliegue y operaciones

Lección 40 — Docker

Imágenes, Dockerfile multi-stage y compose para el desarrollo de TicketFlow.

Publicada
En esta lección
  1. Objetivos
  2. 1. El Dockerfile multi-stage, línea a línea
  3. 2. Las reglas de la capa COPY y el.dockerignore
  4. 3. El compose de desarrollo
  5. 4. Migraciones y arranque: el orden que no corrompe
  6. 5. El tamaño, la seguridad y la reproducibilidad
  7. Autoevaluación

Stack: Docker + compose · Proyecto: TicketFlow Estado: Publicada — apertura del módulo de despliegue Prerrequisito: Lección 39 — Pools, paginación y compresión


Objetivos

  1. Escribir el Dockerfile multi-stage de TicketFlow: imagen pequeña, sin secretos, reproducible desde el lockfile.
  2. Armar el compose de desarrollo con healthchecks y volúmenes que no torturan el hot-reload.
  3. Aplicar la regla de la 27 a la imagen: una imagen, tres entornos — nada de config horneada.

1. El Dockerfile multi-stage, línea a línea

El multi-stage separa CONSTRUIR (compiladores, headers, dev deps) de EJECUTAR (runtime mínimo):

dockerfile
# ---- builder ----
FROM python:3.12-slim AS builder
ENV PIP_NO_CACHE_DIR=1 PIP_DISABLE_PIP_VERSION_CHECK=1
WORKDIR /app
COPY requirements.txt requirements.lock ./
RUN pip install --prefix=/install -r requirements.lock

# ---- runtime ----
FROM python:3.12-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
      libpq5 curl && rm -rf /var/lib/apt/lists/*     # solo el runtime de libpq, no el -dev
WORKDIR /app
COPY --from=builder /install /usr/local
COPY . .
RUN python manage.py collectstatic --noinput \
 && useradd -m 10001 && chown -R 10001 /app
USER 10001
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s CMD curl -fsS http://localhost:8000/healthz/ | grep -q ok
CMD ["gunicorn", "ticketflow.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "4"]

Los detalles que separan una imagen adulta de un tutorial: --prefix=/install copia SOLO el venv instalado (no pip cache, no compiladores); libpq5 sin el -dev (compilar quedó en builder); usuario no-root (el contenedor comprometido no escribe fuera de lo suyo, 22); collectstatic en build (no en cada arranque); el HEALTHCHECK (43 lo usa para saber si el contenedor vive). El lockfile (requirements.lock) fija la versión EXACTA de cada dependencia (con hashes si el paranoico del 22 gana): la imagen es reproducible — el mismo hash hace 6 meses o hoy.

2. Las reglas de la capa COPY y el.dockerignore

Cada capa COPY invalida las siguientes: el orden es del MENOS cambiante al MÁS (requirements antes que el código: el código cambia a diario, las deps cada semana — el cache de pip sobrevive al deploy diario). Y el .dockerignore es el primo del .gitignore (06): excluye .git (240 MB de historia en la imagen: no), .env* (la 27: un secreto horneado en la imagen es un secreto filtrado — docker history y el registry lo conservan), tests/, docs/, el sqlite del dev y los virtualenvs. El test del secreto: docker history ticketflow --no-trunc | grep -i secret debe devolver vacío — y trivy image ticketflow (41 lo mete en CI) para las CVEs de la capa base.

3. El compose de desarrollo

El compose orquesta los 5 procesos de TicketFlow con las esperas sanas:

yaml
services:
  db:
    image: postgres:16-alpine
    environment: {POSTGRES_USER: tf, POSTGRES_PASSWORD: tf, POSTGRES_DB: tf}
    volumes: [pgdata:/var/lib/postgresql/data]
    healthcheck: {test: ["CMD-SHELL", "pg_isready -U tf"], interval: 5s, retries: 10}
  redis:
    image: redis:7-alpine
    healthcheck: {test: ["CMD", "redis-cli", "ping"], interval: 5s, retries: 10}
  web:
    build: .
    env_file: .env                    # la config del entorno (27), jamás horneada
    command: python manage.py runserver 0.0.0.0:8000
    volumes:
      - ./site:/app/site              # bind mount: el hot-reload lee tu editor
      - ./ticketflow:/app/ticketflow
    depends_on:
      db: {condition: service_healthy}    # espera SALUD, no solo "arrancó"
      redis: {condition: service_healthy}
    ports: ["8000:8000"]
  worker:
    build: .
    command: celery -A ticketflow worker -l info
    depends_on: {db: {condition: service_healthy}, redis: {condition: service_healthy}}
  beat:
    build: .
    command: celery -A ticketflow beat -l info
volumes: {pgdata: {}}

Dos matices de desarrollo: el runserver y el bind mount dan hot-reload (el código del editor entra al contenedor); y el worker/beat SIN el runserver usan la MISMA imagen (la 27: una imagen, comandos distintos — el comando es del entorno, no de la imagen). La producción NO usa este compose para "desplegar" (43 decide si es K8s/Cloud Run/compose de prod), pero las variables y healthchecks son los mismos: el compose de dev es el prototipo del despliegue.

4. Migraciones y arranque: el orden que no corrompe

El error clásico: command: python manage.py migrate && runserver en el compose — tres réplicas arrancan a la vez y tres migrate compiten por la tabla django_migrations (el lock de la 10 lo serializa, pero el warning y la lentitud no se explican solos). El patrón adulto: las migraciones son un JOB del despliegue (41/43), no el arranque del contenedor: el pipeline corre migrate UNA vez (11: expand-contract pensado para desplegar), después despliega las réplicas nuevas. El arranque del contenedor solo espera salud (healthcheck) y sirve. El wait-for-it.sh y primos son el parche del problema mal resuelto; el healthcheck de compose + el job de migrate en CI/CD lo resuelven de raíz.

5. El tamaño, la seguridad y la reproducibilidad

La métrica del tamaño: la imagen de runtime de TicketFlow queda ~180-220 MB (slim + venv + statics): docker images es el instrumento; cada 100 MB son 3 s extra de pull en el despliegue (55 veces al día, 41). Seguridad: el usuario no-root, la imagen base con digest pinneado (python:3.12-slim@sha256:... para que "slim" no cambie bajo tus pies), y el scan en CI (41). Y la prueba de reproducibilidad final — la de la 27:

bash
docker build -t ticketflow:latest .          # una vez
docker run --env-file .env.dev  -p 8000:8000 ticketflow:latest
docker run --env-file .env.stag -p 8001:8000 ticketflow:latest
# mismo image ID en los tres entornos: el despliegue es config, no build

Autoevaluación

  1. ¿Qué queda en cada stage del multi-stage y por qué el runtime NO lleva ni pip ni el -dev de libpq?
  2. ¿Por qué requirements antes que código en las capas COPY y qué invalida a qué? ¿Qué debe tener SIEMPRE el.dockerignore?
  3. El healthcheck de compose: ¿qué diferencia hay entre "arrancó" y "está sano", y qué condición lo usa el web?
  4. ¿Por qué migrate && runserver en el compose es el patrón que corrompe y dónde viven las migraciones en el despliegue adulto?
  5. La prueba de reproducibilidad: ¿qué demuestra el mismo image ID con tres.env y cómo se pinnea la base para que no cambie bajo tus pies?

Continúa con los ejercicios. Las solutions.md solo tras intentarlo.