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

  1. Qué problema resuelve un orquestador, y cuándo compensa
  2. Arquitectura y objetos de Kubernetes
  3. Entorno de práctica y kubectl
  4. Namespace, ConfigMap y Secret
  5. El Deployment de CicloUrbana
  6. Las tres sondas y los grupos de salud de 07-01
  7. Service e Ingress con TLS
  8. Migraciones de Flyway: Job o initContainer
  9. Despliegue rolling, rollout status y rollout undo
  10. HPA y PodDisruptionBudget
  11. Empaquetar con Helm
  12. PostgreSQL dentro o fuera del clúster
  13. Observabilidad y depuración de un pod que no arranca
  14. GitOps y Spring Cloud Kubernetes
  15. Errores Comunes y Consejos
  16. Ejercicios

  1. 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í.

  1. 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

  1. Entorno de práctica y kubectl

Para 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ó.

  1. Namespace, ConfigMap y Secret

El 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.

  1. El 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.

  1. 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 datos

Por 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.

  1. 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.

  1. Migraciones de Flyway: Job o initContainer

El 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-prod

Si 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.

  1. Despliegue rolling, rollout status y rollout undo

Con 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 imagen

rollout 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.

  1. 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.

  1. 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.

  1. 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.

  1. 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=.lastTimestamp

Los 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.

  1. 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          9m

Describe 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 rotacion

El 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-prod

Por 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

Módulo 2: Conceptos Básicos de Spring Boot

Módulo 3: Construyendo Servicios Web RESTful

Módulo 4: Acceso a Datos con Spring Boot

Módulo 5: Seguridad en Spring Boot

Módulo 6: Pruebas en Spring Boot

Módulo 7: Funciones Avanzadas de Spring Boot

Módulo 8: Despliegue de Aplicaciones Spring Boot

Módulo 9: Rendimiento y Monitoreo

Módulo 10: Mejores Prácticas y Consejos

© Copyright 2026. Todos los derechos reservados