Todo el módulo se ha ocupado hasta ahora de crecer: más pods, del tamaño correcto, sobre más nodos, y antes de que llegue el tráfico. Pero hay una pregunta que no hemos hecho: ¿qué pasa cuando algo se rompe, o cuando alguien tiene que tocar el clúster?

El próximo martes toca actualizar la versión de Kubernetes de los nodos de Rutas Norte. Alguien ejecutará kubectl drain sobre el primer nodo para vaciarlo. Si en ese nodo están tres de las cuatro réplicas de api-reservas —porque el planificador las puso ahí y nadie le dijo que no lo hiciera—, ese drenaje se llevará el 75 % de la capacidad de la API en un instante, y la cuarta réplica recibirá cuatro veces su carga habitual hasta caer también.

Y hay una variante peor: que el nodo no lo drene nadie, sino que se apague solo por un fallo de hardware en la zona de disponibilidad B, a las tres de la madrugada, sin previo aviso.

Los dos escenarios producen el mismo daño, pero Kubernetes solo puede protegerte de uno de ellos. Entender por qué, y qué hacer con el otro, es el contenido de esta lección.

Vamos a ver la distinción entre interrupciones voluntarias e involuntarias, el PodDisruptionBudget y la API de desalojo que lo respeta, los PDB imposibles que bloquean el mantenimiento para siempre, la distribución topológica con topologySpreadConstraints frente a la podAntiAffinity de 06-05, qué significa la alta disponibilidad en las demás capas del sistema, el procedimiento completo de mantenimiento de un nodo, y una prueba de caos para comprobar que todo funciona de verdad.

Contenido

  1. Interrupciones voluntarias e involuntarias
  2. El PodDisruptionBudget: qué es y qué protege
  3. minAvailable frente a maxUnavailable
  4. La API de desalojo y kubectl drain
  5. Demostración: un drenaje que se queda esperando
  6. El PDB imposible que bloquea el mantenimiento
  7. unhealthyPodEvictionPolicy
  8. Los PDB de Rutas Norte, componente a componente
  9. topologySpreadConstraints a fondo
  10. topologySpreadConstraints frente a podAntiAffinity
  11. Repartir Rutas Norte entre zonas y nodos
  12. Alta disponibilidad en las demás capas
  13. El procedimiento de mantenimiento de un nodo
  14. Interacción con el Cluster Autoscaler y los despliegues
  15. Una prueba de caos sencilla
  16. Errores comunes y consejos
  17. Ejercicios
  18. Conclusión

  1. Interrupciones voluntarias e involuntarias

Kubernetes distingue formalmente dos clases de interrupción, y la distinción no es académica: determina qué mecanismos de protección existen.

Interrupciones involuntarias

Ocurren sin que nadie las haya pedido. Son fallos:

Causa Ejemplo en Rutas Norte
Fallo de hardware del nodo La fuente de alimentación del servidor que aloja tres réplicas
Caída de la zona de disponibilidad Un corte eléctrico en el centro de datos de la zona B
El kernel mata un proceso por falta de memoria OOMKilled de api-reservas (módulo 3)
Desalojo del kubelet por presión de recursos El nodo se queda sin disco y desaloja pods BestEffort
Partición de red El nodo pierde conectividad con el plano de control
Borrado accidental de una máquina virtual Alguien se equivoca en la consola del proveedor

Kubernetes no puede impedir ninguna de estas. Cuando el hardware falla, falla. Lo único que se puede hacer es limitar el daño: si las réplicas están repartidas, la caída de un nodo se lleva una fracción; si están amontonadas, se lleva todo.

La protección contra interrupciones involuntarias es arquitectónica: réplicas suficientes, repartidas entre dominios de fallo independientes. Eso es el apartado 9 en adelante.

Interrupciones voluntarias

Las provoca alguien deliberadamente, a través de la API:

Causa Ejemplo en Rutas Norte
Drenar un nodo para mantenimiento Actualizar el kernel o la versión de Kubernetes
Reducción del clúster El Cluster Autoscaler retira un nodo infrautilizado (09-03)
Redesplegar una aplicación Una actualización progresiva de api-reservas (02-04)
El VPA ajusta recursos El actualizador desaloja un pod para recrearlo (09-02)
Borrar un pod a mano kubectl delete pod
Reprogramación por un descheduler Una herramienta que reequilibra pods entre nodos

Aquí sí puede intervenir Kubernetes, porque estas acciones pasan por el servidor de API y pueden ser evaluadas antes de ejecutarse. El mecanismo se llama PodDisruptionBudget, y es el objeto que dice: «puedes hacer mantenimiento, pero no a costa de dejarme sin servicio».

flowchart TD
    subgraph INVOL["Interrupciones INVOLUNTARIAS"]
        I1[Fallo de hardware]
        I2[Caida de zona]
        I3[OOMKilled]
        I4[Particion de red]
    end

    subgraph VOL["Interrupciones VOLUNTARIAS"]
        V1[kubectl drain]
        V2[Reduccion del Cluster Autoscaler]
        V3[Actualizacion progresiva]
        V4[Desalojo del VPA]
    end

    INVOL -->|Kubernetes NO puede impedirlas| ARQ["Proteccion ARQUITECTONICA:<br/>replicas repartidas entre<br/>dominios de fallo<br/>topologySpreadConstraints"]
    VOL -->|Pasan por la API de desalojo| PDB["Proteccion por POLITICA:<br/>PodDisruptionBudget"]

    style INVOL fill:#fdd,stroke:#c00
    style VOL fill:#ffd,stroke:#c90
    style ARQ fill:#cde,stroke:#369
    style PDB fill:#cfc,stroke:#393

Las dos protecciones son complementarias y hacen falta ambas. Un PDB perfecto no te salva de que se caiga la zona donde tienes todas las réplicas. Y una distribución topológica perfecta no impide que un kubectl drain descuidado se lleve tres réplicas de golpe.

Un matiz importante sobre el maxUnavailable del Deployment

Conviene aclarar una confusión frecuente. El Deployment tiene su propio control de interrupción durante las actualizaciones:

spec:
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 1
      maxSurge: 2

Eso gobierna solo el despliegue progresivo (02-04). No protege de un kubectl drain, ni de la reducción del Cluster Autoscaler, ni de un desalojo del VPA. Son mecanismos distintos con ámbitos distintos, y necesitas los dos.

strategy.rollingUpdate.maxUnavailable PodDisruptionBudget
Ámbito Solo actualizaciones del Deployment Cualquier desalojo por la API
Lo aplica El controlador de Deployments El servidor de API, en la petición de desalojo
Protege de Que la propia actualización deje sin servicio drain, CA, VPA, descheduler
Se define en El Deployment Un objeto PodDisruptionBudget aparte

  1. El PodDisruptionBudget: qué es y qué protege

Un PodDisruptionBudget (PDB) es un objeto que declara: «de este conjunto de pods, siempre debe haber al menos N disponibles» (o «como mucho N no disponibles»).

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: api-reservas
  namespace: rutas-norte-pro
spec:
  maxUnavailable: 25%
  selector:
    matchLabels:
      app: api-reservas

Tres campos y nada más:

  • selector: qué pods cubre. Igual que el selector de un Service o un Deployment (02-07).
  • minAvailable o maxUnavailable: la restricción. Mutuamente excluyentes: uno de los dos, nunca ambos.
  • (Opcional) unhealthyPodEvictionPolicy: apartado 7.

Qué hace exactamente

Cuando alguien pide desalojar un pod a través de la API de desalojo, el servidor de API:

  1. Busca los PDB cuyo selector coincida con ese pod.
  2. Para cada uno, calcula cuántos pods están actualmente disponibles (Ready).
  3. Comprueba si desalojar este pod violaría la restricción.
  4. Si la violaría, rechaza la petición con un error HTTP 429 (TooManyRequests).
  5. Si no, la acepta y el pod se elimina.

Es una comprobación en el momento de la petición, no una reserva. El cliente que recibe el 429 normalmente reintenta, y así el desalojo espera hasta que sea seguro.

Qué NO hace un PDB

Esta lista es tan importante como la anterior:

El PDB no Explicación
Impide que un nodo se caiga Es una interrupción involuntaria: nadie pide permiso
Impide kubectl delete pod El borrado directo no pasa por la API de desalojo
Garantiza que haya N réplicas Eso lo hace el Deployment/ReplicaSet
Crea pods nuevos No es un controlador de réplicas
Protege de un OOMKilled Involuntario
Protege de que el kubelet desaloje por presión de recursos Involuntario

El segundo punto sorprende a mucha gente y merece énfasis: kubectl delete pod ignora los PDB por completo. El borrado directo es una operación distinta del desalojo. Si quieres respetar los PDB desde la línea de comandos, usa kubectl drain (que sí usa la API de desalojo) o la propia API:

# Esto IGNORA el PDB
kubectl delete pod api-reservas-7c9d4f8b6d-2mk8p -n rutas-norte-pro

# Esto RESPETA el PDB
kubectl drain <nodo> --pod-selector=app=api-reservas

  1. minAvailable frente a maxUnavailable

Los dos expresan lo mismo desde lados opuestos, pero se comportan de forma muy distinta cuando el número de réplicas cambia, que es exactamente lo que pasa cuando hay un HPA.

minAvailable

«Siempre debe haber al menos N pods disponibles.»

spec:
  minAvailable: 3
  selector:
    matchLabels:
      app: api-reservas

Con 4 réplicas: se puede desalojar 1 (quedan 3). Con 10 réplicas: se pueden desalojar 7.

maxUnavailable

«Como mucho puede haber N pods no disponibles.»

spec:
  maxUnavailable: 1
  selector:
    matchLabels:
      app: api-reservas

Con 4 réplicas: se puede desalojar 1. Con 10 réplicas: también solo 1.

La diferencia crítica con los porcentajes

Aquí está el detalle que casi nadie conoce y que produce comportamientos sorprendentes: el redondeo es distinto en cada caso.

Campo Redondeo del porcentaje Motivo
minAvailable: 50% Hacia arriba Más conservador: garantiza más pods
maxUnavailable: 50% Hacia abajo Más conservador: permite menos desalojos

En ambos casos el redondeo favorece la disponibilidad. Veámoslo con números:

Con 5 réplicas:

PDB Cálculo Resultado Desalojos permitidos
minAvailable: 50% techo(5 × 0,5) = 3 Mínimo 3 disponibles 2
maxUnavailable: 50% suelo(5 × 0,5) = 2 Máximo 2 no disponibles 2

Aquí coinciden. Pero con 7 réplicas:

PDB Cálculo Resultado Desalojos permitidos
minAvailable: 50% techo(7 × 0,5) = 4 Mínimo 4 disponibles 3
maxUnavailable: 50% suelo(7 × 0,5) = 3 Máximo 3 no disponibles 3

Coinciden otra vez. Con 3 réplicas:

PDB Cálculo Resultado Desalojos permitidos
minAvailable: 50% techo(3 × 0,5) = 2 Mínimo 2 disponibles 1
maxUnavailable: 50% suelo(3 × 0,5) = 1 Máximo 1 no disponible 1

Para el 50 % el resultado es equivalente. La diferencia aparece con otros porcentajes. Con 10 réplicas y un 20 %:

PDB Cálculo Resultado Desalojos permitidos
minAvailable: 20% techo(10 × 0,2) = 2 Mínimo 2 disponibles 8
maxUnavailable: 20% suelo(10 × 0,2) = 2 Máximo 2 no disponibles 2

Radicalmente distinto. minAvailable: 20% permite desalojar el 80 % de los pods; maxUnavailable: 20% permite desalojar el 20 %.

Regla mental: minAvailable habla de lo que queda; maxUnavailable habla de lo que se va.

Cuál usar: la regla del HPA

Y ahora la razón por la que esto importa tanto en este módulo. Con un HPA, el número de réplicas cambia solo. Un PDB en número absoluto que era razonable con 30 réplicas se vuelve imposible con 4.

Recordemos el caso del ejercicio 2 de 09-03:

Durante el puente: 30 replicas. Alguien pone minAvailable: 25.
  Desalojos permitidos: 5. Razonable.

Tras el puente: el HPA baja a 4 replicas. El PDB sigue diciendo minAvailable: 25.
  Pods disponibles: 4. Minimo exigido: 25.
  YA se esta por debajo del minimo.
  Desalojos permitidos: CERO. Para siempre.

El nodo que aloje cualquier replica de api-reservas queda ANCLADO.
El Cluster Autoscaler no lo puede retirar. El mantenimiento se bloquea.

Por eso:

Situación Recomendación
Carga con HPA o KEDA maxUnavailable en porcentaje
Número de réplicas fijo y conocido Cualquiera; minAvailable es más explícito
Carga con estado y quórum (etcd, bases de datos) maxUnavailable: 1 siempre
Una sola réplica Ninguno de los dos funciona bien (apartado 6)

maxUnavailable: 25% es la elección por defecto sensata para una carga con HPA. Se adapta sola: con 4 réplicas permite desalojar 1; con 30, permite desalojar 7. La proporción de servicio protegida es constante.

Una nota sobre el denominador: el porcentaje se calcula sobre el número de réplicas deseadas por el controlador (el spec.replicas del Deployment), no sobre los pods Ready en ese momento. Esto importa cuando hay pods arrancando: durante un escalado del HPA de 4 a 12, el denominador ya es 12 aunque solo 5 estén listos.

  1. La API de desalojo y kubectl drain

El PDB solo funciona si quien quiere quitar un pod pide permiso. Ese mecanismo es la API de desalojo (Eviction API).

Cómo funciona

Es un subrecurso del pod:

POST /api/v1/namespaces/rutas-norte-pro/pods/api-reservas-7c9d4f8b6d-2mk8p/eviction

Con este cuerpo:

{
  "apiVersion": "policy/v1",
  "kind": "Eviction",
  "metadata": {
    "name": "api-reservas-7c9d4f8b6d-2mk8p",
    "namespace": "rutas-norte-pro"
  }
}

Respuestas posibles:

Código Significado
200 OK Desalojo aceptado; el pod se elimina con su periodo de gracia
429 TooManyRequests Un PDB lo impide. Reintentar más tarde
500 Internal Server Error Configuración incoherente (un PDB mal formado)

El 429 incluye un mensaje explicativo:

{
  "kind": "Status",
  "status": "Failure",
  "message": "Cannot evict pod as it would violate the pod's disruption budget.",
  "reason": "TooManyRequests",
  "details": {
    "causes": [
      {
        "reason": "DisruptionBudget",
        "message": "The disruption budget api-reservas needs 3 healthy pods and has 3 currently"
      }
    ]
  }
}

Ese mensaje —«necesita 3 pods sanos y tiene 3 actualmente»— es el que verás en la práctica y el que hay que saber leer.

Quién usa la API de desalojo

Cliente ¿Respeta los PDB?
kubectl drain
Cluster Autoscaler (09-03)
Actualizador del VPA (09-02)
Descheduler
Karpenter (consolidación)
kubectl delete pod No. Borrado directo
Controlador de Deployments (actualización progresiva) No. Usa maxUnavailable propio
El kubelet desalojando por presión de recursos No. Es involuntario
Un nodo que se apaga No. Es involuntario

Las cuatro primeras filas son las que importan: todos los mecanismos automáticos que hemos ido añadiendo en este módulo respetan los PDB. Es la garantía de que el autoescalado no se lleve por delante la disponibilidad.

El campo disruptionsAllowed

El PDB publica en su estado cuántos desalojos permite en este momento:

kubectl get pdb -n rutas-norte-pro
NAME                    MIN AVAILABLE   MAX UNAVAILABLE   ALLOWED DISRUPTIONS   AGE
api-reservas            N/A             25%               1                     12d
tienda-web              N/A             1                 1                     12d
postgres-reservas       1               N/A               0                     12d
worker-notificaciones   N/A             50%               3                     12d

ALLOWED DISRUPTIONS es el número más útil de toda la lección. Si es 0, ningún pod de ese conjunto puede desalojarse ahora mismo, y cualquier drenaje que los afecte se quedará esperando.

Un 0 puede ser correcto (postgres-reservas con una réplica y minAvailable: 1) o síntoma de un problema (un PDB imposible, o pods que no están Ready).

Vista detallada:

kubectl describe pdb api-reservas -n rutas-norte-pro
Name:             api-reservas
Namespace:        rutas-norte-pro
Max unavailable:  25%
Selector:         app=api-reservas
Status:
    Allowed disruptions:  2
    Current:              8
    Desired:              6
    Total:                8
Campo Significado
Current Pods Ready ahora mismo
Desired Mínimo que exige el PDB (calculado)
Total Pods que cubre el selector
Allowed disruptions Current - Desired

  1. Demostración: un drenaje que se queda esperando

Nada enseña mejor que verlo. Preparemos el escenario en minikube.

Preparación

minikube start -p rutas-norte --nodes=3 --cpus=2 --memory=4096

kubectl create namespace rutas-norte-dev

# Un Deployment con 3 replicas
kubectl -n rutas-norte-dev create deployment demo-pdb \
  --image=registry.k8s.io/pause:3.9 --replicas=3

kubectl -n rutas-norte-dev set resources deployment/demo-pdb \
  --requests=cpu=100m,memory=64Mi

Un PDB deliberadamente restrictivo:

# /tmp/pdb-demo.yaml
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: demo-pdb
  namespace: rutas-norte-dev
spec:
  # Con 3 replicas, exigir 3 disponibles significa CERO desalojos permitidos.
  minAvailable: 3
  selector:
    matchLabels:
      app: demo-pdb
kubectl apply -f /tmp/pdb-demo.yaml
kubectl get pdb -n rutas-norte-dev
NAME       MIN AVAILABLE   MAX UNAVAILABLE   ALLOWED DISRUPTIONS   AGE
demo-pdb   3               N/A               0                     8s

ALLOWED DISRUPTIONS: 0. Ya sabemos qué va a pasar.

El drenaje

kubectl get pods -n rutas-norte-dev -o wide
NAME                        READY   STATUS    NODE
demo-pdb-6c9f8d7b5-2mkjp    1/1     Running   rutas-norte
demo-pdb-6c9f8d7b5-7wqzx    1/1     Running   rutas-norte-m02
demo-pdb-6c9f8d7b5-9nvtr    1/1     Running   rutas-norte-m03
kubectl drain rutas-norte-m02 --ignore-daemonsets --delete-emptydir-data
node/rutas-norte-m02 cordoned
evicting pod rutas-norte-dev/demo-pdb-6c9f8d7b5-7wqzx
error when evicting pods/"demo-pdb-6c9f8d7b5-7wqzx" -n "rutas-norte-dev"
(will retry after 5s): Cannot evict pod as it would violate the pod's disruption budget.
evicting pod rutas-norte-dev/demo-pdb-6c9f8d7b5-7wqzx
error when evicting pods/"demo-pdb-6c9f8d7b5-7wqzx" -n "rutas-norte-dev"
(will retry after 5s): Cannot evict pod as it would violate the pod's disruption budget.
evicting pod rutas-norte-dev/demo-pdb-6c9f8d7b5-7wqzx
error when evicting pods/"demo-pdb-6c9f8d7b5-7wqzx" -n "rutas-norte-dev"
(will retry after 5s): Cannot evict pod as it would violate the pod's disruption budget.
...

El drenaje se queda en bucle infinito, reintentando cada 5 segundos.

Observaciones importantes:

  1. El nodo YA está acordonado (cordoned). Aunque el drenaje no avance, el nodo no acepta pods nuevos. Es lo primero que hace drain.
  2. El mensaje es explícito: Cannot evict pod as it would violate the pod's disruption budget.
  3. drain no se rinde. Reintenta indefinidamente hasta que se lo permitan o hasta que lo interrumpas.

Este es exactamente el comportamiento deseado: el PDB ha impedido que el mantenimiento rompa el servicio. Pero también es exactamente el comportamiento que bloquea el mantenimiento para siempre si el PDB está mal, y esa es la lección del apartado 6.

Desbloquearlo

Tres formas:

# Opcion A: mas replicas. Con 4 replicas y minAvailable: 3, se permite 1 desalojo.
kubectl -n rutas-norte-dev scale deployment demo-pdb --replicas=4

En cuanto la cuarta réplica esté Ready, el drenaje que estaba reintentando avanza solo. Es muy satisfactorio de ver.

# Opcion B: corregir el PDB
kubectl -n rutas-norte-dev patch pdb demo-pdb --type merge \
  -p '{"spec":{"minAvailable":null,"maxUnavailable":"25%"}}'

Ojo: minAvailable y maxUnavailable son mutuamente excluyentes, así que hay que anular uno al poner el otro.

# Opcion C (ULTIMO RECURSO, peligroso): forzar el drenaje
kubectl drain rutas-norte-m02 --ignore-daemonsets --delete-emptydir-data \
  --disable-eviction

--disable-eviction usa borrado directo en lugar de la API de desalojo, saltándose los PDB. Es la opción de emergencia, y hay que saber que puede dejar el servicio sin ninguna réplica disponible. Úsala solo cuando entiendas exactamente qué se va a caer y hayas decidido que es aceptable.

Limpieza

kubectl uncordon rutas-norte-m02
kubectl delete -f /tmp/pdb-demo.yaml
kubectl delete deployment demo-pdb -n rutas-norte-dev

Nunca olvides el uncordon. Un nodo acordonado que nadie descordona es capacidad pagada e inutilizable, y el síntoma (pods Pending mientras hay nodos aparentemente libres) desconcierta muchísimo.

  1. El PDB imposible que bloquea el mantenimiento

Formalicemos el problema, porque es la trampa más peligrosa de esta lección.

La definición

Un PDB imposible es aquel cuya restricción no puede satisfacerse desalojando ni un solo pod. Su ALLOWED DISRUPTIONS es 0 de forma permanente.

Los dos casos:

Caso 1: minAvailable igual (o mayor) al número de réplicas.

spec:
  replicas: 3        # en el Deployment
---
spec:
  minAvailable: 3    # en el PDB

Desalojar cualquier pod dejaría 2 disponibles, por debajo del mínimo de 3. Cero desalojos, siempre.

Y la variante insidiosa: minAvailable: 25 sobre un Deployment con HPA que ha bajado a 4 réplicas. El PDB era razonable cuando se escribió; el HPA lo ha vuelto imposible sin que nadie lo tocara.

Caso 2: una sola réplica con cualquier PDB restrictivo.

spec:
  replicas: 1
---
spec:
  minAvailable: 1        # Equivalente a maxUnavailable: 0

Con una réplica, desalojarla deja cero disponibles. Cero desalojos, siempre.

Las consecuencias

Consecuencia Detalle
El mantenimiento de nodos se bloquea kubectl drain reintenta para siempre
El Cluster Autoscaler no puede reducir Nodos infrautilizados que nadie retira, pagándose (09-03)
El VPA no puede aplicar recomendaciones El actualizador falla al desalojar (09-02)
Las actualizaciones del clúster se atascan Un proveedor gestionado puede abortar la actualización
El diagnóstico es difícil El síntoma (nodos zombis) está lejos de la causa (un PDB)

Ese último punto es el que hace peligroso el problema: nadie relaciona un nodo que no se retira con un PDB escrito hace tres meses. Los logs del Cluster Autoscaler lo dicen (pdb-blocked), pero hay que saber mirar ahí.

Detectarlos

# Todos los PDB del cluster con sus desalojos permitidos
kubectl get pdb --all-namespaces
NAMESPACE          NAME                    MIN AVAILABLE   MAX UNAVAILABLE   ALLOWED DISRUPTIONS
rutas-norte-pro    api-reservas            N/A             25%               2
rutas-norte-pro    tienda-web              N/A             1                 1
rutas-norte-pro    postgres-reservas       1               N/A               0
rutas-norte-pro    redis-cache             1               N/A               0
rutas-norte-pro    worker-notificaciones   N/A             50%               4
monitorizacion     prometheus              1               N/A               0

Los tres ceros hay que analizarlos uno por uno:

  • postgres-reservas: una réplica primaria. ALLOWED DISRUPTIONS: 0 es deliberado y correcto: no queremos que ningún mecanismo automático mueva la base de datos. El precio es que su nodo requiere intervención manual para el mantenimiento.
  • redis-cache: mismo caso, aunque aquí es más discutible: perder la caché unos minutos es molesto pero no catastrófico. Habría argumentos para permitir el desalojo.
  • prometheus: si Prometheus tiene una sola réplica (habitual), es el mismo patrón.

Una alerta útil para detectar los imposibles no deliberados:

# PDB que llevan mas de una hora sin permitir ningun desalojo
kube_poddisruptionbudget_status_pod_disruptions_allowed == 0

Con una anotación en los PDB deliberados para excluirlos de la alerta.

Qué hacer con las cargas de una sola réplica

Es una pregunta que hay que responder de forma explícita, y hay tres respuestas legítimas:

Opción A: no poner PDB. Sin PDB, el desalojo se permite siempre. El servicio tendrá un corte durante el mantenimiento (los segundos que tarde en arrancar en otro nodo), pero el mantenimiento fluye. Es la opción correcta para servicios no críticos.

Opción B: poner maxUnavailable: 1 (equivalente a minAvailable: 0). Permite siempre el desalojo. Parece inútil, pero documenta explícitamente que se ha pensado en ello y se acepta la interrupción. Mejor que la ausencia de PDB, que es ambigua.

Opción C: poner dos réplicas. Si el servicio de verdad no puede interrumpirse, la solución no es un PDB: es tener más de una réplica. Un PDB no crea disponibilidad, solo la protege.

Para postgres-reservas, la respuesta correcta no es ninguna de las tres: es un operador (06-07) que gestione un clúster de PostgreSQL con primario y réplicas, y que sepa promocionar una réplica cuando el primario tenga que moverse. Lo desarrollamos en el apartado 12.

  1. unhealthyPodEvictionPolicy

Un campo relativamente reciente que resuelve un problema muy concreto y muy molesto.

El problema

Imagina que api-reservas tiene 6 réplicas y un PDB de maxUnavailable: 1. Un despliegue defectuoso hace que 4 de las 6 réplicas no pasen la sonda de readiness (07-01): están Running pero no Ready.

Replicas totales: 6
Replicas Ready: 2
Replicas no Ready: 4

PDB: maxUnavailable: 1 -> minimo 5 disponibles.
Disponibles actuales: 2. Ya estamos POR DEBAJO del minimo.
ALLOWED DISRUPTIONS: 0.

Ahora quieres desalojar uno de los pods rotos —precisamente porque está roto y quieres que se recree en otro nodo— y el PDB no te deja. El PDB está protegiendo pods que no están sirviendo tráfico.

Es una situación circular y absurda: el sistema está roto, y el mecanismo de protección impide arreglarlo.

La solución

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: api-reservas
  namespace: rutas-norte-pro
spec:
  maxUnavailable: 25%
  selector:
    matchLabels:
      app: api-reservas
  # Permite desalojar SIEMPRE los pods que no estan Ready, sin consumir
  # presupuesto de interrupcion.
  unhealthyPodEvictionPolicy: AlwaysAllow

Los dos valores:

Valor Comportamiento
IfHealthyBudget (defecto) Los pods no sanos solo se pueden desalojar si el PDB tiene presupuesto disponible
AlwaysAllow Los pods no sanos (que no están Ready) se pueden desalojar siempre

Con AlwaysAllow, el razonamiento es directo: un pod que no está Ready no está sirviendo tráfico, así que desalojarlo no reduce la disponibilidad. Al contrario: permite que se recree, posiblemente en un nodo sano.

Cuándo usarlo

Situación Recomendación
Aplicaciones sin estado (api-reservas, tienda-web, workers) AlwaysAllow. Siempre
Aplicaciones con quórum (etcd, Kafka, Zookeeper) IfHealthyBudget (el defecto). Un pod no Ready puede estar recuperándose y seguir contando para el quórum
Bases de datos con réplicas Depende del sistema; generalmente IfHealthyBudget

La distinción es importante: en un sistema de quórum, un miembro que no responde a la sonda puede seguir participando en el consenso. Desalojarlo alegremente puede perder el quórum. En una aplicación sin estado, un pod no Ready es puro lastre.

Para Rutas Norte, AlwaysAllow en todos los componentes sin estado. Evita el atasco circular y no tiene contrapartidas.

  1. Los PDB de Rutas Norte, componente a componente

Escribamos los manifiestos definitivos, con la justificación de cada decisión.

api-reservas

# k8s/entornos/pro/pdb-api-reservas.yaml
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: api-reservas
  namespace: rutas-norte-pro
  labels:
    app: api-reservas
    app.kubernetes.io/part-of: rutas-norte
spec:
  # PORCENTAJE, no numero absoluto. api-reservas tiene KEDA/HPA (09-01, 09-04)
  # y oscila entre 4 y 30 replicas a lo largo del dia. Un minAvailable
  # absoluto quedaria obsoleto en cuanto el HPA se moviera, y con el valle
  # nocturno se convertiria en un PDB IMPOSIBLE que bloquearia el
  # mantenimiento y la reduccion de nodos.
  #
  # Con 25 %:
  #    4 replicas -> permite desalojar 1  (suelo(4 x 0,25) = 1)
  #   12 replicas -> permite desalojar 3
  #   30 replicas -> permite desalojar 7
  # La PROPORCION de servicio protegida es constante: siempre el 75 %.
  maxUnavailable: 25%

  selector:
    matchLabels:
      app: api-reservas

  # Los pods que no pasan la readiness no estan sirviendo trafico: desalojarlos
  # no reduce la disponibilidad y evita el atasco circular de un despliegue
  # defectuoso que impide su propia reparacion.
  unhealthyPodEvictionPolicy: AlwaysAllow

tienda-web

# k8s/entornos/pro/pdb-tienda-web.yaml
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: tienda-web
  namespace: rutas-norte-pro
  labels:
    app: tienda-web
    app.kubernetes.io/part-of: rutas-norte
spec:
  # 34 %, mas permisivo que api-reservas. Razon: tienda-web es nginx sirviendo
  # estatico. Arranca en ~1 segundo y no tiene estado ni cache que calentar,
  # asi que una replica desalojada vuelve casi al instante en otro nodo.
  # Permitir mas desalojos simultaneos acelera el mantenimiento sin riesgo real.
  #
  # Con 3 replicas -> permite desalojar 1  (suelo(3 x 0,34) = 1)
  # Con 15 replicas -> permite desalojar 5
  maxUnavailable: 34%

  selector:
    matchLabels:
      app: tienda-web

  unhealthyPodEvictionPolicy: AlwaysAllow

worker-notificaciones

# k8s/entornos/pro/pdb-worker-notificaciones.yaml
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: worker-notificaciones
  namespace: rutas-norte-pro
  labels:
    app: worker-notificaciones
    app.kubernetes.io/part-of: rutas-norte
spec:
  # 50 %, el mas permisivo de la plataforma. Justificacion:
  #
  # 1. NADIE ESPERA. Un correo de confirmacion enviado 30 segundos mas tarde
  #    no tiene consecuencia alguna para el usuario.
  # 2. LA COLA ES EL AMORTIGUADOR. Si se desaloja la mitad de los workers, el
  #    trabajo no se pierde: se acumula en la cola y se procesa despues.
  #    El mensaje en vuelo se reentrega automaticamente.
  # 3. KEDA REACCIONA. Si la cola crece porque hay menos workers, KEDA (09-04)
  #    escalara para compensar.
  #
  # Con escalado a CERO (minReplicaCount: 0) hay un matiz: cuando hay 0
  # replicas, el PDB no cubre ningun pod y ALLOWED DISRUPTIONS sera 0, pero
  # eso es irrelevante porque no hay nada que desalojar.
  maxUnavailable: 50%

  selector:
    matchLabels:
      app: worker-notificaciones

  unhealthyPodEvictionPolicy: AlwaysAllow

redis-cache

# k8s/entornos/pro/pdb-redis-cache.yaml
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: redis-cache
  namespace: rutas-norte-pro
  labels:
    app: redis-cache
    app.kubernetes.io/part-of: rutas-norte
  annotations:
    # Anotacion para excluir este PDB de la alerta de "PDB imposible":
    # su ALLOWED DISRUPTIONS es 0 de forma DELIBERADA.
    rutasnorte.example/pdb-bloqueo-intencionado: >
      redis-cache es un StatefulSet de una replica. Desalojarlo vacia la cache
      y descarga toda la carga de consultas de disponibilidad sobre
      postgres-reservas. Requiere intervencion manual y ventana de mantenimiento.
spec:
  # minAvailable: 1 con UNA replica = CERO desalojos permitidos.
  # Es DELIBERADO. Consecuencias asumidas:
  #   - El Cluster Autoscaler no retirara el nodo que aloje a redis-cache.
  #   - kubectl drain sobre ese nodo se quedara esperando.
  #   - El mantenimiento de ese nodo requiere decision humana explicita.
  #
  # Es un compromiso ACEPTADO: preferimos que el mantenimiento sea manual a
  # que un mecanismo automatico vacie la cache en el peor momento posible.
  minAvailable: 1

  selector:
    matchLabels:
      app: redis-cache

  # IfHealthyBudget (el defecto). No ponemos AlwaysAllow: si redis-cache no
  # esta Ready, puede estar cargando datos o recuperandose. Desalojarlo
  # empeoraria las cosas.
  unhealthyPodEvictionPolicy: IfHealthyBudget

postgres-reservas

# k8s/entornos/pro/pdb-postgres-reservas.yaml
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: postgres-reservas
  namespace: rutas-norte-pro
  labels:
    app: postgres-reservas
    app.kubernetes.io/part-of: rutas-norte
  annotations:
    rutasnorte.example/pdb-bloqueo-intencionado: >
      postgres-reservas es la base de datos primaria de la plataforma con una
      unica replica. NINGUN mecanismo automatico debe moverla. El mantenimiento
      del nodo que la aloje requiere una ventana planificada, con parada
      controlada de la aplicacion y copia de seguridad previa. Ver 05-06.
      La solucion definitiva es un operador de PostgreSQL: ver 06-07.
spec:
  # CERO desalojos. Es la proteccion mas fuerte posible, y es intencionada.
  #
  # Un desalojo automatico de postgres-reservas significaria:
  #   - Corte de TODAS las conexiones de api-reservas.
  #   - Perdida de la cache de paginas de PostgreSQL (minutos de latencia mala).
  #   - Riesgo de inconsistencia si hay transacciones en vuelo.
  #   - La plataforma entera caida durante el arranque.
  #
  # Guarda datos personales de clientes: cualquier riesgo es inaceptable.
  minAvailable: 1

  selector:
    matchLabels:
      app: postgres-reservas

  unhealthyPodEvictionPolicy: IfHealthyBudget

Tabla resumen

Componente PDB Desalojos con réplicas mín. unhealthyPodEvictionPolicy Justificación
api-reservas maxUnavailable: 25% 1 de 4 AlwaysAllow Con HPA: porcentaje obligatorio. 75 % de servicio garantizado
tienda-web maxUnavailable: 34% 1 de 3 AlwaysAllow Arranca en 1 s; más permisivo acelera el mantenimiento
worker-notificaciones maxUnavailable: 50% 1 de 2 AlwaysAllow La cola amortigua; nadie espera
redis-cache minAvailable: 1 0 IfHealthyBudget Una réplica; vaciar la caché es caro. Bloqueo deliberado
postgres-reservas minAvailable: 1 0 IfHealthyBudget Base de datos primaria. Bloqueo deliberado
informes-ocupacion Ninguno Es un CronJob. Se protege con safe-to-evict: false (09-03)

Nótese la última fila: los Jobs no llevan PDB. No tiene sentido: un Job que se desaloja se reinicia y pierde el trabajo. Su protección es la anotación cluster-autoscaler.kubernetes.io/safe-to-evict: "false" que vimos en 09-03.

  1. topologySpreadConstraints a fondo

Los PDB protegen de las interrupciones voluntarias. Para las involuntarias, la protección es repartir las réplicas entre dominios de fallo independientes. Y ahí entra el campo que mencionamos en 06-05 y prometimos desarrollar aquí.

El problema que resuelve

Sin ninguna restricción, el planificador de Kubernetes coloca los pods donde caben, optimizando el uso de recursos. Nada le impide poner las cuatro réplicas de api-reservas en el mismo nodo, si es donde hay hueco.

nodo-1 (zona A):  api-reservas x4, tienda-web x2
nodo-2 (zona A):  postgres-reservas
nodo-3 (zona B):  redis-cache, worker x2
nodo-4 (zona B):  (casi vacio)

Se cae nodo-1  ->  CERO replicas de api-reservas. Plataforma caida.
Se cae la zona A -> CERO replicas de api-reservas Y la base de datos.

topologySpreadConstraints le dice al planificador: «reparte estos pods de forma equilibrada entre estos dominios».

Los campos

spec:
  template:
    spec:
      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: topology.kubernetes.io/zone
          whenUnsatisfiable: DoNotSchedule
          labelSelector:
            matchLabels:
              app: api-reservas
          minDomains: 3
          nodeAffinityPolicy: Honor
          nodeTaintsPolicy: Honor
          matchLabelKeys:
            - pod-template-hash

topologyKey — la etiqueta de nodo que define el dominio. Los valores estándar:

Etiqueta Dominio Protege de
kubernetes.io/hostname Un nodo Fallo de un servidor
topology.kubernetes.io/zone Una zona de disponibilidad Fallo de un centro de datos
topology.kubernetes.io/region Una región geográfica Catástrofe regional
Etiquetas propias (rack, bastidor) Lo que tú definas Fallo de un bastidor o un conmutador

maxSkew — la diferencia máxima permitida entre el dominio con más pods y el que menos. Es el corazón del mecanismo.

sesgo = (pods en el dominio con MAS) - (pods en el dominio con MENOS)

Con maxSkew: 1, la diferencia entre cualquier par de dominios no puede superar 1. Es el reparto más equilibrado posible.

whenUnsatisfiable — qué hacer si no se puede cumplir:

Valor Comportamiento Cuándo usarlo
DoNotSchedule El pod se queda Pending antes que violar la restricción Distribución obligatoria
ScheduleAnyway Se coloca igualmente, pero el planificador prefiere cumplirla Distribución preferida

Esta es la decisión más consecuente del bloque, y la desarrollamos en el apartado 11.

labelSelector — qué pods se cuentan para calcular el sesgo. Normalmente los del propio Deployment.

minDomains — el número mínimo de dominios que deben existir. Sin él, hay un agujero sutil:

Sin minDomains, con 3 replicas y solo 1 zona con nodos disponibles:
  Zona A: 3 pods. Zona B: no hay nodos. Zona C: no hay nodos.
  Dominios EXISTENTES: 1
  Sesgo: 3 - 3 = 0  ->  la restriccion SE CUMPLE.
  Resultado: las 3 replicas en la misma zona. NO es lo que querias.

Con minDomains: 3:
  El planificador considera que hay 3 dominios, dos de ellos con 0 pods.
  Sesgo: 3 - 0 = 3 > maxSkew 1  ->  NO se cumple.
  Con DoNotSchedule: los pods quedan Pending hasta que haya nodos en otras zonas.

minDomains solo tiene efecto con whenUnsatisfiable: DoNotSchedule. Es la garantía de que el reparto entre zonas es real y no una ilusión.

nodeAffinityPolicy y nodeTaintsPolicy — si tener en cuenta las afinidades y taints al calcular los dominios:

Valor Comportamiento
Honor (defecto) Solo cuenta los nodos donde el pod podría ir (respetando afinidad y taints)
Ignore Cuenta todos los nodos

El valor por defecto Honor es casi siempre lo correcto. Si api-reservas tiene afinidad hacia el grupo de nodos generales, no queremos que el nodo de datos (con su taint) cuente como un dominio vacío que nunca se podrá llenar.

matchLabelKeys — el campo más sutil y el que resuelve un problema real durante los despliegues.

matchLabelKeys y el problema de las actualizaciones

Durante una actualización progresiva (02-04) coexisten pods de la versión vieja y de la nueva. Sin matchLabelKeys, el cálculo del sesgo los cuenta todos juntos:

Actualizacion de api-reservas de v1.14 a v1.15. 6 replicas.

Estado intermedio:
  zona A: 2 pods v1.14 + 1 pod v1.15 = 3 pods
  zona B: 2 pods v1.14 = 2 pods
  zona C: 1 pod v1.14 = 1 pod

Sesgo calculado sobre TODOS: 3 - 1 = 2 > maxSkew 1
-> El planificador NO puede colocar mas pods v1.15 en la zona A.
-> La actualizacion se atasca o se distribuye mal.

Con matchLabelKeys: ["pod-template-hash"], el planificador cuenta solo los pods de la misma versión (el pod-template-hash es una etiqueta que el controlador de Deployments añade automáticamente y que identifica el ReplicaSet):

Con matchLabelKeys: ["pod-template-hash"]:
  Para colocar un pod v1.15, solo cuenta los pods v1.15:
    zona A: 1, zona B: 0, zona C: 0
  Sesgo: 1 - 0 = 1 <= maxSkew 1  ->  se puede colocar en B o C.

La nueva version se distribuye correctamente por su cuenta.

Regla práctica: incluye siempre matchLabelKeys: ["pod-template-hash"] en las restricciones de un Deployment. Evita atascos durante los despliegues y no tiene contrapartida.

El cálculo del sesgo, paso a paso

Practiquemos con un caso concreto. Tres zonas, maxSkew: 1, 7 réplicas de api-reservas.

Colocacion de la replica 1:
  A: 0, B: 0, C: 0. Cualquier zona vale. -> A
  Estado: A:1, B:0, C:0

Colocacion de la replica 2:
  Si va a A: A:2, B:0, C:0. Sesgo = 2-0 = 2 > 1. NO PERMITIDO.
  Si va a B: A:1, B:1, C:0. Sesgo = 1-0 = 1 <= 1. PERMITIDO.
  -> B (o C)
  Estado: A:1, B:1, C:0

Colocacion de la replica 3:
  Solo C mantiene el sesgo en 1 o menos.  -> C
  Estado: A:1, B:1, C:1  (sesgo 0)

Replicas 4, 5, 6: se reparten una por zona.
  Estado: A:2, B:2, C:2  (sesgo 0)

Colocacion de la replica 7:
  Cualquier zona: sesgo pasaria a 1. Permitido en las tres.
  -> A (arbitrario)
  Estado FINAL: A:3, B:2, C:2  (sesgo 1)

Resultado: 3-2-2. El reparto más equilibrado posible con 7 réplicas en 3 zonas.

Y ahora la comprobación de disponibilidad:

Se cae la zona A: quedan 4 de 7 replicas (57 %).
Se cae la zona B: quedan 5 de 7 replicas (71 %).

Sin topologySpreadConstraints, en el peor caso las 7 podrian estar en una zona:
Se cae esa zona: quedan 0 de 7 (0 %). Plataforma caida.

  1. topologySpreadConstraints frente a podAntiAffinity

En 06-05 vimos la antiafinidad entre pods para separar réplicas. Ahora tenemos dos herramientas que parecen hacer lo mismo. No son equivalentes, y conviene saber cuándo usar cada una.

La antiafinidad, recordada

affinity:
  podAntiAffinity:
    # Version DURA: no se puede violar
    requiredDuringSchedulingIgnoredDuringExecution:
      - labelSelector:
          matchLabels:
            app: api-reservas
        topologyKey: kubernetes.io/hostname

Significa: «no coloques este pod en un nodo donde ya haya otro pod con app: api-reservas».

Es una restricción binaria: o hay un pod del mismo tipo en el dominio, o no lo hay. No hay noción de equilibrio.

La diferencia fundamental

podAntiAffinity (requerida) topologySpreadConstraints
Semántica «Como mucho uno por dominio» «Reparte equitativamente entre dominios»
Granularidad Binaria: hay o no hay Numérica: controla la diferencia
Con más réplicas que dominios Los sobrantes quedan Pending para siempre Se reparten equilibradamente
Coste computacional Alto: O(n²) en clústeres grandes Bajo
Versión «preferida» preferredDuringScheduling con weight whenUnsatisfiable: ScheduleAnyway
Multi-nivel Difícil de expresar Natural: varias restricciones a la vez
Reequilibrado tras un fallo No hay Tampoco, pero el reparto inicial es mejor

El caso que lo decide

api-reservas escala de 4 a 30 réplicas y el clúster tiene 9 nodos.

Con podAntiAffinity requerida sobre hostname:

Regla: como mucho 1 replica de api-reservas por nodo.
Nodos: 9.
Replicas maximas colocables: 9.

El HPA pide 30 replicas.
Se colocan 9. Las otras 21 quedan en Pending PARA SIEMPRE.

El Cluster Autoscaler ve pods pendientes y arranca nodos... y con cada nodo
nuevo cabe UNA replica mas. Para 30 replicas harian falta 30 NODOS.
Coste: absurdo.

Con topologySpreadConstraints:

Regla: maxSkew 1 entre nodos.
Nodos: 9. Replicas: 30.

Reparto: 30 / 9 = 3,33
Resultado: 3 nodos con 4 replicas, 6 nodos con 3 replicas.
Sesgo: 4 - 3 = 1. Cumple.

Las 30 replicas se colocan. Ningun Pending.

Esta es la razón por la que topologySpreadConstraints es la herramienta correcta para cargas con autoescalado. La antiafinidad requerida y el HPA son incompatibles en la práctica.

Cuándo sigue siendo mejor la antiafinidad

Caso Por qué
Componentes con pocas réplicas fijas y separación estricta Etcd, un plano de control: 3 réplicas, una por nodo, sin excepción
Antiafinidad entre aplicaciones distintas «api-reservas no debe compartir nodo con informes-ocupacion»: eso topologySpread no lo expresa
Reglas de coubicación (podAffinity) «Pon este pod cerca de la caché»: no tiene equivalente en topologySpread

Ese segundo caso merece un ejemplo, porque es un uso legítimo y frecuente:

# api-reservas NO debe compartir nodo con el CronJob de informes, que consume
# 4 nucleos de golpe y estrangularia a la API durante 40 minutos.
affinity:
  podAntiAffinity:
    requiredDuringSchedulingIgnoredDuringExecution:
      - labelSelector:
          matchLabels:
            app: informes-ocupacion       # OTRA aplicacion, no la misma
        topologyKey: kubernetes.io/hostname

La combinación recomendada

Para api-reservas en Rutas Norte usamos las dos, cada una para lo suyo:

spec:
  template:
    spec:
      # 1. Reparto equilibrado entre zonas y nodos (disponibilidad)
      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: topology.kubernetes.io/zone
          whenUnsatisfiable: DoNotSchedule
          labelSelector:
            matchLabels:
              app: api-reservas
          matchLabelKeys: ["pod-template-hash"]
        - maxSkew: 2
          topologyKey: kubernetes.io/hostname
          whenUnsatisfiable: ScheduleAnyway
          labelSelector:
            matchLabels:
              app: api-reservas
          matchLabelKeys: ["pod-template-hash"]

      # 2. Antiafinidad con OTRA aplicacion (aislamiento de recursos)
      affinity:
        podAntiAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
            - labelSelector:
                matchLabels:
                  app: informes-ocupacion
              topologyKey: kubernetes.io/hostname

  1. Repartir Rutas Norte entre zonas y nodos

Apliquémoslo a la plataforma, con los cálculos de sesgo.

api-reservas: dos niveles de distribución

# k8s/base/deployment-api-reservas.yaml (fragmento)
apiVersion: apps/v1
kind: Deployment
metadata:
  name: api-reservas
  namespace: rutas-norte-pro
spec:
  # Sin replicas: lo gobierna KEDA/HPA (09-01, 09-04)
  selector:
    matchLabels:
      app: api-reservas
  template:
    metadata:
      labels:
        app: api-reservas
        app.kubernetes.io/part-of: rutas-norte
    spec:
      topologySpreadConstraints:
        # NIVEL 1: entre ZONAS DE DISPONIBILIDAD. Restriccion DURA.
        #
        # Este es el dominio de fallo grande: si se cae una zona entera, hay
        # que garantizar que sobrevivan replicas en las otras dos. Es una
        # garantia que NO estamos dispuestos a negociar, asi que DoNotSchedule:
        # preferimos un pod Pending a un reparto desequilibrado.
        - maxSkew: 1
          topologyKey: topology.kubernetes.io/zone
          whenUnsatisfiable: DoNotSchedule
          labelSelector:
            matchLabels:
              app: api-reservas
          # 3 zonas siempre, aunque una no tenga nodos ahora mismo. Sin esto,
          # si el Cluster Autoscaler solo tiene nodos en 2 zonas, el reparto
          # "equilibrado" entre esas 2 cumpliria la restriccion y perderiamos
          # la garantia de las 3 zonas.
          minDomains: 3
          matchLabelKeys: ["pod-template-hash"]

        # NIVEL 2: entre NODOS. Restriccion BLANDA.
        #
        # Repartir entre nodos es deseable pero NO al precio de dejar pods
        # Pending. Durante el puente de mayo, con 30 replicas y el Cluster
        # Autoscaler arrancando nodos, un DoNotSchedule aqui bloquearia el
        # escalado justo cuando mas falta hace.
        #
        # maxSkew 2 (no 1): con 30 replicas y 9 nodos, exigir maxSkew 1 seria
        # innecesariamente rigido. Un 4-3-3-4-3-3-4-3-3 es perfectamente sano.
        - maxSkew: 2
          topologyKey: kubernetes.io/hostname
          whenUnsatisfiable: ScheduleAnyway
          labelSelector:
            matchLabels:
              app: api-reservas
          matchLabelKeys: ["pod-template-hash"]

      containers:
        - name: api
          image: registry.rutasnorte.example/api-reservas:1.14.2
          resources:
            requests: {cpu: 412m, memory: 800Mi}
            limits: {cpu: "1", memory: 1600Mi}

La asimetría entre los dos niveles es la decisión clave, y merece explicarse bien:

Nivel whenUnsatisfiable Razonamiento
Zona DoNotSchedule La caída de una zona es un evento catastrófico y plausible. Un pod Pending es preferible a perder la garantía
Nodo ScheduleAnyway La caída de un nodo es frecuente pero de impacto limitado. Bloquear el escalado sería peor que un reparto imperfecto

El cálculo con 4 réplicas (valle)

3 zonas, maxSkew 1, minDomains 3.

Reparto: 4 / 3 = 1,33
Resultado: A:2, B:1, C:1. Sesgo = 2-1 = 1. Cumple.

Caida de la zona A: quedan 2 de 4 (50 %).
Caida de la zona B o C: quedan 3 de 4 (75 %).

Con maxUnavailable: 25% en el PDB, 4 replicas permiten 1 desalojo.
La caida de una zona (2 replicas) es INVOLUNTARIA: el PDB no aplica.
Sobreviven 2 replicas -> el servicio sigue, degradado.
KEDA/HPA detectara la carga por replica duplicada y escalara.

El cálculo con 30 réplicas (pico del puente)

Nivel ZONA (maxSkew 1, DoNotSchedule):
  30 / 3 = 10 exactas.
  Resultado: A:10, B:10, C:10. Sesgo 0. Perfecto.

Nivel NODO (maxSkew 2, ScheduleAnyway), con 9 nodos (3 por zona):
  Dentro de cada zona, 10 replicas entre 3 nodos: 4-3-3.
  Sesgo global entre nodos: 4 - 3 = 1 <= 2. Cumple.

  Reparto final:
    zona A: nodo-a1:4, nodo-a2:3, nodo-a3:3
    zona B: nodo-b1:4, nodo-b2:3, nodo-b3:3
    zona C: nodo-c1:4, nodo-c2:3, nodo-c3:3

Caida de un NODO: se pierden 3 o 4 replicas de 30 (10-13 %). Imperceptible.
Caida de una ZONA: se pierden 10 de 30 (33 %). El servicio aguanta con 20.

tienda-web

      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: topology.kubernetes.io/zone
          whenUnsatisfiable: DoNotSchedule
          labelSelector:
            matchLabels:
              app: tienda-web
          minDomains: 3
          matchLabelKeys: ["pod-template-hash"]
        - maxSkew: 1
          topologyKey: kubernetes.io/hostname
          # maxSkew 1 y ScheduleAnyway: con solo 3-15 replicas, un reparto
          # de 1 por nodo es alcanzable y deseable. Pero seguimos sin
          # bloquear el escalado.
          whenUnsatisfiable: ScheduleAnyway
          labelSelector:
            matchLabels:
              app: tienda-web
          matchLabelKeys: ["pod-template-hash"]

Con 3 réplicas y minDomains: 3, el reparto es exactamente una por zona. Es el mínimo aceptable para la puerta de entrada de la plataforma.

postgres-reservas: el caso especial

Con una sola réplica, topologySpreadConstraints no aporta nada: no hay nada que repartir. Lo que sí importa es dónde vive:

# k8s/base/statefulset-postgres-reservas.yaml (fragmento)
spec:
  template:
    spec:
      # Vive en el grupo de nodos de datos (09-03), con su taint y su afinidad.
      tolerations:
        - key: rol
          operator: Equal
          value: datos
          effect: NoSchedule
      affinity:
        nodeAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
            nodeSelectorTerms:
              - matchExpressions:
                  - key: rol
                    operator: In
                    values: ["datos"]
        # NO compartir nodo con redis-cache: si se cae ese nodo, no queremos
        # perder la base de datos Y la cache a la vez, porque el arranque de
        # la base de datos con la cache vacia es el peor escenario posible.
        podAntiAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
            - labelSelector:
                matchLabels:
                  app: redis-cache
              topologyKey: kubernetes.io/hostname

Y una consideración crucial que conecta con el módulo 5: el volumen persistente de postgres-reservas está anclado a una zona. Un PV de disco de bloque de un proveedor de nube solo es accesible desde su zona. Eso significa que:

  • El pod solo puede planificarse en nodos de esa zona.
  • Si se cae la zona, no se puede recuperar en otra zona sin restaurar de una copia de seguridad (05-06).

Esto no es un problema de Kubernetes, es una propiedad del almacenamiento de bloques. Y es la razón fundamental por la que la alta disponibilidad de una base de datos no se resuelve con objetos de Kubernetes (apartado 12).

  1. Alta disponibilidad en las demás capas

Hasta ahora hemos hablado de las aplicaciones. Pero la alta disponibilidad es una propiedad del sistema completo, y hay tres capas más que conviene revisar.

El plano de control y el quórum de etcd

Recordemos 01-02: el plano de control lo forman el servidor de API, el planificador, el gestor de controladores y etcd, la base de datos que guarda todo el estado del clúster.

etcd usa el algoritmo de consenso Raft, que necesita mayoría estricta para operar. De ahí sale la regla del número impar:

Miembros Quórum (mayoría) Fallos tolerados
1 1 0
2 2 0
3 2 1
4 3 1
5 3 2
6 4 2
7 4 3

Fíjate en que 4 miembros toleran los mismos fallos que 3, y 6 los mismos que 5. Un número par añade coste y latencia de consenso sin añadir tolerancia. De ahí que la configuración estándar sea 3 o 5 miembros, nunca par.

Qué pasa al perder el quórum:

Cluster de 3 miembros de etcd. Se caen 2.
Quorum necesario: 2. Miembros vivos: 1. NO HAY QUORUM.

Consecuencias:
  - etcd pasa a SOLO LECTURA. No acepta escrituras.
  - El servidor de API no puede persistir cambios.
  - kubectl apply falla. No se pueden crear, modificar ni borrar objetos.
  - El HPA no puede cambiar replicas.
  - El Cluster Autoscaler no puede hacer nada.

PERO, y esto es lo importante:
  - Los pods que YA estaban corriendo SIGUEN CORRIENDO.
  - Los kubelets siguen ejecutando sus contenedores.
  - Los Services siguen enrutando (kube-proxy tiene su estado local).
  - LA PLATAFORMA SIGUE ATENDIENDO TRAFICO.

El plano de control caído no tumba las aplicaciones en marcha. Es una propiedad de diseño muy valiosa de Kubernetes: el plano de datos sobrevive al plano de control. Lo que se pierde es la capacidad de cambiar cosas: escalar, desplegar, recuperarse de un fallo de pod.

Distribución recomendada del plano de control:

3 nodos de plano de control, uno por zona de disponibilidad:
  zona A: control-1 (etcd, api-server, scheduler, controller-manager)
  zona B: control-2
  zona C: control-3

Caida de una zona: quedan 2 de 3. Quorum = 2. SE MANTIENE.

En Kubernetes gestionado (10-06) esto lo hace el proveedor y no lo ves. Es uno de los argumentos más fuertes a favor de un clúster gestionado: la alta disponibilidad del plano de control es difícil de hacer bien y no aporta valor diferencial.

El controlador de Ingress

El controlador de Ingress (04-04) es la puerta de entrada de todo el tráfico externo. Si cae, la plataforma es inalcanzable aunque todos los pods estén sanos.

# k8s/entornos/pro/deployment-ingress-nginx.yaml (fragmento)
apiVersion: apps/v1
kind: Deployment
metadata:
  name: ingress-nginx-controller
  namespace: ingress-nginx
spec:
  replicas: 4              # Nunca 1. Nunca 2 en el mismo dominio de fallo.
  template:
    spec:
      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: topology.kubernetes.io/zone
          whenUnsatisfiable: DoNotSchedule
          labelSelector:
            matchLabels:
              app.kubernetes.io/name: ingress-nginx
          minDomains: 3
        - maxSkew: 1
          topologyKey: kubernetes.io/hostname
          whenUnsatisfiable: DoNotSchedule    # Aqui SI es duro
          labelSelector:
            matchLabels:
              app.kubernetes.io/name: ingress-nginx
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: ingress-nginx
  namespace: ingress-nginx
spec:
  minAvailable: 2          # Numero absoluto: las replicas del Ingress son FIJAS
  selector:
    matchLabels:
      app.kubernetes.io/name: ingress-nginx
  unhealthyPodEvictionPolicy: AlwaysAllow

Dos decisiones que se apartan de lo anterior:

  1. DoNotSchedule también entre nodos. El Ingress no tiene HPA (sus réplicas son fijas), así que no hay riesgo de bloquear un escalado. Y dos réplicas del Ingress en el mismo nodo son dos réplicas que se pierden juntas.
  2. minAvailable: 2 en números absolutos. Con réplicas fijas, el número absoluto es más explícito y más fácil de razonar que un porcentaje.

CoreDNS

CoreDNS (04-03) resuelve todos los nombres del clúster. Si falla, api-reservas no encuentra postgres-reservas, worker-notificaciones no encuentra RabbitMQ, y todo se cae con errores de DNS que despistan muchísimo.

Es un componente que casi nadie revisa y que es un punto único de fallo silencioso.

# Fragmento del Deployment de CoreDNS en kube-system
spec:
  replicas: 3
  template:
    spec:
      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: topology.kubernetes.io/zone
          whenUnsatisfiable: ScheduleAnyway
          labelSelector:
            matchLabels:
              k8s-app: kube-dns
      priorityClassName: system-cluster-critical

Y su PDB:

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: coredns
  namespace: kube-system
spec:
  maxUnavailable: 1
  selector:
    matchLabels:
      k8s-app: kube-dns
  unhealthyPodEvictionPolicy: AlwaysAllow

Un consejo adicional muy rentable: NodeLocal DNSCache, un DaemonSet que pone una caché de DNS en cada nodo. Reduce la latencia de resolución, descarga a CoreDNS, y hace que un fallo temporal de CoreDNS no afecte a las consultas cacheadas. Volveremos a él en 09-06 al hablar del ndots: 5.

postgres-reservas: la disponibilidad la da el operador

Y llegamos al caso más importante, el que resume la lección de todo el módulo sobre las cargas con estado.

Kubernetes no puede hacer que PostgreSQL sea de alta disponibilidad. Lo que Kubernetes ofrece:

  • Reiniciar el pod si el proceso muere.
  • Reprogramarlo en otro nodo si el nodo falla (si el volumen es accesible).
  • Un PDB que impide desalojos automáticos.

Lo que Kubernetes no ofrece:

Capacidad necesaria Por qué Kubernetes no la da
Replicación de datos entre instancias Es un protocolo de PostgreSQL, no de Kubernetes
Promoción automática de una réplica a primaria Requiere conocer el estado de la replicación
Redirigir las escrituras a la nueva primaria Requiere cambiar el Service en el momento justo
Evitar el «cerebro dividido» (dos primarias) Requiere una lógica de consenso específica
Garantizar que no se pierden transacciones confirmadas Requiere entender el WAL de PostgreSQL

Todo eso lo aporta un operador (06-07): CloudNativePG, Zalando Postgres Operator, Crunchy PGO. Un operador de PostgreSQL:

flowchart TD
    OP[Operador de PostgreSQL<br/>controlador con conocimiento del dominio]
    P[(postgres-primario<br/>zona A<br/>ESCRITURAS)]
    R1[(postgres-replica-1<br/>zona B<br/>lecturas)]
    R2[(postgres-replica-2<br/>zona C<br/>lecturas)]
    SVCW[Service: postgres-rw<br/>apunta al PRIMARIO]
    SVCR[Service: postgres-ro<br/>apunta a las REPLICAS]

    OP -->|vigila salud y replicacion| P
    OP -->|vigila| R1
    OP -->|vigila| R2
    P -->|replicacion en streaming| R1
    P -->|replicacion en streaming| R2
    OP -.->|si el primario cae:<br/>PROMOCIONA y reapunta| SVCW
    SVCW --> P
    SVCR --> R1
    SVCR --> R2

    style OP fill:#cde,stroke:#369,stroke-width:2px
    style P fill:#cfc,stroke:#393

Con un operador, el modelo cambia por completo:

# Ejemplo con CloudNativePG
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
  name: postgres-reservas
  namespace: rutas-norte-pro
spec:
  instances: 3                    # 1 primaria + 2 replicas
  primaryUpdateStrategy: unsupervised

  storage:
    size: 100Gi
    storageClass: rutasnorte-rapida    # De 05-04

  # El operador reparte las instancias entre zonas automaticamente
  affinity:
    enablePodAntiAffinity: true
    topologyKey: topology.kubernetes.io/zone
    podAntiAffinityType: required

  postgresql:
    parameters:
      max_connections: "200"
      shared_buffers: "1GB"

  monitoring:
    enablePodMonitor: true         # Se integra con Prometheus (07-03)

Y el operador crea, mantiene y gestiona:

  • Tres StatefulSets con sus PVC en tres zonas.
  • Los Services postgres-reservas-rw (primario) y postgres-reservas-ro (réplicas).
  • La replicación en streaming entre instancias.
  • La conmutación automática: si el primario cae, promociona una réplica y reapunta el Service -rw en unos segundos.
  • Sus propios PDB, correctamente configurados para su modelo de quórum.
  • Copias de seguridad continuas al almacén de objetos.

La lección: para las cargas con estado, la alta disponibilidad se delega en un operador que entienda el sistema. Los PDB y topologySpreadConstraints son herramientas genéricas de Kubernetes; un operador aporta el conocimiento específico del dominio que ninguna herramienta genérica puede tener.

Aplicar esto en Rutas Norte es el trabajo pendiente que se aborda en el módulo 11 (11-02).

  1. El procedimiento de mantenimiento de un nodo

Reunamos todo en el procedimiento operativo completo. Esto es lo que se ejecuta el martes por la mañana.

El flujo

flowchart TD
    A[1. Verificar el estado previo<br/>PDB, replicas, alertas] --> B{Todos los PDB<br/>permiten desalojos?}
    B -->|No| C[Corregir los PDB imposibles<br/>o planificar intervencion manual]
    C --> A
    B -->|Si| D[2. cordon: dejar de aceptar pods nuevos]
    D --> E[3. drain: desalojar los pods existentes<br/>respetando los PDB]
    E --> F{Se completo?}
    F -->|No, bloqueado| G[Diagnosticar: que PDB lo impide?]
    G --> H[Escalar replicas o corregir]
    H --> E
    F -->|Si| I[4. Verificar: nodo vacio<br/>y servicio sano]
    I --> J[5. MANTENIMIENTO<br/>actualizar kernel, kubelet, reiniciar]
    J --> K[6. Verificar que el nodo vuelve Ready]
    K --> L[7. uncordon: volver a aceptar pods]
    L --> M[8. Verificar la distribucion<br/>y descordonar el siguiente]

    style D fill:#ffd,stroke:#c90
    style E fill:#ffd,stroke:#c90
    style L fill:#cfc,stroke:#393

Paso 1: verificación previa

# ¿Todos los PDB permiten al menos un desalojo?
kubectl get pdb --all-namespaces
NAMESPACE          NAME                    MIN AVAILABLE   MAX UNAVAILABLE   ALLOWED DISRUPTIONS
rutas-norte-pro    api-reservas            N/A             25%               1
rutas-norte-pro    tienda-web              N/A             34%               1
rutas-norte-pro    postgres-reservas       1               N/A               0     <- ATENCION
rutas-norte-pro    redis-cache             1               N/A               0     <- ATENCION
rutas-norte-pro    worker-notificaciones   N/A             50%               1
ingress-nginx      ingress-nginx           2               N/A               2
kube-system        coredns                 N/A             1                 1

Los dos ceros son los deliberados del apartado 8. Hay que saber en qué nodos están:

kubectl get pods -n rutas-norte-pro -o wide \
  -l 'app in (postgres-reservas,redis-cache)'
NAME                  READY   STATUS    NODE           ZONA
postgres-reservas-0   2/2     Running   nodo-datos-1   eu-west-1a
redis-cache-0         1/1     Running   nodo-datos-2   eu-west-1b

Esos dos nodos requieren un procedimiento distinto, con ventana de mantenimiento planificada. Los demás se pueden drenar sin más.

# ¿Qué hay en el nodo que vamos a drenar?
kubectl get pods --all-namespaces -o wide --field-selector spec.nodeName=nodo-a2
NAMESPACE         NAME                            READY   STATUS    AGE
rutas-norte-pro   api-reservas-7c9d4f8b6d-2mkjp   2/2     Running   4h
rutas-norte-pro   api-reservas-7c9d4f8b6d-9nvtr   2/2     Running   4h
rutas-norte-pro   tienda-web-6f7d9c4b58-4kjnx     1/1     Running   2d
kube-system       fluentd-x7kqp                   1/1     Running   12d   <- DaemonSet
kube-system       falco-2wxkp                     1/1     Running   12d   <- DaemonSet
kube-system       kube-proxy-mn2vp                1/1     Running   12d   <- DaemonSet

Tres pods de aplicación y tres DaemonSets. Los DaemonSets no se drenan (uno por nodo por definición).

# ¿Hay alguna alerta activa? No se hace mantenimiento con incidentes abiertos.
kubectl get events --all-namespaces --field-selector type=Warning \
  --sort-by=.lastTimestamp | tail -20

Paso 2: cordon

kubectl cordon nodo-a2
node/nodo-a2 cordoned

Qué hace exactamente: pone spec.unschedulable: true en el nodo y le añade el taint node.kubernetes.io/unschedulable:NoSchedule.

kubectl get nodes
NAME      STATUS                     ROLES    AGE   VERSION
nodo-a1   Ready                      <none>   12d   v1.30.2
nodo-a2   Ready,SchedulingDisabled   <none>   12d   v1.30.2
nodo-a3   Ready                      <none>   12d   v1.30.2

cordon NO mueve nada. Los pods que ya están siguen corriendo. Solo impide que lleguen nuevos. Es una operación segura y reversible al instante.

Paso 3: drain

kubectl drain nodo-a2 \
  --ignore-daemonsets \
  --delete-emptydir-data \
  --grace-period=60 \
  --timeout=300s

Las opciones, una a una:

Opción Qué hace ¿Necesaria?
--ignore-daemonsets No intenta desalojar pods de DaemonSet Sí, siempre. Sin ella drain falla
--delete-emptydir-data Acepta perder el contenido de los emptyDir si hay pods con emptyDir (caché de nginx)
--grace-period=60 Segundos para que el pod termine limpiamente Recomendado: da tiempo al apagado ordenado
--timeout=300s Abandona tras 5 minutos Muy recomendable. Evita esperar para siempre
--force Borra también pods sin controlador Peligroso: esos pods no vuelven
--disable-eviction Ignora los PDB usando borrado directo Solo emergencias
--pod-selector Drena solo los pods que coincidan Útil para drenajes parciales
--dry-run=client Muestra qué haría sin hacerlo Excelente para verificar antes

Salida esperada:

node/nodo-a2 already cordoned
Warning: ignoring DaemonSet-managed Pods: kube-system/fluentd-x7kqp,
         kube-system/falco-2wxkp, kube-system/kube-proxy-mn2vp
evicting pod rutas-norte-pro/api-reservas-7c9d4f8b6d-2mkjp
evicting pod rutas-norte-pro/tienda-web-6f7d9c4b58-4kjnx
evicting pod rutas-norte-pro/api-reservas-7c9d4f8b6d-9nvtr
error when evicting pods/"api-reservas-7c9d4f8b6d-9nvtr" -n "rutas-norte-pro"
(will retry after 5s): Cannot evict pod as it would violate the pod's disruption budget.
pod/tienda-web-6f7d9c4b58-4kjnx evicted
pod/api-reservas-7c9d4f8b6d-2mkjp evicted
evicting pod rutas-norte-pro/api-reservas-7c9d4f8b6d-9nvtr
pod/api-reservas-7c9d4f8b6d-9nvtr evicted
node/nodo-a2 drained

Fíjate en el segundo pod de api-reservas: el primer intento falló con el mensaje del PDB, esperó 5 segundos, y al reintentar tuvo éxito. ¿Qué pasó en esos 5 segundos? El primer pod desalojado se recreó en otro nodo y pasó a Ready, liberando presupuesto de interrupción.

Este es el PDB funcionando exactamente como debe: no ha impedido el mantenimiento, lo ha serializado para que nunca haya más de un pod caído a la vez.

Paso 4: verificar

# El nodo debe estar vacio de pods de aplicacion
kubectl get pods --all-namespaces -o wide --field-selector spec.nodeName=nodo-a2
NAMESPACE     NAME                READY   STATUS    AGE
kube-system   fluentd-x7kqp       1/1     Running   12d
kube-system   falco-2wxkp         1/1     Running   12d
kube-system   kube-proxy-mn2vp    1/1     Running   12d

Solo DaemonSets. Correcto.

# Y el SERVICIO debe estar sano: esto es lo que de verdad importa
kubectl get pods -n rutas-norte-pro -l app=api-reservas
kubectl get pdb -n rutas-norte-pro

Comprobación en Grafana (07-04): latencia p95 estable, tasa de error en cero. Si el drenaje ha degradado el servicio, no continúes con el siguiente nodo.

Paso 5: el mantenimiento

# Ejemplo: actualizar el kubelet en un cluster con kubeadm (10-02)
ssh nodo-a2

sudo apt-get update
sudo apt-get install -y kubeadm=1.30.3-1.1
sudo kubeadm upgrade node
sudo apt-get install -y kubelet=1.30.3-1.1 kubectl=1.30.3-1.1
sudo systemctl daemon-reload
sudo systemctl restart kubelet

exit

Paso 6: verificar el nodo

kubectl get node nodo-a2
NAME      STATUS                     ROLES    AGE   VERSION
nodo-a2   Ready,SchedulingDisabled   <none>   12d   v1.30.3

Ready con la versión nueva. Sigue acordonado, que es lo correcto.

# Comprobar que no hay condiciones de error
kubectl describe node nodo-a2 | grep -A10 Conditions

Paso 7: uncordon

kubectl uncordon nodo-a2
node/nodo-a2 uncordoned
NAME      STATUS   ROLES    AGE   VERSION
nodo-a2   Ready    <none>   12d   v1.30.3

Nunca olvides este paso. Un nodo que queda acordonado es capacidad pagada e invisible: el Cluster Autoscaler puede arrancar nodos nuevos mientras este está vacío y disponible pero rechazando pods.

Un detalle que sorprende: uncordon no reequilibra los pods existentes. El nodo está disponible, pero los pods que se movieron no vuelven. Kubernetes no reprograma pods en marcha. La redistribución ocurre de forma natural con el siguiente escalado o despliegue.

Si quieres forzar el reequilibrado, la herramienta es el descheduler, un componente que desaloja pods que violan las políticas de distribución para que se replanifiquen mejor. Respeta los PDB, naturalmente.

Paso 8: el siguiente nodo

# Verificar la distribucion antes de continuar
kubectl get pods -n rutas-norte-pro -l app=api-reservas -o wide \
  | awk '{print $7}' | sort | uniq -c
      2 nodo-a1
      1 nodo-a3
      2 nodo-b1
      1 nodo-b2

Tras el drenaje, nodo-a2 está vacío y sus pods se repartieron. La distribución sigue siendo razonable entre zonas (3 en A, 3 en B... aunque falta C: habría que revisar si minDomains se está cumpliendo).

Regla de oro: un nodo cada vez, con verificación entre cada uno. Drenar dos nodos en paralelo puede violar los PDB de formas que no anticipaste.

Qué se rompe si no hay PDB

Vale la pena hacer el ejercicio mental. Sin ningún PDB:

kubectl drain nodo-a2 --ignore-daemonsets --delete-emptydir-data
node/nodo-a2 cordoned
evicting pod rutas-norte-pro/api-reservas-7c9d4f8b6d-2mkjp
evicting pod rutas-norte-pro/api-reservas-7c9d4f8b6d-9nvtr
evicting pod rutas-norte-pro/tienda-web-6f7d9c4b58-4kjnx
pod/api-reservas-7c9d4f8b6d-2mkjp evicted
pod/api-reservas-7c9d4f8b6d-9nvtr evicted
pod/tienda-web-6f7d9c4b58-4kjnx evicted
node/nodo-a2 drained

Instantáneo. Los tres pods desalojados a la vez. Y en ese instante:

api-reservas tenia 4 replicas: 2 en nodo-a2, 1 en nodo-a1, 1 en nodo-b1.
Tras el drenaje: 2 replicas vivas, 2 arrancando (25-30 segundos).

Durante 30 segundos: LA MITAD de la capacidad de la API.
Cada replica viva recibe el DOBLE de carga.
Latencia p95: de 180 ms a 900 ms.
Algunas peticiones: timeout.

Y si hubieran sido 3 de las 4 replicas en ese nodo: 25 % de capacidad.
La replica superviviente se satura y cae. CAIDA TOTAL.

El PDB convierte ese desastre en un desalojo serializado de 90 segundos sin impacto perceptible. Es la diferencia entre un mantenimiento y un incidente.

  1. Interacción con el Cluster Autoscaler y los despliegues

Dos interacciones que conviene tener presentes.

Con el Cluster Autoscaler (09-03)

El CA usa la API de desalojo, así que respeta los PDB. Consecuencias:

Consecuencia 1: un PDB imposible ancla el nodo permanentemente. Ya lo vimos en 09-03: los logs dicen pdb-blocked y el nodo nunca se retira. Con postgres-reservas es intencionado; con un PDB mal configurado, es dinero tirado.

Consecuencia 2: la reducción es más lenta con PDB. El CA tiene que desalojar los pods de uno en uno, esperando a que se recreen. Un nodo con 8 pods de api-reservas y maxUnavailable: 25% con 12 réplicas totales tarda varios minutos en vaciarse. Es correcto y deseable, pero hay que saberlo para no pensar que el CA está roto.

Consecuencia 3: hay que revisar los PDB cuando cambian los maxReplicas. Si subes maxReplicas de api-reservas de 30 a 60, el maxUnavailable: 25% permitirá 15 desalojos simultáneos en el pico. ¿Es aceptable perder 15 réplicas a la vez? Probablemente sí, pero es una decisión que merece pensarse.

Y una interacción especialmente sutil: el colchón de sobreaprovisionamiento no debe llevar PDB. Los pods de relleno de 09-03 están para ser desalojados; un PDB los protegería y rompería todo el mecanismo. Si acaso, un PDB explícitamente permisivo:

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: relleno-capacidad
  namespace: rutas-norte-pro
spec:
  # 100 % de indisponibilidad permitida: estos pods estan PARA ser desalojados.
  # Documenta la intencion mejor que la ausencia de PDB.
  maxUnavailable: 100%
  selector:
    matchLabels:
      app: relleno-capacidad

Con los despliegues (02-04)

El controlador de Deployments no usa la API de desalojo durante una actualización progresiva: borra pods directamente y aplica su propio maxUnavailable. Los PDB no intervienen.

Esto significa que necesitas configurar los dos coherentemente:

# En el Deployment: gobierna la ACTUALIZACION
spec:
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 25%     # Coherente con el PDB
      maxSurge: 25%
---
# En el PDB: gobierna los DESALOJOS (drain, CA, VPA)
spec:
  maxUnavailable: 25%

Si el Deployment permite el 50 % de indisponibilidad y el PDB solo el 25 %, tendrás una incoherencia: durante un despliegue el servicio bajará al 50 %, pero un drenaje solo podrá bajarlo al 75 %. No es un error, pero es una inconsistencia de criterio que conviene resolver.

Recomendación: usa el mismo valor en ambos, para que el nivel de servicio garantizado sea el mismo pase lo que pase.

Y un escenario a evitar: un despliegue y un mantenimiento a la vez. Durante una actualización, la mitad de los pods están arrancando y no cuentan como disponibles. Si además drenas un nodo, el PDB bloqueará el drenaje (correctamente) y el mantenimiento se atascará. Nunca hagas mantenimiento durante un despliegue, y viceversa.

  1. Una prueba de caos sencilla

Todo lo anterior es teoría hasta que se comprueba. Vamos a matar un nodo y ver qué pasa.

Preparación en minikube

minikube start -p rutas-norte --nodes=4 --cpus=2 --memory=4096

# Etiquetar los nodos con zonas ficticias
kubectl label node rutas-norte     topology.kubernetes.io/zone=zona-a
kubectl label node rutas-norte-m02 topology.kubernetes.io/zone=zona-a
kubectl label node rutas-norte-m03 topology.kubernetes.io/zone=zona-b
kubectl label node rutas-norte-m04 topology.kubernetes.io/zone=zona-b

Despliegue con distribución y PDB:

# /tmp/caos-demo.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: caos-api
  namespace: rutas-norte-dev
spec:
  replicas: 6
  selector:
    matchLabels:
      app: caos-api
  template:
    metadata:
      labels:
        app: caos-api
    spec:
      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: topology.kubernetes.io/zone
          whenUnsatisfiable: DoNotSchedule
          labelSelector:
            matchLabels:
              app: caos-api
          matchLabelKeys: ["pod-template-hash"]
        - maxSkew: 1
          topologyKey: kubernetes.io/hostname
          whenUnsatisfiable: ScheduleAnyway
          labelSelector:
            matchLabels:
              app: caos-api
          matchLabelKeys: ["pod-template-hash"]
      containers:
        - name: web
          image: nginx:1.27-alpine
          resources:
            requests: {cpu: 50m, memory: 32Mi}
          readinessProbe:
            httpGet: {path: /, port: 80}
            initialDelaySeconds: 2
            periodSeconds: 3
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: caos-api
  namespace: rutas-norte-dev
spec:
  maxUnavailable: 25%
  selector:
    matchLabels:
      app: caos-api
  unhealthyPodEvictionPolicy: AlwaysAllow
---
apiVersion: v1
kind: Service
metadata:
  name: caos-api
  namespace: rutas-norte-dev
spec:
  selector:
    app: caos-api
  ports:
    - port: 80
kubectl apply -f /tmp/caos-demo.yaml
kubectl get pods -n rutas-norte-dev -o wide
NAME                        READY   STATUS    NODE              ZONA
caos-api-7d9c5f8b6b-2mkjp   1/1     Running   rutas-norte       zona-a
caos-api-7d9c5f8b6b-4nqzx   1/1     Running   rutas-norte-m02   zona-a
caos-api-7d9c5f8b6b-7wtrv   1/1     Running   rutas-norte-m02   zona-a
caos-api-7d9c5f8b6b-9hbcx   1/1     Running   rutas-norte-m03   zona-b
caos-api-7d9c5f8b6b-kp3ln   1/1     Running   rutas-norte-m04   zona-b
caos-api-7d9c5f8b6b-tv6ws   1/1     Running   rutas-norte-m03   zona-b

Distribución perfecta: 3 en zona-a, 3 en zona-b. El maxSkew: 1 entre nodos también se cumple: 1-2-2-1.

Experimento 1: el mantenimiento planificado (voluntario)

# Terminal 1: generar trafico continuo y contar errores
kubectl -n rutas-norte-dev run cliente --rm -it --restart=Never \
  --image=busybox:1.36 -- /bin/sh -c \
  'ok=0; err=0; while true; do
     if wget -q -T2 -O- http://caos-api > /dev/null 2>&1; then
       ok=$((ok+1)); else err=$((err+1)); fi;
     echo "OK=$ok ERR=$err";
     sleep 0.2;
   done'
# Terminal 2: el drenaje
kubectl drain rutas-norte-m02 --ignore-daemonsets --delete-emptydir-data

Resultado esperado en el terminal 1:

OK=847 ERR=0
OK=848 ERR=0
OK=849 ERR=0
...
OK=1204 ERR=0

Cero errores. El PDB serializó el desalojo de los 2 pods de ese nodo, y el Service (04-02) sacó de sus endpoints cada pod en cuanto dejó de estar Ready.

Esto es lo que se busca: el mantenimiento es invisible para el usuario.

# Restaurar
kubectl uncordon rutas-norte-m02

Experimento 2: la caída de un nodo (involuntaria)

# Simular un fallo de hardware: apagar el nodo bruscamente
minikube -p rutas-norte node stop rutas-norte-m03

Observemos la secuencia completa:

kubectl get nodes --watch
NAME              STATUS     ROLES    AGE
rutas-norte-m03   Ready      <none>   1h
rutas-norte-m03   NotReady   <none>   1h     <- a los ~40 segundos
kubectl get pods -n rutas-norte-dev -o wide --watch
NAME                        READY   STATUS        NODE
caos-api-7d9c5f8b6b-9hbcx   1/1     Running       rutas-norte-m03
caos-api-7d9c5f8b6b-tv6ws   1/1     Running       rutas-norte-m03
...
(pasan ~5 minutos)
...
caos-api-7d9c5f8b6b-9hbcx   1/1     Terminating   rutas-norte-m03
caos-api-7d9c5f8b6b-tv6ws   1/1     Terminating   rutas-norte-m03
caos-api-7d9c5f8b6b-x2klm   0/1     Pending       <none>
caos-api-7d9c5f8b6b-p9wnz   0/1     Pending       <none>
caos-api-7d9c5f8b6b-x2klm   0/1     ContainerCreating   rutas-norte-m04
caos-api-7d9c5f8b6b-p9wnz   1/1     Running             rutas-norte-m04

Los tiempos son la enseñanza del experimento:

Momento Evento Tiempo acumulado
t=0 El nodo se apaga 0
t=40 s El plano de control lo marca NotReady (node-monitor-grace-period) 40 s
t=40 s El Service saca sus pods de los endpoints. El tráfico deja de ir ahí 40 s
t=5 min Los taints NoExecute desalojan los pods (tolerationSeconds: 300) 5 min
t=5 min El ReplicaSet crea los pods sustitutos 5 min
t=5 min 20 s Los nuevos pods están Ready 5 min 20 s

El punto crítico son esos primeros 40 segundos: el nodo está muerto pero Kubernetes todavía no lo sabe, y el Service sigue enviándole tráfico. Durante esos 40 segundos, un tercio de las peticiones fallan.

En el terminal 1 verías algo así:

OK=1204 ERR=0
OK=1210 ERR=3      <- empiezan los errores
OK=1218 ERR=11
...
OK=1301 ERR=68     <- ~40 segundos de errores
OK=1409 ERR=68     <- se detienen: el Service ya excluyo el nodo muerto
OK=1520 ERR=68

Esta es la diferencia fundamental entre las interrupciones voluntarias y las involuntarias, medida con un cronómetro:

Voluntaria (drain) Involuntaria (nodo muerto)
El Service excluye los pods Inmediatamente (readiness falla al recibir SIGTERM) A los ~40 segundos
Errores para el usuario Cero ~40 segundos de errores
Protección posible PDB Ninguna; solo limitar el daño repartiendo

Qué hacer con esos 40 segundos

No se pueden eliminar, pero se pueden mitigar:

Medida Efecto
Repartir bien las réplicas (topologySpreadConstraints) Con 2 de 6 réplicas en el nodo muerto, solo falla el 33 % de las peticiones, no el 100 %
Reintentos en el cliente tienda-web reintenta con otro pod; el usuario no ve el error
Malla de servicio (08-04) Detección de fallos y reintentos automáticos, con expulsión de instancias que fallan
Bajar node-monitor-grace-period Detecta antes... pero produce falsos positivos con latencia de red

La última opción es tentadora y casi siempre mala idea: bajar el umbral de detección hace que una latencia temporal de red se interprete como un nodo caído, y desaloja pods sanos. Los 40 segundos por defecto son un compromiso bien elegido.

Limpieza

minikube -p rutas-norte node start rutas-norte-m03
kubectl delete -f /tmp/caos-demo.yaml

Escalando la prueba de caos

Este experimento manual es el nivel más básico. En un entorno serio, se automatiza con herramientas de ingeniería del caos (Chaos Mesh, LitmusChaos) que permiten:

  • Matar pods aleatorios de forma continua.
  • Inyectar latencia de red entre servicios.
  • Simular la caída de una zona completa.
  • Llenar el disco de un nodo.
  • Ejecutarlo periódicamente y alertar si el sistema no aguanta.

Pero el experimento manual es donde hay que empezar, porque es el que enseña los tiempos reales. Hazlo una vez en rutas-norte-pre antes del puente de mayo y sabrás exactamente qué esperar.

Errores Comunes y Consejos

Error 1: PDB con minAvailable absoluto sobre una carga con HPA. El error más frecuente de este módulo. El HPA baja las réplicas en el valle, el minAvailable se vuelve inalcanzable, y el PDB pasa a bloquear todo el mantenimiento y toda la reducción de nodos. Con autoescalado, siempre porcentajes.

Error 2: creer que un PDB protege de la caída de un nodo. No lo hace. Los PDB solo intervienen en desalojos que pasan por la API. Un nodo que se apaga no pide permiso. La protección contra fallos es la distribución topológica.

Error 3: podAntiAffinity requerida sobre hostname en una carga con HPA. Limita las réplicas al número de nodos. El HPA pedirá 30, se colocarán 9, y 21 quedarán Pending para siempre mientras el Cluster Autoscaler arranca nodos que solo caben de uno en uno. Usa topologySpreadConstraints.

Error 4: DoNotSchedule entre nodos en una carga con autoescalado. Durante el puente de mayo, con el CA arrancando nodos, una restricción dura entre nodos bloquea el escalado justo cuando más falta hace. DoNotSchedule para zonas, ScheduleAnyway para nodos.

Error 5: olvidar matchLabelKeys: ["pod-template-hash"]. Sin él, el cálculo del sesgo mezcla las versiones vieja y nueva durante un despliegue, y la actualización se atasca o se distribuye mal. Inclúyelo siempre en Deployments.

Error 6: olvidar el uncordon. Un nodo acordonado y olvidado es capacidad pagada e inutilizable. El síntoma —pods Pending mientras hay nodos aparentemente sanos— es especialmente desconcertante. Ponlo en la lista de comprobación del mantenimiento.

Error 7: --disable-eviction para «desatascar» un drenaje. Salta los PDB usando borrado directo. Puede dejar un servicio sin ninguna réplica. Si un drenaje está bloqueado, la respuesta correcta es diagnosticar por qué, no desactivar la protección.

Error 8: no poner PDB a los componentes de infraestructura. CoreDNS, el controlador de Ingress y Prometheus son tan críticos como la aplicación. Un drenaje que se lleve las dos réplicas de CoreDNS tumba la resolución de nombres de todo el clúster, con síntomas que nadie relaciona con el mantenimiento.

Consejo 1: revisa ALLOWED DISRUPTIONS antes de cada mantenimiento. Un kubectl get pdb --all-namespaces de dos segundos te dice si el mantenimiento va a fluir o a atascarse. Es la comprobación previa más rentable que existe.

Consejo 2: anota los PDB deliberadamente bloqueantes. postgres-reservas y redis-cache tienen ALLOWED DISRUPTIONS: 0 a propósito. Una anotación que lo explique evita que alguien los «arregle» sin entender por qué están así, y permite excluirlos de las alertas.

Consejo 3: alerta sobre PDB bloqueados no deliberados.

# PDB sin desalojos permitidos, excluyendo los intencionados
kube_poddisruptionbudget_status_pod_disruptions_allowed == 0
  unless on(namespace, poddisruptionbudget)
  kube_poddisruptionbudget_annotations{annotation_rutasnorte_example_pdb_bloqueo_intencionado!=""}

Consejo 4: verifica la distribución real, no la configurada.

# Replicas por zona
kubectl get pods -n rutas-norte-pro -l app=api-reservas \
  -o custom-columns=NODO:.spec.nodeName --no-headers | \
  while read n; do kubectl get node "$n" -o jsonpath='{.metadata.labels.topology\.kubernetes\.io/zone}{"\n"}'; done | \
  sort | uniq -c
      3 eu-west-1a
      3 eu-west-1b
      2 eu-west-1c

Una restricción bien escrita que no se cumple (porque no hay nodos en una zona, por ejemplo) es peor que ninguna: da falsa sensación de seguridad.

Consejo 5: ensaya el mantenimiento en rutas-norte-pre. Drena un nodo de preproducción con tráfico de prueba y mide el impacto. Si en pre hay errores, en pro los habrá multiplicados.

Consejo 6: haz la prueba de caos al menos una vez. Matar un nodo y cronometrar la recuperación enseña más que cualquier documentación. Los 40 segundos de detección son un número que hay que haber visto con los propios ojos.

Consejo 7: coordina el maxUnavailable del Deployment con el del PDB. Usa el mismo valor para que el nivel de servicio garantizado sea el mismo durante un despliegue y durante un mantenimiento.

Ejercicios

Ejercicio 1: diagnosticar un mantenimiento bloqueado

Es martes por la mañana y hay que actualizar los nodos. El drenaje del primer nodo lleva 20 minutos atascado:

kubectl drain nodo-b1 --ignore-daemonsets --delete-emptydir-data
node/nodo-b1 already cordoned
Warning: ignoring DaemonSet-managed Pods: kube-system/fluentd-2wxkp, kube-system/falco-x7kqp
evicting pod monitorizacion/prometheus-server-0
evicting pod rutas-norte-pro/api-reservas-7c9d4f8b6d-mn2vp
evicting pod rutas-norte-pro/worker-notificaciones-5f8d9c-4kjnx
pod/worker-notificaciones-5f8d9c-4kjnx evicted
error when evicting pods/"prometheus-server-0" -n "monitorizacion"
(will retry after 5s): Cannot evict pod as it would violate the pod's disruption budget.
error when evicting pods/"api-reservas-7c9d4f8b6d-mn2vp" -n "rutas-norte-pro"
(will retry after 5s): Cannot evict pod as it would violate the pod's disruption budget.

Estado de los PDB:

NAMESPACE          NAME                    MIN AVAILABLE   MAX UNAVAILABLE   ALLOWED DISRUPTIONS
rutas-norte-pro    api-reservas            8               N/A               0
rutas-norte-pro    tienda-web              N/A             34%               1
rutas-norte-pro    worker-notificaciones   N/A             50%               1
monitorizacion     prometheus              1               N/A               0

Estado de los pods:

NAMESPACE          NAME                            READY   STATUS    NODE
rutas-norte-pro    api-reservas-7c9d4f8b6d-2mkjp   2/2     Running   nodo-a1
rutas-norte-pro    api-reservas-7c9d4f8b6d-9nvtr   2/2     Running   nodo-a2
rutas-norte-pro    api-reservas-7c9d4f8b6d-mn2vp   2/2     Running   nodo-b1
rutas-norte-pro    api-reservas-7c9d4f8b6d-tv6ws   2/2     Running   nodo-b2
rutas-norte-pro    api-reservas-7c9d4f8b6d-x2klm   1/2     Running   nodo-a1
monitorizacion     prometheus-server-0             1/1     Running   nodo-b1

Analiza los dos bloqueos por separado. Para cada uno: ¿es legítimo o es un error? ¿Cuál es la causa exacta? ¿Cómo lo desbloqueas ahora y cómo evitas que vuelva a pasar? Presta especial atención al pod x2klm.

Ejercicio 2: calcular la distribución y el impacto

api-reservas tiene esta configuración:

topologySpreadConstraints:
  - maxSkew: 1
    topologyKey: topology.kubernetes.io/zone
    whenUnsatisfiable: DoNotSchedule
    minDomains: 3
    labelSelector:
      matchLabels:
        app: api-reservas
  - maxSkew: 2
    topologyKey: kubernetes.io/hostname
    whenUnsatisfiable: ScheduleAnyway
    labelSelector:
      matchLabels:
        app: api-reservas
# PDB
spec:
  maxUnavailable: 25%

El clúster tiene 9 nodos: 3 en eu-west-1a, 3 en eu-west-1b, 3 en eu-west-1c.

Calcula para 11 réplicas y para 26 réplicas:

(a) El reparto entre zonas y el sesgo resultante. (b) El reparto entre nodos dentro de cada zona. (c) Cuántas réplicas se pierden si cae un nodo y qué porcentaje de capacidad queda. (d) Cuántas réplicas se pierden si cae una zona entera y qué porcentaje queda. (e) Cuántos desalojos simultáneos permite el PDB en cada caso. (f) Si al drenar un nodo con 26 réplicas desplegadas se puede vaciar ese nodo de una sola vez, o el PDB lo serializa.

Además: ¿qué pasaría con 2 réplicas y minDomains: 3? ¿Y con 2 réplicas si una zona se queda sin nodos disponibles?

Ejercicio 3: diseñar la alta disponibilidad de un componente nuevo

Rutas Norte incorpora pasarela-pagos, el componente que habla con el banco para cobrar las reservas. Perfil:

  • Es un servicio HTTP sin estado, en el camino crítico: si falla, no se puede comprar.
  • Cada transacción tarda entre 2 y 8 segundos (esperando al banco), y no se puede interrumpir a mitad: una transacción cortada puede dejar un cobro sin reserva asociada.
  • El banco limita a 50 conexiones simultáneas desde la IP de Rutas Norte.
  • Tráfico: 5-40 transacciones por segundo en un día normal; hasta 300 en el pico del puente de mayo.
  • Arranque: 20 segundos (hay que establecer la sesión TLS mutua con el banco y validar certificados).
  • El equipo de cumplimiento exige que cada transacción quede registrada antes de responder al usuario.
  • Debe funcionar aunque caiga una zona de disponibilidad completa.

Diseña la estrategia completa de alta disponibilidad: número de réplicas, PDB, topologySpreadConstraints, terminationGracePeriodSeconds, y cualquier otro mecanismo que consideres necesario. Justifica cada decisión y escribe los manifiestos. Presta atención especial al límite de 50 conexiones del banco y a la exigencia de no interrumpir transacciones.


Soluciones

Solución 1

Hay tres hallazgos, no dos.

Bloqueo 1: api-reservas con minAvailable: 8 — ERROR de configuración.

Replicas totales de api-reservas: 5
  - 4 con READY 2/2 (sanas)
  - 1 con READY 1/2 (el pod x2klm: un contenedor no esta listo)

Replicas DISPONIBLES (Ready): 4
PDB exige: minAvailable: 8

4 < 8  ->  YA estamos por debajo del minimo.
ALLOWED DISRUPTIONS: 0. Y seguira siendo 0 pase lo que pase.

Es el caso de libro del PDB imposible con HPA: alguien puso minAvailable: 8 cuando había 12 réplicas durante una punta, y ahora el HPA ha bajado a 5. El PDB no se ajustó porque es un número absoluto.

Tercer hallazgo: el pod x2klm está en 1/2.

Este es el detalle que hay que cazar. api-reservas tiene dos contenedores (la API y el exportador de métricas). 1/2 significa que uno de ellos no está Ready.

kubectl describe pod api-reservas-7c9d4f8b6d-x2klm -n rutas-norte-pro
kubectl logs api-reservas-7c9d4f8b6d-x2klm -n rutas-norte-pro -c exportador-metricas

Un pod que no está completamente Ready no cuenta como disponible para el PDB. Así que aunque corrigiéramos el PDB, ese pod es un problema aparte que hay que investigar: puede ser el síntoma de algo más grave.

Desbloqueo inmediato:

kubectl patch pdb api-reservas -n rutas-norte-pro --type merge \
  -p '{"spec":{"minAvailable":null,"maxUnavailable":"25%"}}'

Efecto inmediato:

Replicas deseadas: 5
maxUnavailable: 25% -> suelo(5 x 0,25) = 1
Minimo disponible: 5 - 1 = 4
Disponibles actuales: 4
ALLOWED DISRUPTIONS: 0

...Sigue siendo 0, por culpa del pod x2klm que no esta Ready.

El parche solo no basta. Hay que resolver también el pod roto. Dos vías:

# Via A: si el pod esta roto sin remedio, borrarlo para que se recree
kubectl delete pod api-reservas-7c9d4f8b6d-x2klm -n rutas-norte-pro
# (kubectl delete NO respeta PDB: aqui nos viene bien)

# Via B: anadir unhealthyPodEvictionPolicy para que los pods no sanos
# no consuman presupuesto
kubectl patch pdb api-reservas -n rutas-norte-pro --type merge \
  -p '{"spec":{"unhealthyPodEvictionPolicy":"AlwaysAllow"}}'

La vía B es la correcta a largo plazo, y es exactamente el problema que resuelve unhealthyPodEvictionPolicy (apartado 7): un pod no sano estaba bloqueando la reparación del sistema.

Manifiesto corregido:

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: api-reservas
  namespace: rutas-norte-pro
  annotations:
    rutasnorte.example/nota: >
      PORCENTAJE obligatorio: api-reservas tiene KEDA/HPA y oscila entre
      4 y 30 replicas. Un minAvailable absoluto se vuelve imposible en el
      valle y bloquea todo el mantenimiento. Ver 09-05.
spec:
  maxUnavailable: 25%
  selector:
    matchLabels:
      app: api-reservas
  unhealthyPodEvictionPolicy: AlwaysAllow

Bloqueo 2: prometheus con minAvailable: 1 y una réplica — LEGÍTIMO pero mal resuelto.

prometheus-server-0: 1 replica (es un StatefulSet).
PDB: minAvailable: 1.
Desalojarla dejaria 0 disponibles.
ALLOWED DISRUPTIONS: 0. Permanente y por diseno.

El bloqueo es coherente con la configuración, pero la configuración es discutible. Hay que decidir qué se quiere:

Opción A: aceptar la interrupción. Prometheus con una réplica y almacenamiento local. Perder unos minutos de métricas durante un mantenimiento es molesto pero no crítico: no afecta al servicio a los usuarios.

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: prometheus
  namespace: monitorizacion
spec:
  # Una sola replica: permitimos el desalojo. Perdemos unos minutos de
  # metricas durante el mantenimiento, lo cual es aceptable: Prometheus
  # no esta en el camino critico de la venta.
  maxUnavailable: 1
  selector:
    matchLabels:
      app: prometheus

Opción B: dos réplicas. Prometheus en alta disponibilidad se hace con dos instancias independientes que recolectan lo mismo, más Thanos o Mimir para deduplicar. Es más complejo y solo se justifica si las alertas son críticas.

Decisión pragmática: opción A. Prometheus no está en el camino de la venta, y la observabilidad puede tener un hueco de tres minutos durante un mantenimiento planificado.

Nota importante y contraintuitiva: perder Prometheus durante el mantenimiento tiene un efecto secundario del módulo anterior. Si api-reservas escala con KEDA por una métrica de Prometheus (09-04), KEDA entrará en modo fallback mientras Prometheus no esté. Por eso el fallback de 09-04 es tan importante: cubre exactamente este caso.

Procedimiento completo para desatascar:

# 1. Corregir el PDB de api-reservas
kubectl patch pdb api-reservas -n rutas-norte-pro --type merge \
  -p '{"spec":{"minAvailable":null,"maxUnavailable":"25%","unhealthyPodEvictionPolicy":"AlwaysAllow"}}'

# 2. Corregir el PDB de prometheus
kubectl patch pdb prometheus -n monitorizacion --type merge \
  -p '{"spec":{"minAvailable":null,"maxUnavailable":1}}'

# 3. Verificar
kubectl get pdb --all-namespaces
NAMESPACE          NAME                    MAX UNAVAILABLE   ALLOWED DISRUPTIONS
rutas-norte-pro    api-reservas            25%               1
monitorizacion     prometheus              1                 1

El drenaje, que seguía reintentando en segundo plano, avanza solo.

# 4. Investigar el pod x2klm (no urgente, pero no lo olvides)
kubectl describe pod api-reservas-7c9d4f8b6d-x2klm -n rutas-norte-pro

Prevención estructural:

  1. Comprobación previa obligatoria en el procedimiento de mantenimiento:
kubectl get pdb --all-namespaces \
  -o custom-columns='NS:.metadata.namespace,NOMBRE:.metadata.name,PERMITIDOS:.status.disruptionsAllowed' \
  | awk 'NR==1 || $3=="0"'
  1. Política de Kyverno (08-03) que rechace PDB con minAvailable absoluto en cargas que tengan HPA o ScaledObject.

  2. Alerta permanente:

kube_poddisruptionbudget_status_pod_disruptions_allowed == 0

Con la anotación de exclusión para los bloqueos deliberados (postgres-reservas, redis-cache).

Solución 2

Caso A: 11 réplicas

(a) Reparto entre zonas (maxSkew: 1, DoNotSchedule, 3 zonas):

11 / 3 = 3,67

Reparto mas equilibrado posible:
  eu-west-1a: 4
  eu-west-1b: 4
  eu-west-1c: 3

Sesgo = 4 - 3 = 1 <= maxSkew 1. CUMPLE.

(b) Reparto entre nodos (maxSkew: 2, ScheduleAnyway, 3 nodos por zona):

Zona a (4 replicas, 3 nodos): 2-1-1
Zona b (4 replicas, 3 nodos): 2-1-1
Zona c (3 replicas, 3 nodos): 1-1-1

Sesgo global entre nodos: max 2 - min 1 = 1 <= maxSkew 2. CUMPLE.

(c) Caída de un nodo:

Peor caso: cae un nodo con 2 replicas.
Perdidas: 2 de 11.
Quedan: 9 de 11 = 81,8 % de capacidad.

(d) Caída de una zona:

Peor caso: cae una zona con 4 replicas.
Perdidas: 4 de 11.
Quedan: 7 de 11 = 63,6 % de capacidad.

(e) Desalojos permitidos:

maxUnavailable: 25% sobre 11 replicas.
suelo(11 x 0,25) = suelo(2,75) = 2

ALLOWED DISRUPTIONS: 2

Caso B: 26 réplicas

(a) Reparto entre zonas:

26 / 3 = 8,67

Reparto:
  eu-west-1a: 9
  eu-west-1b: 9
  eu-west-1c: 8

Sesgo = 9 - 8 = 1 <= maxSkew 1. CUMPLE.

(b) Reparto entre nodos:

Zona a (9 replicas, 3 nodos): 3-3-3
Zona b (9 replicas, 3 nodos): 3-3-3
Zona c (8 replicas, 3 nodos): 3-3-2

Sesgo global entre nodos: 3 - 2 = 1 <= maxSkew 2. CUMPLE.

(c) Caída de un nodo:

Peor caso: cae un nodo con 3 replicas.
Perdidas: 3 de 26.
Quedan: 23 de 26 = 88,5 % de capacidad.

(d) Caída de una zona:

Peor caso: cae una zona con 9 replicas.
Perdidas: 9 de 26.
Quedan: 17 de 26 = 65,4 % de capacidad.

(e) Desalojos permitidos:

suelo(26 x 0,25) = suelo(6,5) = 6

ALLOWED DISRUPTIONS: 6

(f) ¿Se puede vaciar un nodo de una vez con 26 réplicas?

Un nodo tiene 3 replicas de api-reservas.
El PDB permite 6 desalojos simultaneos.

3 <= 6  ->  SI, se pueden desalojar las 3 de una vez.

El drenaje NO se serializara por culpa del PDB de api-reservas.
Sera casi instantaneo para este componente.

Comparemos con el caso de 11 réplicas:

Con 11 replicas, un nodo tiene 2 replicas y el PDB permite 2 desalojos.
2 <= 2 -> tambien caben de una vez, aunque justo al limite.

Si el nodo tuviera 3 replicas (posible con maxSkew 2), el PDB serializaria:
desaloja 2, espera a que se recreen, desaloja la tercera.

Observación importante: el maxUnavailable: 25% escala con las réplicas, así que el mantenimiento se vuelve MÁS rápido con más réplicas, no más lento. Es exactamente lo que se quiere: en el pico hay más capacidad y se puede permitir perder más réplicas simultáneamente.

Caso especial: 2 réplicas con minDomains: 3

minDomains: 3 significa que el planificador considera 3 dominios,
aunque alguno este vacio.

Colocacion de la replica 1: zona a. Estado: a:1, b:0, c:0.
  Sesgo = 1 - 0 = 1 <= maxSkew 1. Cumple.

Colocacion de la replica 2:
  Si va a la zona a: a:2, b:0, c:0. Sesgo = 2 - 0 = 2 > 1. NO PERMITIDO.
  Si va a la zona b: a:1, b:1, c:0. Sesgo = 1 - 0 = 1 <= 1. PERMITIDO.

Resultado: 1 replica en la zona a, 1 en la zona b, 0 en la c.
La zona c queda vacia, y eso ES CORRECTO: con 2 replicas no se pueden
cubrir 3 zonas.

Sesgo final: 1. Cumple. La restricción funciona bien con menos réplicas que zonas.

Caso especial: 2 réplicas si una zona se queda sin nodos

Escenario: la zona c pierde todos sus nodos (caida completa).
Nodos disponibles: solo en las zonas a y b.

minDomains: 3 hace que el planificador SIGA CONTANDO 3 dominios.

Estado tras la caida: a:1, b:1, c:0 (la zona c no tiene nodos).
Sesgo = 1 - 0 = 1 <= maxSkew 1. CUMPLE. Nada que hacer.

Pero si el HPA quisiera escalar a 4 replicas:
  Colocacion de la replica 3:
    Si va a la zona a: a:2, b:1, c:0. Sesgo = 2 - 0 = 2 > 1. NO PERMITIDO.
    Si va a la zona b: a:1, b:2, c:0. Sesgo = 2 - 0 = 2 > 1. NO PERMITIDO.
    Si va a la zona c: NO HAY NODOS.

  -> La replica 3 se queda en PENDING PARA SIEMPRE.

Este es el peligro de minDomains combinado con DoNotSchedule. La restricción, pensada para garantizar el reparto, impide escalar cuando una zona no está disponible: exactamente el momento en que más falta hace escalar, porque has perdido un tercio de la capacidad.

Mitigaciones posibles:

Opción Efecto Contrapartida
Quitar minDomains Con 2 zonas vivas, el reparto entre ellas cumple Pierdes la garantía de las 3 zonas en operación normal
whenUnsatisfiable: ScheduleAnyway en zona Nunca bloquea Pierdes la garantía dura
Subir maxSkew a 2 Con a:2, b:1, c:0, el sesgo es 2 <= 2. Permite Reparto menos equilibrado
Aceptarlo y tener un procedimiento Ante una caída de zona, se relaja la restricción a mano Requiere intervención humana bajo presión

Recomendación para Rutas Norte: maxSkew: 1 con minDomains: 3 y un procedimiento documentado para relajarlo ante una caída de zona:

# PROCEDIMIENTO DE EMERGENCIA: caida de zona
# Relajar la restriccion topologica para permitir escalar en las zonas vivas.
kubectl patch deployment api-reservas -n rutas-norte-pro --type json -p '[
  {"op": "replace", "path": "/spec/template/spec/topologySpreadConstraints/0/whenUnsatisfiable",
   "value": "ScheduleAnyway"}
]'
# REVERTIR cuando la zona vuelva.

Tenerlo escrito de antemano, probado, y en el runbook. Es mucho mejor que improvisarlo a las tres de la madrugada.

Solución 3

Análisis de los requisitos y sus consecuencias:

Requisito Consecuencia de diseño
Camino crítico, sin estado Mínimo 3 réplicas, repartidas entre 3 zonas
Transacción de 2-8 s no interrumpible terminationGracePeriodSeconds alto + apagado ordenado
50 conexiones máximas del banco Límite duro al número de réplicas
5-300 transacciones/s Autoescalado necesario, pero acotado por el límite del banco
Arranque de 20 s Objetivo de escalado conservador; no escalar a cero
Registro antes de responder El apagado debe esperar a que se escriba el registro
Debe sobrevivir a una zona DoNotSchedule entre zonas, minDomains: 3

El límite de 50 conexiones es la restricción dominante, y hay que calcularlo antes que nada.

Paso 1: calcular el techo real de réplicas

El banco permite 50 conexiones simultaneas desde la IP de Rutas Norte.

Si cada replica mantiene un pool de N conexiones:
  Replicas maximas = 50 / N

Con N = 5 conexiones por replica:  10 replicas maximas
Con N = 3 conexiones por replica:  16 replicas maximas
Con N = 2 conexiones por replica:  25 replicas maximas

Cuantas transacciones por segundo sostiene una replica?
  Cada transaccion tarda 2-8 s, media ~5 s.
  Con un pool de N conexiones concurrentes:
    transacciones/s = N / 5

  Con N = 5: 1 transaccion/s por replica
  Con N = 3: 0,6 transacciones/s por replica

Para 300 transacciones/s en el pico:
  Con N = 5: 300 replicas. IMPOSIBLE (excede el limite del banco por 30x).
  Con N = 3: 500 replicas. Peor.

Aquí hay un problema grave que ninguna configuración de Kubernetes resuelve:

Capacidad TEORICA MAXIMA del sistema:
  50 conexiones concurrentes / 5 segundos por transaccion = 10 transacciones/s

OBJETIVO DEL PICO: 300 transacciones/s

EL SISTEMA NO PUEDE PROCESAR MAS DE 10 TRANSACCIONES POR SEGUNDO.
El limite es el BANCO, no Kubernetes.

Esta es la lección de 09-01 sobre el cuello de botella real, en su forma más pura. Escalar pasarela-pagos a 30 réplicas no aumenta la capacidad ni un ápice: las 30 réplicas se pelearían por las mismas 50 conexiones.

Paso 2: la arquitectura que sí funciona

La solución no es de escalado, es de arquitectura:

1. NEGOCIAR con el banco un limite mayor. 50 conexiones es ridiculo para
   300 transacciones/s. Es la accion mas importante y no es tecnica.

2. Mientras tanto: DESACOPLAR con una cola.
   - api-reservas encola la peticion de cobro y responde al usuario
     "procesando su pago".
   - pasarela-pagos consume de la cola a la velocidad que el banco permite.
   - El usuario recibe la confirmacion por notificacion push o correo.

   Esto convierte una restriccion dura en una latencia aceptable, y encaja
   perfectamente con KEDA (09-04): la cola es la senal de escalado natural.

3. Un POOL COMPARTIDO de conexiones al banco, en lugar de un pool por replica.

Como el enunciado pide diseñar la alta disponibilidad, asumimos la opción 3 con un número de réplicas acotado.

Paso 3: los manifiestos

# k8s/entornos/pro/deployment-pasarela-pagos.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: pasarela-pagos
  namespace: rutas-norte-pro
  labels:
    app: pasarela-pagos
    app.kubernetes.io/part-of: rutas-norte
spec:
  # SIN campo replicas: lo gobierna el HPA/KEDA.
  selector:
    matchLabels:
      app: pasarela-pagos

  strategy:
    type: RollingUpdate
    rollingUpdate:
      # Coherente con el PDB (ver mas abajo). Nunca perder mas de 1 replica.
      maxUnavailable: 1
      # maxSurge 1, no mas: cada replica nueva abre conexiones al banco y
      # tenemos un limite duro de 50. Un maxSurge alto podria agotarlo
      # durante el despliegue.
      maxSurge: 1

  template:
    metadata:
      labels:
        app: pasarela-pagos
        app.kubernetes.io/part-of: rutas-norte
    spec:
      # PERIODO DE GRACIA DE 90 SEGUNDOS.
      #
      # Una transaccion tarda hasta 8 segundos y NO se puede interrumpir:
      # cortarla podria dejar un cobro sin reserva asociada, que es el peor
      # fallo posible en una pasarela de pagos.
      #
      # 90 s = 8 s (transaccion mas larga) + margen amplio para:
      #   - vaciar la cola interna de transacciones en curso
      #   - escribir el registro de cumplimiento de cada una
      #   - cerrar limpiamente las conexiones TLS con el banco
      #
      # El proceso DEBE manejar SIGTERM: dejar de aceptar peticiones nuevas,
      # terminar las en curso, escribir los registros, y salir.
      terminationGracePeriodSeconds: 90

      topologySpreadConstraints:
        # ZONAS: restriccion DURA. El requisito de sobrevivir a la caida de
        # una zona es explicito y no negociable.
        - maxSkew: 1
          topologyKey: topology.kubernetes.io/zone
          whenUnsatisfiable: DoNotSchedule
          minDomains: 3
          labelSelector:
            matchLabels:
              app: pasarela-pagos
          matchLabelKeys: ["pod-template-hash"]

        # NODOS: restriccion DURA tambien, y aqui SI podemos permitirnosla.
        # Con un maximo de 9 replicas y 9 nodos, exigir 1 por nodo es
        # alcanzable. Y dos replicas de la pasarela en el mismo nodo son
        # dos replicas que se pierden juntas: inaceptable en el camino
        # critico del pago.
        - maxSkew: 1
          topologyKey: kubernetes.io/hostname
          whenUnsatisfiable: DoNotSchedule
          labelSelector:
            matchLabels:
              app: pasarela-pagos
          matchLabelKeys: ["pod-template-hash"]

      containers:
        - name: pasarela
          image: registry.rutasnorte.example/pasarela-pagos:2.4.1

          # PARADA ORDENADA: el hook preStop da margen para que el Service
          # saque este pod de sus endpoints ANTES de que empiece a apagarse.
          # Sin esto, hay una ventana de 1-2 segundos en la que el pod recibe
          # peticiones nuevas mientras ya se esta apagando.
          lifecycle:
            preStop:
              exec:
                command: ["/bin/sh", "-c", "sleep 10"]

          resources:
            requests: {cpu: 200m, memory: 256Mi}
            limits: {cpu: 500m, memory: 512Mi}

          env:
            # POOL PEQUENO Y EXPLICITO: 5 conexiones por replica.
            # Con maxReplicas 9: 9 x 5 = 45 < 50. Deja margen de 5 conexiones
            # para el despliegue (maxSurge 1) y para reintentos.
            # ESTE NUMERO ES CRITICO: subirlo agota el limite del banco.
            - name: BANCO_POOL_MAX
              value: "5"

          readinessProbe:
            httpGet: {path: /preparado, port: 8080}
            initialDelaySeconds: 20        # El arranque tarda 20 s (TLS mutuo)
            periodSeconds: 5
            failureThreshold: 2
          livenessProbe:
            httpGet: {path: /salud, port: 8080}
            initialDelaySeconds: 30
            periodSeconds: 10
            failureThreshold: 3
          # STARTUP PROBE: da hasta 60 s para el arranque sin que la
          # livenessProbe mate el pod a mitad de la negociacion TLS.
          startupProbe:
            httpGet: {path: /salud, port: 8080}
            periodSeconds: 5
            failureThreshold: 12
# k8s/entornos/pro/pdb-pasarela-pagos.yaml
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: pasarela-pagos
  namespace: rutas-norte-pro
  labels:
    app: pasarela-pagos
    app.kubernetes.io/part-of: rutas-norte
spec:
  # maxUnavailable: 1 EN NUMERO ABSOLUTO, no en porcentaje.
  #
  # Es la excepcion a la regla general del apartado 3, y es deliberada:
  #
  # 1. El rango de replicas es ESTRECHO (3 a 9), no como api-reservas (4 a 30).
  #    Un porcentaje del 25 % daria: 3 replicas -> 0 desalojos (BLOQUEO), y
  #    9 replicas -> 2 desalojos. El caso de 3 replicas seria un PDB imposible.
  #
  # 2. Cada replica que se cae puede llevarse hasta 5 transacciones en vuelo,
  #    cada una con dinero real de un cliente. Queremos que los desalojos se
  #    SERIALICEN siempre, sea cual sea el numero de replicas.
  #
  # 3. Con el terminationGracePeriodSeconds de 90 s, cada desalojo tarda hasta
  #    minuto y medio. Serializarlos hace el mantenimiento lento pero seguro,
  #    que es exactamente el compromiso que queremos en una pasarela de pagos.
  maxUnavailable: 1

  selector:
    matchLabels:
      app: pasarela-pagos

  # AlwaysAllow: un pod que no pasa la readiness no esta procesando pagos
  # (el Service ya no le manda trafico), asi que desalojarlo no pone en
  # riesgo ninguna transaccion.
  unhealthyPodEvictionPolicy: AlwaysAllow
# k8s/entornos/pro/scaledobject-pasarela-pagos.yaml
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: pasarela-pagos
  namespace: rutas-norte-pro
spec:
  scaleTargetRef:
    name: pasarela-pagos

  pollingInterval: 15
  cooldownPeriod: 600

  # 3 replicas de suelo: UNA POR ZONA. Es el minimo para sobrevivir a la
  # caida de una zona conservando 2 replicas operativas. NUNCA escalar a cero:
  # el arranque de 20 s (negociacion TLS mutua con el banco) es inaceptable
  # con un usuario en la pantalla de pago.
  minReplicaCount: 3

  # 9 REPLICAS MAXIMO. ESTE NUMERO NO SALE DEL TRAFICO, SALE DEL BANCO:
  #   9 replicas x 5 conexiones = 45 conexiones < 50 del limite.
  #   Margen de 5 conexiones para el despliegue (maxSurge 1) y reintentos.
  #
  # ADVERTENCIA CRITICA: subir este numero SIN renegociar el limite con el
  # banco NO aumenta la capacidad. Solo hace que las replicas compitan por
  # las mismas 50 conexiones y empiecen a recibir errores de conexion
  # rechazada. El cuello de botella es el BANCO (ver 09-01).
  maxReplicaCount: 9

  fallback:
    failureThreshold: 3
    replicas: 6

  advanced:
    horizontalPodAutoscalerConfig:
      behavior:
        scaleUp:
          # 30 s de estabilizacion (no 0): cada replica nueva tarda 20 s en
          # estar lista y abre 5 conexiones al banco. No queremos crear
          # replicas a lo loco y agotar el limite de conexiones.
          stabilizationWindowSeconds: 30
          selectPolicy: Max
          policies:
            - type: Pods
              value: 2
              periodSeconds: 60
        scaleDown:
          # 15 minutos. Cada replica que se apaga tarda hasta 90 s en terminar
          # sus transacciones. Bajar despacio es esencial.
          stabilizationWindowSeconds: 900
          selectPolicy: Min
          policies:
            - type: Pods
              value: 1
              periodSeconds: 300

  triggers:
    # Escalado por CONEXIONES ACTIVAS AL BANCO, no por CPU ni por peticiones.
    # Es la metrica que refleja el recurso escaso real.
    - type: prometheus
      metadata:
        serverAddress: http://prometheus-operated.monitorizacion.svc.cluster.local:9090
        metricName: pasarela_conexiones_banco_activas
        query: |
          sum(pasarela_pagos_conexiones_banco_activas{namespace="rutas-norte-pro"})
        # Objetivo: 3,5 conexiones activas por replica (de un pool de 5).
        # Al 70 % de ocupacion del pool, escalamos.
        threshold: "3.5"
        activationThreshold: "1"
      authenticationRef:
        kind: ClusterTriggerAuthentication
        name: prometheus-lectura

    # Red de seguridad: transacciones encoladas esperando una conexion libre.
    - type: prometheus
      metadata:
        serverAddress: http://prometheus-operated.monitorizacion.svc.cluster.local:9090
        metricName: pasarela_transacciones_en_espera
        query: |
          sum(pasarela_pagos_transacciones_en_espera{namespace="rutas-norte-pro"})
        threshold: "3"
        activationThreshold: "1"
      authenticationRef:
        kind: ClusterTriggerAuthentication
        name: prometheus-lectura

Resumen de decisiones y justificaciones:

Decisión Elección Justificación
minReplicaCount 3 Una por zona; sobrevive a la caída de una zona con 2 operativas
maxReplicaCount 9 9 × 5 = 45 < 50 conexiones del banco. No sale del tráfico
PDB maxUnavailable: 1 absoluto Rango estrecho (3-9); un 25 % daría 0 desalojos con 3 réplicas
topologySpread zona DoNotSchedule + minDomains: 3 Requisito explícito de sobrevivir a una zona
topologySpread nodo DoNotSchedule, maxSkew: 1 Con 9 réplicas y 9 nodos es alcanzable; dos réplicas por nodo es riesgo innecesario
terminationGracePeriodSeconds 90 s 8 s de transacción + registro + cierre TLS, con margen amplio
preStop sleep 10 Que el Service saque el pod antes de que empiece a apagarse
startupProbe 60 s de margen El TLS mutuo tarda 20 s; evita que la liveness mate el arranque
Métrica de escalado Conexiones activas al banco Es el recurso escaso real, no la CPU ni las peticiones
maxSurge 1 Cada réplica extra consume conexiones del límite del banco
Escalado a cero No 20 s de arranque con un usuario en la pantalla de pago

La conclusión que hay que llevarse del ejercicio, y que va más allá de la configuración:

La alta disponibilidad de pasarela-pagos está limitada por el banco, no por Kubernetes. Ninguna cantidad de PDB, topologySpreadConstraints o autoescalado permite procesar más de 10 transacciones por segundo con 50 conexiones y 5 segundos por transacción.

Con 300 transacciones por segundo en el pico del puente de mayo, el sistema se saturará irremediablemente. Las acciones que de verdad resuelven el problema son:

  1. Renegociar el límite con el banco. No es técnica, es comercial, y es la más importante.
  2. Desacoplar con una cola, convirtiendo una restricción dura en una latencia aceptable comunicada al usuario.
  3. Un pool compartido en lugar de un pool por réplica, con un componente intermedio que multiplexe.
  4. Un límite de tasa explícito en api-reservas que rechace educadamente las peticiones de pago cuando la pasarela está saturada, en lugar de dejar que se acumulen hasta el timeout.

Diseñar la alta disponibilidad de un componente sin entender su cuello de botella real es exactamente el error que 09-01 advertía: escalar la capa web cuando el límite está en otra parte multiplica el coste sin vender un solo billete más.

Conclusión

La alta disponibilidad no es un objeto de Kubernetes que se activa: es una propiedad del sistema que se construye entendiendo qué puede fallar y qué protección existe para cada cosa.

Lo esencial:

  • Kubernetes distingue interrupciones voluntarias e involuntarias, y solo puede protegerte de las primeras. Un kubectl drain pasa por la API y puede negociarse; un servidor que se apaga, no.
  • El PodDisruptionBudget declara cuánto servicio debe permanecer disponible ante los desalojos. Lo respetan kubectl drain, el Cluster Autoscaler, el actualizador del VPA y el descheduler; no lo respetan kubectl delete pod ni las actualizaciones progresivas del Deployment.
  • minAvailable habla de lo que queda; maxUnavailable de lo que se va. Los porcentajes redondean en direcciones opuestas, siempre favoreciendo la disponibilidad. Con HPA o KEDA, siempre porcentajes: un número absoluto se vuelve imposible en el valle y bloquea todo el mantenimiento.
  • El PDB imposible (minAvailable igual a las réplicas, o una sola réplica) ancla nodos, bloquea drenajes y le impide al Cluster Autoscaler reducir. A veces es deliberado (postgres-reservas); documéntalo con una anotación para que nadie lo «arregle».
  • unhealthyPodEvictionPolicy: AlwaysAllow rompe el atasco circular de un despliegue defectuoso cuyos pods rotos impiden su propia reparación. Úsalo en todo lo que no tenga quórum.
  • topologySpreadConstraints reparte equitativamente entre dominios de fallo. Frente a la podAntiAffinity de 06-05, gana claramente en cargas con autoescalado: la antiafinidad requerida limita las réplicas al número de nodos, y con 30 réplicas y 9 nodos deja 21 pods Pending para siempre.
  • La asimetría entre niveles es la decisión clave: DoNotSchedule entre zonas (la caída de una zona es catastrófica) y ScheduleAnyway entre nodos (bloquear el escalado sería peor). Y matchLabelKeys: ["pod-template-hash"] siempre, para que los despliegues no se atasquen.
  • La alta disponibilidad de las demás capas importa igual: tres o cinco miembros de etcd (nunca par) repartidos entre zonas, varias réplicas del controlador de Ingress y de CoreDNS con sus PDB, y para postgres-reservas, un operador que sepa promocionar una réplica: eso Kubernetes no lo hace y no puede hacerlo.
  • El procedimiento de mantenimiento es cordondrain → actualizar → uncordon, con verificación de PDB antes y de servicio entre cada nodo. Sin PDB, un drenaje se lleva todas las réplicas de un nodo en un instante; con PDB, las serializa y el usuario no nota nada.
  • La prueba de caos revela lo que la teoría esconde: un drenaje produce cero errores; un nodo que muere produce unos 40 segundos de errores mientras el plano de control se entera. Esa diferencia, medida con cronómetro, es la definición práctica de voluntario frente a involuntario.

Rutas Norte tiene ahora la plataforma repartida entre tres zonas con garantías duras, PDB coherentes con el autoescalado, y un procedimiento de mantenimiento que no interrumpe la venta. Las réplicas crecen cuando hace falta, tienen el tamaño correcto, los nodos aparecen cuando no caben, el escalado se anticipa a los eventos conocidos, y nada de eso se rompe cuando alguien actualiza un nodo o cuando se cae un servidor.

Y sin embargo, queda una pregunta que no hemos hecho en todo el módulo: ¿es que hace falta tanto?

Hemos dado por buenos los números que veníamos arrastrando: que una réplica de api-reservas sostiene 85 peticiones por segundo, que hace falta escalar a 30 en el pico, que el requests.cpu debe ser 412m. Pero nadie ha preguntado por qué una réplica solo aguanta 85 peticiones por segundo, ni si podría aguantar 200 con los mismos recursos. Nadie ha mirado si el pool de conexiones a PostgreSQL está bien dimensionado, si las consultas tienen los índices que necesitan, si redis-cache se está usando de verdad, o si el estrangulamiento de CPU que vimos en el módulo 3 aparece mucho antes de lo que creíamos.

Escalar es multiplicar la ineficiencia por el número de réplicas. Si cada réplica desperdicia la mitad de su capacidad, treinta réplicas desperdician quince.

En la próxima lección, Ajuste de Rendimiento, cerramos el módulo mirando hacia dentro: la metodología de medir, encontrar el cuello de botella real y cambiar una sola cosa cada vez; pruebas de carga con k6 simulando el puente de mayo y cómo leer sus resultados de verdad; el ajuste de la capa de aplicación, que es donde casi siempre está el problema; cómo se traduce un limit de CPU en cuota del kernel y por qué el estrangulamiento aparece mucho antes del 100 %; el arranque rápido como requisito del autoescalado; el ajuste del clúster, incluido el efecto del ndots: 5 que dejamos pendiente en 04-03; y un presupuesto de latencia que convierte el objetivo de negocio en objetivos por componente.

Curso de Kubernetes

Módulo 1: Introducción a Kubernetes

Módulo 2: Componentes Principales de Kubernetes

Módulo 3: Gestión de Configuración y Secretos

Módulo 4: Redes en Kubernetes

Módulo 5: Almacenamiento en Kubernetes

Módulo 6: Conceptos Avanzados de Kubernetes

Módulo 7: Monitoreo y Registro

Módulo 8: Seguridad en Kubernetes

Módulo 9: Escalado y Rendimiento

Módulo 10: Ecosistema y Herramientas de Kubernetes

Módulo 11: Estudios de Caso y Aplicaciones del Mundo Real

Módulo 12: Preparación para la Certificación de Kubernetes

© Copyright 2026. Todos los derechos reservados