《从零开始NetDevOps》是本人8年多的NetDevOps实战总结的一本书(且称之为书,通过公众号连载的方式,集结成册,希望有天能以实体书的方式和大家相见)。

NetDevOps是指以网络工程师为主体,针对网络运维场景进行自动化开发的工作思路与模式,是2014年左右从国外刮起来的一股“网工学Python"的风潮,最近几年在国内逐渐兴起。本人在国内某大型金融机构的数据中心从事网络自动化开发8年之久,希望能通过自己的知识分享,给大家呈现出一个不同于其他人的实战为指导、普适性强、善于抠细节、知其然知其所以然风格、深入浅出的NetDevOps知识体系,给大家一个不同的视角,一个来自于实战中的视角。


本文主要介绍基于Python的网络自动化运维框架Nornir,它灵活且强大,无需繁琐的DSL语言,只需要写好Python即可实现非常丰富的功能,在网络自动化领域不断开花结果。本人之前系统介绍过Nornir,受限于时间和精力,之前的篇章有些地方没有讲得很细,而这次,耗时两个月,打造的新版本Nornir教程覆盖范围广,内容更细致,且将常用插件做了介绍,同时又辅助以大量代码和几个实战示例详解,相信会给大家不同的感受。由于内容总体已经达到4万字左右,故分为三个篇章发表,本次发表的是基础篇。为区别老版本,突出新字,故命名为《Nornir宝典2023新编》




第六章 NetDevOps专属自动化运维框架Nornir

6.1 Nornir简介

提起自动化运维框架,大家可能会想到ansible、puppet等系统运维开发相关的自动化框架,ansible、puppet等也有对网络设备的相关功能支持。

但是大家在实际使用中会发现,它们支持的网络设备比较有限,以ansible为例,它支持思科、Juniper等国外主流设备,国内的支持华为的CE交换机系列,且部分功能模块对设备的软件版本还有一定要求。有人说ansible支持扩展,可以编写自己的插件或者模块,实际情况是编写难度较大,调试比较麻烦,对编程能力要求比较高。另外,在执行效率上而言,也被国内外的某些用户所诟病。所以整体而言,在国产化大趋势之下、海量网络厂商的背景之下,即使它号称支持无侵入的SSH连接,理论上支持的设备应该很多,而实际使用之下,我们会发现情况并不容客观。

所以NetDevOps工程师也希望有一款对网络运维自动化开发有帮助的自动化运维的开发框架。

我们为什么需要自动化运维的开发框架呢?它可以更好地组织我们的代码,让我们聚焦于一个功能模块的开发,借助于框架的一些设计,我们只需按要求写一些模块或者函数即可实现我们的需求及功能。它内置了很多基础的功能,比如帮助我们管理网络主机(host),帮助我们管理到主机的连接(connection),帮助我们去批量并发执行相关任务。

随着NetDevOps技术的不断发展,属于网络专属的自动化运维开发框架Nornir也应运而生!

【拳打Ansible,脚踢Puppet】Nornir宝典2023新编——基础篇_Python


6.1.1 初识Nornir

Nornir是一个用Python编写的自动化框架,主要是针对网络运维自动化,只要你懂Python,就可以非常方便的使用Nornir了,其官方网站是https://nornir.tech/ 。

由于Nornir允许用户使用纯Python代码,所以我们可以使用与其他Python代码相同的方法对其进行故障排除和调试,这就解决了那些低代的自动化工具的调试和排障困难的问题。

Nornir自比是自动化界的Flask,侧面反映它的强大与灵活。Flask是一个Python的web开发框架,Flask留给了用户很多自由发挥的空间,所以用户可以基于恰到好处的接口,用自己喜欢的方式构建一个功能强大的web网站。Nornir也是同样的事情,它实现了自动化运维开发框架所需的几种核心功能,比如管理好资产、变量,内置一些与设备的常见的连接方式,筛选关联设备,批量并发执行,支持自定义插件及扩展。你可以写自己的各种插件,实现任何你想实现的功能,而且是基于Python的。回想我们之前写的众多脚本,学习的众多组件,这很让人很兴奋——我们的代码和逻辑可以与Nornir结合,实现高效的开发模式!

Nornir是一个可以通过编写插件(plugin)实现各种扩展的自动化框架,无论是网络设备管理、connection、task任务模块等都可以通过按照Nornir的规范编写类和函数来实现扩展。Nornir的2.X版本,很多功能都内置在了框架本身,显得十分臃肿,更新速度也会受限。后来从Nornir3.0开始,很多功能组件都分拆出去,形成了独立的插件包,比如nornir_netmiko实现了与网络设备的CLI交互,nornir_utils实现了一系列便捷工具诸如结果打印和文件保存,Nornir的插件库官方也进行了相关汇总,在网址https://nornir.tech/nornir/plugins/ 我们可以看到官方的插件包清单。

尤其是nornir_netmiko这个插件包的存在,让Nornir相对于ansible等自动化框架更适合网络自动化运维。众所周知,网络设备众多,交互也相对比较复杂,ansib等自动化框架在处理一些网络设备尤其是国产化设备时,总是捉襟见肘。而借助于nornir_netmiko中netmiko的相关插件包,使Nornir可以非常便捷地与众多网络设备进行交互。

我们以一段代码来看看Nornir究竟长什么样子,有什么魅力!

from nornir import InitNornir
from nornir_utils.plugins.functions import print_result
from nornir_netmiko import netmiko_send_command

nr = InitNornir(config_file="nornir.yaml")
results = nr.run(task=netmiko_send_command, command_string='display version')
print_result(results)

以上是一个简单的示例,三行代码(实际有效代码)实现了对一批设备的批量命令执行。通过一个官方的内置插件nornir_netmiko(其实需要单独安装,并不在本体里),集成了netmiko的功能,可以对网络设备执行相关命令,返回相关结果。

从代码上来看,也清晰易读,加载了nornir.yaml配置文件(后续我们为大家讲解如何编写),获取设备的清单、并发设置等配置,初始化了一个Nornir对象,执行要执行的任务模块为netmiko_send_command,对所有网络设备批量执行信息获取,最后打印执行结果。

netmiko_send_command************************************************************
* netdevops01 ** changed : False ***********************************************
vvvv netmiko_send_command ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO
Huawei Versatile Routing Platform Software
VRP (R) software, Version 8.180 (CE6800 V200R005C10SPC607B607)
Copyright (C) 2012-2018 Huawei Technologies Co., Ltd.
HUAWEI CE6800 uptime is 0 day, 0 hour, 33 minutes 
SVRP Platform Version 1.0
^^^^ END netmiko_send_command ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
* netdevops02 ** changed : False ***********************************************
vvvv netmiko_send_command ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO
Huawei Versatile Routing Platform Software
VRP (R) software, Version 8.180 (CE6800 V200R005C10SPC607B607)
Copyright (C) 2012-2018 Huawei Technologies Co., Ltd.
HUAWEI CE6800 uptime is 0 day, 0 hour, 51 minutes 
SVRP Platform Version 1.0
^^^^ END netmiko_send_command ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

其结果输出以设备和任务两个维度去展示,结果中有很多细节,比如设备配置是否发生了变化、输出内容、调用模块名称等等,在窗口中还会有一些颜色的区别,比如执行错误的模块输出的是红色,成功的是绿色等等。这些和ansible比较类似,都是框架帮我们去实现的。

整个代码,核心逻辑实际只有3行,实现了配置的实时获取,相对于ansible而言,笔者觉得要简单很多,对于一个懂Python的网工而言,比较易读,也完全有能力写出。

尤其是在并发执行,本书并未着墨过多,就是希望通过这类自动化框架的讲解,可以屏蔽这些偏底层的开发知识,让我们更多聚焦在网络自动化业务本身。我们只需要会Python的基本技能,掌握Nornir自动化框架即可。

关于这个脚本,仍有一些细节我们没有过多展示,比如配置文件的编写,网络设备资产的管理等等,接下来我们将为大家一一展开。

6.1.2 Nornir的安装

初学者学习使用Nornir的时候,笔者建议安装如下Python包及版本:

nornir==3.3.0
nornir_netmiko==0.1.2
nornir_utils==0.2.0

我们可以将这段文本放到一个文本文件中,一般命名为requirements.txt,然后使用执行pip的命令进行安装,指定这个文件:

pip install -r requirements.txt

这样pip会读取文本中的包名及版本帮助我们实现批量安装。

由于本书的netmiko是以3.4.0作为建议使用版本,所以对应的nornir_netmiko的版本是0.1.2(0.2.0及以上版本对netmiko的版本要求是大于等于4.0)。Nornir我们选择的是最新的3.3.0版本,nornir_utils可以使用最新的0.2.0版本。其中Nornir一定要选择3.X的版本,大家在搜索相关学习资料时也一定要留意版本,因为目前网上的资料仍有一部分基于Nornir2.X的版本,二者有着很大的不同。

6.1.3 Nornir的基本概念

我们以一个Nornir的工程为例,来对Nornir的一些基本概念进行展开。

【拳打Ansible,脚踢Puppet】Nornir宝典2023新编——基础篇_Python_02

Snipaste_2022-11-04_15-07-50

Nornir的runbook(一个可以执行的基于Nornir框架开发完成的Python脚本),通过配置文件进行加载,初始化完成一个Nornir对象,配置文件中一个关键的信息是管理的网络设备,这些信息都存储在inventory文件夹中的文件中。然后基于Nornir框架,调用我们的task任务模块完成对指定网络设备的批量操作,其中可以进行比较复杂的编排,实现网络自动化。

这就是Nornir运行的基本流程。

网络设备管理文件

我们的第一个Nornir脚本(官方也称之为runbook,类似于ansible的playbook)能跑通,我们需先用yaml数据格式编写一个网络设备的资产管理文件,一般将其放在inventory文件夹内,命名为hosts.yaml文件。它内含了我们一共有多少台网络设备,其名称、IP地址、用户名、密码等信息,后续我们会详细展开讲解,其内容如下:

---
netdevops01:
  hostname: 192.168.137.201
  username: netdevops
  password: Admin123~
  port: 22
  platform: huawei

netdevops02:
  hostname: 192.168.137.202
  username: netdevops
  password: Admin123~
  port: 22
  platform: huawei

yaml的相关基础知识,大家可以在本书的番外篇章进行学习了解,这种数据格式,在层级适中的情况下,非常适合人肉眼观察。缩进相同的是同等级的数据。这个yaml文件等同于如下数据:

{
    'netdevops01': {'hostname': '192.168.137.201',
                    'username': 'netdevops',
                    'password': 'Admin123~',
                    'port': 22,
                    'platform': 'huawei'},
    'netdevops02': {'hostname': '192.168.137.201',
                    'username': 'netdevops',
                    'password': 'Admin123~',
                    'port': 22,
                    'platform': 'huawei'}
}

这种文件用于管理网络设备,告诉Nornir我们有如上设备,以及这些设备的一些基础信息。

配置文件

之后我们需要编写一个Nornir的配置文件nornir.yaml,当然我们也可以命名为其他名称,这个文件告诉Nornir这个自动化运维框架如何进行初始化,内里承载的数据相当于Nornir对象初始化所需的参数及其值。

---
inventory:
  plugin: SimpleInventory
  options:
    host_file: "inventory/hosts.yaml"

runner:
  plugin: threaded
  options:
    num_workers: 50

这个配置文件对于初学者而言基本也是固定的,能修改的就是网络设备资产文件的路径host_file和并发数num_workers。前者根据实际情况修改路径,后者笔者建议在实际生产中不宜过大,否则会导致导致一些设备的网络连接出现问题,从而导致任务失败,并发数是笔者结合自身使用经验得到的一个推荐值。

runbook及task

runbook即我们实际要运行的脚本,通过Python编写,调用Nornir,告知它我们要对哪些设备进行如何的作业编排,执行哪些任务。

from nornir import InitNornir
from nornir_utils.plugins.functions import print_result
from nornir_netmiko import netmiko_send_command

nr = InitNornir(config_file="nornir.yaml")
results = nr.run(task=netmiko_send_command, command_string='display version')
print_result(results)

上述脚本即一个runbook,代码通过nornir.yaml配置文件进行了Nornir对象的初始化,Nornir对象会筛选我们网络设备清单中的设备(默认是全部网络设备),这些设备清单会作为Nornir对象的一个属性,然后Nornir对象调用了run方法,其中有个重要参数是task任务模块(是一种符合Nornir规范的函数),后接的参数一部分是task模块的参数,一部分是run方法的参数,这块我们后续会细讲。Nornir对象会对其所筛选的网络设备并发执行指定的task任务模块,比如runbook中的是netmiko_send_command模块,即调用了netmiko对网络设备发送命令。调用run方法后会返回一个Nornir的结果对象,我们可以使用nornir_utils中的print_result,将其格式化展示出,会根据实际情况加上一些颜色,让结果更醒目,内容更突出。

【拳打Ansible,脚踢Puppet】Nornir宝典2023新编——基础篇_网络设备_03

image-20221101111952871

在runbook中,我们可以写更复杂的逻辑:先筛选出某个区域的设备采集端口,然后筛选出另外一个区域采集软件版本等等。设备可以筛选多次,task模块也可以执行多次,我们千万不要被示例中的一次行为约束了我们的思维。

connection

在runbook调用过程中,我们是如何与设备进行连接的呢?这块靠的是connection这个概念,它负责维护到设备的连接关系,且连接关系不局限于SSH,本书中内容以SSH为连接协议,所以connection我们选择了nornir_netmiko插件,它会根据hosts.yaml中的信息,帮助我们在合适的时机登录到设备,并以一定逻辑维系这个连接,在一个Nornir对象中,在一个相对较短的时间范围内,我们多次与设备打交道,进行复杂的CLI交互,可能只需要登录一次。这个就是靠的nornir_netmiko中的基于netmiko的connection插件(plugin)完成的。这个connection的使用,有时候对我们而言是无感知的,在本次的runbook中,我们无需去创建连接,或者关闭连接,这些都是由nornir_netmiko中的connection插件完成的。

6.2 网络设备Inventory

6.2.1 Inventory

Inventory有人翻译为主机清单,或者大家喜欢叫设备清单,没有一个特别准确的事实标准的翻译。一个自动化框架里,或大或小,都需要有一个类似的网络设备管理模块,记录了我们维护的设备、型号、厂商、IP、用户名等等,在处理自动化的时候,通过设备名或者IP地址来对设备进行批量配置。

Nornir也有一个inventory的模块,内含一个Inventory类,用于对网络设备进行管理。资产管理涉及到hosts(主机,即网络设备)、groups(组)和defaults(默认值)三个概念,这三个概念分别对应了Host、Group、Default三个Python类,这三个类的抽象以及用法的灵活,使Nornir的设备清单管理非常强大,与网络的一些场景非常契合。

Nornir创建一个Inventory对象,会用Nornir包中自带的inventory的plugin类——SimpleInventory,它通过指定三个yaml文件(host_file、 group_file、 defaults_file)的路径,加载hosts、groups、defaults等相关配置,返回一个Inventory对象,这个Inventory对象中有多个网络设备的Host对象,进而实现设备的管理和灵活筛选,以及连接(ssh netconf等等)的建立。

所以我们需要掌握编写hosts.yaml、groups.yaml与defaults.yaml这三类文件,其中hosts.yaml最为重要。

6.2.2 网络设备清单编写

我们一般创建一个inventory文件夹,按要求编写hosts.yaml、groups.yaml与defaults.yaml这三个文件,用于告知Nornir:

  1. 我们都管理了哪些网络设备,这些网络设备的基本属性,这部分是hosts.yaml文件负责,这个文件是必须编写的。
  2. 网络设备所属分组,以及这些分组的一些公有属性,比如我们创建了一个beijing的group,他们的location值可以设置为beijing,则这个组的设备其location都为beijing。这部分是groups.yaml文件负责,这个文件是可选的。
  3. 一些默认值,比如网络设备认证的用户名密码是统一的,则可以放到defaults.yaml文件当中,这个文件也是可选的。

hosts定义

一台网络设备首先在hosts.yaml中被定义,其相关属性也在hosts.yaml中被定义,如果这个设备有所属的组,则会继承group的相关属性,但是有冲突的部分,hosts.yaml中定义的属性优先级高。所有的hosts.yaml中的网络设备都会继承defaults.yaml中定义的默认值,如果有冲突,仍以hosts.yaml中定义的属性优先级高。

---
netdevops01:
  hostname: 192.168.137.201
  username: netdevops
  password: Admin123~
  port: 22
  platform: huawei

netdevops02:
  hostname: 192.168.137.202
  username: netdevops
  password: Admin123~
  port: 22
  platform: huawei

如上是一个hosts.yaml的示例,其中netdevops01与netdevops02代表两台网络设备,是网络设备的name,全局必须唯一。

每台设备内部基本的属性有:

  1. hostname,网络设备的IP地址或者FQDN域名,这个字段对应到netmiko中的host字段(等同于ip字段)。
  2. username,登录设备所需的用户名,这个字段对应到netmiko中的username字段。
  3. password,登录设备所需的密码,这个字段对应到netmiko中的password字段。
  4. port,登录设备的端口,这个字段对应到netmiko中的port字段。
  5. platform,网络设备的平台,这个字段对应到netmiko中的device_type字段,这个需要注意。

这几个字段是我们编写时常用的、也几乎是必填的字段。通过这样一个host网络设备的定义,我们就完成了hosts.yaml的定义。细心的读者会思考一个问题,假如我要将设置一个netmiko的执行超时时间该如何设置呢?我们可以在一台网络设备的connection_options,添加一个netmiko的相关参数,在其extras字段中添加相关参数,如下:

netdevops01:
  hostname: 192.168.137.201
  username: netdevops
  password: Admin123~
  port: 22
  platform: huawei
  connection_options:
    netmiko:
      extras:
        timeout: 120
        conn_timeout: 20
        secret: Admin1234!

一台设备可以有多个connection的参数配置,所以对connection_options进行配置,它是一个字典数据,key是connection的名称,这个值是由connection插件中定义的名称,nornir_netmiko中的connection插件叫做netmiko,所以我们对这个名为“netmiko”的connection进行配置,但是需要下钻一层到extras字段中,进行相关参数的赋值,这些参数与ConnectHandler中的参数完全一致,我们可以传入任何我们想调配的参数。实际上网了设备的驱动不局限于基于netmiko的SSH、TELNET,还会有一些其他用于连接网络设备的驱动,鉴于本书的网络自动化是基于Netmiko实现SSH的网络连接,通过CLI与网络设备进行交互,故不过多展开。

以上这些参数都是与Host类的属性一一对应的。

我们可以通过如下代码,查看Host类的模式:

from nornir.core.inventory import Host
import json
print(json.dumps(Host.schema(), indent=4))

其结果为:

{
    "name": "str",
    "connection_options": {
        "$connection_type": {
            "extras": {
                "$key": "$value"
            },
            "hostname": "str",
            "port": "int",
            "username": "str",
            "password": "str",
            "platform": "str"
        }
    },
    "groups": [
        "$group_name"
    ],
    "data": {
        "$key": "$value"
    },
    "hostname": "str",
    "port": "int",
    "username": "str",
    "password": "str",
    "platform": "str"
}

从这段Host的schema模式中,我们也可以窥探到其基本属性。定义完网络设备的基本信息之后,我们还可以定义若干网络设备的自定义属性,这些自定义的属性我们通通都放到data字段当中去,其值为一个字典(从schema中可以观察到),这个字典可以简单,也可以复杂,丰俭由人。比如,我们定义了一台设备之后,要定义对齐进行配置备份的相关命令,可以按如下方式:

---
netdevops01:
  hostname: 192.168.137.201
  username: netdevops
  password: Admin123~
  port: 22
  platform: huawei
  data:
    cmds:
      - display version
      - display current-configuration
    series: CE6800
  
netdevops02:
  hostname: 192.168.137.202
  username: netdevops
  password: Admin123~
  port: 22
  platform: huawei
  data:
    cmds:
      - display version
      - display current-configuration
    series: CE6800

我们给每个设备定义了一个data字段,这里面可以存放状态设备的自定义属性,为了我们后续使用runbook进行自动化配置备份,我们可以把要进行配置备份的命令放到自定义属性中,设置一个cmds的字段,其值为一个list,包含了两条用于进行配置备份的命令。我们也可以按需添加其他字段,比如设备的系列,是一些与配置相关的BGP的ASN号,设备所在的城市等等。

groups定义

定义完网络设备hosts之后,我们也可以把一些有共性的网络设备定义为一个组,然后定义一个group,group里是这个组里的网络设备所共同继承的属性。一个网络设备host可以隶属于多个group,如果有多个group,优先继承排列在前的group的相关属性。

在Host的schema模式中,我们也可以观察到,一个Host对象可以有多个group,我们只需定义一个group,然后将group列表添加到Host对象的groups字段。

一个group分组实际是Nornir中的一个Group对象,Group类与Host类几乎一致,所以可以调配的参数也几乎一致,我们一般是添加一些自定义的字段,笔者认为除了hostname以外不能修改,其他的都可以根据实际情况修改。我们写一个示例:

---
huawei:
  platform: huawei
  username: netdevops
  password: Admin123~
  port: 22
  connection_options:
    netmiko:
      extras:
         timeout: 120
         conn_timeout: 20
  data:
    backup_cmds:
      - display version
      - display current-configuration

beijing:
  data:
    city: beijing

在这个示例中我们定义了一个huawei的分组,将设备的platform、用户名、密码、端口、netmiko的连接信息等进行了配置,然后对配置备份的命令进行了配置,同时又写了另外一个分组beijing,添加了城市字段。

然后我们在之前的hosts.yaml文件中就可以对这两个group进行引用,host就会自动继承分组的信息。

---
netdevops01:
  hostname: 192.168.137.201
  groups:
    - huawei
    - beijing

netdevops02:
  hostname: 192.168.137.202
  groups:
    - huawei
    - beijing

按照这种方式填写,我们一台网络设备的基本信息只需填写hostname即可,其他的由于我们的统一规划,都从groups中继承。这样hosts.yaml文件就会比较清晰,当有众多网络设备的时候,适当地进行分组,可以优化网络设备清单管理。当然我们后续也会为大家介绍更简洁的方法,但在此之前我们需要先为大家介绍Nornir的基础用法。

defaults定义

defaults相当于全局变量,Default类承载相关信息,它与Host、Group类也几乎完全一致,可以调配的参数如出一辙。我们需要结合运维所需,把一些全局统一的信息写在defaults.yaml文件中,比如用户名密码,默认的SSH端口号,以及一些其他运维所需的全局的自定义参数,比如设备的Domain配置等等。编写完成之后,无需再host中引用,直接生效。如同groups的编写,笔者也认为除了hostname,其他字段都可以结合实际情况,把全局默认的添加到defaults.yaml文件中。

---
username: netdevops
password: admin123!
port: 22
data:
  desc: just a demo

以上就是网络设备清单的基本编写,通过填写基本信息,我们可以完成定义一台网络设备Hosts对象,组合起来就是一个Inventory对象,我们可以对其进行筛选,用基本信息完成后续的登录设备操作,自定义字段也可以在task任务模块中使用,对于网络设备Host对象的相关操作我们后续为大家逐步展开。

6.3 配置文件加载Nornir对象

在编写完基础的网络设备Inventory清单后,我们需要再编写一个Nornir的配置文件,用于完成Nornir对象的加载。

6.3.1 配置文件的编写

配置文件也是以yaml的方式呈现,命名可以命名为config.yaml、nornir.yaml,大家也可以根据自己的事情习惯进行命名。配置文件是有多个配置项组成的,每个配置项都是一个字典数据,我们可以按需对配置项进行配置,配置项可以编写在yaml文件中也可以编写为字典,对于初学者,或者对于绝大多数人而言,配置项主要包含inventory、runner、logging这三种。

---
inventory:
  plugin: SimpleInventory
  options:
    host_file: "inventory/hosts.yaml"
    group_file: "inventory/groups.yaml"
    defaults_file: "inventory/defauls.yaml"

runner:
  plugin: threaded
  options:
    num_workers: 50

logging:
  enabled: True
  level: INFO
  log_file: nornir.log

以上一个配置文件是笔者结合自身使用情况和理解,罗列出的常用的和可能用到的配置项。

inventory配置项

inventory配置项指定我们要使用的Inventory的插件类,主要包含了两个参数:plugin插件名称和plugin插件类实例化所需的参数options。

Nornir内置一个SimpleInventory插件类,我们赋值plugin为SimpleInventory(字符串类型),SimpleInventory插件可以通过指定host_file、group_file、defaults_file(注意,这个参数中的单词defaults是复数形式)这三个文件的路径,用于实现Inventory对象的初始化。所有插件类的参数传入都是通过options传入,其值为字典数据。SimpleInventory实例化的参数中只有host_file是必填项,如果无groups、defaults等配置数据,可以不必填写二者对应的文件路径。

我们也可以按照Nornir的规范,自己编写Inventory插件类,以数据库或者RESTful API接口的方式与自己的CMDB对接,加载网络设备清单。

runner配置项

runner配置项用于指定Nornir对象对于任务跑批的方式,也主要包含了两个参数:plugin插件名称和plugin插件类实例化所需的参数options。

关于runner的插件类,官方指定了两种:一种是thread,使用多线程进行并发执行;另外一种是serial,一台设备接一台设备的线性完成。在实际使用中,为了提高效率,笔者实际使用及所见到的代码几乎都是thread。所以我们对runner配置项的plugi赋值为thread。多线程的并发数,我们可以对thread传参数,在options中赋值num_workers为我们希望并发的数字即可。结合笔者的最佳实践,如果有登录设备的操作建议使用50,大家可以根据情况调整。如果批量登录设备进行操作,有少量失败的设备,有可能是因为并发数过多,在规定时间内没有获得线程资源,netmiko组件超时而失败。这个时候我们可以适当调整小并发数,并发数50只是笔者根据自身情况得出的一个实践值,仅供参考。

logging配置项

logging配置项用于进行Nornir日志的相关配置。主要有三个参数:enabled是否开启日志,level日志级别,log_file日志文件路径。

Nornir的日志系统默认是开启的,可以通过enabled字段调整,其值为布尔类型,默认为None,相当于True,我们也可以赋值为False将日志系统关闭。日志文件的输出路径可以通过log_file调整,默认是在当前目录的nornir.log文件中。记录何种日志级别我们也可以通过level字段进行调配,默认是INFO级别,即INFO及以上级别的日志会输出到log_file中。Nornir是可以和其他第三方的Python框架结合的,比如Django、Flask,当与第三方框架结合的时候,由于第三方框架也会有自身的日志配置,有可能引发冲突,所以我们需要显式地关闭日志,只需把enabled的字段置为False即可,大家在使用中要注意。

对于配置文件的相关细节,大家可以参考官方文档https://nornir.readthedocs.io/en/latest/configuration/index.html。文本结合笔者使用,罗列出了使用频率相对较高的几个配置项。

6.3.2 加载Nornir对象

将配置文件准备好之后,我们就可以通过编写runbook,runbook中首先要构建Nornir对象。我们只需要调用Nornir的InitNornir函数,将配置文件路径赋值给config_file即可,函数会返回一个Nornir对象。

from nornir import InitNornir

nr = InitNornir(config_file="nornir.yaml")

返回的Nornir对象,根据官方约定俗成的建议,我们可以缩写为nr,也有部分NetDevOps工程师会以devices或者routers类似的网络设备来命名返回的变量,可读性也比较好。我们也可以二者兼之,以nr作为前缀开头,devices或者routers等与设备名称相关的单词结尾。

上述示例中我们以配置文件来去加载Nornir对象,实际InitNornir函数也支持配置文件中的相关参数,我们赋值以字典即可加载成Nornir对象。比如一个配置文件如下:

---
inventory:
  plugin: SimpleInventory
  options:
    host_file: "inventory/hosts.yaml"
    group_file: "inventory/groups.yaml"
    defaults_file: "inventory/defaults.yaml"

runner:
  plugin: threaded
  options:
    num_workers: 50

logging:
  enabled: True
  level: INFO
  log_file: nornir.log

这段yaml配置文件中的数据如下:

{'inventory':
     {'plugin': 'SimpleInventory',
      'options':
          {'host_file': 'inventory/hosts.yaml',
           'group_file': 'inventory/groups.yaml',
           'defaults_file': 'inventory/defaults.yaml'
           }
      },
 'runner': {'plugin': 'threaded', 'options': {'num_workers': 50}},
 'logging': {'enabled': True, 'level': 'INFO', 'log_file': 'nornir.log'}
 }

我们稍微调整了换行和缩进,以保证可读性,这个数据就是一个Python的字典,与yaml的配置文件可以无损互转,通过缩进我们也可以在yaml中轻松找到层级。

InitNornir支持通过关键字赋值,可以将配置文件中的所有参数进行关键字赋值,所以上述通过config_file加载配置文件的初始化方法,我们可以调整为如下代码:

from nornir import InitNornir
from nornir_utils.plugins.functions import print_result
from nornir_netmiko import netmiko_send_command

inventory = {'plugin': 'SimpleInventory',
             'options':
                 {'host_file': 'inventory/hosts.yaml',
                  'group_file': 'inventory/groups.yaml',
                  'defaults_file': 'inventory/defaults.yaml'
                  }
             }
runner = {'plugin': 'threaded', 'options': {'num_workers': 50}}
logging = {'enabled': True, 'level': 'INFO', 'log_file': 'nornir2.log'}
nr = InitNornir(inventory=inventory, runner=runner, logging=logging)

每个参数的名称与yaml文件中的配置项名称一致,参数的值则使用Python的字典数据,然后在InitNornir函数中,对相关参数按需赋值即可。

这两种方式都可以加载Nornir对象,一般写普通的runbook的时候使用第一种方式即可,我们编写配置文件,代码中通过配置文件加载配置项,相对比较简单。第二种比较适合和一些第三方框架或者平台结合时使用,因为有些配置文件不方便上传到服务器,或者变化比较频繁,我们可以在脚本中修改配置项,实现Nornir对象生成的便利性。这两种方法只能选其一,不建议两种都配置,InitNornir函数目前会先查找配置文件,无配置文件,则将用户传入的关键字参数提取相关信息进行加载。

官方示例中,InitNornir中还使用了一个dry_run参数,这个参数不是配置文件中的参数,是InitNornir函数的参数,代表是否模拟相关配置变化,默认是关闭的。但这与一些插件中的task是联动的,在nornir_netmiko中不起作用,且目前基于CLI的交互方式,几乎无法进行配置的dry_run模式,即只在设备上进行仿真验证不生效,综上不建议大家调配此参数。

6.3.3 Inventory及Host对象

Nornir对象加载完成后,会有一个inventory的属性,其值为一个Inventory对象,还有一个config的属性,其值与我们通过配置文件加载的数据一一对应,我们主要关注inventory、logging、runner的配置数据,其余配置数据采取了默认值。inventory属性即一个Inventory对象,它包含了hosts属性,对应我们通过yaml文件传入的网络设备Host对象,groups和defaults对应了其余两种对象。这些我们都可以通过在IDE中打断点观察到,如下图。

【拳打Ansible,脚踢Puppet】Nornir宝典2023新编——基础篇_网络设备_04

image-20221110162523762

其中hosts对应的是Nornir管理的全量网络设备,当我们希望对指定的网络设备批量执行任务时,会涉及到Host对象的基本使用。

我们通过IDE可以观察到一个Host对象的相关属性数据:

【拳打Ansible,脚踢Puppet】Nornir宝典2023新编——基础篇_Python_05

image-20221110163557632

一个Host对象最主要的数据是基本属性和自定义字段,这些信息我们在执行相关任务或者做设备过滤的时候会被使用到,其中维护的连接也可以按需使用。相关细节,我们会在后面慢慢展开。

6.4 过滤网络设备

加载完Nornir对象,理论上,我们就可以对网络设备执行对应相关任务。但我们一般会指定要执行任务的网络设备,默认情况下不做任何筛选,则是对Inventory管理的所有网络设备执行相关任务。实际使用中,我们一般在hosts.yaml文件中登记所有网络设备,然后结合实际情况,筛选过滤出指定的网络设备执行相关任务。而Nornir也给我们提供了非常便利的设备筛选过滤(filter)功能。

接下来我们将为大家展示一下Nornir灵活的网络设备筛选过滤功能,当然在实验环境及学习过程中,我们可以先跳过这个环节,先去学习Task的相关内容,以便尽快上手写个自己的runbook。在实际使用阶段,我们要学会Nornir基础的筛选网络设备的用法,以便对我们要执行的任务控制在指定范围内。

以下演示都基于如下hosts.yaml文件中描述的网络设备进行相关筛选。

---
netdevops01:
  hostname: 192.168.137.201
  username: netdevops
  password: Admin123~
  port: 22
  platform: huawei
  data:
    city: beijing
    series: CE6800

netdevops02:
  hostname: 192.168.137.202
  username: netdevops
  password: Admin123~
  port: 22
  platform: huawei
  data:
    city: beijing
    series: CE6800

netdevops03:
  hostname: 192.168.137.203
  username: netdevops
  password: Admin123~
  port: 22
  platform: cisco
  data:
    city: shanghai
    series: nexus9000

6.4.1 基础过滤

Nornir对象加载完成后,我们可以调用其filter的方法,传入过滤条件,则Nornir对象会返回一个新的Nornir对象,而这个Nornir对象的Inventory管理的是过滤后的网络设备。这就是Nornir进行过滤的基本逻辑,原有的Nornir对象如果不进行新的赋值,并不会发生任何变化,而经过filter方法过滤后,返回给我们的是一个新的Nornir对象,我们一般将其赋值给一个新的变量。

在filter方法中,我们对Host对象的任意字段进行过滤,比如我们筛选platform字段值为huawei的网络设备,可以按如下代码进行操作

from nornir import InitNornir

nr = InitNornir(config_file="nornir.yaml")
huawei_devs = nr.filter(platform='huawei')
print(huawei_devs, nr,sep='\n')

输出结果为:

<nornir.core.Nornir object at 0x000001F2E1A2A190>
<nornir.core.Nornir object at 0x000001F2E1A2A2E0>

从结果中我们会发现nr与huawei_devs均为Nornir对象,后面的16进制数字是这个对象的内存地址,地址不同,代表二者是两个不同的对象。想看其各自的inventory都管理了哪些网络设备Host对象,可以一层一层的下钻数据属性,通过如下代码展开:

from nornir import InitNornir

nr = InitNornir(config_file="nornir.yaml")
huawei_devs = nr.filter(platform='huawei')
print(nr.inventory.hosts)
print(huawei_devs.inventory.hosts)

其输出结果如下:

{'netdevops01': Host: netdevops01, 'netdevops02': Host: netdevops02, 'netdevops03': Host: netdevops03}
{'netdevops01': Host: netdevops01, 'netdevops02': Host: netdevops02}

我们发现第二行是经过筛选的huawei_devs这个Nornir对象管理的网络设备Hosts对象,只有platform值为huawei的两台设备。

filter中的参数也可以直接传data中对应的字段属性条件,其方法如下:

from nornir import InitNornir

nr = InitNornir(config_file="nornir.yaml")
shanghai_devs = nr.filter(city='shanghai')
print(shanghai_devs.inventory.hosts)

我们无需用data['city']='shanghai'这种过滤方式,直接传入data中的字段即可,结果Nornir筛选出了符合条件的网络设备,上述代码运行结果:

{'netdevops03': Host: netdevops03}

我们也可以用多个条件取筛选,它们之间是”且“的关系:

nr = InitNornir(config_file="nornir.yaml")
cisco_shanghai_devs = nr.filter(platform='cisco', city='shanghai')
print(cisco_shanghai_devs.inventory.hosts)

其结果如下:

{'netdevops03': Host: netdevops03}

如果我们过滤的条件不巧一台设备也没,则返回的仍是一个Nornir对象,但是他的hosts字段是一个空列表。

这是最基本的Nornir的网络设备筛选过滤功能,基本可以覆盖绝对多数场景。

6.4.2 自定义过滤函数

假如我们有一个比较复杂的筛选条件,甚至是一个需要动态判断的筛选条件,我们还可以通过自定义过滤函数的方式来进行扩展。

自定义过滤函数定义比较简单,形如my_filter_func(host),其中host是一个Host的对象,Nornir会自动帮我们把每个设备当做参数传入我们的函数,我们在函数里写好我们自己的逻辑,最终返回True 或者False即可,True代表我们认可这台设备,符合我们的筛选过滤条件,False代表这台设备被忽略掉。自定义过滤函数在使用的时候,只需要将函数赋值给filter方法中的filter_func参数,告知Nornir过滤时使用此函数即可。

在此我们简单编写一个示例,过滤出所有北京的华为设备,代码如下:

from nornir import InitNornir

def huawei_bj_filter(host):
    if host.platform == 'huawei' and host['city']=='beijing':
        return True
    return False

nr = InitNornir(config_file="nornir.yaml")
huawei_beijing_devs = nr.filter(filter_func=huawei_bj_filter)
print(huawei_beijing_devs.inventory.hosts)

其运行结果为:

{'netdevops01': Host: netdevops01, 'netdevops02': Host: netdevops02}

我们将自己编写的自定义过滤函数命名为huawei_bj_filter,有且只有一个参数host,即Host对象,它代表的是当前正在执行任务的网络设备。在过滤函数内部,我们结合自身筛选逻辑,对当前Host对象进行综合判断,返回True或者False即可。对于Host对象的属性访问,基础信息为设备名称name、用户名username、密码password、端口port、平台platform、设备地址hostname,自定义信息都在data属性中,我们可以使用host.data['some_field']这种形式,也可以直接host['some_field']这种形式。

我们根据对Host对象的逻辑判断,甚至更复杂的操作就可以实现设备的过滤,此处大家了解即可,实际使用场景并不是那么常见。

6.4.3 过滤对象

除了自定义函数,Nornir也提供了一个过滤对象F来实现比较复杂的查询需求。它的使用比较简单灵活,一个F对象就是一个过滤条件,重点是我们可以对多个过滤条件进行逻辑运算——与或非,这三种逻辑预算的符号分别对象“&”、“|”、“~”。

我们先演示一个最基础的过滤对象的使用,基本思路是通过过滤类F( nornir.core.filter.F)创建一个过滤对象,它的创建与filter方法的基础过滤很像,将Host对象中的基础字段或者自定义字段的键值传入即可构建完成,然后传入Nornir对象的filter方法即可。

from nornir import InitNornir
from nornir.core.filter import F

nr = InitNornir(config_file="nornir.yaml")
f = F(platform='huawei')
huawei_devs = nr.filter(f)
print(huawei_devs.inventory.hosts)

我们会将过滤对象赋值给变量f,实际上我们也可以直接在filter方法中创建过滤类F的对象,如下:

from nornir import InitNornir
from nornir.core.filter import F

nr = InitNornir(config_file="nornir.yaml")
huawei_devs = nr.filter(F(platform='huawei'))
print(huawei_devs.inventory.hosts)

类似于Nornir对象的filter方法,过滤对象中也可以传入多个字段,在一个过滤对象中传入多个条件的键值条件接口,它们之间是且的关系。

from nornir import InitNornir
from nornir.core.filter import F

nr = InitNornir(config_file="nornir.yaml")
# 逻辑非运算,使用~
nr_devs = nr.filter(~F(city='shanghai'))
# 逻辑且运算,使用&
nr_devs = nr.filter(F(platform='huawei')&F(city='beijing'))
# 逻辑或运算,使用|
nr_devs = nr.filter(F(city='beijing')&F(city='shanghai'))

示例中对三种逻辑预算做了演示,也可以看到过滤对象进行过滤的方法非常灵活,尤其是可以进行逻辑运算,示例中只列举了1-2个F对象的逻辑运算,实际我们可以结合场景使用多个过滤对象进行逻辑运算,大家不要被示例局限住。

Nornir为我们提供了便捷的设备过滤方法,这点非常重要,因为我们的配置一定是基于业务逻辑在部分设备上进行,甚至在一个复杂的应急场景里,我们需要按需一批一批设备的进行(比如先升级某个区域,或者先断开某个角色交换机的级联口等),我们不可能频繁的加载多个主机的列表清单,我们需要在一个设备清单里,按需筛选出设备来进行一些操作。

6.5 任务Task

加载配置文件完成Nornir对象的初始化之后,我们可能会按需过滤网络设备,本质是生成了一个新的Nornir对象,与原有Nornir对象的区别是管理的网络设备是其子集,然后在指定的网络设备上执行相关任务,这个靠的就是task。

6.5.1 task函数及其调用

task是一段可以被循环使用的用于执行一定逻辑的代码,类似ansible的module,它在筛选的设备上进行操作。它可以简单理解成一个函数,但是它的第一个参数是task上下文,这个参数由Nornir框架来给我们进行自动赋值,它会将task执行的上下文信息赋值给第一个参数,我们无需也不要给这个参数赋值。我们定义第一个参数的时候名称可以起任何我们想起的名称,官方约定俗称定义为task。但笔者结合自身经验,对于初学者,为防止与我们所说的task函数混淆,我们将其命名为task_context,如其名,它记录的是task执行的上下文,比如正在执行task任务的网络设备Host对象等。我们先写一个最简单的task函数,让每台执行task任务的网络设备和大家“打个招呼”。

from nornir.core.task import Result

def say_hello(task_context):
    """
    让每台设备来和大家打个招呼
    :param task_context:用于上下文相关信息的管理,比如设备信息,nornir的配置等等
    :return:返回打招呼的字符串
    """
    words_templ = "Hello!I'm a network device. My name is{}"
    words = words_templ.format(task_context.host.name)
    return Result(host=task_context.host,result=words)

如上就是一个最简单的task函数,它的第一个参数是task上下文,我们建议新手命名为task_context,提醒我们这是一个task执行情况的上下文,在我们慢慢熟悉以后,我们可以按照官方的使用习惯,将其改为task。同时它必须返回一个运行的结果nornir.core.task.Result对象,它是单台网络设备执行单个task任务的结果的类。在创建这个Result对象的时候,对于初学者而言,最需要大家掌握的两个参数是host和result,了解即可的是severity、changed、failed:

  1. host指的是当前执行任务的网络设备Host对象,这个必须我们进行显式赋值,比较固定,从task_context上下文中取其host属性即可。
  2. result是此次任务我们想要返回的实际结果内容,它可以是任意Python对象,对于初学者而言,可能是我们想打印的结果,这个结果可以是字符串、字典、列表等基础形式。但实际上它也可以是复杂的Python对象,这个结果我们在和其他框架系统对接的时候,可以取出result中的对象用于复杂的逻辑业务,这种使用方法,我们会在后续篇章的示例中展开,大家可以在后续实践再去体会。
  3. severity是指当前结果的日志级别,用于控制脚本执行结果的打印效果,默认值是logging.INFO级别。
  4. changed是指当前task执行后,是否发生变化,多指网络配置是否发生变化,默认是否。这个值在打印的时候会体现出来,如果发生了变化,这个task任务的执行结果的首位行会显示为黄色,否则显示为绿色,这点和ansible也非常像。但是对于一个任务执行对网络设备是否发生变化,需要结合一定的场景来判断是否有必要,初学者甚至是写到后期,笔者也建议大多数场景不必过于纠结此字段,即使是ansible的相关网络模块对于配置是否变化也无法给出非常准确无误的结果。Nornir的一些插件也做的不尽如人意,比如它们会认为只要调用了config相关的命令,配置就会发生了变化,而没考虑可能配置未生效等情况。
  5. failed代表当前设备的当前任务是否失败,默认是否,根据业务逻辑进行赋值,如果此值为True,则打印的时候会将显示结果首位行标记为红色。

task函数定义好之后,紧接着我们就可以写一个runbook来执行这个task函数,代码如下,也十分简单。

from nornir import InitNornir
from nornir.core.task import Result
from nornir_utils.plugins.functions import print_result


def say_hello(task_context):
    """
    让每台设备来和大家打个招呼
    :param task_context:用于上下文相关信息的管理,比如设备信息,nornir的配置等等
    :return:返回打招呼的字符串
    """
    words_templ = "Hello!I'm a network device. My name is{}"
    words = words_templ.format(task_context.host.name)
    return Result(host=task_context.host, result=words)


nr = InitNornir(config_file="nornir.yaml")
results = nr.run(task=say_hello)
print_result(results)

我们只需如往常初始化一个Nornir对象,按需过滤网络设备,然后调用Nornir对象的run方法,run方法的第一个参数是task,即我们希望过滤后的每个网络设备Host对象执行的task函数,赋值的时候一定注意,我们赋值的是函数,不是函数名的字符串,也不是函数的调用——“say_hello()”。我们写的第一个task函数,我们可以认为是一个无参数的task函数,因为它的第一个参数task_context也是由Nornir框架为我们赋值的,我们在run的时候无需再传入任何参数。上述runbook的执行结果如下:

say_hello***********************************************************************
* netdevops01 ** changed : False ***********************************************
vvvv say_hello ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO
Hello!I'm a network device. My name isnetdevops01
^^^^ END say_hello ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
* netdevops02 ** changed : False ***********************************************
vvvv say_hello ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO
Hello!I'm a network device. My name isnetdevops02
^^^^ END say_hello ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

Nornir会对所有网络设备进行批量的任务执行,每个网络设备按照task函数内部的业务逻辑进行运行,然后打印出Result中的result属性,结合Result的其他属性调整输出的格式和颜色。从打印结果中我们可以观察到,执行的是哪个task函数,这个task函数执行涉及到哪些网络设备,网络设备是否发生了变化以及输出信息的日志级别。

当然执行结果输出到控制台打印出来只是我们比较常用的方法之一,如果与第三方框架、系统、平台结合,我们可能需要通过Python去访问结果的详细信息,我们后续也会为大家对输出结果进行详解介绍。

6.5.2 带参数的task函数及其调用

我们简单写了一个say_hello且无参数的task函数,而在实际使用场景中,我们有时是需要用到参数的,那有参数的task函数如何定义及调用呢?

有参数的task函数定义也与普通函数一致,第一个参数是task任务执行的上下文,我们约定俗称定义为task_context(或者使用官方习惯的参数名task),之后定义其他由我们控制进行赋值的参数即可,这些由我们定义的参数与普通函数中的参数完全一致,我们想定义多少参数就定义多少,且参数支持默认值。我们将上个示例中的无参数的task函数稍作修改,变成一个有参数的task函数。

from nornir import InitNornir
from nornir.core.task import Result
from nornir_utils.plugins.functions import print_result


def say_with_words(task_context, words):
    """
    让每台设备来和大家打个招呼
    :param task_context:用于上下文相关信息的管理,比如设备信息,nornir的配置等等
    :param words:用户自定义的参数,无默认值
    :return:返回打招呼的字符串
    """
    words_templ = "This is {}.{}"
    words = words_templ.format(task_context.host.name, words)
    return Result(host=task_context.host, result=words)


nr = InitNornir(config_file="nornir.yaml")
results = nr.run(task=say_with_words, words='Hello world!')
print_result(results)

定义参数的时候,我们如同普通函数一样,除了定义第一个task上下文参数以外,在后面定义我们想要定义的任意参数。示例中的words也可以追加默认值。

在调用自定义的带参数的task函数的时候,我们还是使用Nornir的run方法,第一个参数是我们定义的task函数,后面是task函数的自定义参数的赋值,我们通过run方法帮忙让其将自定义参数传递给task函数。由于是通过run方法将自定义参数透传给自定义的task函数,Nornir必须清楚地知道每个参数名及其值,示例中调用方法必须是nr.run(task=say_with_words, words='Hello world!'),而不能是results = nr.run(say_with_words, 'Hello world!'),后者会报错。run方法的第一个参数task的形参可写可不写,建议大家写上。

6.5.3 task函数的组合

在自动化框架中,功能模块的复用可以极大提高开发效率,在Nornir中,执行相关任务的功能模块我们可以理解为task函数,它是可以被反复使用的。任何复杂的业务逻辑,本质都是由众多原子功能组成的,其中一部分甚至是可以被复用的,在Nornir中,task函数也是如此。我们可以将已有的task函数相互组合,在一个task函数中调用若干个其他task函数,协同完成一个比较复杂的业务逻辑,Nornir称之为组合task(grouping task)。

一个task函数调用另外一个task函数的使用方法比较简单,只需要在task上下文变量task_context中调用run方法,与Nornir对象的run方法几乎完全一致,第一个是task函数,后面是这个函数的参数,二者还有一些隐藏的参数,我们会后续给大家详细展开。Nornir对象的run方法是作用与若干台网络设备Host对象,task上下文的run方法只作用于当前网络设备Host对象。这就是二者的区别,我们简单演示一段代码来看看。

from nornir import InitNornir
from nornir.core.task import Result
from nornir_utils.plugins.functions import print_result


def say_with_words(task_context, words):
    words_templ = "{}"
    words = words_templ.format(words)
    return Result(host=task_context.host, result=words)


def say_dev_name(task_context):
    words_templ = "This is {}."
    words = words_templ.format(task_context.host)
    return Result(host=task_context.host, result=words)


def grouping_say(task_context):
    task_context.run(task=say_with_words, words='Hello!')
    task_context.run(task=say_dev_name)
    task_context.run(task=say_with_words, words='Bye!')
    result = 'Grouing task is done'
    return Result(host=task_context.host, result=result)


nr = InitNornir(config_file="nornir.yaml")
results = nr.run(task=grouping_say)
print_result(results)

这段runbook中,我们一共定义了三个task函数——grouping_say、say_dev_name、say_with_words,在grouping_say函数中,我们通过task_context的run方法调用其他task函数,在runbook中我们只需要调用grouping_say即可,在这个组合task函数中,它复用了其他两个task且不止一次。这个runbook的输出结果如下:

【拳打Ansible,脚踢Puppet】Nornir宝典2023新编——基础篇_网络设备_06

image-20221115153733895

grouping task的结果展示大概是呈现一个树状结果,先是runbook中的grouping task任务的执行结果,然后是内部的依次调用的子task任务执行的结果,如果子任务也是一个grouping task,则还会按照这个逻辑去展开结果,所以最终呈现一个树状结果。

6.6 结果及其输出

之前我们已经简单讲解了print_result函数,用于打印Nornir runbook的执行结果,它会将网络Host对象、执行的task任务名称、成功与否、变化与否、执行结果展示出来,其中task任务的执行结果其实包含了result和diff两个属性,但一般情况下diff展示使用不多,大家可以简单认为主要展示的是result属性。

6.6.1 四种结果

Nornir任务执行的结果如果去深究,其实是一个比较复杂的概念,对初学者并不友好,主要是因为它涉及到了四个层次:

  1. task函数中的结果,即某设备某次任务的执行结果,它是一个Result类。
  2. 单设备单次任务真实的结果载荷,Result对象中的最重要属性result,其值可以为任意对象。
  3. 一个Nornir对象调用一次run方法的结果,是对若干设备批量执行的一次task的结果,它是一个AggregatedResult类。
  4. Nornir对象整体执行结果AggregatedResult对象中以Host对象为维度进行聚合的结果,它是一个MultiResult。

其中对于第一、第二种“结果”我们之前都感受到了,Result对象是task函数必须返回的值,而Result对象在构建过程中其result参数尤为关键,它会被放置到Result对象的result属性当中。关于Result对象的创建我们在task函数的使用中已经做了介绍。Nornir对象执行task函数,会返回一个AggregatedResult对象,我们使用nornir_utils中的print_result就可以打印输出。但是在与系统对接的时候,我们可能对这些“结果”进行更深层次的使用,我们用一段代码来为大家讲解这四种“结果”。

from nornir import InitNornir
from nornir.core.task import Result


def get_dev_platform(task_context):
    return Result(host=task_context.host, result=task_context.host.platform)


def get_dev_info(task_context):
    dev_info = {
        'name': task_context.host.name,
        'ip': task_context.host.hostname,
    }
    # task上下文调用run方法,执行结果返回的是当前设备调用的task函数的所有结果即MultiResult对象
    host_task_result_obj = task_context.run(get_dev_platform)
    # 由于task函数可以组合调用,最顶层调用位于MultiResult对象最前列
    # 我们用类似列表的索引方式,访问索引0即可获取我们调用的task函数的执行结果
    # 然后取其result属性获取实际返回的载荷结果,
    # 也可以访问其failed等属性,了解其是否失败或者此次任务是否对设备配置产生影响
    platform = host_task_result_obj[0].result
    dev_info['platform'] = platform
    return Result(host=task_context.host, result=dev_info)


if __name__ == '__main__':

    nr = InitNornir(config_file="nornir.yaml")
    nr_results = nr.run(task=get_dev_info)
    print(nr_results, type(nr_results))

    # AggregatedResult对象类似字典,我们可以通过调用其items方法对其循环,
    # 返回的结果是一个元组,两个值对应Host对象和当前Host对象的所有task任务执行结果MultiResult对象
    for host, host_results in nr_results.items():
        print('#' * 20, '这是一个设备的所有task的执行结果MultiResult对象', '#' * 20)
        print(host_results)
        print(type(host_results))
        print('-' * 20, '接下来我们循环迭代一个设备所有task任务的执行结果', '-' * 20)
        # MultiResult对象类似列表,我们直接可以进行for循环,可以使用索引号访问其某次task执行的结果Result对象
        for host_task_result_obj in host_results:
            print('~' * 20, '这是一台设备一个task任务的执行结果Result对象', '~' * 20)
            print(type(host_task_result_obj))
            print(host_task_result_obj)
            print('+' * 20, '这是一台设备一个task任务的执行结果Result对象的result属性', '+' * 20)
            print(type(host_task_result_obj.result))
            print(host_task_result_obj.result)

这段代码我们写了两个task函数,get_dev_info函数会调用get_dev_platform函数,每个task函数都会返回一个单设备单task任务执行结果Result对象。task上下文调用run方法,执行结果返回的是当前设备调用的task函数的所有结果即MultiResult对象(代码中对应host_task_result_obj变量指向的对象),最顶层调用位于MultiResult对象最前列,我们用类似列表的索引方式,访问索引0即可获取我们调用的task函数的执行结果Result对象,然后取其result属性获取实际返回的载荷结果。

Nornir对象执行一个task函数时,针对的是众多网络设备,所以其返回结果是多设备的task任务执行结果,我们可以简单理解它会以类似字典的方式组织结果,key对应的是Host对象,value是这个Host对象的task任务执行结果MultiResult对象。我们通过调用Nornir对象执行结果AggregatedResult对象(代码中对应nr_results变量指向的对象)的items方法,即可获取到Host对象及这台网络设备的所有task任务执行结果MultiResult对象。

对于某网络设备(Host对象)的所有task执行结果MultiResult对象host_task_result_obj,我们可以简单理解其为列表,我们可以通过索引下标访问,也可以for循环遍历访问。这个时候获取的是某网络设备具体task函数执行的结果Result对象,我们可以通过访问其result属性访问开发人员返回的真实载荷数据,也可以访问Result对象的其他属性,比如通过failed判断这次task是否失败,通过changed判断是否产生了一些配置的变化或者影响。

这段代码的执行结果如下,需要大家仔细去核对体会这四种“结果”。

AggregatedResult (get_dev_info): {'netdevops01': MultiResult: [Result: "get_dev_info", Result: "get_dev_platform"], 'netdevops02': MultiResult: [Result: "get_dev_info", Result: "get_dev_platform"]} <class 'nornir.core.task.AggregatedResult'>
#################### 这是一个设备的所有task的执行结果MultiResult对象 ####################
MultiResult: [Result: "get_dev_info", Result: "get_dev_platform"]
<class 'nornir.core.task.MultiResult'>
-------------------- 接下来我们循环迭代一个设备所有task任务的执行结果 --------------------
~~~~~~~~~~~~~~~~~~~~ 这是一台设备一个task任务的执行结果Result对象 ~~~~~~~~~~~~~~~~~~~~
<class 'nornir.core.task.Result'>
{'name': 'netdevops01', 'ip': '192.168.137.201', 'platform': 'huawei'}
++++++++++++++++++++ 这是一台设备一个task任务的执行结果Result对象的result属性 ++++++++++++++++++++
<class 'dict'>
{'name': 'netdevops01', 'ip': '192.168.137.201', 'platform': 'huawei'}
~~~~~~~~~~~~~~~~~~~~ 这是一台设备一个task任务的执行结果Result对象 ~~~~~~~~~~~~~~~~~~~~
<class 'nornir.core.task.Result'>
huawei
++++++++++++++++++++ 这是一台设备一个task任务的执行结果Result对象的result属性 ++++++++++++++++++++
<class 'str'>
huawei
#################### 这是一个设备的所有task的执行结果MultiResult对象 ####################
MultiResult: [Result: "get_dev_info", Result: "get_dev_platform"]
<class 'nornir.core.task.MultiResult'>
-------------------- 接下来我们循环迭代一个设备所有task任务的执行结果 --------------------
~~~~~~~~~~~~~~~~~~~~ 这是一台设备一个task任务的执行结果Result对象 ~~~~~~~~~~~~~~~~~~~~
<class 'nornir.core.task.Result'>
{'name': 'netdevops02', 'ip': '192.168.137.202', 'platform': 'huawei'}
++++++++++++++++++++ 这是一台设备一个task任务的执行结果Result对象的result属性 ++++++++++++++++++++
<class 'dict'>
{'name': 'netdevops02', 'ip': '192.168.137.202', 'platform': 'huawei'}
~~~~~~~~~~~~~~~~~~~~ 这是一台设备一个task任务的执行结果Result对象 ~~~~~~~~~~~~~~~~~~~~
<class 'nornir.core.task.Result'>
huawei
++++++++++++++++++++ 这是一台设备一个task任务的执行结果Result对象的result属性 ++++++++++++++++++++
<class 'str'>
huawei

对于普通用户而言,我们只是简单写一些脚本代码,我们只需要理解Result对象即可,且理解其输出大约是呈现一种树状结果。当我们希望Nornir与一些第三方框架对接时,比如Django、Flask、Celery等,我们需要不能再通过打印去判断执行情况,需要在执行中或者执行结束后,对结果对象进行拆解判断。有时候在写脚本的时候,我们调用子task函数的时候,需要对其执行情况判断,也需要了解一些比较复杂的结果对象。大家需要在实际使用中,逐步体会并掌握。

6.6.2 输出控制

Nornir对象执行task任务之后返回的结果AggregatedResult对象,我们可以通过nornir_utils中的print_result函数来实现比较良好的格式化输出。print_result函数实际能打印我们刚才介绍的三种结果类:AggregatedResult、MultiResult、Result。打印的格式也基本给大家进行了介绍。在实际使用中,task函数有时候会组合调用,尤其是我们有时候会调用nornir_netmiko的一些task函数,最终打印结果中有大量的设备回显,结果比较冗长,而我们有时只想关注自定义task函数的执行结果。这个时候该如何处理呢?

我们可以通过设置结果的日志级别来进行干预,print_result函数中有一个参数是severity_level,等于及高于这个级别的结果都会被输出到控制台当中。print_result函数的severity_level参数默认值是logging.INFO,logging是Python内置的一个关于日志的模块,其内置的日志登记从高到低,可以参考如下:

CRITICAL = 50
FATAL = CRITICAL
ERROR = 40
WARNING = 30
WARN = WARNING
INFO = 20
DEBUG = 10
NOTSET = 0

Result对象的日志级别默认值也是INFO,我们可以通过适当调低task函数中返回的Result的severity_level日志级别,这样在输出结果中,这个task的执行结果就不会被输出了。当然前提是这个task执行情况确实是一个低于INFO级别的信息,无需过多关注。如果task函数是一个组合task函数,它调用了众多的task函数,我们很难一个个的修改子task函数的Result日志级别,而且有些子task函数是通过第三方包进行安装的,也无法修改。这个时候我们可以在task上下文调用run方法的时候赋值severity_level,将其子task全部赋值为一个比较低级别日志等级,这样可以隐藏掉其子task的结果显示。我们也可以通过调高print_result函数中的severity_level日志级别,让一些默认的INFO级别的信息不显示,但是实际使用比较少。以上这种协调调整的出发点都是实际情况,我们确实无需过多关注某个结果,对其级别进行调整。

方式1,通过Result对象的日志级别隐藏一些无需过多关注的执行结果:

from nornir import InitNornir
from nornir.core.task import Result
from nornir_utils.plugins.functions import print_result
import logging


def get_dev_platform(task_context):
    return Result(host=task_context.host,
                  result=task_context.host.platform,
                  severity_level=logging.DEBUG)


def get_dev_info(task_context):
    dev_info = {
        'name': task_context.host.name,
        'ip': task_context.host.hostname,
    }
    host_task_result_obj = task_context.run(get_dev_platform)
    platform = host_task_result_obj[0].result
    dev_info['platform'] = platform
    return Result(host=task_context.host, result=dev_info)


if __name__ == '__main__':
    nr = InitNornir(config_file="nornir.yaml")
    nr_results = nr.run(task=get_dev_info)
    print_result(nr_results)

全局默认的结果显示级别是INFO级别,而我们的get_dev_platform函数返回的Result对象中我们调整为了DEBUG(低于INFO),这样最终runbook执行的时候,就不会显示该task函数的执行结果了。上述代码输出结果为:

get_dev_info********************************************************************
* netdevops01 ** changed : False ***********************************************
vvvv get_dev_info ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO
{'ip': '192.168.137.201', 'name': 'netdevops01', 'platform': 'huawei'}
^^^^ END get_dev_info ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
* netdevops02 ** changed : False ***********************************************
vvvv get_dev_info ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO
{'ip': '192.168.137.202', 'name': 'netdevops02', 'platform': 'huawei'}
^^^^ END get_dev_info ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

结果中明显不再显示get_dev_platform函数返回的结果。

方式2,通过task上下文调用run方法时赋值severity_level一个低级别值。

from nornir import InitNornir
from nornir.core.task import Result
from nornir_utils.plugins.functions import print_result
import logging


def get_dev_platform(task_context):
    return Result(host=task_context.host,result=task_context.host.platform)


def get_dev_info(task_context):
    dev_info = {
        'name': task_context.host.name,
        'ip': task_context.host.hostname,
    }
    host_task_result_obj = task_context.run(get_dev_platform, severity_level=logging.DEBUG)
    platform = host_task_result_obj[0].result
    dev_info['platform'] = platform
    return Result(host=task_context.host, result=dev_info)


if __name__ == '__main__':
    nr = InitNornir(config_file="nornir.yaml")
    nr_results = nr.run(task=get_dev_info)
    print_result(nr_results)

第二段代码的输出结果与方式1的输出结果完全一致,都只有get_dev_info函数的结果,而隐藏了子task函数的结果。

我们在task函数get_dev_info中使用task上下文的run方法调用子task函数,在run方法中,赋值severity_level为DEBUG(低于INFO),而最终print_result打印的默认级别是INFO,所以get_dev_info的执行结果会展示,而其调用的子函数get_dev_platform的执行结果被隐藏。如果get_dev_platform子函数仍调用了若干子函数,这些子函数的结果也会被隐藏。

当大家对于输出结果想进行一些控制的时候,使用以上几种方法基本就可以,一定要结合实际情况,如果一个子task的执行结果不是那么关注,可以改为DEBUG,或者通过其上层调用的task函数赋值severity_level进行隐藏。如果runbook执行结果不符合预期,我们想看更多详细信息,也可以在print_result函数中赋值severity_level为DEBUG级别,展示更多内容,方便排障。大家结合自身实际情况去控制日志的输出。

6.6.3 异常任务的输出逻辑

有些任务因为各种原因没有正常运维,Nornir的执行逻辑是并发的设备之间是并行的,相互独立,默认情况下不会相互干扰。一个组合task函数内部,如果某段代码异常,则会阻止后续的task任务的继续执行,Nornir会自动封装当前组合task任务执行结果Result,它的内容为具体为对应的子task执行失败。

在输出的时候,正常的设备任然按照之前的逻辑输出,而失败的网络设备会只会输出当前异任务的异常Result对象:

  1. 如果是自身原因导致的,会输出代码异常点的错误堆栈。
  2. 如果是由子任务异常引起的,则先输出是何子task异常,然后再输出异常子task异常点的错误堆栈

我们以一段代码进行简单演示。

from nornir import InitNornir
from nornir.core.task import Result
from nornir_utils.plugins.functions import print_result


def get_dev_platform(task_context):
    if task_context.host.hostname.endswith('1'):
        raise Exception('异常演示')
    return Result(host=task_context.host, result=task_context.host.platform)


def get_dev_info(task_context):
    dev_info = {
        'name': task_context.host.name,
        'ip': task_context.host.hostname,
    }
    host_task_result_obj = task_context.run(get_dev_platform)
    platform = host_task_result_obj[0].result
    dev_info['platform'] = platform
    return Result(host=task_context.host, result=dev_info)


if __name__ == '__main__':
    nr = InitNornir(config_file="nornir.yaml")
    nr_results = nr.run(task=get_dev_info)
    print_result(nr_results)

代码中,我们对一台主机进行了干预,让其抛出异常,其运行结果如下:

get_dev_info********************************************************************
* netdevops01 ** changed : False ***********************************************
vvvv get_dev_info ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv ERROR
Subtask: get_dev_platform (failed)

---- get_dev_platform ** changed : False --------------------------------------- ERROR
Traceback (most recent call last):
  File "C:\Pythons\python3.9\lib\site-packages\nornir\core\task.py", line 99, in start
    r = self.task(self, **self.params)
  File "C:\Users\Administrator\OneDrive\文档\NetDevOps\Mybook\codes\第六章\04-result\04-print_result_err.py", line 8, in get_dev_platform
    raise Exception('异常演示')
Exception: 异常演示

^^^^ END get_dev_info ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
* netdevops02 ** changed : False ***********************************************
vvvv get_dev_info ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO
{'ip': '192.168.137.202', 'name': 'netdevops02', 'platform': 'huawei'}
---- get_dev_platform ** changed : False --------------------------------------- INFO
huawei
^^^^ END get_dev_info ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

我们结果发现netdevops01设备执行task时发生了异常,且有两段异常信息,一段是告知我们那个子task报错,一段是具体异常的子task的详细错误堆栈。