10  Contenerización y portabilidad

mlops
docker
portabilidad
Autor/a

Wilder Ramírez Delgado

Fecha de publicación

12 de septiembre de 2026

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

  1. Definir portabilidad como “mismo contrato en otro cómputo”, no como “el notebook abre”.
  2. Distinguir entorno virtual, imagen y contenedor.
  3. Leer un Dockerfile como receta, no como magia.
  4. Detectar un desajuste de versiones y fijar un lock de dependencias.
  5. 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

  1. El síntoma y las tres capas de aislamiento.
  2. El manifiesto de entorno y el lock.
  3. Dockerfile como receta.
  4. Capas, usuario y secretos.
  5. Prueba de portabilidad del artefacto.
  6. Taller.
# Entorno: versiones reales y carpeta que simula el empaque.

import hashlib
import json
import sys
from pathlib import Path

import joblib
import numpy as np
import pandas as pd
import sklearn
from sklearn.linear_model import LogisticRegression
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler

RUTA = 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))
{
  "python": "3.13.9",
  "sklearn": "1.8.0",
  "numpy": "2.3.5"
}

3) El lock: declarar no basta, hay que fijar

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 if str(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-slim
WORKDIR /app
COPY requirements.lock .
RUN pip install --no-cache-dir -r requirements.lock
COPY servicio.py modelo.joblib ./
USER nobody
EXPOSE 8000
HEALTHCHECK 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 python:3.12-slim
WORKDIR /app
COPY requirements.lock .
RUN pip install --no-cache-dir -r requirements.lock
COPY servicio.py modelo.joblib ./
USER nobody
EXPOSE 8000
HEALTHCHECK CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/salud')"
CMD ["python", "servicio.py"]

Lectura línea a línea

  • FROM: el piso. slim reduce superficie de ataque y tamaño.
  • COPY requirements.lock antes 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
**/*.csv
artefactos_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.

def arranque_portable(paquete, actual):
    errs = chequear_entorno(paquete["manifiesto"], actual)
    if errs:
        raise RuntimeError(errs)
    return paquete["modelo"].predict([[0.1, -0.2]])[0]

print("arranque ok → predicción:", int(arranque_portable(cargado, manifiesto)))
try:
    arranque_portable({"modelo": pipe, "manifiesto": esperado_mal}, manifiesto)
except RuntimeError as exc:
    print("arranque rechazado:", exc)
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 platform
print("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

  1. Copiar el notebook y llamar a eso deploy.
  2. Imagen con latest y lock sin pines.
  3. Entrenar dentro del contenedor de servicio.
  4. Meter el .env en la capa.
  5. Correr como root.
  6. Confundir “el contenedor está up” con “el modelo cargó”.

16) Checklist

  1. ¿Hay lock hasheable?
  2. ¿El joblib viaja con manifiesto?
  3. ¿El Dockerfile copia el lock antes que el código?
  4. ¿Hay USER no root y HEALTHCHECK?
  5. ¿El .dockerignore excluye datos y secretos?
  6. ¿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 in sorted(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.