Prometheus and snmp_exporter for Cisco IOS XE: SNMPv3, Generator and a Grafana Board

Prometheus and snmp_exporter polling Cisco IOS XE over SNMPv3: generator module, secrets in a separate auth.yml, scrape relabelling, a dark Grafana 13 board, and an honest Zabbix vs Prometheus comparison.

Prometheus and snmp_exporter for Cisco IOS XE: SNMPv3, Generator and a Grafana Board

The Zabbix series polled two IOS XE routers and an access switch over SNMPv3. This post points Prometheus at the same three devices, using the same SNMPv3 user, and builds a dark Grafana board on top. I ran it on Prometheus 3.15.1, snmp_exporter 0.31.0 and Grafana OSS 13.0.2, against IOS XE 17.18.2. Every number and screenshot below comes from that setup.

Lab diagram: Prometheus 3.15.1 scrapes snmp_exporter 0.31.0 on 10.66.20.41 port 9116 using two jobs, every 60 seconds, with relabeling. The exporter walks wan-rtr1 (10.66.20.11) and wan-rtr2 (10.66.20.12) over SNMPv3 authPriv, and acc-sw1 (10.66.20.21) over authNoPriv, using GETBULK on UDP 161. It uses snmp.yml (module cisco_iosxe, built from generator.yml and the MIBs) and a credentials file, auth.yml, with mode 600. ACL ZBX-SNMP on all three devices permits the exporter. Grafana 13.0.2 queries Prometheus with PromQL. All four targets are up and the wan-rtr1 walk takes 0.11 s over 259 PDUs. acc-sw1 rejects v3 priv with an invalid-input error, so it stays on authNoPriv.
Prometheus scrapes snmp_exporter, which walks the routers over SNMPv3 authPriv and the switch over authNoPriv. Grafana reads it all with PromQL.

How the pieces fit

Prometheus doesn't speak SNMP. snmp_exporter does the SNMP work, and Prometheus calls it over HTTP for each target:

GET http://snmp-exporter:9116/snmp?target=10.66.20.11&module=cisco_iosxe&auth=cisco_v3

The exporter walks the OIDs listed in the module, using the credentials named in the auth, and returns metrics. Prometheus stores them. Grafana queries Prometheus. That means one exporter can serve every device, and the device list lives in the Prometheus config.

Device side: let the exporter in

The routers already had a read-only SNMPv3 user in group ZBX-RO, locked to ACL ZBX-SNMP. The only change needed was one more ACL line for the exporter's address on each device:

ip access-list standard ZBX-SNMP
 permit 10.66.20.41
!
snmp-server group ZBX-RO v3 priv read ZBX-VIEW access ZBX-SNMP
snmp-server user zbxmon ZBX-RO v3 auth sha <AUTH_PASS> priv aes 128 <PRIV_PASS> access ZBX-SNMP

One finding was useful. I tried to move the access switch to authPriv as well, and it didn't work. On the L2 switch image I used, snmp-server group ZBX-RO v3 priv ... returns % Invalid input detected, so the switch stays on authNoPriv: SHA authentication with no encryption. Check what your switch image accepts before you write one auth profile for every device. Deleting and re-adding the group also invalidated the existing user. The exporter then logged incoming packet is not authentic until I re-entered the snmp-server user line.

Build the module with the generator

snmp_exporter doesn't read MIBs at run time. You describe what to walk in generator.yml. The generator resolves it against the MIB files and writes snmp.yml. I kept the MIB set small: SNMPv2-MIB, IF-MIB, BGP4-MIB, OSPF-MIB and their imports. A smaller walk means a faster poll.

auths:
  cisco_v3:
    version: 3
    security_level: authPriv
    username: SNMP_USER_PLACEHOLDER
    password: AUTH_PLACEHOLDER
    auth_protocol: SHA
    priv_protocol: AES
    priv_password: PRIV_PLACEHOLDER
modules:
  cisco_iosxe:
    walk:
      - sysUpTime
      - sysName
      - ifTable
      - ifXTable
      - bgpPeerTable
      - ospfNbrTable
    lookups:
      - source_indexes: [ifIndex]
        lookup: ifName
      - source_indexes: [ifIndex]
        lookup: ifAlias
    overrides:
      ifName: {ignore: true}
      ifAlias: {ignore: true}
      ifDescr: {ignore: true}
      ifType: {type: EnumAsInfo}
      ifAdminStatus: {type: gauge}
      ifOperStatus: {type: gauge}
      ifInOctets: {ignore: true}
      ifOutOctets: {ignore: true}
      ifSpecific: {ignore: true}
generator generate -m ./mibs -g generator.yml -o snmp.yml

Three of these choices matter:

  • Lookups turn ifIndex into ifName and ifAlias labels, so the graphs say "Et0/1 to wan-rtr2" instead of "ifIndex 2".
  • Ignoring the 32-bit octet counters leaves only ifHCInOctets/ifHCOutOctets. A 32-bit counter wraps in under six minutes at 100 Mb/s, which is too close to the scrape interval.
  • Status as gauge keeps ifOperStatus as a plain 1/2 number that's easy to compare.

Keep the secrets out of snmp.yml

snmp_exporter accepts more than one --config.file. I removed the auths block from the generated file and put the real credentials in a separate auth.yml, with mode 600:

snmp_exporter --config.file=snmp.yml --config.file=auth.yml

# auth.yml
auths:
  cisco_v3:
    version: 3
    security_level: authPriv
    username: <SNMP_USER>
    password: <AUTH_PASS>
    auth_protocol: SHA
    priv_protocol: AES
    priv_password: <PRIV_PASS>
  cisco_v3_authnopriv:
    version: 3
    security_level: authNoPriv
    username: <SNMP_USER>
    password: <AUTH_PASS>
    auth_protocol: SHA

Now snmp.yml can go in Git and be regenerated freely, and the secrets never leave the one host.

Test a device by hand before Prometheus touches it:

curl -s "http://10.66.20.41:9116/snmp?target=10.66.20.11&module=cisco_iosxe&auth=cisco_v3" | grep -E '^(sysName|bgpPeerState|ospfNbrState|snmp_scrape)'
sysName{sysName="wan-rtr1.lab"} 1
bgpPeerState{bgpPeerRemoteAddr="10.0.12.2"} 6
ospfNbrState{ospfNbrIpAddr="10.0.12.2"} 8
snmp_scrape_duration_seconds{module="cisco_iosxe"} 0.11
snmp_scrape_pdus_returned{module="cisco_iosxe"} 259

BGP state 6 is Established and OSPF state 8 is Full. The whole walk took 0.11 seconds.

The Prometheus scrape config

Use one job per auth profile. The relabelling moves the device address into the target parameter, keeps it as the instance label, and sends the actual HTTP request to the exporter:

scrape_configs:
  - job_name: snmp-cisco-router
    scrape_interval: 60s
    scrape_timeout: 30s
    metrics_path: /snmp
    params:
      module: [cisco_iosxe]
      auth: [cisco_v3]
    static_configs:
      - targets: [10.66.20.11, 10.66.20.12]
        labels: {role: router}
    relabel_configs:
      - source_labels: [__address__]
        target_label: __param_target
      - source_labels: [__param_target]
        target_label: instance
      - target_label: __address__
        replacement: 10.66.20.41:9116

  - job_name: snmp-cisco-switch
    # same as above, with:
    params:
      module: [cisco_iosxe]
      auth: [cisco_v3_authnopriv]
    static_configs:
      - targets: [10.66.20.21]
        labels: {role: switch}

  - job_name: snmp-exporter
    static_configs:
      - targets: [10.66.20.41:9116]

Sixty seconds matches what Zabbix was doing. SNMP CPU on the device is the cost, so don't scrape at Prometheus's default 15 seconds unless you need to. On Status > Targets, all four targets showed up.

The Grafana board

I added Prometheus as a data source in Grafana. Then I generated real traffic across the WAN link with long ping runs between the routers and the switch, so the graphs had something to show.

Dark Grafana 13 dashboard showing three IOS XE devices up, BGP and OSPF peers, SNMP poll time, uptime, interface throughput, errors and interface status
IOS XE over snmp_exporter: health stats, throughput, top talkers, errors and interface state.

The useful queries:

# throughput, labelled with the device's own hostname
rate(ifHCInOctets{job=~"snmp-cisco-(router|switch)",ifName=~"Et.*"}[5m]) * 8
  * on(instance) group_left(sysName) max by(instance, sysName)(sysName)

# interfaces that are down but admin up
count((ifOperStatus == 2) and on(instance, ifIndex) (ifAdminStatus == 1)) or vector(0)

# BGP sessions Established, OSPF neighbours Full
count(bgpPeerState == 6)
count(ospfNbrState == 8)

Two of these are worth explaining:

  • The max by(instance, sysName) wrapper. My first version joined straight onto sysName and the throughput panel showed an error. When I renamed the scrape job, old and new sysName series overlapped in the time range, so the join matched twice. Wrapping the right-hand side in max by makes it robust.
  • The admin-up filter. wan-rtr2 Et0/2 and the unused Et0/3 ports are shut on purpose. They show down in the status table but don't count as faults.

Explore is the quickest way to test a query before it goes on a panel. Here both WAN interfaces track the ping load:

Grafana Explore showing ifHCInOctets rate for the router WAN interfaces rising to about 17 Mb/s
Explore: WAN-facing interfaces on both routers, ifAlias in the legend.

Zabbix or Prometheus?

I've now monitored the same three devices both ways, so here's how they compare.

Zabbix 7.0Prometheus + snmp_exporter
Getting startedLink a Cisco template and you get hundreds of items and triggersYou choose every OID, so it takes more work up front
AlertingTriggers, dependencies and escalations built inPromQL alert rules, then Alertmanager or Grafana alerting
QueriesPer-item functionsPromQL joins and aggregates across devices and labels
ConfigMostly UI and database, with an APIPlain YAML that fits in Git
Traps and syslogBuilt in (covered here)Not handled. Use Loki for syslog
Long-term dataHistory and trends in SQLLocal TSDB, 15 days here, remote-write for longer

If you want something an operator can run on day one, with dependencies and escalations, use Zabbix. If your team already runs Prometheus for servers, likes config in Git and wants to write cross-device queries, use Prometheus. Both can feed the same Grafana, as the Zabbix NOC dashboard post shows. You don't have to pick only one.

Next up: syslog from these routers into Loki, on a board that puts logs next to these metrics. If you're new to Grafana, start with installing Grafana on Ubuntu. For streaming instead of polling, see model-driven telemetry on IOS XE.

Now live: IOS XE syslog into Loki with Alloy, logs next to these metrics.

Read next