Il costo della complessità: Ansible AWX
5 Maggio 2024
Configurazione di NTP su dispositivi Cisco IOS con Ansible
Questo articolo serve di esempio per vedere le diverse strategie per configurare una feature su dispositivi di rete utilizzando Ansible. In particolare ci occuperemo di configurare NTP su dispositivi Cisco IOS, tuttavia i ragionamenti che faremo qui possono essere generalizzati.
Come abbiamo accennato, in generale, ci sono due modalità possibili:
- possiamo usare moduli specifici di Ansible, nello specifico
cisco.ios.ios_ntp_global
; - possiamo usare il modulo di configurazione generico
cisco.ios.ios_config
.
I due approcci sono radicalmente diversi:
- L’uso di moduli specifici demanda al modulo stesso tutte le verifiche relative all’idempotenza, all’integrità e alla pulizia della configurazione. Se da una parte il modulo stesso fa tutto il lavoro, dall’altra dobbiamo fare attenzione che si comporti correttamente (bug free), che supporti tutte le impostazioni che ci servono, e sopratutto non possiamo effettuare ottimizzazioni di sorta.
- L’uso di moduli di configurazione generica da una parte ci permette ampia flessibilità di configurare qualsiasi parametro serve (molto utile in configurazioni complesse che coinvolgano, ad esempio BGP, MPLS, QoS…), dall’altra ci richiede di gestire correttamente idempotenza, pulizia e sopratutto ottimizzazione.
Vediamo nel dettaglio come configurate NTP su Cisco IOS nelle due modalità descritte, partendo da una lista di peer NTP definita a livello di configurazione:
ntp_peers:
- 0.pool.ntp.org
- 1.pool.ntp.org
- 2.pool.ntp.org
- 3.pool.ntp.org
- 4.pool.ntp.org
Utilizzo del modulo cisco.ios.ios_ntp_global
Quando utilizziamo moduli che non conosciamo, è sempre buona norma:
- rivedere la documentazione del modulo;
- effettuare dei test per verificare il comportamento in tutti i vari scenari possibili;
- assicurarsi, dopo eventuali upgrade, che il comportamento sia cambiato.
Rivedendo la documentazione vediamo subito che il modulo vuole la lista dei peer in un formato differente dal nostro. Poiché tutti i parametri sono standardizzati, da un punto di vista di design ha senso mantenere la lista dei peer NTP così come visto precedentemente, spostando la “traduzione” del formato nel ruolo:
- ansible.builtin.set_fact:
ntp_peers_config_list: "{{ ntp_peers_config_list|default([]) + [{'peer': item}] }}"
with_items: "{{ ntp_peers }}"
Ci accorgeremo immediatamente che stiamo introducendo un problema di performance che tratteremo in seguito.
A questo punto abbiamo una lista dei peer NTP nel formato corretto, non ci rimane che invocare il modulo:
- cisco.ios.ios_ntp_global:
config:
peers: "{{ ntp_peers_config_list }}"
state: replaced
L’unico punto aperto è il parametro state
che può assumere diversi valori:
merged
: la configurazione aggiunta va ad aggiungersi a quanto già presente (eventuali peer NTP esistenti non verranno rimossi).replaced
eoverridden
: la configurazione aggiunta va a sostituire quanto già presente (eventuali peer NTP esistenti verranno rimossi).deleted
: la configurazione esistente verrà rimossa e nulla verrà configurato.
In questo caso le modalità replaced
e overridden
sono identiche. In generale il comportamento va sempre verificato con l’apposita documentazione, ma possiamo dire che in generale:
replaced
: singole configurazioni presenti nel dispositivo e non previste vengono prima rimosse e poi reinserite correttamente.overridden
: tutta la configurazione presente nel dispositivo viene cancellata e poi reinserita.
Se nel modulo
cisco.ios.ios_config
il comportamento è identico, nel modulo
cisco.ios.ios_acls
il comportamento differisce poiché le ACL sono composte da sottoblocchi:
replaced
in questo caso toglie le singole ACE (access control entry) da ciascuna ACL, per poi inserire le ACE corrette.overridden
in questo caso cancella le ACL per poi reinserirle corrette.
Esistono poi tre valori che non modificheranno lo stato della configurazione e che possono essere usati per altre elaborazioni, come il debugging:
rendered
: la configurazione che verrebbe aggiunta viene salvata in output nella chiaverendered
.gathered
: la configurazione presente nel dispositivo viene letta e salvata in output nel formato JSON usando la chiavegathered
.parsed
: comegathered
ma l’input non è la configurazione del dispositivo, ma quanto presente nella variabilerunning_config
. L’output viene salvato nel formato JSON usando la chiaveparsed
.
Data la complessità delle configurazioni che è possibile effettuare su dispositivi di rete, dovrebbe apparire chiaro perché è importante verificare che il modulo si comporti come ci aspettiamo, che supporti le configurazioni che ci servono e che il comportamento rimanga il medesimo in caso di aggiornamenti.
La configurazione non viene salvata automaticamente al termine del task: appare chiaro che in un playbook che invoca decine di moduli diversi, se la configurazione fosse salvata ad ogni task, avremmo un possibile problema di performance. Inoltre dobbiamo tener presente che, poiché stiamo di fatto facendo text scraping, l’esecuzione di ciascun modulo prevede di leggere la configurazione tramite il comando show running-config
: questo può generare un ulteriore problema di performance.
Utilizzo del modulo generico
Il modulo generico
cisco.ios.ios_config
ci da piena libertà di aggiungere e togliere qualsiasi configurazione ci venga in mente. Rimane anche a noi, se lo riteniamo opportuno, l’onere di gestire idempotenza, correttezza ed efficienza.
Il modulo
cisco.ios.ios_config
:
- Come nel caso precedente, esegue automaticamente il comando
show running-config
. L’output verrà usato per verificare se determinati comandi già esistono e influenza quindi pesantemente lo statochanged
. Ad esempio se configuriamoint e0/0
, ad ogni esecuzione Ansible immetterà nuovamente la configurazione perché nella configurazione è presente inveceinterface Ethernet0/0
. Deduciamo quindi che dobbiamo essere estremamente precisi nei comandi che andiamo ad eseguire. Ad ogni change Ansible ci avviserà con il seguente messaggio: To ensure idempotency and correct diff the input configuration lines should be similar to how they appear if present in the running configuration on device. - Diversamente dal caso precedente, la configurazione può essere letta dalla variabile
running_config
, il che ci permette di ottimizzare playbook complessi. - Per come funzionano i dispositivi Cisco, i comandi di default non sono mostrati nella configurazione. Inserire, o meglio riportare un parametro al suo default comporta che Ansible dichiarerà sempre lo stato
changed
. Questo perché il comando inserito (default) non verrà mai verificato come presente nella configurazione. Dobbiamo quindi gestire manualmente questo evento. - Per come funzionano i dispositivi Cisco, non posso dichiarare una lista di peer NTP, ma devo aggiungere i mancanti e togliere i necessari. Diversamente dal caso precedente, sta a noi l’onere di implementare correttamente questo comportamento.
Un approccio semplificato che chiamo “blind” (cieco), prevede di eseguire comunque dei comandi anche se non necessari, come as esempio la rimozione di uno specifico peer NTP. Questo approccio ha sicuramente il vantaggio di essere semplice e immediato, ma richiede di prevedere in anticipo tutti i casi possibili e di avere sempre lo stato changed
, vanificando quindi la possibilità di verificare la “compliance”.
Detto questo, il nostro playbook deve:
- aggiungere i peer NTP che sono presenti nella lista ma non presenti nella configurazione del dispositivo;
- togliere i peer NTP che sono presenti nella configurazione del dispositivo ma non nella lista.
La prima parte è estremamente semplice:
- cisco.ios.ios_config:
lines:
- "ntp peer {{ item }}"
replace: line
save_when: never
with_items: "{{ ntp_peers }}"
Ci sono due parametri da vedere con attenzione:
replace
: prende come valoreline
(default) oblock
e definisce come la configurazione viene applicata. Conline
vengono inviati i singoli comandi non presenti nella configurazione, conblock
vengono inviati tutti i comandi anche se uno solo differisce dalla configurazione.save_when
: definisce quando la configurazione viene salvata e i valori possono essere:always
,never
(default),modified
,changed
.
Occorre ora rimuovere i peer NTP non più necessari. Per far questo dobbiamo prima estrarre tutti i server NTP configurati:
- ansible.builtin.set_fact:
current_ntp_peers: "{{ running_config | regex_findall('^ntp peer .*$', multiline=True) | regex_replace('ntp peer ', '')}}"
Infine possiamo rimuovere i server NTP non necessari, ossia quelli che sono configurati ma non presenti nella lista ntp_peers
:
- cisco.ios.ios_config:
lines:
- "no ntp peer {{ item }}"
running_config: "{{ running_config }}"
replace: line
save_when: never
with_items: "{{ current_ntp_peers }}"
when: item not in ntp_peers
Questo approccio ci permette ampia libertà ma allo stesso tempo risulta complesso gestire configurazioni a blocchi innestati (come il routing).
Salvare la configurazione
A questo punto i dispositivi avranno la configurazione desiderata, ma essa non è salvata. Per fare ciò abbiamo tre approcci:
handler
: che ci permette di “attivare” un task se esso risulta nello statochanged
. Questa modalità è la più semplice ma ha un problema: se il playbook si interrompe, non è detto che la successiva esecuzione riporti ancora uno statochanged
e di conseguenza attivi il task dihandler
.- Salvare sempre la configurazione alla fine del playbook: questo approccio è il più semplice, tuttavia per ogni esecuzione la configurazione verrà salvata e non avremo mai visibilità di quando la configurazione del router necessità effettivamente di essere salvata.
- Verificare se le configurazioni
running
e distartup
sono diverse e, in questo caso, salvare effettivamente la configurazione.
Vediamo prima come salvare la configurazione:
- cisco.ios.ios_command:
commands: write memory
Abbiamo usato write memory
invece del moderno copy running-config startup-config
perché il primo non chiede conferma, mentre il secondo si: gestire la conferma sarebbe una complessità inutile.
Questo task può essere configurato all’interno dei post_tasks
, o richiamato come handler
da tutti i task che potenzialmente modificano la configurazione.
I task diventano quindi:
- cisco.ios.ios_config:
lines:
- "ntp peer {{ item }}"
replace: line
save_when: never
with_items: "{{ ntp_peers }}"
notify: SAVING CONFIGURATION
Mentre il task per salvare la configurazione deve essere configurato come handler
, preferibilmente all’interno del file handlers/main.yml
.
Ottimizzare le performance
Dobbiamo ora ottimizzare il salvataggio della configurazione, effettuandolo solo se necessario. Da un punto di vista procedurale questo significa comparare la startup-config
con la running-config
, e, in caso di differenze, effettuare il salvataggio.
Vediamo quindi passo passo come operare. Inizialmente occorre leggere la startup-config
e la running-config
aggiornate:
- cisco.ios.ios_command:
commands: show startup-config
register: show_startup_config_output
- cisco.ios.ios_command:
commands: show running-config
register: show_running_config_output
Dobbiamo quindi verificare se le due configurazioni differiscono, al netto però di alcune righe.
- ansible.utils.fact_diff:
before: "{{ show_startup_config_output.stdout[0] }}"
after: "{{ show_running_config_output.stdout[0] }}"
plugin:
vars:
skip_lines:
- "^Current configuration.*"
- "^Building configuration.*"
register: config_differ
Possiamo quindi condizionare il salvataggio della configurazione dal risultato del task precedente usando config_differ.changed
.
Un’accortezza: il processo di salvataggio andrebbe fatto solo se il playbook non è in check mode (-C
). Infine dobbiamo ricordarci che il task di salvataggio andrebbe eseguito anche se il playbook viene eseguito includendo solo alcuni tags
.
Conclusioni
Nonostante tutto è da preferire l’uso di moduli specializzati, se bug free e se implementano tutte le funzionalità che ci servono. Questo vale anche se dovessimo incontrare problemi di performance. Tuttavia nella mia esperienza sarà spesso necessario utilizzare anche il modulo di configurazione generica per configurare specifici dettagli non previsti.