🎯 Meta: entender qué es Kubernetes, cuándo lo necesitas (y cuándo NO), y sus conceptos básicos. No te vamos a convertir en administrador de clústeres; te vamos a dar el mapa mental para que sepas de qué habla la gente y des tus primeros pasos.
Versión: Kubernetes 1.36 (jun-2026; releasea ~3 veces al año, soporta las 3 últimas minor durante ~14 meses — mantén tu clúster dentro de ese margen).
G.1 · ¿Qué problema resuelve? (y el aviso importante)
Docker Compose (cap. 12) corre tus contenedores en una máquina. Pero, ¿y si necesitas:
- Correr en muchas máquinas y repartir la carga entre ellas.
- Auto-escalar: más contenedores cuando hay tráfico, menos cuando no.
- Auto-reparar: si un contenedor (o una máquina) muere, que se reemplace solo.
- Desplegar sin downtime y hacer rollback automático si algo falla.
Kubernetes (k8s) orquesta contenedores a gran escala haciendo todo eso automáticamente.
⚠️ EL AVISO MÁS IMPORTANTE DE ESTE LIBRO SOBRE INFRAESTRUCTURA: Kubernetes es complejo. Muy complejo. La inmensa mayoría de proyectos no lo necesitan y adoptarlo demasiado pronto es un error clásico que hunde equipos en complejidad accidental. Para casi todo, un VPS con Docker Compose + Nginx (caps. 12-14), o una plataforma gestionada (Coolify, Railway, Cloud Run), es más que suficiente y muchísimo más simple.
Necesitas Kubernetes cuando: tienes muchos servicios, tráfico alto y variable, varios equipos, y requisitos serios de disponibilidad. Aprende que existe y cómo funciona; adóptalo solo cuando el dolor real lo justifique.
G.2 · El modelo mental
Kubernetes es un sistema de control declarativo: tú describes el estado deseado ("quiero 3 copias de mi API corriendo siempre") y k8s trabaja sin parar para que la realidad coincida. Si una copia muere, la recrea. Tú no le dices cómo, le dices qué quieres.
Tú declaras: "quiero 3 réplicas de la API v2"
▼
┌─────────────────── CLÚSTER ───────────────────┐
│ Control Plane (el cerebro) │
│ ├─ API Server (recibe tus órdenes) │
│ ├─ Scheduler (decide dónde va cada pod) │
│ └─ Controllers (vigilan que se cumpla) │
│ │
│ Nodos (las máquinas que ejecutan) │
│ ├─ Nodo 1: [pod][pod] │
│ ├─ Nodo 2: [pod] │
│ └─ Nodo 3: [pod][pod] │
└──────────────────────────────────────────────┘
Si un pod muere → k8s crea otro automáticamenteG.3 · Conceptos que debes conocer
| Concepto | Qué es | Analogía |
|---|---|---|
| Pod | La unidad mínima: 1+ contenedores juntos | Un contenedor "envuelto" |
| Deployment | Gestiona réplicas de pods y actualizaciones | "quiero N copias siempre vivas" |
| Service | Punto de acceso estable a un grupo de pods | Un balanceador interno con DNS |
| Ingress | Enruta tráfico externo a los services (HTTP/HTTPS) | El Nginx del clúster |
| ConfigMap | Configuración no sensible | Variables de entorno |
| Secret | Datos sensibles (contraseñas, tokens) | .env gestionado |
| Namespace | Separación lógica dentro del clúster | Carpetas para organizar |
| Node | Una máquina (física o virtual) del clúster | Un servidor |
🧠 La jerarquía: un Deployment mantiene varios Pods (réplicas de tu app); un Service les da una dirección estable (los pods van y vienen, el service no); un Ingress lleva el tráfico de internet a los services. De fuera hacia dentro: Ingress → Service → Pod → contenedor.
G.4 · Tu primer Deployment (YAML)
Kubernetes se configura con YAML declarativo. Un Deployment de tu API:
# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: tienda-api
spec:
replicas: 3 # quiero 3 copias siempre
selector:
matchLabels:
app: tienda-api
template:
metadata:
labels:
app: tienda-api
spec:
containers:
- name: api
image: ghcr.io/miorg/tienda-api:v2 # tu imagen (cap. 12)
ports:
- containerPort: 8000
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: db-secret
key: url
resources: # límites de recursos (importante)
requests: { cpu: "100m", memory: "128Mi" }
limits: { cpu: "500m", memory: "512Mi" }
livenessProbe: # ¿está vivo? si no, lo reinicia
httpGet: { path: /salud, port: 8000 }
initialDelaySeconds: 10
readinessProbe: # ¿listo para recibir tráfico?
httpGet: { path: /salud, port: 8000 }
startupProbe: # da tiempo a apps LENTAS de arrancar antes de que liveness actúe
httpGet: { path: /salud, port: 8000 }
failureThreshold: 30 # hasta 30 × periodSeconds antes de rendirse
periodSeconds: 2💡
startupProbeevita el ciclo de reinicios de apps lentas al arrancar: sin ella, si tu app tarda 40s en estar lista (migraciones, caché en calentamiento) y ellivenessProbetiene uninitialDelaySecondscorto, k8s la mata y reinicia antes de que termine de arrancar — un bucle infinito deCrashLoopBackOffque no es un bug real. MientrasstartupProbeno pase,livenessProbe/readinessProbeno se evalúan.
Y el Service que le da acceso estable:
# service.yaml
apiVersion: v1
kind: Service
metadata:
name: tienda-api
spec:
selector:
app: tienda-api
ports:
- port: 80
targetPort: 8000kubectl apply -f deployment.yaml -f service.yaml # aplicar el estado deseado
kubectl get pods # ver los pods
kubectl logs <pod> # logs de un pod
kubectl scale deployment tienda-api --replicas=5 # escalar a 5
kubectl rollout undo deployment tienda-api # rollback a la versión anterior💡
livenessProbeyreadinessProbeson clave: la liveness reinicia un pod colgado; la readiness evita mandarle tráfico hasta que esté listo (ej. terminó de conectar a la BD). Por eso tu app necesita un endpoint/salud(cap. 14) — Kubernetes lo usa para saber cómo estás.
Despliegues sin downtime (rolling update)
Cuando cambias la imagen (v2 → v3), el Deployment hace un rolling update: levanta pods nuevos, espera a que su readiness pase, y solo entonces mata los viejos. Si la v3 no arranca, el tráfico sigue en la v2 — por eso las probes no son opcionales.
kubectl set image deployment/tienda-api api=ghcr.io/miorg/tienda-api:v3
kubectl rollout status deployment/tienda-api # observa el progreso
kubectl rollout history deployment/tienda-api # versiones anteriores
kubectl rollout undo deployment/tienda-api # rollback en un comando# Control fino del rolling update (dentro de spec:)
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1 # cuántos pods extra puede crear a la vez
maxUnavailable: 0 # cuántos pueden faltar (0 = siempre a plena capacidad)G.5 · Configuración: ConfigMaps y Secrets
La config vive fuera de la imagen (misma regla de los 12 factores del cap. 12):
# configmap.yaml — configuración NO sensible
apiVersion: v1
kind: ConfigMap
metadata:
name: tienda-config
data:
LOG_LEVEL: "info"
CACHE_TTL: "300"# Secret — datos sensibles (crea mejor por comando, no lo subas a git en claro)
kubectl create secret generic db-secret \
--from-literal=url='postgres://user:pass@db:5432/tienda'# En el Deployment: inyectar TODO el ConfigMap como variables de entorno
containers:
- name: api
envFrom:
- configMapRef: { name: tienda-config }
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef: { name: db-secret, key: url }⚠️ Los Secrets de k8s van en base64, NO cifrados. Cualquiera con acceso al clúster los lee. Para producción seria: cifrado at rest activado, RBAC restrictivo, y/o un gestor externo (External Secrets Operator + Vault/AWS Secrets Manager). Y jamás secrets en claro en git — si necesitas versionarlos, Sealed Secrets o SOPS.
G.6 · Ingress: la puerta de entrada HTTP
El Service es interno. Para exponer HTTP/HTTPS al mundo con dominios y TLS usas un Ingress (necesita un ingress controller instalado — el más común es ingress-nginx):
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: tienda
annotations:
cert-manager.io/cluster-issuer: letsencrypt # TLS automático con cert-manager
spec:
ingressClassName: nginx
tls:
- hosts: [api.mitienda.com]
secretName: tienda-tls
rules:
- host: api.mitienda.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: tienda-api
port: { number: 80 }Internet ──▶ Ingress (nginx + TLS) ──▶ Service ──▶ Pods
api.mitienda.com DNS interno: tienda-api💡 DNS interno gratis: cada Service tiene nombre DNS dentro del clúster. Tu API se conecta a PostgreSQL con
postgres://db:5432si el Service se llamadb— igual que los nombres de servicio en Docker Compose (cap. 12). El futuro de esta pieza es la Gateway API (sucesora del Ingress); conceptualmente es lo mismo con más control.
G.7 · Depurar en Kubernetes (los comandos que usarás de verdad)
kubectl get pods -o wide # estado, restarts, nodo, IP
kubectl describe pod <pod> # eventos: por qué no arranca (¡lee el final!)
kubectl logs <pod> -f # logs en vivo
kubectl logs <pod> --previous # logs del contenedor ANTERIOR (tras un crash)
kubectl exec -it <pod> -- sh # shell dentro del pod
kubectl port-forward svc/tienda-api 8080:80 # probar el service desde tu máquina
kubectl get events --sort-by=.lastTimestamp # qué está pasando en el clúster
kubectl top pods # CPU/memoria (requiere metrics-server)Los tres estados de error que verás mil veces:
| Estado | Significa | Primer paso |
|---|---|---|
CrashLoopBackOff | El contenedor arranca y muere en bucle | kubectl logs <pod> --previous |
ImagePullBackOff | No puede descargar la imagen | ¿nombre/tag correcto? ¿registry privado sin credenciales? |
Pending | No hay nodo donde colocarlo | describe pod: ¿recursos insuficientes? |
G.8 · Auto-escalado
Kubernetes puede añadir/quitar réplicas según la carga (HPA — Horizontal Pod Autoscaler):
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: tienda-api
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: tienda-api
minReplicas: 2
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70 # si la CPU media pasa del 70%, añade pods🔗 El auto-escalado se apoya en métricas (apéndice F). Kubernetes y la observabilidad van de la mano: para escalar bien, primero tienes que medir bien.
G.9 · Otras piezas que te cruzarás
| Recurso | Para qué | Ejemplo |
|---|---|---|
| StatefulSet | Apps con estado e identidad estable (BD, colas) | PostgreSQL, Kafka |
| DaemonSet | Un pod en cada nodo | Agente de logs/métricas |
| Job | Tarea que corre hasta terminar | Migración de BD |
| CronJob | Job programado | Backup nocturno |
| PersistentVolumeClaim | Disco persistente para un pod | Datos de PostgreSQL |
# CronJob: el backup del cap. 14, versión k8s
apiVersion: batch/v1
kind: CronJob
metadata: { name: backup-db }
spec:
schedule: "0 3 * * *" # cron normal: 03:00 cada día
jobTemplate:
spec:
template:
spec:
restartPolicy: OnFailure
containers:
- name: backup
image: postgres:18-alpine
command: ["/bin/sh", "-c", "pg_dump $DATABASE_URL | gzip > /backup/$(date +%F).sql.gz"]⚠️ ¿Bases de datos DENTRO de k8s? Se puede (StatefulSet + PVC + operadores como CloudNativePG), pero el estado es la parte más delicada de k8s. Si estás empezando: base de datos gestionada fuera del clúster y dentro solo lo stateless. Dormirás mejor.
El patrón Operator: automatizar lo que un DBA haría a mano
Un Operator es software que extiende k8s con conocimiento específico de una aplicación: en vez de que tú ejecutes pg_dump, promuevas una réplica o apliques un parche de seguridad a mano, el Operator observa un recurso personalizado (CRD) y ejecuta esas operaciones él solo.
# Con CloudNativePG instalado, "pedir" un clúster PostgreSQL de 3 nodos es esto:
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata: { name: tienda-db }
spec:
instances: 3 # 1 primario + 2 réplicas, el Operator gestiona el failover
storage: { size: 20Gi }
backup:
barmanObjectStore: { destinationPath: "s3://mis-backups/tienda" }🧠 La idea central: un Deployment normal solo sabe "mantén N copias de este contenedor". Un Operator para PostgreSQL sabe qué significa un clúster de PostgreSQL sano: cómo promover una réplica a primario si el líder muere, cómo hacer un backup consistente, cómo aplicar un upgrade de versión sin perder datos. Es la diferencia entre k8s genérico y k8s que entiende tu aplicación con estado — por eso StatefulSet + un buen Operator es la forma real de correr BDs en el clúster, nunca un StatefulSet a pelo.
G.10 · Helm y Kustomize: no repitas YAML
Con 3 entornos (dev/staging/prod) no copias YAML 3 veces:
- Kustomize (integrado en kubectl): una base + parches por entorno.
kubectl apply -k overlays/prod - Helm: plantillas con variables (
values.yaml) + gestor de paquetes para instalar software de terceros:
helm repo add bitnami https://charts.bitnami.com/bitnami
helm install redis bitnami/redis # Redis completo en el clúster en un comando
helm upgrade tienda ./chart -f values-prod.yaml💡 Regla práctica: Kustomize para TUS apps (simple, sin plantillas), Helm para instalar software de terceros (ingress-nginx, cert-manager, Prometheus…). GitOps (ArgoCD/Flux) los despliega desde git automáticamente — el CD del cap. 14 llevado al clúster.
G.11 · Herramientas del ecosistema
| Necesito... | Herramienta |
|---|---|
| Probar k8s en mi portátil | kind, minikube, k3d (clúster local) |
| Empaquetar apps k8s reutilizables | Helm (el "gestor de paquetes" de k8s) |
| GitOps (desplegar desde git) | ArgoCD, Flux |
| k8s ligero (edge, IoT, VPS pequeño) | k3s |
| k8s gestionado (sin administrar el control plane) | GKE, EKS, AKS, DigitalOcean |
💡 Si de verdad vas a usar Kubernetes, usa uno gestionado (GKE/EKS/AKS). Administrar tu propio control plane es un trabajo a tiempo completo. Los gestionados se encargan de la parte difícil y tú solo despliegas.
G.12 · El camino de crecimiento (dónde encaja k8s)
1 servidor, 1 app → VPS + systemd o Docker (caps. 12, 14)
1 servidor, varias apps → Docker Compose + Nginx (caps. 12, 13)
Quiero simplicidad + CD → Coolify / Dokku / Railway / Cloud Run
Escala real, multi-nodo → Kubernetes (gestionado) ← SOLO cuando lo necesitas🧠 La regla de oro (una vez más): no saltes a Kubernetes por moda o por el CV. Cada escalón añade complejidad; súbelo solo cuando el anterior se te quede pequeño de verdad. Un monolito (cap. 11) en un VPS con Docker ha llevado a muchísimas empresas muy lejos. Conocer k8s te hace mejor ingeniero; usarlo sin necesidad te hace la vida imposible.
G.13 · NetworkPolicy y PodDisruptionBudget: aislar y proteger
Por defecto, en Kubernetes cualquier pod puede hablar con cualquier otro pod del clúster. Un NetworkPolicy restringe eso — el firewall del cap. 13, pero entre pods:
# 1. Denegar TODO el tráfico entrante por defecto en el namespace (política base)
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: negar-todo }
spec:
podSelector: {} # aplica a TODOS los pods del namespace
policyTypes: [Ingress]
---
# 2. Permitir SOLO que la API hable con la base de datos (nada más)
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: permitir-api-a-db }
spec:
podSelector:
matchLabels: { app: postgres }
policyTypes: [Ingress]
ingress:
- from:
- podSelector: { matchLabels: { app: tienda-api } }
ports:
- port: 5432🧠 "Denegar todo" primero, luego permitir explícitamente es la misma filosofía de whitelist del apéndice C (validación de entrada): en vez de listar lo prohibido, declaras lo permitido y todo lo demás queda bloqueado por defecto. Sin
NetworkPolicy, si un atacante compromete un pod cualquiera (una dependencia vulnerable, ap. C.10), puede escanear y tocar cualquier otro servicio del clúster, incluida tu base de datos.
PodDisruptionBudget (PDB): protege tus réplicas durante mantenimiento planificado del clúster (actualizar un nodo, vaciarlo con kubectl drain) — distinto de un crash (G.4):
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata: { name: tienda-api-pdb }
spec:
minAvailable: 2 # nunca menos de 2 pods vivos durante un drenado planificado
selector:
matchLabels: { app: tienda-api }⚠️ Sin PDB, un
kubectl drain(mantenimiento normal de un nodo) puede tumbar TODAS tus réplicas a la vez si por mala suerte están todas en el mismo nodo — el PDB obliga al proceso de drenado a esperar y desalojar de a poco, respetando el mínimo. Para una base de datos con quorum (3 réplicas de Postgres, StatefulSet de G.9), usamaxUnavailable: 1: nunca más de una fuera a la vez, o pierdes el quorum.
💡 Regla práctica: define un PDB para todo servicio de producción, incluso los que no necesitan alta disponibilidad real — en ese caso,
maxUnavailable: 100%documenta explícitamente "este servicio tolera caerse entero durante mantenimiento", en vez de dejarlo como un olvido. Es más barato declararlo que descubrir el problema en el peor momento.
✅ Ejercicio del apéndice
1. Instala kind o minikube y levanta un clúster local.
2. Dockeriza una de tus APIs (cap. 12) y súbela a un registry.
3. Escribe un Deployment (3 réplicas) + Service + liveness/readiness probes.
Usa tu endpoint /salud.
4. `kubectl apply`, comprueba los 3 pods, y mata uno a mano (`kubectl delete pod`).
Observa cómo k8s crea otro solo.
5. Añade un ConfigMap (LOG_LEVEL) y un Secret (DATABASE_URL) e inyéctalos como env.
6. Escala a 5 réplicas y luego configura un HPA por CPU.
7. Haz un despliegue de una v2, observa el rolling update con `rollout status`,
despliega una v3 rota a propósito (imagen inexistente) y arréglalo con `rollout undo`.
8. Provoca un CrashLoopBackOff (comando que falle) y diagnostícalo con
describe + logs --previous.
9. Instala ingress-nginx con Helm y expón tu API con un Ingress en local.
10. Añade un NetworkPolicy que solo permita a tu API hablar con tu base de datos
(deniega todo lo demás) y un PodDisruptionBudget con minAvailable acorde a
tus réplicas. Provoca un `kubectl drain` del nodo y observa cómo el PDB lo frena.
11. Reflexiona honestamente: ¿tu proyecto real necesita esto, o Docker Compose basta?💡 Pistas de la solución
- Si
kindno arranca por falta de recursos, pruebak3d— el mismo modelo mental con menos consumo de RAM en portátiles modestos. - El
readinessProbees la pieza que suele fallar primero: si tu endpoint/saludtarda en responder mientras la app aún conecta a la BD, k8s le mandará tráfico antes de tiempo. SubeinitialDelaySecondso haz que/saluddevuelva 503 hasta que la conexión esté lista. - Para el
CrashLoopBackOffprovocado a propósito, el truco más simple es uncommandque hagaexit 1— verás el ciclo de reinicios y aprenderás a leerdescribe podbajo presión. - El Ingress con
ingress-nginxen local necesita que tu clúster (kind) tenga los puertos 80/443 mapeados al host — revisa la documentación dekindsobreextraPortMappings.
🧠 Autoevaluación
Sin límites, un pod puede consumir toda la CPU/memoria del nodo y afectar a otros pods que comparten esa máquina ("noisy neighbor"). Los requests/limits (G.4) le dicen al scheduler cuánto reservar y evitan que un pod descontrolado tumbe a los demás.
Que el tag de la imagen no existe en el registry (typo, o el build de CI aún no terminó), o que el registry es privado y el clúster no tiene las credenciales (imagePullSecrets) configuradas. kubectl describe pod muestra el mensaje exacto del intento de pull fallido.
Con maxUnavailable: 0, k8s nunca reduce la capacidad actual: solo añade pods nuevos (maxSurge) y espera a que pasen la readiness antes de matar uno viejo. Es más lento porque no puede avanzar en paralelo tan agresivamente, pero garantiza que el servicio nunca sirve con menos réplicas de las declaradas.
Base64 es codificación, no cifrado — se decodifica con un comando de una línea (echo ... | base64 -d). Cualquiera con permiso de lectura sobre el Secret (o acceso al etcd del clúster) ve el valor real. La seguridad real viene de RBAC restrictivo, cifrado at rest, y gestores externos como Vault.
Para empezar, fuera y gestionada (RDS, Cloud SQL, DigitalOcean Managed Databases). El estado es la parte más delicada de operar en k8s — StatefulSet + PersistentVolumeClaim funciona, pero exige entender backups, failover y operadores especializados (CloudNativePG) antes de confiar en ello en producción.
Por defecto, cualquier pod del clúster puede conectarse a cualquier otro — el atacante puede escanear y tocar directamente tu base de datos, tu Redis o cualquier servicio interno desde ese pod comprometido, sin pasar por ningún control adicional. Un NetworkPolicy de "denegar todo por defecto, permitir explícitamente" (G.13) limita el movimiento lateral exactamente igual que un firewall entre servidores tradicionales.
Si las 3 réplicas (o varias) terminan programadas en el mismo nodo por casualidad, el drenado puede desalojarlas todas a la vez y dejar el servicio caído momentáneamente, aunque el Deployment las vuelva a crear después en otro nodo. Un PDB con minAvailable/maxUnavailable obliga al proceso de drenado a respetar un mínimo de réplicas vivas en todo momento, incluso durante mantenimiento planificado.
Un StatefulSet solo mantiene pods con identidad y almacenamiento estables — no sabe qué es un primario, una réplica o un backup consistente. Un Operator (como CloudNativePG) entiende la semántica específica de PostgreSQL: promueve una réplica si el líder cae, orquesta backups y aplica upgrades sin perder datos. Es la pieza que falta entre "contenedores con disco" y "una base de datos gestionada de verdad".
startupProbe: mientras no pase, livenessProbe no se evalúa, así que k8s no mata el pod por tardar en arrancar. Sin ella, un initialDelaySeconds corto en el livenessProbe reinicia el pod antes de que termine de inicializarse, entrando en un bucle de reinicios que no refleja ningún fallo real.
Volver al: README.md · Relacionado: 12-docker.md, F-observabilidad.md, 11-arquitectura.md