Afinidad y selección de nodo
El scheduler hace un buen trabajo por defecto: mira dónde cabe el Pod, puntúa los nodos y elige. Pero hay decisiones que él no puede tomar por ti, porque dependen de cosas que no sabe. Que ese trabajo necesita disco SSD. Que esa caché debe estar pegada a la aplicación que la usa. Que las dos réplicas del mismo servicio no deberían compartir nodo, porque entonces el nodo es un punto único de fallo.
Hay cuatro herramientas para influir en esa decisión, y el error habitual es usar la más fuerte cuando bastaba la más suave.
Los nodos del playground ya vienen etiquetados. Míralos desde la pestaña dev-machine:
kubectl get nodes --show-labels
node-01 tiene disktype=ssd y zona=a; node-02, disktype=hdd y zona=b. Fíjate además en las etiquetas que Kubernetes pone solo en cada nodo, y que puedes usar como cualquier otra: kubernetes.io/hostname, kubernetes.io/arch, kubernetes.io/os y, en un cloud de verdad, topology.kubernetes.io/zone y /region.
Ojo con el cero. Aquí los nodos son node-01 y node-02; en el libro son node-1, node-2 y node-3. Los nombres los pone la plataforma cuando registra los nodos y no se pueden cambiar, así que este es el módulo donde más vas a notar la diferencia: si copias un nodeName o un kubectl describe node del libro sin tocarlo, te responderá NotFound. Ante la duda, kubectl get nodes manda.
Paso 1: nodeSelector, el martillo
Ya lo usaste en el challenge de scheduling, así que solo el recordatorio:
spec:
nodeSelector:
disktype: ssd
Es un filtro de igualdad estricta y no admite matices: o hay un nodo que casa, o el Pod se queda en Pending para siempre. Es perfecto cuando la regla es simple y no negociable, y se queda corto en cuanto quieres decir "prefiero, pero no exijo" o "cualquiera de estos dos valores".
Paso 2: nodeAffinity, el mismo martillo con matices
La db de la tienda quiere disco rápido, pero no a cualquier precio: si no hay ningún nodo con SSD prefiere arrancar igual antes que quedarse en Pending para siempre. Eso es exactamente lo que expresa nodeAffinity. Crea db.yaml:
cat << 'EOF' > db.yaml
apiVersion: v1
kind: Pod
metadata:
name: db
labels:
app: db
spec:
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: disktype
operator: In
values:
- ssd
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 50
preference:
matchExpressions:
- key: zona
operator: In
values:
- a
containers:
- name: app
image: ghcr.io/iximiuz/labs/nginx:alpine
EOF
El YAML, explicado con preguntas y respuestas
¿De verdad se llaman así los campos?
Sí, y su nombre es un tratado en sí mismo. requiredDuringScheduling significa "esto es obligatorio en el momento de programar". IgnoredDuringExecution significa "y una vez programado, me da igual": si mañana alguien le quita la etiqueta al nodo, tu Pod no se mueve. Es coherente con toda la filosofía de Kubernetes: las decisiones de scheduling se toman una vez, al colocar el Pod.
¿Qué añade esto sobre un nodeSelector?
Dos cosas que el nodeSelector no puede dar. Primero, operadores: In, NotIn, Exists, DoesNotExist, Gt, Lt. Ya no estás atado a la igualdad. Segundo, y más importante, la versión blanda.
¿Qué hace la parte preferred con su weight?
Es una preferencia, no un requisito. El scheduler suma los pesos de las preferencias que cada nodo cumple y elige el que más puntúa, pero si ninguno cumple, coloca el Pod igualmente. Es la diferencia entre "quiero estar en la zona A" y "no arranques si no estás en la zona A", y es la herramienta correcta el 80 % de las veces. Un required que no se cumple deja un Pod en Pending; un preferred que no se cumple deja un Pod funcionando en un sitio subóptimo. Piensa cuál de las dos cosas prefieres a las tres de la mañana.
kubectl apply -f db.yaml
kubectl get pod db -o wide
En node-01, que es el único con SSD.
Paso 3: podAffinity, "quiero estar cerca de aquel"
Las dos anteriores hablan de nodos. Estas dos hablan de Pods, y es un salto conceptual: la condición ya no es "cómo es el nodo", sino "quién más vive en él".
Crea cache.yaml: una caché que quiere estar en el mismo nodo que la analítica, para que la latencia de red entre ellas sea cero.
cat << 'EOF' > cache.yaml
apiVersion: v1
kind: Pod
metadata:
name: cache
labels:
app: cache
spec:
affinity:
podAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
app: db
topologyKey: kubernetes.io/hostname
containers:
- name: app
image: ghcr.io/iximiuz/labs/nginx:alpine
EOF
¿Qué demonios es topologyKey?
La pieza clave, y la que casi nadie entiende a la primera. No dice "en el mismo nodo": dice "en el mismo valor de esta etiqueta". Con kubernetes.io/hostname, dos nodos son "el mismo sitio" solo si son literalmente el mismo nodo. Cambia la clave a topology.kubernetes.io/zone y "el mismo sitio" pasa a significar "la misma zona de disponibilidad", con lo que la caché podría ir a cualquier nodo de la zona de la analítica. La misma regla, dos radios de acción completamente distintos, según la etiqueta que elijas como unidad de medida.
¿Y el labelSelector a quién señala?
A los Pods de referencia (los que llevan app: db), no a los nodos. El scheduler mira dónde están esos Pods y coloca el nuevo en consecuencia.
kubectl apply -f cache.yaml
kubectl get pods -o wide -l 'app in (db,cache)'
Mismo nodo. Y observa el efecto secundario: el Pod cache acabó en node-01 sin mencionar node-01 en ningún sitio. Se ató a otro Pod, y ese Pod arrastró la decisión.
Paso 4: podAntiAffinity, "no me pongas con mis clones"
La misma idea, en negativo, y con diferencia el uso más frecuente de la familia: repartir las réplicas de un servicio para que la caída de un nodo no se las lleve a todas. Crea cache-ha.yaml, con un nombre y una etiqueta propios para no mezclarlo con el Pod cache del paso anterior, que sigue vivo:
cat << 'EOF' > cache-ha.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: cache-ha
spec:
replicas: 2
selector:
matchLabels:
app: cache-ha
template:
metadata:
labels:
app: cache-ha
spec:
affinity:
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
app: cache-ha
topologyKey: kubernetes.io/hostname
containers:
- name: nginx
image: ghcr.io/iximiuz/labs/nginx:alpine
EOF
Fíjate en el detalle elegante: el labelSelector apunta a sus propios Pods. La regla se lee "no me pongas donde ya haya uno como yo".
kubectl apply -f cache-ha.yaml
kubectl rollout status deployment/cache-ha --timeout=90s
kubectl get pods -o wide -l app=cache-ha
Un Pod en cada nodo.
Y esa etiqueta propia no es un capricho: si el Deployment hubiera reutilizado app: cache, su regla habría contado también al Pod suelto del paso anterior, que ocupa node-01. La primera réplica se habría ido a node-02 y la segunda se habría quedado en Pending para siempre, sin un solo nodo libre de clones. Que es exactamente lo que pasa a continuación.
¿Y si pido 5 réplicas y solo tengo 2 nodos? Que las 3 que sobran se quedan en Pending para siempre, porque la regla es dura y no hay nodos libres de clones. Es el precio de required, y es la razón por la que muchos equipos usan aquí la versión preferred, o directamente topologySpreadConstraints (el de la lección de PDB), que permite decir "reparte lo mejor que puedas, pero no me dejes Pods sin arrancar".
Paso 5: nodeName, el atajo que no deberías usar
Existe un quinto mecanismo, y es el más bruto de todos:
spec:
nodeName: node-01
¿Qué hace exactamente? Escribir directamente el campo que el scheduler habría rellenado. Es decir: el Pod ya llega programado, y el scheduler ni lo mira. No hay comprobación de recursos, no hay taints, no hay afinidades. Si el nodo no existe o está lleno, el Pod se queda ahí colgado y nadie lo va a rescatar.
Recuerda la primera lección del curso: un Pod sin nodeName es un Pod huérfano esperando al scheduler. nodeName es rellenar esa casilla a mano y saltarse la cola.
Sus usos legítimos son contados: depuración, y los static Pods del control plane (que precisamente por eso pueden arrancar antes de que exista un scheduler). En una aplicación, jamás.
Resumen comparativo
| Mecanismo | Habla de | Fuerza | Cuándo usarlo |
|---|---|---|---|
nodeSelector | Nodos | Dura | Regla simple de igualdad y no negociable |
nodeAffinity required | Nodos | Dura | Igual, pero con operadores (In, Exists...) |
nodeAffinity preferred | Nodos | Blanda | "Prefiero, pero arranca igual". La opción por defecto |
podAffinity | Otros Pods | Dura o blanda | Juntar cargas que se hablan mucho |
podAntiAffinity | Otros Pods | Dura o blanda | Separar réplicas del mismo servicio |
topologySpreadConstraints | Otros Pods | Ajustable (maxSkew) | Repartir de forma equilibrada por nodo o zona |
nodeName | Un nodo | Absoluta | Nunca, salvo depuración y static Pods |
Y recuerda la otra mitad del vocabulario, la del challenge anterior: taints y tolerations no eligen destino, solo abren puertas. Un taint repele; una toleration deja de ser repelido. Para ir a un sitio hace falta una de las herramientas de esta lección.
- Previous lesson
- ResourceQuotas y LimitRanges
- Next lesson
- Challenge: programa los Pods pendientes