Le constat : un nœud qui ne peut pas avoir faim
Le 17 septembre 2026, je fais un audit de routine de la configuration kubelet sur les six nœuds du cluster. Pas de panne, pas d’alerte : juste une question qui traînait depuis l’incident Longhorn instance-manager. Ce jour-là, un pod avait gonflé jusqu’à 12 GiB sans que Kubernetes ne lève le petit doigt. Pourquoi aucun nœud n’est-il jamais passé en MemoryPressure ?
Le cluster est volontairement hétérogène : trois masters Raspberry Pi 4 avec 8 Go, deux workers Pi 5 avec 16 Go, et un petit worker Pi 4 avec 2 Go qui porte surtout des DaemonSets. Une seule config kubelet pour des machines dont la RAM varie d’un facteur huit, c’est déjà suspect.
La config réellement chargée par un kubelet ne se lit pas dans un fichier : elle s’interroge via l’API, à travers le proxy du nœud.
kubectl get --raw "/api/v1/nodes/<node>/proxy/configz" \
| jq '.kubeletconfig | {evictionHard, mergeDefaultEvictionSettings,
systemReserved, kubeReserved, enforceNodeAllocatable}'
En substance, la réponse est la même sur les six nœuds :
{
"evictionHard": { "imagefs.available": "5%", "nodefs.available": "5%" },
"mergeDefaultEvictionSettings": false,
"systemReserved": null,
"kubeReserved": null,
"enforceNodeAllocatable": ["pods"]
}
Trois lignes suffisent à tout expliquer.
- Aucun seuil mémoire. K3s définit ses propres seuils d’éviction, uniquement pour le disque (5 % sur
imagefsetnodefs). - Le défaut Kubernetes est écarté. Upstream, le kubelet surveille
memory.available<100Mi. AvecmergeDefaultEvictionSettings=false, la liste de K3s remplace celle de Kubernetes au lieu de s’y ajouter : le seuil mémoire disparaît. - Rien n’est réservé.
systemReservedetkubeReservedsont ànull, donc le scheduler croit que toute la RAM est à lui.
La vérification côté nœud confirme :
kubectl get node <node> -o jsonpath='{.status.capacity.memory} {.status.allocatable.memory}'
# 8007324Ki 8007324Ki
allocatable == capacity, au kilo-octet près. Sur un master, c’est d’autant plus gênant qu’etcd et le processus k3s server tournent hors des pods : le scheduler distribue une mémoire qu’ils occupent déjà.
La théorie : pourquoi MemoryPressure ne pouvait jamais passer à true
La condition MemoryPressure d’un nœud n’est pas une mesure, c’est un verdict. Le kubelet calcule memory.available (capacité moins le working set du nœud) et le compare à ses seuils d’éviction. Franchir un seuil eviction-hard met la condition à True, pose un taint NoSchedule et lance une éviction ordonnée : d’abord les pods qui consomment au-delà de leurs requests, typiquement des Burstable, en tenant compte de la priorité.
Sans seuil mémoire, il n’y a rien à franchir. La condition reste à False jusqu’à la dernière page libre, et c’est l’OOM-killer du noyau qui arbitre, par oom_score. Il ne connaît ni les classes QoS, ni les requests, ni l’importance d’un pod. Sur un worker qui porte des répliques Longhorn, tuer le mauvais processus en pleine écriture n’est pas qu’une gêne.
La formule que le kubelet applique quand on lui donne enfin des chiffres :
allocatable = capacity − kube-reserved − system-reserved − eviction-hard
La réserve système fait de la place à ce qui vit hors des pods. Le seuil d’éviction garde une marge entre « le scheduler a tout distribué » et « le noyau commence à tuer ».
Les réglages : trois profils pour six nœuds
Je retiens trois profils plutôt qu’un réglage unique : un seuil de 200 Mi n’a pas le même sens sur 16 Go que sur 2 Go.
| Profil | eviction-hard mémoire | minimum-reclaim | system-reserved | allocatable avant → après |
|---|---|---|---|---|
| 3 masters Pi 4 8 Go | <200Mi | 100Mi | 512Mi | 8007324Ki → 7278236Ki (−712 Mi) |
| 2 workers Pi 5 16 Go | <200Mi | 100Mi | 256Mi | −456 Mi |
| 1 worker Pi 4 2 Go | <100Mi | 50Mi | 128Mi | 1888960Ki → 1655488Ki (−228 Mi) |
Les masters reçoivent la plus grosse réserve, 512 Mi, parce qu’etcd et k3s server y vivent en dehors du cgroup des pods. Les workers Pi 5 n’ont besoin que de 256 Mi pour k3s-agent et systemd. Quant au petit worker, sa capacité réelle n’est que de 1,80 Gi : tout est divisé par deux.
-712 Mi · 9,1 % de la capacité 9.1% of capacity
3 nœuds · etcd + k3s server hors pods 3 nodes · etcd + k3s server outside pods
-456 Mi · 2,8 % de la capacité 2.8% of capacity
2 nœuds · répliques Longhorn 2 nodes · Longhorn replicas
-228 Mi · 12,4 % de la capacité 12.4% of capacity
1 nœud · DaemonSets surtout 1 node · mostly DaemonSets
MemoryPressure ne peut jamais passer à true et c'est l'OOM-killer qui tranche.
Après, allocatable = capacity − system-reserved − eviction-hard.
Segments sous 3 % élargis pour la lisibilité.
Before, the bar is full: no memory threshold, so
MemoryPressure can never become true and the OOM killer decides.
After, allocatable = capacity − system-reserved − eviction-hard.
Segments under 3% are widened for readability.
eviction-minimum-reclaim mérite une ligne d’explication. Quand une éviction se déclenche, le kubelet ne s’arrête pas pile au seuil : il récupère au moins ce montant en plus. Sans ce paramètre, un nœud qui oscille autour de 200 Mi évincerait un pod, remonterait à 201 Mi, puis recommencerait dix secondes plus tard.
Le piège : eviction-hard remplace, il n’ajoute pas
K3s lit nativement /etc/rancher/k3s/config.yaml, côté server comme côté agent, sans toucher aux unités systemd. Le répertoire n’existait même pas : c’est le premier config.yaml du cluster. Jusque-là, tout tournait avec les défauts de K3s.
Voici le profil master :
# /etc/rancher/k3s/config.yaml — profil master (Pi 4 8 Go)
kubelet-arg:
- "eviction-hard=memory.available<200Mi,imagefs.available<5%,nodefs.available<5%"
- "eviction-minimum-reclaim=memory.available=100Mi,imagefs.available=10%,nodefs.available=10%"
- "system-reserved=memory=512Mi"
Le piège tient dans la première ligne. Passer eviction-hard en kubelet-arg remplace toute la liste par défaut de K3s. La version naïve, eviction-hard=memory.available<200Mi seule, ajoute la protection mémoire et supprime du même coup les deux seuils disque. On gagne un filet et on en perd deux, sans le moindre avertissement. Il faut donc ré-inclure explicitement imagefs.available<5% et nodefs.available<5%. La même logique vaut pour eviction-minimum-reclaim, où j’ai remis les 10 % disque.
Deuxième choix volontaire : system-reserved reste comptable. enforceNodeAllocatable garde sa valeur ["pods"], et je n’ajoute pas de --system-reserved-cgroup. Les processus système ne sont pas enfermés dans un cgroup plafonné, donc aucun risque de voir etcd tué pour avoir dépassé 512 Mi. La réserve sert à ce que le scheduler arrête de distribuer de la mémoire qui n’existe pas. C’est le mode recommandé, et le seul que j’ai envie d’expliquer à 2 h du matin.
Le rolling : canari, masters, puis workers
Modifier la config kubelet implique de redémarrer k3s (ou k3s-agent) sur chaque nœud. containerd n’est pas touché : les conteneurs continuent de tourner, et le nœud passe NotReady pendant 10 à 30 secondes environ, le temps que le kubelet revienne. Sur un cluster avec un etcd à trois membres et des volumes répliqués, l’ordre compte quand même.
-
Canari : le petit worker Pi 4 de 2 Go. Il ne porte aucune réplique Longhorn, seulement des DaemonSets. S’il refuse de redémarrer, rien d’important ne tombe. Je vérifie deux choses : que la syntaxe passe (le kubelet démarre) et que l’allocatable correspond au calcul, 1655488Ki.
-
Les trois masters, un par un. Entre chaque nœud, une gate stricte : 6/6 nœuds
Ready, et etcd sain vu de l’API server.kubectl get --raw '/readyz?verbose' | grep etcd # [+]etcd ok # [+]etcd-readiness okRedémarrer deux masters à la fois, c’est jouer le quorum etcd à pile ou face. Un à la fois, avec cette vérification entre chaque, le quorum n’est jamais menacé.
-
Les deux workers Pi 5, l’un après l’autre. Ici, la gate porte sur le stockage : nœud
Ready, pods du nœudRunning, et tous les volumes Longhornhealthyavant de passer au second.kubectl -n longhorn-system get volumes.longhorn.io \ -o custom-columns=NAME:.metadata.name,ROBUSTNESS:.status.robustness
Le rollback par nœud tient en une ligne : supprimer le fichier et redémarrer le service.
sudo rm /etc/rancher/k3s/config.yaml && sudo systemctl restart k3s # master
sudo rm /etc/rancher/k3s/config.yaml && sudo systemctl restart k3s-agent # worker
Il n’a pas servi. Bilan : zéro pod redémarré, les six allocatables conformes au calcul, et aucun pod devenu unschedulable. Le worker le plus chargé garde encore 6,8 Gi de marge entre la somme des requests et son nouvel allocatable. Rolling terminé, tout est vert… presque.
L’incident : Permission denied (publickey), avec 20 jours d’uptime
Peu après le redémarrage du kubelet sur le premier worker Pi 5, les push git vers Gitea échouent. La CI aussi, pour la même raison :
git@<gitea-host>: Permission denied (publickey).
fatal: Could not read from remote repository.
Le lien avec le rolling n’a rien d’évident. Le pod Gitea n’a pas redémarré, et sshd non plus : 20 jours d’uptime chacun. La clé n’a pas changé, l’interface web répond. Pourtant, toutes les clés sont refusées.
Les logs de sshd dans le pod donnent la vraie piste : « bad ownership or modes ». OpenSSH, avec StrictModes activé par défaut, refuse de lire un authorized_keys que quelqu’un d’autre que son propriétaire peut modifier. Il vérifie aussi les répertoires parents. Les permissions sur le volume :
/data/git/.ssh/authorized_keysen 660 au lieu de 600 ;- le home
/data/giten 2775, donc writable par le groupe et avec le bit setgid.
Quelqu’un a fait un chmod -R g+w sur le PVC, et ce quelqu’un, c’est le kubelet. Le Deployment Gitea déclare fsGroup: 1000 sans préciser fsGroupChangePolicy, donc avec la politique par défaut Always. À chaque (ré)attachement, le volume manager du kubelet parcourt alors tout le volume pour imposer le groupe et le droit d’écriture du groupe. Au redémarrage du kubelet, en reconstruisant l’état des volumes montés, il a ré-appliqué ce fsGroup récursivement. Pour la plupart des applications, c’est inoffensif. Pour un sshd en StrictModes, ça revient à fermer la porte à clé.
La réparation à chaud est immédiate, directement dans le pod :
chmod 700 /data/git/.ssh
chmod 600 /data/git/.ssh/authorized_keys
chmod g-w /data/git
Les push repassent aussitôt. Reste à rendre la correction durable, sinon le prochain redémarrage de kubelet rejouera exactement la même scène.
Le correctif durable : OnRootMismatch et un init container idempotent
Deux changements dans le manifest de Gitea :
initContainers:
- name: fix-ssh-permissions
image: busybox
command: ['sh', '-c', 'mkdir -p /data/git/.ssh && chown -R 1000:1000 /data/git/.ssh && chmod 700 /data/git/.ssh && { [ ! -f /data/git/.ssh/authorized_keys ] || chmod 600 /data/git/.ssh/authorized_keys; } && chmod g-w /data/git']
securityContext:
runAsUser: 0
# ...
securityContext:
fsGroup: 1000
fsGroupChangePolicy: OnRootMismatch
fsGroupChangePolicy: OnRootMismatch: le kubelet ne parcourt le volume que si la racine n’a pas déjà le bon groupe. Une fois le volume en place, plus dechmodrécursif à chaque attachement. En prime, les montages vont plus vite sur un gros volume.- L’init container durci existait déjà pour créer
.ssh. Il impose désormais aussi 600 surauthorized_keys(s’il existe) et retire leg+wdu home. Il est idempotent : à chaque démarrage, il remet l’état attendu parsshd, quelle que soit la cause de la dérive.
Le Deployment est en stratégie Recreate : appliquer le correctif coûte environ une minute d’indisponibilité git et web, assumée.
Leçons retenues
- Auditez
configz, pas vos souvenirs./api/v1/nodes/<node>/proxy/configzmontre ce que le kubelet a réellement chargé. Ici, il révélait un cluster sans filet mémoire depuis le premier jour. Vérifiez aussiallocatablecontrecapacity: s’ils sont égaux, rien n’est réservé. - Sur K3s,
eviction-hardremplace la liste par défaut. Ré-incluez toujoursimagefs.availableetnodefs.available, et faites de même poureviction-minimum-reclaim. Le piège est silencieux : le kubelet démarre sans broncher avec une protection disque en moins. - Dimensionnez par profil de machine. Un seuil et une réserve pensés pour 16 Go sont démesurés sur 2 Go, et l’inverse est trop juste. Trois profils pour six nœuds, c’est trois fichiers à maintenir, pas une usine.
- Une réserve comptable suffit pour commencer. Garder
enforceNodeAllocatable=["pods"]donne au scheduler une vision honnête sans risquer de tuer etcd ou systemd sur un plafond de cgroup mal calibré. - Ordonnez le rolling par risque, avec une gate mesurable. D’abord le nœud qui ne porte rien, puis les masters avec etcd ok, puis les nœuds de stockage avec Longhorn healthy. Gardez aussi le rollback en une ligne prêt avant de commencer.
- Un redémarrage de kubelet n’est pas neutre pour les volumes. Avec
fsGroupet la politique par défautAlways, il peut ré-appliquer unchmodrécursif sur des volumes déjà montés. Tout ce qui vérifie ses permissions (sshd, certaines bases de données, des clés TLS) mérite unfsGroupChangePolicy: OnRootMismatchet un init container qui rétablit l’état attendu. - Méfiez-vous des « uptime 20 jours ». Un processus intact n’est pas une preuve que son environnement l’est. Le symptôme était côté SSH, la cause côté kubelet, et l’élément déclencheur un changement qui n’avait rien à voir avec Gitea.
Depuis, une saturation mémoire produit ce qu’elle aurait dû produire dès le départ : MemoryPressure=True, un taint, et une éviction ordonnée par QoS. L’OOM-killer reste dans le noyau, mais il n’est plus le premier à intervenir.