HestiaCP: il playbook che ho riscritto tre volte” title=”Ansible su 17 host HestiaCP: il playbook che ho riscritto tre volte” loading=”eager” decoding=”async” />
Maggio 2025, lunedì mattina alle 8:14, un giro di ansible-playbook --diff --check sulla fleet Romiltec di 17 host HestiaCP mi restituisce un singolo cambiamento previsto: un parametro sysctl su un nodo, drift di un mese, niente di drammatico. È la versione tre del playbook, quella che gira oggi, ed è la prima volta da quando lo abbiamo scritto due anni fa che il giro completo dura sotto i 12 minuti e produce zero falsi positivi nel diff. Ansible 17 host hestiacp playbook, in un’azienda di cinque persone con un parco di hosting WordPress eterogeneo, è un esercizio che ho dovuto riscrivere tre volte per arrivare a qualcosa di sostenibile. Negli altri post di Production racconto le scelte di stack di Romiltec: questo è il postmortem del playbook che gestisce l’ossatura della fleet di hosting.
Il problema: 17 host, drift inevitabile, una source of truth che non c’era
La fleet Romiltec di hosting WordPress è cresciuta per accumulazione storica. Ogni cliente di hosting WP gravita su un host dedicato, per ragioni che combinano isolamento di sicurezza (una compromissione su un sito non contamina gli altri tenant), facilità di facturazione e migrazione, e una scelta consapevole di non condividere il kernel fra clienti diversi. Diciassette host significa diciassette installazioni di HestiaCP (versioni minor allineate ma non sempre identiche nel pomeriggio del rollout), diciassette pool nginx più PHP-FPM, diciassette stacks di backup configurati separatamente nel tempo da varie persone.
Il drift di configurazione è inevitabile in un parco così. Esempi reali, non drammatici, presi dall’audit del settembre 2023: un host aveva vm.swappiness=10, gli altri 16 lo avevano a 60 (default Debian). Tre host avevano regole fail2ban custom su wp-login.php che gli altri non avevano, perché gli erano state aggiunte ad-hoc durante un picco di brute force di un cliente specifico. Un host aveva restic con bucket Backblaze in zona US-West, gli altri in zona EU-Central, retaggio di una scelta di fornitore di tre anni prima.
Niente di rotto, ma cumulativamente uno scenario in cui “qual è la configurazione canonica dei nostri host?” non aveva una risposta scritta. La risposta vivente era nelle teste delle persone, e Romiltec è cinque persone. Servive una source of truth eseguibile.
V1: il playbook imperativo da 800 righe
L’autunno 2023, ho scritto la prima versione di un playbook Ansible centralizzato. Era brutalmente imperativo: un singolo file site.yml con tutto inline.
# site.yml v1 (estratto, non normativo)
- hosts: hestia_fleet
become: true
tasks:
- name: ensure swappiness 10
ansible.posix.sysctl:
name: vm.swappiness
value: '10'
state: present
- name: ensure fail2ban installed
apt:
name: fail2ban
state: present
- name: copy fail2ban jail wp-login
copy:
src: files/fail2ban/jail-wp-login.local
dest: /etc/fail2ban/jail.d/wp-login.local
owner: root
group: root
mode: '0644'
notify: restart fail2ban
- name: ensure restic installed
apt:
name: restic
state: present
# ...e così per altre 750 righe
Funzionava, in senso letterale: il playbook faceva ciò che chiedevo. Ma aveva tre problemi diventati subito dolorosi:
- Il “cosa fa veramente” non era ovvio. Per leggere se l’host aveva un certo comportamento, dovevo scorrere l’elenco. Nessuna astrazione, nessuna decomposizione per dominio.
- Il riuso era zero. Ogni nuovo task copiavo-incollavo lo stile dei precedenti.
- Il diff in
--checkera rumoroso. Task non perfettamente idempotenti su dettagli di permission o di ordering riportavano “changed” in dry run anche quando lo stato era corretto.
Tre mesi di vita per la V1, poi l’ho buttata.
V2: role per ogni servizio, ma duplicava la logica Hestia
L’inverno 2023-2024 ho riscritto il playbook con la struttura “role per ogni servizio gestito”: role nginx, role php, role mariadb, role system, role backup, role monitoring, role firewall. Layout standard di Ansible, niente di esotico:
roles/
system/
tasks/main.yml
handlers/main.yml
templates/sysctl.conf.j2
nginx/
tasks/main.yml
templates/server.conf.j2
php/
tasks/main.yml
templates/pool.conf.j2
mariadb/
tasks/main.yml
...
site.yml
inventory/
hosts.yml
group_vars/
hestia_fleet.yml
host_vars/
host01.yml
Sembrava la cosa giusta da fare. È quasi sempre la cosa giusta da fare, in un contesto greenfield. Su un parco gestito già da HestiaCP è stato il mio errore più costoso.
Il problema è che HestiaCP è già un orchestrator: gestisce nginx, PHP-FPM, MariaDB, mail, DNS, FTP, e li configura in un layout documentato (/etc/hestiacp/, le user-config sotto i directory standard di Hestia). Quando il mio role nginx provava a templare un server.conf personalizzato, andava in conflitto con i template che Hestia stessa rigenerava al successivo v-add-web-domain o v-rebuild-web-domains. Quando il role php definiva un pool PHP-FPM, Hestia ne creava un altro al primo dominio aggiunto, e il diff Ansible era infinito.
Risultato pratico: ogni ansible-playbook --diff produceva 30-40 righe di “changed” per host, di cui forse 2-3 erano veri drift e il resto erano falsi positivi causati dal duplicato di responsabilità fra Ansible e Hestia. Il segnale-rumore era talmente basso che gli alert “qualcosa è cambiato sull’host X” venivano ignorati. Quando un alert ignorato è la norma, il sistema di alerting è rotto.
Sei mesi di V2, poi anche questa l’ho buttata. La lezione (che ho impiegato troppo a interiorizzare) era: se hai già un orchestrator nel sistema, il tuo automation tool non deve provare a essere un secondo orchestrator. Deve negoziare con il primo.
V3: la convenzione “Hestia owns the sites, Ansible owns the infra”
Primavera 2024, ho riscritto la terza volta con un principio radicale e dichiarato:
Hestia owns the sites. Ansible owns the infra.
Tradotto operativamente: il playbook Ansible non tocca mai nulla che Hestia gestisce attivamente (nginx vhost, PHP-FPM pool, MariaDB user, dominio DNS, mailbox, FTP user). Tocca solo il livello sotto: kernel sysctl, fail2ban (regole custom non gestite da Hestia), utente di amministrazione di sistema dedicato per il team, restic + Backblaze B2 EU, agent Prometheus per la telemetria, certificati Caddy interni per qualche reverse proxy ausiliario.
I role della V3 sono cinque, non più sette. E sono tutti su layer che Hestia non occupa:
roles/
system_baseline/ # sysctl, hostname, sshd_config minimali, ntp
ops_user/ # utente sysadmin team con SSH key rotation
fail2ban_custom/ # solo regole custom, jail.d/*.local, mai jail principale
backup_restic/ # restic + B2 (zona EU, allineamento data residency UE)
observability/ # prometheus node-exporter, log shipper minimo
Il nuovo site.yml è banalmente una sequenza di chiamate ai role:
- hosts: hestia_fleet
become: true
roles:
- role: system_baseline
- role: ops_user
- role: fail2ban_custom
- role: backup_restic
- role: observability
Estratto del role system_baseline (semplificato, niente IP letterali, niente FQDN):
# roles/system_baseline/tasks/main.yml
- name: ensure sysctl baseline drop-in
ansible.builtin.template:
src: 99-romiltec-baseline.conf.j2
dest: /etc/sysctl.d/99-romiltec-baseline.conf
owner: root
group: root
mode: '0644'
notify: reload sysctl
- name: ensure sshd hardening drop-in (no overlap with Hestia config)
ansible.builtin.template:
src: 50-romiltec-hardening.conf.j2
dest: /etc/ssh/sshd_config.d/50-romiltec-hardening.conf
owner: root
group: root
mode: '0600'
notify: reload sshd
# roles/system_baseline/handlers/main.yml
- name: reload sysctl
ansible.builtin.command: sysctl --system
changed_when: false
- name: reload sshd
ansible.builtin.systemd:
name: ssh
state: reloaded
Due dettagli importanti, perché sono i punti dove ho perso tempo (ne riparlo sotto):
- Drop-in, non sostituzione di file principali. I template scrivono sotto
sysctl.d/esshd_config.d/, non insysctl.confosshd_config. Coesistenza pulita con quello che Hestia o Debian default hanno già. - Handler che chiama
sysctl --system. Modificare un drop-in non è sufficiente: il kernel non rilegge sysctl da solo. Senza l’handler, il file è corretto ma il kernel non riflette il valore.
Bug 1: idempotenza rotta su sysctl, run successivo non rifletteva il cambio
Il primo bug della V3 me lo sono fatto da solo, lunedì mattina di marzo 2024, dopo avere rilasciato il refactor.
Avevo dimenticato il notify: reload sysctl su un task che modificava il drop-in. Ansible scriveva il file, Ansible diceva “changed”, il giro terminava felice, ma sysctl --system non veniva chiamato. Risultato: il valore in /etc/sysctl.d/99-romiltec-baseline.conf era quello atteso, ma sysctl -n net.ipv4.tcp_keepalive_time continuava a riportare il vecchio. Il test fallito (un health check da CI) mi ha portato dritto alla causa, ma mi sono perso mezza giornata pensando inizialmente a un drift orchestrato male.
Il fix era banale, due righe:
- name: ensure sysctl baseline drop-in
ansible.builtin.template:
src: 99-romiltec-baseline.conf.j2
dest: /etc/sysctl.d/99-romiltec-baseline.conf
# ...
notify: reload sysctl # questa qui, mancava
Lezione: il template che scrive su disco senza handler che ricarica il servizio crea un sistema in cui il file è corretto ma il comportamento del nodo non lo è. È esattamente lo scenario in cui un check ansible-playbook --check vede zero drift, ma il sistema ha drift reale invisibile.
Bug 2: become mancante su un task di chmod, fallimento silenzioso
Il secondo bug è stato più subdolo. Aprile 2024, role ops_user. Un task che assicurava i permessi su ~/.ssh/authorized_keys dell’utente sysadmin del team:
- name: ensure authorized_keys permissions for ops user
ansible.builtin.file:
path: /home/{{ ops_user_name }}/.ssh/authorized_keys
mode: '0600'
owner: "{{ ops_user_name }}"
group: "{{ ops_user_name }}"
# bug: become non specificato a livello di task, e il role era usato
# senza become a livello di play (perché altri task del role lo gestivano implicitamente)
Il task girava nel contesto dell’utente con cui il playbook si connetteva, non come root. Su un nodo dove il file authorized_keys era già di proprietà dell’utente sysadmin, il chmod riusciva (proprietario può chmodare il proprio file). Su un nodo dove l’utente sysadmin era stato appena creato dal task precedente con un umask diverso e le permission erano 0640, il chmod 0600 falliva silenziosamente con un fallimento mascherato da failed_when: false ereditato male.
Il sintomo: un host, e uno solo, in cui il login con la chiave SSH del sysadmin non funzionava perché OpenSSH rifiutava il file con permission troppo aperte. Lo abbiamo scoperto perché una persona del team non riusciva a fare login durante un on-call serale.
Fix:
- name: ensure authorized_keys permissions for ops user
ansible.builtin.file:
path: /home/{{ ops_user_name }}/.ssh/authorized_keys
mode: '0600'
owner: "{{ ops_user_name }}"
group: "{{ ops_user_name }}"
become: true # esplicito, non ereditato
Lezione operativa: quando un task richiede privilegi root e li può ricevere solo via become, dichiararlo esplicitamente. Il become ereditato dal play o dal role è comodo ma fragile, e questo tipo di bug “funziona ovunque tranne sul caso edge” è quello che ti morde sei mesi dopo, quando hai dimenticato la sequenza di costruzione iniziale.
I numeri della V3, dopo un anno di rodaggio
Misure raccolte fra ottobre 2024 e maggio 2025, tutte sulla fleet di 17 host.
Tempo di un giro completo della fleet (ansible-playbook site.yml --diff, parallelismo 8 con forks: 8 in ansible.cfg):
- 11-14 minuti, mediana 12,3 minuti.
- Variabilità dipende dal fatto se ci sono fact gathering rallentati su 1-2 host con disk I/O lento, niente di patologico.
Deviazioni rilevate per giro (numero di task che riportano “changed” non programmate):
- Mediana 2 deviazioni per giro completo, con un picco di 7 dopo un weekend lungo dove qualcuno aveva fatto modifiche manuali su un nodo.
- Le deviazioni vengono notificate sul canale interno di on-call e triagate la mattina dopo, in 5-10 minuti per deviazione.
Tempo di provisioning di un nuovo host (da Debian fresca con HestiaCP installata, al join nella fleet con tutti i role applicati):
- 28 minuti, di cui ~10 il primo giro Ansible (tutto da fare), ~12 la verifica che Hestia non vada in conflitto con i drop-in che applichiamo (spot-check manuale sui file di config), ~6 il setup della prima rotazione di backup restic con bucket nuovo.
- Pre-V3: lo stesso provisioning chiedeva 2-3 ore di lavoro distribuito su due giorni, perché c’erano sempre falsi positivi nel diff da indagare prima di dichiarare il nodo allineato.
Dimensione del playbook:
- V1: ~800 righe in un file unico.
- V2: ~1.400 righe distribuite su sette role, con duplicazione di logica.
- V3: ~520 righe distribuite su cinque role, con responsabilità non sovrapposte.
Meno righe non è un valore di per sé, ma in questo caso riflette la scelta architetturale: smettere di duplicare quello che Hestia già fa libera spazio cognitivo e linee di codice.
Cosa rifarei diversamente
Una cosa, sopra le altre: avrei iniziato direttamente con la convenzione “Hestia owns the sites, Ansible owns the infra”.
Il principio sembra ovvio scritto a posteriori. Non lo era nell’autunno 2023, perché il pattern Ansible mainstream per orchestrare un parco di server WordPress è “automatizza tutto, anche nginx e PHP”. Tutta la documentazione Ansible che ho consumato all’epoca andava in quella direzione. Il momento in cui ho capito di dover invertire il principio è stato quando un cliente ha aggiunto un dominio dal pannello Hestia, e Ansible al giro successivo lo ha reverted, perché il role nginx rigenerava il vhost dal mio template senza sapere del nuovo dominio.
Diciotto mesi di playbook subottimale prima di arrivare alla V3 sono il costo di non aver fatto quella domanda di principio prima di scrivere codice: chi è già il padrone di questo livello? Se la risposta è “Hestia”, o “Cloud Init”, o “il package manager di Debian”, il tuo playbook deve negoziare con quel padrone, non sostituirlo. Il principio è generalizzabile: vale per Ansible su Hestia, vale per Terraform su un cloud che già usa qualche IaC dell’utente, vale per qualunque livello di automazione su un sistema dove qualche altro componente è già autorevole.
L’altra cosa che farei diversamente: scriverei i test Molecule per ogni role fin dalla V1. Non li ho aggiunti finché la V3 non era stabile, e i due bug che ho descritto sopra (sysctl senza handler, become mancante) sarebbero stati intercettati da un test Molecule banale che convergeva il role su un container Debian e poi verificava sysctl -n o stat sui file. Il costo di Molecule è basso, il beneficio è alto, l’ho sottovalutato perché mi sembrava overhead per una fleet di 17 host. Una fleet di 17 host è esattamente il punto in cui il test ti salva una mezza giornata almeno una volta al trimestre.
A maggio 2025 il playbook gira ogni mattina alle 6:00 in --check --diff, manda un report sul canale interno se trova drift, e parte in modo --apply solo su comando esplicito di una persona del team. Il rapporto fra cose che fa il playbook e cose che fa Hestia è chiaro, scritto, e onorato dalle due parti. Tre versioni in due anni per arrivarci sembrano tante. Lo sono. Ma ognuna ha insegnato un livello di astrazione che la successiva ha dato per scontato. Lo rifarei nello stesso ordine, e arriverei alla V3 in sei mesi invece che in diciotto, perché ora so quale domanda fare per prima.
