feat(system) — earlyoom sur les 3 Pi : tuer net au lieu de thrasher pendant 3 h #57

Merged
arcodange merged 2 commits from arcodange/earlyoom-filet-memoire into main 2026-08-16 03:12:23 +02:00
2 changed files with 127 additions and 0 deletions
@@ -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
@@ -3,6 +3,11 @@
- name: Setup général des rpis
ansible.builtin.import_playbook: rpi.yml
# Avant docker et k3s : le filet mémoire doit être en place avant les
# consommateurs qu'il est censé borner.
- name: Filet mémoire earlyoom
ansible.builtin.import_playbook: earlyoom.yml
- name: dns
ansible.builtin.import_playbook: ../dns/dns.yml