J'ai mis K8s dans 512 Mo de RAM

Et il restait de la place pour

Contexte

Denis Germain 👨‍💻🧙🔥🏃‍♂️

  • Platform Engineer(ing Manager)
    • (Deezer, Lectra, E.Leclerc)
  • Admin k8s "the hard way" depuis 2017
  • Blog tech (et +) : blog.zwindler.fr
  • Auteur ? 😏

@zwindler.fr

Auteur d'un livre !

Kubernetes : 50 solutions pour les postes de développement et les clusters de production aux éditions Eyrolles

center

Kubernetes, une usine à gaz ?

  • OpenShift : 6 machines et ~100 Gio de RAM juste pour démarrer 🥲
  • k3s / k0s :
    • "seulement" 500 Mio de RAM (sur le papier)
    • Raspberry Pi 3/4/5 : ça passe ✅

center

Raspberry Pi Zero 2 W

  • ~20 $, une grosse "clé USB"
  • 4 cœurs Cortex-A53 arm 64 bits à 1 GHz
  • ~100 mA au repos
  • wifi 2,4 GHz uniquement
  • stockage sur carte microSD
  • 512 Mio de RAM

Pas le premier à essayer

A tiny cluster based on 4x Raspberry Pi Zero 2 W

Was planning for k3s. But so far I haven't been able to get it up and running, so I'm experimenting with OpenFaaS.

KubeSolo

Runs in <200MB RAM, perfect for IoT/edge devices.

  • Portainer, fork mono-noeud de k3s : clustering et HA retirés, défauts réglés pour le matériel contraint
  • Contrairement à KubeEdge : pas de control plane distant, fonctionne avec un réseau instable
    center

La V1 : un article

Un cluster Kubernetes sur Raspberry Pi Zero 2W, vraiment ?

Sysops Pratique (ex Linux Pratique) n°154, mars / avril 2026, p. 44

  • Ça marche... de justesse
  • DOOM tourne, un niveau sans crash

Ce talk : la V2. Tout ce que j'ai appris depuis (et quelques petites erreurs)

Plan de bataille

  1. Dégraisser l'OS 🦴
  2. Installer Kubernetes
  3. Faire tourner des workloads
  4. Kubernetes en edge : bonne idée ?

1. Dégraisser l'OS

Varus, rends-moi mes 512 Mio de RAM !

  • Raspberry Pi OS Lite (Debian 13 trixie), juste après installation
  • Seulement 416 Mio visibles, 142 Mio déjà utilisés

center

La mémoire du GPU

  • 64 Mio réservés au GPU par défaut, inutile en headless
  • gpu_mem=16 dans /boot/firmware/config.txt : +48 Mio
  • Le firmware charge alors start_cd.elf / fixup_cd.dat
    • 💡 Absents sur Alpine : la LED clignote 4 fois, pas de boot

center

Un node headless n'a pas besoin de...

  • services : avahi, bluetooth, cron, timers, cloud-init, gettys
  • matériel : son, Bluetooth, GPU (vc4-kms-v3d)
  • détection caméra et écran au boot
  • modules caméra et codec vidéo

Une connexion SSH = un systemd de plus

  • pam_systemd : un systemd --user dès qu'on se connecte
  • inutile ici : +9 Mio
$ ps -eo rss,user,args | grep -E 'systemd --user|sd-pam|systemd-logind'
 7940 root     /usr/lib/systemd/systemd-logind
12256 zwindler /usr/lib/systemd/systemd --user
 3844 zwindler (sd-pam)

Petites erreurs dans l'article

  • vm.swappiness=10 "pour éviter de swapper sur la microSD"
  • Sur trixie, la swap n'est pas sur la SD : c'est zram (RAM compressée)
  • Avec zram, au contraire, swapper tôt : swappiness=180, page-cluster=0
  • Et couper le writeback zram vers la SD

La mesure de l'article

center

MemAvailable "le mytho"

  • min_free_kbytes : réserve d'urgence du kernel (16 Mio sur Raspberry Pi)
  • MemAvailable : une estimation, avec des marges calculées sur cette réserve
  • réserve de 16 à 8 Mio
    • +8 Mio réels, +25 Mio affichés
  • sommes de RSS : bibliothèques partagées comptées plusieurs fois
  • mieux : memory.current par cgroup

NetworkManager

  • ~20 Mio de RSS, et 19 s de boot à lui seul
  • Remplacé par wpa_supplicant + dhcpcd
  • Pièges : netplan, pas de systemd-resolved
  • Pas d'Ethernet sur le Zero : une erreur = retour à la carte SD

Bonus : boot de 27 à 13 s avec ce seul changement

Le device tree qui désactive les cgroups

L'install de KubeSolo passe, mais aucun pod ne démarre :

failed to create shim task: OCI runtime create failed: [...]
/sys/fs/cgroup/kubepods/burstable/[ID-DU-POD]/coredns/memory.max: no such file or directory
$ cat /sys/fs/cgroup/cgroup.subtree_control
cpuset cpu io pids

Le coupable : bcm2710-rpi-zero-2-w.dtb

$ grep -R cgroup_disable=memory /boot/firmware/
bcm2710-rpi-zero-2-w.dtb: coherent_pool=1M [...] cgroup_disable=memory
  • Sur ARM, pas de découverte du matériel comme sur PC (ACPI, PCI)
    • le firmware donne au kernel une description de la carte, le device tree (.dtb, un par modèle)
  • Parfois, il contient aussi des paramètres de boot, que le firmware place avant ceux de cmdline.txt
  • Plutôt que de recompiler le .dtb : cgroup_enable=memory dans cmdline.txt, le dernier paramètre gagne

Bilan OS

462 +46 86 -56 13 s -21 s
Mio visibles Mio utilisés de boot

D'où vient la RAM gagnée ?

center

2. Installer Kubernetes

curl | sudo sh

  • La doc officielle : curl -sfL https://get.kubesolo.io | sudo sh -
  • Ici : build offline (images embarquées), installeur épinglé, SHA-256 vérifiés

RIP l'effet bonaldi

Le garbage collector de Go

  • KubeSolo est en Go et ne fixe ni GOGC ni GOMEMLIMIT
  • GOMEMLIMIT : le GC accélère à l'approche de la limite
Réglage MemAvailable (KubeSolo + CoreDNS) CPU au repos
défaut 118 Mio
--no-local-storage 133 Mio 11 %
+ GOMEMLIMIT=180MiB 182 Mio 12-15 %
+ GOMEMLIMIT=160MiB 197 Mio 65-79 %, GC thrashing

180 Mio : FBI, Fausse Bonne Idée

  • Au repos : ~110 Mio de heap + les stacks de ~4300 goroutines, quelques Mio de marge
  • Quelques pods de plus : GC 4/5 fois par seconde, 4 cœurs à 100 %
GOMEMLIMIT MemAvailable au repos Sous charge
180 Mio 182 Mio GC thrashing, 3,9 cœurs
220 Mio ~145 Mio GC calme, 0,3 à 0,8 cœur

Gagner de la RAM au repos, c'est bien.
Garder de la marge pour travailler, c'est mieux 🥲.

La RAM que les pods ne verront jamais

  • Seuil d'éviction du kubelet : memory.available<100Mi (upstream)
  • Non configurable dans KubeSolo
    • Fichier de config kubelet réécrit à chaque démarrage
  • Sur 462 Mio : plus de 20 % de la RAM ne sert jamais aux pods

3. Des workloads

nginx

resources:
  requests: { memory: 8Mi, cpu: 50m }
  limits:   { memory: 32Mi }
$ curl http://<pi>:30080
Hello from KubeSolo on a Raspberry Pi Zero 2 W

Does it run ?

  • r/itrunsdoom : robots cuisine, PDF, téléphones VoIP...
  • V1 : storax/kubedoom, les monstres sont des pods
    • DOOM + Xvfb + serveur VNC
    • ~50 Mio

c'est toute la RAM restante 💀

doom-vnc

  • doomgeneric + LibVNCServer
  • Pas de X, pas de SDL : un binaire statique de 1,1 Mio
  • image FROM scratch
  • En pause sans client VNC
kubedoom doom-vnc
Image compressée 126 Mio 2,4 Mio
Démarrage à chaque boot 4 min 38 s de pull quelques secondes
RAM sans client ~32 Mio 2,5 Mio
CPU sans client 45-50 % 0 %

Kubernetes, c'est fait pour scaler...

Est-ce qu'on peut scaler DOOM ?

4. Kubernetes en edge : bonne idée ?

Mais pourquoi tu fais ça ???

Do we recommend running Kubernetes on embedded devices?
No. Not unless you absolutely need to.
But for those edge environments where container orchestration is a hard requirement and the hardware is brutally constrained, KubeSolo gives you a fighting chance.
-- kubesolo.io/about

Est-ce que ça a du sens ?

ok tier :

  • pour une flotte de devices industriels ✅
  • sur un réseau instable ✅
  • et rester dans un écosystème cohérent malgré l'IoT ✅

nope :

  • pour une seule app 💀
  • plus de la moitié de la RAM juste pour faire tourner un k8s bridé 💀

Takeaways

  1. Les valeurs par défaut sont calibrées pour des Raspberry Pi plus puissants (min_free_kbytes, éviction, GC)
  2. Douter, mesurer : RSS, MemAvailable, et même son propre article
  3. Kubernetes est-il l'usine à gaz que l'on croit ?

@zwindler.fr

Merci !

github.com/zwindler/kubesolo-pizero

Backup

KubeSolo : les bugs rencontrés

Bug en v1.2.0 Corrigé
flags booléens refusés 1.2.1
images effacées à chaque démarrage 1.2.1
arrêt bloqué jusqu'au SIGKILL 1.2.2
local-path : 16 691 fichiers sur la SD 1.2.1
seuil d'éviction non configurable toujours là

Redémarrage : 3 min 20 vers 95 s

Scaler DOOM

center

Jusqu'où ?

DOOM demandés Running MemAvailable swap zram
14 14 86 Mio 83 Mio
20 20 76 Mio 138 Mio
22 21 81 Mio 154 Mio
  • bloqué par les requests, pas par la RAM
  • zram absorbe : aucune éviction

Budget (45 min questions incluses) : - Intro : 4 min - Partie 1 OS : 15 min - Partie 2 KubeSolo : 8 min - Partie 3 workloads : 6 min - Conclusion : 4 min - Questions : 8 min Règle : pendant les attentes (reboot, install), on est sur les slides, jamais sur le terminal.

LIVE 1 : ./demo/10-os-tuning.sh show

LIVE 2 : lsmod | wc -l, systemctl list-timers puis ./demo/10-os-tuning.sh services config.txt : dtparam=audio=off (pilote son), dtoverlay=disable-bt (Bluetooth), vc4-kms-v3d commenté (pilote graphique du GPU, affichage et 3D), camera_auto_detect=0 et display_auto_detect=0 (le firmware ne cherche plus de caméra CSI ni d'écran DSI), max_framebuffers=1. Blacklist : bcm2835_codec / _isp / _v4l2 / _mmal_vchiq (encodage vidéo matériel, traitement d'image caméra), chargés même sans caméra. V1 : avahi, polkit, ModemManager, bluetooth (-10 à -12 Mo).

LIVE 3 : ps -eo pid,user,rss,comm | grep -E "systemd|sd-pam" puis ./demo/10-os-tuning.sh sessions Reboot ensuite avec ./demo/10-os-tuning.sh reboot (systemctl reboot se plaint de logind)

Sources : - doc kernel, sysctl/vm (swappiness) : "For in-memory swap, like zram or zswap [...] values beyond 100 can be considered". Swappiness = coût relatif swap / système de fichiers (0 à 200, défaut 60) ; swap 2x plus rapide que le FS donne 133. Avec une SD lente, on irait vers ~200. - doc kernel, page-cluster : lecture anticipée en swap (3 = 8 pages). Plus bas = latence du premier défaut plus faible. Avec zram, pas de temps d'accès à amortir, chaque page lue en avance est décompressée pour rien. - wiki Arch, Zram, "Optimizing swap on zram" : swappiness 180, page-cluster 0, watermark_boost_factor 0, watermark_scale_factor 125. - Pop!_OS, usr/bin/pop-zram-config : swappiness 180, page-cluster 0 avec zstd (la zram du Raspberry Pi est en zstd). Tests d'utilisateurs Fedora cités par Arch. Le script met 180 comme Arch et Pop!_OS. Les gains mémoire du README ont été mesurés avec 100, la swappiness ne change rien au repos.

Somme des RSS par process : c'est cette méthode qui surestime, slide suivante.

LIVE 4 : sudo sysctl vm.min_free_kbytes vm.swappiness vm.page-cluster puis ./demo/10-os-tuning.sh memory min_free_kbytes : mémoire que le kernel garde toujours libre pour ses allocations urgentes (interruptions, réseau, le pilote wifi brcmfmac). Défaut du kernel ~3 Mo pour 512 Mo, Raspberry Pi OS impose 16 Mo. Baisser de 16 à 8 Mo libère réellement 8 Mo. MemAvailable est l'estimation de ce qu'une appli peut obtenir sans swapper. Par prudence, il retire des marges dérivées de min_free_kbytes : le seuil high (~1,5 x, 24 puis 12 Mo) de la mémoire libre, le seuil low (~1,25 x, 20 puis 10 Mo) du cache récupérable. Les marges fondent avec la réserve : +12 +10 + quelques Mo, soit ~+25. Image : la banque garde 16 euros de côté et l'appli affiche le disponible en retirant 1,5 fois cette réserve plus une autre marge. Réserve à 8 : 8 euros de plus à dépenser, 25 de plus affichés. Coefficients à confirmer dans /proc/zoneinfo (min, low, high) sur un Pi. Risque si on descend trop bas : échecs d'allocation du wifi dans dmesg.

Appliqué avec l'étape boot, après l'enquête cgroups (même reboot).

LIVE 5 (l'enquête) : cat /sys/fs/cgroup/cgroup.subtree_control, cat /proc/cmdline, grep -a cgroup_disable /boot/firmware/*.dtb

LIVE 5 suite : ./demo/10-os-tuning.sh boot network puis ./demo/10-os-tuning.sh reboot Pendant le reboot (~15 s) : slide Bilan OS. Si rien ne revient au bout d'une minute : débrancher / rebrancher.

LIVE 6 : ./demo/10-os-tuning.sh confirm show (tout de suite, sinon rollback au bout de 10 min) Image d'origine : 416 visibles, 142 utilisés, ~274 disponibles, boot 34 s. V1 (article) : 464 / ~129 / ~334. V2 : 462 / 86 / 376. Le bilan montre visible et utilisé, mesurés par free, pas MemAvailable (estimation, gonflée de ~17 Mo par min_free_kbytes : ~85 Mo réels sur les +102 affichés). En live, used ~107 et available ~355 avec les sessions SSH et sudo.

Gains réels étape par étape. min_free_kbytes compté à +8 Mo (la réserve libérée), pas les +25 affichés par MemAvailable. La somme des barres (~85 Mo) est donc inférieure aux +102 de MemAvailable du bilan (274 vers 376), qui incluent l'effet de calcul. gpu_mem reste de loin le plus gros gain.

LIVE 7 : ./demo/20-kubesolo.sh (~3 min) puis revenir aux slides pendant l'install

Mesuré pendant la préparation de la démo "scaler DOOM" : la grille ramait, la cause était KubeSolo et pas DOOM. Mesure à froid (16/10, Pi neuf, KubeSolo 1.2.2, CoreDNS + nginx + DOOM, une session SSH) : ~141 Mio.

LIVE 8 : ./demo/fetch-kubeconfig.sh <user>@<pi> puis kubectl get nodes,pods -A

LIVE 9 : kubectl apply -f demo/manifests/nginx.yaml puis curl http://<pi>:30080 Un Deployment, une ConfigMap (nginx.conf, worker_processes 1), un Service NodePort.

LIVE 10 : kubectl apply -f demo/manifests/doom.yaml puis vnc://<pi>:30900 (mot de passe idbehold) En jeu : ~4 Mio de RAM, 26 fps en ZRLE, 58 % d'un coeur

LIVE 11 : démo 11 du menu (doom-grid à 9 replicas, une fenêtre VNC par pod, en grille 3x3). Entrée pour tout ranger à la fin. Capture de secours : slide de backup "Scaler DOOM" après Merci.

Oui : gérer une flotte comme un seul système (même API, mêmes manifests, GitOps jusqu'au bord) ; tout tourne en local, pas de control plane distant (contrairement à KubeEdge) ; l'équipe connaît déjà Kubernetes. Non : une app sur une machine, systemd + Podman suffisent ; la RAM est le produit ; stockage lent (SD) pour les images et kine ; rien à orchestrer (scaler DOOM sur un seul nœud...). Le chiffre : MemAvailable 376 Mio après le tuning OS, ~141-155 Mio avec KubeSolo 1.2.2 (GOMEMLIMIT 220 MiB), CoreDNS, nginx et DOOM.

Issues : #188 (flags, remplacés par /etc/kubesolo/config.yaml), #197 / #199 (images et pods perdus au redémarrage), #207 (arrêt), #195 (local-path). local-path : plus de 5 min à décompresser, au-delà du timeout de 2 min du kubelet. v1.2.2 apporte aussi les upgrades en place avec rollback automatique. Testée sur le Pi de secours : 7 min, pods conservés (0 restart).

Capture du 16/10 : 9 replicas de doom-grid sur le Pi neuf, KubeSolo 1.2.2, GOMEMLIMIT 220 MiB. Fluide et jouable.

Test du 21/10 sur kubedoom-backup (KubeSolo 1.2.2, GOMEMLIMIT 220 MiB), DOOM en pause (sans client VNC), nginx et DOOM seul à 0. À 22 : le kubelet refuse à l'admission (OutOfmemory) : "requested 16 Mio, used 356 Mio, capacity 362 Mio". Somme des requests > allouable du node, alors que 81 Mio restent disponibles. Le ReplicaSet recrée les pods refusés en boucle (157 en quelques minutes). Jamais de MemoryPressure : les pages des DOOM en pause partent en zram, MemAvailable reste entre 76 et 102 Mio. GC de KubeSolo calme jusqu'à 20 (0,13/s). En jeu (fenêtres VNC ouvertes), c'est le CPU et le réseau qui limitent : 9 testé, fluide.