diff --git a/ansible/arcodange/factory/playbooks/system/earlyoom.yml b/ansible/arcodange/factory/playbooks/system/earlyoom.yml new file mode 100644 index 0000000..f2a71d8 --- /dev/null +++ b/ansible/arcodange/factory/playbooks/system/earlyoom.yml @@ -0,0 +1,122 @@ +--- +# Filet mémoire des Raspberry Pi : tuer net plutôt que thrasher. +# +# Motivation (incidents 2026-07-23 et 2026-08-15, tous deux causés par le build +# CMS) : les Pi tournent SANS swap, et sur un noyau sans swap un dépassement +# mémoire ne produit PAS forcément un OOM-kill. Le noyau part en thrash — il +# recycle sans fin le page cache pour retrouver quelques pages — et la machine +# reste vivante mais inutilisable pendant des heures. Mesuré le 2026-08-15 sur +# pi1 : load 293, 175 Mio disponibles sur 8 Gio, sshd incapable de forker +# (« kex_exchange_identification: Connection reset »), apiserver k3s et traefik +# affamés, donc tout *.arcodange.lab injoignable — Gitea compris, alors qu'il +# tourne sain sur pi2. Aucune auto-récupération : il a fallu ~50 min. +# +# earlyoom surveille MemAvailable en espace utilisateur et déclenche AVANT que +# le noyau ne se bloque : SIGTERM au premier seuil, SIGKILL au second. C'est +# volontairement le choix « kill direct » plutôt que zram/swap : +# - pas de swap à ajouter, donc aucun conflit avec le --fail-swap-on de k3s ; +# - un build qui déborde meurt en quelques secondes au lieu de coucher le lab. +# +# Avec SwapTotal = 0, la condition swap d'earlyoom est toujours satisfaite : +# seul le seuil mémoire ci-dessous pilote réellement le déclenchement. + +- name: Filet mémoire earlyoom (tuer net, sans swap) + hosts: raspberries:&local + become: yes + gather_facts: yes + + vars: + # Seuils en % de MemAvailable : SIGTERM,SIGKILL. + # Sur les Pi de 8 Gio → SIGTERM sous ~805 Mio, SIGKILL sous ~402 Mio. + # Repère : pendant l'incident du 2026-08-15, pi1 est descendu à 175 Mio et + # a stagné autour de 332 Mio — les deux seuils auraient été franchis. + earlyoom_mem_thresholds: "10,5" + + # Intervalle du rapport mémoire dans syslog (secondes) : utile en post-mortem. + earlyoom_report_interval: 3600 + + # Processus à épargner. earlyoom compare au NOM du processus (/proc/PID/comm), + # tronqué à 15 caractères — d'où « longhorn-manage » et non + # « longhorn-manager ». Tuer l'un de ceux-là aggraverait la panne ou nous + # couperait l'accès distant. On y ajoute postgres et gitea : sur pi2 ils + # tournent en docker compose hors k3s et portent les données du lab, alors + # que les jobs CI qui débordent vivent sur les runners de pi1 et pi3. + # Attention, --avoid est une préférence (déprioriser), pas une immunité : + # si rien d'autre ne peut être tué, earlyoom les prendra quand même. + earlyoom_avoid_regex: '^(k3s-server|k3s|k3s-agent|containerd|dockerd|sshd|systemd|pihole-FTL|longhorn-manage|iscsid|postgres|gitea|earlyoom)$' + + # Processus à sacrifier en priorité : les outils de build. + # ATTENTION au premier motif : le `nuxt generate` qui a couché pi1 se + # présentait sous le nom « MainThread » et non « node » (Node.js renomme son + # thread principal). Sans lui, --prefer serait resté sans effet sur le + # coupable réel. Vérifié dans `ps -eo comm` le 2026-08-15. + earlyoom_prefer_regex: '^(MainThread|node|npm|npx|esbuild|vite|rollup|tsc|webpack|cc1|cc1plus|ld|go|rustc|cargo)$' + + tasks: + + - name: Installer earlyoom + ansible.builtin.apt: + name: earlyoom + state: present + update_cache: yes + + - name: Configurer les seuils et les préférences de victime + ansible.builtin.copy: + dest: /etc/default/earlyoom + owner: root + group: root + mode: '0644' + content: | + # Generated by Ansible – playbooks/system/earlyoom.yml + # Ne pas éditer à la main : toute modification sera écrasée. + EARLYOOM_ARGS="-m {{ earlyoom_mem_thresholds }} -r {{ earlyoom_report_interval }} --avoid '{{ earlyoom_avoid_regex }}' --prefer '{{ earlyoom_prefer_regex }}'" + notify: Restart earlyoom + + - name: Activer et démarrer earlyoom + ansible.builtin.systemd: + name: earlyoom + state: started + enabled: yes + daemon_reload: yes + + # Appliquer la conf avant de vérifier : sans ça, le premier passage + # validerait le service démarré avec les arguments par défaut du paquet. + - name: Appliquer la configuration en attente + ansible.builtin.meta: flush_handlers + + - name: Relire l'état du service + ansible.builtin.systemd: + name: earlyoom + register: earlyoom_service + + - name: Vérifier qu'earlyoom tourne + ansible.builtin.assert: + that: + - earlyoom_service.status.ActiveState == "active" + fail_msg: >- + earlyoom n'est pas actif sur {{ inventory_hostname }} + (ActiveState={{ earlyoom_service.status.ActiveState }}). + success_msg: "earlyoom actif sur {{ inventory_hostname }}" + + # Le filet ne vaut que si les seuils sont réellement ceux qu'on croit : on + # relit la ligne de commande du processus vivant, pas le fichier de conf. + - name: Relire les arguments du processus earlyoom + ansible.builtin.command: pgrep -a earlyoom + register: earlyoom_cmdline + changed_when: false + + - name: Vérifier que les seuils configurés sont bien appliqués + ansible.builtin.assert: + that: + - "'-m ' ~ earlyoom_mem_thresholds in earlyoom_cmdline.stdout" + fail_msg: >- + earlyoom tourne sans les seuils attendus sur {{ inventory_hostname }} : + {{ earlyoom_cmdline.stdout }} + success_msg: "seuils -m {{ earlyoom_mem_thresholds }} appliqués sur {{ inventory_hostname }}" + + handlers: + + - name: Restart earlyoom + ansible.builtin.systemd: + name: earlyoom + state: restarted