Ansible Cisco Network Automation 101: Playbook Examples - 夜莺博客

Ansible Cisco Network Automation 101: Playbook Examples

Ansible is the easiest entry point into network automation: playbooks are written in YAML, no agent runs on the device, and the same structure works for Cisco, Juniper and other platforms. This tutorial walks through installing Ansible, building the directory structure (ansible.cfg, inventory, group_vars), running show commands with ios_command, making configuration changes with ios_config, and understanding idempotency and desired state.

Ansible Cisco network automation playbook example

The goal of this first playbook is not to replace your change process, it is to make the mechanical part of that change boring. Once a playbook can read a VLAN table from a file and push it to twenty access switches, the interesting engineering problems - which VLAN belongs where, what happens when a trunk loses its allowed list, how do you prove the change landed - become the only things you still have to think about. Everything below is written against Cisco IOS and IOS-XE, but the identical pattern maps to NX-OS and IOS-XR with a different collection.

By the end of this article you will have a working control node, a repeatable inventory, a facts-gathering playbook, a configuration playbook that only touches what it must, and a backup and rollback story you can run in front of a change advisory board without embarrassment.

Why Ansible for Cisco Network Automation

There are three honest reasons Ansible keeps winning the first-round fight for network teams:

  • Agentless. The control node speaks SSH or NETCONF to the device using the credentials you already have. Nothing to install, nothing to license, no image upgrade window.
  • Declarative enough to be safe. A module such as ios_config compares desired lines against the running configuration and reports changed only when the device actually needed the change. That property is what lets you run the same playbook on a Monday and again on a Friday and get the same result.
  • The ecosystem is already written. cisco.ios, cisco.nxos, arista.eos, junipernetworks.junos and dozens more ship resource modules for interfaces, L2 interfaces, L3 interfaces, VLANs, ACLs, OSPF and BGP - you are wiring them together rather than writing them.

Ansible is not a source of truth and it is not a state database. It will happily push a wrong configuration to the whole fleet because you typed it. Pair it with a template or an IPAM source later; get the plumbing right first.

Prerequisites

  • A control node: any Linux, macOS or Windows Subsystem for Linux host with Python 3.9 or newer. This can be a jump host inside the management network.
  • IP reachability from the control node to the management addresses of the switches, plus a username with privilege 15 (or a lower-privileged user plus enable).
  • SSH enabled on the devices. On IOS-XE: ip domain-name, a generated RSA key of 2048 bits or more, and line vty 0 15 with transport input ssh.
  • A decision about secrets: Ansible Vault from day one, or a credential store you will integrate later. Do not start with plain text and promise yourself you will fix it.

Installing Ansible

Use a virtual environment so that the version of Ansible your automation depends on is never upgraded underneath you by a distribution package manager.

python3 -m venv venv
source venv/bin/activate
pip install ansible

Then install the vendor collections you intend to use. The cisco.ios collection is what provides ios_command, ios_config, ios_facts, ios_l2_interfaces and the rest:

ansible-galaxy collection install cisco.ios
ansible-galaxy collection install ansible.netcommon
ansible-galaxy collection list | grep -i cisco

Verify the control node can reach a device before you write anything clever:

ssh admin@10.10.1.50
ansible all -m ping   # from inside the project directory, once the inventory exists

Directory Structure

A network automation project is a small application. Give it a layout on the first day so that the second playbook has somewhere to live.

ansible-network-101
.
├── ansible.cfg
├── inventory
│   ├── group_vars
│   │   └── switches.yml
│   ├── host_vars
│   └── hostfile.ini
└── playbook.yml

A sane growth path from this skeleton: playbooks/ for site-specific playbooks, templates/ for Jinja2 configuration templates, files/ for static artefacts, filter_plugins/ for custom filters, and requirements.yml pinning every collection version so a colleague can reproduce your control node in one command. Commit the project to Git - an automation change that is not reviewed is just a faster way to make the same mistake.

ansible.cfg and Inventory

The ansible.cfg in the project directory overrides the system-wide configuration, which is exactly what you want: the project's behaviour travels with the project.

# ansible.cfg
[defaults]
host_key_checking = False
inventory = inventory/hostfile.ini

# inventory/hostfile.ini
[switches]
switch_01 ansible_host=10.10.1.50

Two notes that save hours. First, host_key_checking = False is acceptable for a lab and for devices that are rebuilt constantly, but in production it is better to pre-populate known_hosts and keep checking on. Second, give every device a stable ansible_host and use a descriptive inventory name for the device - switch_01 in the playbook output is far easier to read in a wall of changed lines than a bare address.

Scale the inventory by function rather than by geography, then use groups to express both:

[core_switches]
core01 ansible_host=10.10.1.2
[access_switches]
access01 ansible_host=10.10.2.11
access02 ansible_host=10.10.2.12

[site_london:children]
core_switches
access_switches

Group Vars for Network Devices

# inventory/group_vars/switches.yml
---
ansible_connection: ansible.netcommon.network_cli
ansible_network_os: cisco.ios.ios
ansible_become: yes
ansible_become_method: enable
ansible_user: admin
ansible_password: cisco123
ansible_become_password: cisco123

network_cli tells Ansible to treat the host as a network device; ansible_become_method: enable enters Cisco privilege mode before executing tasks. Avoid storing plain-text passwords in production - use Ansible Vault.

The Vault workflow is only slightly more work and removes the worst failure mode of network automation projects (credentials in Git history):

ansible-vault create inventory/group_vars/switches/vault.yml
# inside, store: vault_ansible_password: cisco123
ansible-vault encrypt_string 'cisco123' --name 'ansible_password'
ansible-playbook playbook.yml --ask-vault-pass

Reference the vaulted value from the unencrypted variable file so the readable file stays reviewable:

# inventory/group_vars/switches/vars.yml
ansible_password: "{{ vault_ansible_password }}"
ansible_become_password: "{{ vault_ansible_password }}"

Running Show Commands

---
- name: "Ansible 101"
  hosts: switches
  gather_facts: no

  tasks:
    - name: Show Version
      cisco.ios.ios_command:
        commands: show version
      register: output

    - name: Print output
      debug:
        msg: "{{ output.stdout_lines }}"

Run with ansible-playbook playbook.yml.

gather_facts: no matters on network devices: the default fact gathering assumes a Python interpreter on the target and will fail or waste time. Use ios_facts when you actually want device facts. Ask for several commands at once and index the results:

    - name: Collect several operational commands
      cisco.ios.ios_command:
        commands:
          - show ip interface brief
          - show vlan brief
          - show interfaces status
      register: sh

    - name: Report interfaces that are down/notconnect
      debug:
        msg: "{{ sh.stdout[2] | split_lines_to_list | select('search', 'notconnect') | list }}"

For anything you intend to assert on, prefer machine-readable output. IOS-XE supports | json on most show commands, which turns a screen-scrape into structured data you can index, diff and store:

    - name: Structured output
      cisco.ios.ios_command:
        commands:
          - command: show interfaces
            output: json
      register: if_json

    - name: Print the interface name of the first erroring port
      debug:
        msg: "{{ (if_json.stdout[0].interfaces | dict2items | selectattr('value.counters.in_errors','gt',0) | list) }}"

A useful discipline: keep "read" playbooks completely free of configuration modules. Read-only playbooks can be run by anyone, at any time, including from a monitoring job - and they build the trust that lets you eventually run write playbooks unattended.

Making a Configuration Change

---
- name: "Create VLANs"
  hosts: switches
  gather_facts: no

  tasks:
    - name: VLAN Config
      cisco.ios.ios_config:
        lines:
          - vlan 30
          - name server_vlan
          - vlan 31
          - name user_vlan

This is the smallest useful write playbook, and it already exhibits the behaviour that makes Ansible safe. Under the hood the module enters configuration mode, and for each line asks the device whether it is present in the running configuration. Lines that already exist are skipped; the module reports changed only if at least one line was added.

Two options turn this into something production-shaped:

  • parents - the context lines that must be entered before lines (for example interface Te1/0/1). Use it instead of putting the parent in lines, because the module then treats the block as one unit.
  • save_when - controls when write memory is issued. save_when: modified is the setting you want on IOS; the default leaves the change in the running configuration only, which is a good safety property during a change window and a bad one when you close the ticket.
    - name: Configure NTP with parents and explicit save
      cisco.ios.ios_config:
        lines:
          - ntp server 10.10.0.10 prefer
          - ntp server 10.10.0.11
        parents: ntp
        save_when: modified
        backup: yes
      register: ntp_result

    - name: Show what changed
      debug:
        var: ntp_result.commands

Interface Configuration

Resource modules describe the desired state of an object, not the commands that produce it. That is a different mental model from lines, and it is the one to prefer wherever a module exists for the object you are configuring.

    - name: Port Configuration
      cisco.ios.ios_l2_interfaces:
        config:
          - name: Te1/0/1
            mode: access
            access:
              vlan: 30

The same pattern covers routed ports, SVIs and VLAN definitions, and it removes a category of bug that plagues line-based playbooks: the playbook that adds switchport access vlan 30 to a port that is also a trunk, because nothing in the YAML expressed "this port is an access port" as a complete statement.

    - name: Routed uplink with a description and IP address
      cisco.ios.ios_l3_interfaces:
        config:
          - name: Gi1/0/24
            ipv4:
              - address: 10.10.99.1/30
      cisco.ios.ios_interfaces:
        config:
          - name: Gi1/0/24
            description: uplink-to-core01
            enabled: true

Idempotency and Desired State

Ansible is declarative: you define the desired state (for example, two VLANs) and Ansible ensures the switch matches it. Run the playbook a second time and it reports changed=0 because the configuration already matches - this makes automation safe to re-run and keeps the network consistent.

Idempotency is not a property of Ansible; it is a property of how you write the playbook, and it is easy to lose. A task that appends to an existing ACL, or that issues a clear or a reload, is not idempotent no matter which module runs it. A practical rule for network playbooks: if you cannot run the playbook twice in a row during a maintenance window without changing the outcome or disturbing traffic, it is not ready for automation.

Two habits follow from that rule. First, make every write playbook emit evidence - register the module output and print the commands that were applied, so the change record contains the exact delta. Second, record the running configuration before and after with backup: yes and keep the two files: diff before after is the fastest possible answer when someone asks what changed at 02:14.

Check Mode and --diff: Seeing the Change Before It Happens

Run any playbook with --check --diff and the supported modules will report what would change without touching the device:

ansible-playbook playbook.yml --check --diff

On network devices, check mode support varies by module and by connection type, so treat a clean check run as evidence rather than proof. It is still the single best habit in this article: the check run is where you find the typo in a VLAN ID, in the middle of the afternoon, rather than in a change window.

Detecting and Repairing Configuration Drift

The flip side of idempotency is drift detection. Because ios_config compares each line against the running configuration, the same playbook is a drift check: run it in check mode on a schedule and any changed result means somebody - a person, a script, or a previous partial run - moved the device away from the intended state.

    - name: Drift check for the standard access-port block
      cisco.ios.ios_config:
        lines:
          - switchport mode access
          - switchport access vlan 30
          - spanning-tree portfast
          - spanning-tree bpduguard enable
        parents: "interface {{ item }}"
        match: line
      loop: "{{ access_ports }}"
      check_mode: yes
      changed_when: false   # in a report job we only care about the diff
      register: drift

For a belt-and-braces approach, schedule a separate playbook that pulls show running-config nightly and diffs it against the last known-good copy. That is exactly what a dedicated configuration backup tool does, and there is no shame in running one alongside Ansible - see our guide to Ansible network facts and configuration backup for the module-level approach, and the Netmiko-based fallback if you need something that works on devices where an Ansible collection does not exist.

Backing Up Configurations Before a Change

Almost every configuration module accepts backup: yes. The module writes a timestamped copy of the running configuration to a backup/ folder on the control node before it applies anything:

    - name: Always keep a pre-change copy
      cisco.ios.ios_config:
        lines: "{{ new_lines }}"
        parents: "{{ context }}"
        backup: yes
        save_when: modified
      register: cfg

Make the backup path a directory that is itself under version control or copied into object storage, not a scratch folder on the control node. A backup that only exists on the laptop of the person who ran the playbook is not a backup.

Provisioning a VLAN End to End

Single-module examples are easy; the value is in composing them. This playbook reads a VLAN list from a variables file, creates the VLANs, and then configures the access ports that belong to each VLAN - with the pre-change backup and the save in the right places.

# vars/vlans.yml
--- 
vlans:
  - {id: 30, name: server_vlan, ports: [Gi1/0/1, Gi1/0/2]}
  - {id: 31, name: user_vlan,   ports: [Gi1/0/3, Gi1/0/4]}

# playbooks/provision_vlans.yml
---
- name: Provision VLANs and access ports
  hosts: access_switches
  gather_facts: no
  vars_files:
    - ../vars/vlans.yml

  tasks:
    - name: Ensure VLANs exist
      cisco.ios.ios_config:
        lines:
          - "vlan {{ item.id }}"
          - "name {{ item.name }}"
      loop: "{{ vlans }}"
      loop_control:
        label: "vlan {{ item.id }} {{ item.name }}"

    - name: Configure access ports
      cisco.ios.ios_l2_interfaces:
        config:
          - name: "{{ item.0 }}"
            mode: access
            access:
              vlan: "{{ item.1.id }}"
      loop: "{{ vlans | subelements('ports') }}"
      loop_control:
        label: "{{ item.0 }} -> vlan {{ item.1.id }}"

    - name: Verify the ports landed in the right VLAN
      cisco.ios.ios_command:
        commands:
          - "show vlan brief"
      register: vlancheck

    - name: Fail loudly if a port is missing
      assert:
        that:
          - "item.0 in vlancheck.stdout[0]"
        fail_msg: "{{ item.0 }} is not present in show vlan brief"
      loop: "{{ vlans | subelements('ports') }}"
      loop_control:
        label: "{{ item.0 }}"

    - name: Save the configuration
      cisco.ios.ios_config:
        save_when: modified
      changed_when: false

Three details in that playbook are the difference between a demo and something you would run on a Tuesday. The verification task runs in the same play, so the change and its evidence are produced together. The assertion turns a silent failure into a failed run and a non-zero exit code, which is what your CI job and your change record both need. And save_when: modified is deferred to a single final task, so a failure in the middle leaves the running configuration changed but unsaved - the device still boots to the previous state if you lose power.

Error Handling, Retries and Throttling

Network devices are slow and occasionally busy. Two mechanisms handle almost every real failure:

    - name: Retry a command that may time out
      cisco.ios.ios_command:
        commands: show version
      register: res
      retries: 3
      delay: 5
      until: res is not failed

    - name: Limit concurrency on fragile devices
      cisco.ios.ios_config:
        lines: "{{ item.lines }}"
      loop: "{{ changes }}"
      throttle: 1

Also raise the connection timeouts for large device fleets in a single play:

# ansible.cfg
[persistent_connection]
command_timeout = 60
connect_timeout = 30

For change-window work, the most valuable error-handling pattern is not a retry - it is serial. Rolling through a switch stack one device at a time, with a verification task after each, converts a fleet-wide outage risk into a single-device outage risk:

- hosts: access_switches
  serial: 1
  max_fail_percentage: 0

Rolling Back a Failed Change

You have the pre-change backup from backup: yes. Restoring it is a two-step operation: enter configuration mode, apply the saved configuration lines, save.

    - name: Roll back from the most recent backup
      cisco.ios.ios_config:
        src: "{{ backup_file }}"
        save_when: modified
      vars:
        backup_file: "{{ lookup('fileglob', 'backup/{{ inventory_hostname }}*.cfg') | split(',') | last }}"

IOS has no one-line "revert to previous configuration" for the running configuration, which is why the backup has to exist before the change and why you should test the restore path in a lab at least once. Configuration replace with a saved archive, or configure replace on IOS-XE with the nvram:rollback archive, is the cleaner mechanism if your platform supports it - it computes the difference and applies the minimum set of commands rather than pasting an entire configuration back.

Common Errors and How to Fix Them

  • "Unable to open shell" or a timeout on the first task - almost always a missing or wrong ansible_connection. Without ansible.netcommon.network_cli Ansible tries to run Python on the switch.
  • Permission denied on enable - ansible_become: yes with ansible_become_method: enable and a correct ansible_become_password. If the user is already privilege 15, set ansible_become: no instead of fighting it.
  • Commands rejected with "% Invalid input" - a parent context is missing. The parents option, not a line inside lines, is the fix.
  • Playbook reports changed on every run - the line Ansible is checking does not match the device's running configuration verbatim, for example name server_vlan versus name SERVER_VLAN, or a command the device normalises (interface abbreviations, VLAN name case). Use match: line or match: strict deliberately, and read the reported diff.
  • Facts gathering fails with "python interpreter not found" - gather_facts: no, or use the network-specific facts module.
  • Playbook works for one device and not the next - a device-specific line in group_vars. Move it to host_vars/<device>.yml.

Frequently Asked Questions

Do I need to learn Python to use Ansible? No. You need enough YAML to be precise and enough Jinja2 to filter a list. Python becomes useful when you write a filter plugin or fall back to a tool such as Netmiko for a device with no collection.

Is Ansible fast enough for a thousand switches? For configuration and inventory work, yes, with care. Raise the fork count, use serial to bound the blast radius, and split the fleet into groups of a few hundred devices per play. If you are polling telemetry from a thousand devices every minute, that is a different tool - streaming telemetry, not Ansible.

Should I use ios_config with lines or the resource modules? Resource modules where they exist, because they express intent. Fall back to ios_config with lines and parents for anything without a module yet: QoS policy bodies, route-maps, crypto maps.

How do I keep credentials out of Git? Ansible Vault for the values plus a defined review rule that vault files are the only files allowed to be encrypted. Store the vault password in a secret manager, not in a file next to the project.

Can I run this from AWX or a CI pipeline? Yes, and you should. The playbook, the inventory and the vault password become pipeline inputs; the pipeline provides the audit trail, the approval gate and the scheduler that the command line does not.

Next Steps

Once the VLAN playbook is working, the natural extensions are templating the configuration so that a device's whole identity comes from data rather than from lines, and moving from IOS to a multi-vendor inventory. Our companion pieces cover Ansible network modules including ios_config, facts and config backup, Jinja2 templates for network device configurations, and the Python route with Netmiko for network engineers.

Related articles: Ansible network facts and config backup examples and Cisco Catalyst port security with sticky MAC.

原文链接:https://www.packetswitch.co.uk/ansible-network-automation-101