Contenedores y Kubernetes
"Contenedores: 'funciona en mi máquina' es ahora 'funciona donde sea'."
Qué vas a aprender en este capítulo
Docker revolucionó el deploy de software al estandarizar cómo se empaqueta una aplicación. Kubernetes (K8s) revolucionó cómo se ejecutan miles de contenedores en producción. Este capítulo cubre ambos.
3.1 Contenedores con Docker
💡 Intuición
Una imagen Docker es una "receta" inmutable que contiene el OS mínimo, dependencias y tu aplicación. Un contenedor es una instancia ejecutándose de esa imagen.
Diferencia con VMs: las VMs virtualizan hardware (cada VM tiene su kernel). Los contenedores comparten el kernel del host pero aíslan procesos, redes y filesystems → mucho más livianos (MB vs GB) y rápidos (segundos vs minutos para arrancar).
📐 Fundamento
Dockerfile multi-stage para FastAPI:
# Stage 1: builder
FROM python:3.12-slim AS builder
WORKDIR /build
COPY requirements.txt .
RUN pip wheel --no-cache-dir --no-deps --wheel-dir /build/wheels -r requirements.txt
# Stage 2: runtime
FROM python:3.12-slim
# Crear usuario no-root por seguridad
RUN useradd --create-home --uid 1000 app
WORKDIR /app
USER app
# Copiar solo wheels desde builder (sin compilers, gcc, etc.)
COPY --from=builder --chown=app:app /build/wheels /tmp/wheels
RUN pip install --user --no-cache-dir /tmp/wheels/*.whl && rm -rf /tmp/wheels
COPY --chown=app:app . .
ENV PATH="/home/app/.local/bin:$PATH"
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s \
CMD curl -f http://localhost:8000/health || exit 1
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
Comandos esenciales:
# Build
docker build -t la-esquina-api:1.0 .
# Ejecutar
docker run -d -p 8000:8000 \
-e DATABASE_URL=postgresql://... \
--name api la-esquina-api:1.0
# Ver logs
docker logs -f api
# Entrar a un container
docker exec -it api /bin/bash
# Listar
docker ps # corriendo
docker images # imágenes
docker system df # uso de disco
# Limpiar
docker system prune -a # eliminar todo lo no usado
docker-compose para entornos multi-servicio:
# docker-compose.yml
version: '3.8'
services:
api:
build: .
ports: ["8000:8000"]
environment:
DATABASE_URL: postgresql://user:pass@db:5432/esquina
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: esquina
POSTGRES_USER: user
POSTGRES_PASSWORD: pass
volumes: [pgdata:/var/lib/postgresql/data]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U user -d esquina"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
command: redis-server --requirepass ${REDIS_PASSWORD}
volumes:
pgdata:
docker-compose up -d # iniciar todo
docker-compose logs -f api # logs de un servicio
docker-compose down -v # parar y eliminar volúmenes
Mejores prácticas para imágenes:
| Práctica | Por qué |
|---|---|
| Usar imágenes base oficiales | Mantenidas con parches de seguridad |
Usar -slim o -alpine |
10x menos tamaño |
| Multi-stage builds | Sin compilers en imagen final |
| Usuario no-root | Seguridad |
.dockerignore |
No incluir node_modules, .git, etc. |
Pinear versiones (python:3.12.1) |
Builds reproducibles |
| HEALTHCHECK | Para que el orquestador sepa si está sano |
| No incluir secretos | Usar variables de entorno o secret managers |
| Cachear layers | Copiar requirements.txt antes que el código |
Container registries:
- Docker Hub — público, gratuito (con límites).
- GitHub Container Registry (ghcr.io) — gratis para repos públicos.
- AWS ECR / GCP Artifact Registry / Azure Container Registry — privados, integrados con su cloud.
3.2 ¿Qué es Kubernetes?
💡 Intuición
Tenés 100 contenedores corriendo. ¿Quién los reinicia si caen? ¿Quién distribuye carga entre ellos? ¿Quién los actualiza sin downtime? ¿Quién los re-ubica cuando un servidor falla?
Kubernetes hace todo eso automáticamente. Sos vos quien declara: "quiero 5 réplicas de este contenedor con 1GB de RAM cada una". K8s se encarga del resto: dónde correrlas, cómo distribuir tráfico, qué hacer si una falla.
📐 Fundamento
Arquitectura de un clúster:
┌─────────────────────────────────────────┐
│ Control Plane (Master) │
│ - API Server (kubectl habla con esto) │
│ - etcd (state del clúster, vía Raft) │
│ - Scheduler (decide dónde correr Pods) │
│ - Controller Manager (mantiene estado) │
└───────────────┬─────────────────────────┘
│
┌────────────┼────────────┐
▼ ▼ ▼
[Node 1] [Node 2] [Node 3] ← worker nodes
- kubelet - kubelet - kubelet (agente que ejecuta Pods)
- kube- - kube- - kube-
proxy proxy proxy
- Pods - Pods - Pods
Conceptos clave:
| Recurso | Qué es |
|---|---|
| Pod | Una o más containers que comparten red y storage. La unidad mínima |
| Deployment | Define cuántas réplicas de un Pod, cómo hacer rolling updates |
| Service | Endpoint de red estable para acceder a Pods |
| Ingress | Routing HTTP/HTTPS desde fuera del clúster |
| ConfigMap | Configuración no-secreta |
| Secret | Datos sensibles (passwords, API keys) |
| Namespace | Aislamiento lógico (dev, prod, equipos) |
| PersistentVolume | Storage persistente |
| HorizontalPodAutoscaler | Autoescala Pods según métricas |
3.3 Desplegar una app a K8s
📐 Fundamento
Deployment + Service para FastAPI:
# api-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: api
labels: { app: api }
spec:
replicas: 3
selector:
matchLabels: { app: api }
template:
metadata:
labels: { app: api }
spec:
containers:
- name: api
image: ghcr.io/la-esquina/api:1.2.0
ports:
- containerPort: 8000
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: db-credentials
key: url
- name: ENV
value: production
resources:
requests: { memory: "256Mi", cpu: "250m" }
limits: { memory: "512Mi", cpu: "500m" }
livenessProbe:
httpGet: { path: /health, port: 8000 }
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet: { path: /ready, port: 8000 }
initialDelaySeconds: 5
periodSeconds: 5
---
# api-service.yaml
apiVersion: v1
kind: Service
metadata:
name: api-service
spec:
selector: { app: api }
ports:
- port: 80
targetPort: 8000
type: ClusterIP # solo accesible dentro del clúster
---
# api-ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: api-ingress
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
tls:
- hosts: [api.la-esquina.com]
secretName: api-tls
rules:
- host: api.la-esquina.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: api-service
port: { number: 80 }
Aplicar y gestionar:
# Aplicar manifests
kubectl apply -f api-deployment.yaml -f api-service.yaml -f api-ingress.yaml
# Ver estado
kubectl get pods # Pods corriendo
kubectl get deployments,services,ingress # todo lo del namespace actual
kubectl describe pod api-7d4b... # detalles de un Pod
kubectl logs -f api-7d4b... # logs en vivo
kubectl exec -it api-7d4b... -- /bin/bash # entrar a un Pod
# Escalar manualmente
kubectl scale deployment api --replicas=10
# Rolling update (cambiar imagen)
kubectl set image deployment/api api=ghcr.io/la-esquina/api:1.2.1
# Ver rollout
kubectl rollout status deployment/api
kubectl rollout history deployment/api
kubectl rollout undo deployment/api # rollback
# Eliminar
kubectl delete -f api-deployment.yaml
Autoscaling:
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata: { name: api-hpa }
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: api
minReplicas: 3
maxReplicas: 50
metrics:
- type: Resource
resource:
name: cpu
target: { type: Utilization, averageUtilization: 70 }
K8s autoescalará entre 3 y 50 réplicas para mantener CPU promedio en 70%.
Secrets:
# Crear secret desde literales
kubectl create secret generic db-credentials \
--from-literal=url='postgresql://user:pass@host/db'
# O desde archivo
kubectl create secret generic api-keys --from-file=./keys.json
Para producción usar:
- Sealed Secrets o External Secrets Operator — secrets cifrados en Git.
- HashiCorp Vault — secret management completo.
- AWS Secrets Manager / GCP Secret Manager — secret managers cloud-native.
3.4 Helm — el package manager de K8s
📐 Fundamento
Helm empaqueta apps K8s en charts parametrizables. Como apt-get o npm para Kubernetes.
# Estructura de un chart
mi-chart/
├── Chart.yaml # metadata
├── values.yaml # valores por default
└── templates/
├── deployment.yaml
├── service.yaml
└── ingress.yaml
templates/deployment.yaml usa template syntax:
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Release.Name }}-api
spec:
replicas: {{ .Values.replicaCount }}
template:
spec:
containers:
- name: api
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
resources:
{{- toYaml .Values.resources | nindent 10 }}
values.yaml:
replicaCount: 3
image:
repository: ghcr.io/la-esquina/api
tag: 1.2.0
resources:
requests: { memory: "256Mi", cpu: "250m" }
Instalar:
helm install la-esquina ./mi-chart
helm install la-esquina ./mi-chart --set replicaCount=10
helm install la-esquina ./mi-chart -f values-prod.yaml
# Upgrade
helm upgrade la-esquina ./mi-chart --set image.tag=1.2.1
# Rollback
helm rollback la-esquina
# Charts públicos
helm repo add bitnami https://charts.bitnami.com/bitnami
helm install postgres bitnami/postgresql
3.5 Kubernetes managed: EKS, GKE, AKS
📐 Fundamento
Operar el control plane de K8s manualmente es complejo (etcd, certificados, upgrades). Los proveedores cloud lo gestionan por vos.
| Servicio | Cloud | Pricing |
|---|---|---|
| EKS (Elastic Kubernetes Service) | AWS | $0.10/h por clúster + nodos EC2 |
| GKE (Google Kubernetes Engine) | GCP | Free tier: 1 zonal cluster gratis |
| AKS (Azure Kubernetes Service) | Azure | Control plane gratis |
Crear EKS con Terraform:
module "eks" {
source = "terraform-aws-modules/eks/aws"
version = "~> 19.0"
cluster_name = "la-esquina-prod"
cluster_version = "1.29"
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnets
eks_managed_node_groups = {
workers = {
min_size = 2
max_size = 10
desired_size = 3
instance_types = ["t3.medium"]
}
}
}
# Configurar kubectl
output "kubectl_config" {
value = "aws eks update-kubeconfig --name ${module.eks.cluster_name}"
}
Alternativas más simples a Kubernetes (cuando es overkill):
- AWS ECS / Fargate — orquestación más simple, integrada con AWS.
- Google Cloud Run — contenedores serverless.
- Fly.io / Railway — deploy de contenedores sin gestionar nada.
- Docker Swarm — orquestación incluida en Docker (menos features que K8s).
Cuándo NO usar Kubernetes:
- Equipo pequeño (< 10 ingenieros).
- App monolítica simple.
- Sin necesidad de escalado complejo.
K8s tiene una curva de aprendizaje empinada y costos operacionales altos. Para muchas apps, Cloud Run o Fly.io son suficientes.
3.6 Ejercicios
✏️ Ejercicio 3.1 — Optimizar Dockerfile
El siguiente Dockerfile produce una imagen de 1.2GB y tarda 8 minutos en construirse. Identificá los problemas y proponé una versión optimizada.
FROM python:3.12
COPY . /app
WORKDIR /app
RUN apt-get update && apt-get install -y gcc make
RUN pip install -r requirements.txt
RUN pytest
EXPOSE 8000
CMD python -m uvicorn main:app --host 0.0.0.0 --port 8000
Solución
Problemas:
python:3.12es la imagen completa (~1GB). Usarpython:3.12-slim(~150MB).COPY . /appantes de instalar deps invalida el caché de pip al menor cambio.gccymakese instalan pero no se necesitan en runtime.pytestcorre en cada build — debería estar en CI, no en el Dockerfile.- No se usan multi-stage builds.
- No hay usuario no-root.
- No hay
HEALTHCHECK. apt-get updatecachea la lista; siempre limpiar después.
Versión optimizada:
# Stage 1: builder con compilers
FROM python:3.12-slim AS builder
RUN apt-get update && apt-get install -y --no-install-recommends gcc \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /build
COPY requirements.txt .
RUN pip wheel --no-cache-dir --no-deps --wheel-dir /build/wheels -r requirements.txt
# Stage 2: runtime sin compilers
FROM python:3.12-slim
RUN useradd --create-home --uid 1000 app
WORKDIR /app
USER app
# Layer 1: instalar deps (cacheado mientras requirements.txt no cambie)
COPY --from=builder --chown=app:app /build/wheels /tmp/wheels
RUN pip install --user --no-cache-dir /tmp/wheels/*.whl && rm -rf /tmp/wheels
# Layer 2: copiar código (cambia frecuentemente)
COPY --chown=app:app . .
ENV PATH="/home/app/.local/bin:$PATH"
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s \
CMD curl -f http://localhost:8000/health || exit 1
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
Resultado esperado:
- Tamaño: ~200MB (5x menos).
- Build time: 1-2 minutos en cambios de código (caché de deps).
- Más seguro: no-root, sin compilers en runtime.
3.7 Para profundizar
- Kubernetes Documentation — kubernetes.io/docs (tutoriales excelentes).
- The Kubernetes Book — Nigel Poulton.
- kelseyhightower/kubernetes-the-hard-way — para entender los componentes a fondo.
- Siguiente: Serverless.
3.8 Errores comunes
⚠️ Trampa común
Crear un Pod directamente en producción. kubectl run crea un Pod suelto. Si el nodo se cae o el Pod muere, no se reinicia: Kubernetes no sabe que debe haber uno corriendo. Solo Deployment (o StatefulSet, DaemonSet) garantiza que siempre haya réplicas.
Tip: Pods sueltos son útiles para debugging (kubectl run debug --rm -it --image=busybox). Para cualquier carga real: siempre Deployment.
⚠️ Trampa común
Usar la etiqueta :latest en imágenes. nginx:latest parece simple, pero hace tu deploy no reproducible: el "latest" de hoy es distinto al de mañana. Peor: kubectl rollout restart puede traer una versión nueva sin que vos lo decidas, y romper producción a las 3 a.m.
Tip: siempre fijar tag específico (nginx:1.27.1-alpine) o, mejor, usar digest (nginx@sha256:abc...) que es inmutable. Los pipelines de CI deben taggear con SHA del commit o semver.
Definiciones nuevas: contenedor, imagen, Dockerfile, multi-stage build, registry, docker-compose, Kubernetes, control plane, node, kubelet, Pod, Deployment, Service, Ingress, ConfigMap, Secret, namespace, HorizontalPodAutoscaler, Helm, chart, EKS, GKE, AKS.