Konfiguration von NTP auf Cisco IOS mit Ansible

Andrea Dainese
04 November 2023
Post cover

Dieser Artikel dient als Beispiel zur Erkundung verschiedener Strategien zur Konfiguration eines Features auf Netzwerkgeräten unter Verwendung von Ansible. Konkret werden wir uns auf die Konfiguration von NTP auf Cisco IOS-Geräten konzentrieren, obwohl die Diskussionen hier verallgemeinert werden können.

Wie bereits erwähnt, gibt es im Allgemeinen zwei mögliche Ansätze:

Die beiden Ansätze unterscheiden sich grundlegend:

  • Die Verwendung spezifischer Module delegiert alle Überprüfungen hinsichtlich Idempotenz, Integrität und Sauberkeit der Konfiguration an das Modul selbst. Während das Modul die gesamte Arbeit erledigt, müssen wir sicherstellen, dass es sich korrekt verhält (fehlerfrei), alle benötigten Einstellungen unterstützt und insbesondere keine Optimierungen vornehmen können.
  • Die Verwendung generischer Konfigurationsmodule ermöglicht es uns, beliebige erforderliche Parameter zu konfigurieren (sehr nützlich bei komplexen Konfigurationen, die beispielsweise BGP, MPLS, QoS usw. umfassen), erfordert jedoch, dass wir Idempotenz, Sauberkeit und insbesondere Optimierung korrekt verwalten.

Lassen Sie uns im Detail sehen, wie NTP auf Cisco IOS in den beiden beschriebenen Modi konfiguriert wird, beginnend mit einer Liste von auf Konfigurationsebene definierten NTP-Peers:

ntp_peers:
  - 0.pool.ntp.org
  - 1.pool.ntp.org
  - 2.pool.ntp.org
  - 3.pool.ntp.org
  - 4.pool.ntp.org

Verwendung des Moduls cisco.ios.ios_ntp_global module

Bei der Verwendung von Modulen, mit denen wir nicht vertraut sind, ist es immer ratsam:

  • Überprüfen Sie die Moduldokumentation;
  • Führen Sie Tests durch, um das Verhalten in allen möglichen Szenarien zu überprüfen;
  • Stellen Sie sicher, dass sich das Verhalten nach Upgrades geändert hat.

Eine Überprüfung der Dokumentation zeigt sofort, dass das Modul die Liste der Peers in einem anderen Format als unseres benötigt. Da alle Parameter standardisiert sind, macht es aus designtechnischer Sicht Sinn, die Liste der NTP-Peers wie zuvor gesehen beizubehalten und die “Übersetzung” des Formats in die Rolle zu verschieben:

- ansible.builtin.set_fact:
    ntp_peers_config_list: "{{ ntp_peers_config_list|default([]) + [{'peer': item}] }}"
  with_items: "{{ ntp_peers }}"

Wir werden sofort feststellen, dass wir ein Leistungsproblem einführen, das wir später angehen werden.

An diesem Punkt haben wir eine Liste von NTP-Peers im richtigen Format, alles, was wir tun müssen, ist das Modul aufzurufen:

- cisco.ios.ios_ntp_global:
    config:
      peers: "{{ ntp_peers_config_list }}"
    state: replaced

Der einzige offene Punkt ist der state-Parameter, der verschiedene Werte annehmen kann:

  • merged: Die hinzugefügte Konfiguration wird zu dem hinzugefügt, was bereits vorhanden ist (bereits vorhandene NTP-Peers werden nicht entfernt).
  • replaced und overridden: Die hinzugefügte Konfiguration ersetzt, was bereits vorhanden ist (bereits vorhandene NTP-Peers werden entfernt).
  • deleted: Die vorhandene Konfiguration wird entfernt, und es wird nichts konfiguriert.

In diesem Fall sind die Modi replaced und overridden identisch. Im Allgemeinen sollte das Verhalten immer mit der entsprechenden Dokumentation überprüft werden, aber wir können sagen, dass:

  • replaced: Individuelle Konfigurationen, die im Gerät vorhanden sind und nicht erwartet werden, werden zuerst entfernt und dann korrekt wieder eingefügt.
  • overridden: Die gesamte Konfiguration, die im Gerät vorhanden ist, wird gelöscht und dann korrekt wieder eingefügt.

Wenn im Modul cisco.ios.ios_config das Verhalten identisch ist, unterscheidet sich im Modul cisco.ios.ios_aclss das Verhalten, weil ACLs aus Teilblöcken bestehen:

  • replaced Entfernt in diesem Fall einzelne ACEs (Zugriffssteuerungseinträge) aus jeder ACL und fügt dann die richtigen ACEs ein.
  • overridden Entfernt in diesem Fall die ACLs und fügt sie dann korrekt wieder ein.

Es gibt dann drei Werte, die den Zustand der Konfiguration nicht ändern und für andere Bearbeitungen wie Debugging verwendet werden können:

  • rendered: Die hinzugefügte Konfiguration wird im Ausgabedatei im Schlüssel rendered gespeichert.
  • gathered: Die Konfiguration auf dem Gerät wird gelesen und im Ausgabedatei im JSON-Format unter Verwendung des Schlüssels gathered gespeichert.
  • parsed: Ähnlich wie gathered, aber die Eingabe ist nicht die Gerätekonfiguration, sondern das, was im running_config-Variablen vorhanden ist. Die Ausgabe wird im JSON-Format unter Verwendung des Schlüssels parsed gespeichert.

Angesichts der Komplexität der Konfigurationen, die auf Netzwerkgeräten durchgeführt werden können, sollte klar sein, warum es wichtig ist, zu überprüfen, ob das Modul wie erwartet funktioniert, die von uns benötigten Konfigurationen unterstützt und das Verhalten bei Updates gleich bleibt.

Die Konfiguration wird nicht automatisch am Ende der Aufgabe gespeichert: Es ist klar, dass in einem Playbook, das Dutzende verschiedener Module aufruft, wenn die Konfiguration nach jeder Aufgabe gespeichert würde, wir ein mögliches Leistungsproblem hätten. Darüber hinaus müssen wir bedenken, dass wir effektiv Text-Scraping durchführen, die Ausführung jedes Moduls das Lesen der Konfiguration unter Verwendung des Befehls show running-config beinhaltet: Dies kann ein zusätzliches Leistungsproblem verursachen.

Verwendung des generischen Moduls

Das generische Modul cisco.ios.ios_config gibt uns die volle Freiheit, jede Konfiguration hinzuzufügen und zu entfernen, die wir uns vorstellen können. Es liegt an uns, falls wir es für angemessen halten, Idempotenz, Korrektheit und Effizienz zu verwalten.

Das Modul cisco.ios.ios_config:

  • Wie im vorherigen Fall, führt automatisch den Befehl show running-config aus. Die Ausgabe wird verwendet, um zu überprüfen, ob bestimmte Befehle bereits vorhanden sind und beeinflusst somit stark den changed-Zustand. Wenn wir beispielsweise int e0/0 konfigurieren, wird bei jeder Ausführung von Ansible die Konfiguration erneut eingegeben, weil interface Ethernet0/0 stattdessen in der Konfiguration vorhanden ist. Daraus schließen wir, dass wir äußerst präzise in den Befehlen sein müssen, die wir ausführen. Bei jeder Änderung benachrichtigt uns Ansible mit der folgenden Nachricht: Um Idempotenz und eine korrekte Differenz zu gewährleisten, sollten die Eingabekonfigurationszeilen ähnlich sein wie sie erscheinen, wenn sie in der Laufkonfiguration auf dem Gerät vorhanden sind.
  • Im Gegensatz zum vorherigen Fall kann die Konfiguration aus der running_config-Variablen gelesen werden, was es uns ermöglicht, komplexe Playbooks zu optimieren.
  • Aufgrund der Funktionsweise von Cisco-Geräten werden Standardbefehle nicht in der Konfiguration angezeigt. Das Einfügen oder besser das Wiederherstellen eines Parameters in sein Standardverhalten bedeutet, dass Ansible immer den changed-Zustand erklärt. Dies liegt daran, dass der eingefügte (Standard-)Befehl niemals als vorhanden in der Konfiguration überprüft wird. Daher müssen wir dieses Ereignis manuell verwalten.
  • Aufgrund der Funktionsweise von Cisco-Geräten kann ich keine Liste von NTP-Peers deklarieren, sondern ich muss die fehlenden hinzufügen und die notwendigen entfernen. Im Gegensatz zum vorherigen Fall liegt es an uns, dieses Verhalten richtig zu implementieren.

Ein vereinfachter Ansatz, den ich “blind” nenne, besteht darin, Befehle trotzdem auszuführen, auch wenn sie nicht notwendig sind, z. B. das Entfernen eines bestimmten NTP-Peers. Dieser Ansatz hat sicherlich den Vorteil, einfach und sofortig zu sein, er erfordert jedoch, alle möglichen Fälle vorauszusehen und führt immer zum changed-Zustand, was die Möglichkeit der Überprüfung der “Compliance” zunichte macht.

Das gesagt, unser Playbook muss:

  • die NTP-Peers hinzufügen, die in der Liste vorhanden sind, aber nicht in der Gerätekonfiguration vorhanden sind;
  • die NTP-Peers entfernen, die in der Gerätekonfiguration vorhanden sind, aber nicht in der Liste.

Der erste Teil ist äußerst einfach:

- cisco.ios.ios_config:
    lines:
      - "ntp peer {{ item }}"
    replace: line
    save_when: never
  with_items: "{{ ntp_peers }}"

Der erste Teil ist äußerst einfach:

  • replace: Nimmt line (Standard) oder block als Wert und definiert, wie die Konfiguration angewendet wird. Mit line werden einzelne Befehle, die nicht in der Konfiguration vorhanden sind, gesendet, mit block werden alle Befehle gesendet, auch wenn einer von der Konfiguration abweicht.
  • save_when: Definiert, wann die Konfiguration gespeichert wird, und die Werte können sein: always, never (Standard), modified, changed.

Nun müssen wir die nicht mehr erforderlichen NTP-Peers entfernen. Dazu müssen wir zunächst alle konfigurierten NTP-Server extrahieren:

- ansible.builtin.set_fact:
    current_ntp_peers: "{{ running_config | regex_findall('^ntp peer .*$', multiline=True) | regex_replace('ntp peer ', '')}}"

Schließlich können wir die nicht benötigten NTP-Server entfernen, d. h. diejenigen, die konfiguriert sind, aber nicht in der ntp_peers-Liste vorhanden sind:

- 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

Dieser Ansatz gibt uns viel Freiheit, ist aber gleichzeitig komplex in der Verwaltung von verschachtelten Blockkonfigurationen (wie Routing).

Konfiguration speichern

An diesem Punkt haben die Geräte die gewünschte Konfiguration, aber sie wird nicht gespeichert. Dazu haben wir drei Ansätze:

  • handler: Der es uns ermöglicht, eine Aufgabe auszuführen, wenn sie den changed-Zustand verursacht. Diese Methode ist die einfachste, hat jedoch ein Problem: Wenn das Playbook stoppt, besteht keine Garantie, dass die nächste Ausführung immer noch einen changed-Zustand meldet und folglich die handler-Aufgabe auslöst.
  • Die Konfiguration am Ende des Playbooks immer speichern: Diese Methode ist die einfachste, jedoch wird bei jeder Ausführung die Konfiguration gespeichert, und wir haben nie die Möglichkeit festzustellen, wann die Routerkonfiguration tatsächlich gespeichert werden muss.
  • Überprüfen, ob sich die running- und startup-Konfigurationen unterscheiden und in diesem Fall tatsächlich die Konfiguration speichern.

Lassen Sie uns zunächst sehen, wie die Konfiguration gespeichert wird:

- cisco.ios.ios_command:
    commands: write memory

Wir haben write memory anstelle des modernen copy running-config startup-config verwendet, weil ersterer keine Bestätigung erfordert, während letzterer dies tut: Die Verwaltung der Bestätigung wäre eine unnötige Komplexität.

Diese Aufgabe kann innerhalb der post_tasks konfiguriert oder als handler von allen Aufgaben aufgerufen werden, die möglicherweise die Konfiguration ändern.

Die Aufgaben werden wie folgt:

- cisco.ios.ios_config:
    lines:
      - "ntp peer {{ item }}"
    replace: line
    save_when: never
  with_items: "{{ ntp_peers }}"
  notify: SAVING CONFIGURATION

Während die Aufgabe zum Speichern der Konfiguration als handler konfiguriert sein muss, idealerweise in der Datei handlers/main.yml.

Leistung optimieren

Wir müssen jetzt die Konfigurationsoptimierung vornehmen und sie nur durchführen, wenn dies erforderlich ist. Prozedural bedeutet dies, die startup-config mit der running-config zu vergleichen und bei Unterschieden die Speicherung durchzuführen.

Lassen Sie uns schrittweise vorgehen. Zunächst müssen wir die aktualisierte startup-config und running-config lesen:

- 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

Wir müssen dann überprüfen, ob sich die beiden Konfigurationen unterscheiden, unter Berücksichtigung einiger Zeilen.

- 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

Wir können dann die Speicherung der Konfiguration vom Ergebnis der vorherigen Aufgabe abhängig machen, indem wir config_differ.changed verwenden.

Eine Vorsichtsmaßnahme: Der Speichervorgang sollte nur erfolgen, wenn das Playbook nicht im Überprüfungsmodus (-C) ausgeführt wird. Schließlich müssen wir daran denken, dass die Speicheraufgabe auch ausgeführt werden sollte, wenn das Playbook nur einige tags enthält.

Schlussfolgerungen

Trotz allem ist es ratsam, spezialisierte Module zu verwenden, wenn sie fehlerfrei sind und alle Funktionen, die wir benötigen, implementieren. Dies gilt auch, wenn Leistungsprobleme auftreten. In meiner Erfahrung wird es jedoch oft notwendig sein, das generische Konfigurationsmodul zu verwenden, um spezifische Details zu konfigurieren, die nicht vorhergesehen sind.