Aller au contenu
Conteneurs & Orchestration medium

Scheduling avancé Kubernetes

35 min de lecture

logo kubernetes

Le scheduler Kubernetes place les Pods sur les nœuds disponibles selon des règles précises. Par défaut, il équilibre la charge automatiquement. Mais en production, vous devez souvent contrôler ce placement : isoler des workloads, garantir la haute disponibilité, ou exploiter du matériel spécifique (GPU, SSD).

  • Cibler des nœuds par labels avec nodeSelector puis nodeAffinity
  • Rapprocher ou séparer des Pods avec podAffinity et podAntiAffinity
  • Réserver des nœuds avec les taints et les tolerations
  • Répartir des réplicas sur les zones avec topologySpreadConstraints
  • Diagnostiquer un Pod Pending à partir des événements du scheduler
  • Un cluster Kubernetes fonctionnel avec au moins deux nœuds pour observer les effets de placement
  • Les bases des Pods et des Deployments
  • Les notions de requests et limits, le scheduler s'en sert pour filtrer les nœuds

La colonne Direction est la clé de lecture de ce tableau : elle indique qui décide. Avec nodeSelector et nodeAffinity, c'est le Pod qui exprime une exigence ; avec les taints, c'est le nœud qui pose une condition d'entrée. Cette différence a une conséquence pratique : un développeur peut écrire une affinité dans son manifeste, alors qu'un taint demande un accès administrateur au cluster. Les deux commandes ci-dessous donnent l'état des lieux avant toute décision de placement, puisque tous ces mécanismes reposent sur des labels et des taints existants.

MécanismeDirectionUsage principal
nodeSelectorPod → NodePlacement simple par labels
nodeAffinityPod → NodePlacement avancé avec expressions
podAffinityPod → PodCo-localisation de Pods
podAntiAffinityPod ↔ PodSéparation de Pods
taints/tolerationsNode → PodExclusion de nœuds sauf exception
topologySpreadConstraintsPod ↔ TopologyRépartition équilibrée
Fenêtre de terminal
# Voir les labels et taints d'un nœud
kubectl describe node <node-name> | grep -A10 Labels
kubectl describe node <node-name> | grep -A5 Taints
# Lister les nœuds avec leurs labels
kubectl get nodes --show-labels

Le nodeSelector est le mécanisme le plus simple : il place le Pod uniquement sur des nœuds ayant tous les labels spécifiés.

Un label de nœud est une simple paire clé/valeur, mais son nommage engage la suite : c'est lui que tous vos manifestes référenceront. Adoptez des clés descriptives du matériel ou du rôle (disktype, gpu, tier) plutôt que des noms de machines, sinon vous devrez modifier chaque manifeste au premier remplacement de serveur. Notez la syntaxe de suppression, avec un tiret final collé à la clé : c'est la même mécanique que pour les taints, et elle surprend souvent.

Fenêtre de terminal
# Ajouter un label
kubectl label node worker-1 disktype=ssd
# Vérifier
kubectl get nodes -l disktype=ssd
# Supprimer un label
kubectl label node worker-1 disktype-

Le champ nodeSelector se place directement sous spec, au même niveau que containers. Son comportement est strict et silencieux : si aucun nœud ne porte le label, le Pod reste indéfiniment en Pending sans message d'erreur au kubectl apply. Vérifiez donc toujours qu'au moins un nœud correspond avec kubectl get nodes -l disktype=ssd avant de déployer, en particulier sur un cluster où les labels sont posés par un autre outil.

apiVersion: v1
kind: Pod
metadata:
name: ssd-pod
spec:
nodeSelector:
disktype: ssd
containers:
- name: app
image: nginx:1.25@sha256:a484819eb60211f5299034ac80f6a681b06f89e65866ce91f356ed7c72af059c

nodeAffinity offre plus de flexibilité que nodeSelector avec des expressions logiques (In, NotIn, Exists, DoesNotExist, Gt, Lt).

Les noms sont longs mais se lisent en deux parties. DuringScheduling décrit ce qui se passe au placement : la règle est obligatoire ou seulement souhaitée. IgnoredDuringExecution décrit ce qui se passe ensuite : si les labels du nœud changent, le Pod déjà en cours d'exécution n'est pas déplacé. C'est une limite importante à connaître, car elle signifie qu'une affinité ne garantit rien dans la durée ; seul un taint avec l'effet NoExecute provoque une éviction après coup.

TypeComportement
requiredDuringSchedulingIgnoredDuringExecutionObligatoire, le Pod ne démarre pas si aucun nœud ne matche
preferredDuringSchedulingIgnoredDuringExecutionPréféré, le scheduler essaie, mais place ailleurs si nécessaire

Deux niveaux d'imbrication règlent la logique de cet exemple, et les confondre est l'erreur la plus fréquente. Les entrées de nodeSelectorTerms se combinent avec un OU, alors que les matchExpressions d'un même terme se combinent avec un ET. Ici, un seul terme contenant une seule expression In sur deux valeurs : le nœud doit se trouver dans l'une ou l'autre des zones listées.

apiVersion: v1
kind: Pod
metadata:
name: affinity-required
spec:
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
nodeSelectorTerms:
- matchExpressions:
- key: topology.kubernetes.io/zone
operator: In
values:
- eu-west-1a
- eu-west-1b
containers:
- name: app
image: nginx:1.25@sha256:a484819eb60211f5299034ac80f6a681b06f89e65866ce91f356ed7c72af059c

Le Pod ne démarrera que sur un nœud dans la zone eu-west-1a ou eu-west-1b.

Le champ weight accepte une valeur de 1 à 100 et n'a de sens que par comparaison : le scheduler additionne les poids des préférences satisfaites pour classer les nœuds éligibles. Ce n'est donc pas un pourcentage, et un poids élevé ne rend pas la règle obligatoire. Cette variante convient au matériel « souhaitable mais pas indispensable », typiquement un disque rapide : le Pod démarre quand même si tous les nœuds SSD sont saturés.

apiVersion: v1
kind: Pod
metadata:
name: affinity-preferred
spec:
affinity:
nodeAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 80
preference:
matchExpressions:
- key: disktype
operator: In
values:
- ssd
- weight: 20
preference:
matchExpressions:
- key: disktype
operator: In
values:
- hdd
containers:
- name: app
image: nginx:1.25@sha256:a484819eb60211f5299034ac80f6a681b06f89e65866ce91f356ed7c72af059c

Le scheduler préfère les nœuds SSD (poids 80) mais accepte les HDD (poids 20) si nécessaire.

Attention à un point que la documentation mentionne peu : les opérateurs Gt et Lt ne fonctionnent que dans une nodeAffinity. Employés dans le labelSelector d'une podAffinity, l'API rejette le Pod avec le message not a valid selector operator. Retenez également que Gt et Lt comparent des entiers et exigent une valeur unique dans values, alors que Exists et DoesNotExist ignorent complètement le contenu de values.

OpérateurDescriptionExemple
InValeur dans la listezone In [a, b]
NotInValeur hors de la listeenv NotIn [prod]
ExistsLa clé existe (valeur ignorée)gpu Exists
DoesNotExistLa clé n'existe passpot DoesNotExist
GtValeur supérieure (numérique)cores Gt 4
LtValeur inférieure (numérique)memory Lt 32

Ces mécanismes définissent des règles de placement entre Pods, pas entre Pod et Node. La différence est importante à l'exécution : le scheduler doit interroger les Pods déjà placés pour évaluer la règle, ce qui rend ces contraintes plus coûteuses en calcul que nodeAffinity et sensibles à l'ordre de placement. Sur un cluster de plusieurs centaines de nœuds, une anti-affinité required sur un Deployment de grande taille ralentit visiblement le scheduling.

L'affinité entre Pods sert à réduire la latence réseau entre deux composants qui échangent beaucoup, typiquement une application et son cache. Le champ décisif est topologyKey : il définit ce que « proche » veut dire. Avec kubernetes.io/hostname, la contrainte impose le même nœud ; avec topology.kubernetes.io/zone, elle se contente de la même zone, ce qui laisse bien plus de latitude au scheduler.

apiVersion: v1
kind: Pod
metadata:
name: cache-client
spec:
affinity:
podAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
app: redis
topologyKey: kubernetes.io/hostname
containers:
- name: client
image: busybox:1.36@sha256:73aaf090f3d85aa34ee199857f03fa3a95c8ede2ffd4cc2cdb5b94e566b11662
command: ['sh', '-c', 'sleep 3600']

Ce Pod sera placé sur le même nœud (topologyKey: kubernetes.io/hostname) qu'un Pod ayant le label app=redis.

L'anti-affinité répond au besoin inverse : éviter que la panne d'un nœud emporte tous les réplicas d'un service. Le sélecteur pointe ici vers le même label que le Deployment lui-même, ce qui revient à dire « pas deux Pods de cette application sur le même nœud ». Cette configuration a une conséquence directe sur le dimensionnement : avec la forme required, il vous faut au moins autant de nœuds que de réplicas, sinon les Pods excédentaires restent Pending. La variante preferred évite ce blocage au prix de la garantie.

apiVersion: apps/v1
kind: Deployment
metadata:
name: web
spec:
replicas: 3
selector:
matchLabels:
app: web
template:
metadata:
labels:
app: web
spec:
affinity:
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
app: web
topologyKey: kubernetes.io/hostname
containers:
- name: nginx
image: nginx:1.25@sha256:a484819eb60211f5299034ac80f6a681b06f89e65866ce91f356ed7c72af059c

Chaque réplica sera placé sur un nœud différent. Si vous n'avez que 2 nœuds, le 3ᵉ Pod restera Pending.


Les taints sont appliqués aux nœuds pour repousser les Pods. Les tolerations permettent aux Pods de tolérer ces taints.

Un taint se compose de trois éléments : une clé, une valeur facultative et un effet. La commande demande les droits d'administration sur les nœuds, puisqu'elle modifie un objet du cluster et non un manifeste applicatif. Comme pour les labels, la suppression passe par un tiret ajouté en fin de commande, et il faut alors répéter l'effet à l'identique : kubectl taint nodes worker-1 dedicated- sans effet supprime tous les taints portant cette clé.

Fenêtre de terminal
# Syntaxe : kubectl taint nodes <node> <key>=<value>:<effect>
kubectl taint nodes worker-1 dedicated=database:NoSchedule
# Vérifier
kubectl describe node worker-1 | grep Taints
# Supprimer un taint (ajout du tiret final)
kubectl taint nodes worker-1 dedicated=database:NoSchedule-

Un même nœud peut porter plusieurs taints avec des effets différents, et ils s'appliquent tous. La distinction essentielle sépare les deux premiers du troisième : NoSchedule et PreferNoSchedule ne concernent que les nouveaux placements, alors que NoExecute agit aussi sur les Pods déjà en cours d'exécution et les expulse. C'est le seul effet capable de vider un nœud, ce qui en fait l'outil de la mise en maintenance, mais aussi le plus dangereux à poser sans avoir vérifié quels Pods le tolèrent.

EffectComportement
NoScheduleNouveaux Pods sans toleration ne sont pas schedulés
PreferNoScheduleLe scheduler évite ce nœud mais l'utilise si nécessaire
NoExecutePods existants sans toleration sont évincés

Une toleration doit correspondre au taint sur les trois champs à la fois : clé, valeur et effet. Une erreur sur l'un d'eux et le Pod reste Pending, sans que la cause soit évidente puisque le manifeste semble correct. Retenez surtout que tolérer un taint n'est pas une affinité : la toleration autorise le Pod à se poser sur le nœud, elle ne l'y attire pas. Pour réserver réellement des machines à une charge, il faut associer le taint sur le nœud et une nodeAffinity dans le Pod.

apiVersion: v1
kind: Pod
metadata:
name: db-pod
spec:
tolerations:
- key: "dedicated"
operator: "Equal"
value: "database"
effect: "NoSchedule"
containers:
- name: postgres
image: postgres:15@sha256:74e110c41804365e3915fcc09d5e7a1eff50161aaa94d5da0e58e0cd75ae509c

Une toleration réduite au seul operator: "Exists", sans clé ni effet, accepte n'importe quel taint. C'est ce que déclarent les DaemonSets d'infrastructure (agents de journalisation, greffons réseau) qui doivent tourner sur tous les nœuds, y compris ceux du control plane. Ne l'employez jamais sur une charge applicative : le Pod ignorerait alors aussi le taint NoExecute posé pour vider un nœud avant maintenance.

tolerations:
- operator: "Exists"

Le premier taint du tableau n'est pas à créer : Kubernetes le pose lui-même sur les nœuds du control plane pour empêcher les charges applicatives d'y atterrir. Le connaître évite une perte de temps classique en formation, quand un Pod reste Pending sur un cluster à un seul nœud. Les deux suivants illustrent les usages que vous poserez vous-même : réserver du matériel spécialisé et préparer une maintenance.

TaintUsage
node-role.kubernetes.io/control-plane:NoScheduleProtège le control plane
dedicated=gpu:NoScheduleRéserve des nœuds GPU
maintenance=true:NoExecuteÉvince les Pods avant maintenance

topologySpreadConstraints, Répartition équilibrée

Section intitulée « topologySpreadConstraints, Répartition équilibrée »

Ce mécanisme répartit les Pods de manière équilibrée sur une topologie (zones, nœuds, racks). Il comble la limite de l'anti-affinité, qui ne sait dire que « jamais deux sur le même nœud » sans notion de proportion. Ici, vous exprimez un objectif de répartition et acceptez un écart borné, ce qui reste applicable quand le nombre de réplicas dépasse le nombre de domaines.

Là où l'anti-affinité raisonne en tout ou rien, topologySpreadConstraints raisonne en écart toléré. Le champ maxSkew fixe la différence maximale de Pods entre deux domaines de la topologie : à 1, la répartition est aussi équilibrée que possible. Le second champ décisif est whenUnsatisfiable : DoNotSchedule laisse les Pods en Pending plutôt que de déséquilibrer, alors que ScheduleAnyway place quand même. Sur un cluster de production, ce choix arbitre entre disponibilité immédiate et résilience.

apiVersion: apps/v1
kind: Deployment
metadata:
name: balanced-app
spec:
replicas: 6
selector:
matchLabels:
app: balanced
template:
metadata:
labels:
app: balanced
spec:
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app: balanced
containers:
- name: app
image: nginx:1.25@sha256:a484819eb60211f5299034ac80f6a681b06f89e65866ce91f356ed7c72af059c
ParamètreDescription
maxSkewDifférence maximale de Pods entre topologies (1 = équilibré)
topologyKeyLabel définissant la topologie (zone, nœud, rack)
whenUnsatisfiableDoNotSchedule (strict) ou ScheduleAnyway (best-effort)
labelSelectorPods concernés par la contrainte

Six réplicas pour trois zones donnent la répartition parfaite ci-dessous, avec un écart nul entre les domaines. Refaites le calcul avec 7 réplicas pour comprendre la contrainte : la distribution devient 3/2/2, soit un écart de 1, encore acceptable. Avec maxSkew: 1, un huitième Pod ne pourrait pas aller en zone a sans créer un écart de 2, il irait donc obligatoirement compléter l'une des deux autres zones. Et si une zone entière devient indisponible, DoNotSchedule laisse les Pods en attente au lieu de les entasser sur les zones restantes.

ZonePods
eu-west-1a2
eu-west-1b2
eu-west-1c2

Kubernetes peut évincer des Pods de basse priorité pour faire de la place à des Pods de haute priorité.

Une PriorityClass est un objet global au cluster, pas un objet de namespace : sa création relève donc de l'administrateur. Deux champs demandent une décision réfléchie. globalDefault: true applique la classe à tous les Pods qui n'en déclarent aucune, une bascule à n'activer qu'en connaissance de cause. preemptionPolicy accepte PreemptLowerPriority, qui autorise l'éviction de Pods moins prioritaires, ou Never, qui donne la priorité dans la file d'attente sans jamais expulser personne.

apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
name: high-priority
value: 1000000
globalDefault: false
preemptionPolicy: PreemptLowerPriority
description: "Pour les workloads critiques"

Côté Pod, une seule ligne suffit : priorityClassName référence la classe par son nom. Kubernetes recopie alors la valeur numérique dans le champ spec.priority du Pod au moment de l'admission, et c'est cette valeur figée qui sert ensuite au scheduler. Conséquence utile à connaître : modifier la valeur d'une PriorityClass n'affecte pas les Pods déjà créés, seulement les suivants.

apiVersion: v1
kind: Pod
metadata:
name: critical-pod
spec:
priorityClassName: high-priority
containers:
- name: app
image: nginx:1.25@sha256:a484819eb60211f5299034ac80f6a681b06f89e65866ce91f356ed7c72af059c

Labellisez un nœud avec tier=frontend et créez un Pod qui ne s'exécute que sur ce nœud. Vérifiez le résultat avec kubectl get pod -o wide : la colonne NODE doit afficher le nœud labellisé. Pensez à retirer le label après l'exercice avec kubectl label node worker-1 tier-, sinon il influencera vos déploiements suivants sans que vous vous en souveniez.

Solution
Fenêtre de terminal
# Labelliser
kubectl label node worker-1 tier=frontend
# Pod
kubectl apply -f - <<'EOF'
apiVersion: v1
kind: Pod
metadata:
name: frontend-pod
spec:
nodeSelector:
tier: frontend
containers:
- name: nginx
image: nginx:1.25@sha256:a484819eb60211f5299034ac80f6a681b06f89e65866ce91f356ed7c72af059c
EOF
# Vérifier
kubectl get pod frontend-pod -o wide

Cet exercice se fait en trois temps pour observer le contraste : d'abord poser la contrainte, ensuite constater le blocage, enfin le lever avec la toleration correspondante. Sur un cluster à plusieurs nœuds, le Pod sans toleration se placera ailleurs au lieu de rester Pending ; adaptez alors le taint à tous les nœuds ou contentez-vous d'observer sur quel nœud il atterrit. La dernière commande retire le taint : ne l'oubliez pas, un nœud resté marqué se remarque plusieurs jours après.

  1. Appliquez un taint workload=ml:NoSchedule sur un nœud
  2. Créez un Pod sans toleration (doit rester Pending)
  3. Créez un Pod avec toleration (doit démarrer)
Solution
Fenêtre de terminal
# Taint
kubectl taint nodes worker-1 workload=ml:NoSchedule
# Pod sans toleration
kubectl run no-toleration --image=nginx -o yaml --dry-run=client | \
kubectl apply -f -
kubectl get pod no-toleration
# Pending (si worker-1 est le seul nœud disponible)
# Pod avec toleration
kubectl apply -f - <<'EOF'
apiVersion: v1
kind: Pod
metadata:
name: ml-pod
spec:
tolerations:
- key: "workload"
operator: "Equal"
value: "ml"
effect: "NoSchedule"
containers:
- name: app
image: nginx:1.25@sha256:a484819eb60211f5299034ac80f6a681b06f89e65866ce91f356ed7c72af059c
EOF
# Nettoyage
kubectl taint nodes worker-1 workload=ml:NoSchedule-

Créez un Deployment de 3 réplicas avec une anti-affinité garantissant qu'aucun Pod ne partage le même nœud.

Solution
kubectl apply -f - <<'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
name: ha-app
spec:
replicas: 3
selector:
matchLabels:
app: ha-app
template:
metadata:
labels:
app: ha-app
spec:
affinity:
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
app: ha-app
topologyKey: kubernetes.io/hostname
containers:
- name: nginx
image: nginx:1.25@sha256:a484819eb60211f5299034ac80f6a681b06f89e65866ce91f356ed7c72af059c
EOF
# Vérifier la distribution
kubectl get pods -l app=ha-app -o wide

Quand un Pod reste Pending, le scheduler indique pourquoi dans les events :

Fenêtre de terminal
# Events du Pod
kubectl describe pod <pod-name> | grep -A10 Events
# Message réel observé sur un cluster mono-nœud (Kubernetes 1.31)
# Warning FailedScheduling default-scheduler
# 0/1 nodes are available: 1 node(s) didn't match Pod's node affinity/selector.
# preemption: 0/1 nodes are available: 1 Preemption is not helpful for scheduling.
# Autres causes fréquentes, même format
# 0/3 nodes are available: 1 node(s) had untolerated taint, 2 node(s) didn't match Pod's node affinity/selector
# 0/3 nodes are available: 3 Insufficient cpu
# 0/3 nodes are available: 3 node(s) didn't match pod anti-affinity rules

Le message se lit en deux temps. La première phrase donne le motif de filtrage et le nombre de nœuds concernés par chaque motif : c'est elle qui désigne le mécanisme fautif. La seconde, préfixée par preemption:, indique si l'éviction de Pods moins prioritaires débloquerait la situation. Preemption is not helpful signifie que le problème ne vient pas d'un manque de ressources mais d'une contrainte de placement, donc que libérer de la place ne changerait rien.

Ces quatre vérifications suivent l'ordre dans lequel le scheduler filtre les nœuds, ce qui évite de chercher au mauvais endroit. Commencez toujours par les événements, qui nomment la contrainte fautive ; les trois commandes suivantes servent à confirmer l'hypothèse en regardant l'état réel des nœuds. Si les événements mentionnent Insufficient cpu ou Insufficient memory, la deuxième étape suffit et le problème relève du dimensionnement, pas du placement.

  1. Vérifier les events du Pod

    Fenêtre de terminal
    kubectl describe pod <name> | tail -20
  2. Vérifier les ressources disponibles

    Fenêtre de terminal
    kubectl describe nodes | grep -A5 "Allocated resources"
  3. Vérifier les taints des nœuds

    Fenêtre de terminal
    kubectl get nodes -o custom-columns=NAME:.metadata.name,TAINTS:.spec.taints
  4. Vérifier les labels des nœuds

    Fenêtre de terminal
    kubectl get nodes --show-labels | tr ',' '\n'

  1. nodeSelector : placement simple, tous les labels doivent matcher
  2. nodeAffinity : placement avancé avec expressions (In, NotIn, Exists, Gt, Lt)
  3. podAffinity/AntiAffinity : placement relatif entre Pods (co-localisation ou séparation)
  4. Taints repoussent les Pods, tolerations permettent aux Pods de passer
  5. topologySpreadConstraints : répartition équilibrée sur zones/nœuds
  6. PriorityClass : permet la preemption des Pods de basse priorité
  7. Un Pod Pending → kubectl describe pod pour voir la raison

Ce site vous est utile ?

Sachez que moins de 1% des lecteurs soutiennent ce site.

Je maintiens +700 guides gratuits, sans pub ni tracking. Un soutien, même symbolique, m'aide à couvrir l'hébergement et à garder ces ressources gratuites. Merci pour votre appui.

Le formulaire ne s'affiche pas ? Ouvrir Ko-fi dans un onglet.

Abonnez-vous et suivez mon actualité DevSecOps sur LinkedIn