Dojo / C01 · 7
← Dojo de entrenamiento ML engineer's C01 · Estación 7 de 12

Empaquetado (Docker)

¿Cómo conviertes el código de serving y el modelo en imágenes que corren igual en tu Mac, en CI y en el clúster?

Estás aquí7 · Empaquetado (Docker)

Dónde estás

el mapa

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)
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 C01En el dojo
Imagen del model serverTriton 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 gatewaymulti-stage, 37 paquetes con wheels, UID 10001una etapa, 23 paquetes: 275 MB en disco, 59.7 MB comprimida
RegistryAmazon ECR: tag inmutable + digestregistry:2 en localhost:5050
Dónde correKubernetes con GPU (estación 9); compose solo en desarrollodocker compose en tu Mac, con healthcheck
Arquitecturanodos Linux amd64tu Mac construye Linux arm64: la misma imagen no corre tal cual en un nodo amd64

La estación completa, con su lugar en el mapa: cheat sheet «Del dato a producción».

Conceptos exprés

16

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
Ver la cuenta
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
Ver la cuenta
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?

espera máxima ≈ start_period + retries × interval
Ver la cuenta
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
Ver la cuenta
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
Ver la cuenta
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)
Ver la cuenta
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.

Bajar el kit .zip · 41 KB
arranque
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ú

  1. gateway/requirements.in y model_server/requirements.inLo que importa cada servicio; de ahí sale su requirements.txt fijado.
  2. gateway/.dockerignore y model_server/Dockerfile.dockerignoreQué no viaja al build context de cada imagen.
  3. gateway/DockerfileLa imagen del gateway: caché ordenada, usuario sin privilegios, 0.0.0.0 y forma exec.
  4. model_server/DockerfileLa imagen del model server con el modelo dentro, construida desde la raíz del kit.
  5. compose.yamlLos dos servicios en una red, con healthcheck, depends_on y config.env.
  6. servidor/compose.yamlEl «servidor»: solo baja las dos imágenes del registry y las corre, sin código ni Dockerfiles.
  7. 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 nubeEn el dojoQué cambia
Amazon ECRregistry:2 en localhost:5050La 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 Servermodel 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 amd64docker compose en una Mac arm64compose 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.

ConstruirRomper Con 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é.

terminal
docker version --format 'Docker {{.Server.Version}} · {{.Server.Os}}/{{.Server.Arch}}'
uv --version
python3 --version

linux/arm64: tu Mac construye imágenes de Linux para ARM. Tu versión de Python puede ser otra.

Salida real
Docker 29.2.0 · linux/arm64
uv 0.13.0 (Homebrew 2026-10-09 aarch64-apple-darwin)
Python 3.14.3
terminal
python3 verificar.py mundo
Salida real
✓ 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.

La salida depende de tu corrida.

terminal
grep -E '^(fastapi|uvicorn|httpx|onnxruntime|numpy)==' gateway/requirements.txt model_server/requirements.txt
Salida real
gateway/requirements.txt:fastapi==0.162.0
gateway/requirements.txt:httpx==0.28.1
gateway/requirements.txt:uvicorn==0.54.0
model_server/requirements.txt:fastapi==0.162.0
model_server/requirements.txt:numpy==2.5.4
model_server/requirements.txt:onnxruntime==1.31.1
model_server/requirements.txt:uvicorn==0.54.0
terminal
python3 verificar.py deps
Salida real
✓ deps          gateway 23 paquetes · model server 25, todos fijados

1/1 en verde
Listo cuando: Todos los paquetes con ==, y onnxruntime solo en el model server.
Ojo: Se versionan los dos archivos (.in y .txt) y el .txt no se edita a mano. Para subir un paquete: uv pip compile … --upgrade-package fastapi.
2Lo que no viaja: .dockerignoreUn build context chico para cada imagen.

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; primero requirements.txt y el pip install, después app.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"]
terminal
docker build --no-cache --progress=plain -t iris-gateway:0.1 gateway

Recortada a las líneas que importan: el contexto (5.6 kB) y el pip install (5.3 s).

Salida real
#3 transferring context: 63B done
#6 transferring context: 5.60kB done
#8 [4/6] RUN pip install --no-cache-dir -r requirements.txt
#8 DONE 5.8s
#11 naming to docker.io/library/iris-gateway:0.1 done
#11 DONE 2.6s
terminal
docker images iris-gateway

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.

terminal
docker build --progress=plain -t iris-gateway:0.1 gateway

Recortada a las líneas CACHED: menos de 1 s.

Salida real
#6 CACHED
#7 CACHED
#8 CACHED
#9 CACHED
#10 CACHED
#11 naming to docker.io/library/iris-gateway:0.1 done
terminal
cp gateway/app.py /tmp/app.py.respaldo && echo '# prueba de caché' >> gateway/app.py
docker build --progress=plain -t iris-gateway:0.2 gateway

Recortada a los pasos: el [4/6] pip install sale CACHED.

Salida real
#4 [1/6] FROM docker.io/library/python:3.13-slim@sha256:ae14d4ce8771606477a81446e7ef429edd6762305fc50c84507c1041fa8285d1
#6 [3/6] COPY requirements.txt .
#6 CACHED
#7 [4/6] RUN pip install --no-cache-dir -r requirements.txt
#7 CACHED
#8 [5/6] COPY app.py .
#8 CACHED
#9 [2/6] WORKDIR /app
#9 CACHED
#10 [6/6] RUN useradd --create-home appuser
#10 CACHED
#11 DONE 0.1s
terminal
cp /tmp/app.py.respaldo gateway/app.py && docker rmi iris-gateway:0.2
docker build -t iris-gateway:0.1 gateway

Deja todo como estaba.

La salida depende de tu corrida.

Listo cuando: Con app.py cambiado, el pip install sale CACHED.
Ojo: Mejora opcional: pon el useradd antes de instalar dependencias y así, con un cambio de código, solo se rehace COPY app.py.
5El Dockerfile del model serverLa imagen iris-model-server:0.1 con el modelo dentro.

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.

model_server/Dockerfile
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 model_server/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY model_repository/ /models/
COPY model_server/server.py .

USER 10001:10001

EXPOSE 8000
CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8000"]
terminal
docker build --progress=plain -f model_server/Dockerfile -t iris-model-server:0.1 .

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    
terminal
python3 verificar.py model-server exec
Salida real
✓ 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

2/2 en verde
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.

Salida real
 Container iris-model-server-1 Recreate 
 Container iris-model-server-1 Recreated 
 Container iris-gateway-1 Recreate 
 Container iris-gateway-1 Recreated 
 Container iris-model-server-1 Starting 
 Container iris-model-server-1 Started 
 Container iris-model-server-1 Waiting 
 Container iris-model-server-1 Healthy 
 Container iris-gateway-1 Starting 
 Container iris-gateway-1 Started 
terminal
docker compose ps --format 'table {{.Service}}\t{{.Status}}\t{{.Ports}}'
Salida real
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
terminal
docker compose logs gateway --no-log-prefix | tail -4
Salida real
INFO:     Shutting down
INFO:     Waiting for application shutdown.
INFO:     Application shutdown complete.
INFO:     Finished server process [1]
terminal
docker compose start gateway && sleep 2 && curl -s localhost:8080/readyz
Salida real
 Container iris-model-server-1 Waiting 
 Container iris-model-server-1 Healthy 
 Container iris-gateway-1 Starting 
 Container iris-gateway-1 Started 
{"status":"ready","model_server":"http://model-server:8000","model":"iris"}
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.

terminal
docker run -d -p 5050:5000 --name registry registry:2
curl -s localhost:5050/v2/_catalog
Salida real
{"repositories":[]}
terminal
docker tag iris-gateway:0.1 localhost:5050/iris-gateway:0.1
docker push localhost:5050/iris-gateway:0.1
Salida real
The push refers to repository [localhost:5050/iris-gateway]
31dd3a2401f8: Pushed
d56babdb2320: Pushed
d906cdc5648d: Pushed
0f2a3670451b: Pushed
38e7f57abdf5: Pushed
3a038b6d1fa1: Pushed
2591e67291ff: Pushed
3ebe763086bb: Pushed
b09e3727000c: Pushed
2b70e040f7d1: Pushed
0.1: digest: sha256:98785c7202ad6ebc57766e129b73f4b168cb5cf23bcaa5f304a6fa061011da07 size: 856
terminal
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
terminal
docker rmi localhost:5050/iris-gateway:0.1 localhost:5050/iris-model-server:0.1

Solo quitas las etiquetas del registry: tus imágenes iris-*:0.1 siguen ahí, porque la pila del paso 6 las usa.

Salida real
Untagged: localhost:5050/iris-gateway:0.1
Untagged: localhost:5050/iris-model-server:0.1
terminal
docker pull localhost:5050/iris-gateway:0.1

El digest es el mismo que te dio el push del paso 8. No aparecen capas porque tu Mac ya las tiene: en una máquina limpia verías cada capa bajando.

Salida real
0.1: Pulling from iris-gateway
Digest: sha256:98785c7202ad6ebc57766e129b73f4b168cb5cf23bcaa5f304a6fa061011da07
Status: Downloaded newer image for localhost:5050/iris-gateway:0.1
localhost:5050/iris-gateway:0.1
terminal
docker pull localhost:5050/iris-model-server:0.1
Salida real
0.1: Pulling from iris-model-server
Digest: sha256:5b0b7201be89ac5e753142fa73617b4a0daa7502fb1c0e0cdca338eaaf52ec28
Status: Downloaded newer image for localhost:5050/iris-model-server:0.1
localhost:5050/iris-model-server:0.1
terminal
docker pull --platform linux/amd64 localhost:5050/iris-gateway:0.1

Lo que le pasaría a un servidor x86 (amd64): tu Mac construyó solo arm64 y en el registry no hay otra plataforma. Lo arreglas en la estación 7.1.

Salida real
Error response from daemon: no matching manifest for linux/amd64 in the manifest list entries: no match for platform in manifest: not found
terminal
mkdir -p servidor
cp config.env servidor/
# escribe servidor/compose.yaml
ls servidor
Salida real
compose.yaml
config.env
terminal
cd servidor
docker compose up -d

El gateway espera a que el model server esté Healthy, igual que en el paso 6.

Salida real
 Network iris-servidor_default Creating 
 Network iris-servidor_default Created 
 Container iris-servidor-model-server-1 Creating 
 Container iris-servidor-model-server-1 Created 
 Container iris-servidor-gateway-1 Creating 
 Container iris-servidor-gateway-1 Created 
 Container iris-servidor-model-server-1 Starting 
 Container iris-servidor-model-server-1 Started 
 Container iris-servidor-model-server-1 Waiting 
 Container iris-servidor-model-server-1 Healthy 
 Container iris-servidor-gateway-1 Starting 
 Container iris-servidor-gateway-1 Started 
terminal
docker compose ps --format 'table {{.Service}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}'

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
terminal
sleep 3
curl -s localhost:8081/readyz; echo
curl -s localhost:8081/v1/predict -H 'Content-Type: application/json' \
  -d '{"flowers": [{"sepal_length": 6.7, "sepal_width": 3.0, "petal_length": 5.2, "petal_width": 2.3}]}'

El gateway recién arrancado tarda un par de segundos en aceptar conexiones: sin el sleep, el curl puede llegar antes.

Salida real
{"status":"ready","model_server":"http://model-server:8000","model":"iris"}
{"model":"iris","predictions":[{"label":"virginica","score":1.0,"probabilities":{"setosa":0.0,"versicolor":0.0,"virginica":1.0}}]}
terminal
cd ..
python3 verificar.py pull
Salida real
✓ 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"]
terminal
docker build --progress=plain -f model_server/Dockerfile.volumen -t iris-model-server:0.1-sin-modelo model_server

Recortada. El contexto ahora es solo model_server/: 1.5 kB.

Salida real
#3 transferring context: 2B done
#4 transferring context: 1.48kB done
#11 naming to docker.io/library/iris-model-server:0.1-sin-modelo done
terminal
docker images iris-model-server

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).

Salida real
16.4kB	COPY model_server/server.py . # buildkit
20.5kB	COPY model_repository/ /models/ # buildkit
12.3kB	COPY model_server/requirements.txt . # build…
16.4kB	COPY server.py . # buildkit
12.3kB	COPY requirements.txt . # buildkit
terminal
ls -l model_repository/iris/1/model.onnx
Salida real
-rw-r--r--@ 1 erikpalacios  wheel  1299 Nov  4 00:22 model_repository/iris/1/model.onnx
terminal
docker run --rm iris-model-server:0.1-sin-modelo 2>&1 | grep -E 'Error|Exiting' | tail -3

Sin volumen no hay modelo y el proceso muere al arrancar: fail fast. Mejor eso que un servicio arriba que falla en cada request.

Salida real
onnxruntime.capi.onnxruntime_pybind11_state.NoSuchFile: [ONNXRuntimeError] : 3 : NO_SUCHFILE : Load model from /models/iris/1/model.onnx failed:Load model /models/iris/1/model.onnx failed. File doesn't exist
ERROR:    Application startup failed. Exiting.
terminal
docker run -d --name iris-volumen -p 9001:8000 \
  -v "$PWD/model_repository:/models:ro" iris-model-server:0.1-sin-modelo
sleep 3
curl -s -o /dev/null -w "HTTP %{http_code}\n" localhost:9001/v2/models/iris/ready
Salida real
HTTP 200
terminal
docker inspect iris-volumen --format '{{range .Mounts}}{{.Destination}} (rw={{.RW}}){{end}}'

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

Repaso

para el celular
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.

Versión del contenido: 4 de noviembre de 2026.