Tienes los conceptos y un clúster kind con un Redis suelto. Ahora va la plataforma entera: los cuatro servicios de Aurora Libros con manifiestos reales, las sondas de 06-01, el endurecimiento de 05-03 y la imagen que publicó tu pipeline en 06-02. Al final, /libros devolverá los nueve títulos desde dentro del clúster.
Contenido
- Organización de
k8s/y elNamespace aurora-cache: Deployment y Service- Por qué
aurora-dbes unStatefulSet - El
StatefulSetconvolumeClaimTemplates - Service headless y el
ConfigMapconinit.sql aurora-api: imagen por digest eimagePullSecrets- Las tres sondas en el manifiesto
requests,limitsy las clases de QoSsecurityContextde Pod y de contenedor- Configuración con
ConfigMapySecret aurora-web: Deployment, Service eIngress- TLS con cert-manager
- Kustomize: bases y overlays por entorno
- Puesta en marcha y verificación
- Depuración: los estados de un Pod
- Helm como alternativa
- Organización de
k8s/ y el Namespace
k8s/ y el Namespacek8s/base/{namespace,config,cache,db,api,web}.yaml + kustomization.yaml
k8s/overlays/{staging,produccion}/kustomization.yamlUn fichero por servicio, con todos sus objetos juntos separados por ---. Es más práctico que un fichero por objeto: para entender aurora-api abres un único fichero y ves su Deployment, su Service y su configuración. El primero es el Namespace, un objeto de cinco líneas (apiVersion: v1, kind: Namespace, metadata.name: aurora) que crea la partición donde vivirá todo lo demás.
aurora-cache: Deployment y Service
aurora-cache: Deployment y Service# k8s/base/cache.yaml
apiVersion: apps/v1
kind: Deployment
metadata: { name: aurora-cache }
spec:
replicas: 1
selector: { matchLabels: { app.kubernetes.io/name: aurora-cache } }
template:
metadata: { labels: { app.kubernetes.io/name: aurora-cache } }
spec:
containers:
- name: redis
image: redis:7-alpine
args: ["--maxmemory","200mb","--maxmemory-policy","allkeys-lru"]
ports: [{ containerPort: 6379, name: redis }]
resources:
requests: { memory: 64Mi, cpu: 50m }
limits: { memory: 256Mi, cpu: 500m }
livenessProbe: { tcpSocket: { port: redis }, initialDelaySeconds: 5 }
readinessProbe: { exec: { command: ["redis-cli","ping"] }, periodSeconds: 5 }
---
apiVersion: v1
kind: Service
metadata: { name: aurora-cache }
spec:
selector: { app.kubernetes.io/name: aurora-cache }
ports: [{ port: 6379, targetPort: redis }]Dos patrones que se repetirán en todos los manifiestos. El primero: los puertos se nombran (name: redis) y luego se referencian por nombre en el Service y en las sondas; si algún día cambias el número, lo cambias en un solo sitio. El segundo: la etiqueta app.kubernetes.io/name es la única que aparece en el selector, y las demás quedan fuera de él, porque el selector es inmutable y no conviene atarlo a nada que vaya a cambiar. Y fíjate en que Redis es aquí una caché desechable: sin volumen y sin persistencia, porque si el Pod muere la API vuelve a servir con origen: db hasta que se recalienta, que es exactamente lo que decidiste en la sonda de readiness de 06-01.
- Por qué
aurora-db es un StatefulSet
aurora-db es un StatefulSet| Aspecto | Deployment |
StatefulSet |
|---|---|---|
| Nombres de los Pods | Aleatorios (aurora-api-7d9f-x2k) |
Ordinales estables: aurora-db-0, -1 |
| Identidad al recrearse | Otra distinta | La misma, con su mismo disco |
| Almacenamiento | Compartido o efímero | Un PVC propio por Pod, volumeClaimTemplates |
| Orden de arranque y parada | Todos a la vez | Secuencial -0, -1; parada en orden inverso |
| DNS por Pod | No | Sí, con Service headless |
| Actualización | Progresiva por ReplicaSet | Ordinal, de mayor a menor |
Un Deployment con un volumen para PostgreSQL falla por dos motivos. Primero, la estrategia por defecto arranca el Pod nuevo antes de matar el viejo: durante unos segundos habría dos PostgreSQL escribiendo sobre los mismos ficheros, lo que corrompe los datos. Segundo, si escalaras a dos réplicas, ambas compartirían el mismo PVC —o se pelearían por él— sin ninguna coordinación.
Advertencia. Ejecutar una base de datos en Kubernetes es factible, pero en producción exige un operador (CloudNativePG, Zalando Postgres Operator, Crunchy) que gestione failover, copias, replicación y actualizaciones de versión, o directamente un servicio gestionado fuera del clúster. Un
StatefulSeta pelo como el de esta lección es correcto para aprender y para entornos de desarrollo, pero no cubre la recuperación ante desastres. Valida esta decisión con el responsable de infraestructura y de datos de tu organización antes de poner datos reales.
- El
StatefulSet con volumeClaimTemplates
StatefulSet con volumeClaimTemplates# k8s/base/db.yaml
apiVersion: apps/v1
kind: StatefulSet
metadata: { name: aurora-db }
spec:
serviceName: aurora-db # obligatorio: el Service headless que da DNS por Pod
replicas: 1
selector: { matchLabels: { app.kubernetes.io/name: aurora-db } }
template:
metadata: { labels: { app.kubernetes.io/name: aurora-db } }
spec:
securityContext: { fsGroup: 999 } # el volumen pertenece al grupo del usuario postgres
containers:
- name: postgres
image: postgres:16-alpine
ports: [{ containerPort: 5432, name: postgres }]
env:
- { name: POSTGRES_USER, value: aurora }
- { name: POSTGRES_DB, value: aurora_libros }
- { name: PGDATA, value: /var/lib/postgresql/data/pgdata }
- name: POSTGRES_PASSWORD
valueFrom: { secretKeyRef: { name: aurora-secretos, key: DB_PASSWORD } }
volumeMounts:
- { name: datos, mountPath: /var/lib/postgresql/data }
- { name: init, mountPath: /docker-entrypoint-initdb.d, readOnly: true }
resources:
requests: { memory: 256Mi, cpu: 100m }
limits: { memory: 1Gi, cpu: "2" }
readinessProbe:
exec: { command: ["pg_isready","-U","aurora","-d","aurora_libros"] }
periodSeconds: 5
# liveness sin consultar la base: solo "el proceso vive"
livenessProbe: { tcpSocket: { port: postgres }, initialDelaySeconds: 30 }
volumes: [{ name: init, configMap: { name: aurora-init-sql } }]
volumeClaimTemplates: # un PVC por Pod, creado automáticamente
- metadata: { name: datos }
spec:
accessModes: [ReadWriteOnce]
resources: { requests: { storage: 10Gi } }El PGDATA en un subdirectorio no es un capricho: muchos aprovisionadores crean un lost+found en la raíz del volumen y PostgreSQL se niega a inicializarse en un directorio que no esté vacío. Usar /data/pgdata evita ese fallo, que es de los que cuestan una tarde entera. Por su parte, el volumeClaimTemplates genera un PVC llamado datos-aurora-db-0, con una propiedad deliberada: borrar el StatefulSet no borra sus PVC. Es una red de seguridad —los datos sobreviven a un borrado accidental— y también una fuente de sorpresas, porque recrear el StatefulSet reengancha el volumen anterior con todo lo que hubiera dentro.
- Service headless y el
ConfigMap con init.sql
ConfigMap con init.sql---
apiVersion: v1
kind: Service
metadata: { name: aurora-db }
spec:
clusterIP: None # headless: sin IP virtual, DNS directo a cada Pod
selector: { app.kubernetes.io/name: aurora-db }
ports: [{ port: 5432, targetPort: postgres }]Un Service normal reparte entre Pods, que es justo lo que no quieres con una base de datos: cada réplica es distinta y tienes que poder dirigirte a una concreta. Con clusterIP: None, el DNS devuelve directamente las direcciones de los Pods, y cada uno recibe además su nombre estable: aurora-db-0.aurora-db.aurora.svc.cluster.local. Con réplicas de lectura, ese nombre es el que te permite apuntar las escrituras al primario.
El init.sql con los nueve títulos entra como ConfigMap generado desde el fichero, con kubectl create configmap aurora-init-sql --from-file=init.sql=db/init.sql -n aurora. Un límite que conviene conocer: un ConfigMap no puede pasar de 1 MiB, porque vive en etcd. Para un init.sql de nueve libros sobra, pero un volcado real no cabe; en ese caso se usa un initContainer que lo descargue de un almacenamiento de objetos.
aurora-api: imagen por digest e imagePullSecrets
aurora-api: imagen por digest e imagePullSecrets# k8s/base/api.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: aurora-api
labels: { app.kubernetes.io/name: aurora-api, app.kubernetes.io/version: "2.0.0" }
spec:
replicas: 3
revisionHistoryLimit: 5
selector: { matchLabels: { app.kubernetes.io/name: aurora-api } }
template:
metadata: { labels: { app.kubernetes.io/name: aurora-api } }
spec:
imagePullSecrets: [{ name: ghcr-auroralibros }]
terminationGracePeriodSeconds: 30 # > PLAZO_APAGADO_MS (15 s) de 06-01
containers:
- name: api
image: ghcr.io/auroralibros/aurora-api:2.0.0@sha256:a1b2c3d4e5f60718293a4b5c6d7e8f90
imagePullPolicy: IfNotPresent
ports: [{ containerPort: 3000, name: http }]kubectl create secret docker-registry ghcr-auroralibros -n aurora \
--docker-server=ghcr.io --docker-username=auroralibros --docker-password="$TOKEN_GHCR"Fijar el digest junto a la etiqueta es lo que hace que el despliegue sea inmutable de verdad: la etiqueta documenta qué versión es y el digest garantiza qué bytes se ejecutan, aunque alguien haya movido 2.0.0 en el registro. Es la práctica que exige cualquier política de admisión seria. Sobre imagePullPolicy hay una trampa clásica: con la etiqueta latest el valor por defecto es Always, y con cualquier otra es IfNotPresent; fijando el digest, IfNotPresent es correcto y evita descargas innecesarias, porque un digest siempre identifica el mismo contenido.
- Las tres sondas en el manifiesto
startupProbe: # protege a las otras dos durante el arranque
httpGet: { path: /salud/arrancado, port: http }
periodSeconds: 5
failureThreshold: 12 # hasta 60 s para arrancar
livenessProbe: # NO toca la BD: solo "¿responde el proceso?"
httpGet: { path: /salud/vivo, port: http }
periodSeconds: 15
timeoutSeconds: 3
failureThreshold: 3
readinessProbe: # SÍ comprueba dependencias
httpGet: { path: /salud/listo, port: http }
periodSeconds: 5
failureThreshold: 2
# margen extra para el drenaje del kube-proxy
lifecycle: { preStop: { exec: { command: ["sleep","5"] } } }| Sonda | Endpoint | Si falla | Tiempo hasta actuar |
|---|---|---|---|
startupProbe |
/salud/arrancado |
Se reinicia el contenedor | 12 × 5 s = 60 s |
livenessProbe |
/salud/vivo |
Se reinicia el contenedor | 3 × 15 s = 45 s |
readinessProbe |
/salud/listo |
Se quita de los endpoints | 2 × 5 s = 10 s |
Los números están elegidos con una lógica: la readiness reacciona rápido (10 s) porque su consecuencia es barata y reversible —dejar de mandar tráfico—, mientras que la liveness reacciona despacio (45 s) porque su consecuencia es cara y disruptiva —matar el proceso—. Invertir esos plazos es la receta de los reinicios en cascada bajo carga. Y el preStop con sleep 5 cumple aquí el mismo papel que la espera de cinco segundos que programaste en 06-01: cuando Kubernetes decide terminar un Pod, elimina su endpoint y manda SIGTERM a la vez, y esas dos cosas se propagan a ritmos distintos por todos los kube-proxy del clúster. El sleep retrasa el SIGTERM lo justo para que las tablas de enrutado se actualicen antes.
requests, limits y las clases de QoS
requests, limits y las clases de QoSPara aurora-api, requests: { memory: 128Mi, cpu: 100m } y limits: { memory: 512Mi, cpu: "1" }. Los dos campos hacen cosas distintas:
| Campo | Para qué sirve | Qué pasa si te pasas |
|---|---|---|
requests |
Planificación: el scheduler solo coloca el Pod donde quepa | El Pod no se programa: Pending |
limits.memory |
Techo duro del cgroup | OOMKilled: el proceso muere |
limits.cpu |
Cuota de tiempo de CPU | Throttling: se ralentiza, no muere |
| Clase de QoS | Condición | Al quedarse el nodo sin memoria |
|---|---|---|
Guaranteed |
requests == limits en todos los contenedores |
Último en ser expulsado |
Burstable |
requests < limits |
Intermedio |
BestEffort |
Sin requests ni limits |
Primero en ser expulsado |
aurora-api queda como Burstable, que es lo correcto para una API: reserva poco para que quepan muchas réplicas por nodo y puede subir hasta el límite en los picos. aurora-db, en cambio, es candidata a Guaranteed en producción, porque no quieres que el kernel elija a tu base de datos cuando haya que expulsar algo. Los números salen directamente de la tabla de docker stats de 06-01: 214 MiB de pico → límite de 512 MiB.
securityContext de Pod y de contenedor
securityContext de Pod y de contenedor securityContext: # nivel POD: se aplica a todos los contenedores
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
seccompProfile: { type: RuntimeDefault }
containers:
- name: api
securityContext: # nivel CONTENEDOR: gana sobre el del Pod
allowPrivilegeEscalation: false # equivale a no-new-privileges
readOnlyRootFilesystem: true # equivale a read_only
capabilities: { drop: [ALL] } # equivale a cap_drop: [ALL]
volumeMounts: [{ name: tmp, mountPath: /tmp }]
# el tmpfs de 05-03, necesario porque la raíz es de solo lectura
volumes: [{ name: tmp, emptyDir: { medium: Memory, sizeLimit: 32Mi } }]Esto es, línea por línea, la traducción del endurecimiento del compose.prod.yaml de 05-03. La única novedad es runAsNonRoot: true, que merece atención: no cambia el usuario, sino que verifica que la imagen no arranque como root, y si lo hace el Pod falla al crearse con CreateContainerConfigError. Es una comprobación barata que impide que una imagen mal construida llegue a ejecutarse. Y fsGroup resuelve el problema clásico de permisos con volúmenes: Kubernetes cambia el grupo propietario del contenido montado a ese GID, de modo que un proceso sin privilegios puede escribir en su PVC.
- Configuración con
ConfigMap y Secret
ConfigMap y Secret# k8s/base/config.yaml
apiVersion: v1
kind: ConfigMap
metadata: { name: aurora-config }
data:
DB_HOST: aurora-db
DB_USER: aurora
DB_NAME: aurora_libros
DB_POOL_MAX: "10"
REDIS_HOST: aurora-cache
CACHE_TTL: "60"
PLAZO_APAGADO_MS: "15000"
LOG_NIVEL: info
---
# y en el contenedor de aurora-api:
# envFrom: [{ configMapRef: { name: aurora-config } }] # todas las claves
# env:
# - name: DB_PASSWORD
# valueFrom: { secretKeyRef: { name: aurora-secretos, key: DB_PASSWORD } }La API no necesita ni una línea de cambio: los mismos nombres de variable que definiste en config.js en 06-01 llegan ahora desde un ConfigMap en vez de desde Compose, que es la recompensa de haber sacado toda la configuración de la imagen. Podrías además montar el secreto como fichero y usar DB_PASSWORD_FILE, más seguro por lo que viste en 06-03 sobre la herencia a subprocesos; aquí se usa secretKeyRef por brevedad, pero en producción el volumen es preferible y tu código ya lo soporta.
Un detalle operativo importante: cambiar un ConfigMap no reinicia los Pods. Para aplicar el cambio hay que forzarlo con kubectl rollout restart deployment/aurora-api, o dejar que Kustomize genere el ConfigMap con un sufijo de hash, que es lo que haremos.
aurora-web: Deployment, Service e Ingress
aurora-web: Deployment, Service e Ingress# k8s/base/web.yaml (extracto)
apiVersion: apps/v1
kind: Deployment
metadata: { name: aurora-web }
spec:
replicas: 2
selector: { matchLabels: { app.kubernetes.io/name: aurora-web } }
template:
metadata: { labels: { app.kubernetes.io/name: aurora-web } }
spec:
containers:
- name: nginx
image: nginx:alpine
ports: [{ containerPort: 80, name: http }]
volumeMounts: [{ name: contenido, mountPath: /usr/share/nginx/html, readOnly: true }]
readinessProbe: { httpGet: { path: /, port: http }, periodSeconds: 5 }
resources:
requests: { memory: 16Mi, cpu: 10m }
limits: { memory: 64Mi, cpu: 200m }
volumes: [{ name: contenido, configMap: { name: aurora-web-html } }]
---
apiVersion: v1
kind: Service
metadata: { name: aurora-web }
spec:
selector: { app.kubernetes.io/name: aurora-web }
ports: [{ port: 80, targetPort: http }]
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: aurora
annotations: { nginx.ingress.kubernetes.io/proxy-read-timeout: "30" }
spec:
ingressClassName: nginx
rules:
- host: libros.aurora.example
http:
paths:
- { path: /libros, pathType: Prefix, backend: { service: { name: aurora-api, port: { number: 3000 } } } }
- { path: /salud, pathType: Prefix, backend: { service: { name: aurora-api, port: { number: 3000 } } } }
- { path: /, pathType: Prefix, backend: { service: { name: aurora-web, port: { number: 80 } } } }Fíjate en el cambio de arquitectura: en Compose, aurora-web hacía de proxy inverso hacia la API. Aquí ese trabajo lo hace el Ingress, y Nginx queda reducido a servir ficheros estáticos. Es lo natural en Kubernetes: el enrutado es responsabilidad de la plataforma, no de tu contenedor, y así puedes escalar el frontal y la API por separado.
- TLS con cert-manager
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
tls:
- hosts: [libros.aurora.example]
secretName: aurora-tls # cert-manager lo crea y lo renueva soloCon cert-manager instalado y un ClusterIssuer configurado, esas cinco líneas bastan: el operador ve la anotación, solicita el certificado a Let's Encrypt, resuelve el desafío ACME, guarda el resultado en el Secret aurora-tls y lo renueva automáticamente antes de que caduque. Es el ejemplo canónico de operador y de lo que Kubernetes añade sobre Swarm: una tarea recurrente que alguien hacía a mano se convierte en un controlador más. La configuración del emisor —producción o staging de Let's Encrypt, DNS-01 frente a HTTP-01— es una decisión de plataforma que conviene acordar con tu equipo de infraestructura.
- Kustomize: bases y overlays por entorno
Kustomize es la misma idea de los overrides de Compose de 04-06, con la diferencia de que aquí forma parte de kubectl: una base común y overlays que la parchean por entorno, sin duplicar ni un fichero ni usar plantillas.
# k8s/base/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: aurora
resources: [namespace.yaml, config.yaml, cache.yaml, db.yaml, api.yaml, web.yaml]
commonLabels: { app.kubernetes.io/part-of: aurora-libros }
configMapGenerator:
- { name: aurora-init-sql, files: [init.sql=../../db/init.sql] } # el hash va en el nombre
---
# k8s/overlays/produccion/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: aurora
resources: [../../base]
images:
- { name: ghcr.io/auroralibros/aurora-api, newTag: 2.0.0, digest: sha256:a1b2c3d4e5f60718293a4b5c6d7e8f90 }
replicas: [{ name: aurora-api, count: 3 }]
patches:
- target: { kind: Deployment, name: aurora-api }
patch: |
- { op: replace, path: /spec/template/spec/containers/0/resources/limits/memory, value: 512Mi }kubectl kustomize k8s/overlays/produccion | head -20 # ver el resultado sin aplicar
kubectl apply -k k8s/overlays/produccionEl configMapGenerator resuelve el problema del final de la sección 10: genera el ConfigMap con un sufijo derivado del hash del contenido (aurora-init-sql-7f9c2d) y actualiza automáticamente la referencia en los Deployments. Como el nombre cambia, la plantilla del Pod cambia, y el despliegue progresivo se dispara solo cuando editas la configuración. Es exactamente el comportamiento que quieres y que a mano nunca ocurre.
- Puesta en marcha y verificación
kubectl apply -k k8s/overlays/produccion
kubectl rollout status statefulset/aurora-db -n aurora --timeout=120s
kubectl rollout status deployment/aurora-api -n aurora --timeout=120s
kubectl get all -n auroraNAME READY STATUS RESTARTS AGE
pod/aurora-api-6d4f8b7c9-2xkpq 1/1 Running 0 63s (×3)
pod/aurora-cache-5b7d9c8f4-hq3vn 1/1 Running 0 92s
pod/aurora-db-0 1/1 Running 0 92s
pod/aurora-web-79c6d8f5b-k2mtx 1/1 Running 0 63s (×2)
NAME TYPE CLUSTER-IP PORT(S)
service/aurora-api ClusterIP 10.96.201.14 3000/TCP
service/aurora-cache ClusterIP 10.96.184.22 6379/TCP
service/aurora-db ClusterIP None 5432/TCP
service/aurora-web ClusterIP 10.96.77.108 80/TCPEl aurora-db-0 con su ordinal y el CLUSTER-IP None del Service headless confirman que el StatefulSet está bien montado.
kubectl port-forward -n aurora svc/aurora-api 8080:3000 &
curl -s localhost:8080/libros | jq '{origen, total: (.libros|length), primero: .libros[0].titulo}'
curl -s localhost:8080/libros | jq -r '.origen' # segunda llamada
# { "origen": "db", "total": 9, "primero": "El jardín de senderos que se bifurcan" }
# cacheLos nueve títulos están ahí, y la segunda llamada devuelve origen: cache: el cache-aside funciona exactamente igual que en tu portátil, ahora repartido entre tres Pods de API que hablan con un Redis y un PostgreSQL por sus Services. La aplicación no ha cambiado ni una línea desde el módulo 4.
- Depuración: los estados de un Pod
| Estado | Qué significa | Causa habitual | Diagnóstico |
|---|---|---|---|
Pending |
No hay nodo donde colocarlo | requests demasiado altas, taints, PVC sin enlazar |
kubectl describe pod → Events |
ContainerCreating |
Preparando volúmenes y red | PVC en espera, secreto inexistente | describe → Events |
ImagePullBackOff |
No puede descargar la imagen | Nombre mal escrito, falta imagePullSecrets |
describe → Events |
CrashLoopBackOff |
Arranca y muere en bucle | Fallo de configuración, sonda mal puesta | logs --previous |
OOMKilled |
Superó limits.memory |
Límite bajo o fuga de memoria | describe → Last State |
Error |
Salió con código distinto de 0 | Excepción en el arranque | logs |
Running pero 0/1 |
Vivo pero no listo | La readiness no pasa | describe → Events, logs |
Terminating eterno |
No termina de morir | No captura SIGTERM o hay un finalizador |
describe, delete --force |
kubectl describe pod aurora-api-6d4f8b7c9-2xkpq -n aurora | tail -15 # Events
kubectl logs aurora-api-6d4f8b7c9-2xkpq -n aurora --previous # el intento anterior
kubectl get events -n aurora --sort-by=.lastTimestamp | tail -10
kubectl debug -it aurora-api-6d4f8b7c9-2xkpq -n aurora --image=nicolaka/netshoot --target=apikubectl debug resuelve el problema que tenías con las imágenes mínimas de 05-03: adjunta un contenedor efímero con todas las herramientas al Pod ya en marcha, compartiendo su red y sus namespaces, sin reiniciar nada y sin necesidad de que la imagen contenga un sh. Es el netshoot del módulo 3, aplicado al mundo de Kubernetes.
- Helm como alternativa
| Aspecto | Kustomize | Helm |
|---|---|---|
| Mecanismo | Parches sobre YAML plano | Plantillas Go con valores |
| Integración y curva | En kubectl (-k); suave |
Binario aparte; más pronunciada |
| Software de terceros | Poco práctico | Su punto fuerte: helm install ingress-nginx |
| Estado y rollback | Con kubectl rollout |
helm rollback con historial propio |
La combinación habitual es usar Helm para el software de terceros —ingress-nginx, cert-manager, Prometheus— y Kustomize para lo tuyo, que es lo que hace Aurora Libros. Helm brilla cuando quieres distribuir tu aplicación a terceros que necesitan parametrizarla; para una aplicación propia con dos entornos, Kustomize evita convertir tus manifiestos en plantillas ilegibles.
Errores Comunes y Consejos
- Una base de datos en un
Deployment. Durante la actualización conviven dos Pods escribiendo sobre el mismo disco.StatefulSetconstop-first, y en producción un operador. - Olvidar
imagePullSecrets.ImagePullBackOffen todos los Pods con un registro privado. El secreto es por namespace. PGDATAen la raíz del volumen. Ellost+founddel aprovisionador impide inicializar PostgreSQL.- Liveness apuntando a
/salud/listo. Reinicios en cascada cuando la base de datos tose. La liveness solo mira el proceso. initialDelaySecondscorto sinstartupProbe. El Pod se reinicia antes de terminar de arrancar, para siempre.- Editar un ConfigMap y esperar que se aplique. No reinicia nada:
rollout restartoconfigMapGenerator. - Consejo:
kubectl apply -k --dry-run=serveren el pipeline valida los manifiestos contra la API real antes de tocar el clúster, ykubectl rollout restart deployment/...es la forma correcta de "reiniciar" (nodelete pod). - Consejo:
kubectl get events --sort-by=.lastTimestampes la vista más útil cuando algo falla y no sabes ni qué objeto mirar.
Ejercicios
Ejercicio 1. Despliega Aurora Libros completa en tu clúster kind y verifica de extremo a extremo: que aurora-db-0 tiene su PVC enlazado, que /libros devuelve los nueve títulos y que la segunda llamada viene de la caché.
Ejercicio 2. Diagnostica tres fallos provocados sin mirar los manifiestos: un Pending por requests imposibles, un ImagePullBackOff y un CrashLoopBackOff por falta de una variable obligatoria. Indica en cada caso qué comando lo revela.
Ejercicio 3. Demuestra que el StatefulSet conserva la identidad y los datos: escribe un registro en la base, borra el Pod aurora-db-0 y comprueba qué sobrevive y qué no.
Soluciones
Solución 1.
kubectl apply -k k8s/overlays/produccion
kubectl wait --for=condition=ready pod -l app.kubernetes.io/name=aurora-api -n aurora --timeout=180s
kubectl get pvc -n aurora
# NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS
# datos-aurora-db-0 Bound pvc-8f3c1a… 10Gi RWO standardEl nombre datos-aurora-db-0 es la firma del volumeClaimTemplates: <nombre-de-la-plantilla>-<nombre-del-statefulset>-<ordinal>. Si escalaras a dos réplicas aparecería datos-aurora-db-1, con su propio disco, que es justo lo que un Deployment no puede darte.
kubectl port-forward -n aurora svc/aurora-api 8080:3000 &
curl -s localhost:8080/libros | jq -r '.origen, (.libros|length), .libros[4].titulo'
curl -s localhost:8080/libros | jq -r '.origen'
kubectl exec -n aurora aurora-db-0 -- psql -U aurora -d aurora_libros -tAc 'SELECT count(*) FROM libros;'
# db / 9 / Nada (primera llamada)
# cache (segunda llamada)
# 9 (filas en la tabla)La verificación de extremo a extremo está completa: el ConfigMap con init.sql se ejecutó al inicializar PostgreSQL, los nueve títulos están en la tabla, y la API los sirve desde la base de datos la primera vez y desde Redis la segunda. Pero merece la pena detenerse en lo que no ha hecho falta. No has tocado el código de la aplicación, ni el Dockerfile, ni las variables que espera. La imagen que ejecuta el clúster es la misma que construyó el pipeline de 06-02 y la misma que corría en tu Compose: el contrato que estableciste en 06-01 —configuración por entorno, sondas separadas, apagado ordenado— es el que ha permitido que cambiar de plataforma sea solo cambiar quién inyecta las variables.
Solución 2.
kubectl patch deploy aurora-api -n aurora --type=json \
-p='[{"op":"replace","path":"/spec/template/spec/containers/0/resources/requests/memory","value":"64Gi"}]'
kubectl get pods -n aurora | grep Pending
kubectl describe pod -n aurora -l app.kubernetes.io/name=aurora-api | grep -A3 Events
# aurora-api-84c7d9f6b-nk4pz 0/1 Pending 0 22s
# Events:
# Warning FailedScheduling no nodes available: 3 Insufficient memory
kubectl set image deploy/aurora-api api=ghcr.io/auroralibros/aurora-api:9.9.9 -n aurora
kubectl describe pod -n aurora -l app.kubernetes.io/name=aurora-api | grep -A2 'Failed'
# Warning Failed Failed to pull image "...aurora-api:9.9.9": not found
# Warning Failed Error: ErrImagePull → ImagePullBackOff
kubectl patch cm aurora-config -n aurora --type=json -p='[{"op":"remove","path":"/data/DB_HOST"}]'
kubectl rollout restart deploy/aurora-api -n aurora
kubectl logs -n aurora -l app.kubernetes.io/name=aurora-api --previous --tail=2
# {"nivel":"error","mensaje":"configuracion invalida",
# "detalle":"Falta la variable obligatoria DB_HOST (o su variante _FILE)"}| Fallo | Estado | Comando que lo revela | Dónde está la respuesta |
|---|---|---|---|
requests imposibles |
Pending |
describe pod |
Events: Insufficient memory |
| Imagen inexistente | ImagePullBackOff |
describe pod |
Events: not found |
| Falta configuración | CrashLoopBackOff |
logs --previous |
Logs del contenedor |
La regla de diagnóstico que resume la tabla es la que ahorra más tiempo en Kubernetes: si el contenedor nunca llegó a arrancar, la respuesta está en los eventos; si arrancó y murió, está en los logs. En los dos primeros casos kubectl logs no devuelve nada, y no porque falle, sino porque no hay proceso que haya escrito nada. En el tercero, describe solo dice Back-off restarting failed container, que no explica nada; el --previous es imprescindible porque el contenedor actual acaba de nacer y el que falló ya no existe. Y fíjate en la calidad de ese tercer mensaje: dice exactamente qué variable falta, que es el rendimiento del config.js de 06-01 en un entorno donde depurar es mucho más incómodo.
Solución 3.
kubectl exec -n aurora aurora-db-0 -- psql -U aurora -d aurora_libros -c \
"INSERT INTO libros (titulo, autor, precio) VALUES ('El Aleph (2ª ed.)','J. L. Borges', 18.50);"
kubectl get pod aurora-db-0 -n aurora -o jsonpath='{.status.podIP}{"\n"}'
kubectl delete pod aurora-db-0 -n aurora
kubectl wait --for=condition=ready pod/aurora-db-0 -n aurora --timeout=120s
kubectl get pod aurora-db-0 -n aurora -o jsonpath='{.status.podIP}{"\n"}'
kubectl exec -n aurora aurora-db-0 -- psql -U aurora -d aurora_libros -tAc "SELECT count(*) FROM libros;"
# 10.244.2.9
# pod "aurora-db-0" deleted
# 10.244.1.14
# 10| Propiedad | ¿Sobrevive? | Por qué |
|---|---|---|
| Nombre del Pod y su DNS | Sí | Ordinal fijo: aurora-db-0.aurora-db sigue apuntándole |
PVC datos-aurora-db-0 |
Sí | Se reengancha al nuevo Pod |
| Los datos (10 filas) | Sí | Viven en el PVC, no en el Pod |
| IP del Pod | No | Cambia de 10.244.2.9 a 10.244.1.14 |
| Nodo donde corre | No garantizado | Puede reprogramarse |
El contraste con el ejercicio 2 de 06-04 es el punto de la lección. Allí, al borrar un Pod de un Deployment volvía otro Pod con otro nombre; aquí vuelve aurora-db-0, el mismo nombre, el mismo DNS y, sobre todo, el mismo disco, con el décimo libro dentro. Que la IP cambie y no pase nada demuestra por qué la base de datos se referencia siempre por su nombre DNS y nunca por dirección: aurora-db en el ConfigMap sigue resolviendo, así que las tres réplicas de la API se reconectaron solas —con el reintento con backoff de 04-04— sin que tocaras nada.
Ahora la advertencia que este ejercicio no demuestra y conviene no olvidar: has sobrevivido a la muerte de un Pod, no a la de un nodo ni a la de un disco. Con ReadWriteOnce, si el nodo que aloja el PVC queda inaccesible, el Pod puede quedarse en Pending hasta que el volumen se libere. Eso, y la ausencia de failover automático, es exactamente lo que aporta un operador de PostgreSQL y lo que hay que resolver antes de poner datos de clientes aquí.
Conclusión
Aurora Libros entera vive en Kubernetes. Has escrito los manifiestos de los cuatro servicios con criterio: aurora-cache como Deployment desechable con puertos nombrados, aurora-db como StatefulSet —con el porqué claro: dos PostgreSQL sobre los mismos ficheros corrompen los datos— con sus volumeClaimTemplates, su Service headless, su ConfigMap con los nueve títulos y el PGDATA en un subdirectorio para esquivar el lost+found. Y con la advertencia en su sitio: esto sirve para aprender, pero producción pide un operador o una base gestionada, validado con tu equipo de infraestructura.
El Deployment de aurora-api recoge todo lo del módulo: la imagen del pipeline fijada por digest con su imagePullSecrets, las tres sondas de 06-01 apuntando a /salud/arrancado, /salud/vivo y /salud/listo con plazos asimétricos a propósito —readiness rápida porque es barata, liveness lenta porque mata—, el preStop que compra los segundos que necesita el kube-proxy, las requests y limits sacados de la medición real con su clase de QoS, y el securityContext que traduce línea a línea el endurecimiento de 05-03. La configuración entra por ConfigMap y Secret sin tocar una línea de la aplicación, que es la recompensa de haberla sacado de la imagen. aurora-web queda como servidor estático y el enrutado pasa al Ingress, con cert-manager renovando el TLS solo.
Todo se aplica con kubectl apply -k y Kustomize, con una base y overlays por entorno, y el configMapGenerator que dispara el despliegue progresivo cuando cambia la configuración. Has verificado que /libros devuelve los nueve títulos con origen: db y cache en la segunda llamada, sabes leer la tabla de estados de un Pod con la regla que más tiempo ahorra —si nunca arrancó, mira los eventos; si arrancó y murió, mira los logs con --previous—, tienes kubectl debug para inspeccionar imágenes sin shell, y sabes cuándo usar Helm y cuándo Kustomize.
En la siguiente lección, Escalado y Balanceo de Carga, la plataforma aprende a crecer. Verás por qué la API escala replicando y la base de datos no, cómo balancean de verdad kube-proxy y el Ingress, y montarás un HorizontalPodAutoscaler sobre aurora-api que crea réplicas solo mientras generas carga real contra /libros, midiendo la latencia antes y después para saber dónde está el cuello de botella.
Docker: De Principiante a Avanzado
Módulo 1: Introducción a Docker
- ¿Qué es Docker?
- Instalando Docker
- Arquitectura de Docker
- Comandos Básicos de Docker
- Entendiendo las Imágenes de Docker
- Creando tu Primer Contenedor Docker
- El Proyecto del Curso: la Plataforma Aurora Libros
Módulo 2: Trabajando con Imágenes Docker
- Docker Hub y Repositorios
- Construyendo Imágenes Docker
- Conceptos Básicos de Dockerfile
- Instrucciones Avanzadas del Dockerfile
- Gestionando Imágenes Docker
- Etiquetado y Publicación de Imágenes
Módulo 3: Contenedores Docker
- Ejecutando Contenedores
- Ciclo de Vida del Contenedor
- Gestionando Contenedores
- Inspección y Depuración de Contenedores
- Redes en Docker
- Persistencia de Datos con Volúmenes
- Límites de Recursos y Políticas de Reinicio
Módulo 4: Docker Compose
- Introducción a Docker Compose
- Definiendo Servicios en Docker Compose
- Comandos de Docker Compose
- Aplicaciones Multi-Contenedor
- Variables de Entorno en Docker Compose
- Perfiles, Overrides y Múltiples Entornos
- Desarrollo Local con Docker Compose
Módulo 5: Conceptos Avanzados de Docker
- Profundización en Redes Docker
- Opciones de Almacenamiento Docker
- Mejores Prácticas de Seguridad en Docker
- Optimizando Imágenes Docker
- Builds Avanzadas con BuildKit y Buildx
- Registro y Monitoreo en Docker
- El Runtime por Dentro: Namespaces, Cgroups y Capas
Módulo 6: Docker en Producción
- Preparar una Imagen para Producción
- CI/CD con Docker
- Orquestando Contenedores con Docker Swarm
- Introducción a Kubernetes
- Desplegando Contenedores Docker en Kubernetes
- Escalado y Balanceo de Carga
- Estrategias de Despliegue y Rollback
