Skip to content

📙 Clase 10 — Docker y contenerización ​

Python para Backend · 2026-09-08 · Carpeta: 02-Ejercicios/Clase-10 ⬅️ Volver al índice de clases

🎯 Qué aprendí ​

  • Por qué "en mi máquina funciona" no alcanza para entregar/desplegar un proyecto, y qué problema resuelve Docker (empaquetar app + runtime + dependencias en una unidad reproducible).
  • La diferencia entre imagen (plantilla inmutable) y contenedor (instancia en ejecución), y de dónde salen las imágenes base (Docker Hub).
  • A escribir un Dockerfile real (FastAPI) entendiendo el cacheo de capas, y a hacer docker build + docker run con el mapeo de puertos correcto.
  • Por qué localhost dentro de un contenedor no es lo mismo que localhost en la Mac, y las dos formas de resolverlo (host.docker.internal a mano, o el nombre del servicio con Docker Compose).
  • Persistencia con volúmenes (docker volume create / -v), y cómo se declaran redes y volúmenes en docker-compose.yml.
  • Una introducción a Kubernetes (para cuándo un solo docker run ya no alcanza).
  • Un stack completo (orders + products + users + sus bases) corriendo de verdad con un solo docker compose up.

🗺️ Índice ​

📖 PARTE TEÓRICA ​

📚 1. Definiciones clave ​

TérminoQué esSe profundiza en
DockerPlataforma que empaqueta una aplicación junto con su runtime y todas sus dependencias en una unidad reproducible, para que se comporte igual en cualquier entorno (desarrollo, staging, producción).sección 2
WSL 2 (Windows Subsystem for Linux 2)Capa de compatibilidad de Windows que corre un kernel de Linux real dentro de Windows. Docker Desktop para Windows la usa como backend porque los contenedores son procesos de Linux. No aplica en macOS — Docker Desktop para Mac usa el framework de virtualización nativo de macOS en su lugar.sección 2
DockerfileArchivo de texto con la receta: la secuencia de instrucciones para construir una imagen (qué base usar, qué copiar, qué instalar, con qué comando arrancar).sección 3
ImagenPlantilla inmutable que resulta de construir un Dockerfile con docker build. No cambia una vez creada; a partir de ella se pueden lanzar tantos contenedores como se quiera.sección 3
ContenedorInstancia en ejecución de una imagen (con docker run). Puede haber varios contenedores corriendo al mismo tiempo a partir de la misma imagen, y son desechables: si eliminás uno, la imagen no desaparece.sección 3
docker buildComando que construye una imagen a partir de un Dockerfile.sección 3
docker runComando que crea y arranca un contenedor a partir de una imagen.sección 3
Docker HubRegistry (repositorio) público de imágenes Docker en la nube. Con docker push subís una imagen que construiste; con docker pull bajás una imagen ya publicada (propia o de terceros — p. ej. imágenes oficiales como python o postgres). Es la forma más común de distribuir una imagen entre máquinas/entornos sin reconstruirla cada vez.sección 3
FROMInstrucción del Dockerfile que fija la imagen base sobre la que se construye la nuestra (p. ej. python:3.12-slim).sección 4
WORKDIRInstrucción que fija la carpeta de trabajo dentro del contenedor; los comandos siguientes se ejecutan relativos a ella.sección 4
COPYInstrucción que copia archivos del proyecto (fuera del contenedor) hacia adentro de la imagen.sección 4
RUNInstrucción que ejecuta un comando durante la construcción de la imagen (p. ej. instalar dependencias).sección 4
EXPOSEInstrucción que documenta qué puerto usa la app dentro del contenedor; no lo publica por sí sola hacia el host.sección 4
CMDInstrucción que define el comando que se ejecuta al arrancar el contenedor (no durante el build).sección 4
-p host:contenedorFlag de docker run que mapea un puerto del host a un puerto del contenedor (p. ej. -p 8002:8000). Sin esto, el puerto de EXPOSE no es accesible desde fuera.sección 5
--nameFlag de docker run que le da un nombre fijo al contenedor (en vez de uno aleatorio), para poder referenciarlo después (docker stop products-service, etc.).sección 5
--host 0.0.0.0 vs 127.0.0.1Dirección en la que escucha el servidor (Uvicorn). 0.0.0.0 acepta conexiones de cualquier origen (necesario dentro de un contenedor); 127.0.0.1 solo acepta conexiones del propio proceso/máquina — inaccesible desde fuera del contenedor.sección 5
Contenedor efímeroUn contenedor no guarda cambios de forma permanente: si lo borrás (docker rm), todo lo que escribió en su propio sistema de archivos (por ejemplo, los datos de una base) se pierde con él.sección 6
Volumen (Docker)Espacio de almacenamiento gestionado por Docker que vive fuera del contenedor (en el sistema de archivos del host), y que sobrevive aunque el contenedor se borre. Se crea con docker volume create y se monta con -v.sección 6
-v volumen:rutaFlag de docker run que monta un volumen (o una carpeta del host) dentro del contenedor, en la ruta indicada — lo que se escriba ahí persiste fuera del contenedor.sección 6
Docker ComposeHerramienta que describe varios contenedores y cómo se relacionan en un único archivo docker-compose.yml, para levantarlos todos con un solo comando en vez de un docker run por cada uno.sección 7
docker-compose.ymlEl archivo YAML donde se declara cada service: (imagen o build, puertos, variables de entorno...) — la "receta" de toda la arquitectura local.sección 7
Red interna de ComposeRed virtual que Compose crea automáticamente para los servicios de un mismo archivo: cada uno es alcanzable por su nombre (DNS interno) y por su puerto interno, sin necesidad de host.docker.internal.sección 7
networks:Clave de primer nivel en docker-compose.yml que declara una red con nombre (p. ej. orderflow-net); cada servicio la referencia en su propio bloque networks: para unirse a ella.sección 7
driver: bridgeTipo de red más común de Docker — una red virtual aislada donde los contenedores se ven entre sí por nombre.sección 7
Kubernetes (K8s)Plataforma de código abierto (originada en Google) que orquesta contenedores: decide en qué máquina corre cada uno, lo reinicia si se cae, lo escala según demanda y balancea tráfico entre las copias sanas.sección 8
ClústerConjunto de máquinas (nodos) que Kubernetes gestiona como si fueran una sola gran computadora.sección 8
Nodo (Node)Una máquina (física o virtual) del clúster, donde corre kubelet + un runtime de contenedores y, arriba, los Pods programados en ella.sección 8
PodLa unidad más chica que Kubernetes programa: envuelve uno o más contenedores que siempre corren juntos en el mismo Nodo.sección 8
Control PlaneEl "cerebro" del clúster (API Server, Scheduler, etcd): decide qué Pods crear y en qué Nodo, y mantiene el estado deseado.sección 8
kubectlCLI para hablar con el Control Plane (kubectl apply, kubectl get pods, ...).sección 8
Service (Kubernetes)Objeto que expone un grupo de Pods bajo una dirección estable y balancea tráfico entre ellos — no confundir con "un microservicio" (p. ej. Products Service).sección 8

🐳 2. Introducción a Docker: el problema de partida ​

¿Por qué no basta con "en mi máquina funciona"?

Para poder ejecutar el proyecto Products en una computadora nueva, hoy hay que reproducir a mano toda esta cadena de dependencias — y si un solo paso falla o difiere de cómo está armada tu máquina, la aplicación no arranca:

text
Cadena actual de instalación (manual, frágil)
1. Python instalado
2. Entorno virtual activo
3. Dependencias instaladas
4. PostgreSQL configurado
5. Base de datos creada
6. Variables de entorno definidas
7. Uvicorn ejecutándose

⚠️ Cada uno de estos 7 pasos es un punto de falla: una versión de Python distinta, un .env que falta, un PostgreSQL que no está corriendo igual que en tu máquina... y el proyecto no levanta, aunque el código sea exactamente el mismo.

La pregunta clave: ¿qué ocurre si entregamos el proyecto a otra persona cuya computadora está configurada de forma diferente?

Docker resuelve exactamente este problema: empaqueta la aplicación, el runtime y todas sus dependencias en una unidad reproducible. Así, el comportamiento es idéntico en cualquier entorno: desarrollo, staging o producción.

💡 Es la misma idea que un venv (aislar dependencias de Python), pero un nivel más abajo: Docker no solo aísla las librerías de Python, sino todo el sistema que la app necesita (versión de Python, PostgreSQL, variables de entorno, el comando de arranque) en una sola unidad portable.

🗺️ Diagrama: el problema que Docker resuelve ​

Slide "El problema de partida — ¿Por qué no basta con 'en mi máquina funciona'?": a la izquierda, la cadena actual de instalación manual del proyecto Products en 7 pasos (Python instalado, Entorno virtual activo, Dependencias instaladas, PostgreSQL configurado, Base de datos creada, Variables de entorno definidas, Uvicorn ejecutándose); a la derecha, la pregunta clave (¿qué ocurre si entregamos el proyecto a otra persona cuya computadora está configurada de forma diferente?) y la respuesta: Docker empaqueta la aplicación, el runtime y todas sus dependencias en una unidad reproducible, con comportamiento idéntico en desarrollo, staging o producción

🗺️ Diagrama: mapa de la Sesión 10 ​

Slide "Sesión 10: Docker y Contenerización" con la descripción de la sesión (empaquetar, distribuir y ejecutar aplicaciones de forma reproducible, pasando de ejecutar el proyecto localmente a construir una arquitectura contenerizada completa con FastAPI y PostgreSQL) y la lista de los 5 bloques de la clase: 1. Introducción a Docker, 2. Dockerfile, 3. Docker Compose, 4. Contenedores para FastAPI y PostgreSQL, 5. Estrategias de despliegue

🖼️ 3. Docker no es una máquina virtual: imagen vs. contenedor ​

La distinción entre imagen y contenedor es fundamental para entender Docker:

  • Imagen → receta/plantilla inmutable. Se construye una sola vez con docker build.
  • Contenedor → instancia ejecutándose. Puede haber varios contenedores de la misma imagen corriendo simultáneamente.

Del Dockerfile al contenedor, el flujo siempre sigue este camino:

text
Dockerfile → docker build → Imagen → docker run → Contenedor
  (receta)     (cocinar)    (plato    (servir)     (plato ya
                            preparado)              en la mesa)

💡 La analogía de la clase: un Dockerfile es la receta, docker build es cocinar siguiendo esa receta, la imagen es el plato ya preparado (listo, pero todavía no servido) y el contenedor es ese mismo plato servido en la mesa — la instancia real que alguien se está comiendo. Podés servir (docker run) el mismo plato preparado (imagen) tantas veces como quieras, en tantas mesas (contenedores) como necesites.

📌 Dato clave: si eliminás un contenedor, la imagen no desaparece. Podés crear nuevos contenedores a partir de ella en cualquier momento — es justamente lo que la hace reutilizable y distinta de una máquina virtual (que es pesada y única).

🧪 Tip de entrevista: "¿Cuál es la diferencia entre una imagen y un contenedor en Docker?" → La imagen es una plantilla inmutable (el resultado de docker build); el contenedor es una instancia en ejecución de esa imagen (creada con docker run). Una imagen puede generar múltiples contenedores al mismo tiempo.

💡 La imagen construida localmente con docker build no se distribuye sola. Para llevarla a otra máquina/servidor sin reconstruirla, se sube a un registry como Docker Hub (docker push) y del otro lado se descarga con docker pull — ver glosario, sección 1. Es la pieza que conecta la imagen (el "plato preparado") con la instrucción FROM del Dockerfile de la sección 4: FROM python:3.12-slim justamente descarga esa imagen oficial desde Docker Hub.

🗺️ Diagrama: imagen vs. contenedor ​

Slide "Docker no es una máquina virtual": explica que la distinción entre imagen y contenedor es fundamental — una imagen es inmutable, un contenedor es la instancia viva y en ejecución de esa imagen. A la izquierda, el flujo "Del Dockerfile al contenedor" con 5 iconos en secuencia (Dockerfile → docker build → Imagen → docker run → Contenedor), ilustrado con la analogía de un chef preparando y sirviendo platos en una cocina. A la derecha, "La analogía": tarjeta Imagen (receta/plantilla inmutable, se construye una vez con docker build) y tarjeta Contenedor (instancia ejecutándose, puede haber varios contenedores de la misma imagen simultáneamente); abajo, una nota aclarando que si eliminás un contenedor la imagen no desaparece, podés crear nuevos contenedores a partir de ella en cualquier momento

Docker Hub en la práctica: buscando la imagen base de Python

En hub.docker.com se buscan imágenes ya publicadas para usar como base en el FROM del Dockerfile. Buscando python aparecen más de 325.000 resultados — la clave es fijarse en la procedencia de cada una:

InsigniaQué significa¿Cuándo usarla?
Docker Official ImageImagen mantenida y revisada por el equipo de Docker (o el proyecto oficial de ese lenguaje). Es la opción por defecto y la más confiable — la que usa el Dockerfile de la sección 4 (python:3.12-slim).Casi siempre — es la base recomendada salvo que necesites algo muy específico.
Docker Hardened ImageVariante reforzada en seguridad (superficie de ataque reducida). Marcada como Recommended en la búsqueda.Producción con requisitos de seguridad estrictos.
Imagen de un tercero (circleci/python, cimg/python, demisto/python, ubuntu/python...)Publicada por una empresa/comunidad para un propósito puntual (p. ej. imágenes pensadas para correr en CI).Solo si tu caso de uso coincide con lo que esa imagen resuelve puntualmente — no como base genérica.

⚠️ No cualquier imagen de la búsqueda es igual de confiable. Antes de poner algo en un FROM, fijate si tiene la insignia Docker Official Image (o Verified Publisher) — no es lo mismo que una imagen subida por un usuario cualquiera sin mantenimiento.

🧪 Tip de entrevista: "¿Qué diferencia hay entre una Docker Official Image y cualquier otra imagen en Docker Hub?" → La oficial la mantiene el equipo de Docker o el proyecto del lenguaje/herramienta, con builds revisados y actualizados regularmente; el resto son imágenes publicadas por terceros, sin esa garantía de mantenimiento ni de seguridad.

🗺️ Diagrama: Docker Hub — resultados de búsqueda "python" ​

Captura del navegador en hub.docker.com/search?q=python: 1-30 de 325.490 resultados para "python". Primer resultado destacado "Python — Docker Hardened Image" con etiqueta RECOMMENDED; segundo resultado "python — Docker Official Image"; el resto son imágenes de terceros (circleci/python, cimg/python, demisto/python — A Palo Alto Networks Company, ubuntu/python — Canonical), cada una con su cantidad de descargas y estrellas

🐍 4. Nuestro primer Dockerfile: empaquetando FastAPI ​

Docker lee el Dockerfile de arriba hacia abajo y construye capas de sistema de archivos: cada instrucción genera una nueva capa que Docker guarda en caché, acelerando las construcciones posteriores (si no cambió esa línea ni lo que depende de ella, Docker reutiliza la capa en vez de rehacerla).

Dockerfile completo (para empaquetar un proyecto FastAPI):

dockerfile
FROM python:3.12-slim

WORKDIR /app

COPY requirements.txt .

RUN pip install --no-cache-dir -r requirements.txt

COPY app ./app

EXPOSE 8000

CMD ["uvicorn", "app.main:app",
     "--host", "0.0.0.0",
     "--port", "8000"]

¿Qué hace cada instrucción?

InstrucciónQué hace
FROM python:3.12-slimImagen base de Python 3.12 en su variante ligera (slim — menos paquetes de sistema, imagen final más chica).
WORKDIR /appEstablece /app como carpeta de trabajo dentro del contenedor.
COPY requirements.txt . + RUN pip install ...Copia e instala las dependencias antes que el código — aprovecha el caché de capas: si el código cambia pero requirements.txt no, Docker no vuelve a instalar nada.
COPY app ./appRecién acá copia el código de la aplicación (va después a propósito, ver callout).
EXPOSE 8000Documenta que la app usa el puerto 8000; no lo publica automáticamente hacia el host (eso lo hace docker run -p).
CMD [...]Comando que arranca al iniciar el contenedor (no durante el build): levanta Uvicorn escuchando en 0.0.0.0:8000.

💡 Por qué el orden importa (no es solo estético): COPY requirements.txt . y RUN pip install van antes de COPY app ./app a propósito. Como Docker cachea capa por capa, si solo cambiás código de la app (no las dependencias), el build reutiliza la capa de pip install ya hecha y no vuelve a descargar/instalar nada — mucho más rápido que si copiaras todo el proyecto de una y ejecutaras pip install después.

⚠️ EXPOSE es documentación, no publica el puerto. Para acceder desde fuera del contenedor hace falta mapear el puerto al correrlo: docker run -p 8000:8000 ... (lo vemos al ejecutar el contenedor en la parte práctica).

🧪 Tip de entrevista: "¿Por qué copiar requirements.txt antes que el resto del código en un Dockerfile?" → Por el cacheo de capas: separar la instalación de dependencias del copiado del código evita reinstalar todo el requirements.txt cada vez que cambia una línea de la app, acelerando builds repetidos.

🗺️ Diagrama: Dockerfile completo y qué hace cada instrucción ​

Slide "Nuestro primer Dockerfile — Empaquetando FastAPI": explica que Docker lee el Dockerfile de arriba hacia abajo y construye capas de sistema de archivos, cada instrucción genera una nueva capa que se almacena en caché. A la izquierda, el Dockerfile completo (FROM python:3.12-slim, WORKDIR /app, COPY requirements.txt ., RUN pip install --no-cache-dir -r requirements.txt, COPY app ./app, EXPOSE 8000, CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]); a la derecha, seis tarjetas explicando qué hace cada instrucción (FROM: imagen base de Python 3.12 en su variante ligera; WORKDIR: establece /app como carpeta de trabajo dentro del contenedor; COPY + RUN: copia e instala dependencias antes que el código, aprovecha el caché; EXPOSE: documenta el puerto 8000, no lo publica automáticamente; CMD: comando que arranca al iniciar el contenedor)

📌 Este Dockerfile es el que mostró el profe en la diapositiva. Ya se copió a 02-Ejercicios/Clase-10/orders_service/Dockerfile (mismo orders_service que la Clase 7) — de ahí en más, docker build/docker run se corren de verdad ahí y el resultado real se documenta en la parte práctica de abajo.

🚀 5. Construir y ejecutar FastAPI dentro del contenedor ​

Con el Dockerfile listo, los dos comandos esenciales son:

bash
# Construir la imagen (nombre:tag)
docker build -t orderflow-products:1.0 .

# Ejecutar el contenedor
docker run --name products-service \
  -p 8002:8000 \
  orderflow-products:1.0

Mapeo de puertos: localhost:8002 → :8000 (FastAPI, adentro del contenedor).

💡 -p 8002:8000 significa puerto del host : puerto del contenedor. El contenedor siempre usa el 8000 internamente (es lo que declaró EXPOSE 8000 y lo que escucha Uvicorn); nosotros decidimos exponerlo como el 8002 en nuestra máquina. Podrías correr varios contenedores de la misma imagen mapeando cada uno a un puerto de host distinto (8002, 8003, 8004...) sin que choquen entre sí.

Punto crítico: la dirección de escucha (--host)

FlagResultado
✅ Correcto--host 0.0.0.0Acepta conexiones desde fuera del contenedor (desde el host, u otro contenedor).
❌ Incorrecto--host 127.0.0.1Solo escucha dentro del propio contenedor — inaccesible desde el host, aunque el -p esté bien mapeado.

⚠️ Este es el error más común al contenerizar FastAPI: dejar --host 127.0.0.1 (el default de Uvicorn en desarrollo local) hace que el -p 8002:8000 no sirva de nada — el proceso ni siquiera acepta conexiones que le lleguen desde fuera del contenedor. Por eso el CMD de la sección 4 usa explícitamente --host 0.0.0.0.

🧪 Tip de entrevista: "Mapeaste el puerto con -p 8002:8000 pero no podés acceder a la API desde el navegador. ¿Qué revisás primero?" → Que el proceso dentro del contenedor esté escuchando en 0.0.0.0 y no en 127.0.0.1 — el mapeo de puertos no ayuda si la app solo acepta conexiones locales al propio contenedor.

🗺️ Diagrama: build, run y la dirección de escucha correcta ​

Slide "Construir y ejecutar — FastAPI dentro del contenedor": a la izquierda, comandos esenciales (docker build -t orderflow-products:1.0 ., docker run --name products-service -p 8002:8000 orderflow-products:1.0) y el mapeo de puertos localhost:8002 → :8000 (FastAPI); a la derecha, "Punto crítico: la dirección de escucha" comparando --host 0.0.0.0 (Correcto: acepta conexiones desde fuera del contenedor) vs --host 127.0.0.1 (Incorrecto: solo escucha dentro del propio contenedor, inaccesible desde el host), y una nota aclarando que -p 8002:8000 significa puerto del host:puerto del contenedor, el contenedor siempre usa 8000 internamente y se decide exponerlo como 8002 en la máquina

🐘 6. PostgreSQL también puede ser un contenedor ​

No hace falta instalar PostgreSQL en la máquina — podemos ejecutarlo como contenedor, igual que a orders_service. Pero eso trae un problema nuevo: la persistencia de datos.

Ejecutar la base de datos:

bash
docker run --name products-db \
  -e POSTGRES_USER=orderflow \
  -e POSTGRES_PASSWORD=orderflow123 \
  -e POSTGRES_DB=orderflow_products \
  -p 5433:5432 \
  -d postgres:17-alpine

📌 Nota de puertos: acá el host usa 5433 (no 5432) porque en esta máquina ya puede estar corriendo el bd_test_backend de la Clase 4/Clase 7 ocupando el 5432 — dos contenedores no pueden publicar el mismo puerto del host a la vez. Ver Comandos → Docker.

El problema: los contenedores son efímeros. Si eliminamos el contenedor, los datos desaparecen — se van con él. La solución es usar un volumen, que persiste en el sistema de archivos del host, no en el del contenedor:

bash
docker volume create products_data

docker run --name products-db \
  -e POSTGRES_USER=orderflow \
  -e POSTGRES_PASSWORD=orderflow123 \
  -e POSTGRES_DB=orderflow_products \
  -p 5433:5432 \
  -v products_data:/var/lib/postgresql/data \
  -d postgres:17-alpine
Comando/flagQué hace
docker volume create products_dataCrea un volumen gestionado por Docker (vive fuera de cualquier contenedor, en el host).
-v products_data:/var/lib/postgresql/dataMonta ese volumen en /var/lib/postgresql/data — la ruta donde Postgres guarda sus archivos de datos adentro del contenedor. Lo que Postgres escriba ahí en realidad queda en el volumen, no en el contenedor.

💡 Una aplicación se puede reconstruir. La información de negocio almacenada en la base de datos no debería depender del filesystem interno del contenedor — si el contenedor de products-db se borra y se crea de nuevo (misma imagen, mismo volumen), los datos siguen ahí. Sin el -v, sería empezar de cero cada vez.

🧪 Tip de entrevista: "¿Por qué un contenedor de base de datos necesita un volumen?" → Porque el filesystem de un contenedor se borra junto con él (docker rm). Un volumen vive fuera del contenedor, en el host, así que los datos sobreviven aunque el contenedor se recree.

🗺️ Diagrama: contenedor efímero + volumen que persiste ​

Slide "PostgreSQL también puede ser un contenedor": explica que no hace falta instalar PostgreSQL en la máquina, se puede ejecutar como contenedor, pero hay que resolver el problema de la persistencia de datos. A la izquierda, "Ejecutar la base de datos" con el comando docker run --name products-db -e POSTGRES_USER=orderflow -e POSTGRES_PASSWORD=orderflow123 -e POSTGRES_DB=orderflow_products -p 5433:5432 -d postgres:17-alpine; a la derecha, "El problema: los contenedores son efímeros" — si eliminamos el contenedor los datos desaparecen, la solución es usar un volumen que persiste en el sistema de archivos del host, con los comandos docker volume create products_data y docker run ... -v products_data:/var/lib/postgresql/data postgres:17-alpine; abajo, un callout: una aplicación se puede reconstruir, la información de negocio almacenada en la base de datos no debería depender del filesystem interno del contenedor

📝 Decisión propia — no creé el products-db de arriba. Verifiqué con docker inspect/psql que mi bd_test_backend (el mismo Postgres de la Clase 4/Clase 7) ya tiene la base orderflow_products_db creada, y ya tiene un volumen — Docker se lo crea solo (aunque el docker run original no llevaba -v) porque la imagen oficial de Postgres declara VOLUME /var/lib/postgresql/data; solo que es anónimo (sin nombre fácil) en vez de nombrado como products_data. Reutilizarlo es válido y más simple que levantar un segundo Postgres — la única razón para seguir el ejercicio del profe tal cual sería practicar docker volume create a propósito (un volumen nombrado se gestiona más fácil: docker volume ls, docker volume inspect). Con esto, el .env de un servicio que use esta base queda:

DATABASE_URL=postgresql+psycopg://postgres:postgres@host.docker.internal:5432/orderflow_products_db

🧩 7. Docker Compose: de muchos comandos a una arquitectura declarativa ​

En vez de recordar y ejecutar cada docker run a mano (uno por orders-service, otro por orders-db, otro por products-service, otro por products-db...), Compose describe toda la arquitectura en un único archivo YAML. Con un solo comando se levantan todos los servicios.

docker-compose.yml (fragmento):

yaml
services:

  products-db:
    image: postgres:17-alpine

  products-service:
    build:
      context: ./products_service

  orders-db:
    image: postgres:17-alpine

  orders-service:
    build:
      context: ./orders_service
ClaveQué es
services:La lista de todo lo que Compose va a levantar — cada entrada es un contenedor.
image: postgres:17-alpineIgual que en docker run — usa una imagen ya publicada (Docker Hub).
build: context: ./orders_serviceEn vez de una imagen ya hecha, Compose construye la imagen desde ese Dockerfile (equivalente a pararse en esa carpeta y correr docker build).

El archivo docker-compose.yml se convierte en la representación ejecutable de la arquitectura local del proyecto — cuatro servicios, uno declarado al lado del otro, en vez de cuatro comandos sueltos que hay que recordar en orden.

🗺️ Diagrama: docker-compose.yml como nodo central ​

Slide "De muchos comandos a una arquitectura declarativa": a la izquierda, el fragmento de docker-compose.yml con los 4 servicios (products-db, products-service, orders-db, orders-service); a la derecha, "Arquitectura resultante" — un diagrama radial con el archivo docker-compose.yml como nodo central, conectado a Products Service, Products DB, Orders Service y Orders DB; abajo, una nota: el archivo docker-compose.yml se convierte en la representación ejecutable de la arquitectura local del proyecto

El error más común: dentro de Docker, localhost significa "yo mismo"

bash
# ❌ Configuración incorrecta
PRODUCTS_URL=http://localhost:8002

Dentro del contenedor orders-service, localhost apunta al propio contenedor de Orders, no al de Products — es el mismo problema que ya vimos con Postgres, pero entre dos contenedores de la app. La solicitud nunca llega a destino.

bash
# ✅ Configuración correcta con Compose
PRODUCTS_URL=http://products-service:8000

Compose crea una red DNS interna: cada servicio es accesible por su nombre (products-service, no una IP ni host.docker.internal). Y se usa el puerto interno (8000), no el publicado al host.

PuertoQué es¿Quién lo usa?
8002 (publicado)Expuesto al host (tu Mac).Solo útil desde fuera de la red de Docker (vos, con curl/navegador).
8000 (interno)El puerto real donde escucha Uvicorn, siempre el mismo.Lo que usan los servicios entre sí, dentro de la red de Compose.

⚠️ Este es el error más frecuente al empezar con Docker. Entre contenedores del mismo docker-compose.yml, siempre se usa el nombre del servicio y el puerto interno — nunca localhost, nunca el puerto publicado, y (a diferencia de lo que hicimos a mano en la sección 9) tampoco hace falta host.docker.internal para esto: ese nombre es para llegar a algo que corre fuera de la red de Docker (en tu Mac, como bd_test_backend); para hablar entre servicios de un mismo Compose, el nombre del servicio ya alcanza.

🗺️ Diagrama: localhost vs. nombre del servicio (con Compose) ​

Slide "El error más común — Dentro de Docker, localhost significa 'yo mismo'": a la izquierda, configuración incorrecta PRODUCTS_URL=http://localhost:8002 con la explicación de que dentro del contenedor orders-service, localhost apunta al propio contenedor de Orders y la solicitud nunca llega a destino; debajo, configuración correcta con Compose PRODUCTS_URL=http://products-service:8000, aclarando que Compose crea una red DNS interna donde cada servicio es accesible por su nombre, usando el puerto interno 8000 en vez del publicado al host 8002; a la derecha, comparación Puerto 8002 (expuesto al host, solo útil desde fuera de la red Docker) vs Puerto 8000 (puerto interno del contenedor, el que usan los servicios entre sí dentro de la red Docker), y un callout de advertencia: este es el error más frecuente al empezar con Docker, entre contenedores siempre usar el nombre del servicio y el puerto interno

🗺️ Diagrama: cómo se vinculan los servicios de verdad (red interna de Compose) ​

Diagrama de arquitectura mostrando la red interna que crea Docker Compose: dentro de una zona "RED DE COMPOSE", cuatro contenedores — orders-service y products-service (backend) conectados cada uno a su propia base orders-db y products-db (store) usando el nombre del servicio y el puerto interno (orders-db:5432, products-db:5432); una flecha entre orders-service y products-service usando products-service:8000 (nombre de servicio + puerto interno, no localhost ni el puerto publicado); afuera de la zona, un cliente en el Mac entra únicamente por el puerto publicado (localhost:8001 → orders-service:8000) — mostrando que adentro de la red de Compose se usa nombre+puerto interno, y desde afuera se usa localhost+puerto publicado

Redes y volúmenes: cómo se declaran de verdad en el YAML

La red y el volumen del diagrama de arriba no aparecen solos por magia — se declaran explícitamente en docker-compose.yml, con dos claves de primer nivel (hermanas de services:):

yaml
networks:
  orderflow-net:
    driver: bridge

volumes:
  products_data:
  orders_data:

Y cada servicio que las necesita las referencia por nombre:

yaml
services:
  products-service:
    networks:
      - orderflow-net
    volumes:
      - products_data:/var/lib/postgresql/data
Recurso¿Para qué sirve?
networks:Permite que orders-service encuentre a products-service por nombre. Sin una red compartida, los contenedores quedan aislados entre sí (ver diagrama de arriba).
volumes:Guarda los datos de PostgreSQL en el host. Aunque el contenedor se elimine y se recree, los datos permanecen intactos (mismo concepto de la sección 6, ahora declarado en Compose en vez de con docker volume create + -v sueltos).

💡 Es la misma idea de las secciones 6 y 7 (volumen para persistencia, nombre de servicio para comunicación) — la diferencia es que acá no hay que acordarse de ningún flag: Compose lee el YAML y arma la red y los volúmenes solo, con el nombre que vos les diste (orderflow-net, products_data, orders_data).

🗺️ Diagrama: redes y volúmenes declarados en el YAML ​

Slide "Redes y volúmenes — Comunicación y persistencia en Compose": explica que Compose gestiona automáticamente dos recursos clave, redes para que los contenedores se comuniquen y volúmenes para que los datos sobrevivan reinicios. A la izquierda, la definición en docker-compose.yml: un bloque networks con orderflow-net (driver bridge) y un bloque volumes con products_data y orders_data como volúmenes nombrados, y abajo el servicio products-service referenciando networks (orderflow-net) y volumes (products_data:/var/lib/postgresql/data); a la derecha, "¿Para qué sirve cada uno?" — Network (permite que orders-service encuentre a products-service por nombre, sin red compartida los contenedores están aislados) y Volume (guarda los datos de PostgreSQL en el host, aunque el contenedor se elimine y se recree los datos permanecen intactos)

🚢 8. Kubernetes: cuando un solo docker run ya no alcanza ​

📝 El profe mencionó Kubernetes de pasada (no hubo diapositiva propia para esto) — esta sección es un resumen verificado (no transcripción literal de la clase) para tener la definición clara y una imagen mental de cómo funciona.

¿Qué es Kubernetes? Es una plataforma de código abierto (originada en Google) para orquestar contenedores: en vez de que vos corras docker run a mano en cada máquina, Kubernetes decide en qué máquina corre cada contenedor, lo reinicia solo si se cae, lo escala según demanda y reparte el tráfico entre las copias que estén sanas — todo esto sobre un clúster (varias máquinas trabajando como si fueran una sola).

¿Por qué hace falta si ya tenemos Docker? Docker (lo que vimos en toda esta clase) sabe construir y correr un contenedor en una máquina. Eso alcanza para desarrollo y para un servicio chico. En producción real hace falta: varias copias del mismo contenedor repartidas en varias máquinas, que si una máquina se cae los contenedores se reprogramen en otra, que si sube el tráfico se creen más copias solas, y que las actualizaciones se hagan sin downtime — eso es lo que Kubernetes orquesta por vos.

Con Docker solo (docker run)Con Kubernetes
¿Cuántas máquinas?Una, la que corriste el comandoUn clúster (varias)
¿Si el contenedor se cae?Se queda caído — reiniciás a mano (docker start)Se reprograma solo en otro nodo sano
¿Si sube el tráfico?Nada automáticoEscala el número de réplicas (autoscaling)
¿Cómo se actualiza?Parás y levantás el contenedor a manoRolling update — reemplaza de a poco, sin downtime

🧪 Tip de entrevista: "¿Cuál es la diferencia entre Docker y Kubernetes?" → No compiten, se complementan: Docker construye y corre contenedores individuales; Kubernetes orquesta muchos contenedores (típicamente creados con Docker) a través de muchas máquinas — los reinicia, los escala y balancea tráfico entre ellos.

🗺️ Diagrama: cómo funciona un clúster de Kubernetes ​

Diagrama de arquitectura de un clúster de Kubernetes: arriba a la izquierda, kubectl (CLI en el Mac) envía "kubectl apply" al Control Plane (API Server · Scheduler · etcd); arriba a la derecha, un cliente externo hace un HTTP request a un Service (LoadBalancer de orders-service) que reparte el tráfico entre los Nodes. Abajo, dos Nodes (worker), cada uno corriendo kubelet + container runtime con 2 pods de orders-service; el Control Plane programa pods en ambos Nodes (flechas punteadas de gestión) mientras el Service balancea el tráfico real hacia ambos (flechas sólidas)

💻 PARTE PRÁCTICA ​

🐳 9. orders_service contenerizado: build + run reales ​

Archivo real: 02-Ejercicios/Clase-10/orders_service (el mismo microservicio de la Clase 7, copiado tal cual — sin venv/ ni .env reales — y con su propio Dockerfile de la sección 4). Pasos reales, verificados en terminal:

1. Ubicate en la carpeta del proyecto:

bash
cd /Users/styp/Documents/Cursos/Python_para_backend/02-Ejercicios/Clase-10/orders_service

2. Construí la imagen (solo hace falta repetir esto si cambiás código o requirements.txt):

bash
docker build -t orders_service:1.0 .

3. Corré el contenedor:

bash
docker run -d --name orders-service \
  -p 8001:8000 \
  --add-host=host.docker.internal:host-gateway \
  --env-file .env \
  orders_service:1.0
  • -d → en segundo plano (detached).
  • -p 8001:8000 → tu máquina 8001 → contenedor 8000.
  • --add-host=host.docker.internal:host-gateway → fuerza que host.docker.internal resuelva a la IP del host (ver el 🐛 de abajo — sin esto no resolvió solo).
  • --env-file .env → le pasa las variables de entorno (DATABASE_URL, PRODUCTS_URL, JWT_SECRET) sin hornearlas en la imagen.

💡 El .env (a partir de .env.example, no versionado) tiene DATABASE_URL y PRODUCTS_URL apuntando a host.docker.internal en vez de localhost — es la forma en que un contenedor en Docker Desktop para Mac llega a servicios corriendo en tu propia máquina (Postgres, Products Service). Ver Comandos → Clase 10.

🐛 Error real que salió sin el --add-host: probando la conexión a la base desde adentro del contenedor (psycopg.connect(...)), tiró failed to resolve host 'host.docker.internal': Name or service not known — confirmado con docker exec orders-service cat /etc/hosts (el nombre ni aparecía ahí). Recrear el contenedor con --add-host=host.docker.internal:host-gateway lo resolvió al toque.

🗺️ Diagrama: 3 capas de red — contenedor, Mac, red externa ​

127.0.0.1, la IP y el puerto significan algo distinto según en qué anillo de red estés parado — es la raíz del bug de arriba:

Diagrama anidado con tres anillos de red — Contenedor (más interno, foco naranja), Mac/host, y Red externa (más externo) — mostrando que 127.0.0.1, la IP y los puertos significan algo distinto en cada anillo (127.0.0.1 en el contenedor es el propio contenedor, en el Mac es el Mac), y cómo una request de un cliente externo cruza el puerto publicado 8001 en el Mac hasta llegar al puerto interno 8000 del contenedor, con una nota aclarando que host.docker.internal es el puente entre el anillo del Mac y el del contenedor, y que sin --add-host Docker no arma ese puente solo

4. Verificá que levantó:

bash
docker ps                          # debería listar "orders-service" como Up
docker logs orders-service         # ver el log de arranque de Uvicorn
curl http://localhost:8001/health  # {"status":"ok","service":"Orders Service","version":"1.0.0"}

Para pararlo/limpiarlo cuando termines:

bash
docker stop orders-service
docker rm orders-service

⚠️ El /health funciona sin base de datos real. Si probás un endpoint que sí consulta Postgres (/api/v1/orders), va a fallar salvo que tengas un Postgres corriendo y accesible en host.docker.internal:5432 — eso lo resuelve mejor Docker Compose (siguiente tema de la clase), que conecta varios contenedores entre sí sin depender de host.docker.internal.

🗺️ Diagrama: qué pasa al hacer docker build + docker run ​

Diagrama de arquitectura mostrando el flujo build → run del orders_service en Docker Desktop para Mac: a la izquierda, la terminal del Mac ejecuta docker build (lee el Dockerfile y arma la imagen orders_service:1.0) y luego docker run; a la derecha, el contenedor orders-service resultante, escuchando en 0.0.0.0:8000 adentro, mapeado a localhost:8001 en el Mac mediante -p 8001:8000 — por ahí entra el curl/navegador. Desde el contenedor, dos flechas de salida hacia el Mac vía host.docker.internal: una a Postgres (puerto 5432, base orderflow_orders_db) y otra a Products Service (puerto 8002) — ambas usando host.docker.internal en vez de localhost porque localhost dentro del contenedor apunta a sí mismo

🗺️ Diagrama: por qué host.docker.internal y no localhost ​

Diagrama comparando dos escenarios de red: arriba, "❌ Incorrecto" — el contenedor orders-service intenta conectarse a localhost:5432 y la flecha rebota contra sí mismo (localhost dentro del contenedor es el propio contenedor, no el Mac), con Postgres corriendo en el Mac quedando inalcanzado; abajo, "✅ Correcto" — el contenedor se conecta a host.docker.internal:5432, una flecha que sale del contenedor y llega hasta Postgres corriendo en el Mac, mediado por el mecanismo de Docker Desktop que resuelve ese nombre especial a la IP del host

🧩 10. El stack completo con Docker Compose (orders + products + users) ​

Mismo patrón que orders_service (sección 9): se copiaron products_service y users_service de la Clase 7 a 02-Ejercicios/Clase-10 (sin venv/ ni .env reales — la Clase 7 queda intacta, es una copia). A cada uno se le agregó su Dockerfile (idéntico al de la sección 4), y un docker-compose.yml en la raíz de Clase-10 los levanta a los tres de una:

yaml
networks:
  orderflow-net:
    driver: bridge

volumes:
  orders_data:
  products_data:
  users_data:

services:
  orders-db:
    image: postgres:17-alpine
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: orderflow_orders_db
    volumes:
      - orders_data:/var/lib/postgresql/data
    networks: [orderflow-net]

  orders-service:
    build:
      context: ./orders_service
    env_file: [./orders_service/.env]
    depends_on: [orders-db, products-service]
    ports: ["8001:8000"]
    networks: [orderflow-net]

  # products-db / products-service y users-db / users-service: mismo patrón,
  # puertos 8002 y 8003 — ver el archivo completo en el repo.

Cada .env apunta a su base por el nombre del servicio, no por host.docker.internal (eso ya no hace falta adentro de la red de Compose — sección 7):

bash
# orders_service/.env
DATABASE_URL=postgresql+psycopg://postgres:postgres@orders-db:5432/orderflow_orders_db
PRODUCTS_URL=http://products-service:8000

Levantar todo:

bash
cd 02-Ejercicios/Clase-10
docker compose up -d --build

Salida verificada:

bash
$ docker compose ps
NAME                          SERVICE            STATUS          PORTS
clase-10-orders-service-1     orders-service     Up              0.0.0.0:8001->8000/tcp
clase-10-products-service-1   products-service   Up              0.0.0.0:8002->8000/tcp
clase-10-users-service-1      users-service      Up              0.0.0.0:8003->8000/tcp
clase-10-orders-db-1          orders-db          Up              5432/tcp
clase-10-products-db-1        products-db        Up              5432/tcp
clase-10-users-db-1           users-db           Up              5432/tcp

$ curl http://localhost:8001/health
{"status":"ok","service":"Orders Service","version":"1.0.0"}
$ curl http://localhost:8002/health
{"status":"ok","service":"Products Service","version":"1.0.0"}
$ curl http://localhost:8003/health
{"status":"Felicitaciones! El backend esta UP","service":"Users Service","version":"1.0.0"}

La prueba que importa — orders-service llegando a products-service por nombre, desde adentro del contenedor:

bash
$ docker exec clase-10-orders-service-1 python -c \
  "import httpx; print(httpx.get('http://products-service:8000/health', timeout=3).json())"
{'status': 'ok', 'service': 'Products Service', 'version': '1.0.0'}

🐛 Bug real encontrado y corregido: las bases que crea Compose son nuevas y vacías (distintas de bd_test_backend), así que hacía falta correr las migraciones de Alembic contra ellas. Al intentarlo, docker compose exec orders-service alembic upgrade head tiró FAILED: No 'script_location' key found in configuration — el Dockerfile (igual al de la sección 4, pensado solo para servir la API) copiaba app/ pero no alembic.ini ni migrations/, así que esos archivos ni existían dentro del contenedor. Se agregaron dos líneas al Dockerfile de los tres servicios:

dockerfile
COPY app ./app
COPY alembic.ini .
COPY migrations ./migrations

Reconstruyendo (docker compose up -d --build) y corriendo docker compose exec <servicio> alembic upgrade head en los tres, las tablas (orders, products, users) quedaron creadas — verificado con \dt en cada base.

💡 Por qué depends_on no alcanza solo: depends_on: [orders-db] hace que Compose arranque orders-db antes que orders-service, pero no espera a que Postgres esté realmente listo para aceptar conexiones — en un proyecto real conviene sumar un healthcheck a la base y condition: service_healthy en el depends_on (no lo necesitamos acá porque create_engine de SQLAlchemy es perezoso: no conecta hasta la primera query).

🗺️ Diagrama: entornos y redes reales en esta máquina, ahora mismo ​

No es un diagrama de ejemplo — son las IPs y puertos reales de esta máquina, verificados con docker network inspect, mostrando algo importante: hay dos redes de Docker aisladas corriendo al mismo tiempo, y no se ven entre sí:

Diagrama de arquitectura con el inventario real de contenedores Docker: dos redes aisladas dentro de "Mac (host)" — la red "bridge" (default, 172.17.0.0/16) con bd_test_backend (172.17.0.3, postgres:16-alpine, publicado en :5432), y la red "clase-10_orderflow-net" (172.22.0.0/16) con orders-service (172.22.0.7, publicado :8001), products-service (172.22.0.5, publicado :8002), users-service (172.22.0.6, publicado :8003) y sus tres bases orders-db/products-db/users-db (cada una en :5432 interno, sin publicar), con flechas mostrando las conexiones reales servicio→base y orders-service→products-service por nombre; un callout aclara que estas dos redes no se ven entre sí, bd_test_backend queda aislado de este stack

📌 Que bd_test_backend y el stack de Compose usen el mismo número de puerto interno (5432) no es un problema — viven en redes distintas (172.17.x vs 172.22.x), así que no chocan entre sí. Lo que sí importa es que los puertos publicados al host sean todos distintos (5432, 8001, 8002, 8003) — esos comparten el mismo espacio de direcciones: el de tu Mac.

🏋️ 11. EJERCICIOS CON SOLUCIÓN ​

Todos los comandos y salidas de esta sección se verificaron en terminal contra el stack real de 02-Ejercicios/Clase-10 — no hay salidas inventadas.

Ejercicio 1 — Construir una imagen con otro tag ​

Construí la imagen de products_service con el tag 2.0 en vez de 1.0.

🎯 Qué deberías lograr: docker images debe listar una fila products_service con TAG igual a 2.0.

💡 ¿Sabías que…? — un tag es solo una etiqueta

Un mismo build puede tener varios tags apuntando al mismo IMAGE ID — no son copias, son nombres distintos para lo mismo. Por ejemplo, después de construir mi_api:1.0 podés agregarle otro tag sin reconstruir nada:

bash
docker tag mi_api:1.0 mi_api:latest
docker images | grep mi_api   # dos filas, mismo IMAGE ID
Ver solución
bash
cd 02-Ejercicios/Clase-10
docker build -t products_service:2.0 ./products_service
docker images | grep products_service

Salida real:

products_service   2.0   f571c5554e4b   31 minutes ago   386MB

Ejercicio 2 — Correr esa imagen en otro puerto ​

Corré un contenedor de products_service:2.0, publicando el puerto 8010 de tu Mac, usando el .env real de products_service.

🎯 Qué deberías lograr: curl http://localhost:8010/health responde 200 con {"status":"ok","service":"Products Service",...}.

💡 ¿Sabías que…? — podés correr la misma imagen en paralelo, en puertos distintos

Nada te impide levantar 2 o más contenedores de la misma imagen, cada uno publicado en un puerto distinto del host — por ejemplo, para probar dos versiones a la vez:

bash
docker run -d --name api-v1 -p 9001:8000 mi_api:1.0
docker run -d --name api-v2 -p 9002:8000 mi_api:2.0
Ver solución
bash
docker run -d --name test-products-v2 -p 8010:8000 \
  --env-file ./products_service/.env products_service:2.0
curl http://localhost:8010/health

Salida real:

{"status":"ok","service":"Products Service","version":"1.0.0"}

Ejercicio 3 — Leer los logs de arranque ​

Con el contenedor del ejercicio 2 corriendo, mirá sus logs y encontrá la línea donde Uvicorn dice en qué dirección está escuchando.

🎯 Qué deberías lograr: ubicar la línea Uvicorn running on http://0.0.0.0:8000.

💡 ¿Sabías que…? — docker logs -f sigue el log en vivo

Sin -f, docker logs muestra lo que ya se imprimió y termina. Con -f (follow) se queda mostrando líneas nuevas a medida que aparecen — útil mientras hacés requests de prueba en otra terminal:

bash
docker logs -f mi_contenedor

Ctrl+C corta el seguimiento (no detiene el contenedor).

Ver solución
bash
docker logs test-products-v2 --tail 6

Salida real:

INFO:     Started server process [1]
INFO:     Waiting for application startup.
INFO:     Application startup complete.
INFO:     Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)

Ejercicio 4 — Limpiar el contenedor de prueba ​

Apagá y borrá test-products-v2.

🎯 Qué deberías lograr: docker ps -a ya no debe listar test-products-v2.

💡 ¿Sabías que…? — docker rm -f hace las dos cosas de una

docker stop x && docker rm x son dos pasos porque rm normalmente se niega a borrar un contenedor corriendo. docker rm -f x fuerza el apagado y el borrado en un solo comando (útil en scripts, pero pensalo dos veces en algo importante).

Ver solución
bash
docker stop test-products-v2 && docker rm test-products-v2

Ejercicio 5 — slim vs. imagen completa ​

En una copia de prueba del Dockerfile de users_service, cambiá FROM python:3.12-slim por FROM python:3.12 (sin slim), reconstruí con otro tag y comparé el tamaño final con docker images.

🎯 Qué deberías lograr: confirmar que la imagen completa pesa notablemente más que la slim — no hace falta que el número te dé exacto, sí que veas la diferencia.

💡 ¿Sabías que…? — slim no trae herramientas de compilación

python:X-slim quita compiladores y librerías de desarrollo que muchas apps no necesitan en producción (solo en el momento de instalar dependencias con extensiones en C). Ejemplo de referencia con otra imagen base:

bash
docker pull node:20-slim
docker pull node:20
docker images | grep node   # la slim pesa una fracción de la completa
Ver solución
bash
# Dockerfile.full: mismo contenido que el real, con FROM python:3.12 (sin slim)
docker build -f Dockerfile.full -t users_service:full ./users_service
docker images | grep users_service

Salida real verificada:

users_service   full     9c6ee1b5d6cc   1.8GB
users_service   latest   f27f499c795d   409MB

Casi 4.5× más pesada sin el -slim — para una API que solo necesita el intérprete de Python y las dependencias ya compiladas (pip install con wheels), esos ~1.4GB extra no aportan nada.

Ejercicio 6 — Predecir el cacheo de capas ​

Te muestran un Dockerfile donde alguien puso COPY app ./app antes de COPY requirements.txt . / RUN pip install. Explicá qué pasa con el caché de Docker cada vez que cambia una línea de código de la app (sin tocar requirements.txt).

🎯 Qué deberías lograr: identificar que, con ese orden invertido, todas las capas después de COPY app — incluido pip install — se invalidan y se re-ejecutan en cada build, aunque las dependencias no cambiaron.

💡 ¿Sabías que…? — Docker invalida en cascada, no una capa suelta

En cuanto una capa cambia, Docker reconstruye esa y todas las que siguen — no puede "saltear" una capa del medio. Ejemplo de referencia con otro Dockerfile mal ordenado:

dockerfile
FROM node:20-slim
WORKDIR /app
COPY . .                 # copia TODO primero (mal)
RUN npm install           # se re-ejecuta con cualquier cambio de código
Ver solución

Orden correcto (el real, de la sección 4):

dockerfile
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app

Así, si solo cambia app/, Docker reutiliza la capa de pip install ya hecha (cache hit) y el build es mucho más rápido.

Ejercicio 7 — Levantar orders_service standalone ​

Repetí el proceso de la sección 9 para levantar orders-service solo (sin Compose), publicando el puerto 8001.

🎯 Qué deberías lograr: curl http://localhost:8001/health → 200.

💡 ¿Sabías que…? — un mismo servicio puede correr "solo" o "en equipo"

Nada en el código de orders_service sabe si lo estás corriendo standalone o dentro de un docker-compose.yml — la diferencia la da únicamente el .env (a qué host apunta DATABASE_URL). Mismo Dockerfile, mismo build, distinto contexto de red.

Ver solución
bash
cd 02-Ejercicios/Clase-10/orders_service
docker build -t orders_service:1.0 .
docker run -d --name orders-service -p 8001:8000 \
  --add-host=host.docker.internal:host-gateway \
  --env-file .env orders_service:1.0
curl http://localhost:8001/health

Ejercicio 8 — Reproducir (o no) el bug de host.docker.internal ​

Corré ese mismo contenedor sin el flag --add-host y probá conectarte a la base desde adentro.

🎯 Qué deberías lograr: en el setup real de esta clase, la conexión falló con failed to resolve host 'host.docker.internal'. (Este comportamiento puede variar según tu versión de Docker Desktop — si en tu máquina resuelve solo, no hay nada que arreglar en tu caso, pero vale la pena confirmarlo en vez de asumirlo.)

💡 ¿Sabías que…? — /etc/hosts es la fuente de verdad

Cuando algo "no resuelve un nombre", /etc/hosts dentro del contenedor es el primer lugar donde mirar — ahí se ve literalmente qué nombres conoce ese contenedor:

bash
docker exec mi_contenedor cat /etc/hosts
Ver solución
bash
docker run -d --name orders-service -p 8001:8000 --env-file .env orders_service:1.0
docker exec orders-service python -c "
import psycopg
from app.config import settings
psycopg.connect(settings.database_url.replace('+psycopg',''), connect_timeout=3)
"

Salida real (sección 9): failed to resolve host 'host.docker.internal': Name or service not known.

Ejercicio 9 — Corregir con --add-host ​

Recreá el contenedor con --add-host=host.docker.internal:host-gateway y confirmá que la conexión funciona.

🎯 Qué deberías lograr: el script de conexión imprime CONEXION OK.

💡 ¿Sabías que…? — --add-host edita /etc/hosts por vos

Es literalmente un atajo para agregar una línea a /etc/hosts del contenedor al crearlo — host-gateway es una palabra especial que Docker traduce a la IP real del host:

bash
docker run --add-host=api.interno:host-gateway ...
Ver solución
bash
docker stop orders-service && docker rm orders-service
docker run -d --name orders-service -p 8001:8000 \
  --add-host=host.docker.internal:host-gateway \
  --env-file .env orders_service:1.0

Salida real: CONEXION OK: host.docker.internal 5432 orderflow_orders_db.

Ejercicio 10 — Inspeccionar los volúmenes del stack ​

Con el stack de Compose levantado, listá los volúmenes creados y mostrá el punto de montaje real en el host de orders_data.

🎯 Qué deberías lograr: docker volume ls lista clase-10_orders_data, clase-10_products_data, clase-10_users_data; docker volume inspect de cualquiera de ellos muestra un Mountpoint real en disco (dentro de la VM de Docker Desktop).

💡 ¿Sabías que…? — un volumen puede sobrevivir a docker compose down

docker compose down (sin -v) borra contenedores y redes, pero deja los volúmenes — por eso las tablas siguen ahí la próxima vez que hacés docker compose up. Solo docker compose down -v los borra también.

Ver solución
bash
docker volume ls --filter name=clase-10
docker volume inspect clase-10_orders_data --format '{{.Mountpoint}}'

Ejercicio 11 — Publicado vs. interno ​

Dado -p 8005:8000: ¿qué número usa un curl desde tu Mac? ¿Y qué número usa otro contenedor de la misma red de Compose para llegar a este servicio?

🎯 Qué deberías lograr: responder 8005 para el curl desde el Mac, y 8000 (vía el nombre del servicio) para otro contenedor — nunca al revés.

💡 ¿Sabías que…? — el puerto publicado no existe "adentro"

El proceso dentro del contenedor no sabe en qué puerto lo publicaste — ni falta que le haga. Ejemplo de referencia: si publicás -p 9999:8000, un curl desde adentro del propio contenedor a localhost:9999 falla — adentro, el servicio solo existe en :8000.

Ver solución
  • Desde el Mac: curl http://localhost:8005/health
  • Desde otro contenedor de la red: curl http://ese-servicio:8000/health

Ejercicio 12 — Corregir un PRODUCTS_URL mal configurado ​

Te pasan un .env de orders_service con PRODUCTS_URL=http://localhost:8002. Corregilo para que funcione dentro de la red de Compose.

🎯 Qué deberías lograr: el valor corregido debe apuntar al nombre del servicio y al puerto interno.

💡 ¿Sabías que…? — el mismo error, con otro par de servicios

Es exactamente el error de la sección 7: dentro de orders-service, localhost es el propio orders-service, nunca products-service — pase lo que pase con el nombre de las variables.

Ver solución
bash
PRODUCTS_URL=http://products-service:8000

Ejercicio 13 — Escribir networks:/volumes: para un servicio nuevo ​

Un servicio ficticio reports-service necesita su propia base reports-db con volumen reports_data. Escribí el fragmento YAML: las claves de primer nivel networks:/volumes: y el bloque reports-db.

🎯 Qué deberías lograr: el YAML debe ser válido — docker compose config -q no debe tirar error.

💡 ¿Sabías que…? — un volumen nombrado no necesita más configuración

Declarar volumes: reports_data: (sin nada debajo) alcanza — Docker usa el driver local por defecto. Solo hace falta más configuración (driver:, driver_opts:) para casos especiales (NFS, volúmenes remotos).

Ver solución
yaml
volumes:
  reports_data:

services:
  reports-db:
    image: postgres:17-alpine
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: orderflow_reports_db
    volumes:
      - reports_data:/var/lib/postgresql/data
    networks:
      - orderflow-net

Ejercicio 14 — Agregar pgAdmin al stack ​

Sumá un servicio pgadmin (imagen dpage/pgadmin4) al docker-compose.yml, en la misma red, publicado en el puerto 5050.

🎯 Qué deberías lograr: docker compose up -d pgadmin seguido de curl -I http://localhost:5050 debe responder 302 (redirect a la pantalla de login) — significa que el servicio arrancó bien.

💡 ¿Sabías que…? — pgAdmin valida el formato del email

PGADMIN_DEFAULT_EMAIL tiene que parecer un email real — dominios como .local o .test lo rechazan al arrancar (does not appear to be a valid email address), y el contenedor no expone nada hasta que ese chequeo pasa.

Ver solución
yaml
  pgadmin:
    image: dpage/pgadmin4:latest
    environment:
      PGADMIN_DEFAULT_EMAIL: admin@orderflow.dev
      PGADMIN_DEFAULT_PASSWORD: admin123
    ports:
      - "5050:80"
    networks:
      - orderflow-net

Verificado: admin@orderflow.local fue rechazado por pgAdmin al arrancar; admin@orderflow.dev funcionó (curl -I → 302).

Ejercicio 15 — Leer docker compose logs ​

Con el stack arriba, corré docker compose logs orders-service --tail 10 e identificá si hubo algún error de arranque.

🎯 Qué deberías lograr: ubicar las líneas Application startup complete y Uvicorn running on http://0.0.0.0:8000 — señal de que arrancó bien.

💡 ¿Sabías que…? — compose logs junta todos los servicios si no especificás uno

docker compose logs (sin nombre) muestra el log de todos los servicios intercalado, cada línea prefijada con el nombre del servicio — útil para ver el orden real de arranque de todo el stack.

Ver solución
bash
docker compose logs orders-service --tail 10

Ejercicio 16 — Migrar una base fresca ​

Bajá el stack con docker compose down -v (borra también los volúmenes) y volvé a levantarlo; corré Alembic en los 3 servicios para recrear las tablas.

🎯 Qué deberías lograr: \dt en cada base debe mostrar de nuevo alembic_version + la tabla del dominio (orders, products, users).

💡 ¿Sabías que…? — down -v es la limpieza total

Sin -v, docker compose down deja los volúmenes (los datos sobreviven). Con -v, los borra — es la opción a usar cuando de verdad querés empezar de cero, no la que corrés "por las dudas" todos los días.

Ver solución
bash
docker compose down -v
docker compose up -d --build
docker compose exec orders-service alembic upgrade head
docker compose exec products-service alembic upgrade head
docker compose exec users-service alembic upgrade head

Ejercicio 17 — Dos redes, mismo puerto interno ​

Explicá por qué bd_test_backend (red bridge) y orders-db (red clase-10_orderflow-net) pueden usar el mismo puerto interno 5432 sin chocar entre sí.

🎯 Qué deberías lograr: explicar que viven en namespaces de red distintos — con IPs reales de evidencia.

💡 ¿Sabías que…? — es como dos casas con la misma numeración de puertas

Que dos redes usen el mismo puerto interno no es una coincidencia rara — cada red de Docker es su propio espacio aislado, así que "puerto 5432 de la red A" y "puerto 5432 de la red B" son cosas completamente distintas, igual que el "cuarto 5" puede existir en dos casas diferentes sin confundirse.

Ver solución

bd_test_backend → 172.17.0.3 (red bridge, 172.17.0.0/16). orders-db → 172.22.0.2 (red clase-10_orderflow-net, 172.22.0.0/16). Rangos de IP distintos → aunque ambos escuchen en :5432, nunca se ven ni chocan entre sí (ver diagrama de la sección 10).

Ejercicio 18 — ¿Reusar o separar bases? ​

Con el criterio de las secciones 6 y 10: un servicio de "Reportes" que solo lee datos de orders-db y products-db (no escribe) — ¿le conviene tener su propia base, o conectarse directo a las existentes?

🎯 Qué deberías lograr: justificar tu respuesta con el principio database per service, no solo dar una opinión.

💡 ¿Sabías que…? — leer no es lo mismo que ser dueño del esquema

Database per service protege sobre todo la escritura: que un servicio no dependa del esquema interno de otro. Un ejemplo de referencia: un dashboard de métricas que consulta 5 microservicios distintos — casi siempre lo hace llamando a un endpoint /metrics de cada uno, no leyendo sus tablas directo.

Ver solución

Preferible que "Reportes" consuma la API de orders-service y products-service (como hace orders-service con products-service en la sección 7), no que se conecte directo a orders-db/products-db. Conectarse directo a la base ajena acopla a "Reportes" al esquema interno de esos servicios — si mañana cambian una columna, "Reportes" se rompe sin que su propio equipo lo haya tocado.

Ejercicio 19 — El camino completo de una request ​

Con tus palabras (o un diagrama ASCII propio), describí el camino completo de curl http://localhost:8001/api/v1/orders hasta que llega a la tabla orders en Postgres, nombrando cada "anillo" que atraviesa.

🎯 Qué deberías lograr: mencionar al menos 4 pasos: Mac (puerto publicado 8001) → contenedor orders-service (puerto interno 8000) → orders-db (por nombre de servicio, puerto 5432) → la tabla orders en esa base.

💡 ¿Sabías que…? — cada flecha de los diagramas de esta clase es un paso real

No es casualidad que la Clase 10 tenga tantos diagramas de "capas" — cada uno (sección 9: Contenedor/Mac/Red externa; sección 10: dos redes aisladas) es literalmente el mapa de un tramo distinto de este mismo camino.

Ver solución
text
curl (tu Mac)
  → localhost:8001                      [puerto publicado]
    → contenedor orders-service :8000   [puerto interno, Uvicorn]
      → orders-db:5432                  [por nombre de servicio, red de Compose]
        → tabla "orders" (Postgres)

Ejercicio 20 — Reto final: healthcheck + condition: service_healthy ​

Agregá un healthcheck a orders-db (con pg_isready) y cambiá el depends_on de orders-service para que espere a que esté healthy antes de arrancar.

🎯 Qué deberías lograr: docker compose ps debe mostrar orders-db como (healthy), y en el log de arranque orders-service debe iniciar después de que orders-db pase a Healthy (no antes, como pasaba con el depends_on simple de la sección 10).

💡 ¿Sabías que…? — depends_on simple solo espera a que el contenedor "exista"

depends_on: [orders-db] (sin condition) hace que Compose arranque orders-db primero, pero no espera a que Postgres esté listo para aceptar conexiones — apenas el proceso arrancó, ya sigue con el siguiente servicio. Con bases que tardan en inicializar, eso puede causar el primer intento de conexión fallido.

Ver solución
yaml
  orders-db:
    # ...
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 3s
      timeout: 3s
      retries: 5

  orders-service:
    # ...
    depends_on:
      orders-db:
        condition: service_healthy
      products-service:
        condition: service_started

Verificado: docker compose up mostró orders-db Waiting → orders-db Healthy → recién ahí orders-service Starting; docker compose ps mostró orders-db como Up ... (healthy).

❓ Preguntas y respuestas (autoevaluación) ​

1. ¿Qué problema resuelve Docker que un simple venv de Python no resuelve?

Un venv solo aísla las librerías de Python. Docker empaqueta además el runtime completo (versión de Python, el sistema operativo base, variables de entorno, el comando de arranque) — todo lo que hace falta para que la app se comporte igual en cualquier máquina, no solo las dependencias de pip.

2. ¿Cuál es la diferencia entre una imagen y un contenedor?

La imagen es una plantilla inmutable (el resultado de docker build); el contenedor es una instancia en ejecución de esa imagen (creada con docker run). Una misma imagen puede generar muchos contenedores a la vez.

3. ¿Por qué conviene copiar requirements.txt y correr pip install antes de copiar el código de la app en un Dockerfile?

Por el cacheo de capas: si el código cambia pero las dependencias no, Docker reutiliza la capa de pip install ya hecha en vez de reinstalar todo de nuevo — builds mucho más rápidos.

4. ¿Qué diferencia hay entre el puerto que declara EXPOSE en el Dockerfile y el que se mapea con -p en docker run?

EXPOSE solo documenta qué puerto usa la app dentro del contenedor — no lo hace accesible desde fuera. -p host:contenedor es lo que realmente publica ese puerto hacia el host.

5. ¿Qué diferencia hay entre una "Docker Official Image" y una imagen cualquiera de Docker Hub?

La oficial la mantiene el equipo de Docker o el proyecto del lenguaje/ herramienta, con builds revisados y actualizados regularmente. El resto son imágenes publicadas por terceros, sin esa garantía de mantenimiento ni de seguridad.

6. ¿Por qué un contenedor de base de datos necesita un volumen para no perder los datos?

Porque el filesystem de un contenedor se borra junto con él (docker rm). Un volumen vive fuera del contenedor, en el host — así los datos sobreviven aunque el contenedor se borre y se recree.

7. Dentro de un contenedor, ¿a qué apunta localhost? ¿Y host.docker.internal?

localhost adentro de un contenedor es el propio contenedor, nunca tu Mac ni otro contenedor. host.docker.internal es el nombre especial que resuelve a la IP real del host — necesario cuando no hay una red de Compose de por medio (por eso, a veces, hace falta forzarlo con --add-host).

8. Con Docker Compose, ¿cómo se comunican dos servicios entre sí sin usar host.docker.internal?

Por el nombre del servicio y su puerto interno — Compose crea una red DNS interna donde cada servicio es alcanzable por su nombre (p. ej. http://products-service:8000), sin necesitar localhost ni host.docker.internal.

9. ¿Qué hace depends_on por defecto, y qué le falta para asegurar que un servicio realmente esté listo (no solo iniciado)?

Por defecto, depends_on solo controla el orden de arranque — arranca el contenedor dependido antes, pero no espera a que esté listo para aceptar conexiones. Para eso hace falta sumar un healthcheck a ese servicio y usar condition: service_healthy en el depends_on.

10. ¿Cuál es la diferencia entre Docker y Kubernetes?

No compiten, se complementan: Docker construye y corre contenedores individuales; Kubernetes orquesta muchos contenedores (típicamente creados con Docker) a través de muchas máquinas — los reinicia si se caen, los escala según demanda y balancea tráfico entre las copias sanas.

📎 Apuntes relacionados ​

  • Clase 4 — primer docker run de Postgres (bd_test_backend), base del patrón reusado toda esta clase.
  • Clase 7 — orders_service, products_service y users_service originales (copiados sin tocar en esta clase).
  • 00-Notas/01-Comandos.md — todos los comandos de Docker/Compose de esta clase, en su formato de referencia rápida.

➡️ Siguiente ​

Clase 11