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:
- Multi-stage build — un stage
buildercon el toolchain de compilación (build-essential, headers,gcc) que construye las wheels; un stageruntimelimpio que copia solo el artefacto resultante (el venv). El compilador nunca viaja a producción: imagen más chica y menor superficie de ataque. - Base
slim(noalpine) — para Python,python:3.x-slim(Debian, glibc) es el default pragmático: las wheelsmanylinuxinstalan como binarios precompilados.alpineusamusl libc, muchas dependencias carecen de wheelmusllinuxy 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 hacerdocker exec(se debuggea con contenedores efímeros /kubectl debug). - Usuario non-root — el root del contenedor mapea al root del host por defecto (sin user namespaces). Crear un usuario de sistema y
USER appuserreduce el blast radius de un container escape. - Layer cache ordenado — copiar
requirements.txtantes que el código y correrpip installprimero. 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.dockerignoremantiene 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:1y 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:
- Docker Swarm — el más simple;
docker stack deploysobre un compose-like condeploy:. Poco features, casi en mantenimiento; sirve para single-team / pocos servicios. - AWS ECS (Fargate) — orquestación gestionada sin gestionar nodos; se define una task definition (equivalente a un pod) y un service que mantiene N tasks detrás de un ALB. Menos control que k8s, mucho menos overhead operacional. Buena opción cuando ya se está en AWS y no se justifica k8s.
- Kubernetes — el estándar cuando hay 5+ servicios, auto-scaling y HA. Más potente y más complejo; conviene consumirlo gestionado (EKS/GKE/AKS) para que el overhead del control plane no sea tuyo.
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:
- CPU-bound — el servicio quema CPU (serialización, cómputo). Añadir cores/workers ayuda.
- Memory-bound — el working set no entra en RAM (caches, payloads grandes). Añadir memoria o reducir footprint.
- Throughput/concurrency-bound (típico en web I/O-bound) — la latencia sube por requests concurrentes esperando I/O (DB, APIs). Se resuelve con más concurrencia/instancias, no con más CPU.
Dos ejes de scaling:
- Vertical (scale up) — darle más recursos a la instancia: subir
cpu/memorylimits, o subir--workersde gunicorn dentro del pod (regla de arranque para CPU-bound:~2 × núcleos + 1; para I/O-bound async, menos workers uvicorn con alta concurrencia por worker). Tope físico de una máquina y no da HA: un solo punto de falla. - Horizontal (scale out) — más réplicas del servicio detrás de un load balancer. Es la razón de existir del LB: distribuir carga entre N instancias iguales y sacar de rotación las que fallan. Escala casi lineal y da HA, siempre que el servicio sea stateless (sesión/estado fuera del proceso: Redis, DB, JWT). Estado en memoria local rompe el scale-out horizontal.
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
- DNS externo — un registro
A(oCNAMEa un hostname del LB, típico en AWS ALB) apuntaapi.myapp.coma la IP pública del balanceador/Ingress. Es lo que traduce el dominio a la entrada del cluster. - DNS interno del cluster — CoreDNS resuelve los Services por nombre:
order-service.default.svc.cluster.local(forma cortaorder-servicedentro del mismo namespace). Este es el service discovery: los pods son efímeros y cambian de IP, pero el nombre del Service es estable. Es el mismo modelo que el DNS embebido de una red Compose. - Routing por capas — el Ingress (o un reverse proxy como nginx/Traefik) rutea por
host/pathal Service correcto; el Service balancea (round-robin por defecto) entre los podsReady. Un reverse proxy se sienta delante de los servicios (termina TLS, rutea, cachea, rate-limitea); un forward proxy se sienta delante de los clientes para salir a internet. No confundirlos. - HTTPS/TLS termination — el TLS se termina en el borde (Ingress/LB), no en cada pod: el tráfico interno cluster va en texto plano (o mTLS si hay service mesh). En k8s,
cert-manager+ unClusterIssuerde Let’s Encrypt (challenge ACME HTTP-01 o DNS-01) emite y renueva automáticamente el certificado, guardándolo en elSecretque referencia el Ingress. Sin renovación automática, el cert expira cada 90 días y cae producción — automatizarlo es la práctica senior. - Subnets / VPC (nivel cloud) — el cluster vive en una VPC con subnets públicas (donde se expone el LB) y privadas (donde corren los nodos/pods y la DB gestionada, sin IP pública). El routing entre subnets y las security groups/network policies definen qué puede hablar con qué. Regla: la base de datos nunca en subnet pública; solo el balanceador mira a internet.
Ejercicios
- Adelgazar y endurecer una imagen. Partís de un
Dockerfilesingle-stage conFROM python:3.12que corre como root y haceCOPY . .antes delpip 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).
- Arreglar la race de arranque en Compose. El equipo reporta que
appcrashea intermitentemente al levantar condepends_on: [db]porque Postgres no está listo. Corregí eldocker-compose.ymlpara 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.
- 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.
- 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.
- 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?
- ❌ Wrong / trap: “Multi-stage just means multiple
FROMlines so the file is organized.” — Fails because it misses the point: it names the mechanism without the payoff (build tools/artifacts don’t reach the final image) and shows no understanding of attack surface or size. - ✅ Correct: A builder stage installs
build-essential, compilers and dev headers to build wheels; the runtime stage starts from a clean base and copies only the resulting artifacts (the venv / installed packages). The compiler toolchain never ships, so the image is smaller and has less to exploit. - ⭐ Optimal (senior): Copy a self-contained venv (
/opt/venv) or usepip install --prefix; keep the runtime base minimal (slim/distroless); purge apt lists in the sameRUNlayer. Combine with BuildKit cache mounts (RUN --mount=type=cache,target=/root/.cache/pip) so pip cache persists across builds without bloating layers. Result: a runtime image that contains only Python + your deps + your code, non-root, and reproducible.
Q2: slim vs alpine vs distroless for a Python web service — which and why?
- ❌ Wrong / trap: “Alpine, because it’s the smallest, always.” — Fails on Python specifically: alpine uses musl libc, so many packages lack
musllinuxwheels and recompile from source — slower builds, larger images, and subtle runtime bugs. Picking the smallest base blindly is a classic junior tell. - ✅ Correct: For Python,
python:3.x-slim(glibc/Debian) is the pragmatic default:manylinuxwheels install as prebuilt binaries, so builds are fast and reliable while staying reasonably small. Alpine is only worth it if you’ve verified every dependency has a musl wheel. - ⭐ Optimal (senior): Default to slim; move to distroless (
gcr.io/distroless/python3) for production hardening — no shell, no package manager, dramatically reduced CVE surface — paired with a multi-stage build that produces the venv. Accept the trade-off: no shell means you debug via ephemeral debug containers /kubectl debuginstead ofdocker exec. Pin the exact base digest (@sha256:...) for reproducibility.
Q3: How do you order Dockerfile instructions to maximize layer cache hits?
- ❌ Wrong / trap: “
COPY . .first, thenpip install -r requirements.txt.” — Fails because any code change invalidates the COPY layer and forces a fullpip installon every build — the single most common cause of slow CI. - ✅ Correct: Copy
requirements.txtfirst and runpip install, thenCOPY . .afterward. Dependencies change rarely, code changes constantly, so the expensive install layer stays cached until deps actually change. A.dockerignorekeeps the build context small and prevents cache-busting from irrelevant files. - ⭐ Optimal (senior): Same principle plus: pin dependencies with a lockfile (hashes) for deterministic layers; use BuildKit
--mount=type=cachefor the pip/uv cache; split into builder/runtime so runtime layers rarely rebuild; and be aware ordering matters forapt-gettoo (combine update+install+cleanup in oneRUN). In CI, use--cache-from/registry cache to share layers across runners.
Q4: A security reviewer flags your image. What are the top issues and fixes?
- ❌ Wrong / trap: “It runs as root but that’s fine inside a container, containers are isolated.” — Fails: container root maps to host root by default (no user namespaces), and a container escape or a compromised process then has root. Also ignores secrets in ENV, unpinned tags, and unscanned CVEs.
- ✅ Correct: Create a system user and
USER appuserso the process runs non-root; never bake secrets intoENV/layers (they persist in history — inject via runtime env, mounted files, or a secrets manager); pin base image and dependency versions; run a vulnerability scan (Trivy/Grype/docker scout) in CI. - ⭐ Optimal (senior): Non-root + read-only root filesystem (
--read-only/readOnlyRootFilesystem) with a writabletmpfsfor temp; drop Linux capabilities andno-new-privileges; use BuildKit secret mounts (RUN --mount=type=secret,id=...) so build-time secrets never land in a layer; pin base by digest; scan and set a CVE gate in CI; sign images (cosign) and enforce provenance. Never uselatest.
Q5: Your container ignores SIGTERM and takes 10s to die on deploy. Why, and how do you fix PID 1 / signal handling?
- ❌ Wrong / trap: “Use
CMD gunicorn ...(shell form) and add a longer stop timeout.” — Fails: shell form (CMD gunicorn ...) runs the process under/bin/sh -c, soshis PID 1 and does NOT forward SIGTERM to gunicorn — the app never gets the signal and is SIGKILL’d after the grace period. Bumping the timeout hides the bug. - ✅ Correct: Use exec form (
CMD ["gunicorn", ...]) so the app is PID 1 and receives signals directly, enabling graceful shutdown (drain connections, finish in-flight requests). Ensure the app actually handles SIGTERM. - ⭐ Optimal (senior): Exec form + a real init as PID 1 —
tiniviaENTRYPOINT ["tini", "--"]ordocker run --init— to reap zombie children and forward signals correctly (important with gunicorn master/worker or subprocess spawns). Tune gunicorn--graceful-timeoutand the orchestrator’sterminationGracePeriodSecondsto match, and make workers finish in-flight requests before exit. This is what makes zero-downtime rolling deploys actually work.
Q6: When is Docker Compose the right tool, and where do depends_on / healthchecks bite you?
- ❌ Wrong / trap: “
depends_on: [db]guarantees Postgres is ready before the app starts.” — Fails: plaindepends_ononly waits for the container to start, not for the service to be ready to accept connections — the app boots and crashes on a connection refused. Also treating Compose as a production orchestrator is wrong. - ✅ Correct: Use
depends_onwithcondition: service_healthyplus a realhealthcheckon the dependency (pg_isready,redis-cli ping) so startup ordering respects readiness. Compose is for local dev / integration, orchestrating app + DB + cache + worker with named volumes and a user-defined network. - ⭐ Optimal (senior): Even with healthchecks, keep app-side retry/backoff on first connection (healthchecks reduce but don’t eliminate races, and prod orchestrators differ). Use Compose profiles/override files to separate dev from CI; named volumes for stateful services; a dedicated network for service discovery by name. Be explicit that production uses Kubernetes/ECS — Compose’s
depends_onsemantics and lack of self-healing/scaling make it dev-only.
Q7: Horizontal vs vertical scaling — how do you decide, and what’s the precondition for scaling out?
- ❌ Wrong / trap: “Just add more replicas, horizontal scaling always fixes load.” — Fails because it ignores the bottleneck dimension (a memory-bound or DB-bound service won’t improve by adding stateless replicas — it may worsen DB pressure) and ignores the statelessness precondition.
- ✅ Correct: First identify the saturated resource (CPU, memory, or request throughput/concurrency). Vertical = give the instance more CPU/memory or more workers (has a physical ceiling, no HA). Horizontal = more replicas behind a load balancer (near-linear, gives HA) — but only works if the service is stateless: session/state must live outside the process (Redis, DB, JWT). Local in-memory state breaks scale-out.
- ⭐ Optimal (senior): Real systems combine both — vertical to size each replica, horizontal for traffic. For an I/O-bound web service, CPU is a poor autoscaling signal; drive the HPA off a custom metric (RPS per pod, queue depth) instead of CPU. Watch the downstream: adding replicas multiplies DB connections, so tune the connection pool and consider a pooler (PgBouncer). Always pair scale-in with graceful shutdown so terminating pods don’t drop in-flight requests.
Q8: What’s the difference between a Pod, a Deployment, and a Service in Kubernetes?
- ❌ Wrong / trap: “A Pod is a container, a Deployment deploys it, and a Service is the app.” — Fails: conflates Pod with container, gives no role for the Service (stable networking), and misses that Deployments manage replica count and rollouts.
- ✅ Correct: A Pod is the smallest deployable unit — one or more containers sharing network/storage — and it’s ephemeral (can die and be recreated with a new IP). A Deployment declares the desired number of pod replicas and which image, and reconciles reality toward that (recreating dead pods, doing rolling updates/rollbacks). A Service gives a stable name and virtual IP that load-balances to the pods matching a label selector — solving the problem that pod IPs are ephemeral.
- ⭐ Optimal (senior): The Deployment owns a ReplicaSet that owns the Pods; rolling updates create a new ReplicaSet and shift pods over per
maxUnavailable/maxSurge. The Service selects pods by label and only routes to those passing their readiness probe, which is what makes rolling updates and autoscaling non-disruptive. External traffic reaches Services through an Ingress (L7 host/path routing + TLS) or a LoadBalancer Service (L4). Internally, Services are discoverable via cluster DNS (CoreDNS):service.namespace.svc.cluster.local.
Q9: Liveness vs readiness probes — what breaks if you get them wrong?
- ❌ Wrong / trap: “They’re the same health check, just point both at
/health.” — Fails: a slow dependency makes the shared check fail, liveness then restarts otherwise-healthy pods in a loop under load, turning a transient blip into an outage. - ✅ Correct: Liveness answers “is the process wedged? if yes, restart it.” Readiness answers “can this pod serve traffic right now? if no, remove it from the Service endpoints without killing it.” A booting or warming pod should be not ready but alive; a deadlocked process should fail liveness.
- ⭐ Optimal (senior): Keep liveness cheap and dependency-free (just “is the event loop responsive”) so a downstream outage doesn’t trigger mass restarts; put dependency checks (DB reachable, migrations applied) in readiness so unhealthy pods drain out of the LB but stay up to recover. Add a startup probe for slow-booting apps so liveness doesn’t fire during a long cold start. Getting this wrong causes CrashLoopBackOff cascades or traffic sent to cold pods.
Q10: How do you configure the same image for staging vs production?
- ❌ Wrong / trap: “Build a separate image per environment with the config baked in.” — Fails 12-factor: separate images per env means what you tested in staging is not the artifact you ship to prod, defeating reproducibility, and it tempts baking secrets into layers.
- ✅ Correct: Build one image and inject config at runtime via environment variables. Non-secret config comes from a ConfigMap (or
.env/override file in Compose), secrets from a Secret / secrets manager, and the app reads them (e.g.pydantic-settingswith a clear precedence: env vars > env file > code defaults). Promote the exact same image tag from staging to production. - ⭐ Optimal (senior): Back Secrets with an external manager (Vault / AWS Secrets Manager via CSI or External Secrets) instead of plaintext base64 in git; use per-env overlays (Kustomize/Helm values or Compose override files) for the differences only (replica count, resource limits, endpoints); validate config at startup and fail fast on missing/invalid values. The invariant: the image is environment-agnostic and immutable; only injected config differs.
Q11: How do you serve the service over HTTPS with a custom domain, and keep the cert from expiring?
- ❌ Wrong / trap: “Put the TLS cert file inside the image and terminate TLS in the app.” — Fails: certs in the image expire and force a rebuild+redeploy to rotate, they leak into image history, and terminating TLS in every pod is wasteful and hard to renew.
- ✅ Correct: Point a DNS record (
Ato the load balancer IP, orCNAMEto its hostname) at the Ingress/LB, and terminate TLS at the edge (Ingress controller / cloud LB), not in each pod. In Kubernetes, use cert-manager with a Let’s Encrypt ClusterIssuer: annotate the Ingress and declare atls:block, and cert-manager obtains and stores the cert in a Secret. - ⭐ Optimal (senior): The key word is automatic renewal — cert-manager renews before the 90-day Let’s Encrypt expiry, so certs never lapse. Choose the ACME challenge deliberately: HTTP-01 (simple, needs port 80 reachable) vs DNS-01 (works for wildcards and private LBs, needs DNS API access). Traffic is encrypted client→edge; inside the cluster it’s plaintext unless you add mTLS via a service mesh. Keep the DB and internal services on private subnets; only the LB faces the internet.
Q12: A request spans three services and is failing intermittently. How do you trace it, as a user of the platform?
- ❌ Wrong / trap: “Grep the logs of each service and eyeball the timestamps.” — Fails at any real scale: without a shared correlation ID you can’t tie a request’s log lines across services, and timestamps drift/interleave.
- ✅ Correct: Propagate a correlation/trace ID (generate at the edge/ingress if absent, e.g. an
X-Request-ID/traceparentheader, and forward it on every downstream call). Log it in structured (JSON) logs so you can filter one request across all services in the log aggregator (ELK/Loki/CloudWatch), and correlate with metrics/alerts for the same window. - ⭐ Optimal (senior): Use distributed tracing (OpenTelemetry) so the
traceparentheader stitches spans into one trace across services, giving per-hop latency and the failing span directly — logs alone only tell you where it logged, tracing tells you the path and timing. Instrument the outbound clients to inject context automatically, sample intelligently (tail-based sampling to keep the errored traces), and expose the trace ID in error responses so support can jump straight to the trace. Combine the three pillars — logs (what happened), metrics (how much/how often), traces (the request path) — rather than relying on any one.
Referencias
- Docker docs — Building best practices: https://docs.docker.com/build/building/best-practices/
- Docker docs — Multi-stage builds: https://docs.docker.com/build/building/multi-stage/
- Docker docs — BuildKit cache mounts / secret mounts: https://docs.docker.com/build/cache/optimize/
- Dockerfile reference (HEALTHCHECK, ENTRYPOINT/CMD exec form): https://docs.docker.com/reference/dockerfile/
- Compose file reference (depends_on, healthcheck): https://docs.docker.com/reference/compose-file/services/
- tini (init for containers): https://github.com/krallin/tini
- Distroless images: https://github.com/GoogleContainerTools/distroless
- Kubernetes — Deployments: https://kubernetes.io/docs/concepts/workloads/controllers/deployment/
- Kubernetes — Service & cluster DNS: https://kubernetes.io/docs/concepts/services-networking/service/
- Kubernetes — Ingress & TLS: https://kubernetes.io/docs/concepts/services-networking/ingress/
- Kubernetes — Liveness/readiness/startup probes: https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/
- Kubernetes — HorizontalPodAutoscaler: https://kubernetes.io/docs/tasks/run-application/horizontal-pod-autoscale/
- Kubernetes — ConfigMaps & Secrets: https://kubernetes.io/docs/concepts/configuration/configmap/
- cert-manager (automatic TLS with Let’s Encrypt): https://cert-manager.io/docs/
- AWS ECS (Fargate) developer guide: https://docs.aws.amazon.com/AmazonECS/latest/developerguide/Welcome.html
- The Twelve-Factor App (config): https://12factor.net/config
- OpenTelemetry (distributed tracing): https://opentelemetry.io/docs/
- gunicorn — design & worker settings: https://docs.gunicorn.org/en/stable/design.html