Atlas

Docker y orquestación de contenedores para apps web Python

De una imagen bien construida a una app en producción: multi-stage/slim/non-root, Compose para dev, orquestación (Swarm/ECS/Kubernetes) como usuario, scaling horizontal/vertical, configs por entorno y networking (routing/HTTPS/DNS). Nivel senior: no ser DevOps, pero poder diseñar, discutir y operar la infraestructura de un servicio web Python.

Teoría

El objetivo de esta lección es llevar un servicio web Python (FastAPI/Django + gunicorn/uvicorn) desde cero hasta producción: construir una imagen chica y segura, orquestar un entorno local con Compose, desplegarla en un orquestador de contenedores con configuración separada por entorno, escalarla según la dimensión correcta (CPU/memoria/throughput) y exponerla por HTTPS con DNS resuelto. El hilo conductor: la imagen es el artefacto reproducible; el orquestador decide cuántas copias corren, dónde y cómo se recuperan; la red decide cómo se encuentran y cómo entra el tráfico externo.

Un principio transversal que atraviesa todo lo demás: la imagen es inmutable y agnóstica del entorno; la configuración se inyecta en runtime. La misma imagen que corre en staging corre en production; lo único que cambia son las variables de entorno, los secrets montados y el número de réplicas. Esto es la esencia de un build reproducible y de deploys sin sorpresas.

Imagen: multi-stage, base slim, non-root, layer cache

El núcleo de una imagen apta para producción son cuatro decisiones acopladas:

  1. Multi-stage build — un stage builder con el toolchain de compilación (build-essential, headers, gcc) que construye las wheels; un stage runtime limpio que copia solo el artefacto resultante (el venv). El compilador nunca viaja a producción: imagen más chica y menor superficie de ataque.
  2. Base slim (no alpine) — para Python, python:3.x-slim (Debian, glibc) es el default pragmático: las wheels manylinux instalan como binarios precompilados. alpine usa musl libc, muchas dependencias carecen de wheel musllinux y recompilan desde source (builds lentos, imágenes paradójicamente más pesadas, bugs sutiles). distroless (gcr.io/distroless/python3) es el paso de hardening: sin shell ni gestor de paquetes, mínima superficie de CVEs, a costa de no poder hacer docker exec (se debuggea con contenedores efímeros / kubectl debug).
  3. Usuario non-root — el root del contenedor mapea al root del host por defecto (sin user namespaces). Crear un usuario de sistema y USER appuser reduce el blast radius de un container escape.
  4. Layer cache ordenado — copiar requirements.txt antes que el código y correr pip install primero. Las dependencias cambian poco, el código constantemente; así la capa cara de instalación queda cacheada hasta que las deps cambian de verdad. Un .dockerignore mantiene el build context chico y evita cache-busting por archivos irrelevantes o filtrar secrets.

Un quinto punto que separa al senior: PID 1 y señales. El proceso debe recibir SIGTERM para hacer graceful shutdown (drenar conexiones, terminar requests en vuelo) durante un rolling deploy. Usar exec form (CMD ["gunicorn", ...], JSON array) para que la app sea PID 1 y no /bin/sh -c (shell form no reenvía señales). Añadir un init real (tini, o docker run --init) para reapear zombies y reenviar señales correctamente cuando gunicorn spawnea workers.

.dockerignore (crítico — reduce el build context y evita filtrar secrets):

.git
.venv
__pycache__/
*.pyc
.env
.env.*
tests/
.pytest_cache/
.mypy_cache/
.ruff_cache/
*.md
Dockerfile
docker-compose*.yml

Dockerfile — multi-stage, base slim, non-root, cache mount de BuildKit, PID 1 correcto:

# syntax=docker/dockerfile:1

# ---------- builder ----------
FROM python:3.12-slim AS builder

# No .pyc, salida sin buffer, pip sin cache global en la capa
ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    PIP_DISABLE_PIP_VERSION_CHECK=1

WORKDIR /app

# venv aislado que luego copiamos entero al runtime
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"

# Copiar SOLO requirements primero -> capa cacheada si no cambian las deps
COPY requirements.txt .
# Toolchain de compilacion SOLO en el builder; cache mount de BuildKit
# persiste el cache de pip entre builds sin engordar la capa final.
# El flag --mount va inmediatamente despues de RUN, no en medio del comando.
RUN --mount=type=cache,target=/root/.cache/pip \
    apt-get update && apt-get install -y --no-install-recommends build-essential \
    && pip install -r requirements.txt \
    && rm -rf /var/lib/apt/lists/*

# ---------- runtime ----------
FROM python:3.12-slim AS runtime

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    PATH="/opt/venv/bin:$PATH"

# tini = init liviano para reap de zombies y forwarding de senales (PID 1)
RUN apt-get update && apt-get install -y --no-install-recommends tini \
    && rm -rf /var/lib/apt/lists/* \
    && groupadd --system app && useradd --system --gid app --no-create-home appuser

WORKDIR /app

# Copiar el venv ya construido (sin build-essential) y luego el codigo
COPY --from=builder /opt/venv /opt/venv
COPY --chown=appuser:app . .

# Correr como non-root
USER appuser

EXPOSE 8000

# Healthcheck a nivel imagen (Compose lo usa; K8s lo overridea con probes)
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
    CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1

# tini como PID 1 (exec form) -> las senales llegan a gunicorn
ENTRYPOINT ["tini", "--"]
# uvicorn workers para FastAPI async; exec form obligatorio
CMD ["gunicorn", "app.main:app", \
     "--worker-class", "uvicorn.workers.UvicornWorker", \
     "--workers", "4", "--bind", "0.0.0.0:8000"]

Nota de correctitud: la sintaxis RUN --mount=type=cache,... requiere el header # syntax=docker/dockerfile:1 y BuildKit habilitado (default en Docker moderno). El cache mount NO se persiste en la imagen final: solo acelera builds sucesivos.

Escaneo y firma en el pipeline (higiene senior, no opcional): correr un scanner de vulnerabilidades (trivy image, grype, docker scout) con un gate de severidad en CI, pinnear la base por digest (python:3.12-slim@sha256:...) para reproducibilidad, y opcionalmente firmar la imagen (cosign) para enforcement de procedencia. Nunca usar el tag latest.

Compose: orquestar app + DB + cache + worker (dev)

Docker Compose es la herramienta correcta para el entorno local de desarrollo/integración: levanta app + Postgres + Redis + un worker (Celery/RQ) con una red definida por el usuario y volúmenes nombrados, sin instalar servicios en cada máquina. No es un orquestador de producción (no tiene self-healing real, scaling declarativo ni rolling updates gestionados).

La trampa clásica: depends_on: [db] solo espera a que el contenedor arranque, no a que el servicio esté listo para aceptar conexiones. Hay que combinar depends_on con condition: service_healthy más un healthcheck real en la dependencia.

docker-compose.yml — app + Postgres + Redis + Celery worker:

services:
  app:
    build:
      context: .
      target: runtime
    ports:
      - "8000:8000"
    environment:
      DATABASE_URL: postgresql://app:secret@db:5432/appdb
      REDIS_URL: redis://cache:6379/0
    env_file:
      - .env            # secrets fuera de la imagen, inyectados en runtime
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_healthy
    volumes:
      - .:/app          # bind mount para hot-reload en dev
    networks:
      - backend

  worker:
    build:
      context: .
      target: runtime
    command: celery -A app.worker worker --loglevel=info
    environment:
      DATABASE_URL: postgresql://app:secret@db:5432/appdb
      REDIS_URL: redis://cache:6379/0
    depends_on:
      cache:
        condition: service_healthy
      db:
        condition: service_healthy
    networks:
      - backend

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: appdb
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d appdb"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 10s
    networks:
      - backend

  cache:
    image: redis:7-alpine
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 3s
      retries: 5
    networks:
      - backend

volumes:
  pgdata:

networks:
  backend:

Los contenedores en la misma red se resuelven por nombre de servicio: app conecta a Postgres via el host db y a Redis via cache. Esto es DNS interno de Docker (embedded DNS del bridge definido por el usuario), el mismo modelo mental que el Service discovery de Kubernetes.

Aun con healthchecks, la app debe tener retry/backoff en la primera conexión: los healthchecks reducen pero no eliminan las races, y los orquestadores de producción tienen semánticas distintas.

Deploy con configuración separada por entorno

Regla 12-factor: config vive en el entorno, no en el código ni en la imagen. La misma imagen se promueve de staging a production; cambian solo las variables inyectadas. Precedencia típica de menor a mayor prioridad: defaults en código → archivo de config del entorno → variables de entorno → secrets del orquestador.

En Python, pydantic-settings da un punto único tipado con esa precedencia:

from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env", extra="ignore")

    environment: str = "development"
    database_url: str
    redis_url: str
    log_level: str = "INFO"
    sentry_dsn: str | None = None

    @property
    def is_production(self) -> bool:
        return self.environment == "production"


settings = Settings()  # lee env vars -> .env -> defaults, y valida tipos

Con Compose, la separación por entorno se hace con override files: un docker-compose.yml base + un docker-compose.prod.yml que ajusta réplicas, quita bind mounts de dev y apunta a servicios gestionados:

# dev  (base + override implicito docker-compose.override.yml)
docker compose up
# prod-like (base + override explicito)
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d

En Kubernetes, la config no-secreta va en un ConfigMap y los secrets en un Secret (base64, idealmente respaldado por un secrets manager externo como AWS Secrets Manager / Vault vía CSI driver — no commitear el Secret en git en claro):

apiVersion: v1
kind: ConfigMap
metadata:
  name: app-config
data:
  ENVIRONMENT: "production"
  LOG_LEVEL: "INFO"
---
apiVersion: v1
kind: Secret
metadata:
  name: db-credentials
type: Opaque
stringData:                 # stringData: el operador lo codifica en base64
  url: "postgresql://app:REDACTED@db-host:5432/appdb"

El Deployment inyecta ambos como variables de entorno (envFrom para el ConfigMap entero, secretKeyRef para claves puntuales), de modo que el contenedor recibe la config sin que viva en la imagen.

Orquestación de contenedores en producción (a nivel usuario)

Compose no escala ni se auto-cura; producción usa un orquestador. Como usuario (no operador del cluster) hay que saber leer manifests, desplegar, escalar, inspeccionar y hacer rollback. Las tres opciones frecuentes:

Modelo mental de Kubernetes (los objetos que un usuario toca):

Pod:        unidad minima. 1+ containers que comparten network y storage.
            Efimero: puede morir y recrearse con otra IP. Normalmente 1 container = 1 pod.
Deployment: declara cuantas replicas de un pod deben existir y con que imagen.
            Gestiona rolling updates y rollbacks. K8s reconcilia hacia el estado deseado.
Service:    nombre + IP estable (ClusterIP) que balancea a los pods que matchean un label.
            Resuelve el problema de que los pods son efimeros.
ConfigMap/Secret: configuracion y credenciales inyectadas en runtime.
Ingress:    punto de entrada HTTP(S) externo; rutea por host/path a los Services y termina TLS.
HPA:        HorizontalPodAutoscaler; ajusta replicas segun metricas (CPU/mem/custom).
Internet → [Ingress + TLS] → [Service: orders]    → [Pod] [Pod] [Pod]
                           → [Service: inventory] → [Pod] [Pod]
                           → [Service: payments]  → [Pod]

Manifests base (Deployment con probes/resources + Service + Ingress con HTTPS):

# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: order-service
spec:
  replicas: 3
  selector:
    matchLabels:
      app: order-service
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 0        # cero downtime: no bajar pods sanos antes de tener nuevos
      maxSurge: 1
  template:
    metadata:
      labels:
        app: order-service
    spec:
      terminationGracePeriodSeconds: 30   # tiempo para drenar (match con gunicorn graceful)
      containers:
        - name: order-service
          image: myregistry/order-service:1.4.0   # tag inmutable, nunca latest
          ports:
            - containerPort: 8000
          envFrom:
            - configMapRef:
                name: app-config
          env:
            - name: DATABASE_URL
              valueFrom:
                secretKeyRef:
                  name: db-credentials
                  key: url
          resources:
            requests:                 # lo que el scheduler reserva (base para HPA)
              cpu: "100m"
              memory: "128Mi"
            limits:                   # tope duro; superar memory -> OOMKilled
              cpu: "500m"
              memory: "512Mi"
          livenessProbe:              # si falla -> K8s reinicia el container
            httpGet: { path: /health/live, port: 8000 }
            initialDelaySeconds: 10
            periodSeconds: 30
          readinessProbe:             # si falla -> K8s saca el pod del Service (no recibe trafico)
            httpGet: { path: /health/ready, port: 8000 }
            initialDelaySeconds: 5
            periodSeconds: 10
---
# service.yaml
apiVersion: v1
kind: Service
metadata:
  name: order-service
spec:
  selector:
    app: order-service
  ports:
    - port: 80
      targetPort: 8000
  type: ClusterIP        # solo dentro del cluster; el Ingress lo expone afuera
---
# ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: api-ingress
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod   # emite/renueva el cert TLS
spec:
  tls:
    - hosts: ["api.myapp.com"]
      secretName: api-tls-cert       # cert-manager guarda aqui el certificado
  rules:
    - host: api.myapp.com
      http:
        paths:
          - path: /api/v1/orders
            pathType: Prefix
            backend:
              service:
                name: order-service
                port: { number: 80 }

Distinguir liveness (¿el proceso está vivo? si no, reiniciar) de readiness (¿puede atender tráfico ahora? si no, sacarlo del balanceo sin matarlo) es una señal de seniority: confundirlas causa reinicios en cascada bajo carga o tráfico enviado a pods que aún calientan.

Operación como usuario (comandos que hay que dominar):

kubectl apply -f deployment.yaml            # aplica/actualiza el estado deseado
kubectl get pods -l app=order-service       # ver replicas y estado
kubectl scale deployment/order-service --replicas=5   # escalar manual
kubectl rollout status deployment/order-service       # seguir un rolling update
kubectl rollout undo deployment/order-service         # rollback a la revision previa
kubectl logs -f deploy/order-service                  # logs en vivo
kubectl describe pod <pod>                             # eventos: OOMKilled, CrashLoopBackOff...

Application scaling: horizontal vs vertical (CPU / memoria / throughput)

Escalar es responder a un cuello de botella en la dimensión correcta. Primero identificar el recurso saturado:

Dos ejes de scaling:

En k8s el scale-out automático es el HPA, que ajusta réplicas según utilización relativa a los requests:

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: order-service-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: order-service
  minReplicas: 2
  maxReplicas: 10
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70   # si el CPU medio pasa 70% de los requests, escalar out

Matices senior: el HPA compara contra resources.requests (por eso deben estar bien puestos); CPU no siempre es el proxy correcto para servicios I/O-bound (ahí conviene una métrica custom como RPS o profundidad de cola, vía type: Pods/External); y hay que fijar terminationGracePeriodSeconds + graceful shutdown para que el scale-in no corte requests en vuelo. El patrón real suele ser horizontal para tráfico, vertical para ajustar el tamaño base de cada réplica.

Networking: routing, HTTPS/TLS y DNS

El camino de un request externo hasta un worker Python atraviesa varias capas de red:

Cliente → DNS (api.myapp.com -> IP del LB) → Load Balancer / Ingress (termina TLS)
        → Service (ClusterIP, DNS interno) → Pod (IP efimera) → gunicorn/uvicorn

Ejercicios

  1. Adelgazar y endurecer una imagen. Partís de un Dockerfile single-stage con FROM python:3.12 que corre como root y hace COPY . . antes del pip install. Reescribilo multi-stage/slim/non-root con el orden de capas correcto y medí el antes/después.
Solución

Cambios clave: (1) dos stages — builder con build-essential que instala en un venv, runtime desde python:3.12-slim que copia solo /opt/venv; (2) COPY requirements.txt y pip install antes de COPY . . para cachear la capa de deps; (3) crear appuser de sistema y USER appuser; (4) exec form + tini como PID 1; (5) .dockerignore para no meter .git/.venv/.env al context.

docker build -t app:slim .
docker images app:slim              # comparar tamano vs la single-stage con python:3.12 full
docker history app:slim             # verificar que build-essential no esta en la final
docker run --rm app:slim id         # -> uid del appuser, no 0 (root)

Resultado esperado: caída de varios cientos de MB (la base full ~1GB vs slim + deps ~150–250MB), sin toolchain de compilación en la imagen final, proceso non-root, y SIGTERM propagado (graceful shutdown al hacer docker stop).

  1. Arreglar la race de arranque en Compose. El equipo reporta que app crashea intermitentemente al levantar con depends_on: [db] porque Postgres no está listo. Corregí el docker-compose.yml para respetar readiness.
Solución

depends_on: [db] solo espera a que el contenedor arranque, no a que Postgres acepte conexiones. Se cambia a la forma con condition y se añade un healthcheck real a db:

  app:
    depends_on:
      db:
        condition: service_healthy
  db:
    image: postgres:16-alpine
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d appdb"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 10s

Complemento senior: mantener retry/backoff en la app en la primera conexión, porque en producción (k8s/ECS) no existe la semántica service_healthy de Compose y las races reaparecen. El healthcheck reduce la probabilidad, el retry la elimina.

  1. Desplegar, escalar y auto-curar en Kubernetes. Con los manifests base (Deployment/Service/Ingress), desplegá en un cluster local, escalá a 4 réplicas, matá un pod y verificá el auto-healing, y luego hacé un rolling update a una nueva imagen.
Solución
kubectl apply -f deployment.yaml -f service.yaml -f ingress.yaml
kubectl get pods -l app=order-service           # 3 pods Running
kubectl scale deployment/order-service --replicas=4
kubectl delete pod <uno-de-los-pods>            # el Deployment recrea otro -> vuelve a 4
kubectl get pods -w                             # ver el reemplazo en vivo

# rolling update sin downtime
kubectl set image deployment/order-service order-service=myregistry/order-service:1.5.0
kubectl rollout status deployment/order-service
kubectl rollout undo deployment/order-service   # rollback si algo falla

Lo que demuestra: el Deployment reconcilia hacia el estado deseado (auto-healing), maxUnavailable: 0 / maxSurge: 1 garantiza que siempre haya pods sanos durante el update (zero downtime), y readinessProbe evita mandar tráfico a pods que aún no calientan.

  1. Elegir la dimensión de scaling correcta. Un servicio FastAPI I/O-bound (llama a una DB y a una API externa) tiene p99 de latencia alto bajo carga, pero el CPU de los pods está al 30%. El HPA por CPU al 70% nunca dispara. Diagnosticá y proponé la corrección.
Solución

El cuello de botella es throughput/concurrency, no CPU: los requests esperan I/O, no queman CPU, así que un HPA por CPU es ciego a la saturación real. Opciones: (a) subir la concurrencia por réplica (más workers uvicorn / mayor pool async) — scaling vertical de concurrencia; (b) cambiar la métrica del HPA a una custom relevante (RPS por pod o profundidad de la cola) vía type: Pods/External con un adaptador de métricas; (c) asegurar que la app es stateless para poder scale-out horizontalmente detrás del Service. Además, revisar timeouts y pool de conexiones a la DB: a veces el “problema de scaling” es en realidad un pool de conexiones agotado, y añadir réplicas solo empeora la presión sobre la DB.

  1. Servir por HTTPS con DNS y renovación automática. Tenés el servicio corriendo en el cluster con un Ingress, y el dominio api.myapp.com. Detallá los pasos para exponerlo por HTTPS de forma que el certificado se renueve solo.
Solución

(1) DNS: crear un registro que apunte api.myapp.com a la entrada del Ingress — A a la IP pública, o CNAME al hostname del LB (p. ej. un ALB en AWS). (2) Ingress controller: tener uno instalado (nginx-ingress / Traefik) que sea el que recibe el tráfico. (3) cert-manager: instalarlo y crear un ClusterIssuer de Let’s Encrypt (ACME). (4) Anotar el Ingress con cert-manager.io/cluster-issuer: letsencrypt-prod y declarar el bloque tls: con secretName. cert-manager resuelve el challenge (HTTP-01 sirviendo un token en el propio Ingress, o DNS-01 creando un registro TXT), obtiene el cert y lo guarda en el Secret; el Ingress termina TLS con él y cert-manager lo renueva automáticamente antes de los 90 días. El tráfico externo va cifrado hasta el Ingress; dentro del cluster viaja en texto plano (o mTLS con un service mesh). Verificación: curl -v https://api.myapp.com/health debe negociar TLS con un cert válido de Let’s Encrypt.

Preguntas tipo entrevista (EN)

Q1: Why multi-stage builds, and what concretely ends up smaller/safer?

Q2: slim vs alpine vs distroless for a Python web service — which and why?

Q3: How do you order Dockerfile instructions to maximize layer cache hits?

Q4: A security reviewer flags your image. What are the top issues and fixes?

Q5: Your container ignores SIGTERM and takes 10s to die on deploy. Why, and how do you fix PID 1 / signal handling?

Q6: When is Docker Compose the right tool, and where do depends_on / healthchecks bite you?

Q7: Horizontal vs vertical scaling — how do you decide, and what’s the precondition for scaling out?

Q8: What’s the difference between a Pod, a Deployment, and a Service in Kubernetes?

Q9: Liveness vs readiness probes — what breaks if you get them wrong?

Q10: How do you configure the same image for staging vs production?

Q11: How do you serve the service over HTTPS with a custom domain, and keep the cert from expiring?

Q12: A request spans three services and is failing intermittently. How do you trace it, as a user of the platform?

Referencias