Fondamenti di Ansible, senza i quali i tuoi playbook saranno un groviglio di pasta attaccata

Faccio molte revisioni del codice di altri su Ansible e scrivo molto io stesso. Durante l'analisi degli errori (sia degli altri che dei miei) e in un certo numero di colloqui, ho capito l'errore principale che commettono gli utenti di Ansible: si avventurano in cose complesse senza aver prima padroneggiato le basi.

Per rimediare a questa ingiustizia universale, ho deciso di scrivere un'introduzione ad Ansible per coloro che già lo conoscono. Avverto, non è un riassunto delle guide, è un long read con molte parole e poche immagini.

Il livello previsto del lettore è di aver già scritto alcune migliaia di righe di YAML, di avere già qualcosa in produzione, ma "è tutto un po' storto".

Titoli

Il principale errore degli utenti di Ansible è non sapere come si chiamano le cose. Se non si conoscono i nomi, non si può capire ciò che è scritto nella documentazione. Un esempio concreto: durante un colloquio, una persona che sembrava aver dichiarato di avere molta esperienza con Ansible non è riuscita a rispondere alla domanda "di quali elementi è composto un playbook?". Quando ho suggerito che "ci si aspettava che rispondesse che un playbook è composto da play", è seguito un commento devastante: "noi non lo usiamo". La gente scrive su Ansible per soldi ma non usa i play. In realtà, lo usano, ma non sanno cosa sia.

Iniziamo quindi con le basi: come si chiamano le cose. Magari lo sai già, magari no, perché non ci hai fatto caso mentre leggevo la documentazione.

ansible-playbook esegue il playbook. Il playbook è un file con estensione yml/yaml, all'interno del quale c'è qualcosa del genere:

---
- hosts: group1
  roles:
    - role1

- hosts: group2,group3
  tasks:
    - debug:

Abbiamo già capito che l'intero file è un playbook. Possiamo mostrare dove sono le role, dove sono i tasks. Ma dove si trova il play? E come si differenzia il play da role o playbook?

Nella documentazione c'è tutto. E questo viene trascurato. I principianti — perché c'è troppo e non si riesce a memorizzare tutto subito. Gli esperti — perché "cose triviali". Se sei esperto, rileggi queste pagine almeno una volta ogni sei mesi e il tuo codice diventerà di gran lunga migliore.

Quindi, ricordate: un Playbook è un elenco composto da play e import_playbook.
Ecco una play:

- hosts: group1
  roles:
    - role1

e anche questa è un'altra play:

- hosts: group2,group3
  tasks:
    - debug:

Cos'è una play? A cosa serve?

La play è un elemento chiave per il playbook, perché è la play che collega l'elenco dei ruoli e/o delle attività con l'elenco degli host su cui devono essere eseguiti. Nelle profondità della documentazione si può trovare un riferimento a delegate_to, plugin di lookup locali, impostazioni specifiche per network-cli, jump-host e così via. Questi permettono di cambiare leggermente il luogo di esecuzione delle attività. Ma dimenticate tutto ciò. Ognuna di queste opzioni astute ha applicazioni molto specifiche e non sono affatto universali. Noi stiamo parlando delle cose fondamentali che tutti dovrebbero conoscere e utilizzare.

Se vuoi eseguire "qualcosa" "da qualche parte" — scrivi play. Non un ruolo. Non un ruolo con moduli e delegati. Prendi e scrivi play. In cui, nel campo hosts, elenchi dove eseguire, e in roles/tasks — cosa eseguire.

Semplice, vero? E come potrebbe essere diversamente?

Uno dei motivi principali per cui le persone desiderano farlo non attraverso play è "un ruolo che configura tutto". Si desidera avere un ruolo che imposti e server sia di primo tipo che server di secondo tipo.

Un esempio archetipico è il monitoraggio. Vuoi avere un ruolo di monitoraggio che configuri il monitoraggio. Il ruolo di monitoraggio viene assegnato agli host di monitoraggio (nel corrispondente play). Ma si scopre che per il monitoraggio dobbiamo installare pacchetti sugli host che stiamo monitorando. Perché non utilizzare delegate? E dobbiamo anche configurare iptables. delegate? Dobbiamo anche scrivere/modificare la configurazione per il DBMS, in modo che il monitoraggio possa passare. delegate! E se l'ispirazione scatta, si può fare delega include_role in un ciclo annidato con un filtro astuto sulla lista dei gruppi, e dentro include_role si può anche fare delegate_to di nuovo. E così sia…

Un buon auspicio è avere un'unica funzione di monitoring che "fa tutto" — ci porta a un inferno da cui l'unica via d'uscita è riscrivere tutto da zero.

Dove è avvenuto l'errore? Nel momento in cui ti sei accorto che per completare il compito "x" sull'host X dovevi andare sull'host Y e fare "y", avresti dovuto eseguire un semplice esercizio: andare e scrivere un play che su Y esegue y. Non aggiungere qualcosa a "x", ma scrivere da zero. Anche se con variabili hardcoded.

Sembra che nei paragrafi precedenti tutto sia stato detto correttamente. Ma questo non è il tuo caso! Perché vuoi scrivere codice riutilizzabile, che sia DRY e simile a una libreria, e devi cercare un metodo per farlo.

Qui si cela un ulteriore grave errore. Un errore che ha trasformato molti progetti da scritti in modo tollerabile (si può fare meglio, ma tutto funziona e si può riprendere facilmente) in un vero incubo, in cui persino l'autore non riesce a districarsi. Funziona, ma Dio non voglia cambiare qualcosa.

Questo errore si presenta così: un ruolo è una funzione di libreria. Questa analogia ha portato al fallimento di molte buone iniziative, ed è davvero triste da osservare. Un ruolo non è una funzione di libreria. Non può eseguire calcoli e non può prendere decisioni a livello di play. Ricordami, quali decisioni prende play?

Hai ragione. Play prende decisioni (piuttosto, contiene informazioni) su quali task e ruoli eseguire su quali host.

Se deleghi questa decisione a un ruolo, e ancora di più con calcoli, condanni te stesso (e chiunque cerchi di capire il tuo codice) a una vita misera. Un ruolo non decide dove deve essere eseguito. Questa decisione spetta a play. Un ruolo fa ciò che gli viene ordinato, dove gli viene ordinato.

Parleremo dei pericoli della programmazione con Ansible e di perché COBOL sia migliore di Ansible nel capitolo sulle variabili e jinja. Per ora, diciamo solo una cosa: ogni tuo calcolo lascia una traccia indelebile di cambiamento delle variabili globali, e non puoi farci nulla. Non appena due "tracce" si incrociano, tutto è perduto.

Nota per i pignoli: un ruolo può influenzare senza dubbio il controllo del flusso. delegate_to e ha applicazioni ragionevoli. Ci sono meta: end host/play. Ma! Ricordate, stiamo insegnando le basi? Dimenticati di delegate_to. Stiamo parlando del codice più semplice e bello su Ansible. Che è facile da leggere, facile da scrivere, facile da debugare, facile da testare e facile da estendere. Quindi, di nuovo:

play e solo play decidono su quali host eseguire cosa.

In questa sezione abbiamo esaminato il confronto tra play e role. Ora parliamo dei rapporti tra tasks e role.

Tasks e Ruoli

Consideriamo play:

- hosts: somegroup
  pre_tasks:
    - some_tasks1:
  roles:
     - role1
     - role2
  post_tasks:
     - some_task2:
     - some_task3:

Supponiamo che tu debba fare foo. E appare così foo: name=foobar state=present. Dove si scrive? In pre? Post? Creare un role?

… E dove sono finiti i tasks?

Ricominciare dalle basi: la struttura di play. Se sei confuso su questo, non puoi usare play come base per tutto il resto, e il tuo risultato diventa "instabile".

Struttura di play: direttiva hosts, impostazioni di play e sezioni pre_tasks, tasks, roles, post_tasks. Gli altri parametri per play non ci interessano al momento.

L'ordine delle loro sezioni con tasks e roles: pre_tasks, roles, tasks, post_tasks. Poiché semanticamente l'ordine di esecuzione tra tasks e roles non è chiaro, ma le best practices dicono che dobbiamo aggiungere una sezione tasks, solo se non ci sono roles. Se ci sono roles, quindi tutti i task allegati vengono inseriti nella sezione pre_tasks/post_tasks.

Rimane solo ciò che è semantico: prima di tutto pre_tasks, poi roles, poi post_tasks.

Ma non abbiamo ancora risposto alla domanda: dove dobbiamo scrivere la chiamata del modulo? foo Dobbiamo scrivere un ruolo per ogni modulo? O è meglio avere un ruolo principale per tutto? E se non è un ruolo, dove scrivere — nel pre o nel post?

Se a queste domande non c'è risposta argomentata, allora questo è un segno di mancanza di intuizione, cioè le famose "fondamenta instabili". Cerchiamo di capire. Prima domanda di controllo: Se il play ha pre_tasks e post_tasks (e non ci sono né tasks né roles), può succedere che qualcosa si rompa se sposto il primo task da post_tasks alla fine? pre_tasks?

Naturalmente, la formulazione della domanda suggerisce che si romperà. Ma cosa precisamente?

… I handler. La lettura delle basi rivela un fatto importante: tutti i handler vengono flushati automaticamente dopo ogni sezione. Cioè, vengono eseguiti tutti i task da pre_tasks, poi tutti i handler che sono stati notificati. Poi vengono eseguiti tutti i ruoli e tutti i handler che sono stati notificati nei ruoli. Poi post_tasks e i loro handler.

Quindi, se sposti un task da post_tasks in pre_tasks, potenzialmente lo eseguirai prima di eseguire il handler. Ad esempio, se in pre_tasks si installa e si configura server web, e nella post_tasks c'è qualcosa che viene inviato in esso, quindi il trasferimento di questo task nella sezione pre_tasks porterà al fatto che al momento dell'"invio" il server non sarà ancora avviato e tutto si romperà.

E ora ripensiamo, perché abbiamo bisogno di pre_tasks e post_tasks? Например, для того, чтобы выполнить всё нужное (включая хэндлеры) до выполнения роли. А post_tasks ci permetterà di lavorare con i risultati dell'esecuzione dei ruoli (compresi i gestori).

Un esperto di Ansible dirà che c'è meta: flush_handlers, ma perché abbiamo bisogno di flush_handlers, se possiamo affidarci all'ordine di esecuzione delle sezioni nel play? Inoltre, l'uso di meta: flush_handlers può portarci a risultati inaspettati con gestori ripetuti, facendoci ricevere avvisi strani nel caso di utilizzo when a block e così via. Più conosci bene Ansible, più dettagli puoi menzionare per una soluzione "furba". E la soluzione semplice — utilizzare una suddivisione naturale tra pre/roles/post — non presenta complessità.

E, tornando al nostro ‘foo’. Dove metterlo? In pre, post o in roles? Ovviamente, dipende dal fatto che ci servano o meno i risultati del gestore per foo. Se non servono, allora foo non deve essere messo né in pre né in post — queste sezioni hanno un significato speciale: eseguire i task prima e dopo l'array principale di codice.

Ora, la risposta alla domanda "ruolo o task" è legata a ciò che è già presente nel play — se ci sono tasks, allora è necessario aggiungere alle tasks. Se ci sono roles — bisogna creare un ruolo (anche se composto da una sola task). Ricordo che tasks e roles non sono utilizzati contemporaneamente.

La comprensione delle basi di Ansible fornisce risposte ben fondate a quelle che sembrano domande soggettive.

Tasks e ruoli (parte seconda)

Discutiamo ora la situazione in cui stai iniziando a scrivere un playbook. Devi fare foo, bar e baz. Sono tre task, un ruolo o tre ruoli? In sintesi, quando dovresti iniziare a scrivere ruoli? Qual è il significato di scrivere ruoli quando puoi scrivere task? … E cos'è un ruolo?

Uno degli errori più gravi (ne ho già parlato) è considerare che un ruolo sia come una funzione in una libreria di un programma. Come appare la descrizione generalizzata di una funzione? Essa accetta argomenti in input, interagisce con cause collaterali, produce effetti collaterali e restituisce un valore.

Ora, attenzione. Cosa si può fare in un ruolo? Chiamare side effects — sempre, è essenza stessa di Ansible — creare side effects. Avere side causes? Elementare. Ma con "passare un valore e restituirlo" — qui invece no. In primo luogo, non puoi passare un valore al ruolo. Puoi impostare una variabile globale con un periodo di vita pari a quello del play nella sezione vars per il ruolo. Puoi impostare una variabile globale con un periodo di vita nel play all'interno del ruolo. O addirittura con un periodo di vita del playbook (set_fact/register). Ma non puoi avere "variabili locali". Non puoi "ricevere un valore" e "restituirlo".

Questo porta alla conclusione principale: non si può scrivere nulla in ansible senza generare side effects. La modifica delle variabili globali è sempre un side effect per una funzione. In Rust, per esempio, cambiare una variabile globale è unsafe. E in Ansible — è l'unico modo per influenzare i valori per un ruolo. Nota le parole utilizzate: non "passare un valore al ruolo", ma "modificare i valori utilizzati dal ruolo". Non c'è isolamento tra i ruoli. Non c'è isolamento tra i task e i ruoli.

In totale: il ruolo non è una funzione.

Quali sono i vantaggi di un ruolo? Innanzitutto, un ruolo ha dei valori predefiniti (/default/main.yaml), in secondo luogo, un ruolo dispone di cataloghi aggiuntivi per l'archiviazione dei file.

Quali sono dunque i vantaggi dei valori predefiniti? Nel contesto piuttosto distorto della piramide di Maslow riguardante le priorità delle variabili in Ansible, i valori predefiniti dei ruoli sono i meno prioritari (esclusi i parametri della riga di comando di Ansible). Ciò significa che se devi fornire valori predefiniti senza preoccuparti che possano sovrascrivere i valori delle variabili di inventario o delle variabili di gruppo, i valori predefiniti del ruolo sono l'unico posto giusto per te. (Sto esagerando un po' — ce ne sono anche altri |d(your_default_here), ma se parliamo di posizioni fisse, allora solo i valori predefiniti dei ruoli).

Cosa rende ancora migliori i ruoli? Il fatto che abbiano i propri cataloghi. Si tratta di cataloghi per le variabili, sia statiche (ossia calcolate per il ruolo) che dinamiche (c'è un certo pattern o anti-pattern chiamato — include_vars insieme a {{ ansible_distribution }}-{{ ansible_distribution_major_version }}.yml.). Questi sono cataloghi per files/, templates/. Inoltre, consente ai ruoli di avere i propri moduli e plugin (library/). Tuttavia, rispetto ai task nei playbook (che possono includere tutto ciò), il vantaggio qui è solo che i file non sono raggruppati in un'unica massa, ma in diverse piccole pile.

Un altro aspetto: si possono cercare di realizzare ruoli che saranno disponibili per il riutilizzo (tramite galaxy). Dopo l'introduzione delle raccolte, la distribuzione dei ruoli può considerarsi quasi dimenticata.

Così, i ruoli presentano due caratteristiche importanti: hanno dei valori predefiniti (una caratteristica unica) e permettono di strutturare il codice.

Tornando alla domanda iniziale: quando utilizzare task e quando ruoli? I task nel playbook sono spesso utilizzati sia come 'colla' prima/dopo i ruoli, sia come elemento costruttivo autonomo (in tal caso non dovrebbero esserci ruoli nel codice). Un insieme di normali task mescolati con ruoli è decisamente disordinato. Si dovrebbe seguire uno stile specifico: o task o ruoli. I ruoli offrono una separazione delle entità e valori predefiniti, i task permettono di leggere il codice più rapidamente. Di solito nei ruoli si collocano codici più 'stazionari' (importanti e complessi), mentre negli stili di task si scrivono script ausiliari.

È possibile fare l'importazione di un ruolo come attività, ma se lo fai, preparati a giustificare il tuo bisogno di farlo.

Un lettore pignolo potrebbe dire che i ruoli possono importare ruoli, che i ruoli possono avere una dipendenza tramite galaxy.yml, e c'è anche il terribile e spaventoso include_role — ricordo che stiamo migliorando le competenze di base in Ansible, non nella ginnastica ritmica.

Gestori e attività

Discutiamo un'altra cosa ovvia: i gestori. Saperli usare correttamente è quasi un'arte. Qual è la differenza tra un gestore e un'attività?

Poiché stiamo rinfrescando le basi, ecco un esempio:

- hosts: group1
  tasks:
    - foo:
      notify: handler1
  handlers:
     - name: handler1
       bar:

Nella funzione di un ruolo, i gestori si trovano in rolename/handlers/main.yaml. I gestori sono condivisi tra tutti i partecipanti al play: pre/post_tasks possono attivare i gestori dei ruoli, e un ruolo può attivare i gestori dal play. Tuttavia, le chiamate di gestori "cross-ruolo" generano molta più confusione rispetto alla ripetizione di un gestore triviale. (Un altro elemento delle best practices è cercare di non ripetere i nomi dei gestori).

La principale differenza è che un'attività viene eseguita (idempotentemente) sempre (più/meno tag e when), e un handler — per la modifica dello stato (notify si attiva solo se è stato cambiato). Qual è il rischio? Ad esempio, se al successivo avvio non c'è stato nessun cambiamento, allora non ci sarà nemmeno l'handler. Perché potremmo avere bisogno di eseguire l'handler quando non ci sono stati cambiamenti nel task generante? Ad esempio, perché qualcosa è andato storto e c'è stato un cambiamento, ma l'esecuzione non è arrivata all'handler. Ad esempio, perché la rete era momentaneamente giù. La configurazione è cambiata, ma il servizio non è stato riavviato. Al successivo avvio, la configurazione non cambierà più e il servizio rimarrà con la vecchia versione della configurazione.

La situazione con la configurazione è irrisolvibile (o meglio, si potrebbe inventare un protocollo speciale di riavvio con flag di file e ecc., ma questo non è già 'basic ansible' in nessun modo). Tuttavia, c'è un'altra storia comune: abbiamo installato un'applicazione, abbiamo registrato il suo .service-file, e ora vogliamo daemon_reload e state=started. E il posto naturale per questo sembra essere il handler. Ma se lo rendi non un handler ma un task alla fine della lista dei task o una funzione, allora verrà eseguito idempotentemente ogni volta. Anche se il playbook si è interrotto a metà. Questo non risolve affatto il problema di restarted (non puoi impostare un task con l'attributo restarted, poiché si perde l'idempotenza), ma è sicuramente utile impostare state=started, in quanto la stabilità complessiva del playbook aumenta, poiché si riducono le interconnessioni e lo stato dinamico.

Un'altra caratteristica positiva del handler è che non appesantisce l'output. Non ci sono stati cambiamenti — nessun inutile skipped o ok nell'output — quindi è più facile da leggere. Questo è anche un aspetto negativo: se trovi un errore di battitura in un task eseguito linearmente al primo tentativo, i handler verranno eseguiti solo in caso di cambiamenti, cioè in alcune condizioni — molto raramente. Ad esempio, la prima volta in cinque anni. E, ovviamente, ci sarà un errore di battitura nel nome e tutto si romperà. E la seconda volta non possono essere avviati — non ci sono cambiamenti.

È importante considerare la disponibilità delle variabili. Ad esempio, se si utilizza notify per un'attività con ciclo, quali variabili saranno disponibili? Si può dedurre analiticamente, ma non è sempre banale, specialmente se le variabili provengono da fonti diverse.

… Quindi i handler sono molto meno utili e più problematici di quanto sembri. Se è possibile scrivere qualcosa in modo pulito (senza complicazioni), è meglio farlo senza di essi. Se non risulta possibile farlo in modo elegante, è preferibile usarli.

Un lettore attento fa giustamente notare che non abbiamo discusso listen, che un handler può attivare notify per un altro handler, che un handler può includere import_tasks (che può eseguire include_role con with_items), che il sistema di handler in Ansible è turing-completo, che gli handler da include_role si intersecano in modo interessante con quelli del play, ecc. — tutto ciò non è chiaramente "fondamentale".

Tuttavia, c'è un certo WTF che in realtà è una funzionalità e di cui bisogna tenere conto. Se avete un'attività che viene eseguita con delegate_to e ha un notify, l'handler corrispondente viene eseguito senza delegate_to, cioè sull'host assegnato al play. (Anche se l'handler, ovviamente, potrebbe avere delegate_to anch'esso).

Voglio dire due parole sui ruoli riutilizzabili. Prima dell'arrivo delle collezioni, si pensava che fosse possibile creare ruoli universali, che si potessero ansible-galaxy install e via. Funziona su tutti i sistemi operativi in tutte le situazioni. Tuttavia, la mia opinione è che non funziona. Qualsiasi ruolo con supporto per 100500 casi è destinato a essere sommerso da bug corner case. Possono essere mitigati con test approfonditi, ma come per qualsiasi testing, o avete il prodotto cartesiano dei valori in ingresso con una funzione totale, oppure avete "coperti singoli scenari". La mia opinione è che è molto meglio avere un ruolo lineare (complessità ciclomatica 1). include_varsMeno if (sia espliciti che dichiarativi — nella forma

o nella forma when in base a un insieme di variabili), migliore è il ruolo. A volte è necessario fare ramificazioni, ma, ripeto, meno ce ne sono, meglio è. Quindi, sembra un buon ruolo con galaxy (funziona, giusto!) con una serie di include_vars per il set di variabili), tanto migliore è il ruolo. A volte è necessario fare delle diramazioni, ma, ripeto, meno ce ne sono, meglio è. Quindi sembra essere un buon ruolo con galaxy (funziona, dopotutto!) con un sacco di when può risultare meno preferibile rispetto al proprio ruolo tra le cinque attività. Il momento in cui il ruolo con galaxy è migliore è quando inizi a scrivere qualcosa. Il momento in cui diventa peggiore è quando qualcosa si rompe e hai il sospetto che dipenda dal 'ruolo con galaxy'. Lo apri e ci sono cinque inclusioni, otto liste di attività e una pila. when‘ov… E bisogna capire come gestire questa situazione. Invece di avere 5 attività in un elenco lineare, in cui non c'è nulla da rompere.

Nelle parti successive

  • Un po' di informazioni su inventory, variabili di gruppo, plugin host_group_vars, hostvars. Come collegare la spaghetti in un nodo di Gordio. Ambito e precedenza delle variabili, modello di memoria Ansible. "Dove devo memorizzare il nome utente per il database?".
  • jinja: {{ jinja }} — nosql notype nosense plastilina morbida. È ovunque, anche nei posti in cui non te lo aspetti. Un po' su !!unsafe e delizioso yaml.

Fonte: habr.com

Acquista hosting affidabile per siti web con protezione DDoS, VPS VDS server 🔥 Acquista hosting affidabile per siti web con protezione DDoS, VPS VDS server | ProHoster