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. Ejercicio 1 — El Dockerfile
  2. Ejercicio 2 — El secreto y la CVE
  3. Ejercicio 3 — El compose
  4. Ejercicio 4 — Las migraciones
  5. Ejercicio 5 — La imagen de producción
  6. Resumen del profesor

Ejercicio 1 — El Dockerfile

  1. El ahorro típico: imagen naïf (FROM python:3.12 + pip install + COPY.) ≈ 1.1 GB → multi-stage slim ≈ 210 MB (−80%). Cada pull de despliegue pasa de ~40 s a ~7 s: ×15 despliegues/día ×5 min ahorrados — el tamaño es velocidad de despliegue, no estética.
  1. La evidencia:
$ docker run --rm ticketflow whoami
10001
$ docker run --rm ticketflow touch /usr/x
touch: cannot touch '/usr/x': Permission denied

Con la 22 en la mano: el atacante que ejecuta código en tu contenedor hereda el uid 10001 — sin permiso de escritura en el filesystem del contenedor (y con read_only: true en prod, de root filesystem entero), la persistencia (cron, binarios reemplazados) se complica radicalmente. No es el muro (eso es el host, 43): es el precio de entrada.

  1. La reproducibilidad: dos builds seguidos → MISMO image ID (el build cache produce bytes idénticos). Cambiar un comentario de .py invalida SOLO la capa COPY.. y las siguientes (collectstatic, useradd): las de requirements sobreviven (pip cache del builder intacto). El hash del build cambia (el contenido cambió — correcto), pero el CAMBIO es mínimo y trazable: la imagen es el binario reproducible del proyecto.

Ejercicio 2 — El secreto y la CVE

  1. La evidencia del desastre (COPY sin dockerignore con.env presente):
$ docker history ticketflow:naif --no-trunc | grep -i secret
COPY . . → incluye .env con PAYMENT_GATEWAY_KEY=sk_live_...

El history muestra la capa COPY con el contexto entero; docker save/export dan los bytes literales: el secreto live de la pasarela vive en el registry de Docker para siempre (hasta docker image prune del registry, que nadie hace). El fix: .dockerignore + jamás COPY.. sin él + los secretos SÓLO por --env-file/orquestador (27). El grep del history limpio es el test de regresión de esta lección.

  1. El triage de CVEs (3 hallazgos modelo): CVE-2024-XXXX libssl 1.3 (DoS) — relevante si el TLS termina EN el contenedor (aquí no: el proxy del 43 termina TLS → ruido operativo, se parchea con la base igual); CVE-2024-YYYY pip (instalación maliciosa) — no aplica en runtime (no hay pip: quedó en builder); CVE-2024-ZZZZ glibc (escala de privilegios local) — RELEVANTE: combinado con algún vector de código remoto del app, escala al host. El criterio: explotable EN mi contexto (¿el componente está expuesto? ¿exige prerrequisitos que no existen?) — el CVSS alto sin contexto es una lista de compras, no un triage.
  1. El pinneo por digest: "slim" es un tag MÓVIL — un martes cualquiera Debian publica parche y el tag apunta a bytes nuevos: tu build de hoy ≠ tu build de ayer SIN cambiar nada tuyo (la reproducibilidad rota en silencio; el scan de CI que pasaba ayer, falla hoy sin commit). El digest congela los bytes: el upgrade de base es UN commit consciente con el digest nuevo — lo que la 41 automatiza (el bot que PR-ea el digest nuevo con el scan en verde).

Ejercicio 3 — El compose

  1. El log del arranque:
db-1      | 2027-09-28 10:00:01 [1] LOG:  database system is ready to accept connections
redis-1   | Ready to accept connections
db-1      | [healthcheck] pg_isready OK (retries 1/10)
web-1     | Waiting for db: service_healthy...
web-1     | ✔ db healthy → starting gunicorn

"Arrancó" ≠ "sano": PG acepta conexiones ANTES de terminar recovery; el healthcheck (pg_isready) pregunta lo que el app necesita saber. Sin él, el web arranca, la primera query explota con OperationalError y el restart policy dispara la ruleta del arranque.

  1. El hot-reload funcionó (el bind mount ./ticketflow:/app/ticketflow y runserver's autoreloader); el requirements.txt editado + docker compose build: el paso RUN pip install se invalidó SOLO si cambió el hash del fichero COPY — cambiar código NO toca la capa de deps: la instalación de 90 s sobrevive al deploy de código de 2 s. La orden de COPY ES la política de caché del desarrollo.
  1. El uso del shell del worker: migrar a mano (python manage.py shell) contra la BD real de compose para depurar la reserva con locks (10), correr el FakeClock del 24 en un entorno realista, y el "reproducir el bug del cliente con sus datos anonimizados (23)" — el entorno del shell ES el entorno del worker: el bug del job se reproduce donde vive.

Ejercicio 4 — Las migraciones

  1. La evidencia del rompimiento:
web-1 | Applying events.0012_add_status_index...
web-2 | Applying events.0012_add_status_index... (LOCKED: esperando a web-1)
web-3 | Applying events.0012_add_status_index... (LOCKED)
web-1 | OK
web-2 | OK

El lock de django_migrations (10) evita la corrupción (solo uno aplica), pero: los 2 restantes bloquean el arranque 30-120 s (el índice CONCURRENTLY (11) es lo peor: el lock del CREATE INDEX dentro del migrate serializa los tres), y si la migración NO es idempotente (data migration mal escrita), el segundo corredor DUPLICA datos. La molestia no es teórica.

  1. El patrón adulto: migrate como JOB (CI/CD lo corre contra la BD una vez, 41), después --scale web=3 arranca tres gunicorn que SÓLO sirven: los logs de arranque sin "Applying migrations" y el primer 200 en <2 s. El mandato del 11 se cumple: migración y despliegue son fases separadas y observables.
  1. El arranque limpio: docker compose restart web → healthcheck + gunicorn bind: primer 200 en ~1.5 s. Lo que NO espera: ni BD ni Redis (los healthchecks del compose solo aplican al START del compose; en un restart el contenedor confía en la infra ya viva — y si la infra murió, el 500 del 26 lo dice, no un crash loop).

Ejercicio 5 — La imagen de producción

  1. El top de peso: /usr/local/lib/python3.12/site-packages (Django+DRF+Celery+psycopg: 142 MB — el venv es el app, no hay que recortarlo), statics de collectstatic (18 MB: no se sirven del app en prod, el CDN/white noise los lleva — se pueden quitar del image si el CDN los recibe por upload en el pipeline, 41), libc + libpq5 (~40 MB, irreducible en slim). Conclusión: la distroless (sin shell, sin apt) ahorraría ~30 MB y PIERDE el shell de debug (el compose run --rm web shell del ejercicio 3): el slim con no-root es el punto óptimo para un equipo de 1.
  1. El E2E de cero: build (cacheado: 15 s) + up + healthchecks (8 s) + seed (3 s) + pytest -m e2e (160 s) ≈ 3 min por PR: cabe en CI (41) con el cache del build subido como artefacto. El número decide la política del E2E: sin el entorno reproducible, el "corre E2E en cada PR" es un deseo.
  1. El ADR (extracto): "Imagen multi-stage python:3.12-slim@digest, usuario 10001, healthcheck /healthz/, config por entorno. Razones: reproducible (digest), segura (no-root + scan en CI), rápida (210 MB). Descartada: imagen única con todo (1.1 GB, secretos en history) y distroless (pierde shell de debug que el equipo de 1 usa a diario). Revisión: si el scan de la base acumula CVEs relevantes, migrar base o distroless + sidecar de debug".

Resumen del profesor

  • Multi-stage + lockfile + no-root + digest pinneado: imagen pequeña, segura y reproducible; el history sin secretos es el test de regresión.
  • El compose de dev es el prototipo del despliegue: healthchecks como condición (no como deseo), bind mounts para hot-reload, una imagen con comandos por servicio.
  • Las migraciones son un JOB del despliegue, no el arranque del contenedor: el lock de django_migrations salva los datos, no la paz.