Hasta ahora hemos decidido qué se ejecuta y cómo se compone cada pod, pero nunca dónde. El kube-scheduler ha ido colocando nuestros pods en los nodos que le han parecido y no nos hemos quejado, porque en un clúster homogéneo de pruebas cualquier sitio vale.

En producción no vale cualquier sitio. postgres-reservas necesita el nodo con disco SSD, porque en un disco mecánico las consultas de ocupación tardan cinco veces más. Las seis réplicas de api-reservas no deberían estar todas en el mismo nodo: si ese nodo se reinicia, la API entera desaparece justo cuando alguien está pagando un billete. Y las cargas de análisis pesadas no deberían competir por CPU con la venta de billetes en un puente festivo.

Todo eso se expresa con tres mecanismos que estudiaremos aquí: afinidad (el pod elige nodo), antiafinidad (el pod evita compañía) y taints con tolerations (el nodo rechaza pods). Al final tendremos el plan de planificación completo de Rutas Norte y sabremos diagnosticar el pod que se queda Pending para siempre.

Contenido

  1. Cómo decide el kube-scheduler: filtrado y puntuación
  2. nodeName y nodeSelector: los mecanismos básicos
  3. Afinidad de nodo: required frente a preferred
  4. Afinidad y antiafinidad entre pods: topologyKey
  5. Taints y tolerations: el nodo rechaza
  6. Taints automáticos de Kubernetes
  7. PriorityClass y preemption
  8. topologySpreadConstraints: mención y remisión
  9. El plan de planificación de Rutas Norte
  10. Diagnóstico del pod eternamente Pending

  1. Cómo decide el kube-scheduler: filtrado y puntuación

En la lección 01-02 dijimos que el scheduler "asigna pods a nodos". Ahora vemos cómo.

Cuando un pod se crea, su campo spec.nodeName está vacío. El scheduler observa esos pods sin asignar y, para cada uno, ejecuta un ciclo de dos fases.

graph LR
  P[Pod sin nodeName] --> F[Fase 1: FILTRADO<br/>¿qué nodos son viables?]
  F -->|8 nodos → 3 viables| S[Fase 2: PUNTUACIÓN<br/>¿cuál es el mejor?]
  S -->|nodo-5: 78 puntos| B[Binding:<br/>escribir nodeName]
  F -->|0 viables| PE[Pod en Pending<br/>+ evento FailedScheduling]

Fase 1: filtrado

Se descartan los nodos que no pueden alojar el pod. Cada comprobación es un veto absoluto: basta una para eliminar al nodo.

Filtro Qué comprueba
NodeResourcesFit ¿Quedan CPU y memoria libres para las requests del pod?
NodeAffinity ¿Cumple el nodo la afinidad required del pod?
NodeName Si el pod fijó nodeName, ¿es este ese nodo?
TaintToleration ¿Tolera el pod los taints NoSchedule del nodo?
NodePorts Si el pod pide un hostPort, ¿está libre en el nodo?
VolumeBinding ¿Puede el nodo acceder a los volúmenes del pod (zona, topología)?
PodTopologySpread ¿Respeta las restricciones de distribución topológica?
InterPodAffinity ¿Cumple la afinidad y antiafinidad required entre pods?

Un detalle que conecta con el módulo 5: el filtro VolumeBinding es el que hace funcionar volumeBindingMode: WaitForFirstConsumer. Un PVC ya vinculado a un volumen de la zona eu-west-1a elimina del filtrado todos los nodos de las demás zonas.

Y otro que conecta con el módulo 3: NodeResourcesFit mira requests, no el uso real. Un nodo con 8 CPU donde los pods han reservado 7,8 CPU pero solo usan 0,5 se considera lleno. Por eso unos requests inflados desperdician clúster.

Fase 2: puntuación

De los nodos viables, cada complemento de puntuación otorga entre 0 y 100 puntos, y se hace una media ponderada.

Complemento Premia
NodeResourcesBalancedAllocation Nodos donde el uso de CPU y memoria quedaría equilibrado
NodeResourcesFit (LeastAllocated) Nodos con más recursos libres: reparte la carga
ImageLocality Nodos que ya tienen descargada la imagen: arranque más rápido
InterPodAffinity Nodos que satisfacen la afinidad preferred entre pods
NodeAffinity Nodos que satisfacen la afinidad preferred de nodo, con su weight
TaintToleration Nodos sin taints PreferNoSchedule

Gana el de mayor puntuación; si empatan, se elige al azar entre los empatados. El scheduler escribe entonces el nodeName y el kubelet de ese nodo recoge el pod.

Con esto ya se entiende la diferencia esencial entre los dos tipos de regla que veremos: las required actúan en el filtrado (si no se cumplen, el pod no va), y las preferred actúan en la puntuación (si no se cumplen, el pod va igual pero a un nodo peor puntuado).

  1. nodeName y nodeSelector: los mecanismos básicos

nodeName: la fuerza bruta

spec:
  nodeName: rutas-norte-m02

Salta el scheduler por completo: el kubelet de ese nodo ve un pod con su nombre y lo arranca sin más.

Nunca lo uses en producción. Si el nodo no existe, está lleno o está caído, el pod se queda colgado sin ningún evento útil; si el nodo desaparece, no hay recolocación. Su único uso legítimo es la depuración puntual: forzar un pod a un nodo concreto para investigar un problema local.

nodeSelector: el filtro simple

spec:
  nodeSelector:
    disco: ssd
    entorno-nodo: produccion

El pod solo se programa en nodos que tengan todas esas etiquetas con esos valores exactos. Es una conjunción de igualdades, sin más.

Las etiquetas se ponen sobre los nodos como sobre cualquier objeto:

kubectl label node rutas-norte-m02 disco=ssd
kubectl label node rutas-norte-m03 disco=hdd
kubectl get nodes --show-labels | head -2

Kubernetes añade además unas cuantas etiquetas estándar que conviene conocer:

Etiqueta Contenido
kubernetes.io/hostname Nombre del nodo
kubernetes.io/os linux, windows
kubernetes.io/arch amd64, arm64
topology.kubernetes.io/zone Zona de disponibilidad
topology.kubernetes.io/region Región
node.kubernetes.io/instance-type Tipo de instancia del proveedor

Los límites de nodeSelector son tres, y motivan todo lo que viene después:

  1. Solo igualdad exacta. No se puede decir "SSD o NVMe", ni "cualquier nodo que no sea de analítica".
  2. Es obligatorio. Si ningún nodo casa, el pod se queda Pending para siempre. No hay forma de expresar una preferencia.
  3. No dice nada sobre otros pods. No permite "no me pongas con mis hermanos".

  1. Afinidad de nodo: required frente a preferred

La afinidad de nodo resuelve los dos primeros límites: sintaxis expresiva y la posibilidad de preferir en lugar de exigir.

spec:
  affinity:
    nodeAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
        nodeSelectorTerms:
          - matchExpressions:
              - key: disco
                operator: In
                values: ["ssd", "nvme"]
      preferredDuringSchedulingIgnoredDuringExecution:
        - weight: 80
          preference:
            matchExpressions:
              - key: topology.kubernetes.io/zone
                operator: In
                values: ["eu-west-1a"]
        - weight: 20
          preference:
            matchExpressions:
              - key: gama
                operator: In
                values: ["alta"]

Los nombres largos, descifrados

Se leen en dos mitades:

  • requiredDuringScheduling / preferredDuringScheduling: si la regla es obligatoria (filtrado) o deseable (puntuación).
  • IgnoredDuringExecution: qué pasa si la etiqueta del nodo cambia mientras el pod ya está corriendo. La respuesta es: nada. El pod sigue donde está.

Ese IgnoredDuringExecution es más importante de lo que parece. Si programas postgres-reservas en un nodo con disco=ssd y alguien cambia esa etiqueta a disco=hdd, el pod no se mueve. La afinidad se evalúa en el momento de programar y nunca más. Existía un RequiredDuringExecution planeado que desalojaría pods al dejar de cumplirse la regla, pero nunca se implementó. Para desalojo por condiciones del nodo, el mecanismo es el efecto NoExecute de los taints (apartado 5).

Estructura y operadores

  • nodeSelectorTerms es una lista con semántica OR: basta con que se cumpla un término.
  • matchExpressions dentro de un término es una lista con semántica AND: deben cumplirse todas.
      requiredDuringSchedulingIgnoredDuringExecution:
        nodeSelectorTerms:
          # Término A: nodo con SSD Y con al menos 16 núcleos
          - matchExpressions:
              - key: disco
                operator: In
                values: ["ssd"]
              - key: nucleos
                operator: Gt
                values: ["15"]
          # ...O BIEN Término B: cualquier nodo NVMe
          - matchExpressions:
              - key: disco
                operator: In
                values: ["nvme"]

Operadores disponibles:

Operador Significado Ejemplo
In El valor está en la lista disco In [ssd, nvme]
NotIn El valor no está en la lista gama NotIn [economica]
Exists La etiqueta existe, sin importar el valor disco Exists
DoesNotExist La etiqueta no existe node-role.kubernetes.io/analitica DoesNotExist
Gt Mayor que (valor entero, en values con un solo elemento) nucleos Gt 15
Lt Menor que carga-media Lt 50

NotIn y DoesNotExist dan la antiafinidad de nodo: no hay un objeto separado para ello, se expresa negando.

Detalle sobre Gt y Lt: comparan como enteros, la lista values debe tener exactamente un elemento, y el valor de la etiqueta del nodo debe parsear como entero. nucleos Gt "15" significa estrictamente mayor que 15, es decir, 16 o más.

Los pesos de preferred

      preferredDuringSchedulingIgnoredDuringExecution:
        - weight: 80        # de 1 a 100
          preference:
            matchExpressions: [...]

Cada regla preferred que se cumpla suma su weight a la puntuación del nodo. Con dos reglas de peso 80 y 20, un nodo que cumpla ambas puntúa 100 en este complemento; uno que solo cumpla la primera, 80. Los pesos son relativos entre sí: lo que importa es la proporción, no el valor absoluto.

Combinación típica y muy recomendable: required para lo que es un requisito real (arquitectura de CPU, presencia de un disco local) y preferred para lo que es una optimización (zona concreta, gama de máquina). Poner en required lo que en realidad era una preferencia es la primera causa de pods Pending innecesarios.

  1. Afinidad y antiafinidad entre pods: topologyKey

La afinidad de nodo mira etiquetas del nodo. La afinidad entre pods mira qué otros pods hay ya en ese nodo o en esa zona. Es lo que permite decir "quiero estar cerca de la caché" o "no quiero estar con mis hermanas".

spec:
  affinity:
    podAntiAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
        - labelSelector:
            matchLabels:
              app: api-reservas
              entorno: pro
          topologyKey: kubernetes.io/hostname

Léase: "no me coloques en un nodo (kubernetes.io/hostname) donde ya haya un pod con app=api-reservas y entorno=pro".

topologyKey: la pieza central

topologyKey es una etiqueta del nodo que define el ámbito en el que se aplica la regla. El scheduler agrupa los nodos por el valor de esa etiqueta, y cada grupo es un "dominio topológico".

topologyKey Dominio Efecto de una antiafinidad
kubernetes.io/hostname Un nodo Como mucho un pod del grupo por nodo
topology.kubernetes.io/zone Una zona de disponibilidad Como mucho uno por zona
topology.kubernetes.io/region Una región Como mucho uno por región
Etiqueta propia, p. ej. bastidor Un bastidor físico Como mucho uno por bastidor

Elegir bien el topologyKey es elegir contra qué fallo te proteges:

  • hostname protege contra el reinicio o la caída de un nodo.
  • zone protege contra la caída de un centro de datos entero.
  • region protege contra una catástrofe regional, a costa de latencia entre réplicas.

Cuidado con required y hostname: si exiges como mucho una réplica por nodo y tienes 3 nodos, no podrás pasar de 3 réplicas. La cuarta se quedará Pending indefinidamente. Este error aparece a menudo combinado con un autoescalado que intenta subir a 10.

La solución habitual es usar preferred, que reparte pero no bloquea:

    podAntiAffinity:
      preferredDuringSchedulingIgnoredDuringExecution:
        - weight: 100
          podAffinityTerm:
            labelSelector:
              matchLabels:
                app: api-reservas
                entorno: pro
            topologyKey: kubernetes.io/hostname

Con weight: 100, el scheduler penaliza mucho los nodos que ya alojan una réplica, así que reparte mientras pueda; cuando no queda alternativa, coloca dos juntas en vez de dejar el pod Pending. Para api-reservas, cuya disponibilidad importa más que la perfección del reparto, es la elección correcta.

Nótese el cambio de sintaxis entre las dos variantes: en required la lista contiene directamente los términos; en preferred cada elemento es un objeto con weight y podAffinityTerm.

podAffinity: atraer en lugar de repeler

El caso simétrico: worker-notificaciones consulta mucho redis-cache y le beneficia estar en el mismo nodo, donde el tráfico no cruza la red física.

    podAffinity:
      preferredDuringSchedulingIgnoredDuringExecution:
        - weight: 50
          podAffinityTerm:
            labelSelector:
              matchLabels:
                app: redis-cache
                entorno: pro
            topologyKey: kubernetes.io/hostname

Usa podAffinity con moderación. Concentrar pods relacionados en el mismo nodo mejora la latencia y empeora la disponibilidad: ese nodo se convierte en un punto único de fallo para dos componentes a la vez. En Rutas Norte lo dejamos en preferred con peso medio y no lo aplicamos a nada crítico.

El coste de cómputo

Esta es una advertencia práctica que la documentación oficial subraya. Para evaluar afinidad entre pods, el scheduler debe, para cada nodo candidato, examinar los pods de todos los nodos de su dominio topológico. La complejidad crece con el producto de nodos por pods, no linealmente.

En un clúster de 10 nodos es imperceptible. En uno de varios cientos, con reglas de afinidad entre pods en muchas cargas de trabajo, el tiempo de decisión por pod puede pasar de milisegundos a segundos, y en un despliegue masivo eso se nota. La documentación desaconseja expresamente estas reglas en clústeres de más de varios cientos de nodos.

Mitigaciones:

  • Usa topologyKey: kubernetes.io/hostname (dominios pequeños) antes que zone (dominios grandes).
  • Acota el ámbito con namespaceSelector para no evaluar pods de otros namespaces.
  • Para el caso concreto de "repartir réplicas de forma equilibrada", topologySpreadConstraints es más eficiente; lo vemos en el apartado 8.

  1. Taints y tolerations: el nodo rechaza

Afinidad y antiafinidad son mecanismos del pod: el pod expresa dónde quiere ir. Los taints son el mecanismo inverso, del nodo: el nodo declara que rechaza pods salvo que estén expresamente autorizados.

La diferencia importa porque resuelve un problema que la afinidad no puede resolver: reservar un grupo de nodos. Con afinidad, para que solo las cargas de análisis vayan a los nodos de análisis, habría que añadir una regla "no vayas ahí" a todos los demás pods del clúster, incluidos los que despliegue alguien mañana. Con un taint, el nodo se protege solo.

# Poner un taint
kubectl taint nodes rutas-norte-m04 dedicado=analitica:NoSchedule

# Quitarlo (nótese el guion final)
kubectl taint nodes rutas-norte-m04 dedicado=analitica:NoSchedule-

# Consultar
kubectl describe node rutas-norte-m04 | grep -A3 Taints

Un taint tiene la forma clave=valor:efecto.

Los tres efectos

Efecto Al programar A los pods ya en el nodo
NoSchedule Rechaza los pods que no lo toleran No los toca
PreferNoSchedule Los evita si puede, pero los acepta si no hay alternativa No los toca
NoExecute Rechaza los pods que no lo toleran Los desaloja

NoExecute es el único de los tres que actúa sobre pods en ejecución. Es el mecanismo que sí hace lo que RequiredDuringExecution habría hecho en la afinidad.

La sintaxis de una toleration

spec:
  tolerations:
    # Forma 1: con operator Equal (por defecto): clave, valor y efecto exactos
    - key: dedicado
      operator: Equal
      value: analitica
      effect: NoSchedule

    # Forma 2: con operator Exists: la clave existe, el valor da igual
    - key: dedicado
      operator: Exists
      effect: NoSchedule

    # Forma 3: tolerar TODO (usar con mucho cuidado)
    - operator: Exists

Reglas de la sintaxis:

  • operator: Equal (el valor por defecto) exige que coincidan key, value y effect.
  • operator: Exists exige que coincidan key y effect; no debe llevar value.
  • Omitir effect significa "cualquier efecto para esa clave".
  • Omitir key con operator: Exists significa "todos los taints". Es lo que usan los DaemonSets de CNI que deben correr pase lo que pase (06-02).

tolerationSeconds

Solo tiene sentido con NoExecute, y expresa cuánto tiempo aguanta el pod en el nodo antes de ser desalojado:

    - key: node.kubernetes.io/unreachable
      operator: Exists
      effect: NoExecute
      tolerationSeconds: 300

"Si el nodo se vuelve inalcanzable, quédate 5 minutos por si vuelve; si no, desalójame." Es exactamente lo que Kubernetes añade por defecto a todos los pods, como veremos ahora.

La advertencia fundamental

Una toleration no atrae al pod: solo evita que lo rechacen.

Un pod con la toleration de dedicado=analitica puede ir a los nodos de analítica, pero también a cualquier otro nodo del clúster, y probablemente acabe en otro. Para reservar nodos de verdad hacen falta las dos piezas:

    spec:
      # 1. El taint del nodo impide que entren los demás
      tolerations:
        - key: dedicado
          operator: Equal
          value: analitica
          effect: NoSchedule
      # 2. La afinidad hace que este pod sí vaya ahí
      affinity:
        nodeAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
            nodeSelectorTerms:
              - matchExpressions:
                  - key: dedicado
                    operator: In
                    values: ["analitica"]

Taint + toleration = exclusividad. Taint + toleration + afinidad = dedicación.

  1. Taints automáticos de Kubernetes

Kubernetes pone taints por su cuenta cuando un nodo tiene problemas. Conocerlos explica muchos comportamientos que de otro modo parecen mágicos.

Taint Efecto Cuándo lo pone el sistema
node.kubernetes.io/not-ready NoExecute El nodo no está Ready
node.kubernetes.io/unreachable NoExecute El controlador de nodos perdió contacto con el kubelet
node.kubernetes.io/memory-pressure NoSchedule Al nodo le queda poca memoria
node.kubernetes.io/disk-pressure NoSchedule Al nodo le queda poco disco
node.kubernetes.io/pid-pressure NoSchedule Al nodo le quedan pocos PID
node.kubernetes.io/network-unavailable NoSchedule La red del nodo no está configurada
node.kubernetes.io/unschedulable NoSchedule Alguien ejecutó kubectl cordon
node.kubernetes.io/out-of-service NoExecute Un administrador lo marca como fuera de servicio

Y el más conocido, que no es automático sino que lo pone kubeadm al crear el clúster:

node-role.kubernetes.io/control-plane:NoSchedule

Es el que obligó al DaemonSet de logs de 06-02 a llevar una toleration explícita.

El desalojo por nodo caído

Aquí está el mecanismo real detrás de "si un nodo cae, los pods se recrean en otro sitio":

  1. El kubelet deja de enviar latidos.
  2. Pasados 40 segundos, el controlador de nodos marca el nodo NotReady y le añade el taint node.kubernetes.io/unreachable:NoExecute.
  3. Los pods del nodo no se desalojan de inmediato, porque Kubernetes les añadió automáticamente esta toleration al crearlos:
  tolerations:
    - key: node.kubernetes.io/not-ready
      operator: Exists
      effect: NoExecute
      tolerationSeconds: 300
    - key: node.kubernetes.io/unreachable
      operator: Exists
      effect: NoExecute
      tolerationSeconds: 300
  1. A los 300 segundos (5 minutos) se agota la tolerancia y los pods se marcan para borrado. Sus controladores crean sustitutos en otros nodos.

Eso explica el retardo de unos cinco minutos entre que un nodo se cae y que sus pods reaparecen en otro sitio. Se puede ajustar por pod:

      # api-reservas es sensible a la latencia de recuperación: 30 s en vez de 300
      tolerations:
        - key: node.kubernetes.io/unreachable
          operator: Exists
          effect: NoExecute
          tolerationSeconds: 30
        - key: node.kubernetes.io/not-ready
          operator: Exists
          effect: NoExecute
          tolerationSeconds: 30

Bajarlo mucho tiene su riesgo: un corte de red transitorio de 40 segundos provocaría un desalojo masivo y una recreación innecesaria. Para postgres-reservas conviene lo contrario: un valor alto, porque recrear la base de datos en otro nodo implica desmontar y volver a montar un volumen, y es mejor esperar a que el nodo vuelva.

Un aviso: los pods de DaemonSet no reciben estas tolerations automáticas con tolerationSeconds; en su lugar toleran esos taints indefinidamente, para que los agentes sigan funcionando en un nodo con problemas.

  1. PriorityClass y preemption

Cuando el clúster está lleno, ¿qué pasa si llega un pod importante? Sin prioridades, se queda Pending como cualquier otro. Con prioridades, puede desalojar a pods menos importantes para hacerse sitio.

apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
  name: rutasnorte-critica
value: 100000
globalDefault: false
preemptionPolicy: PreemptLowerPriority
description: "Componentes sin los que Rutas Norte no puede vender billetes"
---
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
  name: rutasnorte-normal
value: 10000
globalDefault: true
description: "Prioridad por defecto de las cargas de Rutas Norte"
---
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
  name: rutasnorte-lote
value: 100
globalDefault: false
preemptionPolicy: Never
description: "Trabajos por lotes que pueden esperar"

Uso en un pod:

spec:
  template:
    spec:
      priorityClassName: rutasnorte-critica

Las PriorityClass son objetos de clúster, no de namespace. Kubernetes trae dos predefinidas para componentes del sistema, system-cluster-critical (2000000000) y system-node-critical (2000001000); esta última es la que usamos en el DaemonSet de logs de 06-02.

Qué hace la prioridad

Actúa en dos momentos:

  1. En la cola del scheduler: los pods pendientes se ordenan por prioridad descendente. Un pod crítico se evalúa antes que uno de lote, aunque llevara menos tiempo esperando.
  2. En la preemption: si un pod de alta prioridad no cabe en ningún nodo, el scheduler busca un nodo donde, desalojando pods de menor prioridad, sí cabría. Si lo encuentra, marca esos pods para borrado (con su SIGTERM y su periodo de gracia de 02-01) y programa el pod importante en su lugar.

preemptionPolicy: Never hace que un pod se beneficie de la prioridad en la cola pero nunca desaloje a nadie. Es lo correcto para trabajos por lotes: que se programen pronto si hay hueco, pero que no tiren nada abajo.

El peligro de abusar de las prioridades

Es un mecanismo que se degrada solo si se usa mal:

  • La inflación de prioridades. Cada equipo pone su carga en "crítica". Al final todo es crítico y la prioridad deja de discriminar. Define pocas clases (tres bastan) y documenta qué justifica cada una.
  • La preemption en cascada. El pod A desaloja al B; el controlador de B lo recrea en otro nodo; ahí desaloja al C… Un clúster al límite con prioridades mal repartidas puede pasar minutos moviendo pods sin estabilizarse.
  • Los desalojos no respetan PodDisruptionBudget de forma absoluta. El scheduler intenta respetarlos, pero si no encuentra otra opción, desaloja igualmente. Los PDB son tema de 09-05.
  • Los pods con estado sufren mucho. Desalojar postgres-reservas implica cerrar conexiones, desmontar el volumen y volver a montarlo en otro nodo. Ponle prioridad alta y no le pongas preemptionPolicy: PreemptLowerPriority esperando que se recoloque solo: lo que quieres es que no lo toquen.

Regla práctica: usa prioridades para proteger lo importante de ser desalojado, no para conseguir que lo importante desaloje agresivamente.

  1. topologySpreadConstraints: mención y remisión

Existe un tercer mecanismo, más moderno y más eficiente que la antiafinidad para el caso concreto de repartir réplicas de forma equilibrada:

spec:
  topologySpreadConstraints:
    - maxSkew: 1
      topologyKey: topology.kubernetes.io/zone
      whenUnsatisfiable: ScheduleAnyway
      labelSelector:
        matchLabels:
          app: api-reservas
          entorno: pro

En una frase: "la diferencia entre la zona con más réplicas de api-reservas y la que menos no debe pasar de 1; si no se puede cumplir, prográmalo igual".

Ventajas frente a podAntiAffinity:

podAntiAffinity topologySpreadConstraints
Expresa "ninguno junto a otro" "repartidos con desviación máxima N"
Granularidad Binaria Numérica, mediante maxSkew
Comportamiento si no cabe Pending (con required) ScheduleAnyway o DoNotSchedule, a elegir
Coste de cómputo Alto en clústeres grandes Menor

Su aplicación completa a la alta disponibilidad —cómo combinarlo con PodDisruptionBudgets para sobrevivir a la pérdida de una zona— es contenido de la lección 09-05. Aquí basta con saber que existe y que, para el caso "reparte mis réplicas", suele ser mejor herramienta que la antiafinidad.

  1. El plan de planificación de Rutas Norte

Juntamos todo en un plan coherente para rutas-norte-pro.

Etiquetado y taints de los nodos

# Nodos con SSD para la base de datos
kubectl label node rutas-norte-m02 disco=ssd gama=alta
kubectl label node rutas-norte-m03 disco=ssd gama=alta

# Nodos estándar para la web y la API
kubectl label node rutas-norte-m04 disco=hdd gama=estandar
kubectl label node rutas-norte-m05 disco=hdd gama=estandar

# Nodo dedicado a cargas de análisis, protegido con taint
kubectl label node rutas-norte-m06 dedicado=analitica
kubectl taint node rutas-norte-m06 dedicado=analitica:NoSchedule
graph TB
  subgraph SSD[Nodos disco=ssd]
    M2[m02] --> PG[postgres-reservas-0]
    M3[m03]
  end
  subgraph EST[Nodos gama=estandar]
    M4[m04] --> A1[api-reservas]
    M4 --> T1[tienda-web]
    M5[m05] --> A2[api-reservas]
    M5 --> T2[tienda-web]
  end
  subgraph ANA[Nodo dedicado=analitica<br/>taint NoSchedule]
    M6[m06] --> IO[informes-ocupacion]
  end
  DS[DaemonSet recolector-logs<br/>tolera todo] -.-> M2
  DS -.-> M4
  DS -.-> M6

postgres-reservas: fijado a nodos SSD

# k8s/entornos/pro/postgres-reservas-planificacion.yaml (fragmento del StatefulSet)
spec:
  template:
    spec:
      priorityClassName: rutasnorte-critica
      affinity:
        nodeAffinity:
          # Requisito real: sin SSD las consultas de ocupación no cumplen su SLA
          requiredDuringSchedulingIgnoredDuringExecution:
            nodeSelectorTerms:
              - matchExpressions:
                  - key: disco
                    operator: In
                    values: ["ssd", "nvme"]
          # Preferencia: mejor en la zona donde está el resto de la plataforma
          preferredDuringSchedulingIgnoredDuringExecution:
            - weight: 60
              preference:
                matchExpressions:
                  - key: topology.kubernetes.io/zone
                    operator: In
                    values: ["eu-west-1a"]
        # Nunca dos réplicas de PostgreSQL en el mismo nodo
        podAntiAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
            - labelSelector:
                matchLabels:
                  app: postgres-reservas
                  entorno: pro
              topologyKey: kubernetes.io/hostname
      # Ante un nodo inalcanzable, esperar más de lo normal antes de desalojar:
      # remontar el volumen en otro nodo es caro
      tolerations:
        - key: node.kubernetes.io/unreachable
          operator: Exists
          effect: NoExecute
          tolerationSeconds: 600

Aquí required en la antiafinidad es correcto: dos instancias de PostgreSQL en el mismo nodo no aportan nada y sí introducen contención de disco. Y como el StatefulSet tiene una réplica, no hay riesgo de bloqueo.

api-reservas y tienda-web: reparto entre nodos

# k8s/entornos/pro/api-reservas-planificacion.yaml (fragmento del Deployment)
spec:
  replicas: 6
  template:
    spec:
      priorityClassName: rutasnorte-critica
      affinity:
        nodeAffinity:
          # Fuera de los nodos de analítica y de los de base de datos
          requiredDuringSchedulingIgnoredDuringExecution:
            nodeSelectorTerms:
              - matchExpressions:
                  - key: dedicado
                    operator: DoesNotExist
                  - key: gama
                    operator: In
                    values: ["estandar", "alta"]
        podAntiAffinity:
          # preferred, no required: con 6 réplicas y pocos nodos, required bloquearía
          preferredDuringSchedulingIgnoredDuringExecution:
            - weight: 100
              podAffinityTerm:
                labelSelector:
                  matchLabels:
                    app: api-reservas
                    entorno: pro
                topologyKey: kubernetes.io/hostname
            - weight: 50
              podAffinityTerm:
                labelSelector:
                  matchLabels:
                    app: api-reservas
                    entorno: pro
                topologyKey: topology.kubernetes.io/zone
      tolerations:
        - key: node.kubernetes.io/unreachable
          operator: Exists
          effect: NoExecute
          tolerationSeconds: 60

Dos reglas con pesos distintos: repartir entre nodos importa el doble que repartir entre zonas. El resultado en un despliegue típico:

kubectl get pods -n rutas-norte-pro -l app=api-reservas \
  -o custom-columns=POD:.metadata.name,NODO:.spec.nodeName --sort-by=.spec.nodeName
POD                             NODO
api-reservas-6c8f9d4b7-2m5kx    rutas-norte-m02
api-reservas-6c8f9d4b7-jm2xq    rutas-norte-m03
api-reservas-6c8f9d4b7-p8t4v    rutas-norte-m04
api-reservas-6c8f9d4b7-q1w7z    rutas-norte-m04
api-reservas-6c8f9d4b7-r9k3b    rutas-norte-m05
api-reservas-6c8f9d4b7-x4n6c    rutas-norte-m05

Seis réplicas en cuatro nodos, lo más repartidas posible. Con required la sexta se habría quedado Pending. tienda-web lleva una configuración equivalente.

informes-ocupacion: al nodo de análisis

El CronJob de 06-03 hace consultas agregadas pesadas. Que corra en el nodo dedicado evita que compita con la venta de billetes:

# k8s/entornos/pro/informes-ocupacion-planificacion.yaml (fragmento del jobTemplate)
        spec:
          priorityClassName: rutasnorte-lote     # preemptionPolicy: Never
          tolerations:
            - key: dedicado
              operator: Equal
              value: analitica
              effect: NoSchedule
          affinity:
            nodeAffinity:
              requiredDuringSchedulingIgnoredDuringExecution:
                nodeSelectorTerms:
                  - matchExpressions:
                      - key: dedicado
                        operator: In
                        values: ["analitica"]

Las dos piezas juntas, como explicamos en el apartado 5: la toleration le permite entrar donde nadie más puede, y la afinidad le obliga a ir ahí. La prioridad rutasnorte-lote con preemptionPolicy: Never garantiza que un informe nocturno nunca desalojará a nada.

El DaemonSet de logs

Ya lo escribimos en 06-02, y ahora se entiende del todo:

      tolerations:
        - key: node-role.kubernetes.io/control-plane
          operator: Exists
          effect: NoSchedule
        - key: dedicado
          operator: Equal
          value: analitica
          effect: NoSchedule       # también en el nodo de análisis

Sin la segunda toleration, el nodo m06 no tendría recolector y los logs de los informes nocturnos no llegarían a ninguna parte: exactamente los logs que querrías consultar cuando un informe falla a las tres de la madrugada.

Resumen del plan

Componente Afinidad de nodo Antiafinidad Tolerations Prioridad
postgres-reservas required: disco in [ssd,nvme] required por hostname unreachable 600 s crítica
api-reservas required: no analítica preferred por hostname y zone unreachable 60 s crítica
tienda-web required: no analítica preferred por hostname por defecto normal
redis-cache ninguna preferred por hostname por defecto normal
worker-notificaciones required: no analítica preferred por hostname por defecto normal
informes-ocupacion required: analítica ninguna dedicado=analitica lote (sin preemption)
recolector-logs (DS) ninguna no aplica control-plane + analítica system-node-critical

  1. Diagnóstico del pod eternamente Pending

Un pod Pending no ha encontrado nodo. El scheduler te dice exactamente por qué, y aprender a leer ese mensaje es la habilidad más rentable de esta lección.

kubectl get pods -n rutas-norte-pro
NAME                            READY   STATUS    RESTARTS   AGE
informes-ocupacion-29178180-p2   0/1     Pending   0          6m
kubectl describe pod informes-ocupacion-29178180-p2 -n rutas-norte-pro | tail -8
Events:
  Type     Reason            Age    From               Message
  ----     ------            ----   ----               -------
  Warning  FailedScheduling  6m12s  default-scheduler  0/6 nodes are available:
           1 node(s) had untolerated taint {dedicado: analitica},
           2 node(s) didn't match Pod's node affinity/selector,
           3 Insufficient cpu.
           preemption: 0/6 nodes are available:
           1 Preemption is not helpful for scheduling,
           5 No preemption victims found for incoming pod.

Cómo se lee ese mensaje

La primera línea da el recuento: de 6 nodos, 0 disponibles. Después, el desglose por motivo, y la suma de los números debe dar 6:

  • 1 node(s) had untolerated taint {dedicado: analitica} → el nodo m06 rechazó el pod porque no lleva la toleration. Si el pod era informes-ocupacion, falta la toleration.
  • 2 node(s) didn't match Pod's node affinity/selector → dos nodos no cumplen la afinidad required.
  • 3 Insufficient cpu → tres nodos no tienen CPU libre suficiente para las requests.

El bloque preemption: explica por qué la prioridad tampoco ayudó: No preemption victims found significa que no hay pods de menor prioridad que, desalojados, dejarían sitio.

Tabla de motivos y qué hacer

Mensaje Causa Solución
Insufficient cpu / Insufficient memory No hay recursos libres para las requests Bajar requests, añadir nodos, escalar el clúster (09-03)
had untolerated taint {clave: valor} El nodo tiene un taint que el pod no tolera Añadir la toleration o quitar el taint
didn't match Pod's node affinity/selector La afinidad required o el nodeSelector no casan Revisar las etiquetas del nodo con kubectl get nodes --show-labels
didn't match pod anti-affinity rules Ya hay un pod del grupo en cada dominio Cambiar a preferred, ampliar topologyKey o añadir nodos
node(s) had volume node affinity conflict El PV está en otra zona que el nodo Revisar volumeBindingMode de la StorageClass (05-04)
node(s) were unschedulable Nodo con cordon kubectl uncordon <nodo>
node(s) had no available disk Presión de disco Liberar espacio en el nodo
exceeded quota (en FailedCreate, no Pending) ResourceQuota del namespace agotada Ajustar la cuota (03-04)

Rutina de diagnóstico

# 1. El evento del scheduler: el 90 % de las veces basta con esto
kubectl describe pod <pod> -n <ns> | grep -A15 Events

# 2. ¿Cuánto pide el pod?
kubectl get pod <pod> -n <ns> \
  -o jsonpath='{.spec.containers[*].resources.requests}{"\n"}'

# 3. ¿Cuánto queda libre en cada nodo? (mira "Allocated resources")
kubectl describe nodes | grep -A6 "Allocated resources"

# 4. ¿Qué etiquetas tienen los nodos?
kubectl get nodes --show-labels

# 5. ¿Qué taints tienen?
kubectl get nodes -o custom-columns=NODO:.metadata.name,TAINTS:.spec.taints

# 6. Eventos recientes del namespace, ordenados
kubectl get events -n <ns> --sort-by=.lastTimestamp | tail -20

Consejo de método: empieza siempre por el evento del scheduler. Es el único sitio donde el sistema te dice, con recuento por motivo, qué descartó cada nodo. Inspeccionar nodos a ojo antes de leerlo es perder el tiempo.

Errores Comunes y Consejos

Confundir toleration con afinidad. Es el malentendido más extendido del tema. Una toleration solo evita el rechazo; no lleva el pod a ninguna parte. Para dedicar nodos hacen falta las dos cosas.

required donde bastaba preferred. Cada regla required es un filtro absoluto que puede dejar un pod Pending para siempre. Antes de escribirla, pregúntate: "si no se cumple, ¿prefiero que el pod no se ejecute?". Si la respuesta es no, va en preferred.

Antiafinidad required con topologyKey: hostname y muchas réplicas. Limita las réplicas al número de nodos. Combinado con un HPA que intenta subir a 15 en un pico de puente, produce pods Pending justo cuando más falta hacen.

Esperar que un cambio de etiqueta mueva pods. IgnoredDuringExecution significa exactamente lo que dice. Para mover un pod hay que borrarlo, o usar un taint NoExecute.

Usar nodeName en manifiestos de producción. Salta el scheduler y con él toda la resiliencia. Si el nodo cae, el pod no se recoloca.

Poner operator: Exists con value. La API lo rechaza. Con Exists no se especifica valor.

Olvidar el guion al quitar un taint. kubectl taint node m06 dedicado=analitica:NoSchedule añade; con el guion final quita. Sin el guion se acaba con taints duplicados.

Inflación de PriorityClass. Si todo es crítico, nada lo es. Tres clases bien definidas y documentadas bastan para casi cualquier plataforma.

Preemption sobre cargas con estado. Desalojar postgres-reservas implica desmontar y remontar un volumen en otro nodo. Protégelo con prioridad alta, pero no cuentes con que la preemption lo recoloque limpiamente.

Consejo: kubectl get pods -o wide --sort-by=.spec.nodeName es el comando para verificar de un vistazo que el reparto es el que esperabas.

Consejo: comprueba una regla antes de aplicarla en producción. Aplica el manifiesto en rutas-norte-dev, escala a la cifra que usarías en producción y comprueba que ninguna réplica queda Pending. Una antiafinidad required mal calibrada solo se manifiesta cuando el número de réplicas supera al de nodos.

Consejo: documenta por qué existe cada regla. Un podAntiAffinity sin comentario es indistinguible de un copiar y pegar, y nadie se atreverá a tocarlo dentro de un año. Una línea de comentario con el motivo ahorra mucho.

Ejercicios

Ejercicio 1: afinidad de nodo obligatoria y preferida

En tu minikube (perfil rutas-norte, varios nodos), etiqueta un nodo con disco=ssd y otro con disco=hdd. Crea en rutas-norte-dev un Deployment bd-demo de 2 réplicas de nginx:1.27.2-alpine que:

  • Exija (required) correr en nodos con disco en [ssd, nvme].
  • Prefiera (preferred, peso 70) nodos con gama=alta.

Comprueba dónde se colocan. Después escala a 4 réplicas y observa qué pasa.

Ejercicio 2: antiafinidad required y su límite

Crea un Deployment api-demo de 2 réplicas con antiafinidad required por kubernetes.io/hostname sobre su propia etiqueta app. Verifica que las dos réplicas caen en nodos distintos. Escala a un número mayor que el de nodos disponibles y diagnostica el pod Pending leyendo el evento del scheduler. Corrige el problema pasando a preferred.

Ejercicio 3: nodo dedicado con taint, toleration y afinidad

Elige un nodo, etiquétalo dedicado=analitica y ponle el taint dedicado=analitica:NoSchedule. Después:

  1. Comprueba que un pod normal no se coloca ahí.
  2. Crea un Job analitica-demo que corra ahí, con toleration y afinidad.
  3. Demuestra que solo la toleration no basta: crea un pod con la toleration pero sin afinidad y comprueba dónde acaba.
  4. Limpia el taint y las etiquetas.

Soluciones

Solución 1

kubectl get nodes
kubectl label node rutas-norte-m02 disco=ssd gama=alta --overwrite
kubectl label node rutas-norte-m03 disco=hdd gama=estandar --overwrite
# /tmp/bd-demo.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: bd-demo
  namespace: rutas-norte-dev
  labels:
    app: bd-demo
    app.kubernetes.io/part-of: rutas-norte
    entorno: dev
spec:
  replicas: 2
  selector:
    matchLabels:
      app: bd-demo
      entorno: dev
  template:
    metadata:
      labels:
        app: bd-demo
        app.kubernetes.io/part-of: rutas-norte
        entorno: dev
    spec:
      automountServiceAccountToken: false
      affinity:
        nodeAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
            nodeSelectorTerms:
              - matchExpressions:
                  - key: disco
                    operator: In
                    values: ["ssd", "nvme"]
          preferredDuringSchedulingIgnoredDuringExecution:
            - weight: 70
              preference:
                matchExpressions:
                  - key: gama
                    operator: In
                    values: ["alta"]
      containers:
        - name: nginx
          image: nginx:1.27.2-alpine
          resources:
            requests:
              cpu: 50m
              memory: 64Mi
            limits:
              cpu: 200m
              memory: 128Mi
kubectl apply -f /tmp/bd-demo.yaml
kubectl get pods -n rutas-norte-dev -l app=bd-demo -o wide
NAME                       READY   STATUS    RESTARTS   AGE   NODE
bd-demo-7f9c4d8b6-h2m4x    1/1     Running   0          18s   rutas-norte-m02
bd-demo-7f9c4d8b6-t5k7p    1/1     Running   0          18s   rutas-norte-m02

Las dos réplicas van a m02, el único nodo con disco=ssd. Que además tenga gama=alta le da 70 puntos extra, pero era irrelevante: el filtrado ya había dejado un solo candidato.

kubectl scale deployment bd-demo -n rutas-norte-dev --replicas=4
kubectl get pods -n rutas-norte-dev -l app=bd-demo -o wide
NAME                       READY   STATUS    RESTARTS   AGE   NODE
bd-demo-7f9c4d8b6-h2m4x    1/1     Running   0          2m    rutas-norte-m02
bd-demo-7f9c4d8b6-t5k7p    1/1     Running   0          2m    rutas-norte-m02
bd-demo-7f9c4d8b6-v8n3q    1/1     Running   0          12s   rutas-norte-m02
bd-demo-7f9c4d8b6-w1x6z    1/1     Running   0          12s   rutas-norte-m02

Las cuatro en el mismo nodo. La afinidad de nodo dice dónde puede ir un pod, pero no dice nada sobre repartir. Para eso hace falta antiafinidad, que es el ejercicio 2. Si m02 se reinicia, se cae bd-demo entero.

Solución 2

# /tmp/api-demo.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: api-demo
  namespace: rutas-norte-dev
  labels:
    app: api-demo
    app.kubernetes.io/part-of: rutas-norte
    entorno: dev
spec:
  replicas: 2
  selector:
    matchLabels:
      app: api-demo
      entorno: dev
  template:
    metadata:
      labels:
        app: api-demo
        app.kubernetes.io/part-of: rutas-norte
        entorno: dev
    spec:
      automountServiceAccountToken: false
      affinity:
        podAntiAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
            - labelSelector:
                matchLabels:
                  app: api-demo
                  entorno: dev
              topologyKey: kubernetes.io/hostname
      containers:
        - name: nginx
          image: nginx:1.27.2-alpine
          resources:
            requests:
              cpu: 50m
              memory: 64Mi
            limits:
              cpu: 200m
              memory: 128Mi
kubectl apply -f /tmp/api-demo.yaml
kubectl get pods -n rutas-norte-dev -l app=api-demo -o wide
NAME                       READY   STATUS    RESTARTS   AGE   NODE
api-demo-8b5d7c9f4-k3m2x   1/1     Running   0          15s   rutas-norte-m02
api-demo-8b5d7c9f4-r7t9p   1/1     Running   0          15s   rutas-norte-m03

Una por nodo, como se pedía.

kubectl scale deployment api-demo -n rutas-norte-dev --replicas=4
kubectl get pods -n rutas-norte-dev -l app=api-demo -o wide
NAME                       READY   STATUS    RESTARTS   AGE   NODE
api-demo-8b5d7c9f4-k3m2x   1/1     Running   0          2m    rutas-norte-m02
api-demo-8b5d7c9f4-r7t9p   1/1     Running   0          2m    rutas-norte-m03
api-demo-8b5d7c9f4-w4n8q   0/1     Pending   0          20s   <none>
api-demo-8b5d7c9f4-z2v5c   0/1     Pending   0          20s   <none>
kubectl describe pod -n rutas-norte-dev \
  $(kubectl get pod -n rutas-norte-dev -l app=api-demo \
    --field-selector=status.phase=Pending -o jsonpath='{.items[0].metadata.name}') | tail -5
Events:
  Type     Reason            Age   From               Message
  ----     ------            ----  ----               -------
  Warning  FailedScheduling  25s   default-scheduler  0/3 nodes are available:
           1 node(s) had untolerated taint {node-role.kubernetes.io/control-plane: },
           2 node(s) didn't match pod anti-affinity rules.

El mensaje es explícito: dos nodos ya tienen una réplica y la regla required prohíbe una segunda; el tercero es el plano de control, con su taint. Con antiafinidad required por hostname, el máximo de réplicas es el número de nodos elegibles.

Corrección:

kubectl patch deployment api-demo -n rutas-norte-dev --type=json -p='[
  {"op": "remove", "path": "/spec/template/spec/affinity/podAntiAffinity/requiredDuringSchedulingIgnoredDuringExecution"},
  {"op": "add", "path": "/spec/template/spec/affinity/podAntiAffinity/preferredDuringSchedulingIgnoredDuringExecution",
   "value": [{"weight": 100, "podAffinityTerm": {
     "labelSelector": {"matchLabels": {"app": "api-demo", "entorno": "dev"}},
     "topologyKey": "kubernetes.io/hostname"}}]}
]'

kubectl rollout status deployment/api-demo -n rutas-norte-dev
kubectl get pods -n rutas-norte-dev -l app=api-demo -o wide
NAME                        READY   STATUS    RESTARTS   AGE   NODE
api-demo-6d4f8a7c2-b1n5k    1/1     Running   0          40s   rutas-norte-m02
api-demo-6d4f8a7c2-h9m3t    1/1     Running   0          38s   rutas-norte-m03
api-demo-6d4f8a7c2-p6x2w    1/1     Running   0          35s   rutas-norte-m02
api-demo-6d4f8a7c2-y8k4r    1/1     Running   0          33s   rutas-norte-m03

Cuatro réplicas repartidas dos y dos: la preferencia repartió lo mejor posible y, agotadas las alternativas, permitió duplicar en lugar de bloquear.

Solución 3

kubectl label node rutas-norte-m03 dedicado=analitica --overwrite
kubectl taint node rutas-norte-m03 dedicado=analitica:NoSchedule
kubectl get nodes -o custom-columns=NODO:.metadata.name,TAINTS:.spec.taints
NODO              TAINTS
rutas-norte       [map[effect:NoSchedule key:node-role.kubernetes.io/control-plane]]
rutas-norte-m02   <none>
rutas-norte-m03   [map[effect:NoSchedule key:dedicado value:analitica]]

1. Un pod normal no va ahí:

kubectl run normal-demo -n rutas-norte-dev --image=nginx:1.27.2-alpine --restart=Never
kubectl wait --for=condition=ready pod/normal-demo -n rutas-norte-dev --timeout=60s
kubectl get pod normal-demo -n rutas-norte-dev -o wide
NAME          READY   STATUS    RESTARTS   AGE   NODE
normal-demo   1/1     Running   0          8s    rutas-norte-m02

Acabó en m02, el único sin taint que lo rechace.

2. Job con toleration y afinidad:

# /tmp/analitica-demo.yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: analitica-demo
  namespace: rutas-norte-dev
  labels:
    app: informes-ocupacion
    entorno: dev
spec:
  backoffLimit: 1
  ttlSecondsAfterFinished: 3600
  template:
    metadata:
      labels:
        app: informes-ocupacion
        entorno: dev
    spec:
      restartPolicy: Never
      automountServiceAccountToken: false
      tolerations:
        - key: dedicado
          operator: Equal
          value: analitica
          effect: NoSchedule
      affinity:
        nodeAffinity:
          requiredDuringSchedulingIgnoredDuringExecution:
            nodeSelectorTerms:
              - matchExpressions:
                  - key: dedicado
                    operator: In
                    values: ["analitica"]
      containers:
        - name: calculo
          image: busybox:1.36
          command: ["sh", "-c", "echo 'calculando ocupación en el nodo dedicado'; sleep 10"]
          resources:
            requests:
              cpu: 20m
              memory: 32Mi
            limits:
              cpu: 100m
              memory: 64Mi
kubectl apply -f /tmp/analitica-demo.yaml
kubectl get pods -n rutas-norte-dev -l job-name=analitica-demo -o wide
NAME                   READY   STATUS    RESTARTS   AGE   NODE
analitica-demo-m4k2x   1/1     Running   0          6s    rutas-norte-m03

3. Solo la toleration no basta:

# /tmp/solo-toleration.yaml
apiVersion: v1
kind: Pod
metadata:
  name: solo-toleration
  namespace: rutas-norte-dev
  labels:
    app: solo-toleration
    entorno: dev
spec:
  automountServiceAccountToken: false
  tolerations:
    - key: dedicado
      operator: Equal
      value: analitica
      effect: NoSchedule
  containers:
    - name: nginx
      image: nginx:1.27.2-alpine
      resources:
        requests:
          cpu: 50m
          memory: 64Mi
        limits:
          cpu: 200m
          memory: 128Mi
kubectl apply -f /tmp/solo-toleration.yaml
kubectl wait --for=condition=ready pod/solo-toleration -n rutas-norte-dev --timeout=60s
kubectl get pod solo-toleration -n rutas-norte-dev -o wide
NAME              READY   STATUS    RESTARTS   AGE   NODE
solo-toleration   1/1     Running   0          9s    rutas-norte-m02

Podía haber ido a m03 y no fue: la toleration permite pero no atrae. El scheduler eligió m02 por su puntuación. Esto confirma la advertencia del apartado 5: para dedicar nodos hacen falta taint + toleration + afinidad.

4. Limpieza:

kubectl taint node rutas-norte-m03 dedicado=analitica:NoSchedule-
kubectl label node rutas-norte-m03 dedicado-
kubectl label node rutas-norte-m02 disco- gama- --overwrite
kubectl label node rutas-norte-m03 disco- gama- --overwrite
kubectl delete -f /tmp/analitica-demo.yaml -f /tmp/solo-toleration.yaml \
                 -f /tmp/api-demo.yaml -f /tmp/bd-demo.yaml
kubectl delete pod normal-demo -n rutas-norte-dev

Conclusión

El kube-scheduler decide en dos fases: filtra los nodos inviables y puntúa los que quedan. Esa estructura explica toda la lección: las reglas required actúan en el filtrado y pueden dejar un pod Pending; las preferred actúan en la puntuación y solo influyen en la elección.

Hemos recorrido los mecanismos de menos a más expresivo: nodeName (fuerza bruta, solo para depurar), nodeSelector (igualdad simple), afinidad de nodo con sus nodeSelectorTerms en OR, sus matchExpressions en AND y sus operadores In, NotIn, Exists, DoesNotExist, Gt y Lt. El IgnoredDuringExecution de sus nombres largos significa que un cambio en las etiquetas del nodo nunca moverá un pod ya programado.

La afinidad y antiafinidad entre pods miran quién más hay en el dominio que define topologyKey: nodo, zona o región, según de qué fallo quieras protegerte. Su coste de cómputo es real en clústeres grandes, y para el caso concreto de repartir réplicas conviene mirar hacia topologySpreadConstraints, cuyo uso para alta disponibilidad es contenido de 09-05.

Los taints invierten la relación: es el nodo quien rechaza, con tres efectos (NoSchedule, PreferNoSchedule y NoExecute, el único que desaloja pods en marcha). Kubernetes los usa por su cuenta cuando un nodo falla, y las tolerations automáticas con tolerationSeconds: 300 explican el retardo de cinco minutos antes de que los pods de un nodo caído reaparezcan en otro sitio. La regla que hay que grabar: una toleration permite, no atrae; para dedicar nodos hacen falta taint, toleration y afinidad.

Las PriorityClass ordenan la cola y habilitan la preemption, con el riesgo de la inflación de prioridades y de las cascadas de desalojo. Y el evento FailedScheduling, con su recuento de nodos descartados por cada motivo, es la primera y casi siempre la única parada del diagnóstico de un pod Pending.

Con eso, el plan de planificación de Rutas Norte queda completo: postgres-reservas en los nodos con SSD, api-reservas y tienda-web repartidas entre nodos, un nodo de análisis reservado para informes-ocupacion y un DaemonSet de logs que llega a todas partes.

Hasta aquí hemos usado los objetos que Kubernetes trae de fábrica: Pods, Deployments, StatefulSets, Services, PVC, Jobs. Pero en el módulo 4 instalamos cert-manager y empezamos a crear objetos de tipo Certificate e Issuer, y en el módulo 5 creamos VolumeSnapshot. Ninguno de esos tipos forma parte de Kubernetes: alguien los añadió a la API, y el clúster los trata exactamente igual que a los nativos —kubectl get, describe, explain, validación, RBAC—. Cómo se hace eso, y cómo definiríamos nuestro propio tipo RutaProgramada para Rutas Norte, es el tema de la siguiente lección: las Definiciones de Recursos Personalizados.

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