En AWS conseguimos infraestructura seria con poco esfuerzo operativo: ECS Fargate ejecuta la imagen, RDS guarda los datos, el balanceador reparte y nadie administra un servidor. La lección terminaba con una pregunta abierta: ¿qué pasa cuando la red de Ribalta deja de ser una aplicación y son cinco, con tres equipos desplegando por su cuenta y el ayuntamiento preguntando si esto se puede mover a otro proveedor?
La respuesta de la industria es Kubernetes, el orquestador de contenedores que se ha convertido en el estándar de facto. Esta lección lo aborda con honestidad: primero explica qué problema resuelve y por qué para una sola aplicación como CicloUrbana suele ser complejidad no justificada, y después lo hace bien, con los manifiestos completos y comentados de la red de Ribalta. Aquí es donde encajan por fin dos piezas que llevamos preparando desde el módulo 7: las sondas de salud de 07-01, que Kubernetes consulta de tres formas distintas, y la imagen de 07-04, que es exactamente la unidad que un Deployment despliega.
Contenido
- Qué problema resuelve un orquestador, y cuándo compensa
- Arquitectura y objetos de Kubernetes
- Entorno de práctica y
kubectl Namespace,ConfigMapySecret- El
Deploymentde CicloUrbana - Las tres sondas y los grupos de salud de 07-01
ServiceeIngresscon TLS- Migraciones de Flyway:
JoboinitContainer - Despliegue rolling,
rollout statusyrollout undo HPAyPodDisruptionBudget- Empaquetar con Helm
- PostgreSQL dentro o fuera del clúster
- Observabilidad y depuración de un pod que no arranca
- GitOps y Spring Cloud Kubernetes
- Errores Comunes y Consejos
- Ejercicios
- Qué problema resuelve un orquestador, y cuándo compensa
Un orquestador toma un conjunto de máquinas y las presenta como un único recurso computacional al que se le declara un estado deseado. En lugar de decir «arranca este contenedor en esta máquina», se dice «quiero tres copias de esta imagen, con este límite de memoria, accesibles en este nombre», y el sistema se ocupa de conseguirlo y de mantenerlo pese a las averías.
| Problema | Cómo lo resuelve Kubernetes |
|---|---|
| Un contenedor se cae | El controlador lo detecta y crea otro para volver al número deseado |
| Una máquina muere | Los pods se reprograman en las que quedan |
| Repartir contenedores entre máquinas | El planificador coloca según recursos, afinidades y restricciones |
| Descubrimiento de servicios | Cada Service tiene nombre DNS interno estable |
| Despliegue sin corte y escalado | Deployment progresivo con reversión; HorizontalPodAutoscaler según métricas |
| Configuración y secretos | ConfigMap y Secret montados como variables o ficheros |
| Portabilidad entre proveedores | Los mismos manifiestos en EKS, GKE, AKS o en un servidor propio |
La advertencia honesta, antes de seguir. Todo lo de esa tabla lo hacía también ECS en 08-03, con una fracción del esfuerzo. Kubernetes trae consigo un vocabulario de decenas de objetos, un plano de control que hay que actualizar, complementos que instalar (controlador de ingress, cert-manager, métricas, agregador de logs), un modelo de red no trivial y una superficie de seguridad amplia. Para una sola aplicación, ECS o un PaaS suelen bastar y suelen ser mejores.
Kubernetes empieza a compensar cuando concurren varias de estas condiciones: hay muchos servicios (más de cinco o seis) que despliegan de forma independiente; hay varios equipos que necesitan autonomía sin pisarse, con Namespace y cuotas; se exige portabilidad entre proveedores o alojamiento propio; hace falta automatización avanzada —canary, GitOps, operadores—; y ya existe una plataforma interna con alguien que la mantiene.
Para CicloUrbana, la respuesta sincera hoy es: no hace falta. La estudiamos porque es el escenario del ayuntamiento a tres años vista, porque es el destino de la imagen que ya sabemos construir, y porque las sondas de 07-01 solo revelan todo su sentido aquí.
- Arquitectura y objetos de Kubernetes
flowchart TD
subgraph CP["Plano de control (gestionado por el proveedor)"]
API[API Server] --- ETCD[(etcd)]
API --- SCHED[Scheduler]
API --- CM[Controller Manager]
end
KUBECTL[kubectl / CI] --> API
subgraph N1["Nodo 1"]
K1[kubelet] --> P1[Pod ciclourbana]
K1 --> P2[Pod ciclourbana]
end
subgraph N2["Nodo 2"]
K2[kubelet] --> P3[Pod ciclourbana]
end
API --> K1
API --> K2
ING[Ingress Controller] --> SVC[Service ClusterIP]
SVC --> P1 & P2 & P3
El modelo mental es sencillo y conviene fijarlo: se declara el estado deseado en el API Server, que lo guarda en etcd, y los controladores comparan continuamente lo deseado con lo real y actúan para reducir la diferencia. No se dan órdenes: se describe un objetivo.
| Objeto | Qué es | En CicloUrbana |
|---|---|---|
| Pod | La unidad mínima: uno o varios contenedores que comparten red y volúmenes | Un pod = una instancia de la aplicación |
| ReplicaSet / Deployment | El Deployment gestiona ReplicaSet y orquesta actualizaciones y reversiones |
ciclourbana, 3 réplicas |
| Service | Nombre DNS y IP virtual estables sobre un conjunto cambiante de pods | ciclourbana:8080 dentro del clúster |
| Ingress | Enrutado HTTP/HTTPS desde fuera, con TLS y por host o ruta | ciclourbana.ribalta.example |
| ConfigMap | Configuración no sensible, como variables o ficheros | Perfil activo, URL de la base de datos |
| Secret | Datos sensibles, codificados en base64 | Contraseña de PostgreSQL, secreto JWT |
| Namespace | Partición lógica del clúster con nombres y cuotas propios | ciclourbana-prod, ciclourbana-pre |
| HPA | Ajusta el número de réplicas según métricas | Escalar por CPU entre 3 y 10 |
| PVC | Solicitud de almacenamiento persistente | No lo usamos: la base de datos está fuera |
| Job / CronJob | Proceso puntual o periódico hasta completarse | Las migraciones de Flyway |
- Entorno de práctica y
kubectl
kubectlPara practicar sin coste no hace falta un clúster de pago:
minikube (minikube start --cpus 4 --memory 6144) es la más completa, con addons de ingress y métricas; kind (kind create cluster --name ribalta) es ligera y rapidísima, ideal para la propia CI; y Docker Desktop solo pide marcar la casilla «Enable Kubernetes».
Aviso de coste: un clúster gestionado (EKS, GKE, AKS) cuesta unos 70 $/mes solo por el plano de control, más los nodos. Practica en local; si creas uno gestionado, destrúyelo el mismo día.
Los comandos imprescindibles:
| Comando | Qué hace |
|---|---|
kubectl apply -f manifiesto.yaml |
Crea o actualiza lo declarado (modo declarativo) |
kubectl get pods -n ciclourbana-prod |
Lista pods y su estado |
kubectl describe pod <nombre> |
Detalle y eventos: la primera parada al depurar |
kubectl logs <pod> -f |
Sigue el log; --previous para el contenedor que murió |
kubectl exec -it <pod> -- sh |
Abre una shell dentro del contenedor |
kubectl port-forward svc/ciclourbana 8080:8080 |
Túnel local sin exponer nada |
kubectl rollout status / undo deploy/ciclourbana |
Espera a que termine la actualización; revierte a la anterior |
kubectl get events --sort-by=.lastTimestamp |
Qué ha pasado en el namespace |
Una regla de trabajo: apply sobre ficheros versionados en Git, nunca kubectl edit ni kubectl run en producción, porque un cambio hecho a mano se pierde en el siguiente apply y nadie sabe que existió.
Namespace, ConfigMap y Secret
Namespace, ConfigMap y SecretEl primer manifiesto, 00-namespace.yaml, es trivial: un Namespace llamado ciclourbana-prod con las etiquetas proyecto: ciclourbana y entorno: prod. Aísla nombres, permisos y cuotas del entorno de preproducción.
# 01-configmap.yaml — configuracion NO sensible
apiVersion: v1
kind: ConfigMap
metadata:
name: ciclourbana-config
namespace: ciclourbana-prod
data:
SPRING_PROFILES_ACTIVE: "prod"
TZ: "Europe/Madrid"
JAVA_TOOL_OPTIONS: "-XX:MaxRAMPercentage=75 -XX:+ExitOnOutOfMemoryError"
SPRING_DATASOURCE_URL: "jdbc:postgresql://ciclourbana-prod.abc123xyz.eu-west-1.rds.amazonaws.com:5432/ciclourbana?sslmode=require"
SPRING_DATASOURCE_USERNAME: "ciclourbana_app"
SPRING_FLYWAY_ENABLED: "false"
MANAGEMENT_SERVER_PORT: "8081"
SERVER_FORWARD_HEADERS_STRATEGY: "framework"Las claves son nombres de variables de entorno, que Spring Boot traduce a propiedades por relaxed binding (02-05): SPRING_DATASOURCE_URL → spring.datasource.url. Aquí sí podemos mantener el puerto de gestión separado en 8081 de 07-01 —a diferencia de Heroku— porque un pod puede exponer varios puertos, y el Service decide cuáles publica.
# 02-secret.yaml — NUNCA se versiona con valores reales
apiVersion: v1
kind: Secret
metadata:
name: ciclourbana-secretos
namespace: ciclourbana-prod
type: Opaque
stringData: # stringData admite texto plano; K8s lo codifica al guardarlo
SPRING_DATASOURCE_PASSWORD: "REEMPLAZAR"
JWT_SECRETO: "REEMPLAZAR"Advertencia fundamental: un Secret de Kubernetes solo está codificado en base64, no cifrado. Base64 no es cifrado: kubectl get secret ciclourbana-secretos -o jsonpath='{.data.JWT_SECRETO}' | base64 -d devuelve el valor en claro para cualquiera con permisos de lectura sobre el namespace. Y por defecto se guarda tal cual en etcd.
| Solución | Qué aporta | Coste |
|---|---|---|
| RBAC estricto | Solo quien lo necesita puede leer Secret |
Gratis; imprescindible en cualquier caso |
Cifrado en reposo de etcd |
El proveedor cifra con KMS lo que hay en etcd |
En EKS/GKE es una casilla del clúster |
| Sealed Secrets | Se versiona un SealedSecret cifrado; solo el controlador lo descifra |
Un controlador más; encaja con GitOps |
| External Secrets Operator | Sincroniza Secret desde Secrets Manager, SSM o Vault |
Un operador más; la mejor opción con AWS (08-03) |
Nunca se versiona un Secret con valores reales. Se versiona la plantilla con REEMPLAZAR, y el valor real llega por kubectl create secret --from-literal en la creación inicial, o —lo correcto— por External Secrets desde el almacén de 08-03.
- El
Deployment de CicloUrbana
Deployment de CicloUrbana# 03-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: ciclourbana
namespace: ciclourbana-prod
labels: { app: ciclourbana }
spec:
replicas: 3
revisionHistoryLimit: 5
selector:
matchLabels: { app: ciclourbana }
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1 # como maximo 1 pod extra durante la actualizacion
maxUnavailable: 0 # nunca por debajo de 3 pods sanos
template:
metadata:
labels: { app: ciclourbana, version: "2.4.0" }
spec:
terminationGracePeriodSeconds: 60
securityContext: { runAsNonRoot: true, runAsUser: 10001, fsGroup: 10001, seccompProfile: { type: RuntimeDefault } }
containers:
- name: ciclourbana
image: ghcr.io/ayuntamiento-ribalta/ciclourbana:2.4.0
imagePullPolicy: IfNotPresent
ports:
- { name: http, containerPort: 8080 }
- { name: gestion, containerPort: 8081 }
envFrom:
- configMapRef: { name: ciclourbana-config }
- secretRef: { name: ciclourbana-secretos }
resources:
requests: { cpu: "300m", memory: "768Mi" }
limits: { cpu: "1", memory: "1Gi" }
securityContext: { allowPrivilegeEscalation: false, readOnlyRootFilesystem: true, capabilities: { drop: ["ALL"] } }
volumeMounts: [{ name: temporal, mountPath: /tmp }]
startupProbe: # hasta 120 s para arrancar
httpGet: { path: /actuator/health/liveness, port: gestion }
periodSeconds: 5
failureThreshold: 24
livenessProbe:
httpGet: { path: /actuator/health/liveness, port: gestion }
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
readinessProbe:
httpGet: { path: /actuator/health/readiness, port: gestion }
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
lifecycle: { preStop: { exec: { command: ["sh", "-c", "sleep 10"] } } }
volumes: [{ name: temporal, emptyDir: { sizeLimit: 256Mi } }]Los puntos que importan de verdad:
resources y su relación con MaxRAMPercentage. requests es lo que el planificador reserva para colocar el pod; limits es el techo duro. Superar el límite de memoria no produce una excepción: el kernel mata el proceso (OOMKilled). Con limits.memory: 1Gi y MaxRAMPercentage=75 del ConfigMap, el heap máximo es 768 MB y los 256 MB restantes cubren metaespacio, pilas de hilos, búferes directos y código nativo. Poner el 100 % es la receta garantizada del OOMKilled sin traza. En CPU, limits: 1 significa un núcleo: si se pone un límite bajo, el throttling del kernel alarga el arranque de la JVM y puede hacer fallar el startupProbe — motivo por el que requests.cpu conviene generoso aquí.
securityContext en dos niveles. El del pod fija el usuario sin privilegios (coherente con el usuario no root de la imagen de 07-04); el del contenedor prohíbe escalar privilegios, elimina todas las capacidades del kernel y monta el sistema de ficheros raíz de solo lectura. Esto último rompe la aplicación si algo necesita escribir —Tomcat usa /tmp para subidas y trabajo interno—, y por eso se monta un emptyDir en /tmp: un volumen efímero, propio de cada pod, que desaparece con él. Es el factor 6 de 08-01 impuesto por el sistema.
envFrom con configMapRef y secretRef inyecta de una vez todas las claves de ambos objetos como variables de entorno, así que añadir una propiedad es editar el ConfigMap sin tocar el Deployment. Contrapartida: cambiar un ConfigMap no reinicia los pods, y la nueva configuración no se aplica hasta un kubectl rollout restart deploy/ciclourbana.
preStop y terminationGracePeriodSeconds, casados con el apagado ordenado. La secuencia completa al retirar un pod:
sequenceDiagram
participant K as Kubernetes
participant P as Pod CicloUrbana
participant S as Service / Endpoints
K->>S: eliminar el pod de los endpoints
K->>P: ejecutar preStop (sleep 10)
Note over P: sigue atendiendo peticiones<br/>mientras el Service propaga el cambio
K->>P: SIGTERM
Note over P: graceful shutdown (01-05):<br/>termina lo en curso, cierra ejecutores y pool
Note over K: si a los 60 s sigue vivo -> SIGKILL
El sleep 10 del preStop no es un truco sucio: es la solución estándar al hecho de que la eliminación del endpoint y el envío del SIGTERM ocurren en paralelo, no en secuencia. Sin ese margen, durante un par de segundos hay tráfico dirigido a un pod que ya está cerrando, y los ciudadanos ven 502. Y la aritmética debe cuadrar: terminationGracePeriodSeconds (60) > preStop (10) + timeout-per-shutdown-phase (40), o Kubernetes matará el proceso a mitad del apagado ordenado.
- Las tres sondas y los grupos de salud de 07-01
Este es el apartado donde 07-01 cobra todo su sentido. Kubernetes hace tres preguntas distintas y actúa distinto según la respuesta:
| Sonda | Pregunta | Si falla, Kubernetes... | Endpoint |
|---|---|---|---|
| startupProbe | ¿Ha terminado de arrancar? | Sigue esperando; suspende las otras dos | /actuator/health/liveness |
| livenessProbe | ¿El proceso está irrecuperable? | Mata el contenedor y lo reinicia | /actuator/health/liveness |
| readinessProbe | ¿Puede atender peticiones ahora? | Lo saca del Service, sin matarlo |
/actuator/health/readiness |
Y la configuración que ya escribimos en 07-01 hace que esos endpoints respondan lo correcto:
management:
server:
port: 8081
endpoint:
health:
probes:
enabled: true
group:
readiness: { include: db } # incluye la base de datos
liveness: { include: livenessState } # NO incluye la base de datosPor qué confundirlas provoca reinicios en bucle. Supón que alguien pone la base de datos en el grupo liveness, o que apunta la livenessProbe a /actuator/health completo. RDS tiene un mantenimiento de 40 segundos:
Los tres pods dejan de alcanzar la base de datos y /actuator/health devuelve 503; la livenessProbe falla tres veces seguidas en los tres; Kubernetes mata los tres contenedores a la vez; arrancan de nuevo, la base de datos sigue en mantenimiento y vuelven a morir; y entonces se aplica el retroceso exponencial —CrashLoopBackOff, con esperas de 10, 20, 40 segundos hasta llegar a 5 minutos—. Un incidente de 40 segundos se convierte en uno de veinte, y es el propio orquestador quien lo alarga.
Con la configuración correcta, lo que ocurre es muy distinto: readiness falla, los pods salen del Service, el Ingress devuelve 503 durante los 40 segundos —lo cual es honesto: la aplicación de verdad no puede trabajar— y liveness sigue en verde porque el proceso está perfectamente sano. Cuando la base de datos vuelve, readiness pasa a UP y los pods reciben tráfico otra vez. Sin un solo reinicio.
Y la startupProbe resuelve el conflicto clásico de la JVM. Una aplicación Spring Boot con Hibernate tarda 25-45 segundos en arrancar, y una livenessProbe con failureThreshold: 3 y periodSeconds: 10 la mataría a los 30, antes de que llegara a estar viva. La solución antigua —un initialDelaySeconds grande— retrasa la detección de fallos durante toda la vida del pod; la startupProbe lo separa: da hasta 120 segundos para arrancar (24 × 5 s) y una vez superada cede el control a las otras dos, que ya pueden ser agresivas y detectar un fallo real en 30 segundos.
Service e Ingress con TLS
Service e Ingress con TLS# 04-service.yaml
apiVersion: v1
kind: Service
metadata:
name: ciclourbana
namespace: ciclourbana-prod
spec:
type: ClusterIP # solo accesible dentro del cluster
selector: { app: ciclourbana }
ports:
- { name: http, port: 8080, targetPort: http }ClusterIP es deliberado: el Service no publica nada al exterior, solo da un nombre DNS interno estable (ciclourbana.ciclourbana-prod.svc.cluster.local) sobre los pods que estén ready en cada momento; quien expone al exterior es el Ingress. Y fíjate en que el puerto 8081 de gestión no se publica: las sondas lo consultan directamente en el pod, así que Actuator queda inaccesible desde fuera del clúster por construcción — la separación de puertos de 07-01 haciendo su trabajo.
# 05-ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: ciclourbana
namespace: ciclourbana-prod
annotations:
cert-manager.io/cluster-issuer: "letsencrypt-prod"
nginx.ingress.kubernetes.io/ssl-redirect: "true"
spec:
ingressClassName: nginx
tls:
- { hosts: [ciclourbana.ribalta.example], secretName: ciclourbana-tls } # cert-manager lo crea y renueva
rules:
- host: ciclourbana.ribalta.example
http:
paths:
- path: /
pathType: Prefix
backend:
service: { name: ciclourbana, port: { name: http } }Un Ingress es solo una declaración: hace falta un controlador que la implemente (ingress-nginx, Traefik, o el del proveedor). La anotación cert-manager.io/cluster-issuer hace que cert-manager —un operador que hay que instalar— solicite el certificado a Let's Encrypt, valide el dominio, guarde el certificado en el Secret indicado y lo renueve automáticamente antes de caducar. Es el equivalente de ACM en 08-03.
Como el controlador termina el TLS y habla HTTP con los pods, sigue haciendo falta lo de 08-01, que ya está en el ConfigMap: SERVER_FORWARD_HEADERS_STRATEGY: framework.
- Migraciones de Flyway:
Job o initContainer
Job o initContainerEl ConfigMap desactivó Flyway. La razón es la misma de 08-01 y 08-03, aquí más aguda: con 3 réplicas, tres pods arrancarían a la vez e intentarían migrar simultáneamente. Flyway lo serializa con un bloqueo en la base de datos, así que no corrompe nada, pero los pods que esperan consumen su ventana de arranque, y si la migración falla los tres entran en CrashLoopBackOff y la aplicación queda caída aunque la versión anterior funcionase.
# 06-job-migracion.yaml
apiVersion: batch/v1
kind: Job
metadata:
name: ciclourbana-migracion-2-4-0 # el nombre incluye la version: cada despliegue, un Job
namespace: ciclourbana-prod
spec:
backoffLimit: 2 # como maximo 3 intentos
ttlSecondsAfterFinished: 3600 # se autoelimina en 1 h
template:
spec:
restartPolicy: Never # un Job NO reinicia: falla o completa
containers:
- name: migracion
image: ghcr.io/ayuntamiento-ribalta/ciclourbana:2.4.0 # LA MISMA imagen
args:
- "--spring.flyway.enabled=true"
- "--spring.main.web-application-type=none"
envFrom:
- configMapRef: { name: ciclourbana-config }
- secretRef: { name: ciclourbana-secretos }
resources: { requests: { cpu: "200m", memory: "512Mi" }, limits: { cpu: "1", memory: "1Gi" } }Un Job ejecuta el pod hasta que termine con éxito. web-application-type=none levanta el contexto sin Tomcat: Flyway migra y el proceso sale. La secuencia del despliegue es entonces:
kubectl apply -f 06-job-migracion.yaml
kubectl wait --for=condition=complete --timeout=600s job/ciclourbana-migracion-2-4-0 -n ciclourbana-prod
kubectl set image deploy/ciclourbana ciclourbana=ghcr.io/ayuntamiento-ribalta/ciclourbana:2.4.0 -n ciclourbana-prod
kubectl rollout status deploy/ciclourbana -n ciclourbana-prodSi el kubectl wait falla, no se toca el Deployment: la versión anterior sigue sirviendo a Ribalta con total normalidad.
La alternativa del initContainer ejecuta la migración dentro de cada pod antes del contenedor principal. Es más simple de escribir, pero N réplicas ejecutan N migraciones: con el bloqueo de Flyway no se corrompe nada, pero se multiplica el trabajo, se alarga el arranque de todos los pods y se pierde la propiedad más valiosa del Job —que un fallo de migración detenga el despliegue antes de tocar los pods vivos—. Su único caso razonable es un Deployment con una sola réplica.
Y el recordatorio de siempre: durante el rolling update conviven 2.3.0 y 2.4.0 contra el esquema ya migrado, así que cada migración debe ser compatible hacia atrás — expand/contract de 04-08.
- Despliegue rolling,
rollout status y rollout undo
rollout status y rollout undoCon maxSurge: 1 y maxUnavailable: 0 sobre 3 réplicas, Kubernetes crea un cuarto pod con la versión nueva, espera a que su readinessProbe pase, lo añade al Service y solo entonces retira uno viejo. Y repite. En ningún momento hay menos de 3 pods sanos atendiendo.
Los tres ajustes habituales: maxUnavailable: 0 con maxSurge: 1 garantiza la capacidad a cambio de un despliegue más lento y de necesitar margen en el clúster; maxUnavailable: 1 con maxSurge: 1 es más rápido pero admite un momento con solo 2 pods; y maxSurge: 3 es el más rápido, duplicando el consumo durante la transición.
kubectl rollout status deploy/ciclourbana -n ciclourbana-prod # espera y falla si no progresa
kubectl rollout history deploy/ciclourbana -n ciclourbana-prod # revisiones
kubectl rollout undo deploy/ciclourbana [--to-revision=7] # revertir
kubectl rollout restart deploy/ciclourbana # reiniciar sin cambiar la imagenrollout status es la pieza que la canalización de 08-05 usa como criterio de éxito: devuelve código distinto de cero si la actualización no progresa en el plazo (progressDeadlineSeconds, 600 s por defecto), lo que permite automatizar la reversión.
Y la advertencia de 08-01, que aquí es literal: rollout undo revierte la aplicación, no la base de datos. La migración aplicada sigue aplicada. Si el esquema no es compatible hacia atrás, revertir empeora la situación en lugar de arreglarla.
HPA y PodDisruptionBudget
HPA y PodDisruptionBudget# 07-hpa.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata: { name: ciclourbana, namespace: ciclourbana-prod }
spec:
scaleTargetRef: { apiVersion: apps/v1, kind: Deployment, name: ciclourbana }
minReplicas: 3
maxReplicas: 10
metrics:
- type: Resource
resource: { name: cpu, target: { type: Utilization, averageUtilization: 70 } }
behavior:
scaleUp: { stabilizationWindowSeconds: 30 }
scaleDown: { stabilizationWindowSeconds: 300 }La relación entre el HPA y resources es la clave y casi nadie la ve a la primera: averageUtilization: 70 no significa el 70 % de un núcleo, sino el 70 % de requests.cpu. Con requests.cpu: 300m, el objetivo es 210 milicores por pod. De ahí dos consecuencias prácticas: si requests está muy bajo, el HPA escala constantemente por nada; si está muy alto, no escala nunca aunque los pods sufran. Sin requests.cpu, el HPA no funciona en absoluto y kubectl describe hpa muestra <unknown> en la columna de métricas.
Y como en 08-03: maxReplicas × maximum-pool-size + margen < max_connections de PostgreSQL. Con 10 réplicas y un pool de 10 son 100 conexiones, más el Job de migración y la administración. Hay que comprobarlo antes de subir maxReplicas, no cuando el pico de tráfico produzca too many clients.
# 08-pdb.yaml
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata: { name: ciclourbana, namespace: ciclourbana-prod }
spec:
minAvailable: 2
selector: { matchLabels: { app: ciclourbana } }El PodDisruptionBudget protege frente a las interrupciones voluntarias: cuando un administrador vacía un nodo (kubectl drain) o el autoescalador de nodos consolida máquinas, Kubernetes respeta el presupuesto y no retira pods si eso dejaría menos de 2 disponibles. No protege de fallos involuntarios —un nodo que muere se lleva sus pods igualmente— pero elimina la causa más frecuente de caídas evitables en Kubernetes: una actualización del clúster que vacía dos nodos a la vez y se lleva todas las réplicas.
- Empaquetar con Helm
Los ocho manifiestos anteriores describen un entorno. Para pre hacen falta otros ocho casi idénticos, con distinto namespace, réplicas, base de datos y dominio: copiar y pegar es la garantía de que dentro de tres meses pre y prod diverjan sin que nadie sepa en qué. Helm es el gestor de paquetes de Kubernetes: convierte los manifiestos en plantillas y separa los valores por entorno.
charts/ciclourbana/
├── Chart.yaml # nombre, version del chart, appVersion
├── values.yaml # valores por defecto
├── values-pre.yaml # solo lo que cambia en pre
├── values-prod.yaml # solo lo que cambia en prod
└── templates/ # configmap, deployment, service, ingress,
│ # hpa, pdb, job-migracion...
└── _helpers.tpl # funciones de nombres y etiquetas# values.yaml
replicaCount: 3
image: { repositorio: ghcr.io/ayuntamiento-ribalta/ciclourbana, tag: "2.4.0" }
recursos: { requests: { cpu: 300m, memory: 768Mi }, limits: { cpu: "1", memory: 1Gi } }
ingress: { host: ciclourbana.ribalta.example }
config: { perfil: prod, urlBaseDatos: "jdbc:postgresql://ciclourbana-prod...:5432/ciclourbana?sslmode=require" }
autoescalado: { activo: true, min: 3, max: 10, cpuObjetivo: 70 }# values-pre.yaml — SOLO las diferencias
replicaCount: 1
recursos:
requests: { cpu: 100m, memory: 512Mi }
limits: { cpu: 500m, memory: 768Mi }
ingress: { host: pre.ciclourbana.ribalta.example }
config: { perfil: pre, urlBaseDatos: "jdbc:postgresql://ciclourbana-pre...:5432/ciclourbana?sslmode=require" }
autoescalado: { activo: false }Y la plantilla consume esos valores:
# templates/deployment.yaml (fragmento)
spec:
replicas: {{ .Values.replicaCount }}
template:
spec:
containers:
- name: ciclourbana
image: "{{ .Values.image.repositorio }}:{{ .Values.image.tag }}"
resources: {{- toYaml .Values.recursos | nindent 12 }}helm lint ./charts/ciclourbana
helm template ciclourbana ./charts/ciclourbana -f values-pre.yaml # ver el YAML final sin aplicar
helm install ciclourbana ./charts/ciclourbana -n ciclourbana-pre -f values-pre.yaml --create-namespace
helm upgrade ciclourbana ./charts/ciclourbana -n ciclourbana-prod -f values-prod.yaml \
--set image.tag=2.4.1 --atomic --timeout 5m
helm rollback ciclourbana -n ciclourbana-prod # a la revision anterior--atomic es la opción que más tranquilidad da: si la actualización no termina bien en el plazo, Helm revierte automáticamente al estado anterior. Y --set image.tag=2.4.1 es la línea exacta que ejecutará la canalización de 08-05.
Alternativa: Kustomize, integrado en kubectl (kubectl apply -k), que no usa plantillas sino parches sobre una base común:
| Helm | Kustomize | |
|---|---|---|
| Mecanismo | Plantillas Go con valores | Parches sobre manifiestos base |
| Curva e instalación | Media; herramienta aparte | Baja (YAML sobre YAML); incluido en kubectl |
| Distribuir a terceros | Sí: repositorios de charts | No pensado para ello |
| Estado de la instalación | Sí, con historial y rollback |
No: solo apply |
Regla práctica: Kustomize si solo cambian unos pocos valores entre pre y prod; Helm si el chart tiene lógica, condicionales o hay que distribuirlo. Para CicloUrbana, Helm compensa por --atomic, rollback y el historial de versiones.
- PostgreSQL dentro o fuera del clúster
Se puede ejecutar PostgreSQL en Kubernetes con un StatefulSet y un PVC, o con un operador serio como CloudNativePG o Zalando. Pero:
| Gestionada (RDS, Cloud SQL) | Dentro del clúster | |
|---|---|---|
| Copias, PITR y actualizaciones | Automáticas y probadas por el proveedor | Tú las configuras, tú las pruebas |
| Alta disponibilidad | Multi-AZ con conmutación automática | Un operador que hay que entender y operar |
| Rendimiento del almacenamiento | Optimizado | Depende del StorageClass y la red |
| Riesgo de pérdida de datos | Bajo | Alto si no se domina el operador |
| Coste | Mayor en factura | Menor en factura, mucho mayor en tiempo |
El curso recomienda sin matices la base de datos gestionada. Kubernetes está diseñado para cargas sin estado y desechables —pods que se matan y se recrean sin consecuencias— y una base de datos es exactamente lo contrario. Los operadores modernos son buenos, pero el día que un PVC se corrompe o un fallo de red separa el primario de la réplica, hace falta alguien que sepa de PostgreSQL y de Kubernetes a la vez. Ese perfil es escaso, y el ayuntamiento de Ribalta no lo tiene.
Excepción razonable: PostgreSQL dentro del clúster en dev y en las pruebas, donde perder los datos no cuesta nada.
- Observabilidad y depuración de un pod que no arranca
kubectl logs deploy/ciclourbana -n ciclourbana-prod -f --tail=100
kubectl logs <pod> --previous # el log del contenedor que MURIO: clave en CrashLoopBackOff
kubectl get events -n ciclourbana-prod --sort-by=.lastTimestampLos logs de kubectl logs salen del stdout del contenedor y se pierden cuando el pod desaparece: en producción hace falta un agente que los reenvíe a un agregador (Fluent Bit, Vector o el del proveedor), que es el tema de 09-05.
El método de depuración, en este orden exacto:
kubectl get pods -n ciclourbana-prod # 1. ¿en qué estado está?
kubectl describe pod <pod> -n ciclourbana-prod # 2. sección Events, al final: casi siempre está ahí
kubectl logs <pod> --previous # 3. si reinició, el log del contenedor anterior| Estado | Significado | Causas habituales |
|---|---|---|
Pending |
No se ha podido colocar | No hay nodo con CPU/memoria suficientes para los requests; PVC sin enlazar |
ImagePullBackOff |
No puede descargar la imagen | Etiqueta inexistente, registro privado sin imagePullSecrets, error de nombre |
CreateContainerConfigError |
No puede construir el contenedor | El ConfigMap o el Secret referenciado no existe |
CrashLoopBackOff |
Arranca y muere repetidamente | Excepción en el arranque, migración fallida, liveness mal configurada |
Running pero 0/1 READY |
Vivo pero no listo | readinessProbe fallando: casi siempre no alcanza la base de datos |
OOMKilled |
El kernel lo mató por memoria | limits.memory bajo o MaxRAMPercentage demasiado alto |
OOMKilled en una JVM merece explicación aparte, porque despista. No es un OutOfMemoryError de Java: no hay traza, no hay excepción, no hay nada en el log. El proceso simplemente desaparece y kubectl describe pod muestra Last State: Terminated, Reason: OOMKilled, Exit Code: 137, porque la JVM pidió al sistema más memoria de la que el cgroup permite y el kernel la mató. Las causas, por frecuencia: MaxRAMPercentage demasiado alto o ausente (el heap crece hasta el límite y el resto de la memoria de la JVM no cabe); limits.memory insuficiente para lo que la aplicación necesita de verdad; y, en último lugar, una fuga real, que conviene diagnosticar con -XX:+HeapDumpOnOutOfMemoryError y un volumen donde escribir el volcado.
Y la distinción práctica: un OutOfMemoryError de Java sí deja traza en el log y, con ExitOnOutOfMemoryError (07-04), sale con código 1. Un OOMKilled sale con 137 y no deja nada. Si ves 137, es el kernel; si ves 1 con traza, es el heap.
Para depurar un pod con readOnlyRootFilesystem y sin shell existe kubectl debug -it <pod> --image=busybox --target=ciclourbana, que adjunta al pod en ejecución un contenedor efímero con herramientas.
- GitOps y Spring Cloud Kubernetes
GitOps invierte la dirección del despliegue: en lugar de que la canalización empuje cambios al clúster con kubectl (lo que exige darle credenciales de administrador), un agente dentro del clúster vigila un repositorio Git y aplica lo que encuentra.
El flujo queda: el desarrollador abre un pull request sobre el repositorio de manifiestos, la canalización se limita a actualizar image.tag en ese repositorio, y Argo CD —que vive dentro del clúster— detecta el cambio, lo sincroniza y compara continuamente el estado real con el declarado.
Ventajas concretas: Git es la única fuente de verdad y su historial dice quién desplegó qué y cuándo; la reversión es un git revert; la canalización no necesita credenciales del clúster, lo que reduce mucho la superficie de ataque (08-05); y el agente detecta la deriva, marcando como OutOfSync cualquier cambio hecho a mano. Las herramientas de referencia son Argo CD (interfaz web muy visual) y Flux (más ligero, integrado con Helm).
Spring Cloud Kubernetes, por último, permite a la aplicación leer ConfigMap y Secret como fuentes de propiedades, descubrir servicios por la API de Kubernetes y recargar configuración en caliente. A menudo no hace falta, y es importante saber por qué: montar el ConfigMap como variables (nuestro envFrom) ya resuelve la configuración con cero dependencias; el descubrimiento lo hace el DNS interno, que funciona con cualquier cliente HTTP —el RestClient de 07-06 llama a http://patinetes:8080 sin más—; y la recarga en caliente añade complejidad frente a un kubectl rollout restart, que es explícito, auditable y siempre funciona. Además obligaría a dar a la aplicación permisos RBAC sobre la API, ampliando su superficie de ataque. Vale la pena con decenas de servicios y configuración centralizada; para CicloUrbana, el ConfigMap por envFrom es la respuesta correcta y mantiene el artefacto agnóstico de la plataforma, como pide 08-01.
Errores Comunes y Consejos
Poner la base de datos en el grupo liveness. Un mantenimiento de 40 segundos reinicia todos los pods y provoca CrashLoopBackOff durante veinte minutos. Vida = proceso sano; disponibilidad = puede trabajar.
No poner startupProbe. La livenessProbe mata la JVM antes de que termine de arrancar y el pod nunca llega a estar listo. Y MaxRAMPercentage sin margen produce OOMKilled con código 137 y nada en el log: deja el 25 %.
Olvidar requests.cpu. El HPA no puede calcular la utilización, muestra <unknown> y no escala jamás. Y readOnlyRootFilesystem sin montar /tmp hace fallar a Tomcat al escribir su directorio de trabajo, con un error de arranque poco evidente.
Secretos en base64 dados por cifrados. Base64 se deshace con un comando. Usa RBAC estricto, cifrado en reposo y External Secrets o Sealed Secrets.
Cambiar un ConfigMap esperando que se aplique solo. Los pods conservan las variables con las que arrancaron: hace falta kubectl rollout restart. Y migrar desde un initContainer con varias réplicas significa N pods, N migraciones, y un fallo que tumba el despliegue entero en lugar de detenerlo antes.
Consejo: kubectl describe pod antes que cualquier otra cosa. La sección Events del final explica en texto claro el 80 % de los problemas.
Consejo: verifica antes de aplicar. helm template muestra el YAML final resuelto y kubectl apply --dry-run=server -f lo valida contra la API real sin crear nada: los dos pasos de verificación de la canalización.
Consejo: etiqueta todo con app, version y entorno. Los selectores, las consultas de logs y las métricas dependen de ello.
Ejercicios
Ejercicio 1
Escribe el Deployment de CicloUrbana para el entorno pre: 1 réplica, requests de 200m/512Mi y limits de 500m/768Mi, imagen 2.5.0-rc1, las tres sondas correctamente configuradas y securityContext endurecido. Calcula qué valor de MaxRAMPercentage corresponde y justifica cada failureThreshold sabiendo que en pre la aplicación tarda unos 50 segundos en arrancar por tener menos CPU.
Ejercicio 2
Se despliega la versión 2.4.0 y kubectl get pods muestra durante diez minutos:
NAME READY STATUS RESTARTS AGE
ciclourbana-7d4b8c9f5-2xk9p 0/1 CrashLoopBackOff 6 9m
ciclourbana-7d4b8c9f5-8mq2w 0/1 CrashLoopBackOff 6 9m
ciclourbana-7d4b8c9f5-p4v7t 0/1 CrashLoopBackOff 6 9mDescribe el procedimiento de diagnóstico paso a paso y desarrolla las cinco causas más probables para este síntoma concreto, con la evidencia que confirma cada una y su corrección. Ten en cuenta que la versión 2.3.0 funcionaba correctamente hasta hace diez minutos.
Ejercicio 3
El ayuntamiento quiere desplegar CicloUrbana en pre y prod desde el mismo código, con un solo comando por entorno y sin duplicar manifiestos. Diseña el chart de Helm: qué va en values.yaml, qué en values-pre.yaml y values-prod.yaml, cómo se parametriza el Job de migración para que se ejecute una vez por versión, y cuál es la secuencia completa de comandos que ejecutaría la canalización de 08-05 para desplegar la versión 2.4.1 en producción de forma segura y reversible.
Soluciones
Solución 1
Partiendo del Deployment del apartado 5, cambian el namespace a ciclourbana-pre, replicas: 1, la imagen a 2.5.0-rc1 y estos bloques:
resources:
requests: { cpu: "200m", memory: "512Mi" }
limits: { cpu: "500m", memory: "768Mi" }
startupProbe:
httpGet: { path: /actuator/health/liveness, port: gestion }
periodSeconds: 5
failureThreshold: 30 # 150 s: el triple de los 50 s medidos
livenessProbe:
httpGet: { path: /actuator/health/liveness, port: gestion }
periodSeconds: 10
failureThreshold: 3 # 30 s para detectar un proceso irrecuperable
readinessProbe:
httpGet: { path: /actuator/health/readiness, port: gestion }
periodSeconds: 5
failureThreshold: 3 # 15 s para salir de rotacionEl securityContext endurecido (runAsNonRoot, runAsUser: 10001, allowPrivilegeEscalation: false, readOnlyRootFilesystem: true, capabilities: drop: ["ALL"]), el emptyDir en /tmp, el preStop y terminationGracePeriodSeconds: 60 se mantienen idénticos: no dependen del entorno.
MaxRAMPercentage. Con limits.memory: 768Mi, el 75 % son 576 MB de heap y quedan 192 MB para lo demás. En una JVM con Hibernate y Spring Security, metaespacio (~90 MB), pilas de hilos (~40 MB) y búferes directos rondan ya los 150-180 MB: es demasiado justo. En contenedores pequeños hay que ser más conservador: MaxRAMPercentage=60 (460 MB de heap, 308 MB de margen). Es un principio general y contraintuitivo: cuanto más pequeño el contenedor, menor debe ser el porcentaje, porque los costes fijos de la JVM no escalan con el tamaño.
Los failureThreshold. El startupProbe es el único que debe acomodar el arranque: 50 s medidos, pero con limits.cpu: 500m —medio núcleo— la JVM sufre throttling y en un día de carga puede tardar el doble, así que 30 intentos × 5 s = 150 s dan un margen de tres veces sin ser absurdo. Una vez superado, liveness con 3 × 10 s detecta un proceso irrecuperable en 30 segundos, y readiness con 3 × 5 s saca el pod de rotación en 15. Con replicas: 1, readiness fallando significa 503 para todo pre, lo cual es correcto y honesto: no hay a quién enviar el tráfico.
Solución 2
Procedimiento. Primero kubectl describe pod ciclourbana-7d4b8c9f5-2xk9p -n ciclourbana-prod y leer la sección Events y el campo Last State (razón y código de salida). Después kubectl logs ciclourbana-7d4b8c9f5-2xk9p --previous, que es la parte que más se olvida: sin --previous se pide el log del contenedor actual, que aún no ha escrito nada. Y en paralelo, kubectl get events --sort-by=.lastTimestamp y kubectl rollout history para ver qué cambió respecto a 2.3.0.
Las cinco causas, con su evidencia:
1. La migración de Flyway falla y ddl-auto: validate rechaza el esquema. Primera sospecha, porque 2.3.0 funcionaba: lo que cambió es la versión y con ella el esquema esperado. Evidencia: en el log previo, FlywayException o Schema-validation: missing column [plazas_totales] in table [estaciones]. Corrección: revisar el Job (kubectl logs job/ciclourbana-migracion-2-4-0); si falló y el Deployment se actualizó igualmente, se ha saltado el kubectl wait — revertir solo si el esquema sigue siendo compatible y arreglar la canalización para que el despliegue dependa del Job.
2. OOMKilled. Evidencia: Last State: Terminated, Reason: OOMKilled, Exit Code: 137, y el log previo no muestra ningún error: la aplicación arrancaba con normalidad y desaparece. Corrección: subir limits.memory o bajar MaxRAMPercentage; si 2.4.0 añadió una caché o una consulta que carga muchos objetos, el límite se ha quedado corto.
3. livenessProbe demasiado agresiva o mal apuntada. Evidencia: en Events, Liveness probe failed: HTTP probe failed with statuscode: 503 seguido de Killing container, y en el log previo la aplicación llega a Started CicloUrbanaApplication y muere poco después. Corrección: verificar que liveness incluye solo livenessState y no db, y que hay startupProbe; si 2.4.0 arranca más lento que 2.3.0, la sonda que antes bastaba ahora no llega.
4. Un Secret o ConfigMap que falta o cambió. Evidencia: el estado sería CreateContainerConfigError si el objeto no existe; si existe con un valor mal, el log previo muestra Could not resolve placeholder 'JWT_SECRETO' o un fallo de autenticación contra PostgreSQL. Corrección: kubectl get secret ciclourbana-secretos -o yaml y comprobar que están todas las claves que 2.4.0 necesita — una propiedad nueva en el código y no añadida al ConfigMap produce exactamente esto.
5. La imagen no es la que se cree. Evidencia: un Image ID que no corresponde, o exec format error en el log previo (imagen arm64 construida en Apple Silicon, 08-03). Corrección: reconstruir con --platform linux/amd64 y usar etiquetas inmutables.
Regla de oro para este escenario: los tres pods fallan igual, así que no es un problema de un nodo ni de infraestructura; es la versión nueva. Y la acción inmediata correcta —antes de investigar— es kubectl rollout undo deploy/ciclourbana, siempre que el esquema siga siendo compatible hacia atrás. Si la causa 1 es la real y la migración fue destructiva, revertir no arregla nada: es el escenario del ejercicio 3 de 08-01.
Solución 3
Reparto de valores. En values.yaml va todo lo común y lo que rara vez cambia: repositorio de la imagen, puertos, las tres sondas con sus umbrales, securityContext, preStop, terminationGracePeriodSeconds, etiquetas y las anotaciones del Ingress. En values-pre.yaml y values-prod.yaml, solo las diferencias: replicaCount, recursos, ingress.host, config.perfil, config.urlBaseDatos, autoescalado y el nombre del Secret. El criterio: si un valor es igual en los dos entornos, no puede estar en los ficheros por entorno, o antes o después divergirán sin motivo.
El Job parametrizado por versión. La clave es que el nombre incluya la etiqueta de la imagen, para que cada despliegue cree un Job distinto (Kubernetes rechazaría recrear uno con el mismo nombre) y quede el rastro de cada migración:
# templates/job-migracion.yaml (fragmento)
metadata:
name: {{ include "ciclourbana.fullname" . }}-migracion-{{ .Values.image.tag | replace "." "-" }}
annotations:
"helm.sh/hook": pre-upgrade,pre-install
"helm.sh/hook-weight": "-5"
"helm.sh/hook-delete-policy": before-hook-creation
spec:
backoffLimit: 2
template:
spec:
restartPolicy: Never
containers:
- name: migracion
image: "{{ .Values.image.repositorio }}:{{ .Values.image.tag }}"
args: ["--spring.flyway.enabled=true", "--spring.main.web-application-type=none"]Los hooks de Helm son la pieza elegante: pre-upgrade hace que Helm ejecute el Job y espere a que termine con éxito antes de tocar el Deployment. Si falla, helm upgrade aborta y los pods siguen con la versión anterior — exactamente la propiedad que buscábamos en el apartado 8, ahora sin scripts intermedios.
Secuencia de la canalización para 2.4.1:
# 1. Verificacion estatica, sin tocar el cluster
helm lint ./charts/ciclourbana
helm template ciclourbana ./charts/ciclourbana -f values-prod.yaml --set image.tag=2.4.1 | kubectl apply --dry-run=server -f -
# 2. Desplegar en pre y esperar a que converja
helm upgrade --install ciclourbana ./charts/ciclourbana -n ciclourbana-pre -f values-pre.yaml --set image.tag=2.4.1 --atomic --timeout 8m
# 3. Prueba de humo contra pre
curl -fsS https://pre.ciclourbana.ribalta.example/actuator/health/readiness
curl -fsS https://pre.ciclourbana.ribalta.example/api/v1/estaciones | jq 'length'
# 4. Aprobacion manual (entorno protegido de GitHub Actions, 08-05)
# 5. Produccion: el hook migra, y si falla no se toca el Deployment
helm upgrade ciclourbana ./charts/ciclourbana -n ciclourbana-prod -f values-prod.yaml --set image.tag=2.4.1 --atomic --timeout 10m
# 6. Verificar que corre lo que se cree, y revertir si no
kubectl rollout status deploy/ciclourbana -n ciclourbana-prod
curl -fsS https://ciclourbana.ribalta.example/actuator/info | jq '.build.version'
helm rollback ciclourbana -n ciclourbana-prodPor qué es seguro y reversible. --atomic revierte solo si el despliegue no converge en el plazo; el hook pre-upgrade garantiza que una migración fallida detenga el proceso antes de tocar los pods vivos; helm rollback vuelve a la revisión anterior en un comando; el /actuator/info con build-info de 07-01 confirma qué versión corre de verdad; y la condición de fondo, la de siempre: la reversión solo funciona si la migración de 2.4.1 es compatible hacia atrás (expand/contract, 04-08).
Conclusión
CicloUrbana corre ahora en un orquestador, y lo hace sabiendo por qué y cuándo eso tiene sentido. La lección empezó con una advertencia que conviene no olvidar: para una sola aplicación, ECS o un PaaS suelen bastar y suelen ser mejores, y Kubernetes solo compensa cuando hay muchos servicios, varios equipos autónomos, exigencia de portabilidad o automatización avanzada. Con esa honestidad de partida, has construido la red de Ribalta completa sobre el clúster.
Conoces la arquitectura —plano de control, etcd, planificador, controladores y kubelet— y el modelo mental que la explica: se declara el estado deseado y los controladores reducen la diferencia con la realidad. Manejas los objetos que importan y tienes los manifiestos comentados: un Namespace por entorno, un ConfigMap con la configuración no sensible y un Secret con la advertencia clara de que base64 no es cifrado, junto con las soluciones reales —RBAC estricto, cifrado en reposo, Sealed Secrets y External Secrets Operator, la mejor opción cuando el almacén ya está en AWS (08-03)—.
El Deployment reúne todo lo que el curso venía preparando: la imagen de 07-04, requests y limits casados con MaxRAMPercentage —con el principio de que cuanto más pequeño el contenedor, menor debe ser el porcentaje—, variables desde configMapRef y secretRef, un securityContext con usuario no root, capacidades eliminadas y sistema de ficheros de solo lectura con /tmp en un emptyDir, y el preStop de diez segundos con terminationGracePeriodSeconds holgado que hace encajar el apagado ordenado de 01-05 con la propagación de los endpoints. Y sobre todo, las tres sondas: startupProbe para que la JVM tenga tiempo de arrancar sin que liveness la mate, livenessProbe sobre livenessState para reiniciar solo lo irrecuperable, y readinessProbe sobre el grupo que incluye db para salir de rotación sin morir. Sabes exactamente cómo un mantenimiento de 40 segundos de la base de datos se convierte en veinte minutos de CrashLoopBackOff si se confunden, y por qué la configuración de 07-01 lo evita.
Tienes además el Service ClusterIP que no publica el puerto de gestión, el Ingress con TLS renovado por cert-manager, las migraciones en un Job que detiene el despliegue si falla, el rolling update con maxUnavailable: 0 y su rollout undo con la advertencia sobre la base de datos, el HPA con su relación con requests.cpu que casi nadie ve a la primera, el PodDisruptionBudget, y el chart de Helm con values por entorno, hooks para la migración y --atomic. Sabes cuándo Kustomize es mejor, por qué la base de datos debe seguir siendo gestionada, cómo se depura un pod que no arranca —describe primero, Events al final, logs --previous después— y qué significan CrashLoopBackOff, ImagePullBackOff y un OOMKilled con código 137 que no deja rastro en el log.
Y aquí termina el recorrido por las plataformas. CicloUrbana puede desplegarse en un PaaS, en contenedores gestionados o en un orquestador, y en los tres casos las decisiones de fondo son las de 08-01. Pero en las tres lecciones ha habido un actor implícito que seguía siendo humano: alguien que ejecuta git push heroku main, aws ecs update-service o helm upgrade desde su portátil, con sus credenciales, esperando haber probado antes lo que despliega. La última lección del módulo, Integración y Entrega Continua, elimina a ese actor: una canalización que compila, ejecuta las pruebas del módulo 6 —incluidos los Testcontainers—, analiza la calidad, construye y escanea la imagen, la publica etiquetada con el SHA del commit, despliega en pre, pasa las pruebas de humo y, tras una aprobación explícita, la lleva a producción sin que nadie toque un servidor a mano.
Curso de Spring Boot
Módulo 1: Introducción a Spring Boot
- ¿Qué es Spring Boot?
- Configuración de tu Entorno de Desarrollo
- Creando tu Primera Aplicación Spring Boot
- Entendiendo la Estructura del Proyecto
- El Arranque y el Ciclo de Vida de la Aplicación
Módulo 2: Conceptos Básicos de Spring Boot
- Anotaciones de Spring Boot
- Inyección de Dependencias en Spring Boot
- Ámbito y Ciclo de Vida de los Beans
- Configuración de Spring Boot
- Propiedades de Spring Boot
- Autoconfiguración y Starters por Dentro
Módulo 3: Construyendo Servicios Web RESTful
- Introducción a los Servicios Web RESTful
- Creando Controladores REST
- Manejo de Métodos HTTP
- Validación de Datos de Entrada
- DTOs y Mapeo entre Capas
- Manejo de Excepciones en REST
- Documentar la API con OpenAPI
Módulo 4: Acceso a Datos con Spring Boot
- Introducción a Spring Data JPA
- Configuración de Fuentes de Datos
- Creación de Entidades JPA
- Relaciones entre Entidades
- Uso de Repositorios de Spring Data
- Métodos de Consulta en Spring Data JPA
- Transacciones y Gestión de la Persistencia
- Migraciones de Esquema con Flyway
Módulo 5: Seguridad en Spring Boot
- Introducción a Spring Security
- Configuración de Spring Security
- Autenticación y Autorización de Usuarios
- Implementación de Autenticación JWT
- Seguridad a Nivel de Método y Endurecimiento de la API
Módulo 6: Pruebas en Spring Boot
- Introducción a las Pruebas
- Pruebas Unitarias con JUnit
- Simulación con Mockito
- Pruebas de Integración
- Pruebas con Testcontainers
Módulo 7: Funciones Avanzadas de Spring Boot
- Spring Boot Actuator
- Perfiles de Spring Boot
- Tareas Programadas y Ejecución Asíncrona
- Spring Boot con Docker
- Spring Boot y Microservicios
- Comunicación entre Servicios y Tolerancia a Fallos
Módulo 8: Despliegue de Aplicaciones Spring Boot
- Introducción al Despliegue
- Desplegando en Heroku
- Desplegando en AWS
- Desplegando en Kubernetes
- Integración y Entrega Continua
Módulo 9: Rendimiento y Monitoreo
- Ajuste de Rendimiento
- Caché con Spring Cache
- Monitoreo con Spring Boot Actuator
- Uso de Prometheus y Grafana
- Gestión de Registros y Logs
- Trazabilidad Distribuida
