❯ infra · Running

Donner un vrai MemoryPressure à k3s : eviction-hard et réserves système en rolling sur 6 nœuds hétérogènes Giving k3s a Real MemoryPressure: Eviction Thresholds and System Reserves, Rolled Out Across Six Mismatched Nodes

Par défaut, K3s ne pose aucun seuil d'éviction mémoire et ne réserve rien au système : MemoryPressure ne peut jamais se déclencher et c'est l'OOM-killer du noyau qui arbitre. L'audit, les trois profils de nœuds, le rolling avec canari — et le chmod récursif qui a cassé le SSH de Gitea au passage. Out of the box, K3s sets no memory eviction threshold and reserves nothing for the system, so MemoryPressure can never fire and the kernel OOM killer has the final word. Here is the audit, the three node profiles, the canary-first rolling rollout — and the recursive chmod that quietly broke Gitea's SSH along the way.

· 10 min de lecture 10 min read ·
k3s-clusterk8srpiinfraincidentgitea
🇬🇧 Read this article in English

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.

  1. Aucun seuil mémoire. K3s définit ses propres seuils d’éviction, uniquement pour le disque (5 % sur imagefs et nodefs).
  2. Le défaut Kubernetes est écarté. Upstream, le kubelet surveille memory.available<100Mi. Avec mergeDefaultEvictionSettings=false, la liste de K3s remplace celle de Kubernetes au lieu de s’y ajouter : le seuil mémoire disparaît.
  3. Rien n’est réservé. systemReserved et kubeReserved sont à 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.

Profileviction-hard mémoireminimum-reclaimsystem-reservedallocatable avant → après
3 masters Pi 4 8 Go<200Mi100Mi512Mi8007324Ki → 7278236Ki (−712 Mi)
2 workers Pi 5 16 Go<200Mi100Mi256Mi−456 Mi
1 worker Pi 4 2 Go<100Mi50Mi128Mi1888960Ki → 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.

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.

  1. 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.

  2. 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 ok
    

    Redé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é.

  3. Les deux workers Pi 5, l’un après l’autre. Ici, la gate porte sur le stockage : nœud Ready, pods du nœud Running, et tous les volumes Longhorn healthy avant 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_keys en 660 au lieu de 600 ;
  • le home /data/git en 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 de chmod ré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 sur authorized_keys (s’il existe) et retire le g+w du home. Il est idempotent : à chaque démarrage, il remet l’état attendu par sshd, 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

  1. Auditez configz, pas vos souvenirs. /api/v1/nodes/<node>/proxy/configz montre 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 aussi allocatable contre capacity : s’ils sont égaux, rien n’est réservé.
  2. Sur K3s, eviction-hard remplace la liste par défaut. Ré-incluez toujours imagefs.available et nodefs.available, et faites de même pour eviction-minimum-reclaim. Le piège est silencieux : le kubelet démarre sans broncher avec une protection disque en moins.
  3. 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.
  4. 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é.
  5. 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.
  6. Un redémarrage de kubelet n’est pas neutre pour les volumes. Avec fsGroup et la politique par défaut Always, il peut ré-appliquer un chmod ré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 un fsGroupChangePolicy: OnRootMismatch et un init container qui rétablit l’état attendu.
  7. 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.

k3s-clusterk8srpiinfraincidentgitea