Serie MLOps y Producción · de la ingeniería de datos al monitoreo.
Módulo IV — Despliegue y producción
La sesión 9 dejó un servicio con contrato HTTP. En el portátil del autor importa sklearn==1.8 y un .joblib de esa versión. En el servidor de un compañero hay sklearn==1.2. El archivo carga… o no. O carga y predice otra cosa. El síntoma tiene nombre: “en mi máquina sí corre”.
Este artículo ataca ese síntoma: aislar el entorno y declararlo. El entorno virtual es el aislamiento mínimo. El contenedor es el que viaja: código, dependencias y, si se decide, el sistema base. No hace falta tener Docker instalado para leer un Dockerfile. Hace falta entender qué problema resuelve cada capa.
Objetivos de aprendizaje
Definir portabilidad como “mismo contrato en otro cómputo”, no como “el notebook abre”.
Distinguir entorno virtual, imagen y contenedor.
Leer un Dockerfile como receta, no como magia.
Detectar un desajuste de versiones y fijar un lock de dependencias.
Argumentar qué entra en la imagen (código + lock + artefacto) y qué no (datos crudos, secretos).
Qué no cubre esta sesión
Orquestar muchos contenedores en Kubernetes (solo la idea).
El CI que construye la imagen (sesión 11).
Ruta de trabajo
El síntoma y las tres capas de aislamiento.
El manifiesto de entorno y el lock.
Dockerfile como receta.
Capas, usuario y secretos.
Prueba de portabilidad del artefacto.
Taller.
# Entorno: versiones reales y carpeta que simula el empaque.import hashlibimport jsonimport sysfrom pathlib import Pathimport joblibimport numpy as npimport pandas as pdimport sklearnfrom sklearn.linear_model import LogisticRegressionfrom sklearn.pipeline import Pipelinefrom sklearn.preprocessing import StandardScalerRUTA = Path("artefactos_sesion10")RUTA.mkdir(exist_ok=True)print("Python:", sys.version.split()[0])print("scikit-learn:", sklearn.__version__)print("Este par (lenguaje, librería) ES parte del artefacto, no un detalle.")
Python: 3.13.9
scikit-learn: 1.8.0
Este par (lenguaje, librería) ES parte del artefacto, no un detalle.
1) Portabilidad no es copiar el notebook
En una frase: un sistema es portable si el contrato de inferencia se ejecuta en otra máquina y produce el mismo resultado, no si el .ipynb abre.
Copiar el notebook no copia la versión de Python, las dependencias transitivas, las variables de entorno ni las librerías nativas (BLAS, certificados). El .joblib es especialmente sensible: guarda objetos de esta versión de scikit-learn.
La portabilidad es un problema de empaque. Un compañero amable no es un mecanismo.
2) Tres capas que no son sinónimos
Capa
Qué aísla
Qué no viaja solo
Cuándo basta
venv / conda
Paquetes de Python
El SO, las libs nativas
Mismo sistema, mismo Python
Imagen
Receta + capas (SO + paquetes)
Nada: es el viaje
Otro host con runtime de contenedor
Contenedor
Una instancia de la imagen
—
Ejecutar la receta
La imagen es la clase; el contenedor, el objeto. Confundirlos es como confundir el Dockerfile con el proceso que escucha el puerto 8000.
# Manifiesto mínimo de lo que *esta* máquina está usando.manifiesto = {"python": sys.version.split()[0],"sklearn": sklearn.__version__,"numpy": np.__version__,}(RUTA /"manifiesto.json").write_text(json.dumps(manifiesto, indent=2), encoding="utf-8")print(json.dumps(manifiesto, indent=2))
scikit-learn sin versión es una invitación al desajuste. Un lock (requirements.txt pinneado, poetry.lock, conda-lock) responde: estas versiones exactas, y no las de “hoy en PyPI”.
# Lock didáctico: pines + hash del archivo de pines.lock =f'''scikit-learn=={sklearn.__version__}numpy=={np.__version__}joblib=={joblib.__version__}'''(RUTA /"requirements.lock").write_text(lock, encoding="utf-8")digest = hashlib.sha256(lock.encode()).hexdigest()[:12]print(lock)print("hash del lock:", digest)print("Si el lock cambia, la imagen DEBE reconstruirse. Sesión 11 automatiza eso.")
scikit-learn==1.8.0
numpy==2.3.5
joblib==1.5.3
hash del lock: 3cf079811a55
Si el lock cambia, la imagen DEBE reconstruirse. Sesión 11 automatiza eso.
4) El desajuste se puede detectar, no solo padecer
# Un compañero declara otra versión: el chequeo debe fallar ANTES de predecir.def chequear_entorno(esperado: dict, actual: dict) ->list[str]:return [f"{k}: esperado {esperado[k]}, actual {actual[k]}"for k in esperado ifstr(esperado[k]) !=str(actual[k])]esperado_ok =dict(manifiesto)esperado_mal = {**manifiesto, "sklearn": "1.2.2"}print("Mismo entorno:", chequear_entorno(esperado_ok, manifiesto) or"ok")print("Desajuste :", chequear_entorno(esperado_mal, manifiesto))
Mismo entorno: ok
Desajuste : ['sklearn: esperado 1.2.2, actual 1.8.0']
Si el servicio de la sesión 9 arrancara sin este chequeo, el 500 llegaría dentro de un request, o peor: un 200 con otra geometría de \(T\). El chequeo es de arranque, como /salud.
5) Un artefacto que viaja con su manifiesto
# Entrenamos un pipeline mínimo y lo guardamos JUNTO al manifiesto.rng = np.random.default_rng(0)X = rng.normal(size=(200, 2))y = (X[:, 0] +0.3* X[:, 1] >0).astype(int)pipe = Pipeline([("sc", StandardScaler()), ("clf", LogisticRegression(max_iter=300))])pipe.fit(X, y)joblib.dump({"modelo": pipe, "manifiesto": manifiesto}, RUTA /"paquete.joblib")cargado = joblib.load(RUTA /"paquete.joblib")print("Manifiesto embebido:", cargado["manifiesto"])print("¿Coincide con esta máquina?:", cargado["manifiesto"] == manifiesto)
Manifiesto embebido: {'python': '3.13.9', 'sklearn': '1.8.0', 'numpy': '2.3.5'}
¿Coincide con esta máquina?: True
Empaquetar el modelo sin manifiesto es el .joblib huérfano de la sesión 8, ahora a escala de librerías.
6) El Dockerfile es una receta, no un hechizo
Cada instrucción es una capa. El orden importa: lo que cambia poco (lock) va arriba; lo que cambia mucho (código) va abajo, para no invalidar la caché.
# Receta escrita a disco: se puede leer sin Docker instalado.dockerfile ='''FROM python:3.12-slimWORKDIR /appCOPY requirements.lock .RUN pip install --no-cache-dir -r requirements.lockCOPY servicio.py modelo.joblib ./USER nobodyEXPOSE 8000HEALTHCHECK CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/salud')"CMD ["python", "servicio.py"]'''(RUTA /"Dockerfile").write_text(dockerfile, encoding="utf-8")print(dockerfile)
FROM: el piso. slim reduce superficie de ataque y tamaño.
COPY requirements.lockantes del código: si solo cambia servicio.py, no se reinstalan paquetes.
USER nobody: el proceso no corre como root. Un RCE no nace dueño del host.
HEALTHCHECK: el runtime pregunta /salud (sesión 9). Un contenedor “up” con el modelo sin cargar no es un servicio.
CMD: el proceso de larga vida. No es jupyter notebook.
7) .dockerignore: lo que no debe entrar
# Secretos, datos crudos y el propio .git no viajan en la imagen.ignore ='''.git.env**/*.csvartefactos_sesion10/*.csv__pycache__.ipynb_checkpoints'''(RUTA /".dockerignore").write_text(ignore, encoding="utf-8")print(ignore)print("Una imagen con el CSV de clientes es una filtración, no un deploy.")
.git
.env
**/*.csv
artefactos_sesion10/*.csv
__pycache__
.ipynb_checkpoints
Una imagen con el CSV de clientes es una filtración, no un deploy.
8) Imagen versus contenedor, en una analogía de laboratorio
La imagen es el kit cerrado (receta horneada). El contenedor es el experimento encendido: tiene PID, red y un sistema de archivos escribible que muere al borrar el contenedor. Si el modelo se escribe dentro del contenedor y no en un volumen, al recrearlo desaparece. El artefacto debe estar en la imagen o en un volumen montado; no en la RAM del proceso “porque ya lo entrené aquí”.
# Inventario de lo que SÍ y NO entra en la imagen de inferencia.inventario = pd.DataFrame( [ {"objeto": "servicio.py", "entra": True, "razon": "código del contrato HTTP"}, {"objeto": "requirements.lock", "entra": True, "razon": "pines reproducibles"}, {"objeto": "modelo.joblib", "entra": True, "razon": "artefacto de production"}, {"objeto": "manifiesto.json", "entra": True, "razon": "chequeo de arranque"}, {"objeto": "crudo.csv", "entra": False, "razon": "datos / PII"}, {"objeto": ".env con claves", "entra": False, "razon": "secreto"}, {"objeto": "notebook de entrenamiento", "entra": False, "razon": "no es el runtime"}, ])print(inventario.to_string(index=False))
objeto entra razon
servicio.py True código del contrato HTTP
requirements.lock True pines reproducibles
modelo.joblib True artefacto de production
manifiesto.json True chequeo de arranque
crudo.csv False datos / PII
.env con claves False secreto
notebook de entrenamiento False no es el runtime
9) Multi-stage: entrenar no es servir
Una imagen de entrenamiento pesa (compiladores, datos). Una de servicio debe ser delgada. El patrón multi-stage construye en una etapa y copia solo el artefacto a la etapa final. Esta sesión no lo ejecuta; lo deja como frontera con la sesión 6: el fit no vive en el mismo proceso que el POST /predict del viernes.
10) Tag de imagen: latest miente igual que datos.csv
# Un tag honesto cita el hash del lock y, si se quiere, el run_id.tag =f"prioridad:{digest}"print("tag propuesto:", tag)print("latest no dice qué lock ni qué run. Es el alias peligroso de la sesión 8.")
tag propuesto: prioridad:3cf079811a55
latest no dice qué lock ni qué run. Es el alias peligroso de la sesión 8.
11) Prueba de portabilidad (sin otro host)
No tenemos un segundo computador. Sí podemos exigir: el paquete declara un manifiesto; esta máquina lo cumple; un manifiesto ajeno se rechaza. Eso es el test que el CI (sesión 11) correrá sobre la imagen.
arranque ok → predicción: 0
arranque rechazado: ['sklearn: esperado 1.2.2, actual 1.8.0']
12) Variables de entorno frente a secretos en la imagen
ENV PORT=8000 es configuración. ENV API_KEY=secreto es una filtración horneada en una capa que quedará en el registry de imágenes. Los secretos se inyectan en runtime (el orquestador, el host). El Dockerfile de esta sesión no contiene claves; esa omisión es deliberada.
13) Tamaño y superficie: por qué slim no es estética
Cada paquete extra es CVEs y minutos de pull. Servir un modelo lineal no justifica una imagen con CUDA y PyTorch. La receta se dimensiona al runtime de inferencia, no al laboratorio de entrenamiento.
# Receta de laboratorio vs receta de servicio: dos FROM distintos.print("lab : FROM python:3.12 # compiladores, pesada")print("servicio: FROM python:3.12-slim # la de esta sesión")print("gpu : FROM nvidia/cuda:... # solo si el predict lo exige")
lab : FROM python:3.12 # compiladores, pesada
servicio: FROM python:3.12-slim # la de esta sesión
gpu : FROM nvidia/cuda:... # solo si el predict lo exige
14) Relación con 9 y 11
La sesión 9 define qué proceso corre. Esta sesión define en qué caja corre. La 11 pulsará docker build y no dejará pasar una imagen cuyo /salud falle o cuyo lock no coincida.
14b) El kernel de este notebook no es la imagen
Ejecutar estas celdas no ‘conteneriza’ el modelo. Demuestra el razonamiento del empaque. El estudiante que copie el Dockerfile sin el lock ha entendido la estética, no la portabilidad.
14c) Arquitectura del chip: amd64 no es arm64
Un compañero con Mac M-series y un servidor x86 no comparten binarios nativos. La imagen debe declarar plataforma, o el runtime emula (lento, frágil). El lock de Python no resuelve un .so compilado para otra ISA.
import platformprint("máquina de este notebook:", platform.machine(), platform.system())print("En el Dockerfile: --platform=linux/amd64 si el destino es el servidor x86.")
máquina de este notebook: AMD64 Windows
En el Dockerfile: --platform=linux/amd64 si el destino es el servidor x86.
14d) Bind mount frente a COPY
COPY hornea. El bind mount (-v $PWD:/app) monta el código vivo: útil para desarrollar, letal en producción (el host pisa la receta). Un contenedor de servicio no se desarrolla; se instancia.
14e) Overlay: por qué borrar un archivo en una capa no lo borra del historial
Si un RUN descarga un secreto y el siguiente RUN lo borra, el secreto sigue en la capa anterior. La imagen se puede inspeccionar. Por eso los secretos no se COPY ni se RUN curl | login.
14f) Registry de imágenes
Docker Hub, ECR, GHCR: son el análogo del model registry, para cajas. El tag prioridad:a3f1c9 debe poder bajarse en el host de producción sin pasar por el portátil del autor. Si la imagen solo existe en el daemon local, no hay portabilidad: hay un favor.
print("Analogía sesión 8:")print(" run_id + joblib → registry de modelos")print(" digest + imagen → registry de contenedores")print("Ambos son punteros. Ninguno es 'el archivo que estaba en Descargas'.")
Analogía sesión 8:
run_id + joblib → registry de modelos
digest + imagen → registry de contenedores
Ambos son punteros. Ninguno es 'el archivo que estaba en Descargas'.
14g) Rebuild: qué invalida la caché
# Orden de invalidación (de más estable a más volátil).print("1. FROM → casi nunca")print("2. lock → cuando cambia el hash")print("3. servicio.py → cada commit de contrato")print("4. modelo.joblib → cada promoción (sesión 8)")print("Por eso el lock se COPIA antes que el joblib.")
1. FROM → casi nunca
2. lock → cuando cambia el hash
3. servicio.py → cada commit de contrato
4. modelo.joblib → cada promoción (sesión 8)
Por eso el lock se COPIA antes que el joblib.
14h) Compose no es Kubernetes
docker compose levanta este servicio más, acaso, un nginx. No es un clúster. Nombrarlo aquí evita que el estudiante crea que ya orquestó (sesión 7) por tener un YAML de dos servicios.
14i) Distroless y la tentación de bash dentro
Imágenes distroless no traen shell. Depurar ‘entrando al contenedor’ deja de ser opción. Eso empuja a logs y a /salud —que es, de nuevo, el contrato de la sesión 9.
14j) Una frase de examen
“La imagen es la receta horneada; el contenedor es la receta encendida; el lock es la lista de ingredientes con lote.” Si las tres palabras se intercambian, el estudiante aún no portabilizó nada.
15) Errores frecuentes
Copiar el notebook y llamar a eso deploy.
Imagen con latest y lock sin pines.
Entrenar dentro del contenedor de servicio.
Meter el .env en la capa.
Correr como root.
Confundir “el contenedor está up” con “el modelo cargó”.
16) Checklist
¿Hay lock hasheable?
¿El joblib viaja con manifiesto?
¿El Dockerfile copia el lock antes que el código?
¿Hay USER no root y HEALTHCHECK?
¿El .dockerignore excluye datos y secretos?
¿El tag nombra el lock o el run, no latest?
17) Conclusiones
Contenerizar no es “usar Docker”. Es negar la frase “en mi máquina sí corre” con una receta: lock, manifiesto, imagen delgada y un proceso que no es el notebook. La sesión 11 convertirá esa receta en un botón que una máquina, no un viernes, decide pulsar.
18) Taller práctico
A. Añada pandas al lock y recalcule el hash. ¿Debe cambiar el tag de la imagen?
B. Escriba un chequear_entorno que también compare el hash del lock, no solo los números de versión.
C. Liste tres archivos de su proyecto real que nunca deberían aparecer en el .dockerignore al revés (es decir, que SÍ deben copiarse).
D. Redacte la línea HEALTHCHECK usando el /salud de la sesión 9.
E. Argumente en cinco líneas por qué el notebook de la sesión 6 no entra en la imagen de servicio.
print("Archivos del empaque:")for p insorted(RUTA.iterdir()):print(f" {p.name:22s}{p.stat().st_size:6d} B")print("Taller A–E. Si el lock no está pineado, la imagen es un deseo.")
Archivos del empaque:
.dockerignore 82 B
Dockerfile 314 B
manifiesto.json 70 B
paquete.joblib 1451 B
requirements.lock 50 B
Taller A–E. Si el lock no está pineado, la imagen es un deseo.
💬 ¿Te sirvió?
Deja en los comentarios una duda o un caso donde aplicarías esto — respondo todos. Sígueme para no perderte el próximo artículo de la serie y comparte con alguien que esté aprendiendo arquitectura de IA.
👉 El código completo está disponible para ejecutar directamente.