Template e handler Ansible: esempio pratico
Genera una configurazione nginx con Jinja2 e ricarica il servizio solo dopo modifiche reali. Esegui il playbook due volte per verificare l'idempotenza.
Cosa aggiungono template e handler Ansible al primo playbook
Template e handler Ansible sono i due componenti che trasformano un playbook statico in uno strumento utile. Un template genera un file di configurazione a partire dalle variabili, quindi un unico file può essere usato per tutti gli host. Un handler viene eseguito solo quando un task ha effettivamente modificato qualcosa. In questo modo il servizio viene ricaricato quando cambia davvero la configurazione e resta invariato negli altri casi.
Questa guida riprende esattamente dal punto in cui si interrompe il primo playbook Ansible su un VPS. Hai già un play che installa un pacchetto e avvia un servizio. Tutto ciò che segue viene eseguito su una macchina, perché il play indirizza localhost tramite una connessione locale. Non serve un secondo server per seguire la guida. Lo stesso play può essere eseguito sugli host reali dell'inventario senza modificare i task; l'ultima sezione descrive le modifiche necessarie.
Configurare la directory di lavoro
sudo apt update
sudo apt install -y ansible nginx
ansible --version
mkdir -p ~/ansible-templates/templates
cd ~/ansible-templatesnginx è presente soltanto perché è un servizio reale con un file di configurazione e un comando di reload, cioè tutto ciò che serve all’esempio. ansible --version stampa la versione di ansible-core e l’interprete Python che verrà utilizzato. Annotare entrambi. Il playbook seguente usa nomi di modulo completamente qualificati, come ansible.builtin.template, che richiedono Ansible 2.10 o versioni successive; qualsiasi pacchetto fornito dalla distribuzione è ampiamente più recente.
Creare inventory.ini:
[local]
localhost ansible_connection=local ansible_python_interpreter="{{ ansible_playbook_python }}"ansible_connection=local indica ad Ansible di eseguire ogni task come processo locale, invece di aprire una sessione SSH verso il sistema stesso. La seconda impostazione non è superflua. Quando si inserisce localhost in un file di inventario, diventa un host ordinario e perde l’interprete che Ansible assegna automaticamente a localhost implicito. Di conseguenza, Ansible ricorre alla ricerca dell’interprete e può scegliere un Python diverso da quello che sta eseguendo il play. ansible_playbook_python è l’interprete che sta eseguendo ansible-playbook in questo momento e mantiene allineati i due ambienti.
Creare ansible.cfg:
[defaults]
inventory = inventory.iniSenza questo file, è necessario specificare -i inventory.ini in ogni comando. Se non esiste alcun inventario, Ansible stampa [WARNING]: provided hosts list is empty, only localhost is available. Note that the implicit localhost does not match 'all' e un play con hosts: all non trova corrispondenze. Un’ultima informazione su ansible.cfg: Ansible lo ignora quando si trova in una directory scrivibile da tutti, quindi mantenere il progetto nella propria home directory. Un file di inventario contiene più di un elenco di host, e questo è il file più piccolo sufficiente allo scopo.
template e copy: quando usare ciascuno
ansible.builtin.copy trasferisce un file così com’è. ansible.builtin.template elabora prima il file con Jinja2 e trasferisce il risultato. La documentazione del modulo descrive template come «un modulo virtuale implementato interamente come action plugin ed eseguito sul controller». Questo comporta una conseguenza importante: il rendering avviene sul computer in cui hai eseguito ansible-playbook. L’host di destinazione non vede le tue variabili e non deve avere Jinja2 installato.
Usa copy quando il file è identico su tutti gli host. Usa template non appena un valore varia da un host all’altro oppure quando ti serve un ciclo {% for %} o un blocco {% if %}. copy dispone del parametro content: e le variabili al suo interno vengono sostituite come qualsiasi altro argomento del task, ma lì non sono disponibili cicli né condizioni. Di conseguenza, tutto ciò che ha una struttura appartiene a un template. Entrambi i moduli accettano le stesse opzioni per i file, perché includono gli stessi frammenti della documentazione. Pertanto owner, group, mode, backup e validate si comportano nello stesso modo con entrambi.
Scrivere il modello: una variabile, un ciclo
Salvare questo contenuto come templates/app.conf.j2:
# {{ ansible_managed }}
upstream {{ app_name }}_backend {
{% for backend in app_backends %}
server {{ backend.host }}:{{ backend.port }} weight={{ backend.weight }};
{% endfor %}
}
server {
listen {{ app_listen_port }};
server_name {{ app_server_name }};
location / {
proxy_pass http://{{ app_name }}_backend;
proxy_set_header Host $host;
}
}Qui svolgono il lavoro due tipi di tag Jinja2. {{ ... }} è un'espressione e ne stampa il valore. {% ... %} è un'istruzione e non stampa nulla autonomamente. app_backends è un elenco di dizionari, quindi backend.host legge una chiave da ogni elemento e il ciclo scrive una riga server per ogni elemento, indipendentemente dal numero di elementi definiti.
Una nota sugli spazi bianchi, perché può sorprendere chi conosce Jinja2 in altri contesti. Per impostazione predefinita, Ansible imposta trim_blocks su yes, mentre Jinja2 non lo fa; di conseguenza, il carattere di nuova riga subito dopo un tag {% ... %} viene rimosso e il ciclo non lascia una riga vuota. Ansible lascia lstrip_blocks impostato su no, quindi gli spazi inseriti prima di un tag {% vengono conservati e compaiono nel file generato. Se l'output contiene un'indentazione indesiderata, impostare lstrip_blocks: true nell'attività del modello.
Per impostazione predefinita, {{ ansible_managed }} viene generato come testo letterale Ansible managed. Lasciarlo invariato. Spesso si ridefinisce ansible_managed in ansible.cfg per includere una data; da quel momento, il file generato cambia a ogni esecuzione, l'attività segnala una modifica a ogni esecuzione e il servizio viene ricaricato a ogni esecuzione. Questa singola impostazione elimina la proprietà su cui si basa il resto di questa guida. L'estensione .j2 è una convenzione e Ansible non la verifica.
Il playbook
Salvalo come site.yml:
- name: Render an nginx site from a template
hosts: local
become: true
vars:
app_name: learn
app_listen_port: 8080
app_server_name: learn.example.com
app_backends:
- host: 127.0.0.1
port: 9001
weight: 3
- host: 127.0.0.1
port: 9002
weight: 1
tasks:
- name: Install nginx
ansible.builtin.apt:
name: nginx
state: present
update_cache: true
cache_valid_time: 3600
- name: Render the site configuration
ansible.builtin.template:
src: templates/app.conf.j2
dest: "/etc/nginx/conf.d/{{ app_name }}.conf"
owner: root
group: root
mode: '0644'
backup: true
notify: nginx config changed
- name: Make sure nginx is enabled and running
ansible.builtin.service:
name: nginx
state: started
enabled: true
handlers:
- name: Test the nginx configuration
ansible.builtin.command:
cmd: /usr/sbin/nginx -t
changed_when: false
listen: nginx config changed
- name: Reload nginx
ansible.builtin.service:
name: nginx
state: reloaded
listen: nginx config changedmode: '0644' è racchiuso tra virgolette intenzionalmente. La documentazione delle opzioni dei file indica di racchiudere tra virgolette i numeri ottali «in modo che Ansible riceva una stringa e possa eseguire autonomamente la conversione da stringa a numero». Senza virgolette, il parser YAML interpreta 0644 come un numero semplice e potresti ottenere permessi diversi da quelli richiesti.
notify: nginx config changed identifica un argomento, non un gestore. Entrambi i gestori contengono listen: nginx config changed, quindi una singola notifica li attiva entrambi. In seguito puoi aggiungere un terzo gestore con la stessa riga listen senza modificare l'attività del template. cache_valid_time: 3600 impedisce che una seconda esecuzione nella stessa ora contatti nuovamente i mirror dei pacchetti.
Eseguilo una volta, quindi leggi l'output
ansible-playbook site.ymlSe sudo richiede una password, aggiungi -K e Ansible la richiederà.
Leggi prima le righe relative alle singole attività, quindi il PLAY RECAP in fondo. Ogni attività stampa changed: quando Ansible ha dovuto apportare una modifica oppure ok: quando l'host si trovava già nello stato desiderato; il riepilogo somma questi contatori per ogni host. Dopo il completamento di tutte le attività del play, e non prima, vengono visualizzati RUNNING HANDLER [Test the nginx configuration] seguito da RUNNING HANDLER [Reload nginx].
Ora controlla direttamente la macchina, senza affidarti all'output:
sudo cat /etc/nginx/conf.d/learn.conf
sudo /usr/sbin/nginx -t
curl -sI http://127.0.0.1:8080/nginx -t stampa nginx: configuration file /etc/nginx/nginx.conf test is successful quando la configurazione assemblata è corretta. curl restituisce una riga di stato da nginx e 502 Bad Gateway è il risultato corretto in questo caso, perché il server block è attivo e nessun processo è in ascolto sulle porte 9001 o 9002. sudo tail /var/log/nginx/error.log indica il motivo in parole semplici: connect() failed (111: Connection refused) while connecting to upstream.
Eseguirlo una seconda volta per dimostrare l'idempotenza
ansible-playbook site.ymlQuesta è l'esecuzione significativa, quindi confrontate il relativo output con il primo, riga per riga. L'attività del template dovrebbe ora stampare ok: invece di changed:, e nessun handler dovrebbe comparire nell'output.
Il meccanismo è semplice ed è importante conoscerlo, perché è il comportamento rispetto al quale eseguire il debug. template esegue il rendering del file sul controller e confronta il checksum del risultato con quello del file già presente in dest. Se contenuto, proprietario e modalità corrispondono, non c'è nulla da fare: l'attività segnala ok, quindi notify non viene mai attivato e l'handler non viene eseguito. Gli handler vengono attivati da changed e da nient'altro.
Verificate anche il comportamento opposto. Modificate weight: 3 in weight: 1 all'interno di vars, eseguite nuovamente il play e l'attività del template segnalerà changed; entrambi gli handler verranno eseguiti e sudo cat /etc/nginx/conf.d/learn.conf mostrerà il nuovo valore.
Se una seconda esecuzione identica segnala ancora una modifica, il rendering non è stabile. Cercate prima nell'output un valore basato sul tempo, perché questa è la causa più comune e un ansible_managed personalizzato è spesso responsabile. Controllate poi che mode e owner dell'attività corrispondano a ciò che è effettivamente presente sul disco, perché una differenza in questi attributi costituisce una modifica anche quando i byte sono identici.
Visualizzare le modifiche prima di applicarle
ansible-playbook site.yml --check --diff--check esegue il play senza modificare l'host. --diff mostra le modifiche che ogni task apporterebbe; per template si tratta di un confronto riga per riga tra il contenuto generato e il file presente sul disco. Insieme rispondono alla domanda «cosa farebbe questa esecuzione» senza eseguire effettivamente le modifiche. La modalità check presenta anche alcuni aspetti critici, soprattutto nei task il cui risultato dipende da un task precedente che la modalità check non ha eseguito realmente.
Perché gli handler attendono fino alla fine del play
La documentazione degli handler è chiara: "Per impostazione predefinita, gli handler vengono eseguiti dopo il completamento di tutti i task di un determinato play. Gli handler notificati vengono eseguiti automaticamente dopo ciascuna delle sezioni seguenti, nell'ordine indicato: pre_tasks, roles/tasks e post_tasks."
Il motivo è l'accorpamento delle operazioni. Un play che genera quattro file di configurazione per un servizio dovrebbe riavviare quel servizio una sola volta, al termine, quando tutti e quattro i file sono presenti. Riavviarlo dopo ogni file significherebbe riavviarlo quattro volte, e tre di questi riavvii caricherebbero una configurazione incompleta. La stessa pagina esplicita la garanzia: "Notificare più volte lo stesso handler comporta l'esecuzione dell'handler una sola volta, indipendentemente dal numero di task che lo notificano."
Anche l'ordine è fisso: "Gli handler vengono eseguiti nell'ordine in cui sono definiti nella sezione handlers, non nell'ordine in cui sono elencati nell'istruzione notify." Per questo Test the nginx configuration si trova sopra Reload nginx nel playbook. Il test viene eseguito per primo perché è scritto per primo; nulla nella riga notify modifica questo comportamento.
Come eseguire gli handler in anticipo e come eseguirli dopo un errore
A volte un task successivo dello stesso play richiede che il servizio stia già usando la nuova configurazione. In quel punto, esegui il flush degli handler notificati con il modulo meta. La documentazione lo descrive come l'operazione che fa eseguire ad Ansible «tutti i task degli handler che sono stati notificati fino a quel momento».
- name: Run the notified handlers now instead of at the end of the play
ansible.builtin.meta: flush_handlers
- name: Wait for the new listener to accept connections
ansible.builtin.wait_for:
host: 127.0.0.1
port: 8080
timeout: 10Se rimuovi la riga meta, il task wait_for viene eseguito mentre nginx sta ancora usando la configurazione precedente. Alla prima esecuzione non è ancora presente alcun listener sulla porta 8080, quindi il task attende tutti i dieci secondi e poi ha esito negativo.
Il secondo caso riguarda gli errori. «Se un task notifica un handler, ma un altro task ha esito negativo più avanti nel play, per impostazione predefinita l'handler non viene eseguito su quell'host. L'host potrebbe quindi rimanere in uno stato imprevisto.» Un play che genera un file di configurazione e poi fallisce a causa di un task non correlato lascia quindi il nuovo file sul disco, mentre il servizio in esecuzione continua a usare la configurazione precedente. Puoi modificare questo comportamento con --force-handlers nella riga di comando oppure con force_handlers: true nel play. La stessa opzione è disponibile come force_handlers = True in [defaults] dentro ansible.cfg e come variabile d'ambiente ANSIBLE_FORCE_HANDLERS. Il valore predefinito è False.
I nomi degli handler entrano in conflitto e quello escluso non segnala nulla
La documentazione stabilisce la regola: «Ogni handler deve avere un nome univoco a livello globale. Se vengono definiti più handler con lo stesso nome, è possibile notificare ed eseguire soltanto l'ultimo caricato nel play». Anche gli handler definiti all'interno di un ruolo non sono limitati a quel ruolo. Vengono inseriti in un unico elenco globale di handler per l'intero play. Di conseguenza, se due ruoli definiscono entrambi Restart nginx, il nome risolve esattamente uno dei due. A determinare quale viene usato è l'ordine di caricamento, non il ruolo da cui è stato notificato.
Verificate questa regola prima di basarvi su di essa. Salvate il contenuto seguente in handlers-dup.yml:
- name: Two handlers, one name
hosts: local
gather_facts: false
tasks:
- name: Notify the duplicated name
ansible.builtin.command:
cmd: /bin/true
changed_when: true
notify: Duplicated handler
handlers:
- name: Duplicated handler
ansible.builtin.file:
path: /tmp/dup-first
state: touch
mode: '0644'
- name: Duplicated handler
ansible.builtin.file:
path: /tmp/dup-second
state: touch
mode: '0644'rm -f /tmp/dup-first /tmp/dup-second
ansible-playbook handlers-dup.yml
ls -l /tmp/dup-first /tmp/dup-secondIl play termina correttamente, RUNNING HANDLER [Duplicated handler] compare una volta e ls stampa una riga per /tmp/dup-first e ls: cannot access '/tmp/dup-second': No such file or directory per l'altro. L'handler eseguito è quello scritto per primo, non l'ultimo caricato. Questo è l'opposto di quanto prevede quella frase.
È importante capire la differenza, perché la regola documentata riguarda i blocchi di handler, non le righe di un file. Gli handler provenienti da origini separate, prima un ruolo e poi un altro, appartengono a blocchi distinti. Un blocco successivo nasconde quindi quello precedente. Un semplice elenco handlers: in un play costituisce invece un unico blocco. La ricerca all'interno del blocco procede dall'alto verso il basso e si arresta al primo nome corrispondente. In un singolo file risponde quindi la prima definizione e la seconda non è raggiungibile, mentre tra ruoli il comportamento di sostituzione corrisponde a quello descritto dalla documentazione. In entrambi i casi non è possibile raggiungere entrambi gli handler e nessuno dei due comportamenti dovrebbe essere usato come base per la progettazione.
Esistono due soluzioni semplici. Assegnate a ogni handler un prefisso specifico del ruolo oppure notificate la forma qualificata role_name : handler_name. La documentazione indica quest'ultima come il modo «per assicurarsi che venga notificato un handler di un ruolo anziché un handler esterno al ruolo con lo stesso nome». Gli spazi attorno ai due punti fanno parte della sintassi. Il problema diventa concreto non appena iniziate a includere ruoli che non avete scritto voi.
La stessa pagina contiene un'altra regola: «Evitate di inserire variabili nel nome dell'handler. Poiché i nomi degli handler vengono sottoposti a templating nelle prime fasi, Ansible potrebbe non avere ancora un valore disponibile per un nome di handler di questo tipo». Un handler chiamato Restart {{ service_name }} causa il fallimento dell'intero play se la variabile non è definita quando il nome viene sottoposto a templating. Mantenere i nomi degli handler come stringhe fisse e raggrupparli con listen evita il problema.
validate: rifiutare l'installazione di un file renderizzato non valido
validate esegue un comando sul file renderizzato prima che Ansible lo sposti nella posizione definitiva. La documentazione specifica: «Il comando di validazione da eseguire prima di copiare il file aggiornato nella destinazione finale. Per la validazione viene utilizzato un percorso di file temporaneo, passato tramite %s, che deve essere presente come negli esempi seguenti. Il comando viene inoltre passato in modo sicuro, quindi le funzionalità della shell, come l'espansione e le pipe, non funzioneranno».
Da questo testo derivano direttamente due regole. %s è obbligatorio e una stringa di validazione che ne è priva interrompe l'attività con validate must contain %s. Inoltre non viene utilizzata una shell, quindi pipe, redirezione, globbing e && non funzionano. Un comando, un argomento file.
Gli esempi ufficiali del modulo mostrano i due casi in cui questo funziona perfettamente:
- name: Copy a new sudoers file into place, after passing validation with visudo
ansible.builtin.template:
src: /mine/sudoers
dest: /etc/sudoers
validate: /usr/sbin/visudo -cf %s
- name: Update sshd configuration safely, avoid locking yourself out
ansible.builtin.template:
src: etc/ssh/sshd_config.j2
dest: /etc/ssh/sshd_config
owner: root
group: root
mode: '0600'
validate: /usr/sbin/sshd -t -f %s
backup: yesEntrambi funzionano perché ogni verificatore accetta un solo file e lo valuta secondo le proprie regole. visudo -cf legge un file sudoers. sshd -t -f legge un sshd_config completo.
Perché validate non può controllare il file nginx in questa guida
Aggiungi validate: /usr/sbin/nginx -t -c %s al task template precedente e il task non va a buon fine. Il messaggio indica la causa:
nginx: [emerg] "upstream" directive is not allowed here in <ansible temporary path>:2nginx -t -c si aspetta una configurazione completa che inizi al livello principale con i blocchi events e http. Il file generato da questo play è un frammento, incluso nel blocco http da include /etc/nginx/conf.d/*.conf; all'interno di /etc/nginx/nginx.conf. Considerato da solo, fuori da quel contesto, upstream è effettivamente una direttiva nella posizione sbagliata. Per questo nginx rifiuta un file che, nel punto in cui viene realmente utilizzato, è completamente corretto. Al controllo è stato fornito un frammento, chiedendogli di trattarlo come una configurazione completa.
La soluzione praticabile è già presente nel playbook. Installa il frammento, quindi controlla la configurazione assemblata in un handler definito prima dell'handler di reload. Poiché gli handler vengono eseguiti nell'ordine in cui sono definiti, nginx -t analizza il vero /etc/nginx/nginx.conf con il frammento incluso. Se il controllo fallisce, il play si interrompe prima che venga mai chiamato systemctl reload. È importante considerare il compromesso: quando il controllo fallisce, il file non valido è già stato scritto su disco e nginx continua a servire l'ultima configurazione caricata finché qualcuno non lo riavvia.
È questo il motivo per cui backup: true è utile. Prima di sovrascrivere il file originale, ne scrive una copia nella stessa directory, con il nome basename.PID.YYYY-MM-DD@HH:MM:SS~. La directory finisce quindi per contenere elementi come learn.conf.4127.2026-08-20@11:42:09~. Esegui sudo ls -l /etc/nginx/conf.d/ dopo una modifica per trovarne uno.
Questo dettaglio del nome è più importante di quanto sembri. Il backup non crea problemi in /etc/nginx/conf.d/ perché la configurazione principale include soltanto conf.d/*.conf e il nome del backup termina con una tilde. Non è innocuo in una directory inclusa con un semplice *. Su Debian e Ubuntu, /etc/nginx/nginx.conf include /etc/nginx/sites-enabled/* proprio in questo modo. Se usi template in sites-enabled con backup: true, nginx carica il backup come un secondo server block attivo. Per questo il play scrive invece in conf.d.
Eseguire lo stesso play sugli host reali dell’inventory
Modifica hosts: local sostituendolo con il nome del gruppo che utilizzi, senza spostare altro nel play. Il template viene renderizzato una volta per ogni host, quindi app_listen_port e app_backends possono provenire da group_vars e host_vars mentre il file del template resta unico. Questo è il vantaggio di inserire i valori nelle variabili invece che nel file.
Cambiano due aspetti. Ora become: true richiede una password sudo su ogni target, a meno che su tali host non sia configurato sudo senza password; quindi aggiungi -K. Inoltre, qualsiasi secret presente nel template, ad esempio una password del database o un token API, non deve essere memorizzato in chiaro in vars: in un file incluso nel commit. Cifra questi valori con Ansible Vault e fai riferimento a essi per nome esattamente come fai ora, perché il template non dipende dalla provenienza di una variabile.
Quando il play comprende più di un servizio, vars:, templates/ e handlers: hanno già una posizione standard in cui essere organizzati. Spostarli in tali posizioni è lo scopo della separazione tra playbook e ruolo.
FAQ
Perché il mio handler Ansible non è stato eseguito?
Quasi sempre perché il task che lo notifica ha restituito ok invece di changed. Gli handler vengono eseguiti solo quando rilevano una modifica. Un task template il cui rendering corrisponde già al file presente su disco non notifica quindi alcun handler. Controllate poi quattro aspetti. La stringa in notify deve corrispondere esattamente a name dell'handler o a un topic listen, incluse maiuscole, minuscole e spazi. Un task successivo che fallisce su quell'host sopprime gli handler notificati, a meno che non passiate --force-handlers. Un handler definito in un play diverso non è visibile da questo play. Infine, un task notificante saltato a causa di una condizione when non invia alcuna notifica.
Perché il mio playbook segnala una modifica a ogni esecuzione?
Il testo renderizzato non è stabile tra un'esecuzione e l'altra. La causa più comune è un timestamp nell'output; una stringa ansible_managed personalizzata che include una data produce esattamente questo effetto. Controllate poi mode e owner nel task: se non corrispondono al file già presente su disco, Ansible li corregge e segnala una modifica anche quando il contenuto è identico. Eseguite ansible-playbook site.yml --check --diff per capire quale dei due sia il problema, perché --diff mostra la differenza che il task intende applicare.
Qual è la differenza tra template e copy in Ansible?
ansible.builtin.copy invia un file senza modificarlo. ansible.builtin.template lo renderizza prima tramite Jinja2 sul controller e poi invia il risultato, quindi variabili e cicli vengono risolti prima che il file raggiunga l'host di destinazione. Usate copy per un file identico a livello di byte su tutti gli host. Usate template per tutto ciò che varia in base all'host. I due moduli condividono le stesse opzioni per i file, quindi mode, owner, backup e validate funzionano nello stesso modo in entrambi.
Come posso eseguire un handler nel corso di un play?
Aggiungete ansible.builtin.meta: flush_handlers come task nel punto in cui volete eseguire gli handler. Questo esegue ogni handler notificato fino a quel momento, quindi il play prosegue normalmente. Usatelo quando un task successivo dello stesso play dipende dal fatto che il servizio esegua già la nuova configurazione, ad esempio un wait_for su una porta disponibile solo dopo il reload. È il metodo supportato per eseguire un handler prima della fine del play.
Posso usare validate con un frammento di configurazione nginx?
Non con nginx -t -c %s. Questo comando richiede una configurazione completa che inizi con i blocchi di primo livello events e http, quindi rifiuta un frammento conf.d con un messaggio come "upstream" directive is not allowed here. Il frammento è valido all'interno del blocco http, ma non è valido autonomamente. Installate il file, quindi eseguite nginx -t sulla configurazione assemblata in un handler definito prima dell'handler di reload. Gli handler vengono eseguiti nell'ordine in cui sono definiti, quindi una configurazione non valida interrompe il play prima che venga tentato il reload. Impostate backup: true nel task template, in modo che il file precedente sia ancora disponibile per il ripristino.