En C01, la estación 6 deja listo el código del gateway y el model repository de Triton. Esta estación los convierte en imágenes: un archivo inmutable con el sistema base, las dependencias, el código y (si así lo decides) el modelo. Esa imagen es lo que viaja por CI (estación 8) y lo que corre Kubernetes (estación 9). Aquí la practicas con el servicio de Iris, el de tu laboratorio de Docker: un gateway que valida y etiqueta, y un model server que habla el mismo protocolo v2 que Triton.
Entra
el código de serving: gateway/app.py y model_server/server.py
el modelo exportado: model_repository/iris/1/model.onnx
la lista de lo que importa cada servicio (aún sin versiones)
→7→
Sale
dependencias fijadas, un requirements.txt por servicio
dos imágenes con usuario sin privilegios, caché bien ordenada y forma exec
un compose.yaml que levanta los dos servicios sanos y conectados
las imágenes en un registry, identificadas por su digest
En C01
En el dojo
Imagen del model server
Triton tritonserver:24.08-py3 de NGC con el engine horneado: del orden de 10 GB comprimida (lo midió tu lab de Iris en arm64)
model server propio (FastAPI + onnxruntime): 453 MB en disco, 104 MB comprimida
Imagen del gateway
multi-stage, 37 paquetes con wheels, UID 10001
una etapa, 23 paquetes: 275 MB en disco, 59.7 MB comprimida
Registry
Amazon ECR: tag inmutable + digest
registry:2 en localhost:5050
Dónde corre
Kubernetes con GPU (estación 9); compose solo en desarrollo
docker compose en tu Mac, con healthcheck
Arquitectura
nodos Linux amd64
tu Mac construye Linux arm64: la misma imagen no corre tal cual en un nodo amd64
Una línea para entenderlo; abre la tarjeta para la analogía, por qué importa y la trampa.
Imagen y capasimage / layersUna imagen es una pila de capas de solo lectura: cada instrucción del Dockerfile que cambia archivos agrega una. Un contenedor es esa imagen corriendo, con una capa escribible encima.Analogía, por qué importa y la trampa ↓
AnalogíaUn pastel de capas: no puedes cambiar la de en medio sin rehacer las de arriba.
Por qué importaLo que pesa, lo que se cachea y lo que viaja al registry se cuenta en capas.
TrampaBorrar un archivo en una capa posterior no lo quita de la imagen: sigue pesando en la capa donde se creó.
Build context y .dockerignorebuild contextLa carpeta que el cliente de Docker manda al builder en cada build. Todo lo que no excluye el .dockerignore viaja, aunque el Dockerfile no lo copie.Analogía, por qué importa y la trampa ↓
AnalogíaLa maleta que llevas a la cocina: si metes todo el clóset, tardas en llegar aunque solo vayas a cocinar.
Por qué importaUn .venv o un volcado en el contexto hace lento cada build y puede meter secretos o basura en la imagen con un COPY . descuidado.
TrampaUn .dockerignore vacío (o sin guardar) no avisa nada: el build simplemente se vuelve lento.
Caché de capaslayer cacheSi una instrucción y lo que copia no cambiaron, Docker reusa la capa (CACHED). Si una capa cambia, se rehacen ella y todas las de abajo.Analogía, por qué importa y la trampa ↓
AnalogíaUn recetario donde solo rehaces los pasos a partir del ingrediente que cambiaste.
Por qué importaOrdenar de lo que menos cambia a lo que más cambia (dependencias antes que código) convierte builds de minutos en segundos.
TrampaDocker compara bytes, no significado: un comentario en requirements.txt invalida la instalación.
Dependencias fijadaspinned dependencies / lock fileUn requirements.txt con cada paquete y su versión exacta (==), generado desde la lista corta requirements.in. El build de hoy y el de dentro de seis meses instalan lo mismo.Analogía, por qué importa y la trampa ↓
AnalogíaLa lista del súper (requirements.in) contra el ticket con marca y precio (requirements.txt).
Por qué importaSin fijar, un paquete que publica una versión nueva rompe tu imagen sin que hayas cambiado nada.
TrampaEditar el .txt a mano. Se regenera desde el .in con uv pip compile; si no, el lock deja de reflejar lo que pediste.
Usuario sin privilegiosnon-root userUSER define con qué identidad corre el proceso. Nadie hace login en un contenedor: el riesgo es una vulnerabilidad que deja ejecutar comandos con los permisos del proceso.Analogía, por qué importa y la trampa ↓
AnalogíaEl empleado de mostrador no trae las llaves de la bóveda: si lo engañan, el daño es chico.
Por qué importaKubernetes puede exigir runAsNonRoot, y para verificarlo necesita un UID numérico (USER 10001:10001).
TrampaCrear el usuario pero olvidar la línea USER: la imagen sigue corriendo como root.
Escuchar en 0.0.0.0bind addressDentro del contenedor, 127.0.0.1 solo acepta conexiones del propio contenedor. Para que llegue lo que publicas con -p, el servidor escucha en 0.0.0.0.Analogía, por qué importa y la trampa ↓
AnalogíaAtender el teléfono de tu escritorio contra atender el conmutador de la oficina.
Por qué importaEs el error clásico de «el contenedor corre pero no contesta».
TrampaProbar con curl desde dentro del contenedor: ahí sí funciona y te hace creer que está bien.
Forma exec y señalesexec form / PID 1 / SIGTERMCMD ["uvicorn", …] (con corchetes) hace a uvicorn el PID 1 y recibe SIGTERM. En forma shell, PID 1 es /bin/sh, que no reenvía la señal.Analogía, por qué importa y la trampa ↓
AnalogíaAvisarle directo a quien tiene que cerrar la tienda, en vez de dejarle el recado al guardia.
Por qué importadocker stop y Kubernetes mandan SIGTERM y esperan (10 s y 30 s por defecto); si nadie la atiende, matan el proceso a media request.
TrampaUn script de entrada sin exec al final: el proceso real queda como hijo y no se entera.
Pesos dentro o fuera de la imagenbaked-in model vs volumeDentro: la imagen es código + modelo y su tag es la versión del modelo. Fuera: la imagen solo trae código y el modelo llega por un volumen o se baja al arrancar.Analogía, por qué importa y la trampa ↓
AnalogíaUn platillo empacado al vacío contra uno que armas con ingredientes que llegan aparte.
Por qué importaDentro: reproducible y rollback de un tag. Fuera: imágenes chicas y cambiar el modelo sin reconstruir.
TrampaCon el modelo dentro, arreglar el archivo no basta: hay que reconstruir la imagen.
Red de compose y nombres de servicioservice discoverycompose crea una red donde cada servicio se encuentra por su nombre: el gateway llega a http://model-server:8000.Analogía, por qué importa y la trampa ↓
AnalogíaLas extensiones de un conmutador: marcas «model-server», no el número de la calle.
Por qué importaDentro de un contenedor, localhost es el propio contenedor. Es la diferencia entre tu laptop y la red de contenedores.
TrampaCopiar la configuración de la laptop (localhost:8000) a un contenedor.
Healthcheck y depends_onhealthcheck / readiness gatingEl healthcheck pregunta periódicamente si el servicio está listo; depends_on con condition: service_healthy hace que el gateway arranque solo cuando el model server está sano.Analogía, por qué importa y la trampa ↓
AnalogíaNo abrir la caja hasta que la cocina diga que el horno ya está caliente.
Por qué importaSin él, el gateway arranca antes que el modelo y los primeros requests fallan. En Kubernetes es la readinessProbe.
TrampaUsar curl en una imagen slim que no lo trae: el healthcheck nunca pasa y parece que el servicio está roto.
Configuración por variables de entorno12-factor configLa misma imagen sirve en cualquier lado; lo que cambia (URLs, timeouts) llega en variables de entorno, aquí desde config.env.Analogía, por qué importa y la trampa ↓
AnalogíaEl mismo auto con otro destino en el GPS.
Por qué importaPromover la imagen exacta que probaste, de dev a staging a producción, cambiando solo la configuración.
TrampaDefinir la variable en una línea aparte en la terminal: no le llega al programa.
Publicar puertosport publishing (-p)EXPOSE solo documenta. -p host:contenedor (o ports: en compose) abre el puerto en tu Mac. Un puerto del host lo puede usar un solo proceso.Analogía, por qué importa y la trampa ↓
AnalogíaTener una ventanilla dentro del edificio no sirve si no la conectas con la calle.
Por qué importa«port is already allocated» es de los primeros errores que verás en una máquina compartida.
TrampaSi compose creó el contenedor cuando el puerto estaba ocupado, un up -d solo lo arranca sin puerto: hay que recrearlo.
Registry, tags y digestregistry / immutable tags / digestEl registry guarda imágenes por nombre:tag. El tag es una etiqueta que se puede mover; el digest (sha256 del manifiesto) identifica exactamente un contenido.Analogía, por qué importa y la trampa ↓
AnalogíaEl nombre de un archivo contra su huella digital.
Por qué importaEn C01 los tags de ECR son inmutables y se despliega por digest: lo que probaste es byte a byte lo que corre.
TrampaReusar el mismo tag (latest, 0.1) para contenidos distintos: nadie sabe qué corre dónde.
Política de reiniciorestart policyDice qué hace Docker con un contenedor cuando se detiene o cuando el daemon arranca: no, on-failure, always, unless-stopped.Analogía, por qué importa y la trampa ↓
AnalogíaEl despertador que suena solo cada mañana contra acordarte de ponerlo.
Por qué importaUn servicio de infraestructura (como un registry) sin política no vuelve después de un reinicio de Docker.
TrampaConfiar en que «siempre ha estado ahí»: hasta el primer reinicio.
Pull: lo que hace el servidordocker pull / deploy from registryCada máquina que corre tu servicio baja la imagen del registry, por tag o por digest, y la corre. No construye nada: solo necesita la imagen y su configuración.Analogía, por qué importa y la trampa ↓
AnalogíaLa tienda no hornea el pan: lo recibe ya hecho de la panadería, con su número de lote.
Por qué importaLo que corre en producción es lo que bajó del registry, no lo que hay en tu Mac. Si el digest coincide con el del push, es byte a byte lo que probaste.
TrampaProbar con la imagen local y dar por probada la del registry: si el tag se movió, el pull baja otra cosa.
Plataforma de la imagen (arm64 o amd64)image platform / multi-archUna imagen se construye para una arquitectura de CPU. Tu Mac construye linux/arm64; la mayoría de los servidores son linux/amd64. Una imagen multiplataforma lleva las dos bajo el mismo tag.Analogía, por qué importa y la trampa ↓
AnalogíaUn enchufe americano frente a un contacto europeo: el aparato sirve, pero no entra.
Por qué importaSi el registry solo tiene arm64, un servidor amd64 no la puede bajar. Se construye con docker buildx build --platform linux/amd64,linux/arm64 o se usan servidores arm (Graviton en AWS).
TrampaCreer que si corre en tu Mac corre en el servidor. Revisa las plataformas con docker buildx imagetools inspect antes de desplegar.
de aquí a Romper
El proyecto con kit
Con kit · 4 rondas
El kit trae el servicio de Iris funcionando sin Docker: el código del gateway y del model server, el modelo exportado y una prueba de humo. Tú escribes todo lo que lo empaqueta. No hace falta entorno virtual: verificar.py y romper.py solo usan Python y el CLI de Docker.
Todo lo que sigue, hasta Romper, es de este proyecto: sus precálculos, el kit, los prompts de las cuatro rondas, el paso a paso del Guiado, los objetivos de Solo y las fallas.
Precálculos
6 cuentas
Antes de escribir el primer Dockerfile, las cuentas de servilleta del empaquetado: cuánto pesa lo que construyes, cuánto viaja, cuánto te ahorra la caché y si te cabe en el disco. Cada cuenta va en las dos escalas, C01 y el dojo. Haz la cuenta tú primero y luego ábrela.
1
¿Cuánto pesa la imagen del gateway y cuánto viaja?
imagen = base + dependencias + código; lo que viaja en push y pull es la versión comprimida
En C01Triton de NGC: del orden de 10 GB comprimida, y unos 30 GB en el pico del pull (lo que tu lab de Iris no pudo bajar)GB por imagen: la base manda
En el dojopython:3.13-slim (≈ 160 MB de capas) + pip install (56 MB) + app.py (16 kB)275 MB en disco · 59.7 MB comprimida
Qué decides con esto. Lo que más pesa es la base y las dependencias, no tu código. Por eso se elige una base slim, se instala con --no-cache-dir y el código va en su propia capa al final.
Lo confirmas en el paso 3: docker images iris-gateway y docker history.
2
¿Cuánto te ahorra la caché de capas?
tiempo de build ≈ suma de las capas que se rehacen; si cambia una capa, se rehacen ella y todas las de abajo
En C01El gateway instala 37 paquetes (numpy, Pillow…). Si el código fuera antes que requirements.txt, cada commit los reinstalaría en CIcada commit = reinstalar todo
En el dojosin caché: 9 s, de los que pip install son 5.3 s; con caché y app.py cambiado solo se rehacen COPY app.py y useradd (0.2 s)9 s → menos de 1 s
Qué decides con esto. Copia el manifiesto de dependencias e instala antes de copiar el código: así un cambio de código reusa la capa cara.
Lo confirmas en el paso 4, cuando cambias app.py y pip install sale CACHED.
3
¿Cuánto espera el gateway, como máximo, a que el model server esté sano?
En C01Triton carga el engine en la GPU antes de estar listo: el healthcheck y la readiness tienen que tolerar ese arranque o el orquestador lo reinicia en cicloel start_period a la medida del arranque
En el dojoretries 10 × interval 5 s, sin start_period≈ 50 s de margen; real: sano en ≈ 5 s
Qué decides con esto. Si tu modelo tarda más en cargar que ese margen, compose marca el servicio unhealthy y el gateway no arranca. Con modelos grandes se sube start_period, no retries a lo loco.
Lo confirmas en el paso 6: docker compose ps muestra Up 5 seconds (healthy).
4
¿Qué viaja cuando subes la segunda imagen?
bytes del push = capas que el registry todavía no tiene
En C01Una release es gateway vN + Triton vN. Si la base de Triton no cambia, un modelo nuevo sube solo su capa con el enginesolo lo que cambió
En el dojoel model server comparte con el gateway la base python:3.13-slim: esas capas ya están en el registry4 capas «Mounted from iris-gateway», 5 nuevas
Qué decides con esto. Bases comunes entre servicios hacen pushes y pulls más baratos. Cambiar de base en un servicio rompe ese ahorro.
Lo confirmas en el paso 8, en la salida del segundo docker push.
5
¿Cuánto pesa el modelo frente a la imagen? ¿Va dentro o fuera?
peso del modelo ≈ parámetros × bytes por parámetro; compáralo con lo que ya pesa la imagen y con cada cuánto cambia
En C01Un clasificador de imágenes del tamaño de ResNet-50: 25.6 M parámetros × 2 bytes (FP16) ≈ 51 MB, contra ≈ 10 GB de Triton. Un LLM de 7 B en FP16: 7 B × 2 bytes = 14 GBdecenas de MB: dentro · GB: fuera
En el dojomodel.onnx de 1.3 kB contra 103.8 MB comprimida de la imagen del model server1.6 kB de diferencia (≈ 0.002 %)
Qué decides con esto. Si el modelo es chico frente a la imagen y cambia al ritmo del código, va dentro: una sola versión y rollback de un tag. Si pesa GB o cambia más seguido que el código, va fuera: se monta o se baja de S3 al arrancar.
Lo confirmas en el paso 10: docker images iris-model-server y python3 verificar.py volumen.
6
¿Te cabe la imagen en el disco?
espacio libre > pico del pull (capas comprimidas + descomprimidas a la vez)
En C01Triton: ≈ 10 GB comprimida y ≈ 30 GB de pico. En tu lab de Iris llenó la Mac y tumbó Docker Desktoprevisa antes de bajar
En el dojobase + gateway + model server ≈ 0.7 GB en discode sobra con 20 GB libres
Qué decides con esto. Antes de bajar una imagen grande: df -h / y docker system df. Si no cabe, se limpia antes (docker image prune) o se usa otra máquina.
terminal · compruébalo en el kit
df -h /
docker system df
Tus números van a ser otros: es tu disco.
Salida real
Filesystem Size Used Avail Capacity iused ifree %iused Mounted on
/dev/disk3s1s1 228Gi 12Gi 20Gi 38% 459k 206M 0% /
TYPE TOTAL ACTIVE SIZE RECLAIMABLE
Images 14 7 11.31GB 6.923GB (61%)
Containers 9 5 175.2MB 175.2MB (99%)
Local Volumes 11 2 1.224GB 994.5MB (81%)
Build Cache 93 0 3.034GB 1.871GB
El kit
el mundo
El kit trae el servicio de Iris funcionando sin Docker: el código del gateway y del model server, el modelo exportado y una prueba de humo. Tú escribes todo lo que lo empaqueta. No hace falta entorno virtual: verificar.py y romper.py solo usan Python y el CLI de Docker.
unzip c01-empaquetado.zip && cd c01-empaquetado
docker pull python:3.13-slim
python3 verificar.py mundo
Lo que trae no lo escribes
El servicio de Iris (gateway/app.py, model_server/server.py). El de tu laboratorio de Docker, tal cual: el gateway valida y etiqueta; el model server carga model.onnx con onnxruntime y habla el protocolo v2 de Triton.
El modelo (model_repository/iris/1/model.onnx). Un MLP de 1.3 KB con la normalización dentro. training/train.py es cómo se hizo; no hace falta correrlo.
config.env y scripts/smoke.sh. La configuración del gateway y la prueba de punta a punta (model server directo y luego a través del gateway).
verificar.py y romper.py. 11 checks con los criterios de «listo» y 6 fallas en el mundo, la configuración o la infraestructura, nunca en lo que escribes.
Lo que escribes lo escribes tú
gateway/requirements.in y model_server/requirements.inLo que importa cada servicio; de ahí sale su requirements.txt fijado.
gateway/.dockerignore y model_server/Dockerfile.dockerignoreQué no viaja al build context de cada imagen.
gateway/DockerfileLa imagen del gateway: caché ordenada, usuario sin privilegios, 0.0.0.0 y forma exec.
model_server/DockerfileLa imagen del model server con el modelo dentro, construida desde la raíz del kit.
compose.yamlLos dos servicios en una red, con healthcheck, depends_on y config.env.
servidor/compose.yamlEl «servidor»: solo baja las dos imágenes del registry y las corre, sin código ni Dockerfiles.
model_server/Dockerfile.volumenEl model server sin el modelo dentro: el modelo llega por un volumen de solo lectura.
Necesitas
Docker Desktop corriendo, con ≈ 1 GB libre para la base y las dos imágenes
Python 3.11 o más nuevo (solo para verificar.py y romper.py)
uv para fijar dependencias (brew install uv)
Los puertos 8080, 9000 y 5050 libres en tu Mac
En la nube
En el dojo
Qué cambia
Amazon ECR
registry:2 en localhost:5050
La misma API de registry. En ECR se autentica con aws ecr get-login-password, los tags pueden ser inmutables por configuración y se escanean las imágenes; aquí no hay autenticación ni escaneo.
Triton Inference Server
model server propio (FastAPI + onnxruntime)
Habla el mismo protocolo v2, así que el gateway no cambia. Triton trae batching, métricas y GPU; la imagen de Triton no cabía en la Mac del lab.
Kubernetes con nodos amd64
docker compose en una Mac arm64
compose es para desarrollo: un host, sin réplicas. Y tu Mac construye arm64: para un nodo amd64 hay que construir con --platform linux/amd64.
El CLAUDE.md del kitLas reglas de fondo para Claude Code en la carpeta del kit. Ya viene en el .zip.Ver el CLAUDE.md ↓
# Dojo de entrenamiento ML engineer's · C01 · Estación 7: Empaquetado (Docker)
Este es un mini-proyecto de práctica. Reproduce en pequeño, en una Mac sin GPU ni nube, la estación «Empaquetado (Docker)» del caso C01: convertir el código de serving y el modelo en imágenes que corren igual en cualquier lado. El servicio es el clasificador de Iris (gateway + model server). La persona con la que trabajas está aprendiendo; tu trabajo es que **entienda**, no que termine rápido.
## Qué es cada cosa
| Carpeta o archivo | Qué es | ¿Se toca? |
|---|---|---|
| `gateway/app.py`, `model_server/server.py`, `model_repository/`, `training/`, `scripts/smoke.sh` | El servicio de Iris: el código de serving y el modelo. | **No.** Es el mundo. |
| `config.env` | La configuración del gateway (llega como variables de entorno). | Solo cuando un arreglo lo pide. |
| `gateway/Dockerfile`, `gateway/.dockerignore`, `gateway/requirements.*` | La imagen del gateway. | **Sí.** Es lo que la persona escribe. |
| `model_server/Dockerfile`, `model_server/Dockerfile.dockerignore`, `model_server/requirements.*` | La imagen del model server (el contexto es la raíz del kit). | **Sí.** |
| `compose.yaml` | Los dos servicios, su red, el healthcheck y la configuración. | **Sí.** |
| `servidor/compose.yaml`, `servidor/config.env` | El «servidor» del paso 9: solo baja las imágenes del registry y las corre. | **Sí.** |
| `model_server/Dockerfile.volumen` | El model server con el modelo fuera de la imagen (paso 10). | **Sí.** |
| `verificar.py` | Revisa los criterios de «listo», uno por check. | No. |
| `romper.py` | Mete fallas en el mundo, la configuración o la infraestructura. Nunca en lo que escribe la persona. | No. Y no leas su código para adivinar una falla escondida. |
| `mundo/` | El reinicio, el registro de modelos del mundo y el esqueleto. | No. |
| `GUION.md` / `OBJETIVOS.md` | El paso a paso (ronda Guiado) y solo los objetivos (ronda Solo). | No. |
| `taller/progreso.json`, `BITACORA.md` | El registro de las rondas. | Sí, tú los mantienes (ver abajo). |
## Cómo se trabaja
- Responde en español neutro, de tú, directo y sin relleno. Términos técnicos en inglés la primera vez, con su explicación.
- Explica siempre el **por qué**. Cuando algo en el dojo es distinto de C01 real (escala, servicio local en vez de AWS), dilo en una línea.
- Nunca inventes salidas: si no corriste algo, di que no lo corriste.
- Antes de correr algo que cambia el mundo o la máquina (`docker compose`, `docker push`, `docker pull`, `docker rm`, `romper.py`, `mundo/*.py`), dilo. Lo que solo lee (`verificar.py`, `docker ps`, `docker images`, `docker logs`) se puede correr directo.
- Sigue el prompt de la ronda que te pegaron: en Solo y en Romper solo **no das código ni pistas** salvo que la persona se rinda.
## Bitácora
Al terminar cada paso, objetivo o falla, agrega una entrada en los dos archivos:
`BITACORA.md`, legible en el celular:
```markdown
## 2026-11-03 · Guiado · paso 1 (fijar dependencias)
- **Hice:** …
- **Medí:** … (números reales que salieron)
- **Sigue:** …
```
`taller/progreso.json`, el que se importa en el tablero de la app. Agrega a `bitacora` y actualiza `rondas`:
```json
{
"estacion": "c01-empaquetado",
"rondas": {
"guiado": { "estado": "en_curso", "inicio": "2026-11-03", "fin": null },
"solo": { "estado": "pendiente", "inicio": null, "fin": null, "pistas": 0 },
"romper_ayuda": { "estado": "pendiente", "fallas": [] },
"romper_solo": { "estado": "pendiente", "fallas": [] }
},
"bitacora": [
{ "fecha": "2026-11-03", "ronda": "guiado", "paso": "deps", "hice": "…", "medi": "…", "sigue": "…" }
]
}
```
- `estado` es `pendiente`, `en_curso` o `hecha`.
- Cada falla se anota como `{ "id": "…", "minutos": 0, "pistas": 0, "rendido": false, "aprendi": "…" }`.
- Usa la fecha real de hoy. Mantén el JSON válido: después de editarlo, corre `python -m json.tool taller/progreso.json > /dev/null`.
Las cuatro rondas
los prompts
Cada ronda se trabaja en una sesión de Claude Code abierta en la carpeta del kit. Copia el prompt de la ronda y pégalo; el CLAUDE.md del kit pone las reglas de fondo.
ConstruirRomperCon ayuda
1 · GuiadoConstruyen juntos los pasos 0 a 10.Ver el prompt ↓
Estoy en el Dojo de entrenamiento ML engineer's, en la estación 7 de C01, «Empaquetado (Docker)», ronda **Guiado**.
Lee primero `CLAUDE.md` y `GUION.md` de esta carpeta. Vamos a escribir juntos, siguiendo los pasos 0 a 10 de `GUION.md`, todo lo que convierte el servicio de Iris en imágenes: dependencias fijadas, `.dockerignore`, los dos Dockerfiles, `compose.yaml`, el push al registry local, el pull desde un «servidor» sin código y la comparación del modelo dentro y fuera de la imagen.
Entre el paso 0 y el 1, `GUION.md` trae los «Precálculos»: las cuentas de servilleta de la estación. Pídeme que haga cada cuenta yo primero, corrígeme y después corre el comando que la comprueba (o dime en qué paso se va a confirmar).
En cada paso:
1. Dime el objetivo en una línea y explícame los conceptos que usa, rápido y con el término en inglés.
2. Escribe conmigo el archivo del paso, línea por línea y con el porqué de cada una. No me pegues el archivo entero de golpe.
3. Dime qué comando correr y espera a que te pegue la salida. Los que cambian algo (`docker build`, `docker compose`, `docker push`, `docker rm`) no los corras tú sin decírmelo antes.
4. Corre `python3 verificar.py <check del paso>` y explícame el resultado.
5. Anota el paso en la bitácora (sección «Bitácora» de `CLAUDE.md`) con lo que hice, lo que medí y lo que sigue.
Si algo falla, no lo arregles en silencio: muéstrame el error, dime tu hipótesis y arreglémoslo juntos. Al final, corre `python3 verificar.py` completo y cierra la ronda en `taller/progreso.json`.
3 · Romper con ayudaUna falla real; Claude te guía con preguntas.Ver el prompt ↓
Estoy en el Dojo de entrenamiento ML engineer's, en la estación 7 de C01, «Empaquetado (Docker)», ronda **Romper con ayuda**.
Lee `CLAUDE.md`. Todo pasa `python3 verificar.py`. Ahora:
1. Corre `python3 romper.py --mostrar` (o `--mostrar --falla <id>` si te digo cuál). El script mete la falla en el mundo, la configuración o la infraestructura, nunca en mis Dockerfiles ni en mi compose, y te dice cuál fue. **A mí no me la digas.** Solo cuéntame la «historia»: qué notó alguien en el equipo.
2. Corre `python3 verificar.py` y pregúntame qué veo.
3. Guíame con preguntas en este orden: síntoma → hipótesis (pídeme al menos dos) → el comando que confirma o descarta cada una (`docker ps`, `docker compose ps`, `docker compose logs`, `curl`…) → el arreglo. Si me atoro más de dos veces en el mismo punto, dame una pista concreta y anótala.
4. Cuando lo arregle, corre `python3 romper.py --verificar`. Ahí sí explícame qué era, el concepto detrás y cómo se vería en C01 de verdad (ECR, Kubernetes, CI).
5. Anota en la bitácora la falla, cuánto tardé, cuántas pistas usé y qué aprendí.
Repetimos con otra falla hasta que te diga que paremos. Para volver al estado sano entre fallas: `python3 romper.py --restaurar`.
Solo
2 · SoloRehaces todo desde el esqueleto, solo con los objetivos.Ver el prompt ↓
Estoy en el Dojo de entrenamiento ML engineer's, en la estación 7 de C01, «Empaquetado (Docker)», ronda **Solo**.
Lee `CLAUDE.md`. **No leas `GUION.md`** y no me des código: esta ronda la escribo yo desde el esqueleto, con `OBJETIVOS.md` y los contratos de cada archivo como única guía.
Tu papel:
- Primero corre `python3 mundo/reiniciar.py`: respalda mis archivos, pone el esqueleto, baja las dos pilas (la del kit y la del servidor) y borra las imágenes iris-*, el registry e iris-volumen. Confírmame qué se borró.
- Cuando te lo pida, corre `python3 verificar.py` (o un check suelto) y dime qué objetivo pasa y cuál no, con el mensaje exacto.
- Si te pregunto algo, contéstame con una pregunta que me acerque o con el concepto, nunca con el código ni con el arreglo. Si te digo «me rindo en este objetivo», ahí sí explícamelo, y anótalo en la bitácora como pista usada.
- Anota en la bitácora cada objetivo cuando pase: cuánto tardé y qué me costó.
Al final, con `python3 verificar.py` en verde, cierra la ronda en `taller/progreso.json`.
4 · Romper soloUna falla escondida; nadie te da pistas.Ver el prompt ↓
Estoy en el Dojo de entrenamiento ML engineer's, en la estación 7 de C01, «Empaquetado (Docker)», ronda **Romper solo**.
Lee `CLAUDE.md`. Todo pasa `python3 verificar.py`.
1. Corre `python3 romper.py` **sin** `--mostrar`. El script elige una falla al azar y la esconde; tú tampoco sabes cuál es, así que no intentes averiguarla leyendo `romper.py`, `.taller/` ni lo que cambió.
2. Dime solo la «historia» que imprime y la hora de inicio. Desde ahí trabajo yo solo: no me des pistas ni hipótesis. Si te pido correr un comando, córrelo y pégame la salida tal cual, sin interpretarla.
3. Cuando te diga «listo», corre `python3 romper.py --verificar`. Dime si quedó arreglado, cuál era la falla y cuánto tardé. Si no quedó, dime solo que no y sigo.
4. Si te digo «me rindo», corre `python3 romper.py --revelar` y explícame la falla completa.
5. Anota en la bitácora la falla, el tiempo, si me rendí y qué haría distinto.
Guiado
paso a paso
El código que ves es el de la solución de referencia, y cada salida es la que salió al correr el kit en una carpeta limpia.
0Levantar el mundoDocker, uv y el modelo en su lugar.
Aquí no hay datos que generar: el mundo es el servicio de Iris y Docker Desktop. Baja una vez la imagen base python:3.13-slim (las dos imágenes la usan) y revisa que todo esté.
✓ mundo Docker 29.2.0, uv y el modelo en su lugar
1/1 en verde
Listo cuando: verificar.py mundo en verde.
Ojo: Si un puerto está ocupado (8080, 9000 o 5050), lsof -nP -iTCP:8080 -sTCP:LISTEN te dice quién. El 5000 de macOS lo usa AirPlay: por eso el registry va en el 5050.
1Fijar las dependenciasUn requirements.txt fijado por servicio: cada imagen lleva solo lo que usa.
En requirements.in va lo que importa cada servicio, sin versiones. uv pip compile resuelve el árbol completo y escribe el .txt con cada versión exacta y un comentario # via que dice quién pidió cada paquete. --universal hace que el resultado sirva en tu Mac y en Linux (el contenedor). El gateway no carga el modelo: no necesita onnxruntime ni numpy.
gateway/requirements.in
fastapi
uvicorn[standard]
httpx
model_server/requirements.in
fastapi
uvicorn[standard]
onnxruntime
numpy
terminal
cd gateway && uv pip compile requirements.in -o requirements.txt --universal --python-version 3.13 && cd ..
cd model_server && uv pip compile requirements.in -o requirements.txt --universal --python-version 3.13 && cd ..
Las versiones dependen del día en que lo corras; aquí abajo están las que salieron.
El contexto del gateway es gateway/; el del model server es la raíz del kit (porque necesita model_repository/, que está fuera de su carpeta). Con BuildKit, model_server/Dockerfile.dockerignore aplica solo a ese Dockerfile. Para la raíz conviene la lista al revés: ignorar todo con * y dejar pasar con ! solo lo que el Dockerfile copia. Tu lab de Iris listaba exclusiones; aquí la raíz tiene más cosas (verificar.py, mundo/…) y la lista blanca no se queda atrás cuando agregas archivos.
gateway/.dockerignore
.venv
__pycache__
*.pyc
model_server/Dockerfile.dockerignore
# El contexto es la raíz del kit: ignora todo y deja pasar solo lo que el Dockerfile copia.
*
!model_server/requirements.txt
!model_server/server.py
!model_repository/
terminal
python3 verificar.py contexto
Antes de escribirlos: el del model server se lleva 24 archivos.
Salida real
✓ contexto gateway 5 archivos, 5.9 kB · model server 31 archivos, 58.1 kB
1/1 en verde
terminal
python3 verificar.py contexto
Después: 3 archivos, justo lo que copia el Dockerfile.
Salida real
✓ contexto gateway 5 archivos, 5.9 kB · model server 3 archivos, 7.7 kB
1/1 en verde
Listo cuando: Los dos contextos debajo de 1 MB.
Ojo: Guarda el archivo con contenido y con salto de línea al final. Un .dockerignore vacío no avisa nada, y uno sin salto final hace que echo patrón >> se pegue a la última línea.
3El Dockerfile del gatewayLa imagen iris-gateway:0.1, sin root y con la caché bien ordenada.
Línea por línea: FROM la base slim; PYTHONUNBUFFERED=1 para que los logs salgan al instante; primerorequirements.txt y el pip install, despuésapp.py (caché); useradd + USER para no correr como root; EXPOSE solo documenta; CMD en forma exec y con --host 0.0.0.0. El . final del build es el contexto.
gateway/Dockerfile
FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
RUN useradd --create-home appuser
USER appuser
EXPOSE 8080
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8080"]
DISK USAGE es lo que ocupa en tu disco; CONTENT SIZE, lo comprimido: lo que viaja en push y pull.
Salida real
WARNING: This output is designed for human readability. For machine-readable output, please use --format.
IMAGE ID DISK USAGE CONTENT SIZE EXTRA
iris-gateway:0.1 4346a0abd333 275MB 59.7MB
terminal
docker history iris-gateway:0.1 --format '{{.Size}}\t{{.CreatedBy}}'
Salida real
0B CMD ["uvicorn" "app:app" "--host" "0.0.0.0" …
0B EXPOSE [8080/tcp]
0B USER appuser
69.6kB RUN /bin/sh -c useradd --create-home appuser…
16.4kB COPY app.py . # buildkit
56.4MB RUN /bin/sh -c pip install --no-cache-dir -r…
12.3kB COPY requirements.txt . # buildkit
8.19kB WORKDIR /app
0B ENV PYTHONUNBUFFERED=1
0B CMD ["python3"]
16.4kB RUN /bin/sh -c set -eux; for src in idle3 p…
43.8MB RUN /bin/sh -c set -eux; savedAptMark="$(a…
0B ENV PYTHON_SHA256=abb0102e6d4ae027509bcb8232…
0B ENV PYTHON_VERSION=3.13.18
0B ENV GPG_KEY=7169605F62C751356D054A26A821E680…
4.97MB RUN /bin/sh -c set -eux; apt-get update; a…
0B ENV LANG=C.UTF-8
0B ENV PATH=/usr/local/bin:/usr/local/sbin:/usr…
110MB # debian.sh --arch 'arm64' out/ 'trixie' '@1…
terminal
python3 verificar.py gateway
Salida real
✓ gateway iris-gateway:0.1: uid 1000, CMD en forma exec en 0.0.0.0, caché bien ordenada · 60 MB comprimida
1/1 en verde
Listo cuando: uid distinto de 0, CMD en forma exec en 0.0.0.0 y requirements antes que app.py.
Ojo: El aviso «Running pip as the 'root' user» en el build es normal: los paquetes quedan de root y appuser solo los puede leer, que es lo que quieres.
4La caché de capasVer qué se rehace cuando cambias el código.
Construye otra vez sin cambiar nada: todo sale CACHED. Luego agrega un comentario a app.py y construye :0.2. El pip install sale CACHED: solo se rehacen COPY app.py y useradd, porque cuando una capa cambia, todas las de abajo se invalidan aunque su instrucción sea igual.
El modelo va dentro de la imagen (COPY model_repository/ /models/): el tag de la imagen es la versión del modelo. Por eso el contexto es la raíz y el Dockerfile se indica con -f. El usuario es de sistema, con UID numérico (USER 10001:10001), sin carpeta personal ni shell. Orden de capas: usuario → dependencias → modelo → código.
Recortada: el contexto pesa 8 kB gracias a la lista blanca.
Salida real
#3 transferring context: 3.02kB done
#11 naming to docker.io/library/iris-model-server:0.1 done
terminal
docker run --rm iris-model-server:0.1 id
Salida real
uid=10001(app) gid=10001(app) groups=10001(app)
terminal
docker images iris-model-server
≈ 180 MB más que el gateway: onnxruntime y numpy.
Salida real
WARNING: This output is designed for human readability. For machine-readable output, please use --format.
IMAGE ID DISK USAGE CONTENT SIZE EXTRA
iris-model-server:0.1 5b0b7201be89 453MB 104MB U
Listo cuando: El modelo bueno dentro de /models, UID 10001 y PID 1 = uvicorn.
Ojo: La otra opción es el modelo fuera: una imagen sin COPY model_repository y -v "$PWD/model_repository:/models:ro" al correr. Sin el volumen el contenedor muere al arrancar (fail fast). Para modelos de GB que cambian seguido, fuera; para modelos chicos y reproducibles, dentro.
6Los dos servicios con composeLa pila arriba, sana y conectada por nombre.
compose crea una red donde el gateway llega al model server por su nombre (model-server:8000): dentro de un contenedor, localhost es el propio contenedor. La URL llega en config.env. El healthcheck usa el Python de la imagen porque slim no trae curl, y depends_on … service_healthy hace que el gateway espere. El model server se publica en el 9000 de tu Mac solo para que smoke.sh lo pruebe directo; en producción quedaría privado.
compose.yaml
# Los dos servicios de Iris en una red: el model server (con el modelo dentro de la imagen) y el gateway.
name: iris
services:
model-server:
build:
context: .
dockerfile: model_server/Dockerfile
image: iris-model-server:0.1
ports:
- "9000:8000"
healthcheck:
# slim no trae curl: el healthcheck usa el Python de la imagen
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/v2/health/ready')"]
interval: 5s
timeout: 3s
retries: 10
gateway:
build: ./gateway
image: iris-gateway:0.1
ports:
- "8080:8080"
env_file: config.env
depends_on:
model-server:
condition: service_healthy
terminal
docker compose up -d
Fíjate en el orden: el gateway espera a que el model server esté Healthy.
SERVICE STATUS PORTS
gateway Up Less than a second 0.0.0.0:8080->8080/tcp, [::]:8080->8080/tcp
model-server Up 5 seconds (healthy) 0.0.0.0:9000->8000/tcp, [::]:9000->8000/tcp
terminal
./scripts/smoke.sh
Salida real
== Model server: ¿modelo listo?
HTTP 200
== Model server: metadata (qué espera el modelo)
{"name":"iris","versions":["1"],"platform":"onnxruntime_onnx","inputs":[{"name":"input","datatype":"FP32","shape":[-1,4]}],"outputs":[{"name":"probs","datatype":"FP32","shape":[-1,3]}]}
== Model server: inferencia cruda (protocolo v2)
{"model_name":"iris","model_version":"1","outputs":[{"name":"probs","datatype":"FP32","shape":[1,3],"data":[0.9999985694885254,1.3998552503835526e-06,3.174513129038277e-14]}]}
== Gateway: readiness
[código 56]
terminal
python3 verificar.py red salud
Salida real
✓ red gateway → model-server por nombre: /readyz 200 y las 3 flores bien (setosa, versicolor, virginica)
✓ salud model server healthy y el gateway espera a service_healthy
2/2 en verde
Listo cuando: /readyz 200 y las tres flores bien: setosa, versicolor y virginica.
Ojo: Si cambias config.env, docker compose up -d recrea el gateway con las variables nuevas; un restart no las vuelve a leer.
7Apagar limpioVer que el proceso recibe SIGTERM y cierra en menos de un segundo.
docker compose stop manda SIGTERM al PID 1 y espera 10 s antes de matarlo. Con la forma exec, uvicorn recibe la señal, termina lo que está atendiendo y corre el código después del yield del lifespan (cerrar el cliente HTTP).
terminal
/usr/bin/time -p docker compose stop gateway
0.47 s. En forma shell tardaría los 10 s completos: docker stop espera y luego mata.
Salida real
Container iris-gateway-1 Stopping
Container iris-gateway-1 Stopped
real 0.63
user 0.05
sys 0.03
Listo cuando: El stop tarda menos de un segundo y el log dice «Application shutdown complete».
Ojo: Kubernetes hace lo mismo con terminationGracePeriodSeconds (30 s por defecto). Un proceso que ignora SIGTERM corta requests a media respuesta en cada despliegue.
8Subir al registryLas dos imágenes en un registry local, identificadas por su digest.
El nombre completo de una imagen es registry/repositorio:tag. Levanta un registry local, etiqueta tus imágenes con su dirección y súbelas. Lo que viaja son las capas comprimidas, y las que el registry ya tiene no se vuelven a subir. Cada push devuelve un digest: la huella del contenido.
docker tag iris-model-server:0.1 localhost:5050/iris-model-server:0.1
docker push localhost:5050/iris-model-server:0.1
«Mounted from iris-gateway»: la base ya estaba en el registry y no se vuelve a subir.
Salida real
The push refers to repository [localhost:5050/iris-model-server]
0f2a3670451b: Mounted from iris-gateway
b56186c8f371: Pushed
b09e3727000c: Mounted from iris-gateway
0b27dd84b815: Pushed
bd40a97bb8c0: Pushed
a0b281459f53: Pushed
3a038b6d1fa1: Mounted from iris-gateway
7b05e33e11f3: Pushed
2b70e040f7d1: Mounted from iris-gateway
3ebe763086bb: Mounted from iris-gateway
9fa7e7837a4d: Pushed
0.1: digest: sha256:5b0b7201be89ac5e753142fa73617b4a0daa7502fb1c0e0cdca338eaaf52ec28 size: 856
terminal
python3 verificar.py registry
Salida real
✓ registry en el registry y con tu digest: iris-gateway sha256:98785c7202ad… · iris-model-server sha256:5b0b7201be89…
1/1 en verde
Listo cuando: python3 verificar.py registry en verde: el registry tiene tus dos imágenes con tu digest.
Ojo: Tus digests van a ser otros. En C01 el registry es ECR, con autenticación y tags inmutables. Y al registry local ponle --restart unless-stopped si lo vas a dejar para siempre.
9Bajar del registryUn «servidor» que no tiene ni código ni Dockerfiles baja las dos imágenes del registry y las corre.
Subir es la mitad. La otra mitad es lo que hace cada máquina que corre tu servicio: pull. El servidor no construye nada; solo necesita un compose que apunte al registry y su configuración. Lo simulas en la carpeta servidor/, que solo lleva compose.yaml y una copia de config.env. Primero borra tus etiquetas localhost:5050/… y bájalas: el digest que llega tiene que ser el mismo que te dio el push. En el servidor solo el gateway se publica (en 8081, porque tu pila del paso 6 sigue en 8080); al model server solo le habla el gateway, por la red de compose.
servidor/compose.yaml
# El servidor: baja las imágenes del registry y las corre. No trae código ni Dockerfiles, solo esto y config.env.
name: iris-servidor
services:
model-server:
image: localhost:5050/iris-model-server:0.1
# sin ports: al model server solo le habla el gateway, por la red de compose
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/v2/health/ready')"]
interval: 5s
timeout: 3s
retries: 10
gateway:
image: localhost:5050/iris-gateway:0.1
ports:
- "8081:8080"
env_file: config.env
depends_on:
model-server:
condition: service_healthy
Las imágenes vienen del registry, y el model server no tiene puerto publicado: solo 8000/tcp dentro de la red.
Salida real
SERVICE IMAGE STATUS PORTS
gateway localhost:5050/iris-gateway:0.1 Up Less than a second 0.0.0.0:8081->8080/tcp, [::]:8081->8080/tcp
model-server localhost:5050/iris-model-server:0.1 Up 7 seconds (healthy) 8000/tcp
✓ pull el servidor corre lo que bajó del registry (iris-model-server sha256:5b0b7201be89… · iris-gateway sha256:98785c7202ad…) y predice bien en :8081
1/1 en verde
Listo cuando: python3 verificar.py pull: el servidor corre exactamente lo que bajó del registry (mismo digest) y predice bien en :8081.
Ojo: El tag 0.1 se puede mover: si alguien sube otra imagen con el mismo tag, el siguiente pull baja otra cosa. Para fijarlo de verdad se despliega por digest (image: localhost:5050/iris-gateway@sha256:…). En C01 los tags de ECR son inmutables y Kubernetes hace este mismo pull en cada nodo.
10Pesos dentro o fuera: la comparaciónLa misma imagen del model server sin el modelo, corriendo con el modelo montado por un volumen, y los números de las dos opciones.
En el paso 5 metiste el modelo en la imagen. Ahora construye la otra opción: el mismo Dockerfile sin COPY model_repository. Como ya no copia nada de fuera de model_server/, el contexto es solo esa carpeta. Luego compara: cuánto pesan las dos imágenes, qué pasa si arrancas sin modelo y cómo llega el modelo por un volumen de solo lectura (:ro). Dentro, el tag de la imagen es la versión del modelo y el rollback es volver al tag anterior; un modelo nuevo es rebuild y push de su capa. Fuera, un modelo nuevo es cambiar el archivo y reiniciar, sin rebuild, pero la versión del modelo la llevas aparte de la imagen, y si falta, el contenedor muere al arrancar.
model_server/Dockerfile.volumen
FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1 \
MODEL_REPOSITORY=/models
WORKDIR /app
RUN groupadd --system --gid 10001 app && \
useradd --system --uid 10001 --gid app --no-create-home --shell /usr/sbin/nologin app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# sin COPY model_repository: el modelo llega al correr, con -v …/model_repository:/models:ro
COPY server.py .
USER 10001:10001
EXPOSE 8000
CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8000"]
Las dos pesan lo mismo a la vista: el modelo de Iris pesa 1.3 kB contra más de 100 MB de base y dependencias.
Salida real
WARNING: This output is designed for human readability. For machine-readable output, please use --format.
IMAGE ID DISK USAGE CONTENT SIZE EXTRA
iris-model-server:0.1 5b0b7201be89 453MB 104MB U
iris-model-server:0.1-sin-modelo 39b93fca3036 453MB 104MB
terminal
docker history iris-model-server:0.1 --format '{{.Size}}\t{{.CreatedBy}}' | grep COPY
docker history iris-model-server:0.1-sin-modelo --format '{{.Size}}\t{{.CreatedBy}}' | grep COPY
La única diferencia es la capa COPY model_repository/: 20.5 kB en disco por 1.3 kB de modelo (el sistema de archivos guarda en bloques).
rw=false: el contenedor lee el modelo pero no lo puede cambiar.
Salida real
/models (rw=false)
terminal
python3 verificar.py volumen
Salida real
✓ volumen comprimida: con el modelo 103.8 MB · sin modelo 103.8 MB (diferencia 1.6 kB); iris-volumen lee el modelo del volumen, solo lectura, y está listo en :9001
1/1 en verde
terminal
python3 verificar.py
Salida real
✓ mundo Docker 29.2.0, uv y el modelo en su lugar
✓ deps gateway 23 paquetes · model server 25, todos fijados
✓ contexto gateway 5 archivos, 5.8 kB · model server 3 archivos, 7.7 kB
✓ gateway iris-gateway:0.1: uid 1000, CMD en forma exec en 0.0.0.0, caché bien ordenada · 60 MB comprimida
✓ model-server iris-model-server:0.1: modelo bueno dentro (/models), uid 10001 · 104 MB comprimida
✓ exec iris-gateway PID 1 = uvicorn · iris-model-server PID 1 = uvicorn
✓ red gateway → model-server por nombre: /readyz 200 y las 3 flores bien (setosa, versicolor, virginica)
✓ salud model server healthy y el gateway espera a service_healthy
✓ registry en el registry y con tu digest: iris-gateway sha256:98785c7202ad… · iris-model-server sha256:5b0b7201be89…
✓ pull el servidor corre lo que bajó del registry (iris-model-server sha256:5b0b7201be89… · iris-gateway sha256:98785c7202ad…) y predice bien en :8081
✓ volumen comprimida: con el modelo 103.8 MB · sin modelo 103.8 MB (diferencia 1.6 kB); iris-volumen lee el modelo del volumen, solo lectura, y está listo en :9001
11/11 en verde
Listo cuando: python3 verificar.py completo en verde: 11 de 11.
Ojo: Con Iris la diferencia es de 1.6 kB, así que aquí dentro gana sin discusión. La cuenta cambia cuando el modelo pesa GB: cada versión nueva es una imagen de GB que viaja a cada nodo. Ahí se saca el modelo de la imagen y se baja de S3 al arrancar, o se monta. En Kubernetes el volumen no es una carpeta de tu Mac: es un PersistentVolume o un init container que baja el modelo (estación 9).
Solo
11 objetivos
Antes de empezar, python3 mundo/reiniciar.py: respalda tus archivos en .respaldo/, pone el esqueleto, baja las dos pilas (la del kit y la del servidor) y borra las imágenes iris-*, el registry e iris-volumen. La base python:3.13-slim se queda.
Solo tienes OBJETIVOS.md, los contratos al inicio de cada archivo y python3 verificar.py. Nada de GUION.md.
Claude no te da código: te dice qué check falla y te contesta con preguntas.
Mide tu tiempo por objetivo y anota en la bitácora dónde te atoraste.
01Docker, uv y el modelo en su lugar.python3 verificar.py mundo
02Un requirements.txt fijado por servicio; el gateway sin onnxruntime ni numpy.python3 verificar.py depsPista: La lista corta va en el .in; el .txt sale de uv pip compile.
03Los dos build contexts debajo de 1 MB.python3 verificar.py contextoPista: El contexto del model server es la raíz: ¿qué copia de verdad su Dockerfile?
04La imagen del gateway sin root, con la caché ordenada y escuchando en 0.0.0.0.python3 verificar.py gateway
05La imagen del model server con el modelo bueno dentro y un UID numérico.python3 verificar.py model-server
06Las dos imágenes con uvicorn como PID 1.python3 verificar.py execPista: Corchetes en el CMD.
07El gateway llega al model server por nombre y responde bien las tres flores.python3 verificar.py red
08El model server healthy y el gateway que espera a que lo esté.python3 verificar.py saludPista: slim no trae curl.
09Las dos imágenes en el registry de localhost:5050 con tu digest.python3 verificar.py registry
10Un servidor (servidor/compose.yaml, sin build) que corre las dos imágenes bajadas del registry y predice en 8081, con el model server sin publicar.python3 verificar.py pullPista: El servidor no tiene el código: solo image:, el healthcheck y su copia de config.env.
11El model server sin el modelo dentro (iris-model-server:0.1-sin-modelo), corriendo como iris-volumen en 9001 con el modelo montado en solo lectura.python3 verificar.py volumenPista: Si ya no copia nada de fuera de model_server/, el contexto puede ser solo esa carpeta.
Romper
6 fallas
Con todo en verde, romper.py mete una falla en el mundo, la configuración o la infraestructura (nunca en lo que escribiste) y, si hace falta, deja corriendo lo que «pasó anoche». Tú empiezas por verificar.py.
romper.py
python3 romper.py --mostrar # con ayuda: Claude sabe cuál es
python3 romper.py # solo: escondida, ni Claude la sabe
python3 romper.py --verificar # ¿ya quedó?
python3 romper.py --revelar # me rindo
python3 romper.py --restaurar # deshacer
Catálogo de fallas (spoilers)El catálogo de abajo trae las respuestas. Ábrelo después de romper, para repasar.Abrir el catálogo
infra
La pila no levanta después del reinicio
Anoche reiniciaron la máquina compartida del equipo. Hoy en la mañana alguien dejó corriendo «un momento» la demo de pagos, y desde entonces Iris no responde.
Síntoma. verificar.py: red y salud fallan, no hay contenedores corriendo. docker compose up -d termina con un error.
Hipótesis, cómo confirmar y el arreglo ↓
Hipótesis para descartar
El gateway truena al arrancar (un error en su código o en su configuración).
Otro proceso tiene el puerto 8080 de tu Mac.
El model server no está sano y el gateway no arranca por depends_on.
Error response from daemon: failed to set up container networking: driver failed programming external connectivity on endpoint iris-gateway-1 (2eca062f6ff190d0fb410822cdc2094affefdd425c8f74cb1f4798633e38acae): Bind for 0.0.0.0:8080 failed: port is already allocated
demo-pagos 0.0.0.0:8080->5000/tcp, [::]:8080->5000/tcp
model-server Up 5 seconds (healthy) 0.0.0.0:9000->8000/tcp, [::]:9000->8000/tcp
El arreglo. Quita el contenedor ajeno (docker rm -f demo-pagos) o publica el gateway en otro puerto del host, y recrea el gateway: docker compose up -d --force-recreate gateway. Un up -d a secas solo arranca el contenedor que ya se creó sin su puerto (docker compose ps lo muestra como 8080/tcp, sin 0.0.0.0:8080->).
{"detail":"model server no responde en http://localhost:8000: ConnectError"}
MODEL_SERVER_URL=http://localhost:8000
El arreglo. Dentro del contenedor del gateway, localhost es el propio gateway. Regresa MODEL_SERVER_URL=http://model-server:8000 en config.env y corre docker compose up -d, que recrea el gateway con la variable nueva.
El arreglo. Agrega el patrón al .dockerignore del gateway (o saca el archivo de ahí). Ojo con el salto de línea: si el archivo no termina en uno, echo '*.bin' >> lo pega a la última línea (*.pyc*.bin) y no ignora nada. tail -2 gateway/.dockerignore lo delata.
El pipeline de entrenamiento publicó anoche «la versión nueva» del modelo en model_repository/ y el CI reconstruyó la imagen del model server. Hoy la pila no está sana.
Síntoma. verificar.py: model-server, red y salud fallan. El model server sale con código 3 y el gateway no llega a él.
gateway Up 24 seconds
model-server Exited (3) 2 seconds ago
ERROR: Traceback (most recent call last):
onnxruntime.capi.onnxruntime_pybind11_state.InvalidProtobuf: [ONNXRuntimeError] : 7 : INVALID_PROTOBUF : Load model from /models/iris/1/model.onnx failed:Protobuf parsing failed.
ERROR: Application startup failed. Exiting.
El arreglo. El model.onnx que llegó está truncado: onnxruntime no lo puede abrir y el proceso muere al arrancar (fail fast). Recupera el modelo bueno del registro de modelos del mundo (cp mundo/modelos/iris-1.onnx model_repository/iris/1/model.onnx) y, como el modelo va dentro de la imagen, reconstruye: docker compose build model-server && docker compose up -d.
failed to do request: Head "https://localhost:5050/v2/iris-gateway/blobs/sha256:a1a8541a419e1fb416f4baec97f0fbfdbe6501a1ecd1cfbd0a6379ffe109bf2f": dial tcp [::1]:5050: i/o timeout
registry Exited (2) 33 seconds ago
El arreglo. El registry se creó sin política de reinicio y no volvió después del reinicio de Docker. docker start registry y, para que no se repita, docker update --restart unless-stopped registry. Las imágenes siguen ahí: el contenedor solo estaba detenido.
El arreglo. Alguien subió otra imagen (el model server) con localhost:5050/iris-gateway:0.1: los tags se pueden mover y quien haga pull de :0.1 recibe otra cosa. Vuelve a subir tu imagen (docker tag iris-gateway:0.1 localhost:5050/iris-gateway:0.1 && docker push localhost:5050/iris-gateway:0.1) y, de aquí en adelante, una versión nueva para cada cambio y desplegar por digest.
La imagen es la unidad de despliegue.Lo que probaste en tu Mac, en CI y en staging tiene que ser byte a byte lo que corre en producción: por eso las dependencias van fijadas, el orden de capas cuida la caché, la configuración llega por variables de entorno y en el registry manda el digest, no el tag.Trampa: Pensar que «si corre en mi Mac, corre en el clúster». Tu Mac construye arm64, publica puertos que en el clúster no existen y te deja ser root sin que nadie se queje. Revisa la plataforma, el usuario, el 0.0.0.0 y el healthcheck antes de subir.Listo cuando
Cada servicio tiene su requirements.txt fijado, generado desde su requirements.in.
Las dos imágenes corren sin root, con uvicorn como PID 1 escuchando en 0.0.0.0, y con build contexts chicos.
El model server lleva el modelo bueno dentro, y su tag es la versión del modelo.
compose levanta la pila sana: el gateway llega al model server por nombre y espera su healthcheck.
Las dos imágenes están en el registry, y sabes qué digest es cada una.
Un servidor sin código baja las imágenes del registry, con el mismo digest del push, y las corre.
Sabes cuándo el modelo va dentro de la imagen y cuándo fuera, con los números de las dos opciones.
Así te lo pueden preguntar en una entrevista
¿Por qué copias requirements.txt antes que el código en el Dockerfile?
Por la caché de capas: si cambia una capa, se rehacen ella y todas las de abajo. Las dependencias cambian poco y tardan en instalarse; el código cambia en cada commit. Con ese orden, un cambio de código reusa la capa de dependencias y el build baja de minutos a segundos.
¿El modelo va dentro de la imagen o fuera?
Depende del tamaño y de qué tan seguido cambia. Dentro: la imagen es reproducible, el tag es la versión del modelo y el rollback es cambiar de tag. Fuera (volumen, S3 al arrancar): imágenes chicas y cambiar el modelo sin reconstruir, a cambio de llevar la versión del modelo aparte. En C01, Triton lleva el engine horneado.
Tu contenedor corre pero no contesta en el puerto publicado. ¿Qué revisas?
Que el proceso escuche en 0.0.0.0 y no en 127.0.0.1, que el puerto publicado (-p host:contenedor) apunte al que escucha el proceso, que nadie más tenga ese puerto del host y, en compose, que el contenedor no se haya creado sin su puerto. docker ps, docker logs y un curl desde dentro y desde fuera lo separan rápido.
¿Por qué un tag no basta para saber qué corre en producción?
Porque un tag es una etiqueta que se puede mover: alguien puede subir otra imagen con el mismo nombre. El digest es el hash del manifiesto y cambia si cambia un solo byte. Por eso en C01 los tags de ECR son inmutables y los manifests de Kubernetes pueden fijar la imagen por digest.
Tu imagen corre en tu Mac, pero en el servidor el pull falla con «no matching manifest for linux/amd64». ¿Qué pasó?
La imagen se construyó solo para arm64 (la Mac) y el servidor es amd64. Se construye multiplataforma con docker buildx build --platform linux/amd64,linux/arm64 --push, o solo amd64 si es lo único que se despliega, y se revisa con docker buildx imagetools inspect antes de desplegar.