精通-Python-网络编程第二版-全-

精通 Python 网络编程第二版(全)

原文:zh.annas-archive.org/md5/dda7e4d1dd78bc5577547014ce9b53d1

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

正如查尔斯·狄更斯在《双城记》中写道,“这是最好的时代,也是最坏的时代,这是智慧的时代,也是愚蠢的时代。”他看似矛盾的话语完美地描述了变革和过渡时期的混乱和情绪。毫无疑问,我们正在经历网络工程领域的快速变化。随着软件开发在网络的各个方面变得更加集成,传统的命令行界面和垂直集成的网络堆栈方法不再是管理今天网络的最佳方式。对于网络工程师来说,我们所看到的变化充满了兴奋和机遇,但对于那些需要快速适应和跟上的人来说,也是具有挑战性的。本书旨在通过提供一个实用指南来帮助网络专业人士缓解过渡,解决如何从传统平台发展到基于软件驱动实践的问题。

在这本书中,我们使用 Python 作为首选的编程语言,以掌握网络工程任务。Python 是一种易于学习的高级编程语言,可以有效地补充网络工程师的创造力和问题解决能力,以简化日常操作。Python 正在成为许多大型网络的一个组成部分,通过这本书,我希望与您分享我所学到的经验。

自第一版出版以来,我已经与许多读者进行了有趣而有意义的交流。第一版书的成功让我感到谦卑,并且我对所得到的反馈非常重视。在第二版中,我尝试使示例和技术更加相关。特别是,传统的 OpenFlow SDN 章节被一些网络 DevOps 工具所取代。我真诚地希望新的内容对你有所帮助。

变革的时代为技术进步提供了巨大的机遇。本书中的概念和工具在我的职业生涯中帮助了我很多,我希望它们也能对你有同样的帮助。

这本书适合谁

这本书非常适合已经管理网络设备组并希望扩展他们对使用 Python 和其他工具克服网络挑战的知识的 IT 专业人员和运维工程师。建议具有网络和 Python 的基本知识。

本书涵盖内容

第一章,TCP/IP 协议套件和 Python 回顾,回顾了构成当今互联网通信的基本技术,从 OSI 和客户端-服务器模型到 TCP、UDP 和 IP 协议套件。本章将回顾 Python 语言的基础知识,如类型、运算符、循环、函数和包。

第二章,低级网络设备交互,使用实际示例说明如何使用 Python 在网络设备上执行命令。它还将讨论在自动化中仅具有 CLI 界面的挑战。本章将使用 Pexpect 和 Paramiko 库进行示例。

第三章,API 和意图驱动的网络,讨论了支持应用程序编程接口API)和其他高级交互方法的新型网络设备。它还说明了允许在关注网络工程师意图的同时抽象低级任务的工具。本章将使用 Cisco NX-API、Juniper PyEZ 和 Arista Pyeapi 的讨论和示例。

第四章,《Python 自动化框架- Ansible 基础》,讨论了 Ansible 的基础知识,这是一个基于 Python 的开源自动化框架。Ansible 比 API 更进一步,专注于声明性任务意图。在本章中,我们将介绍使用 Ansible 的优势、其高级架构,并展示一些与思科、Juniper 和 Arista 设备一起使用 Ansible 的实际示例。

第五章,《Python 自动化框架-进阶》,在前一章的基础上,涵盖了更高级的 Ansible 主题。我们将介绍条件、循环、模板、变量、Ansible Vault 和角色。还将介绍编写自定义模块的基础知识。

第六章,《Python 网络安全》,介绍了几种 Python 工具,帮助您保护网络。将讨论使用 Scapy 进行安全测试,使用 Ansible 快速实施访问列表,以及使用 Python 进行网络取证分析。

第七章,《Python 网络监控-第 1 部分》,涵盖了使用各种工具监控网络。本章包含了一些使用 SNMP 和 PySNMP 进行查询以获取设备信息的示例。还将展示 Matplotlib 和 Pygal 示例来绘制结果。本章将以使用 Python 脚本作为输入源的 Cacti 示例结束。

第八章,《Python 网络监控-第 2 部分》,涵盖了更多的网络监控工具。本章将从使用 Graphviz 根据 LLDP 信息绘制网络开始。我们将继续使用推送式网络监控的示例,使用 Netflow 和其他技术。我们将使用 Python 解码流数据包和 ntop 来可视化结果。还将概述 Elasticsearch 以及如何用于网络监控。

第九章,《使用 Python 构建网络 Web 服务》,向您展示如何使用 Python Flask Web 框架为网络自动化创建自己的 API。网络 API 提供了诸如将请求者与网络细节抽象化、整合和定制操作以及通过限制可用操作的暴露来提供更好的安全性等好处。

第十章,《AWS 云网络》,展示了如何使用 AWS 构建一个功能齐全且具有弹性的虚拟网络。我们将介绍诸如 CloudFormation、VPC 路由表、访问列表、弹性 IP、NAT 网关、Direct Connect 等虚拟私有云技术以及其他相关主题。

第十一章,《使用 Git 工作》,我们将说明如何利用 Git 进行协作和代码版本控制。本章将使用 Git 进行网络操作的实际示例。

第十二章,《Jenkins 持续集成》,使用 Jenkins 自动创建操作流水线,可以节省时间并提高可靠性。

第十三章,《网络的测试驱动开发》,解释了如何使用 Python 的 unittest 和 PyTest 创建简单的测试来验证我们的代码。我们还将看到编写用于验证可达性、网络延迟、安全性和网络事务的网络测试的示例。我们还将看到如何将这些测试集成到 Jenkins 等持续集成工具中。

为了充分利用本书

为了充分利用本书,建议具备一些基本的网络操作知识和 Python 知识。大多数章节可以任意顺序阅读,但第四章和第五章必须按顺序阅读。除了书的开头介绍的基本软件和硬件工具外,每个章节还会介绍与该章节相关的新工具。

强烈建议按照自己的网络实验室中显示的示例进行跟踪和练习。

下载示例代码文件

您可以从www.packtpub.com的账户中下载本书的示例代码文件。如果您在其他地方购买了本书,可以访问www.packtpub.com/support注册,以便直接通过电子邮件接收文件。

您可以按照以下步骤下载代码文件:

  1. 登录或注册网址为www.packtpub.com

  2. 选择“支持”选项卡。

  3. 单击“代码下载和勘误”。

  4. 在“搜索”框中输入书名,然后按照屏幕上的说明操作。

下载文件后,请确保使用最新版本的解压缩或提取文件夹:

  • WinRAR/7-Zip 适用于 Windows

  • Zipeg/iZip/UnRarX 适用于 Mac

  • 7-Zip/PeaZip 适用于 Linux

该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Mastering-Python-Networking-Second-Edition。如果代码有更新,将在现有的 GitHub 存储库上进行更新。

我们还有其他代码包,来自我们丰富的图书和视频目录,可在github.com/PacktPublishing/上找到。快去看看吧!

下载彩色图片

我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图片。您可以在此处下载:www.packtpub.com/sites/default/files/downloads/MasteringPythonNetworkingSecondEdition_ColorImages.pdf

使用的约定

本书中使用了许多文本约定。

CodeInText:表示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。例如:“自动配置还生成了vty访问,用于 telnet 和 SSH。”

代码块设置如下:

# This is a comment
print("hello world")

任何命令行输入或输出都按照以下格式编写:

$ python
Python 2.7.12 (default, Dec 4 2017, 14:50:18)
[GCC 5.4.0 20160609] on linux2
Type "help", "copyright", "credits" or "license" for more information.
>>> exit()

粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词会以这种形式出现在文本中。例如:“在‘拓扑设计’选项中,我将‘管理网络’选项设置为‘共享平面网络’,以便在虚拟路由器上使用 VMnet2 作为管理网络。”

警告或重要提示会以这种形式出现。提示和技巧会以这种形式出现。

第一章:TCP/IP 协议套件和 Python 的回顾

欢迎来到网络工程的新时代。18 年前,也就是在千禧年之交,我开始担任网络工程师时,这个角色与其他技术角色有着明显的不同。网络工程师主要具有领域特定的知识,用于管理和操作局域网和广域网,偶尔会涉足系统管理,但没有写代码或理解编程概念的期望。现在情况已经不同了。多年来,DevOps 和软件定义网络(SDN)运动等因素显著地模糊了网络工程师、系统工程师和开发人员之间的界限。

您拿起这本书的事实表明您可能已经是网络 DevOps 的采用者,或者您正在考虑走这条路。也许您已经作为网络工程师工作了多年,就像我一样,想知道 Python 编程语言的热度是怎么回事。或者您可能已经精通 Python,但想知道它在网络工程中的应用。如果您属于这些人群,或者只是对网络工程领域中的 Python 感到好奇,我相信这本书适合您:

Python 和网络工程的交集

已经有很多深入探讨网络工程和 Python 主题的书籍。我不打算在本书中重复他们的努力。相反,本书假设您有一些管理网络的实际经验,以及对网络协议和 Python 语言的基本理解。您不需要成为 Python 或网络工程的专家,但应该发现本章中的概念构成了一个概括性的回顾。本章的其余部分应该设定了对先前知识的期望水平,以便从本书中获得最大的收获。如果您想复习本章的内容,有很多免费或低成本的资源可以帮助您迅速掌握。我建议使用免费的可汗学院(www.khanacademy.org/)和 Python 教程:www.python.org/.

本章将快速介绍相关的网络主题。根据我在这个领域工作的经验,一个典型的网络工程师或开发人员可能不记得确切的 TCP 状态机来完成他们的日常任务(我知道我不记得),但他们会熟悉 OSI 模型的基础知识、TCP 和 UDP 的操作、不同的 IP 头字段以及其他基本概念。

我们还将对 Python 语言进行高层次的概述;对于那些不是每天都用 Python 编码的读者来说,这足够让他们在本书的其余部分有所准备。

具体来说,我们将涵盖以下主题:

  • 互联网概述

  • OSI 和客户端-服务器模型

  • TCP、UDP 和 IP 协议套件

  • Python 语法、类型、运算符和循环

  • 使用函数、类和包扩展 Python

当然,本章中提供的信息并不是详尽无遗的;请查看参考资料以获取更多信息。

互联网概述

什么是互联网?这个看似简单的问题可能会因你的背景而得到不同的答案。互联网对不同的人意味着不同的东西;年轻人、老年人、学生、教师、商人、诗人,都可能对这个问题给出不同的答案。

对于网络工程师来说,互联网是一个全球计算机网络,由一系列互联网络连接大大小小的网络。换句话说,它是一个没有集中所有者的网络。以您的家庭网络为例。它可能由家用以太网交换机和无线接入点组成,将您的智能手机、平板电脑、计算机和电视连接在一起,以便设备之间进行通信。这就是您的局域网LAN)。当您的家庭网络需要与外部世界通信时,它会将信息从您的 LAN 传递到一个更大的网络,通常称为互联网服务提供商ISP)。您的 ISP 通常由边缘节点组成,这些节点将流量聚合到其核心网络中。核心网络的功能是通过更高速的网络连接这些边缘网络。在特定的边缘节点,您的 ISP 连接到其他 ISP,以适当地将您的流量传递到目的地。从目的地返回到您的家用计算机、平板电脑或智能手机的路径可能会或可能不会沿着同样的路径穿过所有这些网络返回到您的设备,而源和目的地保持不变。

让我们来看看构成这个网络之网的组件。

服务器、主机和网络组件

主机是网络上的终端节点,与其他节点进行通信。在今天的世界中,主机可以是传统计算机,也可以是您的智能手机、平板电脑或电视。随着物联网IoT)的兴起,主机的广义定义可以扩展到包括 IP 摄像机、电视机顶盒以及我们在农业、农场、汽车等领域使用的越来越多类型的传感器。随着连接到互联网的主机数量的激增,所有这些主机都需要被寻址、路由和管理。对适当的网络需求从未如此迫切。

我们在互联网上大部分时间都是在请求服务。这可能是查看网页,发送或接收电子邮件,传输文件等。这些服务是由服务器提供的。顾名思义,服务器为多个节点提供服务,并且通常具有更高级别的硬件规格。在某种程度上,服务器是网络上提供额外功能的特殊超级节点。我们将在客户端-服务器模型部分稍后讨论服务器。

如果您将服务器和主机视为城市和城镇,网络组件就是连接它们的道路和高速公路。事实上,在描述跨越全球传输不断增加的比特和字节的网络组件时,信息高速公路这个术语就会浮现在脑海中。在我们稍后将要看到的 OSI 模型中,这些网络组件是第一到第三层设备。它们是第二和第三层的路由器和交换机,用于指导流量,以及第一层的传输设备,如光纤电缆、同轴电缆、双绞铜线和一些 DWDM 设备,等等。

总的来说,主机、服务器和网络组件构成了我们今天所知的互联网。

数据中心的崛起

在上一节中,我们看到了服务器、主机和网络组件在互联网中扮演的不同角色。由于服务器需要更高的硬件容量,它们通常被放在一个中央位置,以便更有效地进行管理。我们经常将这些位置称为数据中心。

企业数据中心

在典型的企业中,公司通常需要内部工具,如电子邮件、文档存储、销售跟踪、订购、人力资源工具和知识共享内部网。这些服务转化为文件和邮件服务器、数据库服务器和 Web 服务器。与用户计算机不同,这些通常是需要大量电力、冷却和网络连接的高端计算机。硬件的副产品也是它们产生的噪音量。它们通常被放置在企业的中心位置,称为主配线架(MDF),以提供必要的电力供应、电力冗余、冷却和网络连接。

为了连接到 MDF,用户的流量通常会在距离用户更近的位置进行聚合,有时被称为中间分配框架(IDF),然后再捆绑并连接到 MDF。 IDF-MDF 的分布通常遵循企业建筑或校园的物理布局。例如,每个楼层可以包括一个 IDF,它会聚合到另一楼层的 MDF。如果企业由多栋建筑组成,可以通过将建筑的流量组合起来,然后连接到企业数据中心来进一步进行聚合。

企业数据中心通常遵循三层网络设计。这些层是接入层、分发层和核心层。接入层类似于每个用户连接的端口,IDF 可以被视为分发层,而核心层包括与 MDF 和企业数据中心的连接。当然,这是企业网络的概括,因为其中一些网络将不会遵循相同的模型。

云数据中心

随着云计算和软件或基础设施即服务的兴起,云提供商建立的数据中心规模庞大。由于它们所容纳的服务器数量,它们通常对电力、冷却、网络速度和供电需求远远高于任何企业数据中心。即使在云提供商数据中心工作多年后,每次我访问云提供商数据中心时,我仍然对它们的规模感到惊讶。事实上,云数据中心如此庞大且耗电量巨大,它们通常建在靠近发电厂的地方,以获得最便宜的电力费率,而在输电过程中不会损失太多效率。它们的冷却需求如此之大,有些被迫在建造数据中心时寻求创意,选择建在通常气候寒冷的地方,这样他们就可以在需要时打开门窗,保持服务器以安全温度运行。任何搜索引擎都可以给出一些惊人的数字,涉及为亚马逊、微软、谷歌和 Facebook 等公司建造和管理云数据中心的科学:

犹他数据中心(来源:https://en.wikipedia.org/wiki/Utah_Data_Center)

在云提供商的规模下,它们需要提供的服务通常不具备成本效益,或者无法合理地容纳在单个服务器中。它们分布在一群服务器之间,有时跨越许多不同的机架,以提供冗余和灵活性给服务所有者。延迟和冗余要求对网络施加了巨大的压力。互连的数量相当于网络设备的爆炸性增长;这意味着这些网络设备需要被装架、配置和管理的次数。典型的网络设计是多级 CLOS 网络:

CLOS 网络

在某种程度上,云数据中心是网络自动化成为速度和可靠性的必要性的地方。如果我们按照传统的方式通过终端和命令行界面管理网络设备,所需的工程小时数将不允许服务在合理的时间内可用。更不用说人类的重复是容易出错、低效和工程人才的可怕浪费。

云数据中心是我多年前开始使用 Python 进行网络自动化的地方,自那以后我就再也没有回头过。

边缘数据中心

如果我们在数据中心级别有足够的计算能力,为什么还要将任何东西放在数据中心之外呢?来自世界各地的客户的所有连接都可以路由回提供服务的数据中心服务器,我们就可以结束一天了,对吗?当然,答案取决于用例。将请求和会话从客户端一直路由到大型数据中心的最大限制是传输中引入的延迟。换句话说,大延迟是网络成为瓶颈的地方。延迟数字永远不会为零:即使光在真空中传播得很快,物理传输也需要时间。在现实世界中,当数据包穿过多个网络时,有时还穿过海底电缆、慢速卫星链路、3G 或 4G 蜂窝链路或 Wi-Fi 连接时,延迟会比真空中的光要高得多。

解决方案?减少终端用户穿越的网络数量。尽可能与用户在用户进入您的网络的边缘处紧密连接,并在边缘位置放置足够的资源来提供服务。让我们花一分钟想象一下,您正在构建下一代视频流媒体服务。为了提高顾客对流畅播放的满意度,您会希望将视频服务器尽可能靠近客户,要么在客户的 ISP 内部,要么非常靠近客户的 ISP。此外,视频服务器农场的上游不仅连接到一个或两个 ISP,而是连接到我可以连接的所有 ISP,以减少跳数。所有连接都将具有所需的带宽,以在高峰时段减少延迟。这种需求催生了大型 ISP 和内容提供商的对等交换边缘数据中心。即使网络设备的数量不像云数据中心那样多,它们也可以从网络自动化中受益,因为网络自动化带来了增加的可靠性、安全性和可见性。

我们将在本书的后面章节中涵盖安全性和可见性。

OSI 模型

没有网络书籍是完整的,没有先讨论开放系统互连OSI)模型。该模型是一个概念模型,将电信功能组件化为不同的层。该模型定义了七个层,每个层都独立地位于另一个层的顶部,只要它们遵循定义的结构和特征。例如,在网络层,IP 可以位于不同类型的数据链路层的顶部,如以太网或帧中继。OSI 参考模型是将不同和多样的技术规范化为一组人们可以达成一致的共同语言的好方法。这大大减少了在各自层上工作的各方的范围,并允许他们深入研究特定任务,而不用太担心兼容性。

OSI 模型

OSI 模型最初是在 20 世纪 70 年代后期进行研究的,后来由国际标准化组织ISO)和现在被称为国际电信联盟ITU-T)的电信标准化部门联合出版。它被广泛接受,并在引入电信新主题时通常被引用。

在 OSI 模型开发的同时期,互联网正在形成。原始设计者使用的参考模型通常被称为 TCP/IP 模型。传输控制协议(TCP)和互联网协议(IP)是最初包含在设计中的协议套件。这在某种程度上类似于 OSI 模型,因为它们将端到端数据通信分为抽象层。不同的是,该模型将 OSI 模型中的第 5 至 7 层合并为应用层,而物理层和数据链路层合并为链路层。

互联网协议套件

OSI 和 TCP/IP 模型都对提供端到端数据通信的标准很有用。然而,大部分时间我们将更多地参考 TCP/IP 模型,因为互联网就是建立在这个模型上的。当我们讨论即将到来的章节中的 Web 框架时,我们将指定 OSI 模型。

客户端-服务器模型

参考模型展示了数据在两个节点之间进行通信的标准方式。当然,到现在为止,我们都知道,并非所有节点都是平等的。即使在 DARPA 网络的早期,也有工作站节点,也有目的是向其他节点提供内容的节点。这些服务器节点通常具有更高的硬件规格,并由工程师更密切地管理。由于这些节点向其他节点提供资源和服务,它们通常被称为服务器。服务器通常处于空闲状态,等待客户端发起对其资源的请求。这种由客户端请求的分布式资源模型被称为客户端-服务器模型。

为什么这很重要?如果你仔细想一想,客户端-服务器模型凸显了网络的重要性。没有它,网络互连的需求其实并不是很大。正是客户端向服务器传输比特和字节的需求,突显了网络工程的重要性。当然,我们都知道,最大的网络——互联网,一直在改变我们所有人的生活,并持续不断地这样做。

你可能会问,每个节点如何确定每次需要相互通信时的时间、速度、源和目的地?这就引出了网络协议。

网络协议套件

在计算机网络的早期,协议是专有的,并由设计连接方法的公司严格控制。如果您在主机中使用 Novell 的 IPX/SPX 协议,您将无法与苹果的 AppleTalk 主机进行通信,反之亦然。这些专有协议套件通常与 OSI 参考模型具有类似的层,并遵循客户端-服务器通信方法。它们通常在局域网(LAN)中运行良好,这些局域网是封闭的,无需与外部世界通信。当流量需要移动到本地局域网之外时,通常会使用互联网设备,如路由器,来将一个协议转换为另一个协议。例如,路由器连接 AppleTalk 网络到基于 IP 的网络。翻译通常不完美,但由于在早期大部分通信发生在局域网内,这是可以接受的。

然而,随着对局域网之外的互联网通信需求的增加,标准化网络协议套件的需求变得更加迫切。专有协议最终让位于 TCP、UDP 和 IP 的标准化协议套件,这极大地增强了一个网络与另一个网络进行通信的能力。互联网,所有网络中最伟大的网络,依赖这些协议来正常运行。在接下来的几节中,我们将看一下每个协议套件。

传输控制协议

传输控制协议TCP)是今天互联网上使用的主要协议之一。如果您打开过网页或发送过电子邮件,您就已经接触过 TCP 协议。该协议位于 OSI 模型的第 4 层,负责以可靠和经过错误检查的方式在两个节点之间传递数据段。TCP 由一个包括源端口、目的端口、序列号、确认号、控制标志和校验和在内的 160 位标头组成:

TCP 标头

TCP 的功能和特性

TCP 使用数据报套接字或端口来建立主机之间的通信。称为Internet Assigned Numbers AuthorityIANA)的标准机构指定了知名端口,以指示特定服务,例如端口80用于 HTTP(web),端口25用于 SMTP(邮件)。在客户端-服务器模型中,服务器通常在这些知名端口之一上监听,以便接收来自客户端的通信请求。TCP 连接由操作系统通过表示连接的本地端点的套接字来管理。

协议操作由一个状态机组成,其中状态机需要跟踪何时正在监听传入连接,以及在通信会话期间释放资源。每个 TCP 连接都经历一系列状态,如ListenSYN-SENTSYN-RECEIVEDESTABLISHEDFIN-WAITCLOSE-WAITCLOSINGLAST-ACKTIME-WAITCLOSED

TCP 消息和数据传输

TCP 和用户数据报协议UDP)之间最大的区别是,TCP 以有序和可靠的方式传输数据。操作保证传递通常被称为 TCP 是一种面向连接的协议。它通过首先建立三次握手来同步发送方和接收方之间的序列号SYNSYN-ACKACK来实现这一点。

确认用于跟踪对话中的后续段。最后,在对话结束时,一方将发送一个FIN消息,另一方将ACK这个FIN消息,并发送自己的FIN消息。FIN发起方然后将ACK收到的FIN消息。

正如许多我们曾经排查过 TCP 连接的人所能告诉你的那样,这个操作可能会变得非常复杂。大多数情况下,这个操作只是在后台默默地进行。

关于 TCP 协议可以写一整本书;事实上,已经有许多优秀的书籍写就了这个协议。

由于本节是一个快速概述,如果感兴趣,可以使用 TCP/IP 指南(www.tcpipguide.com/)这个优秀的免费资源来深入了解这个主题。

用户数据报协议

用户数据报协议UDP)也是互联网协议套件的核心成员之一。与 TCP 一样,它在 OSI 模型的第 4 层上运行,负责在应用程序和 IP 层之间传递数据段。与 TCP 不同的是,UDP 的标头只有 64 位,其中只包括源端口、目的端口、长度和校验和。轻量级的标头使其非常适合那些更喜欢快速数据传递而不需要在两个主机之间建立会话或需要可靠数据传递的应用程序。也许在今天快速的互联网连接下很难想象,但在 X.21 和帧中继链路的早期,额外的标头对传输速度产生了很大影响。尽管速度差异同样重要,但与 TCP 一样,不必维护各种状态也节省了两个端点的计算机资源:

UDP 标头

您可能会想为什么在现代时代还要使用 UDP;考虑到可靠传输的缺乏,我们难道不希望所有连接都是可靠且无错误的吗?如果考虑多媒体视频流或 Skype 通话,这些应用程序受益于轻量级标头,因为应用程序只是希望尽快传递数据报。您还可以考虑基于 UDP 协议的快速 DNS 查找过程。当您在浏览器中输入的地址被转换为计算机可理解的地址时,用户将受益于轻量级过程,因为这必须在您从您喜爱的网站接收到第一个比特之前发生。

再次强调,本节对 UDP 的主题并不充分,鼓励读者通过各种资源探索该主题,如果您对学习更多关于 UDP 感兴趣的话。

互联网协议

正如网络工程师所说,他们活在互联网协议(IP)层,这是 OSI 模型的第 3 层。IP的工作是在终端节点之间进行寻址和路由等。IP 的寻址可能是它最重要的工作。地址空间分为两部分:网络部分和主机部分。子网掩码用于指示网络地址中的网络部分和主机部分,通过将网络部分与 1 匹配,主机部分与 0 匹配。IPv4 和后来的 IPv6 都以点分表示法表示地址,例如192.168.0.1。子网掩码可以以点分表示法(255.255.255.0)或使用斜杠表示应考虑的网络位数(/24):

IPv4 头部

IPv6 头部是 IPv4 的 IP 头部的下一代,具有固定部分和各种扩展标头:

IPv6 固定头部

固定头部中的下一个标头字段可以指示后续携带附加信息的扩展标头。扩展标头可以包括路由和分段信息。尽管协议设计者希望从 IPv4 转移到 IPv6,但今天的互联网仍然主要使用 IPv4 进行寻址,部分服务提供商网络内部使用 IPv6 进行寻址。

IP NAT 和安全

网络地址转换NAT)通常用于将一系列私有 IPv4 地址转换为公共可路由的 IPv4 地址。但它也可以意味着 IPv4 到 IPv6 之间的转换,例如在运营商边缘使用 IPv6 内部网络需要转换为 IPv4 时。有时也出于安全原因使用 NAT6 到 6。

安全是一个持续的过程,整合了网络的所有方面,包括自动化和 Python。本书旨在使用 Python 帮助您管理网络;安全将作为本书后续章节的一部分进行讨论,例如使用 SSHv2 替代 telnet。我们还将探讨如何使用 Python 和其他工具来获得网络的可见性。

IP 路由概念

在我看来,IP 路由是指让两个端点之间的中间设备根据 IP 头部传输数据包。对于所有通过互联网进行的通信,数据包将通过各种中间设备传输。如前所述,中间设备包括路由器、交换机、光学设备和其他不会检查网络和传输层以外内容的设备。用一种道路旅行的类比来说,你可能会从加利福尼亚州的圣迭戈市到华盛顿州的西雅图市旅行。IP 源地址类似于圣迭戈,目的地 IP 地址可以被视为西雅图。在你的旅行中,你会经过许多不同的中间地点,比如洛杉矶、旧金山和波特兰;这些可以被视为源地址和目的地之间的路由器和交换机。

为什么这很重要?在某种程度上,这本书是关于管理和优化这些中间设备。在跨越多个美式橄榄球场大小的超大数据中心时代,高效、灵活、可靠和具有成本效益的网络管理方式成为公司的竞争优势的重要点。在未来的章节中,我们将深入探讨如何使用 Python 编程有效地管理网络。

Python 语言概述

简而言之,这本书是关于如何使用 Python 使我们的网络工程生活更轻松。但是 Python 是什么,为什么它是许多 DevOps 工程师的首选语言呢?用 Python 基金会执行摘要的话来说:

“Python 是一种解释型、面向对象的高级编程语言,具有动态语义。它的高级内置数据结构,结合动态类型和动态绑定,使其非常适合快速应用程序开发,以及作为脚本或粘合语言来连接现有组件。Python 的简单、易学的语法强调可读性,因此降低了程序维护的成本。”

如果你对编程还比较陌生,前面提到的面向对象、动态语义可能对你来说意义不大。但我认为我们都可以同意,对于快速应用程序开发来说,简单易学的语法听起来是一件好事。作为一种解释型语言,Python 意味着不需要编译过程,因此编写、测试和编辑 Python 程序的时间大大缩短。对于简单的脚本,如果你的脚本出错,通常只需要一个print语句就可以调试出问题所在。使用解释器还意味着 Python 很容易移植到不同类型的操作系统,比如 Windows 和 Linux,一个在一个操作系统上编写的 Python 程序可以在另一个操作系统上使用。

面向对象的特性鼓励通过将大型程序分解为简单可重用的对象来实现代码重用,以及其他可重用的格式,如函数、模块和包。事实上,所有的 Python 文件都是可以被重用或导入到另一个 Python 程序中的模块。这使得工程师之间可以轻松共享程序,并鼓励代码重用。Python 还有一个“电池包含在内”的口号,这意味着对于常见的任务,你不需要下载任何额外的包。为了在不使代码过于臃肿的情况下实现这一点,当你安装 Python 解释器时,一组标准库会被安装。对于常见的任务,比如正则表达式、数学函数和 JSON 解码,你只需要使用import语句,解释器就会将这些函数移入你的程序中。这是我认为 Python 语言的一个杀手功能。

最后,Python 代码可以从几行代码的相对小型脚本开始,并逐渐发展成一个完整的生产系统,对于网络工程师来说非常方便。正如我们许多人所知,网络通常是在没有总体规划的情况下有机地发展的。一种可以随着网络规模增长的语言是非常宝贵的。您可能会惊讶地看到,许多前沿公司(使用 Python 的组织)正在使用被许多人认为是脚本语言的语言来开发完整的生产系统。

如果您曾经在需要在不同的供应商平台上工作时不得不切换,比如 Cisco IOS 和 Juniper Junos,您就知道在尝试完成相同任务时切换语法和用法是多么痛苦。由于 Python 足够灵活,可以用于大型和小型程序,因此没有这种上下文切换,因为它只是 Python。

在本章的其余部分,我们将对 Python 语言进行高层次的介绍,以便稍作复习。如果您已经熟悉基础知识,可以快速浏览或跳过本章的其余部分。

Python 版本

许多读者已经意识到,Python 在过去几年中一直在从 Python 2 过渡到 Python 3。 Python 3 于 2008 年发布,已经有 10 多年的历史,最近发布了 3.7 版本。不幸的是,Python 3 与 Python 2 不兼容。在撰写本书第二版时,即 2018 年中期,Python 社区基本上已经转向 Python 3。最新的 Python 2.x 版本 2.7 是在 2010 年中期发布的,已经有 6 年多的历史了。幸运的是,两个版本可以在同一台机器上共存。我个人在命令提示符中输入 Python 时使用 Python 2 作为默认解释器,需要使用 Python 3 时则使用 Python 3。关于调用 Python 解释器的更多信息将在下一节中给出,但这里有一个在 Ubuntu Linux 机器上调用 Python 2 和 Python 3 的示例:

$ python
Python 2.7.12 (default, Dec 4 2017, 14:50:18)
[GCC 5.4.0 20160609] on linux2
Type "help", "copyright", "credits" or "license" for more information.
>>> exit() 
$ python3
Python 3.5.2 (default, Nov 23 2017, 16:37:01)
[GCC 5.4.0 20160609] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> exit() 

随着 2.7 版本的终止生命周期,大多数 Python 框架现在支持 Python 3。Python 3 还有许多很好的特性,比如异步 I/O,在需要优化我们的代码时可以利用这些特性。本书的代码示例将使用 Python 3,除非另有说明。我们还将尽量在适用时指出 Python 2 和 Python 3 之间的区别。

如果特定的库或框架更适合 Python 2,比如 Ansible(请参阅以下信息),我们将指出并使用 Python 2。

在撰写本文时,Ansible 2.5 及以上版本支持 Python 3。在 2.5 之前,Python 3 支持被视为技术预览。鉴于相对较新的支持性,许多社区模块仍然需要迁移到 Python 3。有关 Ansible 和 Python 3 的更多信息,请参阅docs.ansible.com/ansible/2.5/dev_guide/developing_python_3.html

操作系统

如前所述,Python 是跨平台的。Python 程序可以在 Windows、Mac 和 Linux 上运行。实际上,在需要确保跨平台兼容性时需要注意一些细节,比如在 Windows 文件名中反斜杠的微妙差异。由于本书是为 DevOps、系统和网络工程师编写的,Linux 是预期受众的首选平台,特别是在生产环境中。本书中的代码将在 Linux Ubuntu 16.06 LTS 机器上进行测试。我也会尽力确保代码在 Windows 和 MacOS 平台上运行相同。

如果您对操作系统的详细信息感兴趣,它们如下:

$ uname -a
Linux packt-network-python 4.13.0-45-generic #50~16.04.1-Ubuntu SMP Wed May 30 11:18:27 UTC 2018 x86_64 x86_64 x86_64 GNU/Linux 

运行 Python 程序

Python 程序由解释器执行,这意味着代码通过解释器传递给底层操作系统执行,并显示结果。Python 开发社区有几种不同的解释器实现,例如 IronPython 和 Jython。在本书中,我们将使用今天最常用的 Python 解释器,即 CPython。在本书中提到 Python 时,我们指的是 CPython,除非另有说明。

您可以使用 Python 的交互式提示符来使用 Python 的一种方式。当您想要快速测试一段 Python 代码或概念而不写整个程序时,这是很有用的。通常只需输入Python关键字即可:

Python 3.5.2 (default, Nov 17 2016, 17:05:23)
[GCC 5.4.0 20160609] on linux
Type "help", "copyright", "credits" or "license" for 
more information.
>>> print("hello world")
hello world
>>>

在 Python 3 中,print语句是一个函数;因此,它需要括号。在 Python 2 中,您可以省略括号。

交互模式是 Python 最有用的功能之一。在交互式 shell 中,您可以输入任何有效的语句或语句序列,并立即得到结果。我通常用它来探索我不熟悉的功能或库。谈论即时满足!

在 Windows 上,如果没有收到 Python shell 提示符,则可能没有将程序添加到系统搜索路径中。最新的 Windows Python 安装程序提供了一个复选框,用于将 Python 添加到系统路径中;确保已经选中。或者您可以通过转到环境设置手动将程序添加到路径中。

然而,运行 Python 程序的更常见的方法是保存您的 Python 文件,并在之后通过解释器运行它。这将使您免于在交互式 shell 中一遍又一遍地输入相同的语句。Python 文件只是通常以.py扩展名保存的普通文本文件。在*Nix 世界中,您还可以在顶部添加shebang#!)行,以指定将用于运行文件的解释器。#字符可用于指定不会被解释器执行的注释。以下文件helloworld.py包含以下语句:

# This is a comment
print("hello world") 

可以按照以下方式执行:

$ python helloworld.py
hello world
$

Python 内置类型

Python 在解释器中内置了几种标准类型:

  • NoneNull对象

  • 数值intlongfloatcomplexbool(带有TrueFalse值的int子类)

  • 序列str、list、tuple 和 range

  • 映射dict

  • 集合setfrozenset

None 类型

None类型表示没有值的对象。在不明确返回任何内容的函数中返回None类型。None类型也用于函数参数,如果调用者没有传入实际值,则会出错。

数值

Python 数值对象基本上是数字。除了布尔值外,intlongfloatcomplex这些数值类型都是有符号的,这意味着它们可以是正数或负数。布尔值是整数的一个子类,可以是两个值之一:True1False0。其余的数值类型是通过它们能够准确表示数字的方式来区分的;例如,int是具有有限范围的整数,而long是具有无限范围的整数。浮点数是使用机器上的双精度表示(64 位)的数字。

序列

序列是具有非负整数索引的对象的有序集合。在本节和接下来的几节中,我们将使用交互式解释器来说明不同的类型。请随时在您自己的计算机上输入。

有时人们会感到惊讶,string实际上是一个序列类型。但是如果你仔细看,字符串是一系列字符组合在一起。字符串可以用单引号、双引号或三引号括起来。请注意,在以下示例中,引号必须匹配,三引号允许字符串跨越不同的行:

>>> a = "networking is fun"
>>> b = 'DevOps is fun too'
>>> c = """what about coding?
... super fun!"""
>>>

另外两种常用的序列类型是列表和元组。列表是任意对象的序列。列表可以通过将对象括在方括号中创建。就像字符串一样,列表由从零开始的非零整数索引。通过引用索引号检索列表的值:

>>> vendors = ["Cisco", "Arista", "Juniper"]
>>> vendors[0]
'Cisco'
>>> vendors[1]
'Arista'
>>> vendors[2]
'Juniper'

元组类似于列表,通过括号括起的值创建。与列表一样,可以通过引用其索引号来检索元组中的值。与列表不同的是,创建后不能修改值:

>>> datacenters = ("SJC1", "LAX1", "SFO1")
>>> datacenters[0]
'SJC1'
>>> datacenters[1]
'LAX1'
>>> datacenters[2]
'SFO1' 

所有序列类型都有一些共同的操作,例如按索引返回元素以及切片:

>>> a
'networking is fun'
>>> a[1]
'e'
>>> vendors
['Cisco', 'Arista', 'Juniper']
>>> vendors[1]
'Arista'
>>> datacenters
('SJC1', 'LAX1', 'SFO1')
>>> datacenters[1]
'LAX1'
>>>
>>> a[0:2]
'ne'
>>> vendors[0:2]
['Cisco', 'Arista']
>>> datacenters[0:2]
('SJC1', 'LAX1')
>>>

记住索引从0开始。因此,1的索引实际上是序列中的第二个元素。

还有一些常见的函数可以应用于序列类型,例如检查元素数量和最小值和最大值:

>>> len(a)
17
>>> len(vendors)
3
>>> len(datacenters)
3
>>>
>>> b = [1, 2, 3, 4, 5]
>>> min(b)
1
>>> max(b)
5

毫不奇怪,还有各种方法仅适用于字符串。值得注意的是,这些方法不会修改基础字符串数据本身,并始终返回一个新的字符串。如果您想使用新值,您需要捕获返回值并将其分配给不同的变量:

>>> a
'networking is fun'
>>> a.capitalize()
'Networking is fun'
>>> a.upper()
'NETWORKING IS FUN'
>>> a
'networking is fun'
>>> b = a.upper()
>>> b
'NETWORKING IS FUN'
>>> a.split()
['networking', 'is', 'fun']
>>> a
'networking is fun'
>>> b = a.split()
>>> b
['networking', 'is', 'fun']
>>>

以下是列表的一些常用方法。在将多个项目放在一起并逐个迭代它们方面,列表是一种非常有用的结构。例如,我们可以制作一个数据中心脊柱交换机的列表,并通过逐个迭代它们来应用相同的访问列表。由于列表的值可以在创建后修改(与元组不同),因此我们还可以在程序中扩展和对比现有列表:

>>> routers = ['r1', 'r2', 'r3', 'r4', 'r5']
>>> routers.append('r6')
>>> routers
['r1', 'r2', 'r3', 'r4', 'r5', 'r6']
>>> routers.insert(2, 'r100')
>>> routers
['r1', 'r2', 'r100', 'r3', 'r4', 'r5', 'r6']
>>> routers.pop(1)
'r2'
>>> routers
['r1', 'r100', 'r3', 'r4', 'r5', 'r6']

映射

Python 提供了一种映射类型,称为字典。字典是我认为的穷人的数据库,因为它包含可以由键索引的对象。在其他语言中,这通常被称为关联数组或哈希表。如果您在其他语言中使用过类似字典的对象,您将知道这是一种强大的类型,因为您可以使用可读的键引用对象。对于试图维护和排除代码的可怜家伙来说,这个键将更有意义。几个月后,您编写代码并在凌晨 2 点排除故障时,这个家伙可能就是您。字典值中的对象也可以是另一种数据类型,比如列表。您可以用大括号创建一个字典:

>>> datacenter1 = {'spines': ['r1', 'r2', 'r3', 'r4']}
>>> datacenter1['leafs'] = ['l1', 'l2', 'l3', 'l4']
>>> datacenter1
{'leafs': ['l1', 'l2', 'l3', 'l4'], 'spines': ['r1',  
'r2', 'r3', 'r4']}
>>> datacenter1['spines']
['r1', 'r2', 'r3', 'r4']
>>> datacenter1['leafs']
['l1', 'l2', 'l3', 'l4']

集合

集合用于包含无序的对象集合。与列表和元组不同,集合是无序的,不能通过数字索引。但是有一个特点使集合成为有用的:集合的元素永远不会重复。想象一下,您有一个需要放入访问列表中的 IP 列表。这个 IP 列表中唯一的问题是它们充满了重复项。现在,想象一下,您需要使用多少行代码来循环遍历 IP 列表,逐个筛选出唯一的项。然而,内置的集合类型只需要一行代码就可以消除重复的条目。老实说,我并不经常使用集合,但是当我需要它时,我总是非常感激它的存在。一旦创建了集合,它们可以使用并集、交集和差集进行比较:

>>> a = "hello"
>>> set(a)
{'h', 'l', 'o', 'e'}
>>> b = set([1, 1, 2, 2, 3, 3, 4, 4])
>>> b
{1, 2, 3, 4}
>>> b.add(5)
>>> b
{1, 2, 3, 4, 5}
>>> b.update(['a', 'a', 'b', 'b'])
>>> b
{1, 2, 3, 4, 5, 'b', 'a'}
>>> a = set([1, 2, 3, 4, 5])
>>> b = set([4, 5, 6, 7, 8])
>>> a.intersection(b)
{4, 5}
>>> a.union(b)
{1, 2, 3, 4, 5, 6, 7, 8}
>>> 1 *
{1, 2, 3}
>>>

Python 运算符

Python 有一些数字运算符,你会期望的;请注意截断除法(//,也称为地板除法)将结果截断为整数和浮点数,并返回整数值。取模(%)运算符返回除法中的余数值:

>>> 1 + 2
3
>>> 2 - 1
1
>>> 1 * 5
5
>>> 5 / 1
5.0
>>> 5 // 2
2
>>> 5 % 2
1

还有比较运算符。请注意,双等号用于比较,单等号用于变量赋值:

>>> a = 1
>>> b = 2
>>> a == b
False
>>> a > b
False
>>> a < b
True
>>> a <= b
True 

我们还可以使用两个常见的成员运算符来查看对象是否在序列类型中:

>>> a = 'hello world'
>>> 'h' in a
True
>>> 'z' in a
False
>>> 'h' not in a
False
>>> 'z' not in a
True

Python 控制流工具

ifelseelif语句控制条件代码的执行。正如你所期望的,条件语句的格式如下:

if expression:
  do something
elif expression:
  do something if the expression meets
elif expression:
  do something if the expression meets
...
else:
  statement

这是一个简单的例子:

>>> a = 10
>>> if a > 1:
...   print("a is larger than 1")
... elif a < 1:
...   print("a is smaller than 1")
... else:
...   print("a is equal to 1")
...
a is larger than 1
>>>

while循环将继续执行,直到条件为false,所以如果你不想继续执行(并且崩溃你的进程),请小心:

while expression:
  do something
>>> a = 10
>>> b = 1
>>> while b < a:
...   print(b)
...   b += 1
...
1
2
3
4
5
6
7
8
9

for循环适用于任何支持迭代的对象;这意味着所有内置的序列类型,如列表、元组和字符串,都可以在for循环中使用。下面for循环中的字母i是一个迭代变量,所以你通常可以在代码的上下文中选择一个有意义的东西:

for i in sequence:
  do something
>>> a = [100, 200, 300, 400]
>>> for number in a:
...   print(number)
...
100
200
300
400

你还可以创建自己的对象,支持迭代器协议,并能够为这个对象使用for循环。

构建这样一个对象超出了本章的范围,但这是有用的知识;你可以在docs.python.org/3/c-api/iter.html上阅读更多关于它的内容。

Python 函数

大多数情况下,当你发现自己在复制和粘贴一些代码片段时,你应该将它们分解成一个自包含的函数块。这种做法可以实现更好的模块化,更容易维护,并允许代码重用。Python 函数是使用def关键字定义的,后面跟着函数名和函数参数。函数的主体由要执行的 Python 语句组成。在函数的末尾,你可以选择向函数调用者返回一个值,或者默认情况下,如果你没有指定返回值,它将返回None对象:

def name(parameter1, parameter2):
  statements
  return value

在接下来的章节中,我们将看到更多的函数示例,所以这里是一个快速的例子:

>>> def subtract(a, b):
...   c = a - b
...   return c
...
>>> result = subtract(10, 5)
>>> result
5
>>>

Python 类

Python 是一种面向对象编程OOP)语言。Python 创建对象的方式是使用class关键字。Python 对象通常是函数(方法)、变量和属性(属性)的集合。一旦类被定义,你就可以创建这样一个类的实例。类作为后续实例的蓝图。

OOP 的主题超出了本章的范围,所以这里是一个router对象定义的简单例子:

>>> class router(object):
...   def __init__(self, name, interface_number,
vendor):
...     self.name = name
...     self.interface_number = interface_number
...     self.vendor = vendor
...
>>>

一旦定义,你就可以创建任意数量的该类的实例:

>>> r1 = router("SFO1-R1", 64, "Cisco")
>>> r1.name
'SFO1-R1'
>>> r1.interface_number
64
>>> r1.vendor
'Cisco'
>>>
>>> r2 = router("LAX-R2", 32, "Juniper")
>>> r2.name
'LAX-R2'
>>> r2.interface_number
32
>>> r2.vendor
'Juniper'
>>>

当然,Python 对象和 OOP 还有很多内容。我们将在以后的章节中看到更多的例子。

Python 模块和包

任何 Python 源文件都可以用作模块,你在该源文件中定义的任何函数和类都可以被重用。要加载代码,引用模块的文件需要使用import关键字。当文件被导入时会发生三件事:

  1. 文件为源文件中定义的对象创建了一个新的命名空间

  2. 调用者执行模块中包含的所有代码

  3. 文件在调用者内创建一个指向被导入模块的名称。名称与模块的名称匹配

还记得你在交互式 shell 中定义的subtract()函数吗?为了重用这个函数,我们可以把它放到一个名为subtract.py的文件中:

def subtract(a, b):
  c = a - b
  return c

subtract.py的同一目录中的文件中,你可以启动 Python 解释器并导入这个函数:

Python 2.7.12 (default, Nov 19 2016, 06:48:10)
[GCC 5.4.0 20160609] on linux2
Type "help", "copyright", "credits" or "license" for  
more information.
>>> import subtract
>>> result = subtract.subtract(10, 5)
>>> result
5

这是因为默认情况下,Python 首先会在当前目录中搜索可用的模块。如果你在不同的目录中,你可以使用sys模块和sys.path手动添加搜索路径位置。还记得我们之前提到的标准库吗?你猜对了,那些只是作为模块使用的 Python 文件。

包允许将一组模块组合在一起。这进一步将 Python 模块组织成更具命名空间保护的可重用性。包是通过创建一个希望用作命名空间的名称的目录来定义的,然后可以将模块源文件放在该目录下。为了让 Python 将其识别为 Python 包,只需在该目录中创建一个__init__.py文件。在与subtract.py文件相同的示例中,如果你创建一个名为math_stuff的目录并创建一个__init__.py文件:

echou@pythonicNeteng:~/Master_Python_Networking/
Chapter1$ mkdir math_stuff
echou@pythonicNeteng:~/Master_Python_Networking/
Chapter1$ touch math_stuff/__init__.py
echou@pythonicNeteng:~/Master_Python_Networking/
Chapter1$ tree .
.
├── helloworld.py
└── math_stuff
 ├── __init__.py
 └── subtract.py

1 directory, 3 files
echou@pythonicNeteng:~/Master_Python_Networking/
Chapter1$

现在你将引用模块时需要包括包名:

>>> from math_stuff.subtract import subtract
>>> result = subtract(10, 5)
>>> result
5
>>>

正如你所看到的,模块和包是组织大型代码文件并使共享 Python 代码变得更加容易的好方法。

总结

在本章中,我们介绍了 OSI 模型并回顾了网络协议套件,如 TCP、UDP 和 IP。它们作为处理任意两个主机之间的寻址和通信协商的层。这些协议在设计时考虑了可扩展性,并且基本上没有从其原始设计中改变。考虑到互联网的爆炸性增长,这是相当了不起的成就。

我们还快速回顾了 Python 语言,包括内置类型、运算符、控制流、函数、类、模块和包。Python 是一种功能强大、适合生产的语言,同时也很容易阅读。这使得 Python 成为网络自动化的理想选择。网络工程师可以利用 Python 从简单的脚本开始,逐渐转向其他高级特性。

在第二章中,低级网络设备交互,我们将开始学习如何使用 Python 与网络设备进行编程交互。

第二章:低级网络设备交互

在第一章中,TCP/IP 协议套件和 Python 概述,我们研究了网络通信协议背后的理论和规范。我们还快速浏览了 Python 语言。在本章中,我们将开始深入探讨使用 Python 管理网络设备的不同方式,特别是我们如何使用 Python 与传统网络路由器和交换机进行程序通信。

我所说的传统网络路由器和交换机是什么意思?虽然很难想象今天出现的任何网络设备都没有用于程序通信的应用程序接口API),但众所周知,许多在以前部署的网络设备中并不包含 API 接口。这些设备的管理方式是通过使用终端程序的命令行接口CLI),最初是为了人类工程师而开发的。管理依赖于工程师对设备返回的数据的解释以采取适当的行动。随着网络设备数量和网络复杂性的增加,手动逐个管理它们变得越来越困难。

Python 有两个很棒的库可以帮助完成这些任务,Pexpect 和 Paramiko,以及从它们衍生出的其他库。本章将首先介绍 Pexpect,然后以 Paramiko 的示例进行讲解。一旦你了解了 Paramiko 的基础知识,就很容易扩展到 Netmiko 等更多的库。值得一提的是,Ansible(在第四章中介绍,Python 自动化框架- Ansible 基础,以及第五章中介绍,Python 自动化框架-进阶)在其网络模块中大量依赖 Paramiko。在本章中,我们将讨论以下主题:

  • 命令行界面的挑战

  • 构建虚拟实验室

  • Python Pexpect 库

  • Python Paramiko 库

  • Pexpect 和 Paramiko 的缺点

让我们开始吧!

命令行界面的挑战

在 2014 年的 Interop 博览会上,BigSwitch Networks的首席执行官道格拉斯·默里展示了以下幻灯片,以说明在 1993 年至 2013 年间数据中心网络DCN)发生了什么变化:

数据中心网络变化(来源:www.bigswitch.com/sites/default/files/presentations/murraydouglasstartuphotseatpanel.pdf

他的观点很明显:在这 20 年里,我们管理网络设备的方式几乎没有什么变化。虽然他在展示这张幻灯片时可能对现任供应商持有负面偏见,但他的观点是很有道理的。在他看来,20 年来管理路由器和交换机唯一改变的是协议从不太安全的 Telnet 变为更安全的 SSH。

正是在 2014 年左右,我们开始看到行业达成共识,即远离手动、人为驱动的 CLI,转向自动化、以计算机为中心的 API。毫无疑问,当我们进行网络设计、启动初步概念验证和首次部署拓扑时,我们仍需要直接与设备进行通信。然而,一旦我们超越了初始部署,要求是要始终可靠地进行相同的更改,使其无误,并且一遍又一遍地重复,而不让工程师分心或感到疲倦。这个要求听起来就像是计算机和我们最喜爱的语言 Python 的理想工作。

回到幻灯片,主要挑战在于路由器和管理员之间的交互。路由器将输出一系列信息,并期望管理员根据工程师对输出的解释输入一系列手动命令。例如,你必须输入enable才能进入特权模式,并在收到带有#符号的返回提示后,你再输入configure terminal以进入配置模式。同样的过程可以进一步扩展到接口配置模式和路由协议配置模式。这与计算机驱动的程序思维形成鲜明对比。当计算机想要完成单个任务时,比如在接口上放置 IP 地址,它希望一次性将所有信息结构化地提供给路由器,并且期望路由器给出一个yesno的答复来指示任务的成功或失败。

Pexpect 和 Paramiko 都实现了这个解决方案,即将交互过程视为子进程,并监视进程与目标设备之间的交互。根据返回的值,父进程将决定随后的操作(如果有的话)。

构建虚拟实验室

在我们深入研究这些软件包之前,让我们来看看组建一个实验室以便学习的选项。正如古语所说,“熟能生巧”:我们需要一个隔离的沙盒来安全地犯错误,尝试新的做事方式,并重复一些步骤以加强在第一次尝试时不清楚的概念。安装 Python 和管理主机所需的软件包很容易,但那些我们想要模拟的路由器和交换机呢?

要组建一个网络实验室,我们基本上有两个选项,每个选项都有其优势和劣势:

  • 物理设备:这个选项包括可以看到和触摸的物理设备。如果你足够幸运,你可能能够组建一个与你的生产环境完全相同的实验室。

  • 优势:从实验室到生产的过渡更容易,易于经理和同事理解并触摸设备。简而言之,由于熟悉度高,对物理设备的舒适度非常高。

  • 劣势:为实验室使用而支付设备相对昂贵。设备需要工程师花费时间来安装,并且一旦构建完成后就不太灵活。

  • 虚拟设备:这些是实际网络设备的仿真或模拟。它们可以是供应商提供的,也可以是开源社区提供的。

  • 优势:虚拟设备更容易设置,成本相对较低,并且可以快速更改拓扑结构。

  • 劣势:它们通常是其物理对应物的缩减版本。有时虚拟设备和物理设备之间存在功能差距。

当然,决定使用虚拟实验室还是物理实验室是一个个人决定,需要在成本、实施的便利性和实验室与生产之间的差距之间进行权衡。在我工作过的一些环境中,当进行初步概念验证时使用虚拟实验室,而在接近最终设计时使用物理实验室。

在我看来,随着越来越多的供应商决定生产虚拟设备,虚拟实验室是学习环境中的正确选择。虚拟设备的功能差距相对较小,并且有专门的文档,特别是当虚拟实例由供应商提供时。与购买物理设备相比,虚拟设备的成本相对较低。使用虚拟设备构建的时间更快,因为它们通常只是软件程序。

对于这本书,我将使用物理和虚拟设备的组合来进行概念演示,更偏向于虚拟设备。对于我们将看到的示例,差异应该是透明的。如果虚拟和物理设备在我们的目标方面有任何已知的差异,我会确保列出它们。

在虚拟实验室方面,除了来自各种供应商的镜像,我还在使用一款来自 Cisco 的程序Virtual Internet Routing LabVIRLlearningnetworkstore.cisco.com/virtual-internet-routing-lab-virl/cisco-personal-edition-pe-20-nodes-virl-20

我想指出,读者完全可以选择是否使用这个程序。但强烈建议读者有一些实验室设备来跟随本书中的示例。

思科 VIRL

我记得当我第一次开始准备我的思科认证网络专家CCIE)实验考试时,我从 eBay 购买了一些二手思科设备来学习。即使打折,每台路由器和交换机也要花费数百美元,所以为了省钱,我购买了一些上世纪 80 年代的非常过时的思科路由器(在您喜欢的搜索引擎中搜索思科 AGS 路由器,会让您大笑一番),它们明显缺乏功能和性能,即使是对实验室标准来说。尽管当我打开它们时(它们的声音很大),它们给家人带来了有趣的对话,但组装物理设备并不好玩。它们又重又笨重,连接所有的电缆很麻烦,为了引入链路故障,我会直接拔掉一根电缆。

快进几年。Dynamip被创建,我爱上了它创建不同网络场景的简易性。当我尝试学习一个新概念时,这尤其重要。您只需要来自 Cisco 的 IOS 镜像,一些精心构建的拓扑文件,就可以轻松构建一个虚拟网络,以便测试您的知识。我有一个完整的网络拓扑文件夹,预先保存的配置和不同版本的镜像,根据场景的需要。GNS3 前端的添加使整个设置具有美丽的 GUI 外观。使用 GNS3,您可以轻松地点击和拖放您的链接和设备;您甚至可以直接从 GNS3 设计面板打印出网络拓扑图给您的经理。唯一缺少的是该工具没有得到供应商的官方认可,因此缺乏可信度。

2015 年,Cisco 社区决定通过发布 Cisco VIRL 来满足这一需求。如果您有一台符合要求的服务器,并且愿意支付所需的年度许可证费用,这是我首选的开发和尝试大部分 Python 代码的方法,无论是为这本书还是我的自己的生产使用。

截至 2017 年 1 月 1 日,只有 20 节点个人版许可证可供购买,价格为每年 199.99 美元。

在我看来,即使需要花费一些金钱,VIRL 平台相对于其他替代方案有一些优势:

  • 易用性:所有 IOSv、IOS-XRv、CSR100v、NX-OSv 和 ASAv 的镜像都包含在一个单独的下载中。

  • 官方有点):尽管支持是由社区驱动的,但它在 Cisco 内部被广泛使用。由于其受欢迎程度,错误得到快速修复,新功能得到仔细记录,并且有用的知识在其用户之间广泛分享。

  • 云迁移路径:当您的仿真超出您拥有的硬件能力时,比如 Cisco dCloud(dcloud.cisco.com/)、Packet 上的 VIRL(virl.cisco.com/cloud/)和 Cisco DevNet(developer.cisco.com/)时,该项目提供了一个逻辑的迁移路径。这是一个有时被忽视的重要特性。

  • 链接和控制平面模拟:该工具可以模拟真实世界链路特性的每个链路的延迟、抖动和数据包丢失。还有一个用于外部路由注入的控制平面流量生成器。

  • 其他:该工具提供了一些不错的功能,比如 VM Maestro 拓扑设计和模拟控制,AutoNetkit 用于自动生成配置,以及用户工作空间管理(如果服务器是共享的)。还有一些开源项目,比如 virlutils(github.com/CiscoDevNet/virlutils),由社区积极开发,以增强该工具的可用性。

在本书中,我们不会使用 VIRL 中的所有功能。但由于这是一个相对较新的工具,值得您考虑,如果您决定使用这个工具,我想提供一些我使用过的设置。

再次强调拥有一个实验室的重要性,但不一定需要是思科 VIRL 实验室。本书中提供的代码示例应该适用于任何实验室设备,只要它们运行相同的软件类型和版本。

VIRL 提示

VIRL 网站(virl.cisco.com/)提供了大量的指导、准备和文档。我还发现 VIRL 用户社区通常能够提供快速准确的帮助。我不会重复这两个地方已经提供的信息;然而,这里是我在本书中用于实验室的一些设置:

  1. VIRL 使用两个虚拟以太网接口进行连接。第一个接口设置为主机机器的互联网连接的 NAT,第二个用于本地管理接口的连接(在下面的示例中为 VMnet2)。我使用一个具有类似网络设置的单独的虚拟机来运行我的 Python 代码,第一个主要以太网用于互联网连接,第二个以太网连接到 Vmnet2,用于实验室设备管理网络:

  1. VMnet2 是一个自定义网络,用于将 Ubuntu 主机与 VIRL 虚拟机连接:

  1. 在拓扑设计选项中,我将管理网络选项设置为共享平面网络,以便在虚拟路由器上使用 VMnet2 作为管理网络:

  1. 在节点配置下,您可以选择静态配置管理 IP 的选项。我尝试静态设置管理 IP 地址,而不是让软件动态分配它们。这可以更确定地访问:

思科 DevNet 和 dCloud

思科提供了另外两种非常好的、并且在撰写本文时是免费的方法,用于使用各种思科设备进行网络自动化实践。这两种工具都需要思科连接在线(CCO)登录。它们都非常好,尤其是价格方面(它们是免费的!)。我很难想象这些在线工具会长时间保持免费;我相信,这些工具在某个时候将需要收费或者被纳入需要收费的更大计划中。然而,在它们免费提供的时候我们可以利用它们。

第一个工具是思科 DevNet (developer.cisco.com/) 实验室,其中包括引导式学习轨迹、完整文档和远程实验室等多种好处。一些实验室是一直开放的,而另一些需要预订。实验室的可用性将取决于使用情况。如果你没有自己的实验室,这是一个很好的选择。在我使用 DevNet 的经验中,一些文档和链接已经过时,但可以很容易地获取到最新版本。在软件开发这样一个快速变化的领域,这是可以预料的。无论你是否有一个本地运行的 VIRL 主机,DevNet 都是一个你应该充分利用的工具:

思科的另一个在线实验室选择是dcloud.cisco.com/。你可以把 dCloud 看作是在其他人的服务器上运行 VIRL,而不必管理或支付这些资源。看起来思科把 dCloud 既当作一个独立的产品,又当作 VIRL 的扩展。例如,在你无法在本地运行超过几个 IOX-XR 或 NX-OS 实例的情况下,你可以使用 dCloud 来扩展你的本地实验室。这是一个相对较新的工具,但绝对值得一试:

GNS3

这本书和其他用途我使用了一些其他虚拟实验室。其中一个是GNS3工具:

正如本章前面提到的,GNS3 是我们很多人用来准备认证考试和实验练习的工具。这个工具从最初的 Dynamips 的简单前端发展成了一个可行的商业产品。思科制造的工具,比如 VIRL、DevNet 和 dCloud,只包含思科技术。尽管它们提供了虚拟实验室设备与外部世界通信的方式,但并不像直接在模拟环境中拥有多供应商虚拟化设备那样简单。GNS3 是供应商中立的,可以在实验室中直接包含多供应商虚拟化平台。这通常是通过克隆镜像(比如 Arista vEOS)或者通过其他虚拟化程序直接启动网络设备镜像(比如 Juniper Olive 仿真)来实现的。有人可能会认为 GNS3 没有思科 VIRL 项目那样的广度和深度,但由于它可以运行不同版本的思科技术,所以在需要将其他供应商技术纳入实验室时,我经常使用它。

另一个得到很多好评的多供应商网络仿真环境是Emulated Virtual Environment Next Generation (EVE-NG)www.eve-ng.net/。我个人对这个工具没有太多经验,但我在这个行业的许多同事和朋友都在他们的网络实验室中使用它。

还有其他虚拟化平台,比如 Arista vEOS (eos.arista.com/tag/veos/)、Juniper vMX (www.juniper.net/us/en/products-services/routing/mx-series/vmx/)和 vSRX (www.juniper.net/us/en/products-services/security/srx-series/vsrx/),你可以在测试中作为独立的虚拟设备使用。它们是测试特定平台功能的绝佳补充工具,比如平台上 API 版本的差异。它们通常作为付费产品在公共云提供商市场上提供,以便更容易地访问。它们通常提供与它们的物理对应产品相同的功能。

Python Pexpect 库

Pexpect 是一个纯 Python 模块,用于生成子应用程序、控制它们并响应其输出中的预期模式。Pexpect 的工作原理类似于 Don Libes 的 Expect。Pexpect 允许您的脚本生成一个子应用程序并控制它,就像一个人在键入命令一样。Pexpect,Read the Docs: pexpect.readthedocs.io/en/stable/index.html

让我们来看看 Python Pexpect 库。与 Don Libe 的原始 Tcl Expect 模块类似,Pexpect 启动或生成另一个进程并监视它以控制交互。Expect 工具最初是为了自动化诸如 FTP、Telnet 和 rlogin 之类的交互式进程而开发的,后来扩展到包括网络自动化。与原始 Expect 不同,Pexpect 完全由 Python 编写,不需要编译 TCL 或 C 扩展。这使我们能够在我们的代码中使用熟悉的 Python 语法和其丰富的标准库。

Pexpect 安装

由于这是我们要安装的第一个软件包,我们将同时安装pip工具和pexpect软件包。该过程非常简单:

sudo apt-get install python-pip #Python2
sudo apt-get install python3-pip
sudo pip3 install pexpect
sudo pip install pexpect #Python2

我正在使用pip3来安装 Python 3 包,同时使用pip在 Python 2 环境中安装包。

快速测试一下确保软件包可用:

>>> import pexpect
>>> dir(pexpect)
['EOF', 'ExceptionPexpect', 'Expecter', 'PY3', 
 'TIMEOUT', '__all__', '__builtins__', '__cached__', 
 '__doc__', '__file__', '__loader__', '__name__', 
 '__package__', '__path__', '__revision__', 
 '__spec__', '__version__', 'exceptions', 'expect', 
 'is_executable_file', 'pty_spawn', 'run', 'runu', 
 'searcher_re', 'searcher_string', 'spawn', 
 'spawnbase', 'spawnu', 'split_command_line', 'sys',
 'utils', 'which']
>>> 

Pexpect 概述

对于我们的第一个实验,我们将构建一个简单的网络,其中有两个相连的 IOSv 设备:

实验拓扑

每个设备都将在192.16.0.x/24范围内拥有一个环回地址,管理 IP 将在172.16.1.x/24范围内。VIRL 拓扑文件包含在适应书籍可下载文件中。您可以将拓扑导入到您自己的 VIRL 软件中。如果您没有 VIRL,您也可以通过使用文本编辑器打开拓扑文件来查看必要的信息。该文件只是一个 XML 文件,每个节点的信息都在node元素下:

实验节点信息

设备准备就绪后,让我们看看如果您要 Telnet 到设备中,您将如何与路由器进行交互:

echou@ubuntu:~$ telnet 172.16.1.20
Trying 172.16.1.20...
Connected to 172.16.1.20.
Escape character is '^]'.
<skip>
User Access Verification

Username: cisco
Password:

我使用 VIRL AutoNetkit 自动生成路由器的初始配置,生成了默认用户名cisco和密码cisco。请注意,由于配置中分配的特权,用户已经处于特权模式下:

iosv-1#sh run | i cisco
enable password cisco
username cisco privilege 15 secret 5 $1$Wiwq$7xt2oE0P9ThdxFS02trFw.
 password cisco
 password cisco
iosv-1#

自动配置还为 Telnet 和 SSH 生成了vty访问:

line vty 0 4
 exec-timeout 720 0
 password cisco
 login local
 transport input telnet ssh

让我们看一个使用 Python 交互式 shell 的 Pexpect 示例:

Python 3.5.2 (default, Nov 17 2016, 17:05:23)
[GCC 5.4.0 20160609] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import pexpect
>>> child = pexpect.spawn('telnet 172.16.1.20')
>>> child.expect('Username')
0
>>> child.sendline('cisco')
6
>>> child.expect('Password')
0
>>> child.sendline('cisco')
6
>>> child.expect('iosv-1#')
0
>>> child.sendline('show version | i V')
19
>>> child.expect('iosv-1#')
0
>>> child.before
b'show version | i VrnCisco IOS Software, IOSv Software (VIOS-ADVENTERPRISEK9-M), Version 15.6(2)T, RELEASE SOFTWARE (fc2)rnProcessor board ID 9MM4BI7B0DSWK40KV1IIRrn'
>>> child.sendline('exit')
5
>>> exit()

从 Pexpect 版本 4.0 开始,您可以在 Windows 平台上运行 Pexpect。但是,正如 Pexpect 文档中所指出的,目前应该将在 Windows 上运行 Pexpect 视为实验性的。

在上一个交互式示例中,Pexpect 生成了一个子进程并以交互方式监视它。示例中显示了两个重要的方法,expect()sendline()expect()行指示 Pexpect 进程寻找的字符串,作为返回的字符串被视为完成的指示器。这是预期的模式。在我们的示例中,当返回主机名提示(iosv-1#)时,我们知道路由器已经向我们发送了所有信息。sendline()方法指示应将哪些单词作为命令发送到远程设备。还有一个名为send()的方法,但sendline()包括一个换行符,类似于在上一个 telnet 会话中按下Enter键。从路由器的角度来看,这就像有人从终端键入文本一样。换句话说,我们正在欺骗路由器,让它们认为它们正在与人类进行交互,而实际上它们正在与计算机进行通信。

beforeafter属性将设置为子应用程序打印的文本。before属性将设置为子应用程序打印的文本,直到预期的模式。after字符串将包含由预期模式匹配的文本。在我们的情况下,before文本将设置为两个预期匹配(iosv-1#)之间的输出,包括show version命令。after文本是路由器主机名提示符:

>>> child.sendline('show version | i V')
19
>>> child.expect('iosv-1#')
0
>>> child.before
b'show version | i VrnCisco IOS Software, IOSv Software (VIOS-ADVENTERPRISEK9-M), Version 15.6(2)T, RELEASE SOFTWARE (fc2)rnProcessor board ID 9MM4BI7B0DSWK40KV1IIRrn'
>>> child.after
b'iosv-1#'

如果您期望错误的术语会发生什么?例如,如果在生成子应用程序后输入username而不是Username,那么 Pexpect 进程将从子进程中寻找一个username字符串。在这种情况下,Pexpect 进程将会挂起,因为路由器永远不会返回username这个词。会话最终会超时,或者您可以通过Ctrl + C手动退出。

expect()方法等待子应用程序返回给定的字符串,因此在前面的示例中,如果您想要适应小写和大写的u,您可以使用以下术语:

>>> child.expect('[Uu]sername')

方括号作为or操作,告诉子应用程序期望小写或大写的u后跟字符串sername。我们告诉进程的是我们将接受Usernameusername作为预期字符串。

有关 Python 正则表达式的更多信息,请访问docs.python.org/3.5/library/re.html

expect()方法也可以包含一个选项列表,而不仅仅是一个单独的字符串;这些选项本身也可以是正则表达式。回到前面的示例,您可以使用以下选项列表来适应两种不同的可能字符串:

>>> child.expect(['Username', 'username'])

一般来说,当您可以在正则表达式中适应不同的主机名时,使用单个expect字符串的正则表达式,而如果您需要捕获路由器完全不同的响应,例如密码拒绝,那么使用可能的选项。例如,如果您对登录使用了几个不同的密码,您希望捕获%Login invalid以及设备提示。

Pexpect 正则表达式和 Python 正则表达式之间的一个重要区别是,Pexpect 匹配是非贪婪的,这意味着在使用特殊字符时,它们将尽可能少地匹配。因为 Pexpect 在流上执行正则表达式,所以您不能向前查看,因为生成流的子进程可能尚未完成。这意味着特殊的美元符号字符$通常匹配行尾是无用的,因为.+总是不会返回任何字符,而.*模式将尽可能少地匹配。一般来说,记住这一点,并尽可能具体地匹配expect字符串。

让我们考虑以下情景:

>>> child.sendline('show run | i hostname')
22
>>> child.expect('iosv-1')
0
>>> child.before
b'show run | i hostnamernhostname '
>>>

嗯...这里有点不对劲。与之前的终端输出进行比较;您期望的输出应该是hostname iosv-1

iosv-1#show run | i hostname
hostname iosv-1
iosv-1#

仔细查看预期的字符串将会揭示错误。在这种情况下,我们忘记了在iosv-1主机名后面加上#号。因此,子应用程序将返回字符串的第二部分视为预期字符串:

>>> child.sendline('show run | i hostname')
22
>>> child.expect('iosv-1#')
0
>>> child.before
b'show run | i hostnamernhostname iosv-1rn'
>>>

通过几个示例后,您可以看到 Pexpect 的使用模式。用户可以规划 Pexpect 进程和子应用程序之间的交互序列。通过一些 Python 变量和循环,我们可以开始构建一个有用的程序,帮助我们收集信息并对网络设备进行更改。

我们的第一个 Pexpect 程序

我们的第一个程序chapter2_1.py扩展了上一节的内容,并添加了一些额外的代码:

     #!/usr/bin/python3

     import pexpect

     devices = {'iosv-1': {'prompt': 'iosv-1#', 'ip': '172.16.1.20'}, 'iosv-2': {'prompt': 'iosv-2#', 'ip': '172.16.1.21'}}
     username = 'cisco'
     password = 'cisco'

     for device in devices.keys():
         device_prompt = devices[device]['prompt']
         child = pexpect.spawn('telnet ' + devices[device]['ip'])
         child.expect('Username:')
         child.sendline(username)
         child.expect('Password:')
         child.sendline(password)
         child.expect(device_prompt)
         child.sendline('show version | i V')
         child.expect(device_prompt)
         print(child.before)
         child.sendline('exit')

我们在第 5 行使用了嵌套字典:

       devices = {'iosv-1': {'prompt': 'iosv-1#', 'ip': 
      '172.16.1.20'}, 'iosv-2': {'prompt': 'iosv-2#', 
      'ip': '172.16.1.21'}}

嵌套字典允许我们使用适当的 IP 地址和提示符号引用相同的设备(例如iosv-1)。然后我们可以在循环后面使用这些值进行expect()方法。

输出在屏幕上打印出每个设备的show version | i V输出:

 $ python3 chapter2_1.py
 b'show version | i VrnCisco IOS Software, IOSv
 Software (VIOS-ADVENTERPRISEK9-M), Version 15.6(2)T, 
 RELEASE SOFTWARE (fc2)rnProcessor board ID 
 9MM4BI7B0DSWK40KV1IIRrn'
 b'show version | i VrnCisco IOS Software, IOSv 
 Software (VIOS-ADVENTERPRISEK9-M), Version 15.6(2)T,
 RELEASE SOFTWARE (fc2)rn'

更多 Pexpect 功能

在本节中,我们将看看更多 Pexpect 功能,这些功能在某些情况下可能会派上用场。

如果您的远程设备链接速度慢或快,默认的expect()方法超时时间为 30 秒,可以通过timeout参数增加或减少:

>>> child.expect('Username', timeout=5)

您可以选择使用interact()方法将命令传递回用户。当您只想自动化初始任务的某些部分时,这是很有用的:

>>> child.sendline('show version | i V')
19
>>> child.expect('iosv-1#')
0
>>> child.before
b'show version | i VrnCisco IOS Software, IOSv Software (VIOS-ADVENTERPRISEK9-M), Version 15.6(2)T, RELEASE SOFTWARE (fc2)rnProcessor board ID 9MM4BI7B0DSWK40KV1IIRrn'
>>> child.interact()
iosv-1#show run | i hostname
hostname iosv-1
iosv-1#exit
Connection closed by foreign host.
>>>

通过以字符串格式打印child.spawn对象,您可以获得有关该对象的大量信息:

>>> str(child)
"<pexpect.pty_spawn.spawn object at 0x7fb01e29dba8>ncommand: /usr/bin/telnetnargs: ['/usr/bin/telnet', '172.16.1.20']nsearcher: Nonenbuffer (last 100 chars): b''nbefore (last 100 chars): b'NTERPRISEK9-M), Version 15.6(2)T, RELEASE SOFTWARE (fc2)rnProcessor board ID 9MM4BI7B0DSWK40KV1IIRrn'nafter: b'iosv-1#'nmatch: <_sre.SRE_Match object; span=(164, 171), match=b'iosv-1#'>nmatch_index: 0nexitstatus: 1nflag_eof: Falsenpid: 2807nchild_fd: 5nclosed: Falsentimeout: 30ndelimiter: <class 'pexpect.exceptions.EOF'>nlogfile: Nonenlogfile_read: Nonenlogfile_send: Nonenmaxread: 2000nignorecase: Falsensearchwindowsize: Nonendelaybeforesend: 0.05ndelayafterclose: 0.1ndelayafterterminate: 0.1"
>>>

Pexpect 最有用的调试工具是将输出记录在文件中:

>>> child = pexpect.spawn('telnet 172.16.1.20')
>>> child.logfile = open('debug', 'wb')

使用child.logfile = open('debug', 'w')来代替 Python 2。Python 3 默认使用字节字符串。有关 Pexpect 功能的更多信息,请查看pexpect.readthedocs.io/en/stable/api/index.html

Pexpect 和 SSH

如果您尝试使用先前的 Telnet 示例并将其插入 SSH 会话,您可能会对体验感到非常沮丧。您始终必须在会话中包含用户名,回答ssh新密钥问题,以及更多琐碎的任务。有许多方法可以使 SSH 会话工作,但幸运的是,Pexpect 有一个名为pxssh的子类,专门用于建立 SSH 连接。该类添加了登录、注销和处理ssh登录过程中不同情况的各种棘手事务的方法。这些过程大多数情况下是相同的,除了login()logout()

>>> from pexpect import pxssh
>>> child = pxssh.pxssh()
>>> child.login('172.16.1.20', 'cisco', 'cisco', auto_prompt_reset=False)
True
>>> child.sendline('show version | i V')
19
>>> child.expect('iosv-1#')
0
>>> child.before
b'show version | i VrnCisco IOS Software, IOSv Software (VIOS-ADVENTERPRISEK9-M), Version 15.6(2)T, RELEASE SOFTWARE (fc2)rnProcessor board ID 9MM4BI7B0DSWK40KV1IIRrn'
>>> child.logout()
>>>

注意login()方法中的auto_prompt_reset=False参数。默认情况下,pxssh使用 Shell 提示来同步输出。但由于它在大多数 bash 或 CSH 中使用 PS1 选项,它们将在 Cisco 或其他网络设备上出错。

为 Pexpect 整合各种功能

作为最后一步,让我们将到目前为止学到的关于 Pexpect 的一切放入脚本中。将代码放入脚本中可以更容易地在生产环境中使用,并且更容易与同事共享。我们将编写第二个脚本chapter2_2.py

您可以从书的 GitHub 存储库github.com/PacktPublishing/Mastering-Python-Networking-second-edition下载脚本,以及查看由脚本生成的输出作为命令的结果。参考以下代码:

  #!/usr/bin/python3

  import getpass
  from pexpect import pxssh

  devices = {'iosv-1': {'prompt': 'iosv-1#', 'ip': '172.16.1.20'},
  'iosv-2': {'prompt': 'iosv-2#', 'ip': '172.16.1.21'}}
  commands = ['term length 0', 'show version', 'show run']

  username = input('Username: ')
  password = getpass.getpass('Password: ')

  # Starts the loop for devices
  for device in devices.keys():
      outputFileName = device + '_output.txt'
      device_prompt = devices[device]['prompt']
      child = pxssh.pxssh()
      child.login(devices[device]['ip'], username.strip(), password.strip(), auto_promp t_reset=False)
      # Starts the loop for commands and write to output
      with open(outputFileName, 'wb') as f:
          for command in commands:
              child.sendline(command)
              child.expect(device_prompt)
              f.write(child.before)

      child.logout()

该脚本从我们的第一个 Pexpect 程序进一步扩展,具有以下附加功能:

  • 它使用 SSH 而不是 Telnet

  • 通过将命令转换为列表(第 8 行)并循环执行命令(从第 20 行开始),它支持多个命令而不仅仅是一个命令

  • 它提示用户输入用户名和密码,而不是在脚本中硬编码它们

  • 它将输出写入两个文件,iosv-1_output.txtios-2_output.txt,以便进一步分析

对于 Python 2,使用raw_input()而不是input()来进行用户名提示。此外,使用w而不是wb作为文件模式。

Python Paramiko 库

Paramiko 是 SSHv2 协议的 Python 实现。就像 Pexpect 的pxssh子类一样,Paramiko 简化了主机和远程设备之间的 SSHv2 交互。与pxssh不同,Paramiko 仅专注于 SSHv2,不支持 Telnet。它还提供客户端和服务器操作。

Paramiko 是高级自动化框架 Ansible 用于其网络模块的低级 SSH 客户端。我们将在后面的章节中介绍 Ansible。让我们来看看 Paramiko 库。

Paramiko 的安装

使用 Python pip安装 Paramiko 非常简单。但是,它对 cryptography 库有严格的依赖。该库为 SSH 协议提供了基于 C 的低级加密算法。

Windows、Mac 和其他 Linux 版本的安装说明可以在cryptography.io/en/latest/installation/找到。

我们将在接下来的输出中展示 Ubuntu 16.04 虚拟机上 Paramiko 的安装。以下输出显示了安装步骤,以及 Paramiko 成功导入 Python 交互提示符。

如果您使用的是 Python 2,请按照以下步骤。我们将尝试在交互提示符中导入库,以确保库可以使用:

sudo apt-get install build-essential libssl-dev libffi-dev python-dev
sudo pip install cryptography
sudo pip install paramiko
$ python
Python 2.7.12 (default, Nov 19 2016, 06:48:10)
[GCC 5.4.0 20160609] on linux2
Type "help", "copyright", "credits" or "license" for more information.
>>> import paramiko
>>> exit()

如果您使用的是 Python 3,请参考以下命令行安装依赖项。安装后,我们将导入库以确保它已正确安装:

sudo apt-get install build-essential libssl-dev libffi-dev python3-dev
sudo pip3 install cryptography
sudo pip3 install paramiko
$ python3
Python 3.5.2 (default, Nov 17 2016, 17:05:23)
[GCC 5.4.0 20160609] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import paramiko
>>>

Paramiko 概述

让我们来看一个使用 Python 3 交互式 shell 的快速 Paramiko 示例:

>>> import paramiko, time
>>> connection = paramiko.SSHClient()
>>> connection.set_missing_host_key_policy(paramiko.AutoAddPolicy())
>>> connection.connect('172.16.1.20', username='cisco', password='cisco', look_for_keys=False, allow_agent=False)
>>> new_connection = connection.invoke_shell()
>>> output = new_connection.recv(5000)
>>> print(output)
b"rn**************************************************************************rn* IOSv is strictly limited to use for evaluation, demonstration and IOS *rn* education. IOSv is provided as-is and is not supported by Cisco's *rn* Technical Advisory Center. Any use or disclosure, in whole or in part, *rn* of the IOSv Software or Documentation to any third party for any *rn* purposes is expressly prohibited except as otherwise authorized by *rn* Cisco in writing. *rn**************************************************************************rniosv-1#"
>>> new_connection.send("show version | i Vn")
19
>>> time.sleep(3)
>>> output = new_connection.recv(5000)
>>> print(output)
b'show version | i VrnCisco IOS Software, IOSv Software (VIOS-ADVENTERPRISEK9-M), Version 15.6(2)T, RELEASE SOFTWARE (fc2)rnProcessor board ID 9MM4BI7B0DSWK40KV1IIRrniosv-1#'
>>> new_connection.close()
>>>

time.sleep()函数插入时间延迟,以确保所有输出都被捕获。这在网络连接较慢或设备繁忙时特别有用。这个命令不是必需的,但根据您的情况,建议使用。

即使您是第一次看到 Paramiko 操作,Python 及其清晰的语法意味着您可以对程序尝试做什么有一个相当好的猜测:

>>> import paramiko
>>> connection = paramiko.SSHClient()
>>> connection.set_missing_host_key_policy(paramiko.AutoAddPolicy())
>>> connection.connect('172.16.1.20', username='cisco', password='cisco', look_for_keys=False, allow_agent=False)

前四行创建了 Paramiko 的SSHClient类的实例。下一行设置了客户端在 SSH 服务器的主机名(在本例中为iosv-1)不在系统主机密钥或应用程序密钥中时应使用的策略。在我们的情况下,我们将自动将密钥添加到应用程序的HostKeys对象中。此时,如果您登录路由器,您将看到 Paramiko 的额外登录会话:

iosv-1#who
 Line User Host(s) Idle Location
*578 vty 0 cisco idle 00:00:00 172.16.1.1
 579 vty 1 cisco idle 00:01:30 172.16.1.173
Interface User Mode Idle Peer Address
iosv-1#

接下来的几行调用连接的新交互式 shell,并重复发送命令和检索输出的模式。最后,我们关闭连接。

一些之前使用过 Paramiko 的读者可能对exec_command()方法比调用 shell 更熟悉。为什么我们需要调用交互式 shell 而不是直接使用exec_command()呢?不幸的是,在 Cisco IOS 上,exec_command()只允许一个命令。考虑以下使用exec_command()进行连接的示例:

>>> connection.connect('172.16.1.20', username='cisco', password='cisco', look_for_keys=False, allow_agent=False)
>>> stdin, stdout, stderr = connection.exec_command('show version | i V')
>>> stdout.read()
b'Cisco IOS Software, IOSv Software (VIOS-ADVENTERPRISEK9-M), Version 15.6(2)T, RELEASE SOFTWARE (fc2)rnProcessor board ID 9MM4BI7B0DSWK40KV1IIRrn'
>>> 

一切都很顺利;但是,如果您查看 Cisco 设备上的会话数量,您会注意到连接被 Cisco 设备中断,而您并没有关闭连接:

iosv-1#who
 Line User Host(s) Idle Location
*578 vty 0 cisco idle 00:00:00 172.16.1.1
Interface User Mode Idle Peer Address
iosv-1#

因为 SSH 会话不再活动,如果您想向远程设备发送更多命令,exec_command()将返回错误:

>>> stdin, stdout, stderr = connection.exec_command('show version | i V')
Traceback (most recent call last):
 File "<stdin>", line 1, in <module>
 File "/usr/local/lib/python3.5/dist-packages/paramiko/client.py", line 435, in exec_command
 chan = self._transport.open_session(timeout=timeout)
 File "/usr/local/lib/python3.5/dist-packages/paramiko/transport.py", line 711, in open_session
 timeout=timeout)
 File "/usr/local/lib/python3.5/dist-packages/paramiko/transport.py", line 795, in open_channel
 raise SSHException('SSH session not active')
paramiko.ssh_exception.SSHException: SSH session not active
>>>

Kirk Byers 的 Netmiko 库是一个开源的 Python 库,简化了对网络设备的 SSH 管理。要了解更多信息,请查看这篇文章pynet.twb-tech.com/blog/automation/netmiko.html,以及源代码github.com/ktbyers/netmiko

如果您不清除接收到的缓冲区,会发生什么?输出将继续填充缓冲区并覆盖它:

>>> new_connection.send("show version | i Vn")
19
>>> new_connection.send("show version | i Vn")
19
>>> new_connection.send("show version | i Vn")
19
>>> new_connection.recv(5000)
b'show version | i VrnCisco IOS Software, IOSv Software (VIOS-ADVENTERPRISEK9-M), Version 15.6(2)T, RELEASE SOFTWARE (fc2)rnProcessor board ID 9MM4BI7B0DSWK40KV1IIRrniosv-1#show version | i VrnCisco IOS Software, IOSv Software (VIOS-ADVENTERPRISEK9-M), Version 15.6(2)T, RELEASE SOFTWARE (fc2)rnProcessor board ID 9MM4BI7B0DSWK40KV1IIRrniosv-1#show version | i VrnCisco IOS Software, IOSv Software (VIOS-ADVENTERPRISEK9-M), Version 15.6(2)T, RELEASE SOFTWARE (fc2)rnProcessor board ID 9MM4BI7B0DSWK40KV1IIRrniosv-1#'
>>>

为了保持确定性输出的一致性,我们将在每次执行命令时从缓冲区中检索输出。

我们的第一个 Paramiko 程序

我们的第一个程序将使用与我们组合的 Pexpect 程序相同的一般结构。我们将循环遍历设备和命令列表,同时使用 Paramiko 而不是 Pexpect。这将使我们很好地比较 Paramiko 和 Pexpect 之间的差异。

如果您还没有这样做,您可以从书的 GitHub 存储库github.com/PacktPublishing/Mastering-Python-Networking-second-edition下载代码chapter2_3.py。我将在这里列出显著的区别:

devices = {'iosv-1': {'ip': '172.16.1.20'}, 'iosv-2': {'ip': '172.16.1.21'}}

我们不再需要使用 Paramiko 来匹配设备提示;因此,设备字典可以简化:

commands = ['show version', 'show run']

Paramiko 中没有 sendline 的等价物;相反,我们需要在每个命令中手动包含换行符:

def clear_buffer(connection):
    if connection.recv_ready():
        return connection.recv(max_buffer)

我们包含了一个新的方法来清除发送命令的缓冲区,比如terminal length 0enable,因为我们不需要这些命令的输出。我们只是想清除缓冲区并进入执行提示符。这个函数稍后将在循环中使用,比如脚本的第 25 行:

output = clear_buffer(new_connection)

程序的其余部分应该是相当容易理解的,类似于我们在本章中看到的内容。我想指出的最后一件事是,由于这是一个交互式程序,我们在远程设备上放置了一些缓冲区,并等待命令在远程设备上完成后再检索输出。

time.sleep(2)

在清除缓冲区之后,在执行命令之间,我们将等待两秒。这将给设备足够的时间来响应,如果它很忙的话。

更多 Paramiko 功能

我们将在本书的后面部分再次看到 Paramiko,当我们讨论 Ansible 时,Paramiko 是许多网络模块的基础传输。在本节中,我们将看一下 Paramiko 的一些其他功能。

Paramiko 用于服务器

Paramiko 也可以用于通过 SSHv2 管理服务器。让我们看一个使用 Paramiko 管理服务器的例子。我们将使用基于密钥的身份验证进行 SSHv2 会话。

在这个例子中,我使用了与目标服务器相同的虚拟机上的另一个 Ubuntu 虚拟机。您也可以使用 VIRL 模拟器上的服务器或者公共云提供商之一的实例,比如亚马逊 AWS EC2。

我们将为 Paramiko 主机生成一个公私钥对:

ssh-keygen -t rsa

这个命令默认会生成一个名为id_rsa.pub的公钥,作为用户主目录~/.ssh下的公钥,以及一个名为id_rsa的私钥。对待私钥的注意力应与您不想与任何其他人分享的私人密码一样。您可以将公钥视为标识您身份的名片。使用私钥和公钥,消息将在本地由您的私钥加密,然后由远程主机使用公钥解密。我们应该将公钥复制到远程主机。在生产环境中,我们可以通过使用 USB 驱动器进行离线复制;在我们的实验室中,我们可以简单地将公钥复制到远程主机的~/.ssh/authorized_keys文件中。打开远程服务器的终端窗口,这样您就可以粘贴公钥。

使用 Paramiko 将~/.ssh/id_rsa的内容复制到您的管理主机上:

<Management Host with Pramiko>$ cat ~/.ssh/id_rsa.pub
ssh-rsa <your public key> echou@pythonicNeteng

然后,将其粘贴到远程主机的user目录下;在这种情况下,我在双方都使用echou

<Remote Host>$ vim ~/.ssh/authorized_keys
ssh-rsa <your public key> echou@pythonicNeteng

您现在可以使用 Paramiko 来管理远程主机。请注意,在这个例子中,我们将使用私钥进行身份验证,以及exec_command()方法来发送命令:

Python 3.5.2 (default, Nov 17 2016, 17:05:23)
[GCC 5.4.0 20160609] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import paramiko
>>> key = paramiko.RSAKey.from_private_key_file('/home/echou/.ssh/id_rsa')
>>> client = paramiko.SSHClient()
>>> client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
>>> client.connect('192.168.199.182', username='echou', pkey=key)
>>> stdin, stdout, stderr = client.exec_command('ls -l')
>>> stdout.read()
b'total 44ndrwxr-xr-x 2 echou echou 4096 Jan 7 10:14 Desktopndrwxr-xr-x 2 echou echou 4096 Jan 7 10:14 Documentsndrwxr-xr-x 2 echou echou 4096 Jan 7 10:14 Downloadsn-rw-r--r-- 1 echou echou 8980 Jan 7 10:03 examples.desktopndrwxr-xr-x 2 echou echou 4096 Jan 7 10:14 Musicndrwxr-xr-x 2 echou echou 4096 Jan 7 10:14 Picturesndrwxr-xr-x 2 echou echou 4096 Jan 7 10:14 Publicndrwxr-xr-x 2 echou echou 4096 Jan 7 10:14 Templatesndrwxr-xr-x 2 echou echou 4096 Jan 7 10:14 Videosn'
>>> stdin, stdout, stderr = client.exec_command('pwd')
>>> stdout.read()
b'/home/echoun'
>>> client.close()
>>>

请注意,在服务器示例中,我们不需要创建交互式会话来执行多个命令。您现在可以关闭远程主机的 SSHv2 配置中基于密码的身份验证,以实现更安全的基于密钥的身份验证,并启用自动化。一些网络设备,如 Cumulus 和 Vyatta 交换机,也支持基于密钥的身份验证。

为 Paramiko 整合各种功能

我们几乎已经到了本章的结尾。在这最后一节中,让我们使 Paramiko 程序更具重用性。我们现有脚本的一个缺点是:每次我们想要添加或删除主机,或者每当我们需要更改要在远程主机上执行的命令时,我们都需要打开脚本。这是因为主机和命令信息都是静态输入到脚本中的。硬编码主机和命令更容易出错。此外,如果你要把脚本传给同事,他们可能不太愿意在 Python、Paramiko 或 Linux 中工作。

通过将主机和命令文件都作为脚本的参数读入,我们可以消除一些这些顾虑。用户(包括未来的你)可以在需要更改主机或命令时简单地修改这些文本文件。

我们已经在名为chapter2_4.py的脚本中引入了更改。

我们没有将命令硬编码,而是将命令分成一个单独的commands.txt文件。到目前为止,我们一直在使用 show 命令;在这个例子中,我们将进行配置更改。特别是,我们将日志缓冲区大小更改为30000字节:

$ cat commands.txt
config t
logging buffered 30000
end
copy run start

设备的信息被写入devices.json文件。我们选择 JSON 格式来存储设备信息,因为 JSON 数据类型可以很容易地转换为 Python 字典数据类型:

$ cat devices.json
{
 "iosv-1": {"ip": "172.16.1.20"},
 "iosv-2": {"ip": "172.16.1.21"}
}

在脚本中,我们做出了以下更改:

  with open('devices.json', 'r') as f:
      devices = json.load(f)

  with open('commands.txt', 'r') as f:
      commands = [line for line in f.readlines()]

以下是脚本执行的简化输出:

$ python3 chapter2_4.py
Username: cisco
Password:
b'terminal length 0rniosv-2#config trnEnter configuration commands, one per line. End with CNTL/Z.rniosv-2(config)#'
b'logging buffered 30000rniosv-2(config)#'
...

快速检查确保更改已经在running-configstartup-config中生效:

iosv-1#sh run | i logging
logging buffered 30000
iosv-1#sh start | i logging
logging buffered 30000
iosv-2#sh run | i logging
logging buffered 30000
iosv-2#sh start | i logging
logging buffered 30000

展望未来

就自动化网络使用 Python 而言,本章我们已经取得了相当大的进步。然而,我们使用的方法感觉有点像自动化的变通方法。我们试图欺骗远程设备,让它们认为它们在与另一端的人类进行交互。

与其他工具相比,Pexpect 和 Paramiko 的缺点

到目前为止,我们的方法最大的缺点是远程设备没有返回结构化数据。它们返回的数据适合在终端上显示并由人类解释,而不是由计算机程序。人眼可以轻松解释空格,而计算机只能看到回车符。

我们将在接下来的章节中看到更好的方法。作为第三章 API 和意图驱动的网络的序曲,让我们讨论幂等性的概念。

网络设备交互的幂等性

幂等性一词在不同的语境中有不同的含义。但在本书的语境中,该术语意味着当客户端对远程设备进行相同的调用时,结果应始终相同。我相信我们都同意这一点。想象一种情况,每次执行相同的脚本时,你都会得到不同的结果。我觉得那种情况非常可怕。如果是这种情况,你怎么能相信你的脚本呢?这将使我们的自动化工作变得毫无意义,因为我们需要准备处理不同的返回结果。

由于 Pexpect 和 Paramiko 正在交互式地发送一系列命令,非幂等交互的机会更高。回到需要从返回结果中筛选有用元素的事实,差异的风险更高。在我们编写脚本和脚本执行 100 次之间,远程端的某些内容可能已经发生了变化。例如,如果供应商在发布之间更改了屏幕输出,而我们没有更新脚本,脚本可能会出错。

如果我们需要依赖脚本进行生产,我们需要尽可能使脚本具有幂等性。

糟糕的自动化会加速糟糕的事情发生

糟糕的自动化会让你更快地刺瞎自己的眼睛,就是这么简单。计算机在执行任务时比我们人类工程师快得多。如果我们用相同的操作程序由人类和脚本执行,脚本会比人类更快地完成,有时甚至没有在程序之间建立良好的反馈循环的好处。互联网上充满了当有人按下Enter键后立即后悔的恐怖故事。

我们需要确保糟糕的自动化脚本搞砸事情的机会尽可能小。我们都会犯错;在进行任何生产工作之前仔细测试您的脚本和小的影响范围是确保您能在错误发生之前捕捉到它的关键。

总结

在本章中,我们介绍了与网络设备直接通信的低级方式。如果没有一种以编程方式与网络设备通信并对其进行更改的方法,就不可能实现自动化。我们看了 Python 中的两个库,它们允许我们管理本来应该由 CLI 管理的设备。虽然有用,但很容易看出这个过程可能有些脆弱。这主要是因为所涉及的网络设备本来是为人类而不是计算机管理而设计的。

在第三章中,API 和意图驱动的网络,我们将看看支持 API 和意图驱动网络的网络设备。

第三章:API 和意图驱动的网络

在第二章中,低级网络设备交互,我们看了一下使用 Pexpect 和 Paramiko 与网络设备进行交互的方法。这两个工具都使用了一个模拟用户在终端前输入命令的持久会话。这在一定程度上是有效的。很容易发送命令以在设备上执行并捕获输出。然而,当输出超过几行字符时,计算机程序很难解释输出。Pexpect 和 Paramiko 返回的输出是一系列字符,是为人类阅读而设计的。输出的结构包括了人类友好的行和空格,但对计算机程序来说很难理解。

为了让我们的计算机程序自动化执行我们想要执行的许多任务,我们需要解释返回的结果,并根据返回的结果采取后续行动。当我们无法准确和可预测地解释返回的结果时,我们无法有信心执行下一个命令。

幸运的是,这个问题已经被互联网社区解决了。想象一下当计算机和人类都在阅读网页时的区别。人类看到的是浏览器解释的单词、图片和空格;计算机看到的是原始的 HTML 代码、Unicode 字符和二进制文件。当一个网站需要成为另一个计算机的网络服务时会发生什么?同样的网络资源需要同时适应人类客户和其他计算机程序。这个问题听起来是不是很熟悉?答案就是应用程序接口API)。需要注意的是,根据维基百科的说法,API 是一个概念,而不是特定的技术或框架。

在计算机编程中,应用程序编程接口API)是一组子程序定义、协议和用于构建应用软件的工具。一般来说,它是各种软件组件之间清晰定义的通信方法集。一个好的 API 通过提供所有构建块,使得开发计算机程序更容易,然后由程序员组合在一起。

在我们的用例中,清晰定义的通信方法集将在我们的 Python 程序和目标设备之间。我们的网络设备 API 提供了一个独立的接口供计算机程序使用。确切的 API 实现是特定于供应商的。一个供应商可能更喜欢 XML 而不是 JSON,有些可能提供 HTTPS 作为底层传输协议,而其他供应商可能提供 Python 库作为包装器。尽管存在差异,API 的概念仍然是相同的:它是一种为其他计算机程序优化的独立通信方法。

在本章中,我们将讨论以下主题:

  • 将基础设施视为代码、意图驱动的网络和数据建模

  • 思科 NX-API 和面向应用的基础设施

  • Juniper NETCONF 和 PyEZ

  • Arista eAPI 和 PyEAPI

基础设施即代码

在一个完美的世界里,设计和管理网络的网络工程师和架构师应该关注网络应该实现的目标,而不是设备级别的交互。在我作为当地 ISP 的实习生的第一份工作中,我兴奋地安装了一个路由器在客户现场,打开了他们的分段帧中继链路(还记得那些吗?)。我应该怎么做?我问道。我拿到了一个打开帧中继链路的标准操作流程。我去了客户现场,盲目地输入命令,看着绿灯闪烁,然后高兴地收拾行李,为自己的工作感到自豪。尽管第一份工作很令人兴奋,但我并没有完全理解我在做什么。我只是在按照指示行事,没有考虑我输入的命令的影响。如果灯是红色而不是绿色,我该如何排除故障?我想我会打电话回办公室求助(泪水可选)。

当然,网络工程不是关于在设备上输入命令,而是建立一种允许服务尽可能顺畅地从一点传递到另一点的方式。我们必须使用的命令和我们必须解释的输出只是达到目的的手段。换句话说,我们应该专注于网络的意图。我们想要网络实现的目标比我们用来让设备做我们想让它做的命令语法更重要。如果我们进一步提取描述我们意图的代码行的想法,我们可以潜在地将我们整个基础设施描述为特定状态。基础设施将在代码行中描述,并有必要的软件或框架强制执行该状态。

基于意图驱动的网络

自从这本书第一版出版以来,“基于意图的网络”这个术语在主要网络供应商选择将其用于描述其下一代设备后得到了更多的使用。在我看来,“基于意图驱动的网络”是定义网络应该处于的状态,并有软件代码来强制执行该状态的想法。举个例子,如果我的目标是阻止端口 80 被外部访问,那么我应该将这个作为网络意图声明。底层软件将负责知道配置和应用必要的访问控制列表的语法在边界路由器上实现这个目标。当然,“基于意图驱动的网络”是一个没有明确实现的想法。但这个想法很简单明了,我在此要主张我们应该更多地关注网络的意图,并摆脱设备级别的交互。

在使用 API 时,我认为这让我们更接近基于意图驱动的网络的状态。简而言之,因为我们抽象了在目标设备上执行的特定命令的层,我们关注的是我们的意图,而不是具体的命令。例如,回到我们的“阻止端口 80”的访问控制列表的例子,我们可能在思科上使用访问控制列表和访问组,而在 Juniper 上使用过滤列表。然而,在使用 API 时,我们的程序可以开始询问执行者的意图,同时掩盖他们正在与何种物理设备交流。我们甚至可以使用更高级的声明性框架,比如 Ansible,我们将在第四章中介绍,即《Python 自动化框架- Ansible 基础》。但现在,让我们专注于网络 API。

屏幕抓取与 API 结构化输出

想象一个常见的情景,我们需要登录到网络设备,并确保设备上的所有接口都处于 up/up 状态(状态和协议都显示为up)。对于人类网络工程师来说,登录到 Cisco NX-OS 设备,通过终端发出show IP interface brief命令就足够简单,可以轻松地从输出中看出哪个接口是 up 的:

 nx-osv-2# show ip int brief
    IP Interface Status for VRF "default"(1)
    Interface IP Address Interface Status
    Lo0 192.168.0.2 protocol-up/link-up/admin-up
    Eth2/1 10.0.0.6 protocol-up/link-up/admin-up
    nx-osv-2#

换行符、空格和列标题的第一行很容易从人眼中区分出来。事实上,它们是为了帮助我们对齐,比如说,从第一行到第二行和第三行的每个接口的 IP 地址。如果我们把自己放在计算机的位置上,所有这些空格和换行只会让我们远离真正重要的输出,那就是:哪些接口处于 up/up 状态?为了说明这一点,我们可以看一下相同操作的 Paramiko 输出:

 >>> new_connection.send('sh ip int briefn')
    16
    >>> output = new_connection.recv(5000)
    >>> print(output)
    b'sh ip int briefrrnIP Interface Status for VRF 
    "default"(1)rnInterface IP Address Interface 
    StatusrnLo0 192.168.0.2 protocol-up/link-up/admin-up 
    rnEth2/1 10.0.0.6 protocol-up/link-up/admin-up rnrnx-
    osv-2# '
    >>>

如果我们要解析出这些数据,我会以伪代码的方式进行如下操作(简化了我将要编写的代码的表示方式):

  1. 通过换行符分割每一行。

  2. 我可能不需要包含show ip interface brief执行命令的第一行。目前,我认为我不需要它。

  3. 删除第二行直到 VRF 的所有内容,并将其保存在一个变量中,因为我们想知道输出显示的是哪个 VRF。

  4. 对于其余的行,因为我们不知道有多少个接口,我们将使用正则表达式语句来搜索行是否以可能的接口开头,比如lo表示环回接口,Eth表示以太网接口。

  5. 我们需要通过空格将这行分成三个部分,每个部分包括接口名称、IP 地址,然后是接口状态。

  6. 然后进一步使用斜杠(/)分割接口状态,以获取协议、链路和管理状态。

哇,这需要大量的工作,而人类一眼就能看出来!你可能能够优化代码和行数,但总的来说,当我们需要屏幕抓取一些结构不太清晰的东西时,这就是我们需要做的。这种方法有许多缺点,但我能看到的一些更大的问题列在下面:

  • 可扩展性:我们花了很多时间来仔细解析每个命令的输出。很难想象我们如何能够对我们通常运行的数百个命令进行这样的操作。

  • 可预测性:实际上并没有保证输出在不同软件版本之间保持不变。如果输出稍有变化,可能会使我们辛苦收集的信息变得毫无用处。

  • 供应商和软件锁定:也许最大的问题是,一旦我们花费了所有这些时间来解析特定供应商和软件版本(在本例中为 Cisco NX-OS)的输出,我们需要重复这个过程来选择下一个供应商。我不知道你怎么看,但如果我要评估一个新的供应商,如果我不得不重新编写所有的屏幕抓取代码,那么新的供应商就处于严重的入门劣势。

让我们将其与相同show IP interface brief命令的 NX-API 调用输出进行比较。我们将在本章后面详细介绍如何从设备中获取此输出,但这里重要的是将以下输出与先前的屏幕抓取输出进行比较:

    {
     "ins_api":{
     "outputs":{
     "output":{
     "body":{
     "TABLE_intf":[
       {
       "ROW_intf":{
       "admin-state":"up",
       "intf-name":"Lo0",
       "iod":84,
       "ip-disabled":"FALSE",
       "link-state":"up",
       "prefix":"192.168.0.2",
       "proto-state":"up"
       }
       },
     {
     "ROW_intf":{
     "admin-state":"up",
     "intf-name":"Eth2/1",
     "iod":36,
     "ip-disabled":"FALSE",
     "link-state":"up",
     "prefix":"10.0.0.6",
     "proto-state":"up"
     }
     }
     ],
      "TABLE_vrf":[
      {
     "ROW_vrf":{
     "vrf-name-out":"default"
     }
     },
     {
     "ROW_vrf":{
     "vrf-name-out":"default"
     }
     }
     ]
     },
     "code":"200",
     "input":"show ip int brief",
     "msg":"Success"
     }
     },
     "sid":"eoc",
     "type":"cli_show",
     "version":"1.2"
     }
    }

NX-API 可以返回 XML 或 JSON 格式的输出,这是我们正在查看的 JSON 输出。您可以立即看到输出是结构化的,并且可以直接映射到 Python 字典数据结构。无需解析-您只需选择键并检索与键关联的值。您还可以从输出中看到各种元数据,例如命令的成功或失败。如果命令失败,将显示一条消息,告诉发送者失败的原因。您不再需要跟踪已发出的命令,因为它已在“输入”字段中返回给您。输出中还有其他有用的元数据,例如 NX-API 版本。

这种类型的交换使供应商和运营商的生活更加轻松。对于供应商来说,他们可以轻松地传输配置和状态信息。当需要公开额外数据时,他们可以使用相同的数据结构添加额外字段。对于运营商来说,他们可以轻松地摄取信息并围绕它构建基础设施。一般认为自动化是非常需要的,也是一件好事。问题通常集中在自动化的格式和结构上。正如您将在本章后面看到的,API 的伞下有许多竞争技术。仅在传输方面,我们有 REST API、NETCONF 和 RESTCONF 等。最终,整体市场可能会决定未来的最终数据格式。与此同时,我们每个人都可以形成自己的观点,并帮助推动行业向前发展。

基础设施的数据建模

根据维基百科(en.wikipedia.org/wiki/Data_model)的定义,数据模型的定义如下:

数据模型是一个抽象模型,它组织数据元素并规范它们之间以及与现实世界实体属性的关系。例如,数据模型可以指定代表汽车的数据元素由许多其他元素组成,这些元素反过来代表汽车的颜色和大小,并定义其所有者。

数据建模过程可以用以下图表来说明:

数据建模过程

当应用于网络时,我们可以将这个概念应用为描述我们的网络的抽象模型,无论是数据中心、校园还是全球广域网。如果我们仔细观察物理数据中心,可以将层 2 以太网交换机视为包含 MAC 地址映射到每个端口的设备。我们的交换机数据模型描述了 MAC 地址应该如何保存在表中,其中包括键、附加特性(考虑 VLAN 和私有 VLAN)等。同样,我们可以超越设备,将整个数据中心映射到一个模型中。我们可以从每个接入、分发和核心层中的设备数量开始,它们是如何连接的,以及它们在生产环境中应该如何行为。例如,如果我们有一个 fat-tree 网络,每个脊柱路由器应该有多少链接,它们应该包含多少路由,每个前缀应该有多少下一跳?这些特性可以以一种格式映射出来,可以与我们应该始终检查的理想状态进行对比。

另一种下一代YANG)是一种相对新的网络数据建模语言,正在受到关注(尽管一般的看法是,一些 IETF 工作组确实有幽默感)。它首次在 2010 年的 RFC 6020 中发布,并且自那时以来在供应商和运营商中得到了广泛的应用。在撰写本文时,对 YANG 的支持在供应商和平台之间差异很大。因此,生产中的适应率相对较低。但是,这是一项值得关注的技术。

思科 API 和 ACI

思科系统是网络领域的 800 磅大猩猩,在网络自动化的趋势中没有落后。在推动网络自动化的过程中,他们进行了各种内部开发、产品增强、合作伙伴关系,以及许多外部收购。然而,由于产品线涵盖路由器、交换机、防火墙、服务器(统一计算)、无线、协作软件和硬件以及分析软件等,要知道从哪里开始是很困难的。

由于这本书侧重于 Python 和网络,我们将把这一部分范围限定在主要的网络产品上。特别是,我们将涵盖以下内容:

  • NX-API 的 Nexus 产品自动化

  • 思科 NETCONF 和 YANG 示例

  • 数据中心的思科应用中心基础设施

  • 企业级思科应用中心基础设施

对于这里的 NX-API 和 NETCONF 示例,我们可以使用思科 DevNet 始终开启的实验室设备,或者在本地运行思科 VIRL。由于 ACI 是一个独立的产品,并且在以下 ACI 示例中与物理交换机一起许可使用,我建议使用 DevNet 实验室来了解这些工具。如果你是那些有自己的私人 ACI 实验室可以使用的幸运工程师之一,请随意在相关示例中使用它。

我们将使用与第二章中相同的实验拓扑,低级网络设备交互,只有一个设备运行 nx-osv 除外:

 实验室拓扑

让我们来看看 NX-API。

思科 NX-API

Nexus 是思科的主要数据中心交换机产品线。NX-API (www.cisco.com/c/en/us/td/docs/switches/datacenter/nexus9000/sw/6-x/programmability/guide/b_Cisco_Nexus_9000_Series_NX-OS_Programmability_Guide/b_Cisco_Nexus_9000_Series_NX-OS_Programmability_Guide_chapter_011.html)允许工程师通过各种传输方式与交换机进行交互,包括 SSH、HTTP 和 HTTPS。

实验室软件安装和设备准备

以下是我们将安装的 Ubuntu 软件包。你可能已经安装了一些软件包,比如pipgit

$ sudo apt-get install -y python3-dev libxml2-dev libxslt1-dev libffi-dev libssl-dev zlib1g-dev python3-pip git python3-requests

如果你使用的是 Python 2,使用以下软件包代替:sudo apt-get install -y python-dev libxml2-dev libxslt1-dev libffi-dev libssl-dev zlib1g-dev python-pip git python-requests

ncclient (github.com/ncclient/ncclient)库是一个用于 NETCONF 客户端的 Python 库。我们将从 GitHub 存储库中安装它,以便安装最新版本:

$ git clone https://github.com/ncclient/ncclient
$ cd ncclient/
$ sudo python3 setup.py install
$ sudo python setup.py install #for Python 2

Nexus 设备上的 NX-API 默认关闭,因此我们需要打开它。我们可以使用已经创建的用户(如果你使用的是 VIRL 自动配置),或者为 NETCONF 过程创建一个新用户:

feature nxapi
username cisco password 5 $1$Nk7ZkwH0$fyiRmMMfIheqE3BqvcL0C1 role network-operator
username cisco role network-admin
username cisco passphrase lifetime 99999 warntime 14 gracetime 3

对于我们的实验室,我们将同时打开 HTTP 和沙盒配置,因为它们在生产中应该关闭:

nx-osv-2(config)# nxapi http port 80
nx-osv-2(config)# nxapi sandbox

我们现在准备看我们的第一个 NX-API 示例。

NX-API 示例

NX-API 沙盒是一个很好的方式来玩各种命令、数据格式,甚至可以直接从网页上复制 Python 脚本。在最后一步,我们为了学习目的打开了它。在生产中应该关闭它。让我们打开一个网页浏览器,看看基于我们已经熟悉的 CLI 命令的各种消息格式、请求和响应。

在下面的例子中,我选择了JSON-RPCCLI命令类型来执行show version命令:

如果你对消息格式的支持性不确定,或者对你想在代码中检索的值的响应数据字段键有疑问,沙盒会派上用场。

在我们的第一个例子中,我们只是连接到 Nexus 设备,并在连接时打印出交换的能力:

    #!/usr/bin/env python3
    from ncclient import manager
    conn = manager.connect(
            host='172.16.1.90',
            port=22,
            username='cisco',
            password='cisco',
            hostkey_verify=False,
            device_params={'name': 'nexus'},
            look_for_keys=False)
    for value in conn.server_capabilities:
        print(value)
    conn.close_session()

主机、端口、用户名和密码的连接参数都很容易理解。设备参数指定了客户端连接的设备类型。当使用 ncclient 库时,在 Juniper NETCONF 部分会看到不同的响应。hostkey_verify绕过了 SSH 的known_host要求;如果不绕过,主机需要列在~/.ssh/known_hosts文件中。look_for_keys选项禁用了公钥私钥认证,而是使用用户名和密码进行认证。

如果你在 Python 3 和 Paramiko 中遇到问题,请随时使用 Python 2。希望在你阅读本节时,这个问题已经得到解决。

输出将显示这个版本的 NX-OS 支持的 XML 和 NETCONF 特性:

$ python cisco_nxapi_1.py
urn:ietf:params:netconf:capability:writable-running:1.0
urn:ietf:params:netconf:capability:rollback-on-error:1.0
urn:ietf:params:netconf:capability:validate:1.0
urn:ietf:params:netconf:capability:url:1.0?scheme=file
urn:ietf:params:netconf:base:1.0
urn:ietf:params:netconf:capability:candidate:1.0
urn:ietf:params:netconf:capability:confirmed-commit:1.0
urn:ietf:params:xml:ns:netconf:base:1.0

使用 ncclient 和通过 SSH 的 NETCONF 非常好,因为它让我们更接近本地实现和语法。我们将在本书的后面使用相同的库。对于 NX-API,处理 HTTPS 和 JSON-RPC 可能更容易。在 NX-API 开发者沙箱的早期截图中,如果你注意到,在请求框中,有一个标有 Python 的框。如果你点击它,你将能够获得一个基于请求库自动生成的 Python 脚本。

以下脚本使用了一个名为requests的外部 Python 库。requests是一个非常流行的、自称为人类的 HTTP 库,被亚马逊、谷歌、NSA 等公司使用。你可以在官方网站上找到更多关于它的信息。

对于show version的例子,以下 Python 脚本是自动生成的。我将输出粘贴在这里,没有进行任何修改:

    """
     NX-API-BOT 
    """
    import requests
    import json

    """
    Modify these please
    """
    url='http://YOURIP/ins'
    switchuser='USERID'
    switchpassword='PASSWORD'

    myheaders={'content-type':'application/json-rpc'}
    payload=[
      {
        "jsonrpc": "2.0",
        "method": "cli",
        "params": {
          "cmd": "show version",
          "version": 1.2
        },
        "id": 1
      }
    ]
    response = requests.post(url,data=json.dumps(payload), 
    headers=myheaders,auth=(switchuser,switchpassword)).json()

cisco_nxapi_2.py脚本中,你会看到我只修改了前面文件的 URL、用户名和密码。输出被解析为只包括软件版本。以下是输出:

$ python3 cisco_nxapi_2.py
7.2(0)D1(1) [build 7.2(0)ZD(0.120)]

使用这种方法的最好之处在于,相同的总体语法结构既适用于配置命令,也适用于显示命令。这在cisco_nxapi_3.py文件中有所体现。对于多行配置,你可以使用 ID 字段来指定操作的顺序。在cisco_nxapi_4.py中,列出了用于更改接口 Ethernet 2/12 的描述的有效负载:

      {
        "jsonrpc": "2.0",
        "method": "cli",
        "params": {
          "cmd": "interface ethernet 2/12",
          "version": 1.2
        },
        "id": 1
      },
      {
        "jsonrpc": "2.0",
        "method": "cli",
        "params": {
          "cmd": "description foo-bar",
          "version": 1.2
        },
        "id": 2
      },
      {
        "jsonrpc": "2.0",
        "method": "cli",
        "params": {
          "cmd": "end",
          "version": 1.2
        },
        "id": 3
      },
      {
        "jsonrpc": "2.0",
        "method": "cli",
        "params": {
          "cmd": "copy run start",
          "version": 1.2
        },
        "id": 4
      }
    ]

我们可以通过查看 Nexus 设备的运行配置来验证前面配置脚本的结果:

hostname nx-osv-1-new
...
interface Ethernet2/12
description foo-bar
shutdown
no switchport
mac-address 0000.0000.002f 

在接下来的部分,我们将看一些关于 Cisco NETCONF 和 YANG 模型的例子。

Cisco 和 YANG 模型

在本章的前面,我们探讨了使用数据建模语言 YANG 来表达网络的可能性。让我们通过例子再深入了解一下。

首先,我们应该知道 YANG 模型只定义了通过 NETCONF 协议发送的数据类型,而不规定数据应该是什么。其次,值得指出的是 NETCONF 存在作为一个独立的协议,正如我们在 NX-API 部分看到的那样。YANG 作为相对较新的技术,在各个供应商和产品线之间的支持性不够稳定。例如,如果我们对运行 IOS-XE 的 Cisco 1000v 运行相同的能力交换脚本,我们会看到这样的结果:

 urn:cisco:params:xml:ns:yang:cisco-virtual-service?module=cisco-
 virtual-service&revision=2015-04-09
 http://tail-f.com/ns/mibs/SNMP-NOTIFICATION-MIB/200210140000Z?
 module=SNMP-NOTIFICATION-MIB&revision=2002-10-14
 urn:ietf:params:xml:ns:yang:iana-crypt-hash?module=iana-crypt-
 hash&revision=2014-04-04&features=crypt-hash-sha-512,crypt-hash-
 sha-256,crypt-hash-md5
 urn:ietf:params:xml:ns:yang:smiv2:TUNNEL-MIB?module=TUNNEL-
 MIB&revision=2005-05-16
 urn:ietf:params:xml:ns:yang:smiv2:CISCO-IP-URPF-MIB?module=CISCO-
 IP-URPF-MIB&revision=2011-12-29
 urn:ietf:params:xml:ns:yang:smiv2:ENTITY-STATE-MIB?module=ENTITY-
 STATE-MIB&revision=2005-11-22
 urn:ietf:params:xml:ns:yang:smiv2:IANAifType-MIB?module=IANAifType-
 MIB&revision=2006-03-31
 <omitted>

将此与我们在 NX-OS 中看到的输出进行比较。显然,IOS-XE 对 YANG 模型功能的支持要比 NX-OS 多。在整个行业范围内,当支持时,网络数据建模显然是可以跨设备使用的,这对于网络自动化是有益的。然而,鉴于供应商和产品的支持不均衡,我认为它还不够成熟,不能完全用于生产网络。对于本书,我包含了一个名为cisco_yang_1.py的脚本,演示了如何使用 YANG 过滤器urn:ietf:params:xml:ns:yang:ietf-interfaces来解析 NETCONF XML 输出的起点。

您可以在 YANG GitHub 项目页面上检查最新的供应商支持(github.com/YangModels/yang/tree/master/vendor)。

Cisco ACI

Cisco Application Centric InfrastructureACI)旨在为所有网络组件提供集中化的方法。在数据中心环境中,这意味着集中控制器知道并管理着脊柱、叶子和机架顶部交换机,以及所有网络服务功能。这可以通过 GUI、CLI 或 API 来实现。有人可能会认为 ACI 是思科对更广泛的基于控制器的软件定义网络的回应。

对于 ACI 而言,有点令人困惑的是 ACI 和 APIC-EM 之间的区别。简而言之,ACI 专注于数据中心操作,而 APIC-EM 专注于企业模块。两者都提供了对网络组件的集中视图和控制,但每个都有自己的重点和工具集。例如,很少见到任何主要数据中心部署面向客户的无线基础设施,但无线网络是当今企业的重要组成部分。另一个例子是网络安全的不同方法。虽然安全在任何网络中都很重要,但在数据中心环境中,许多安全策略被推送到服务器的边缘节点以实现可伸缩性。在企业安全中,策略在网络设备和服务器之间有一定的共享。

与 NETCONF RPC 不同,ACI API 遵循 REST 模型,使用 HTTP 动词(GETPOSTDELETE)来指定所需的操作。

我们可以查看cisco_apic_em_1.py文件,这是 Cisco 示例代码lab2-1-get-network-device-list.py的修改版本(github.com/CiscoDevNet/apicem-1.3-LL-sample-codes/blob/master/basic-labs/lab2-1-get-network-device-list.py)。

在以下部分列出了没有注释和空格的缩写版本。

名为getTicket()的第一个函数在控制器上使用 HTTPS POST,路径为/api/v1/ticket,在标头中嵌入用户名和密码。此函数将返回仅在有限时间内有效的票证的解析响应:

  def getTicket():
      url = "https://" + controller + "/api/v1/ticket"
      payload = {"username":"usernae","password":"password"}
      header = {"content-type": "application/json"}
      response= requests.post(url,data=json.dumps(payload), headers=header, verify=False)
      r_json=response.json()
      ticket = r_json["response"]["serviceTicket"]
      return ticket

然后,第二个函数调用另一个名为/api/v1/network-devices的路径,并在标头中嵌入新获取的票证,然后解析结果:

url = "https://" + controller + "/api/v1/network-device"
header = {"content-type": "application/json", "X-Auth-Token":ticket}

这是 API 交互的一个常见工作流程。客户端将在第一个请求中使用服务器进行身份验证,并接收一个基于时间的令牌。此令牌将在后续请求中使用,并将作为身份验证的证明。

输出显示了原始 JSON 响应输出以及解析后的表格。执行针对 DevNet 实验室控制器的部分输出如下所示:

    Network Devices =
    {
     "version": "1.0",
     "response": [
     {
     "reachabilityStatus": "Unreachable",
     "id": "8dbd8068-1091-4cde-8cf5-d1b58dc5c9c7",
     "platformId": "WS-C2960C-8PC-L",
    <omitted>
     "lineCardId": null,
     "family": "Wireless Controller",
     "interfaceCount": "12",
     "upTime": "497 days, 2:27:52.95"
     }
    ]
    }
    8dbd8068-1091-4cde-8cf5-d1b58dc5c9c7 Cisco Catalyst 2960-C Series
     Switches
    cd6d9b24-839b-4d58-adfe-3fdf781e1782 Cisco 3500I Series Unified
    Access Points
    <omitted>
    55450140-de19-47b5-ae80-bfd741b23fd9 Cisco 4400 Series Integrated 
    Services Routers
    ae19cd21-1b26-4f58-8ccd-d265deabb6c3 Cisco 5500 Series Wireless LAN 
    Controllers

正如您所看到的,我们只查询了一个控制器设备,但我们能够高层次地查看控制器所知道的所有网络设备。在我们的输出中,Catalyst 2960-C 交换机,3500 接入点,4400 ISR 路由器和 5500 无线控制器都可以进一步探索。当然,缺点是 ACI 控制器目前只支持 Cisco 设备。

Juniper 网络的 Python API

Juniper 网络一直是服务提供商群体中的最爱。如果我们退一步看看服务提供商垂直领域,自动化网络设备将是他们需求清单的首要任务。在云规模数据中心出现之前,服务提供商是拥有最多网络设备的人。一个典型的企业网络可能在公司总部有几个冗余的互联网连接,还有一些以枢纽-辐射方式连接回总部,使用服务提供商的私有 MPLS 网络。对于服务提供商来说,他们需要构建、配置、管理和排除连接和底层网络的问题。他们通过销售带宽以及增值的托管服务来赚钱。对于服务提供商来说,投资于自动化以使用最少的工程小时数来保持网络运行是合理的。在他们的用例中,网络自动化是他们竞争优势的关键。

在我看来,服务提供商网络的需求与云数据中心相比的一个区别是,传统上,服务提供商将更多的服务聚合到单个设备中。一个很好的例子是多协议标签交换MPLS),几乎所有主要的服务提供商都提供,但在企业或数据中心网络中很少使用。正如 Juniper 非常成功地发现了这一需求,并且在满足服务提供商自动化需求方面表现出色。让我们来看一下 Juniper 的一些自动化 API。

Juniper 和 NETCONF

网络配置协议NETCONF)是一个 IETF 标准,最早于 2006 年发布为RFC 4741,后来修订为RFC 6241。Juniper 网络对这两个 RFC 标准做出了重大贡献。事实上,Juniper 是 RFC 4741 的唯一作者。Juniper 设备完全支持 NETCONF 是合情合理的,并且它作为大多数自动化工具和框架的基础层。NETCONF 的一些主要特点包括以下内容:

  1. 它使用可扩展标记语言XML)进行数据编码。

  2. 它使用远程过程调用RPC),因此在使用 HTTP(s)作为传输方式时,URL 端点是相同的,而所需的操作在请求的正文中指定。

  3. 它在概念上是基于自上而下的层。这些层包括内容、操作、消息和传输:

NETCONF 模型

Juniper 网络在其技术库中提供了一个广泛的 NETCONF XML 管理协议开发者指南(www.juniper.net/techpubs/en_US/junos13.2/information-products/pathway-pages/netconf-guide/netconf.html#overview)。让我们来看一下它的用法。

设备准备

为了开始使用 NETCONF,让我们创建一个单独的用户,并打开所需的服务:

 set system login user netconf uid 2001
 set system login user netconf class super-user
 set system login user netconf authentication encrypted-password
 "$1$0EkA.XVf$cm80A0GC2dgSWJIYWv7Pt1"
 set system services ssh
 set system services telnet
 set system services netconf ssh port 830

对于 Juniper 设备实验室,我正在使用一个名为Juniper Olive的较旧、不受支持的平台。它仅用于实验目的。您可以使用您喜欢的搜索引擎找出一些关于 Juniper Olive 的有趣事实和历史。

在 Juniper 设备上,您可以随时查看配置,无论是在一个平面文件中还是在 XML 格式中。当您需要指定一条命令来进行配置更改时,flat文件非常方便:

 netconf@foo> show configuration | display set
 set version 12.1R1.9
 set system host-name foo
 set system domain-name bar
 <omitted>

在某些情况下,当您需要查看配置的 XML 结构时,XML 格式非常方便:

 netconf@foo> show configuration | display xml
 <rpc-reply >
 <configuration junos:commit-seconds="1485561328" junos:commit-
 localtime="2017-01-27 23:55:28 UTC" junos:commit-user="netconf">
 <version>12.1R1.9</version>
 <system>
 <host-name>foo</host-name>
 <domain-name>bar</domain-name>

我们已经在 Cisco 部分安装了必要的 Linux 库和 ncclient Python 库。如果您还没有这样做,请参考该部分并安装必要的软件包。

我们现在准备好查看我们的第一个 Juniper NETCONF 示例。

Juniper NETCONF 示例

我们将使用一个非常简单的示例来执行show version。我们将把这个文件命名为junos_netconf_1.py

  #!/usr/bin/env python3

  from ncclient import manager

  conn = manager.connect(
      host='192.168.24.252',
      port='830',
      username='netconf',
      password='juniper!',
      timeout=10,
      device_params={'name':'junos'},
      hostkey_verify=False)

  result = conn.command('show version', format='text')
  print(result)
  conn.close_session()

脚本中的所有字段应该都很容易理解,除了device_params。从 ncclient 0.4.1 开始,添加了设备处理程序,用于指定不同的供应商或平台。例如,名称可以是 juniper、CSR、Nexus 或 Huawei。我们还添加了hostkey_verify=False,因为我们使用的是 Juniper 设备的自签名证书。

返回的输出是用 XML 编码的rpc-reply,其中包含一个output元素:

    <rpc-reply message-id="urn:uuid:7d9280eb-1384-45fe-be48-
    b7cd14ccf2b7">
    <output>
    Hostname: foo
 Model: olive
 JUNOS Base OS boot [12.1R1.9]
 JUNOS Base OS Software Suite [12.1R1.9]
 <omitted>
 JUNOS Runtime Software Suite [12.1R1.9]
 JUNOS Routing Software Suite [12.1R1.9]
    </output>
    </rpc-reply>

我们可以解析 XML 输出以仅包括输出文本:

      print(result.xpath('output')[0].text)

junos_netconf_2.py中,我们将对设备进行配置更改。我们将从一些新的导入开始,用于构建新的 XML 元素和连接管理器对象:

      #!/usr/bin/env python3

      from ncclient import manager
      from ncclient.xml_ import new_ele, sub_ele

      conn = manager.connect(host='192.168.24.252', port='830', 
    username='netconf' , password='juniper!', timeout=10, 
    device_params={'name':'junos'}, hostkey_v erify=False)

我们将锁定配置并进行配置更改:

      # lock configuration and make configuration changes
      conn.lock()

      # build configuration
      config = new_ele('system')
      sub_ele(config, 'host-name').text = 'master'
      sub_ele(config, 'domain-name').text = 'python'

在构建配置部分,我们创建一个system元素,其中包含host-namedomain-name子元素。如果你想知道层次结构,你可以从 XML 显示中看到system的节点结构是host-namedomain-name的父节点:

     <system>
        <host-name>foo</host-name>
        <domain-name>bar</domain-name>
    ...
    </system>

配置构建完成后,脚本将推送配置并提交配置更改。这些是 Juniper 配置更改的正常最佳实践步骤(锁定、配置、解锁、提交):

      # send, validate, and commit config
      conn.load_configuration(config=config)
      conn.validate()
      commit_config = conn.commit()
      print(commit_config.tostring)

      # unlock config
      conn.unlock()

      # close session
      conn.close_session()

总的来说,NETCONF 步骤与 CLI 步骤非常相似。请查看junos_netconf_3.py脚本,以获取更多可重用的代码。以下示例将步骤示例与一些 Python 函数结合起来:

# make a connection object
def connect(host, port, user, password):
    connection = manager.connect(host=host, port=port, username=user,
            password=password, timeout=10, device_params={'name':'junos'},
            hostkey_verify=False)
    return connection

# execute show commands
def show_cmds(conn, cmd):
    result = conn.command(cmd, format='text')
    return result

# push out configuration
def config_cmds(conn, config):
    conn.lock()
    conn.load_configuration(config=config)
    commit_config = conn.commit()
    return commit_config.tostring

这个文件可以自行执行,也可以被导入到其他 Python 脚本中使用。

Juniper 还提供了一个名为 PyEZ 的 Python 库,可用于其设备。我们将在下一节中看一些使用该库的示例。

开发人员的 Juniper PyEZ

PyEZ是一个高级的 Python 实现,与现有的 Python 代码更好地集成。通过使用 Python API,您可以执行常见的操作和配置任务,而无需对 Junos CLI 有深入的了解。

Juniper 在其技术库的www.juniper.net/techpubs/en_US/junos-pyez1.0/information-products/pathway-pages/junos-pyez-developer-guide.html#configuration上维护了一份全面的 Junos PyEZ 开发人员指南。如果您有兴趣使用 PyEZ,我强烈建议至少浏览一下指南中的各个主题。

安装和准备

每个操作系统的安装说明都可以在安装 Junos PyEZ (www.juniper.net/techpubs/en_US/junos-pyez1.0/topics/task/installation/junos-pyez-server-installing.html)页面上找到。我们将展示 Ubuntu 16.04 的安装说明。

以下是一些依赖包,其中许多应该已经在主机上运行之前的示例中了:

$ sudo apt-get install -y python3-pip python3-dev libxml2-dev libxslt1-dev libssl-dev libffi-dev

PyEZ包可以通过 pip 安装。在这里,我已经为 Python 3 和 Python 2 都安装了:

$ sudo pip3 install junos-eznc
$ sudo pip install junos-eznc

在 Juniper 设备上,NETCONF 需要配置为 PyEZ 的基础 XML API:

set system services netconf ssh port 830

对于用户认证,我们可以使用密码认证或 SSH 密钥对。创建本地用户很简单:

set system login user netconf uid 2001
set system login user netconf class super-user
set system login user netconf authentication encrypted-password "$1$0EkA.XVf$cm80A0GC2dgSWJIYWv7Pt1"

对于ssh密钥认证,首先在主机上生成密钥对:

$ ssh-keygen -t rsa

默认情况下,公钥将被称为id_rsa.pub,位于~/.ssh/目录下,而私钥将被命名为id_rsa,位于相同的目录下。将私钥视为永远不共享的密码。公钥可以自由分发。在我们的用例中,我们将把公钥移动到/tmp目录,并启用 Python 3 HTTP 服务器模块以创建可访问的 URL:

$ mv ~/.ssh/id_rsa.pub /tmp
$ cd /tmp
$ python3 -m http.server
Serving HTTP on 0.0.0.0 port 8000 ...

对于 Python 2,请改用python -m SimpleHTTPServer

从 Juniper 设备中,我们可以通过从 Python 3 web 服务器下载公钥来创建用户并关联公钥:

netconf@foo# set system login user echou class super-user authentication load-key-file http://192.168.24.164:8000/id_rsa.pub
/var/home/netconf/...transferring.file........100% of 394 B 2482 kBps

现在,如果我们尝试使用管理站的私钥进行 ssh,用户将自动进行身份验证:

$ ssh -i ~/.ssh/id_rsa 192.168.24.252
--- JUNOS 12.1R1.9 built 2012-03-24 12:52:33 UTC
echou@foo>

让我们确保两种身份验证方法都可以与 PyEZ 一起使用。让我们尝试用户名和密码组合:

Python 3.5.2 (default, Nov 17 2016, 17:05:23)
[GCC 5.4.0 20160609] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> from jnpr.junos import Device
>>> dev = Device(host='192.168.24.252', user='netconf', password='juniper!')
>>> dev.open()
Device(192.168.24.252)
>>> dev.facts
{'serialnumber': '', 'personality': 'UNKNOWN', 'model': 'olive', 'ifd_style': 'CLASSIC', '2RE': False, 'HOME': '/var/home/juniper', 'version_info': junos.version_info(major=(12, 1), type=R, minor=1, build=9), 'switch_style': 'NONE', 'fqdn': 'foo.bar', 'hostname': 'foo', 'version': '12.1R1.9', 'domain': 'bar', 'vc_capable': False}
>>> dev.close()

我们也可以尝试使用 SSH 密钥身份验证:

>>> from jnpr.junos import Device
>>> dev1 = Device(host='192.168.24.252', user='echou', ssh_private_key_file='/home/echou/.ssh/id_rsa')
>>> dev1.open()
Device(192.168.24.252)
>>> dev1.facts
{'HOME': '/var/home/echou', 'model': 'olive', 'hostname': 'foo', 'switch_style': 'NONE', 'personality': 'UNKNOWN', '2RE': False, 'domain': 'bar', 'vc_capable': False, 'version': '12.1R1.9', 'serialnumber': '', 'fqdn': 'foo.bar', 'ifd_style': 'CLASSIC', 'version_info': junos.version_info(major=(12, 1), type=R, minor=1, build=9)}
>>> dev1.close()

太好了!我们现在准备好查看一些 PyEZ 的示例了。

PyEZ 示例

在之前的交互提示中,我们已经看到设备连接时,对象会自动检索有关设备的一些事实。在我们的第一个示例junos_pyez_1.py中,我们连接到设备并执行了show interface em1的 RPC 调用:

      #!/usr/bin/env python3
      from jnpr.junos import Device
      import xml.etree.ElementTree as ET
      import pprint

      dev = Device(host='192.168.24.252', user='juniper', passwd='juniper!')

      try:
          dev.open()
      except Exception as err:
          print(err)
          sys.exit(1)

      result = 
    dev.rpc.get_interface_information(interface_name='em1', terse=True)
      pprint.pprint(ET.tostring(result))

      dev.close()

设备类具有一个包含所有操作命令的rpc属性。这非常棒,因为我们在 CLI 和 API 中可以做的事情之间没有差距。问题在于我们需要找出xml rpc元素标签。在我们的第一个示例中,我们如何知道show interface em1等同于get_interface_information?我们有三种方法可以找出这些信息:

  1. 我们可以参考Junos XML API 操作开发人员参考

  2. 我们可以使用 CLI 显示 XML RPC 等效,并用下划线(_)替换单词之间的破折号(-

  3. 我们还可以通过使用 PyEZ 库来进行编程

我通常使用第二个选项直接获取输出:

 netconf@foo> show interfaces em1 | display xml rpc
 <rpc-reply >
 <rpc>
 <get-interface-information>
 <interface-name>em1</interface-name>
 </get-interface-information>
 </rpc>
 <cli>
 <banner></banner>
 </cli>
 </rpc-reply>

以下是使用 PyEZ 进行编程的示例(第三个选项):

 >>> dev1.display_xml_rpc('show interfaces em1', format='text')
 '<get-interface-information>n <interface-name>em1</interface-
 name>n</get-interface-information>n'

当然,我们还需要进行配置更改。在junos_pyez_2.py配置示例中,我们将从 PyEZ 导入一个额外的Config()方法:

      #!/usr/bin/env python3
      from jnpr.junos import Device
      from jnpr.junos.utils.config import Config

我们将利用相同的块连接到设备:

      dev = Device(host='192.168.24.252', user='juniper', 
    passwd='juniper!')

      try:
          dev.open()
      except Exception as err:
          print(err)
          sys.exit(1)

new Config()方法将加载 XML 数据并进行配置更改:

      config_change = """
      <system>
        <host-name>master</host-name>
        <domain-name>python</domain-name>
      </system>
      """

      cu = Config(dev)
      cu.lock()
      cu.load(config_change)
      cu.commit()
      cu.unlock()

      dev.close()

PyEZ 示例设计简单。希望它们能展示您如何利用 PyEZ 满足 Junos 自动化需求的方式。

Arista Python API

Arista Networks一直专注于大型数据中心网络。在其公司简介页面(www.arista.com/en/company/company-overview)中,如下所述:

“Arista Networks 成立的目的是开创并提供面向大型数据中心存储和计算环境的软件驱动云网络解决方案。”

请注意,该声明特别指出了大型数据中心,我们已经知道这些数据中心充斥着服务器、数据库和网络设备。自动化一直是 Arista 的主要特点之一是有道理的。事实上,他们的操作系统背后有一个 Linux 支撑,允许许多附加功能,如 Linux 命令和内置的 Python 解释器。

与其他供应商一样,您可以直接通过 eAPI 与 Arista 设备交互,或者您可以选择利用他们的Python库。我们将看到两者的示例。我们还将在后面的章节中看到 Arista 与 Ansible 框架的集成。

Arista eAPI 管理

几年前,Arista 的 eAPI 首次在 EOS 4.12 中引入。它通过 HTTP 或 HTTPS 传输一系列显示或配置命令,并以 JSON 形式回应。一个重要的区别是它是远程过程调用RPC)和JSON-RPC,而不是纯粹通过 HTTP 或 HTTPS 提供的 RESTFul API。对于我们的意图和目的,不同之处在于我们使用相同的 HTTP 方法(POST)向相同的 URL 端点发出请求。我们不是使用 HTTP 动词(GETPOSTPUTDELETE)来表达我们的动作,而是简单地在请求的正文中说明我们的意图动作。在 eAPI 的情况下,我们将为我们的意图指定一个method键和一个runCmds值。

在以下示例中,我使用运行 EOS 4.16 的物理 Arista 交换机。

eAPI 准备

Arista 设备上的 eAPI 代理默认处于禁用状态,因此我们需要在设备上启用它才能使用:

arista1(config)#management api http-commands
arista1(config-mgmt-api-http-cmds)#no shut
arista1(config-mgmt-api-http-cmds)#protocol https port 443
arista1(config-mgmt-api-http-cmds)#no protocol http
arista1(config-mgmt-api-http-cmds)#vrf management

如您所见,我们已关闭 HTTP 服务器,而是仅使用 HTTPS 作为传输。从几个 EOS 版本前开始,默认情况下,管理接口位于名为management的 VRF 中。在我的拓扑中,我通过管理接口访问设备;因此,我已指定了 eAPI 管理的 VRF。您可以通过"show management api http-commands"命令检查 API 管理状态:

arista1#sh management api http-commands
Enabled: Yes
HTTPS server: running, set to use port 443
HTTP server: shutdown, set to use port 80
Local HTTP server: shutdown, no authentication, set to use port 8080
Unix Socket server: shutdown, no authentication
VRF: management
Hits: 64
Last hit: 33 seconds ago
Bytes in: 8250
Bytes out: 29862
Requests: 23
Commands: 42
Duration: 7.086 seconds
SSL Profile: none
QoS DSCP: 0
 User Requests Bytes in Bytes out Last hit
----------- -------------- -------------- --------------- --------------
 admin 23 8250 29862 33 seconds ago

URLs
-----------------------------------------
Management1 : https://192.168.199.158:443

arista1#

启用代理后,您将能够通过访问设备的 IP 地址来访问 eAPI 的探索页面。如果您已更改访问的默认端口,只需在末尾添加即可。认证与交换机上的认证方法绑定。我们将使用设备上本地配置的用户名和密码。默认情况下,将使用自签名证书:

Arista EOS explorer

您将进入一个探索页面,在那里您可以输入 CLI 命令并获得请求正文的良好输出。例如,如果我想查看如何为show version制作请求正文,这就是我将从探索器中看到的输出:

Arista EOS explorer viewer

概述链接将带您进入示例用途和背景信息,而命令文档将作为 show 命令的参考点。每个命令引用都将包含返回值字段名称、类型和简要描述。Arista 的在线参考脚本使用 jsonrpclib (github.com/joshmarshall/jsonrpclib/),这是我们将使用的。然而,截至撰写本书时,它依赖于 Python 2.6+,尚未移植到 Python 3;因此,我们将在这些示例中使用 Python 2.7。

在您阅读本书时,可能会有更新的状态。请阅读 GitHub 拉取请求 (github.com/joshmarshall/jsonrpclib/issues/38) 和 GitHub README (github.com/joshmarshall/jsonrpclib/) 以获取最新状态。

安装很简单,使用easy_installpip

$ sudo easy_install jsonrpclib
$ sudo pip install jsonrpclib

eAPI 示例

然后,我们可以编写一个名为eapi_1.py的简单程序来查看响应文本:

      #!/usr/bin/python2

      from __future__ import print_function
      from jsonrpclib import Server
      import ssl

      ssl._create_default_https_context = ssl._create_unverified_context

      switch = Server("https://admin:arista@192.168.199.158/command-api")

      response = switch.runCmds( 1, [ "show version" ] )
      print('Serial Number: ' + response[0]['serialNumber'])

请注意,由于这是 Python 2,在脚本中,我使用了from __future__ import print_function以便未来迁移更容易。与ssl相关的行适用于 Python 版本 > 2.7.9。更多信息,请参阅www.python.org/dev/peps/pep-0476/

这是我从先前的runCms()方法收到的响应:

 [{u'memTotal': 3978148, u'internalVersion': u'4.16.6M-
 3205780.4166M', u'serialNumber': u'<omitted>', u'systemMacAddress':
 u'<omitted>', u'bootupTimestamp': 1465964219.71, u'memFree': 
 277832, u'version': u'4.16.6M', u'modelName': u'DCS-7050QX-32-F', 
 u'isIntlVersion': False, u'internalBuildId': u'373dbd3c-60a7-4736-
 8d9e-bf5e7d207689', u'hardwareRevision': u'00.00', u'architecture': 
 u'i386'}]

如您所见,结果是包含一个字典项的列表。如果我们需要获取序列号,我们可以简单地引用项目编号和键:

     print('Serial Number: ' + response[0]['serialNumber'])

输出将仅包含序列号:

$ python eapi_1.py
Serial Number: <omitted>

为了更熟悉命令参考,请点击 eAPI 页面上的命令文档链接,并将您的输出与文档中 show version 的输出进行比较。

如前所述,与 REST 不同,JSON-RPC 客户端使用相同的 URL 端点来调用服务器资源。您可以从前面的示例中看到,runCmds()方法包含一系列命令。对于配置命令的执行,您可以遵循相同的框架,并通过一系列命令配置设备。

这是一个名为eapi_2.py的配置命令示例。在我们的示例中,我们编写了一个函数,该函数将交换机对象和命令列表作为属性:

      #!/usr/bin/python2

      from __future__ import print_function
      from jsonrpclib import Server
      import ssl, pprint

      ssl._create_default_https_context = ssl._create_unverified_context

      # Run Arista commands thru eAPI
      def runAristaCommands(switch_object, list_of_commands):
          response = switch_object.runCmds(1, list_of_commands)
          return response

      switch = Server("https://admin:arista@192.168.199.158/command-
    api")

      commands = ["enable", "configure", "interface ethernet 1/3", 
    "switchport acc ess vlan 100", "end", "write memory"]

     response = runAristaCommands(switch, commands)
     pprint.pprint(response)

这是命令执行的输出:

$ python2 eapi_2.py
[{}, {}, {}, {}, {}, {u'messages': [u'Copy completed successfully.']}]

现在,快速检查switch以验证命令的执行:

arista1#sh run int eth 1/3
interface Ethernet1/3
    switchport access vlan 100
arista1# 

总的来说,eAPI 非常简单直接,易于使用。大多数编程语言都有类似于jsonrpclib的库,它们抽象了 JSON-RPC 的内部。通过几个命令,您就可以开始将 Arista EOS 自动化集成到您的网络中。

Arista Pyeapi 库

Python 客户端 Pyeapi(pyeapi.readthedocs.io/en/master/index.html)库是一个原生的 Python 库,包装了 eAPI。它提供了一组绑定来配置 Arista EOS 节点。为什么我们需要 Pyeapi,当我们已经有 eAPI 了呢?在 Python 环境中选择 Pyeapi 还是 eAPI 主要是一个判断调用。

然而,如果你处于非 Python 环境中,eAPI 可能是一个不错的选择。从我们的例子中可以看出,eAPI 的唯一要求是一个支持 JSON-RPC 的客户端。因此,它与大多数编程语言兼容。当我刚开始进入这个领域时,Perl 是脚本和网络自动化的主导语言。仍然有许多企业依赖 Perl 脚本作为他们的主要自动化工具。如果你处于一个公司已经投入大量资源且代码基础是另一种语言而不是 Python 的情况下,使用支持 JSON-RPC 的 eAPI 可能是一个不错的选择。

然而,对于那些更喜欢用 Python 编程的人来说,一个原生的Python库意味着在编写我们的代码时更自然。它确实使得扩展 Python 程序以支持 EOS 节点更容易。它也使得更容易跟上 Python 的最新变化。例如,我们可以使用 Python 3 与 Pyeapi!

在撰写本书时,Python 3(3.4+)支持正式是一个正在进行中的工作,如文档中所述(pyeapi.readthedocs.io/en/master/requirements.html)。请查看文档以获取更多详细信息。

Pyeapi 安装

使用 pip 进行安装非常简单:

$ sudo pip install pyeapi
$ sudo pip3 install pyeapi

请注意,pip 还将安装 netaddr 库,因为它是 Pyeapi 的规定要求的一部分(pyeapi.readthedocs.io/en/master/requirements.html)。

默认情况下,Pyeapi 客户端将在您的主目录中查找一个 INI 风格的隐藏文件(前面带有一个句点)称为eapi.conf。您可以通过指定eapi.conf文件路径来覆盖这种行为,但通常最好将连接凭据与脚本本身分开。您可以查看 Arista Pyeapi 文档(pyeapi.readthedocs.io/en/master/configfile.html#configfile)以获取文件中包含的字段。这是我在实验室中使用的文件:

cat ~/.eapi.conf
[connection:Arista1]
host: 192.168.199.158
username: admin
password: arista
transport: https

第一行[connection:Arista1]包含了我们将在 Pyeapi 连接中使用的名称;其余字段应该是相当容易理解的。您可以将文件锁定为只读,供用户使用此文件:

$ chmod 400 ~/.eapi.conf
$ ls -l ~/.eapi.conf
-r-------- 1 echou echou 94 Jan 27 18:15 /home/echou/.eapi.conf

Pyeapi 示例

现在,我们准备好查看用法了。让我们通过在交互式 Python shell 中创建一个对象来连接到 EOS 节点:

Python 3.5.2 (default, Nov 17 2016, 17:05:23)
[GCC 5.4.0 20160609] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import pyeapi
>>> arista1 = pyeapi.connect_to('Arista1')

我们可以执行 show 命令到节点并接收输出:

>>> import pprint
>>> pprint.pprint(arista1.enable('show hostname'))
[{'command': 'show hostname',
 'encoding': 'json',
 'result': {'fqdn': 'arista1', 'hostname': 'arista1'}}]

配置字段可以是单个命令,也可以是使用config()方法的命令列表:

>>> arista1.config('hostname arista1-new')
[{}]
>>> pprint.pprint(arista1.enable('show hostname'))
[{'command': 'show hostname',
 'encoding': 'json',
 'result': {'fqdn': 'arista1-new', 'hostname': 'arista1-new'}}]
>>> arista1.config(['interface ethernet 1/3', 'description my_link'])
[{}, {}]

请注意,命令缩写(show runshow running-config)和一些扩展将不起作用:

>>> pprint.pprint(arista1.enable('show run'))
Traceback (most recent call last):
...
 File "/usr/local/lib/python3.5/dist-packages/pyeapi/eapilib.py", line 396, in send
 raise CommandError(code, msg, command_error=err, output=out)
pyeapi.eapilib.CommandError: Error [1002]: CLI command 2 of 2 'show run' failed: invalid command [incomplete token (at token 1: 'run')]
>>>
>>> pprint.pprint(arista1.enable('show running-config interface ethernet 1/3'))
Traceback (most recent call last):
...
pyeapi.eapilib.CommandError: Error [1002]: CLI command 2 of 2 'show running-config interface ethernet 1/3' failed: invalid command [incomplete token (at token 2: 'interface')]

然而,您总是可以捕获结果并获得所需的值:

>>> result = arista1.enable('show running-config')
>>> pprint.pprint(result[0]['result']['cmds']['interface Ethernet1/3'])
{'cmds': {'description my_link': None, 'switchport access vlan 100': None}, 'comments': []}

到目前为止,我们一直在使用 eAPI 进行 show 和配置命令。Pyeapi 提供了各种 API 来使生活更轻松。在下面的示例中,我们将连接到节点,调用 VLAN API,并开始对设备的 VLAN 参数进行操作。让我们来看一下:

>>> import pyeapi
>>> node = pyeapi.connect_to('Arista1')
>>> vlans = node.api('vlans')
>>> type(vlans)
<class 'pyeapi.api.vlans.Vlans'>
>>> dir(vlans)
[...'command_builder', 'config', 'configure', 'configure_interface', 'configure_vlan', 'create', 'default', 'delete', 'error', 'get', 'get_block', 'getall', 'items', 'keys', 'node', 'remove_trunk_group', 'set_name', 'set_state', 'set_trunk_groups', 'values']
>>> vlans.getall()
{'1': {'vlan_id': '1', 'trunk_groups': [], 'state': 'active', 'name': 'default'}}
>>> vlans.get(1)
{'vlan_id': 1, 'trunk_groups': [], 'state': 'active', 'name': 'default'}
>>> vlans.create(10)
True
>>> vlans.getall()
{'1': {'vlan_id': '1', 'trunk_groups': [], 'state': 'active', 'name': 'default'}, '10': {'vlan_id': '10', 'trunk_groups': [], 'state': 'active', 'name': 'VLAN0010'}}
>>> vlans.set_name(10, 'my_vlan_10')
True

让我们验证一下设备上是否创建了 VLAN 10:

arista1#sh vlan
VLAN Name Status Ports
----- -------------------------------- --------- -------------------------------
1 default active
10 my_vlan_10 active

正如你所看到的,EOS 对象上的 Python 本机 API 确实是 Pyeapi 在 eAPI 之上的优势所在。它将底层属性抽象成设备对象,使代码更清晰、更易读。

要获取不断增加的 Pyeapi API 的完整列表,请查阅官方文档(pyeapi.readthedocs.io/en/master/api_modules/_list_of_modules.html)。

总结本章,让我们假设我们重复了前面的步骤足够多次,以至于我们想写另一个 Python 类来节省一些工作。pyeapi_1.py脚本如下所示:

      #!/usr/bin/env python3

      import pyeapi

      class my_switch():

          def __init__(self, config_file_location, device):
               # loads the config file
               pyeapi.client.load_config(config_file_location)
               self.node = pyeapi.connect_to(device)
               self.hostname = self.node.enable('show hostname')[0]
    ['result']['host name']
              self.running_config = self.node.enable('show running-
    config')

           def create_vlan(self, vlan_number, vlan_name):
               vlans = self.node.api('vlans')
               vlans.create(vlan_number)
               vlans.set_name(vlan_number, vlan_name) 

从脚本中可以看出,我们自动连接到节点并在连接时设置主机名和running_config。我们还创建了一个使用VLAN API 创建 VLAN 的类方法。让我们在交互式 shell 中尝试运行脚本:

Python 3.5.2 (default, Nov 17 2016, 17:05:23)
[GCC 5.4.0 20160609] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import pyeapi_1
>>> s1 = pyeapi_1.my_switch('/tmp/.eapi.conf', 'Arista1')
>>> s1.hostname
'arista1'
>>> s1.running_config
[{'encoding': 'json', 'result': {'cmds': {'interface Ethernet27': {'cmds': {}, 'comments': []}, 'ip routing': None, 'interface face Ethernet29': {'cmds': {}, 'comments': []}, 'interface Ethernet26': {'cmds': {}, 'comments': []}, 'interface Ethernet24/4': h.': 
<omitted>
'interface Ethernet3/1': {'cmds': {}, 'comments': []}}, 'comments': [], 'header': ['! device: arista1 (DCS-7050QX-32, EOS-4.16.6M)n!n']}, 'command': 'show running-config'}]
>>> s1.create_vlan(11, 'my_vlan_11')
>>> s1.node.api('vlans').getall()
{'11': {'name': 'my_vlan_11', 'vlan_id': '11', 'trunk_groups': [], 'state': 'active'}, '10': {'name': 'my_vlan_10', 'vlan_id': '10', 'trunk_groups': [], 'state': 'active'}, '1': {'name': 'default', 'vlan_id': '1', 'trunk_groups': [], 'state': 'active'}}
>>>

供应商中立库

有几个优秀的供应商中立库,比如 Netmiko(github.com/ktbyers/netmiko)和 NAPALM(github.com/napalm-automation/napalm)。因为这些库并非来自设备供应商,它们有时会慢一步来支持最新的平台或功能。然而,由于这些库是供应商中立的,如果你不喜欢为你的工具绑定供应商,那么这些库是一个不错的选择。使用这些库的另一个好处是它们通常是开源的,所以你可以为新功能和错误修复做出贡献。

另一方面,由于这些库是由社区支持的,如果你需要依赖他人来修复错误或实现新功能,它们可能并不是理想的选择。如果你有一个相对较小的团队,仍然需要遵守工具的某些服务级保证,你可能最好使用供应商支持的库。

总结

在本章中,我们看了一些从思科、Juniper 和 Arista 管理网络设备的各种方法。我们既看了与 NETCONF 和 REST 等直接通信,也使用了供应商提供的库,比如 PyEZ 和 Pyeapi。这些都是不同的抽象层,旨在提供一种无需人工干预就能编程管理网络设备的方式。

在第四章中,Python 自动化框架- Ansible 基础,我们将看一下一个更高级的供应商中立抽象框架,称为Ansible。Ansible 是一个用 Python 编写的开源通用自动化工具。它可以用于自动化服务器、网络设备、负载均衡器等等。当然,对于我们的目的,我们将专注于使用这个自动化框架来管理网络设备。

第四章:Python 自动化框架- Ansible 基础知识

前两章逐步介绍了与网络设备交互的不同方式。在第二章中,低级网络设备交互,我们讨论了管理交互会话以控制交互的 Pexpect 和 Paramiko 库。在第三章中,API 和意图驱动的网络,我们开始从 API 和意图的角度思考我们的网络。我们看了各种包含明确定义的命令结构并提供了一种结构化方式从设备获取反馈的 API。当我们从第二章 低级网络设备交互转移到第三章 API 和意图驱动的网络时,我们开始思考我们对网络的意图,并逐渐以代码的形式表达我们的网络。

让我们更深入地探讨将我们的意图转化为网络需求的想法。如果你曾经从事过网络设计,那么最具挑战性的部分往往不是网络设备的不同部分,而是资格和将业务需求转化为实际网络设计。你的网络设计需要解决业务问题。例如,你可能在一个更大的基础设施团队中工作,需要适应一个繁荣的在线电子商务网站,在高峰时段经历网站响应速度缓慢。你如何确定网络是否存在问题?如果网站的响应速度确实是由于网络拥塞造成的,那么你应该升级网络的哪一部分?其他系统能否利用更大的速度和吞吐量?以下图表是一个简单的过程的示意图,当我们试图将我们的业务需求转化为网络设计时,我们可能会经历的步骤:

业务逻辑到网络部署

在我看来,网络自动化不仅仅是更快的配置。它还应该解决业务问题,并准确可靠地将我们的意图转化为设备行为。这些是我们在网络自动化旅程中应该牢记的目标。在本章中,我们将开始研究一个名为Ansible的基于 Python 的框架,它允许我们声明我们对网络的意图,并从 API 和 CLI 中抽象出更多。

一个更具声明性的框架

有一天早上,你从一个关于潜在网络安全漏洞的噩梦中惊醒。你意识到你的网络包含有价值的数字资产,应该受到保护。作为网络管理员,你一直在做好工作,所以它相当安全,但你想在网络设备周围增加更多的安全措施,以确保安全。

首先,你将目标分解为两个可行的项目:

  • 升级设备到最新版本的软件,这需要:
  1. 将镜像上传到设备。

  2. 指示设备从新镜像启动。

  3. 继续重新启动设备。

  4. 验证设备是否正在运行新软件镜像。

  • 在网络设备上配置适当的访问控制列表,包括以下内容:
  1. 在设备上构建访问列表。

  2. 在接口上配置访问列表,在大多数情况下是在接口配置部分,以便可以应用到接口上。

作为一个以自动化为重点的网络工程师,您希望编写脚本来可靠地配置设备并从操作中获得反馈。您开始研究每个步骤所需的命令和 API,在实验室中验证它们,最终在生产环境中部署它们。在为 OS 升级和 ACL 部署做了大量工作之后,您希望这些脚本可以转移到下一代设备上。如果有一个工具可以缩短这个设计-开发-部署周期,那不是很好吗?

在本章和第五章《Python 自动化框架-超越基础》中,我们将使用一个名为 Ansible 的开源自动化工具。它是一个可以简化从业务逻辑到网络命令的过程的框架。它可以配置系统,部署软件,并协调一系列任务。Ansible 是用 Python 编写的,并已成为受网络设备供应商支持的领先自动化工具之一。

在本章中,我们将讨论以下主题:

  • 一个快速的 Ansible 示例

  • Ansible 的优势

  • Ansible 架构

  • Ansible Cisco 模块和示例

  • Ansible Juniper 模块和示例

  • Ansible Arista 模块和示例

在撰写本书时,Ansible 2.5 版本兼容 Python 2.6 和 2.7,最近才从技术审查中获得了对 Python 3 的支持。与 Python 一样,Ansible 的许多有用功能来自社区驱动的扩展模块。即使 Ansible 核心模块支持 Python 3,许多扩展模块和生产部署仍处于 Python 2 模式。需要一些时间将所有扩展模块从 Python 2 升级到 Python 3。因此,在本书的其余部分,我们将使用 Python 2.7 和 Ansible 2.2。

为什么选择 Ansible 2.2?Ansible 2.5 于 2018 年 3 月发布,提供了许多新的网络模块功能,具有新的连接方法、语法和最佳实践。鉴于其相对较新的功能,大多数生产部署仍处于 2.5 版本之前。然而,在本章中,您还将找到专门用于 Ansible 2.5 示例的部分,供那些想要利用新语法和功能的人使用。

有关 Ansible Python 3 支持的最新信息,请访问docs.ansible.com/ansible/python_3_support.html

从前面的章节可以看出,我是一个学习示例的信徒。就像 Ansible 的底层 Python 代码一样,即使您以前没有使用过 Ansible,Ansible 构造的语法也很容易理解。如果您有一些关于 YAML 或 Jinja2 的经验,您将很快找到语法和预期过程之间的关联。让我们先看一个例子。

一个快速的 Ansible 示例

与其他自动化工具一样,Ansible 最初是用来管理服务器的,然后扩展到管理网络设备的能力。在很大程度上,服务器模块和网络模块以及 Ansible 所称的 playbook 之间是相似的,只是有细微的差别。在本章中,我们将首先看一个服务器任务示例,然后再与网络模块进行比较。

控制节点安装

首先,让我们澄清一下在 Ansible 环境中使用的术语。我们将把安装了 Ansible 的虚拟机称为控制机,被管理的机器称为目标机器或被管理节点。Ansible 可以安装在大多数 Unix 系统上,唯一的依赖是 Python 2.6 或 2.7。目前,Windows 操作系统并不被官方支持作为控制机。Windows 主机仍然可以被 Ansible 管理,只是不被支持作为控制机。

随着 Windows 10 开始采用 Windows 子系统,Ansible 可能很快也准备好在 Windows 上运行。有关更多信息,请查看 Windows 的 Ansible 文档(docs.ansible.com/ansible/2.4/intro_windows.html)。

在受控节点要求中,您可能会注意到一些文档提到 Python 2.4 或更高版本是一个要求。这对于管理诸如 Linux 之类的操作系统的目标节点是正确的,但显然并非所有网络设备都支持 Python。我们将看到如何通过在控制节点上本地执行来绕过网络模块的此要求。

对于 Windows,Ansible 模块是用 PowerShell 实现的。如果您想查看核心和额外存储库中的 Windows 模块,可以在 Windows/subdirectory 中找到。

我们将在我们的 Ubuntu 虚拟机上安装 Ansible。有关其他操作系统的安装说明,请查看安装文档(docs.ansible.com/ansible/intro_installation.html)。在以下代码块中,您将看到安装软件包的步骤:

$ sudo apt-get install software-properties-common
$ sudo apt-add-repository ppa:ansible/ansible
$ sudo apt-get update
$ sudo apt-get install ansible

我们也可以使用pip来安装 Ansible:pip install ansible。我个人更喜欢使用操作系统的软件包管理系统,比如 Ubuntu 上的 Apt。

现在我们可以进行快速验证如下:

$ ansible --version
ansible 2.6.1
  config file = /etc/ansible/ansible.cfg 

现在,让我们看看如何在同一控制节点上运行不同版本的 Ansible。如果您想尝试最新的开发功能而不进行永久安装,这是一个有用的功能。如果我们打算在没有根权限的控制节点上运行 Ansible,我们也可以使用这种方法。

从输出中我们可以看到,写作本书时,最新版本是 2.6.1。请随意使用此版本,但考虑到相对较新的发布,我们将在本书中专注于 Ansible 版本 2.2。

从源代码运行不同版本的 Ansible

您可以从源代码检出运行 Ansible(我们将在第十一章中查看 Git 作为版本控制机制):

$ git clone https://github.com/ansible/ansible.git --recursive
$ cd ansible/
$ source ./hacking/env-setup
...
Setting up Ansible to run out of checkout...
$ ansible --version
ansible 2.7.0.dev0 (devel cde3a03b32) last updated 2018/07/11 08:39:39 (GMT -700)
 config file = /etc/ansible/ansible.cfg
...

要运行不同版本,我们可以简单地使用git checkout切换到不同的分支或标签,并重新执行环境设置:

$ git branch -a
$ git tag --list 
$ git checkout v2.5.6
...
HEAD is now at 0c985fe... New release v2.5.6
$ source ./hacking/env-setup
$ ansible --version
ansible 2.5.6 (detached HEAD 0c985fee8a) last updated 2018/07/11 08:48:20 (GMT -700)
 config file = /etc/ansible/ansible.cfg

如果 Git 命令对您来说有点奇怪,我们将在第十一章中更详细地介绍 Git。

一旦我们到达您需要的版本,比如 Ansible 2.2,我们可以为该版本运行核心模块的更新:

$ ansible --version
ansible 2.2.3.0 (detached HEAD f5be18f409) last updated 2018/07/14 07:40:09 (GMT -700)
...
$ git submodule update --init --recursive
Submodule 'lib/ansible/modules/core' (https://github.com/ansible/ansible-modules-core) registered for path 'lib/ansible/modules/core'

让我们来看看我们将在本章和第五章中使用的实验室拓扑,Python 自动化框架-超越基础知识

实验室设置

在本章和第五章中,我们的实验室将有一个安装了 Ansible 的 Ubuntu 16.04 控制节点机器。这个控制机器将能够访问我们的 VIRL 设备的管理网络,这些设备包括 IOSv 和 NX-OSv 设备。当目标机器是主机时,我们还将有一个单独的 Ubuntu 虚拟机用于我们的 playbook 示例。

实验室拓扑

现在,我们准备看我们的第一个 Ansible playbook 示例。

您的第一个 Ansible playbook

我们的第一个 playbook 将在控制节点和远程 Ubuntu 主机之间使用。我们将采取以下步骤:

  1. 确保控制节点可以使用基于密钥的授权。

  2. 创建清单文件。

  3. 创建一个 playbook。

  4. 执行并测试它。

公钥授权

首先要做的是将您的 SSH 公钥从控制机器复制到目标机器。完整的公钥基础设施教程超出了本书的范围,但在控制节点上有一个快速演练:

$ ssh-keygen -t rsa <<<< generates public-private key pair on the host machine if you have not done so already
$ cat ~/.ssh/id_rsa.pub <<<< copy the content of the output and paste it to the ~/.ssh/authorized_keys file on the target host

你可以在en.wikipedia.org/wiki/Public_key_infrastructure了解更多关于 PKI 的信息。

因为我们使用基于密钥的身份验证,我们可以在远程节点上关闭基于密码的身份验证,使其更加安全。现在,你可以使用私钥从控制节点到远程节点进行ssh连接,而无需输入密码。

你能自动复制初始公钥吗?这是可能的,但高度依赖于你的用例、规定和环境。这类似于网络设备的初始控制台设置以建立初始 IP 可达性。你会自动化这个过程吗?为什么或者为什么不?

库存文件

如果没有远程目标需要管理,我们就不需要 Ansible,对吧?一切都始于我们需要在远程主机上执行一些任务。在 Ansible 中,我们指定潜在远程目标的方式是使用一个库存文件。我们可以将这个库存文件作为/etc/ansible/hosts文件,或者在 playbook 运行时使用-i选项指定文件。我个人更喜欢将这个文件放在与我的 playbook 相同的目录中,并使用-i选项。

从技术上讲,只要它是有效的格式,这个文件可以被命名为任何你喜欢的名字。然而,按照惯例,将这个文件命名为hosts。遵循这个惯例,你可以在未来避免一些麻烦。

库存文件是一个简单的纯文本 INI 风格(en.wikipedia.org/wiki/INI_file)文件,用于说明你的目标。默认情况下,目标可以是 DNS FQDN 或 IP 地址:

$ cat hosts
192.168.199.170

我们现在可以使用命令行选项来测试 Ansible 和hosts文件:

$ ansible -i hosts 192.168.199.170 -m ping
192.168.199.170 | SUCCESS => {
 "changed": false,
 "ping": "pong"
}

默认情况下,Ansible 假设执行 playbook 的用户在远程主机上存在。例如,我在本地以echou的身份执行 playbook;相同的用户也存在于我的远程主机上。如果你想以不同的用户执行,可以在执行时使用-u选项,即-u REMOTE_USER

示例中的上一行将主机文件读入库存文件,并在名为192.168.199.170的主机上执行ping模块。Ping (docs.ansible.com/ansible/ping_module.html)是一个简单的测试模块,连接到远程主机,验证可用的 Python 安装,并在成功时返回输出pong

如果你对已经与 Ansible 一起提供的现有模块的使用有任何疑问,可以查看不断扩展的模块列表(docs.ansible.com/ansible/list_of_all_modules.html)。

如果你遇到主机密钥错误,通常是因为主机密钥不在known_hosts文件中,通常位于~/.ssh/known_hosts下。你可以通过 SSH 到主机并在添加主机时回答yes,或者通过检查/etc/ansible/ansible.cfg~/.ansible.cfg来禁用这个功能,使用以下代码:

[defaults]
host_key_checking = False

现在我们已经验证了库存文件和 Ansible 包,我们可以制作我们的第一个 playbook。

我们的第一个 playbook

Playbooks 是 Ansible 描述使用模块对主机执行的操作的蓝图。这是我们在使用 Ansible 时作为操作员将要花费大部分时间的地方。如果你正在建造一个树屋,playbook 将是你的手册,模块将是你的工具,而库存将是你在使用工具时要处理的组件。

playbook 旨在人类可读,并且采用 YAML 格式。我们将在 Ansible 架构部分看到常用的语法。现在,我们的重点是运行一个示例 playbook,以了解 Ansible 的外观和感觉。

最初,YAML 被说成是另一种标记语言,但现在,yaml.org/已经重新定义这个首字母缩写为 YAML 不是标记语言。

让我们看看这个简单的 6 行 playbook,df_playbook.yml

---
- hosts: 192.168.199.170

 tasks:
 - name: check disk usage
 shell: df > df_temp.txt

在 playbook 中,可以有一个或多个 plays。在这种情况下,我们有一个 play(第二到第六行)。在任何 play 中,我们可以有一个或多个任务。在我们的示例 play 中,我们只有一个任务(第四到第六行)。name字段以人类可读的格式指定任务的目的,使用了shell模块。该模块接受一个df参数。shell模块读取参数中的命令并在远程主机上执行它。在这种情况下,我们执行df命令来检查磁盘使用情况,并将输出复制到名为df_temp.txt的文件中。

我们可以通过以下代码执行 playbook:

$ ansible-playbook -i hosts df_playbook.yml
PLAY [192.168.199.170] *********************************************************

TASK [setup] *******************************************************************
ok: [192.168.199.170]

TASK [check disk usage] ************************************************
changed: [192.168.199.170]

PLAY RECAP *********************************************************************
192.168.199.170 : ok=2 changed=1 unreachable=0 failed=0

如果您登录到受管主机(对我来说是192.168.199.170),您会看到df_temp.txt文件包含df命令的输出。很整洁,对吧?

您可能已经注意到,我们的输出实际上执行了两个任务,尽管我们在 playbook 中只指定了一个任务;设置模块是默认自动添加的。它由 Ansible 执行,以收集有关远程主机的信息,这些信息可以在 playbook 中稍后使用。例如,设置模块收集的事实之一是操作系统。收集有关远程目标的事实的目的是什么?您可以将此信息用作同一 playbook 中其他任务的条件。例如,playbook 可以包含额外的任务来安装软件包。它可以具体使用apt来为基于 Debian 的主机安装软件包,使用yum来为基于 Red Hat 的主机安装软件包,这是基于在设置模块中收集的操作系统事实。

如果您对设置模块的输出感到好奇,您可以通过$ ansible -i hosts <host> -m setup找出 Ansible 收集的信息。

在幕后,我们的简单任务实际上发生了一些事情。控制节点将 Python 模块复制到远程主机,执行模块,将模块输出复制到临时文件,然后捕获输出并删除临时文件。目前,我们可能可以安全地忽略这些底层细节,直到我们需要它们。

重要的是,我们充分理解我们刚刚经历的简单过程,因为我们将在本章后面再次提到这些元素。我特意选择了一个服务器示例来呈现在这里,因为当我们需要偏离它们时(记住我们提到 Python 解释器很可能不在网络设备上),这将更有意义。

恭喜您执行了您的第一个 Ansible playbook!我们将更深入地了解 Ansible 架构,但现在让我们看看为什么 Ansible 非常适合网络管理。记住 Ansible 模块是用 Python 编写的吗?这对于 Python 网络工程师来说是一个优势,对吧?

Ansible 的优势

除了 Ansible 之外,还有许多基础设施自动化框架,包括 Chef、Puppet 和 SaltStack。每个框架都提供其独特的功能和模型;没有一个框架适合所有组织。在本节中,我想列出 Ansible 相对于其他框架的一些优势,以及为什么我认为这是网络自动化的好工具。

我正在列出 Ansible 的优势,而不是将它们与其他框架进行比较。其他框架可能采用与 Ansible 相同的某些理念或某些方面,但很少包含我将要提到的所有功能。我相信正是所有以下功能和理念的结合使得 Ansible 成为网络自动化的理想选择。

无需代理

与一些同行不同,Ansible 不需要严格的主从模型。客户端不需要安装软件或代理来与服务器通信。除了许多平台默认具有的 Python 解释器外,不需要额外的软件。

对于网络自动化模块,Ansible 使用 SSH 或 API 调用将所需的更改推送到远程主机,而不是依赖远程主机代理。这进一步减少了对 Python 解释器的需求。对于网络设备管理来说,这对于网络设备管理来说是非常重要的,因为网络供应商通常不愿意在其平台上安装第三方软件。另一方面,SSH 已经存在于网络设备上。这种心态在过去几年里有所改变,但总体上,SSH 是所有网络设备的共同点,而配置管理代理支持则不是。正如您从第二章“低级网络设备交互”中所记得的那样,更新的网络设备还提供 API 层,这也可以被 Ansible 利用。

由于远程主机上没有代理,Ansible 使用推送模型将更改推送到设备,而不是拉模型,其中代理从主服务器拉取信息。在我看来,推送模型更具确定性,因为一切都起源于控制机器。在拉模型中,“拉”的时间可能因客户端而异,因此导致更改时间的差异。

再次强调与现有网络设备一起工作时无代理的重要性是不言而喻的。这通常是网络运营商和供应商接受 Ansible 的主要原因之一。

幂等性

根据维基百科的定义,幂等性是数学和计算机科学中某些操作的属性,可以多次应用而不会改变初始应用后的结果(https://en.wikipedia.org/wiki/Idempotence)。更常见的说法是,这意味着反复运行相同的过程不会改变系统。Ansible 旨在具有幂等性,这对于需要一定操作顺序的网络操作是有益的。

幂等性的优势最好与我们编写的 Pexpect 和 Paramiko 脚本进行比较。请记住,这些脚本是为了像工程师坐在终端上一样推送命令而编写的。如果您执行该脚本 10 次,该脚本将进行 10 次更改。如果我们通过 Ansible playbook 编写相同的任务,将首先检查现有设备配置,只有在更改不存在时才会执行 playbook。如果我们执行 playbook 10 次,更改只会在第一次运行时应用,接下来的 9 次运行将抑制配置更改。

幂等性意味着我们可以重复执行 playbook,而不必担心会有不必要的更改。这很重要,因为我们需要自动检查状态的一致性,而不会有任何额外的开销。

简单且可扩展

Ansible 是用 Python 编写的,并使用 YAML 作为 playbook 语言,这两者都被认为相对容易学习。还记得 Cisco IOS 的语法吗?这是一种特定领域的语言,只适用于管理 Cisco IOS 设备或其他类似结构的设备;它不是一个通用的语言,超出了其有限的范围。幸运的是,与一些其他自动化工具不同,Ansible 没有额外的特定领域语言或 DSL 需要学习,因为 YAML 和 Python 都被广泛用作通用目的语言。

从上面的例子中可以看出,即使您以前没有见过 YAML,也很容易准确猜出 playbook 的意图。Ansible 还使用 Jinja2 作为模板引擎,这是 Python web 框架(如 Django 和 Flask)常用的工具,因此知识是可转移的。

我无法强调 Ansible 的可扩展性。正如前面的例子所示,Ansible 最初是为了自动化服务器(主要是 Linux)工作负载而设计的。然后它开始用 PowerShell 管理 Windows 机器。随着越来越多的行业人员开始采用 Ansible,网络成为一个开始受到更多关注的话题。Ansible 聘请了合适的人员和团队,网络专业人员开始参与,客户开始要求供应商提供支持。从 Ansible 2.0 开始,网络自动化已成为与服务器管理并驾齐驱的一等公民。生态系统活跃而健康,每个版本都在不断改进。

就像 Python 社区一样,Ansible 社区也很友好,对新成员和新想法持包容态度。我亲身经历过成为新手,试图理解贡献程序并希望编写模块以合并到上游的过程。我可以证明,我始终感到受到欢迎和尊重我的意见。

简单性和可扩展性确实为未来的保护做出了很好的表述。技术世界发展迅速,我们不断努力适应。学习一项技术并继续使用它,而不受最新趋势的影响,这不是很好吗?显然,没有人有水晶球能够准确预测未来,但 Ansible 的记录为未来技术的适应性做出了很好的表述。

网络供应商支持

让我们面对现实,我们不是生活在真空中。行业中有一个流行的笑话,即 OSI 层应该包括第 8 层(金钱)和第 9 层(政治)。每天,我们需要使用各种供应商制造的网络设备。

以 API 集成为例。我们在前几章中看到了 Pexpect 和 API 方法之间的差异。在网络自动化方面,API 显然具有优势。然而,API 接口并不便宜。每个供应商都需要投入时间、金钱和工程资源来实现集成。供应商支持技术的意愿在我们的世界中非常重要。幸运的是,所有主要供应商都支持 Ansible,这清楚地表明了越来越多的网络模块可用(docs.ansible.com/ansible/list_of_network_modules.html)。

为什么供应商支持 Ansible 比其他自动化工具更多?无代理的特性肯定有所帮助,因为只有 SSH 作为唯一的依赖大大降低了进入门槛。在供应商一侧工作过的工程师知道,功能请求过程通常需要数月时间,需要克服许多障碍。每次添加新功能,都意味着需要花更多时间进行回归测试、兼容性检查、集成审查等。降低进入门槛通常是获得供应商支持的第一步。

Ansible 基于 Python 这一事实,这是许多网络专业人员喜欢的语言,也是供应商支持的另一个重要推动力。对于已经在 PyEZ 和 Pyeapi 上进行投资的 Juniper 和 Arista 等供应商,他们可以轻松利用现有的 Python 模块,并快速将其功能集成到 Ansible 中。正如我们将在第五章《Python 自动化框架-超越基础知识》中看到的,我们可以利用现有的 Python 知识轻松编写自己的模块。

在 Ansible 专注于网络之前,它已经拥有大量由社区驱动的模块。贡献过程在某种程度上已经成熟和建立,或者说已经成熟,就像一个开源项目可以成熟一样。Ansible 核心团队熟悉与社区合作进行提交和贡献。

增加网络供应商支持的另一个原因也与 Ansible 能够让供应商在模块上表达自己的优势有关。我们将在接下来的部分中看到,除了 SSH,Ansible 模块还可以在本地执行,并通过 API 与这些设备通信。这确保供应商可以在他们通过 API 提供最新和最好的功能时立即表达出来。对于网络专业人员来说,这意味着您可以在使用 Ansible 作为自动化平台时,使用最前沿的功能来选择供应商。

我们花了相当大的篇幅讨论供应商支持,因为我觉得这经常被忽视在 Ansible 故事中。有供应商愿意支持这个工具意味着您,网络工程师,可以放心地睡觉,知道下一个网络中的重大事件将有很高的机会得到 Ansible 的支持,您不会被锁定在当前供应商上,因为您的网络需要增长。

Ansible 架构

Ansible 架构由 playbooks、plays 和 tasks 组成。看一下我们之前使用的df_playbook.yml

Ansible playbook

整个文件称为 playbook,其中包含一个或多个 plays。每个 play 可以包含一个或多个 tasks。在我们的简单示例中,我们只有一个 play,其中包含一个单独的 task。在本节中,我们将看一下以下内容:

  • YAML:这种格式在 Ansible 中被广泛用于表达 playbooks 和变量。

  • 清单:清单是您可以在其中指定和分组基础设施中的主机的地方。您还可以在清单文件中可选地指定主机和组变量。

  • 变量:每个网络设备都不同。它有不同的主机名、IP、邻居关系等。变量允许使用标准的 plays,同时还能适应这些差异。

  • 模板:模板在网络中并不新鲜。事实上,您可能在不经意间使用了一个模板。当我们需要配置新设备或替换 RMA(退货授权)时,我们通常会复制旧配置并替换主机名和环回 IP 地址等差异。Ansible 使用 Jinja2 标准化模板格式,我们稍后将深入探讨。

在第五章中,《Python 自动化框架-超越基础知识》,我们将涵盖一些更高级的主题,如条件、循环、块、处理程序、playbook 角色以及它们如何与网络管理一起使用。

YAML

YAML 是 Ansible playbooks 和一些其他文件使用的语法。官方的 YAML 文档包含了语法的完整规范。以下是与 Ansible 最常见用法相关的简洁版本:

  • YAML 文件以三个破折号(---)开头

  • 空格缩进用于表示结构,就像 Python 一样

  • 注释以井号(#)开头

  • 列表成员以前导连字符(-)表示,每行一个成员

  • 列表也可以用方括号([])表示,元素之间用逗号(,)分隔

  • 字典由 key: value 对表示,用冒号分隔

  • 字典可以用花括号表示,元素之间用逗号分隔

  • 字符串可以不用引号,但也可以用双引号或单引号括起来

正如您所看到的,YAML 很好地映射到 JSON 和 Python 数据类型。如果我要将df_playbook.yml重写为df_playbook.json,它将如下所示:

        [
          {
            "hosts": "192.168.199.170",
            "tasks": [
            "name": "check disk usage",
            "shell": "df > df_temp.txt"
           ]
          }
        ]

这显然不是一个有效的 playbook,但可以帮助理解 YAML 格式,同时使用 JSON 格式进行比较。大多数情况下,playbook 中会看到注释(#)、列表(-)和字典(key: value)。

清单

默认情况下,Ansible 会查看/etc/ansible/hosts文件中在 playbook 中指定的主机。如前所述,我发现通过-i选项指定主机文件更具表现力。这是我们到目前为止一直在做的。为了扩展我们之前的例子,我们可以将我们的清单主机文件写成如下形式:

[ubuntu]
192.168.199.170

[nexus]
192.168.199.148
192.168.199.149

[nexus:vars]
username=cisco
password=cisco

[nexus_by_name]
switch1 ansible_host=192.168.199.148
switch2 ansible_host=192.168.199.149

你可能已经猜到,方括号标题指定了组名,所以在 playbook 中我们可以指向这个组。例如,在cisco_1.ymlcisco_2.yml中,我可以对nexus组下指定的所有主机进行操作,将它们指向nexus组名:

---
- name: Configure SNMP Contact
hosts: "nexus"
gather_facts: false
connection: local
<skip>

一个主机可以存在于多个组中。组也可以作为children进行嵌套:

[cisco]
router1
router2

[arista]
switch1
switch2

[datacenter:children]
cisco
arista

在上一个例子中,数据中心组包括ciscoarista成员。

我们将在下一节讨论变量。但是,您也可以选择在清单文件中指定属于主机和组的变量。在我们的第一个清单文件示例中,[nexus:vars]指定了整个 nexus 组的变量。ansible_host变量在同一行上为每个主机声明变量。

有关清单文件的更多信息,请查看官方文档(docs.ansible.com/ansible/intro_inventory.html)。

变量

我们在上一节中稍微讨论了变量。由于我们的受管节点并不完全相同,我们需要通过变量来适应这些差异。变量名应该是字母、数字和下划线,并且应该以字母开头。变量通常在三个位置定义:

  • playbook

  • 清单文件

  • 将要包含在文件和角色中的单独文件

让我们看一个在 playbook 中定义变量的例子,cisco_1.yml

---
- name: Configure SNMP Contact
hosts: "nexus"
gather_facts: false
connection: local

vars:
cli:
host: "{{ inventory_hostname }}"
username: cisco
password: cisco
transport: cli

tasks:
- name: configure snmp contact
nxos_snmp_contact:
contact: TEST_1
state: present
provider: "{{ cli }}"

register: output

- name: show output
debug:
var: output

vars部分下可以看到cli变量的声明,该变量在nxos_snmp_contact任务中被使用。

有关nxso_snmp_contact模块的更多信息,请查看在线文档(docs.ansible.com/ansible/nxos_snmp_contact_module.html)。

要引用一个变量,可以使用 Jinja2 模板系统的双花括号约定。除非您以它开头,否则不需要在花括号周围加引号。我通常发现更容易记住并在变量值周围加上引号。

你可能也注意到了{{ inventory_hostname }}的引用,在 playbook 中没有声明。这是 Ansible 自动为您提供的默认变量之一,有时被称为魔术变量。

没有太多的魔术变量,你可以在文档中找到列表(docs.ansible.com/ansible/playbooks_variables.html#magic-variables-and-how-to-access-information-about-other-hosts)。

我们在上一节的清单文件中声明了变量:

[nexus:vars]
username=cisco
password=cisco

[nexus_by_name]
switch1 ansible_host=192.168.199.148
switch2 ansible_host=192.168.199.149

为了在清单文件中使用变量而不是在 playbook 中声明它们,让我们在主机文件中为[nexus_by_name]添加组变量:

[nexus_by_name]
switch1 ansible_host=192.168.199.148
switch2 ansible_host=192.168.199.149

[nexus_by_name:vars]
username=cisco
password=cisco

然后,修改 playbook 以匹配我们在cisco_2.yml中看到的内容,以引用变量:

---
- name: Configure SNMP Contact
hosts: "nexus_by_name"
gather_facts: false
connection: local

vars:
  cli:
     host: "{{ ansible_host }}"
     username: "{{ username }}"
     password: "{{ password }}"
     transport: cli

tasks:
  - name: configure snmp contact
  nxos_snmp_contact:
    contact: TEST_1
    state: present
    provider: "{{ cli }}"

  register: output

- name: show output
  debug:
    var: output

请注意,在这个例子中,我们在清单文件中引用了nexus_by_name组,ansible_host主机变量和usernamepassword组变量。这是一个很好的方法,可以将用户名和密码隐藏在受保护的文件中,并发布 playbook 而不担心暴露敏感数据。

要查看更多变量示例,请查看 Ansible 文档(docs.ansible.com/ansible/playbooks_variables.html)。

要访问提供在嵌套数据结构中的复杂变量数据,您可以使用两种不同的表示法。在nxos_snmp_contact任务中,我们在一个变量中注册了输出,并使用 debug 模块显示它。在 playbook 执行期间,您将看到类似以下的内容:

 TASK [show output] 
 *************************************************************
 ok: [switch1] => {
 "output": {
 "changed": false,
 "end_state": {
 "contact": "TEST_1"
 },
 "existing": {
 "contact": "TEST_1"
 },
 "proposed": {
 "contact": "TEST_1"
 },
 "updates": []
 }
 }

为了访问嵌套数据,我们可以使用cisco_3.yml中指定的以下表示法:

msg: '{{ output["end_state"]["contact"] }}'
msg: '{{ output.end_state.contact }}'

您将只收到指定的值:

TASK [show output in output["end_state"]["contact"]] 
***************************
ok: [switch1] => {
 "msg": "TEST_1"
}
ok: [switch2] => {
 "msg": "TEST_1"
}

TASK [show output in output.end_state.contact] 
*********************************
ok: [switch1] => {
 "msg": "TEST_1"
}
ok: [switch2] => {
 "msg": "TEST_1"
}

最后,我们提到变量也可以存储在单独的文件中。为了了解如何在角色或包含的文件中使用变量,我们应该再多举几个例子,因为它们起步有点复杂。我们将在第五章中看到更多角色的例子,《Python 自动化框架-进阶》。

Jinja2 模板

在前面的部分中,我们使用了 Jinja2 语法{{ variable }}的变量。虽然您可以在 Jinja2 中做很多复杂的事情,但幸运的是,我们只需要一些基本的东西来开始。

Jinja2 (jinja.pocoo.org/)是一个功能齐全、强大的模板引擎,起源于 Python 社区。它在 Python web 框架中广泛使用,如 Django 和 Flask。

目前,只需记住 Ansible 使用 Jinja2 作为模板引擎即可。根据情况,我们将重新讨论 Jinja2 过滤器、测试和查找。您可以在这里找到有关 Ansible Jinja2 模板的更多信息:docs.ansible.com/ansible/playbooks_templating.html

Ansible 网络模块

Ansible 最初是用于管理完整操作系统的节点,如 Linux 和 Windows,然后扩展到支持网络设备。您可能已经注意到我们迄今为止为网络设备使用的 playbook 中微妙的差异,比如gather_facts: falseconnection: local;我们将在接下来的章节中更仔细地研究这些差异。

本地连接和事实

Ansible 模块是默认在远程主机上执行的 Python 代码。由于大多数网络设备通常不直接暴露 Python,或者它们根本不包含 Python,我们几乎总是在本地执行 playbook。这意味着 playbook 首先在本地解释,然后根据需要推送命令或配置。

请记住,远程主机的事实是通过默认添加的 setup 模块收集的。由于我们正在本地执行 playbook,因此 setup 模块将在本地主机而不是远程主机上收集事实。这显然是不需要的,因此当连接设置为本地时,我们可以通过将事实收集设置为 false 来减少这个不必要的步骤。

因为网络模块是在本地执行的,对于那些提供备份选项的模块,文件也会在控制节点上本地备份。

Ansible 2.5 中最重要的变化之一是引入了不同的通信协议(docs.ansible.com/ansible/latest/network/getting_started/network_differences.html#multiple-communication-protocols)。连接方法现在包括network_clinetconfhttpapilocal。如果网络设备使用 SSH 的 CLI,您可以在其中一个设备变量中将连接方法指定为network_cli。然而,由于这是一个相对较新的更改,您可能仍然会在许多现有的 playbook 中看到连接状态为本地。

提供者参数

正如我们从第二章和第三章中所看到的,低级网络设备交互API 和意图驱动的网络,网络设备可以通过 SSH 或 API 连接,这取决于平台和软件版本。所有核心网络模块都实现了provider参数,这是一组用于定义如何连接到网络设备的参数。一些模块只支持cli,而一些支持其他值,例如 Arista EAPI 和 Cisco NXAPI。这就是 Ansible“让供应商发光”的理念所体现的地方。模块将有关于它们支持哪种传输方法的文档。

从 Ansible 2.5 开始,指定传输方法的推荐方式是使用connection变量。您将开始看到提供程序参数逐渐在未来的 Ansible 版本中被淘汰。例如,使用ios_command模块作为示例,docs.ansible.com/ansible/latest/modules/ios_command_module.html#ios-command-module,提供程序参数仍然有效,但被标记为已弃用。我们将在本章后面看到一个例子。

provider传输支持的一些基本参数如下:

  • host:定义远程主机

  • port:定义连接的端口

  • username:要进行身份验证的用户名

  • password:要进行身份验证的密码

  • transport:连接的传输类型

  • authorize:这允许特权升级,适用于需要特权的设备

  • auth_pass:定义特权升级密码

正如您所看到的,并非所有参数都需要指定。例如,对于我们之前的 playbook,我们的用户在登录时始终处于管理员特权,因此我们不需要指定authorizeauth_pass参数。

这些参数只是变量,因此它们遵循相同的变量优先规则。例如,如果我将cisco_3.yml更改为cisco_4.yml并观察以下优先顺序:

    ---
    - name: Configure SNMP Contact
      hosts: "nexus_by_name"
      gather_facts: false
      connection: local

      vars:
        cli:
          host: "{{ ansible_host }}"
          username: "{{ username }}"
          password: "{{ password }}"
          transport: cli

      tasks:
        - name: configure snmp contact
          nxos_snmp_contact:
            contact: TEST_1
            state: present
            username: cisco123
            password: cisco123
            provider: "{{ cli }}"

          register: output

        - name: show output in output["end_state"]["contact"]
          debug:
            msg: '{{ output["end_state"]["contact"] }}'

        - name: show output in output.end_state.contact
          debug:
            msg: '{{ output.end_state.contact }}'

在任务级别定义的用户名和密码将覆盖 playbook 级别的用户名和密码。当尝试连接时,如果用户在设备上不存在,我将收到以下错误:

PLAY [Configure SNMP Contact] 
**************************************************

TASK [configure snmp contact] 
**************************************************
fatal: [switch2]: FAILED! => {"changed": false, "failed": true, 
"msg": "failed to connect to 192.168.199.149:22"}
fatal: [switch1]: FAILED! => {"changed": false, "failed": true, 
"msg": "failed to connect to 192.168.199.148:22"}
to retry, use: --limit 
@/home/echou/Master_Python_Networking/Chapter4/cisco_4.retry

PLAY RECAP 
*********************************************************************
switch1 : ok=0 changed=0 unreachable=0 failed=1
switch2 : ok=0 changed=0 unreachable=0 failed=1

Ansible Cisco 示例

Ansible 中的 Cisco 支持按操作系统 IOS、IOS-XR 和 NX-OS 进行分类。我们已经看到了许多 NX-OS 的例子,所以在这一部分让我们尝试管理基于 IOS 的设备。

我们的主机文件将包括两个主机,R1R2

[ios_devices]
R1 ansible_host=192.168.24.250
R2 ansible_host=192.168.24.251

[ios_devices:vars]
username=cisco
password=cisco

我们的 playbook,cisco_5.yml,将使用ios_command模块来执行任意的show commands

    ---
    - name: IOS Show Commands
      hosts: "ios_devices"
      gather_facts: false
      connection: local

      vars:
        cli:
          host: "{{ ansible_host }}"
          username: "{{ username }}"
          password: "{{ password }}"
          transport: cli

      tasks:
        - name: ios show commands
          ios_command:
            commands:
              - show version | i IOS
              - show run | i hostname
            provider: "{{ cli }}"

          register: output

        - name: show output in output["end_state"]["contact"]
          debug:
            var: output

结果是我们期望的show versionshow run输出:

 $ ansible-playbook -i ios_hosts cisco_5.yml

 PLAY [IOS Show Commands] 
 *******************************************************

 TASK [ios show commands] 
 *******************************************************
 ok: [R1]
 ok: [R2]

 TASK [show output in output["end_state"]["contact"]] 
 ***************************
 ok: [R1] => {
 "output": {
 "changed": false,
 "stdout": [
 "Cisco IOS Software, 7200 Software (C7200-A3JK9S-M), Version 
 12.4(25g), RELEASE SOFTWARE (fc1)",
 "hostname R1"
 ],
 "stdout_lines": [
 [
 "Cisco IOS Software, 7200 Software (C7200-A3JK9S-M), Version 
 12.4(25g), RELEASE SOFTWARE (fc1)"
 ],
 [
 "hostname R1"
 ]
 ]
 }
 }
 ok: [R2] => {
 "output": {
 "changed": false,
 "stdout": [
 "Cisco IOS Software, 7200 Software (C7200-A3JK9S-M), Version 
 12.4(25g), RELEASE SOFTWARE (fc1)",
 "hostname R2"
 ],
 "stdout_lines": [
 [
 "Cisco IOS Software, 7200 Software (C7200-A3JK9S-M), Version 
 12.4(25g), RELEASE SOFTWARE (fc1)"
 ],
 [
 "hostname R2"
 ]
 ]
 }
 }

 PLAY RECAP 
 *********************************************************************
 R1 : ok=2 changed=0 unreachable=0 failed=0
 R2 : ok=2 changed=0 unreachable=0 failed=0

我想指出这个例子所说明的一些事情:

  • NXOS 和 IOS 之间的 playbook 基本相同

  • nxos_snmp_contactios_command模块的语法遵循相同的模式,唯一的区别是模块的参数

  • 设备的 IOS 版本非常古老,不理解 API,但模块仍然具有相同的外观和感觉

正如您从前面的例子中所看到的,一旦我们掌握了 playbook 的基本语法,微妙的差异在于我们想要执行的任务的不同模块。

Ansible 2.5 连接示例

我们简要讨论了 Ansible playbook 中网络连接更改的添加,从版本 2.5 开始。随着这些变化,Ansible 还发布了一个网络最佳实践文档。让我们根据最佳实践指南构建一个例子。对于我们的拓扑,我们将重用第二章中的拓扑,其中有两个 IOSv 设备。由于这个例子涉及多个文件,这些文件被分组到一个名为ansible_2-5_example的子目录中。

我们的清单文件减少到组和主机的名称:

$ cat hosts
[ios-devices]
iosv-1
iosv-2

我们创建了一个host_vars目录,其中包含两个文件。每个文件对应清单文件中指定的名称:

$ ls -a host_vars/
. .. iosv-1 iosv-2

主机的变量文件包含了之前包含在 CLI 变量中的内容。ansible_connection的额外变量指定了network_cli作为传输方式:

$ cat host_vars/iosv-1
---
ansible_host: 172.16.1.20
ansible_user: cisco
ansible_ssh_pass: cisco
***ansible_connection: network_cli***
ansible_network_os: ios
ansbile_become: yes
ansible_become_method: enable
ansible_become_pass: cisco

$ cat host_vars/iosv-2
---
ansible_host: 172.16.1.21
ansible_user: cisco
ansible_ssh_pass: cisco
***ansible_connection: network_cli***
ansible_network_os: ios
ansbile_become: yes
ansible_become_method: enable
ansible_become_pass: cisco

我们的 playbook 将使用ios_config模块,并启用backup选项。请注意,在这个例子中使用了when条件,以便如果有其他操作系统的主机,这个任务将不会被应用:

$ cat my_playbook.yml
---
- name: Chapter 4 Ansible 2.5 Best Practice Demonstration
 ***connection: network_cli***
 gather_facts: false
 hosts: all
 tasks:
 - name: backup
 ios_config:
 backup: yes
 register: backup_ios_location
 ***when: ansible_network_os == 'ios'***

当 playbook 运行时,将为每个主机创建一个新的备份文件夹,其中包含备份的配置:

$ ansible-playbook -i hosts my_playbook.yml

PLAY [Chapter 4 Ansible 2.5 Best Practice Demonstration] ***********************

TASK [backup] ******************************************************************
ok: [iosv-2]
ok: [iosv-1]

PLAY RECAP *********************************************************************
iosv-1 : ok=1 changed=0 unreachable=0 failed=0
iosv-2 : ok=1 changed=0 unreachable=0 failed=0

$ ls -l backup/
total 8
-rw-rw-r-- 1 echou echou 3996 Jul 11 19:01 iosv-1_config.2018-07-11@19:01:55
-rw-rw-r-- 1 echou echou 3996 Jul 11 19:01 iosv-2_config.2018-07-11@19:01:55

$ cat backup/iosv-1_config.2018-07-11@19\:01\:55
Building configuration...

Current configuration : 3927 bytes
!
! Last configuration change at 01:46:00 UTC Thu Jul 12 2018 by cisco
!
version 15.6
service timestamps debug datetime msec
service timestamps log datetime msec
...

这个例子说明了network_connection变量和基于网络最佳实践的推荐结构。我们将在第五章中将变量转移到host_vars目录中,并使用条件语句。这种结构也可以用于本章中的 Juniper 和 Arista 示例。对于不同的设备,我们只需为network_connection使用不同的值。

Ansible Juniper 示例

Ansible Juniper 模块需要 Juniper PyEZ 包和 NETCONF。如果你一直在关注第三章中的 API 示例,你就可以开始了。如果没有,请参考该部分以获取安装说明,以及一些测试脚本来确保 PyEZ 正常工作。还需要 Python 包jxmlease

$ sudo pip install jxmlease

在主机文件中,我们将指定设备和连接变量:

[junos_devices]
J1 ansible_host=192.168.24.252

[junos_devices:vars]
username=juniper
password=juniper!

在我们的 Juniper playbook 中,我们将使用junos_facts模块来收集设备的基本信息。这个模块相当于 setup 模块,如果我们需要根据返回的值采取行动,它会很方便。请注意这里的传输和端口的不同值:

    ---
    - name: Get Juniper Device Facts
      hosts: "junos_devices"
      gather_facts: false
      connection: local

      vars:
        netconf:
          host: "{{ ansible_host }}"
          username: "{{ username }}"
          password: "{{ password }}"
          port: 830
          transport: netconf

      tasks:
        - name: collect default set of facts
          junos_facts:
            provider: "{{ netconf }}"

          register: output

        - name: show output
          debug:
            var: output

执行时,你会从Juniper设备收到这个输出:

PLAY [Get Juniper Device Facts] 
************************************************

TASK [collect default set of facts] 
********************************************
ok: [J1]

TASK [show output] 
*************************************************************
ok: [J1] => {
"output": {
"ansible_facts": {
"HOME": "/var/home/juniper",
"domain": "python",
"fqdn": "master.python",
"has_2RE": false,
"hostname": "master",
"ifd_style": "CLASSIC",
"model": "olive",
"personality": "UNKNOWN",
"serialnumber": "",
"switch_style": "NONE",
"vc_capable": false,
"version": "12.1R1.9",
"version_info": {
"build": 9,
"major": [
12,
1
],
"minor": "1",
"type": "R"
}
},
"changed": false
 }
}

PLAY RECAP 
*********************************************************************
J1 : ok=2 changed=0 unreachable=0 failed=0

Ansible Arista 示例

我们将看一下最终的 playbook 示例,即 Arista 命令模块。此时,我们对 playbook 的语法和结构已经非常熟悉。Arista 设备可以配置为使用clieapi进行传输,因此在这个例子中,我们将使用cli

这是主机文件:

[eos_devices]
A1 ansible_host=192.168.199.158

playbook 也与我们之前看到的类似:

    ---
 - name: EOS Show Commands
 hosts: "eos_devices"
 gather_facts: false
 connection: local

 vars:
 cli:
 host: "{{ ansible_host }}"
 username: "arista"
 password: "arista"
 authorize: true
 transport: cli

 tasks:
 - name: eos show commands
 eos_command:
 commands:
 - show version | i Arista
 provider: "{{ cli }}"
 register: output

 - name: show output
 debug:
 var: output

输出将显示标准输出,就像我们从命令行预期的那样:

 PLAY [EOS Show Commands] 
 *******************************************************

 TASK [eos show commands] 
 *******************************************************
 ok: [A1]

 TASK [show output] 
 *************************************************************
 ok: [A1] => {
 "output": {
 "changed": false,
 "stdout": [
 "Arista DCS-7050QX-32-F"
 ],
 "stdout_lines": [
 [
 "Arista DCS-7050QX-32-F"
 ]
 ],
 "warnings": []
 }
 }

 PLAY RECAP 
 *********************************************************************
 A1 : ok=2 changed=0 unreachable=0 failed=0

总结

在本章中,我们对开源自动化框架 Ansible 进行了全面介绍。与基于 Pexpect 和 API 驱动的网络自动化脚本不同,Ansible 提供了一个更高层的抽象,称为 playbook,用于自动化我们的网络设备。

Ansible 最初是用来管理服务器的,后来扩展到网络设备;因此我们看了一个服务器的例子。然后,我们比较和对比了网络管理 playbook 的不同之处。之后,我们看了 Cisco IOS、Juniper JUNOS 和 Arista EOS 设备的示例 playbook。我们还看了 Ansible 推荐的最佳实践,如果你使用的是 Ansible 2.5 及更高版本。

在[第五章](96b9ad57-2f08-4f0d-9b94-1abec5c55770.xhtml)中,《Python 自动化框架-超越基础知识》,我们将利用本章所学的知识,开始了解 Ansible 的一些更高级的特性。

第五章:Python 自动化框架-超越基础

在第一章中,TCP/IP 协议套件和 Python 回顾,我们看了一些基本结构,以使 Ansible 运行起来。我们使用 Ansible 清单文件、变量和 playbook。我们还看了一些使用 Cisco、Juniper 和 Arista 设备的网络模块的示例。

在本章中,我们将进一步建立在之前章节所学到的知识基础上,并深入探讨 Ansible 的更高级主题。关于 Ansible 已经写了很多书,而且 Ansible 的内容远不止我们可以在两章中涵盖的。这里的目标是介绍我认为作为网络工程师您需要的大部分 Ansible 功能和功能,并尽可能地缩短学习曲线。

需要指出的是,如果您对第四章中提出的一些观点不清楚,现在是回顾它们的好时机,因为它们是本章的先决条件。

在本章中,我们将研究以下主题:

  • Ansible 条件

  • Ansible 循环

  • 模板

  • 组和主机变量

  • Ansible Vault

  • Ansible 角色

  • 编写自己的模块

我们有很多内容要涵盖,所以让我们开始吧!

Ansible 条件

Ansible 条件类似于编程语言中的条件语句。在第一章中,TCP/IP 协议套件和 Python 回顾,我们看到 Python 使用条件语句只执行代码的一部分,使用if.. thenwhile语句。在 Ansible 中,它使用条件关键字只有在条件满足时才运行任务。在许多情况下,play 或任务的执行可能取决于事实、变量或上一个任务的结果。例如,如果您有一个升级路由器镜像的 play,您希望包括一步来确保新的路由器镜像在移动到下一个重启路由器的 play 之前已经在设备上。

在本节中,我们将讨论when子句,它支持所有模块,以及在 Ansible 网络命令模块中支持的独特条件状态。一些条件如下:

  • 等于(eq

  • 不等于(neq

  • 大于(gt

  • 大于或等于(ge

  • 小于(lt

  • 小于或等于(le

  • 包含

when 子句

when子句在您需要检查变量或 play 执行结果的输出并相应地采取行动时非常有用。我们在第四章中看到了when子句的一个快速示例,Python 自动化框架- Ansible 基础,当我们查看 Ansible 2.5 最佳实践结构时。如果您还记得,只有当设备的网络操作系统是 Cisco IOS 时,任务才会运行。让我们在chapter5_1.yml中看另一个使用它的例子:

    ---
    - name: IOS Command Output
      hosts: "iosv-devices"
      gather_facts: false
      connection: local
      vars:
        cli:
          host: "{{ ansible_host }}"
          username: "{{ username }}"
          password: "{{ password }}"
          transport: cli
      tasks:
        - name: show hostname
          ios_command:
            commands:
              - show run | i hostname
                provider: "{{ cli }}"
            register: output
        - name: show output
          when: '"iosv-2" in "{{ output.stdout }}"'
          debug:
            msg: '{{ output }}'

我们在这个 playbook 中看到了之前在第四章中的所有元素,*Python 自动化框架- Ansible 基础**,直到第一个任务结束。在 play 的第二个任务中,我们使用when子句来检查输出是否包含iosv-2关键字。如果是,我们将继续执行任务,该任务使用 debug 模块来显示输出。当 playbook 运行时,我们将看到以下输出:

    <skip>
    TASK [show output]  
    *************************************************************
    skipping: [ios-r1]
 ok: [ios-r2] => {
 "msg": {
 "changed": false,
 "stdout": [
 "hostname iosv-2"
 ],
 "stdout_lines": [
 [
 "hostname iosv-2"
 ]
 ],
 "warnings": []
 }
 }
    <skip>

我们可以看到iosv-r1设备被跳过了,因为条件没有通过。我们可以在chapter5_2.yml中进一步扩展这个例子,只有当条件满足时才应用某些配置更改:

    <skip> 
    tasks:
      - name: show hostname
        ios_command:
          commands:
            - show run | i hostname
          provider: "{{ cli }}"
        register: output
      - name: config example
        when: '"iosv-2" in "{{ output.stdout }}"'
        ios_config:
          lines:
            - logging buffered 30000
          provider: "{{ cli }}"

我们可以在这里看到执行输出:

 TASK [config example] 
 **********************************************************
 skipping: [ios-r1]
 changed: [ios-r2] 
 PLAY RECAP 
 ***********************************************************
 ios-r1 : ok=1 changed=0 unreachable=0 failed=0
 ios-r2 : ok=2 changed=1 unreachable=0 failed=0

再次注意执行输出中ios-r2是唯一应用的更改,而ios-r1被跳过。在这种情况下,日志缓冲区大小只在ios-r2上更改。

when子句在使用设置或事实模块时也非常有用-您可以根据最初收集的一些事实来采取行动。例如,以下语句将确保只有主要版本为16的 Ubuntu 主机将受到条件语句的影响:

when: ansible_os_family == "Debian" and ansible_lsb.major_release|int >= 16

有关更多条件,请查看 Ansible 条件文档(docs.ansible.com/ansible/playbooks_conditionals.html)。

Ansible 网络事实

在 2.5 之前,Ansible 网络配送了许多特定于网络的事实模块。网络事实模块存在,但供应商之间的命名和使用方式不同。从 2.5 版本开始,Ansible 开始标准化其网络事实模块的使用。Ansible 网络事实模块从系统中收集信息,并将结果存储在以ansible_net_为前缀的事实中。这些模块收集的数据在模块文档中有记录的返回值中。这对于 Ansible 网络模块来说是一个相当重要的里程碑,因为它默认情况下可以为您抽象出事实收集过程的大部分繁重工作。

让我们使用在第四章中看到的相同结构,Python 自动化框架- Ansible 基础,Ansible 2.5 最佳实践,但扩展它以查看ios_facts模块如何用于收集事实。回顾一下,我们的清单文件包含两个 iOS 主机,主机变量驻留在host_vars目录中:

$ cat hosts
[ios-devices]
iosv-1
iosv-2

$ cat host_vars/iosv-1
---
ansible_host: 172.16.1.20
ansible_user: cisco
ansible_ssh_pass: cisco
ansible_connection: network_cli
ansible_network_os: ios
ansbile_become: yes
ansible_become_method: enable
ansible_become_pass: cisco

我们的 playbook 将有三个任务。第一个任务将使用ios_facts模块为我们的两个网络设备收集事实。第二个任务将显示为每个设备收集和存储的某些事实。您将看到我们显示的事实是默认的ansible_net事实,而不是来自第一个任务的已注册变量。第三个任务将显示我们为iosv-1主机收集的所有事实:

$ cat my_playbook.yml
---
- name: Chapter 5 Ansible 2.5 network facts
 connection: network_cli
 gather_facts: false
 hosts: all
 tasks:
 - name: Gathering facts via ios_facts module
 ios_facts:
 when: ansible_network_os == 'ios'

 - name: Display certain facts
 debug:
 msg: "The hostname is {{ ansible_net_hostname }} running {{ ansible_net_version }}"

 - name: Display all facts for a host
 debug:
 var: hostvars['iosv-1']

当我们运行 playbook 时,您会看到前两个任务的结果是我们预期的:

$ ansible-playbook -i hosts my_playbook.yml

PLAY [Chapter 5 Ansible 2.5 network facts] *************************************

TASK [Gathering facts via ios_facts module] ************************************
ok: [iosv-2]
ok: [iosv-1]

TASK [Display certain facts] ***************************************************
ok: [iosv-2] => {
 "msg": "The hostname is iosv-2 running 15.6(3)M2"
}
ok: [iosv-1] => {
 "msg": "The hostname is iosv-1 running 15.6(3)M2"
}

第三个任务将显示为 iOS 设备收集的所有网络设备事实。已经收集了大量有关 iOS 设备的信息,可以帮助您进行网络自动化需求:

TASK [Display all facts for a host] ********************************************
ok: [iosv-1] => {
 "hostvars['iosv-1']": {
 "ansbile_become": true,
 "ansible_become_method": "enable",
 "ansible_become_pass": "cisco",
 "ansible_check_mode": false,
 "ansible_connection": "network_cli",
 "ansible_diff_mode": false,
 "ansible_facts": {
 "net_all_ipv4_addresses": [
 "10.0.0.5",
 "172.16.1.20",
 "192.168.0.1"
 ],
 "net_all_ipv6_addresses": [],
 "net_filesystems": [
 "flash0:"
 ],
 "net_gather_subset": [
 "hardware",
 "default",
 "interfaces"
 ],
 "net_hostname": "iosv-1",
 "net_image": "flash0:/vios-adventerprisek9-m",
 "net_interfaces": {
 "GigabitEthernet0/0": {
 "bandwidth": 1000000,
 "description": "OOB Management",
 "duplex": "Full",
 "ipv4": [
 {
 "address": "172.16.1.20",
 "subnet": "24"
 }
[skip]

Ansible 2.5 中的网络事实模块是简化工作流程的重要一步,并使其与其他服务器模块齐头并进。

网络模块条件

让我们通过使用我们在本章开头看到的比较关键字来查看另一个网络设备条件示例。我们可以利用 IOSv 和 Arista EOS 都以 JSON 格式提供show命令的输出这一事实。例如,我们可以检查接口的状态:

 arista1#sh interfaces ethernet 1/3 | json
 {
 "interfaces": {
 "Ethernet1/3": {
 "interfaceStatistics": {
 <skip>
 "outPktsRate": 0.0
 },
 "name": "Ethernet1/3",
 "interfaceStatus": "disabled",
 "autoNegotiate": "off",
 <skip>
 }
 arista1#

如果我们有一个操作要执行,并且它取决于Ethernet1/3被禁用以确保没有用户影响,比如确保没有用户连接到Ethernet1/3,我们可以在chapter5_3.yml剧本中使用以下任务。它使用eos_command模块来收集接口状态输出,并在继续下一个任务之前使用waitforeq关键字来检查接口状态:

    <skip>
     tasks:
       - name: "sh int ethernet 1/3 | json"
         eos_command:
           commands:
             - "show interface ethernet 1/3 | json"
           provider: "{{ cli }}"
           waitfor:
             - "result[0].interfaces.Ethernet1/3.interfaceStatus eq 
    disabled"
         register: output
       - name: show output
         debug:
           msg: "Interface Disabled, Safe to Proceed"

在满足条件后,将执行第二个任务:

 TASK [sh int ethernet 1/3 | json] 
 **********************************************
 ok: [arista1]

 TASK [show output] 
 *************************************************************
 ok: [arista1] => {
 "msg": "Interface Disabled, Safe to Proceed"
 }

如果接口处于活动状态,则将在第一个任务后给出错误如下:

 TASK [sh int ethernet 1/3 | json] 
 **********************************************
 fatal: [arista1]: FAILED! => {"changed": false, "commands": ["show 
 interface ethernet 1/3 | json | json"], "failed": true, "msg": 
 "matched error in response: show interface ethernet 1/3 | json | 
 jsonrn% Invalid input (privileged mode required)rn********1>"}
 to retry, use: --limit 
 @/home/echou/Master_Python_Networking/Chapter5/chapter5_3.retry

 PLAY RECAP 
 ******************************************************************
 arista1 : ok=0 changed=0 unreachable=0 failed=1

查看其他条件,如包含大于小于,因为它们符合您的情况。

Ansible 循环

Ansible 在 playbook 中提供了许多循环,例如标准循环,循环文件,子元素,do-until 等等。在本节中,我们将看两种最常用的循环形式:标准循环和循环哈希值。

标准循环

playbook 中的标准循环经常用于轻松多次执行类似任务。标准循环的语法非常简单:{{ item }}变量是在with_items列表上循环的占位符。例如,看一下chapter5_4.yml playbook 中的以下部分:

      tasks:
        - name: echo loop items
          command: echo {{ item }}
          with_items: ['r1', 'r2', 'r3', 'r4', 'r5']   

它将使用相同的echo命令循环遍历五个列表项:

TASK [echo loop items] *********************************************************
changed: [192.168.199.185] => (item=r1)
changed: [192.168.199.185] => (item=r2)
changed: [192.168.199.185] => (item=r3)
changed: [192.168.199.185] => (item=r4)
changed: [192.168.199.185] => (item=r5)

我们将在chapter5_5.yml playbook 中将标准循环与网络命令模块相结合,以向设备添加多个 VLAN:

 tasks:
   - name: add vlans
     eos_config:
       lines:
           - vlan {{ item }}
       provider: "{{ cli }}"
     with_items:
         - 100
         - 200
         - 300

with_items列表也可以从变量中读取,这样可以更灵活地构建 playbook 的结构:

vars:
  vlan_numbers: [100, 200, 300]
<skip>
tasks:
  - name: add vlans
    eos_config:
      lines:
          - vlan {{ item }}
      provider: "{{ cli }}"
    with_items: "{{ vlan_numbers }}"

标准循环在执行 playbook 中的冗余任务时是一个很好的时间节省器。它还通过减少任务所需的行数使 playbook 更易读。

在下一节中,我们将看看如何循环遍历字典。

循环遍历字典

循环遍历一个简单的列表很好。然而,我们经常有一个带有多个属性的实体。如果您考虑上一节中的vlan示例,每个vlan都会有一些独特的属性,比如vlan描述,网关 IP 地址,可能还有其他属性。通常,我们可以使用字典来表示实体,以将多个属性合并到其中。

让我们在上一节中的vlan示例中扩展为chapter5_6.yml中的字典示例。我们为三个vlan定义了字典值,每个值都有一个嵌套字典,用于描述和 IP 地址:

    <skip> 
    vars:
       cli:
         host: "{{ ansible_host }}"
         username: "{{ username }}"
         password: "{{ password }}"
         transport: cli
       vlans: {
           "100": {"description": "floor_1", "ip": "192.168.10.1"},
           "200": {"description": "floor_2", "ip": "192.168.20.1"}
           "300": {"description": "floor_3", "ip": "192.168.30.1"}
       }

我们可以通过使用每个项目的键作为vlan号来配置第一个任务add vlans

     tasks:
       - name: add vlans
         nxos_config:
           lines:
             - vlan {{ item.key }}
           provider: "{{ cli }}"
         with_dict: "{{ vlans }}"

我们可以继续配置vlan接口。请注意,我们使用parents参数来唯一标识应该针对哪个部分检查命令。这是因为描述和 IP 地址都是在配置中的interface vlan <number>子部分下配置的:

  - name: configure vlans
    nxos_config:
       lines:
         - description {{ item.value.name }}
         - ip address {{ item.value.ip }}/24
       provider: "{{ cli }}"
       parents: interface vlan {{ item.key }}
    with_dict: "{{ vlans }}"

执行时,您将看到字典被循环遍历:

TASK [configure vlans] *********************************************************
changed: [nxos-r1] => (item={'key': u'300', 'value': {u'ip': u'192.168.30.1', u'name': u'floor_3'}})
changed: [nxos-r1] => (item={'key': u'200', 'value': {u'ip': u'192.168.20.1', u'name': u'floor_2'}})
changed: [nxos-r1] => (item={'key': u'100', 'value': {u'ip': u'192.168.10.1', u'name': u'floor_1'}})

让我们检查所需的配置是否应用到设备上:

nx-osv-1# sh run | i vlan
<skip>
vlan 1,10,100,200,300
nx-osv-1#
nx-osv-1# sh run | section "interface Vlan100"
interface Vlan100
 description floor_1
 ip address 192.168.10.1/24
nx-osv-1#

有关 Ansible 的更多循环类型,请随时查看文档(docs.ansible.com/ansible/playbooks_loops.html)。

循环遍历字典在第一次使用时需要一些练习。但就像标准循环一样,循环遍历字典将成为您工具箱中的一个宝贵工具。

模板

就我所记,作为一名网络工程师,我一直在使用一种网络模板。根据我的经验,许多网络设备的网络配置部分是相同的,特别是如果这些设备在网络中担任相同的角色。

大多数情况下,当我们需要为新设备进行配置时,我们使用相同的模板形式的配置,替换必要的字段,并将文件复制到新设备上。使用 Ansible,您可以使用模板模块(docs.ansible.com/ansible/template_module.html)自动化所有工作。

我们正在使用的基本模板文件利用了 Jinja2 模板语言(jinja.pocoo.org/docs/)。我们在第四章中简要讨论了 Jinja2 模板语言,Python 自动化框架- Ansible 基础,我们将在这里更多地了解它。就像 Ansible 一样,Jinja2 有自己的语法和循环和条件的方法;幸运的是,我们只需要了解它的基础知识就足够了。Ansible 模板是我们日常任务中将要使用的重要工具,我们将在本节中更多地探索它。我们将通过逐渐从简单到更复杂地构建我们的 playbook 来学习语法。

模板使用的基本语法非常简单;你只需要指定源文件和要复制到的目标位置。

现在我们将创建一个空文件:

$ touch file1

然后,我们将使用以下 playbook 将file1复制到file2。请注意,playbook 仅在控制机上执行。接下来,我们将为template模块的参数指定源文件和目标文件的路径:

---
- name: Template Basic
  hosts: localhost

  tasks:
    - name: copy one file to another
      template:
        src=./file1
        dest=./file2

在 playbook 执行期间,我们不需要指定主机文件,因为默认情况下 localhost 是可用的。但是,你会收到一个警告:

$ ansible-playbook chapter5_7.yml
 [WARNING]: provided hosts list is empty, only localhost is available
<skip>
TASK [copy one file to another] ************************************************

changed: [localhost]
<skip>

源文件可以有任何扩展名,但由于它们是通过 Jinja2 模板引擎处理的,让我们创建一个名为nxos.j2的文本文件作为模板源。模板将遵循 Jinja2 的惯例,使用双大括号来指定变量:

    hostname {{ item.value.hostname }}
    feature telnet
    feature ospf
    feature bgp
    feature interface-vlan

    username {{ item.value.username }} password {{ item.value.password 
    }} role network-operator

Jinja2 模板

让我们也相应地修改 playbook。在chapter5_8.yml中,我们将进行以下更改:

  1. 将源文件更改为nxos.j2

  2. 将目标文件更改为一个变量

  3. 提供作为字典的变量值,我们将在模板中进行替换:

    ---
    - name: Template Looping
      hosts: localhost

      vars:
        nexus_devices: {
          "nx-osv-1": {"hostname": "nx-osv-1", "username": "cisco", 
    "password": "cisco"}
        }

      tasks:
        - name: create router configuration files
          template:
            src=./nxos.j2
            dest=./{{ item.key }}.conf
          with_dict: "{{ nexus_devices }}"

运行 playbook 后,你会发现名为nx-osv-1.conf的目标文件已经填充好,可以使用了:

$ cat nx-osv-1.conf
hostname nx-osv-1

feature telnet
feature ospf
feature bgp
feature interface-vlan

username cisco password cisco role network-operator

Jinja2 循环

我们还可以在 Jinja2 中循环遍历列表和字典。我们将在nxos.j2中使用这两种循环:

    {% for vlan_num in item.value.vlans %}
    vlan {{ vlan_num }}
    {% endfor %}

    {% for vlan_interface in item.value.vlan_interfaces %}
    interface {{ vlan_interface.int_num }}
      ip address {{ vlan_interface.ip }}/24
    {% endfor %}

chapter5_8.yml playbook 中提供额外的列表和字典变量:

   vars:
     nexus_devices: {
       "nx-osv-1": {
       "hostname": "nx-osv-1",
       "username": "cisco",
       "password": "cisco",
       "vlans": [100, 200, 300],
       "vlan_interfaces": [
          {"int_num": "100", "ip": "192.168.10.1"},
          {"int_num": "200", "ip": "192.168.20.1"},
          {"int_num": "300", "ip": "192.168.30.1"}
        ]
       }
     }

运行 playbook,你会看到路由器配置中vlanvlan_interfaces的配置都已填写好。

Jinja2 条件

Jinja2 还支持if条件检查。让我们在某些设备上打开 netflow 功能的字段中添加这个条件。我们将在nxos.j2模板中添加以下内容:

    {% if item.value.netflow_enable %}
    feature netflow
    {% endif %}

我们将列出 playbook 中的差异:

    vars:
      nexus_devices: {
      <skip>
             "netflow_enable": True
      <skip>
     }

我们将采取的最后一步是通过将nxos.j2放置在true-false条件检查中,使其更具可扩展性。在现实世界中,我们往往会有多个设备了解vlan信息,但只有一个设备作为客户端主机的网关:

    {% if item.value.l3_vlan_interfaces %}
    {% for vlan_interface in item.value.vlan_interfaces %}
    interface {{ vlan_interface.int_num }}
     ip address {{ vlan_interface.ip }}/24
    {% endfor %}
    {% endif %}

我们还将在 playbook 中添加第二个设备,名为nx-osv-2

     vars:
       nexus_devices: {
       <skip>
         "nx-osv-2": {
           "hostname": "nx-osv-2",
           "username": "cisco",
           "password": "cisco",
           "vlans": [100, 200, 300],
           "l3_vlan_interfaces": False,
           "netflow_enable": False
         }
        <skip>
     }

我们现在准备运行我们的 playbook:

$ ansible-playbook chapter5_8.yml
 [WARNING]: provided hosts list is empty, only localhost is available. Note
that the implicit localhost does not match 'all'

PLAY [Template Looping] ********************************************************

TASK [Gathering Facts] *********************************************************
ok: [localhost]

TASK [create router configuration files] ***************************************
ok: [localhost] => (item={'value': {u'username': u'cisco', u'password': u'cisco', u'hostname': u'nx-osv-2', u'netflow_enable': False, u'vlans': [100, 200, 300], u'l3_vlan_interfaces': False}, 'key': u'nx-osv-2'})
ok: [localhost] => (item={'value': {u'username': u'cisco', u'password': u'cisco', u'hostname': u'nx-osv-1', u'vlan_interfaces': [{u'int_num': u'100', u'ip': u'192.168.10.1'}, {u'int_num': u'200', u'ip': u'192.168.20.1'}, {u'int_num': u'300', u'ip': u'192.168.30.1'}], u'netflow_enable': True, u'vlans': [100, 200, 300], u'l3_vlan_interfaces': True}, 'key': u'nx-osv-1'})

PLAY RECAP *********************************************************************
localhost : ok=2 changed=0 unreachable=0 failed=0

让我们检查两个配置文件的差异,以确保条件性的更改正在发生:

$ cat nx-osv-1.conf
hostname nx-osv-1

feature telnet
feature ospf
feature bgp
feature interface-vlan

feature netflow

username cisco password cisco role network-operator

vlan 100
vlan 200
vlan 300

interface 100
 ip address 192.168.10.1/24
interface 200
 ip address 192.168.20.1/24
interface 300
 ip address 192.168.30.1/24

$ cat nx-osv-2.conf
hostname nx-osv-2

feature telnet
feature ospf
feature bgp
feature interface-vlan

username cisco password cisco role network-operator

vlan 100
vlan 200
vlan 300

很整洁,对吧?这肯定可以为我们节省大量时间,以前需要重复复制和粘贴。对我来说,模板模块是一个重大的改变。几年前,这个模块就足以激励我学习和使用 Ansible。

我们的 playbook 变得有点长了。在下一节中,我们将看到如何通过将变量文件转移到组和目录中来优化 playbook。

组和主机变量

请注意,在之前的 playbookchapter5_8.yml中,我们在nexus_devices变量下的两个设备的用户名和密码变量中重复了自己:

    vars:
      nexus_devices: {
        "nx-osv-1": {
          "hostname": "nx-osv-1",
          "username": "cisco",
          "password": "cisco",
          "vlans": [100, 200, 300],
        <skip>
        "nx-osv-2": {
          "hostname": "nx-osv-2",
          "username": "cisco",
          "password": "cisco",
          "vlans": [100, 200, 300],
        <skip>

这并不理想。如果我们需要更新用户名和密码的值,我们需要记住在两个位置更新。这增加了管理负担,也增加了出错的机会。作为最佳实践,Ansible 建议我们使用group_varshost_vars目录来分离变量。

有关更多 Ansible 最佳实践,请查看docs.ansible.com/ansible/playbooks_best_practices.html

组变量

默认情况下,Ansible 将在与 playbook 同一目录中寻找组变量,称为group_vars,用于应用于组的变量。默认情况下,它将在清单文件中匹配组名的文件名。例如,如果我们在清单文件中有一个名为[nexus-devices]的组,我们可以在group_vars下有一个名为nexus-devices的文件,其中包含可以应用于该组的所有变量。

我们还可以使用名为all的特殊文件来包含应用于所有组的变量。

我们将利用此功能来处理我们的用户名和密码变量。首先,我们将创建group_vars目录:

$ mkdir group_vars

然后,我们可以创建一个名为all的 YAML 文件来包含用户名和密码:

$ cat group_vars/all
---
username: cisco
password: cisco

然后我们可以在 playbook 中使用变量:

    vars:
      nexus_devices: {
       "nx-osv-1": {
          "hostname": "nx-osv-1",
          "username": "{{ username }}",
          "password": "{{ password }}",
          "vlans": [100, 200, 300],
        <skip>
         "nx-osv-2": {
          "hostname": "nx-osv-2",
          "username": "{{ username }}",
          "password": "{{ password }}",
          "vlans": [100, 200, 300],
        <skip>

主机变量

我们可以进一步以与组变量相同的格式分离主机变量。这就是我们能够在第四章中应用变量的 Ansible 2.5 playbook 示例以及本章前面部分的方法:

$ mkdir host_vars

在我们的情况下,我们在本地主机上执行命令,因此host_vars下的文件应该相应地命名,例如host_vars/localhost。在我们的host_vars/localhost文件中,我们还可以保留在group_vars中声明的变量:

$ cat host_vars/localhost
---
"nexus_devices":
 "nx-osv-1":
 "hostname": "nx-osv-1"
 "username": "{{ username }}"
 "password": "{{ password }}"
 "vlans": [100, 200, 300]
 "l3_vlan_interfaces": True
 "vlan_interfaces": [
 {"int_num": "100", "ip": "192.168.10.1"},
 {"int_num": "200", "ip": "192.168.20.1"},
 {"int_num": "300", "ip": "192.168.30.1"}
 ]
 "netflow_enable": True

 "nx-osv-2":
 "hostname": "nx-osv-2"
 "username": "{{ username }}"
 "password": "{{ password }}"
 "vlans": [100, 200, 300]
 "l3_vlan_interfaces": False
 "netflow_enable": False

在我们分离变量之后,playbook 现在变得非常轻量,只包含我们操作的逻辑:

 $ cat chapter5_9.yml
 ---
 - name: Ansible Group and Host Variables
 hosts: localhost

 tasks:
 - name: create router configuration files
 template:
 src=./nxos.j2
 dest=./{{ item.key }}.conf
 with_dict: "{{ nexus_devices }}"

group_varshost_vars目录不仅减少了我们的操作开销,还可以通过允许我们使用 Ansible Vault 加密敏感信息来帮助保护文件,接下来我们将看一下。

Ansible Vault

从前一节中可以看出,在大多数情况下,Ansible 变量提供敏感信息,如用户名和密码。最好在变量周围采取一些安全措施,以便我们可以对其进行保护。Ansible Vault(docs.ansible.com/ansible/2.5/user_guide/vault.html)为文件提供加密,使其呈现为明文。

所有 Ansible Vault 函数都以ansible-vault命令开头。您可以通过 create 选项手动创建加密文件。系统会要求您输入密码。如果您尝试查看文件,您会发现文件不是明文。如果您已经下载了本书的示例,我使用的密码只是单词password

$ ansible-vault create secret.yml
Vault password: <password>

$ cat secret.yml
$ANSIBLE_VAULT;1.1;AES256
336564626462373962326635326361323639323635353630646665656430353261383737623<skip>653537333837383863636530356464623032333432386139303335663262
3962

编辑或查看加密文件,我们将使用edit选项编辑或通过view选项查看文件:

$ ansible-vault edit secret.yml 
Vault password:

$ ansible-vault view secret.yml 
Vault password:

让我们加密group_vars/allhost_vars/localhost变量文件:

$ ansible-vault encrypt group_vars/all host_vars/localhost
Vault password:
Encryption successful

现在,当我们运行 playbook 时,我们将收到解密失败的错误消息:

ERROR! Decryption failed on /home/echou/Master_Python_Networking/Chapter5/Vaults/group_vars/all

当我们运行 playbook 时,我们需要使用--ask-vault-pass选项:

$ ansible-playbook chapter5_10.yml --ask-vault-pass
Vault password:

对于任何访问的 Vault 加密文件,解密将在内存中进行。

在 Ansible 2.4 之前,Ansible Vault 要求所有文件都使用相同的密码进行加密。自 Ansible 2.4 及以后版本,您可以使用 vault ID 来提供不同的密码文件(docs.ansible.com/ansible/2.5/user_guide/vault.html#multiple-vault-passwords)。

我们还可以将密码保存在文件中,并确保特定文件具有受限权限:

$ chmod 400 ~/.vault_password.txt
$ ls -lia ~/.vault_password.txt 
809496 -r-------- 1 echou echou 9 Feb 18 12:17 /home/echou/.vault_password.txt

然后,我们可以使用--vault-password-file选项执行 playbook:

$ ansible-playbook chapter5_10.yml --vault-password-file ~/.vault_password.txt

我们还可以仅加密一个字符串,并使用encrypt_string选项将加密的字符串嵌入到 playbook 中(docs.ansible.com/ansible/2.5/user_guide/vault.html#use-encrypt-string-to-create-encrypted-variables-to-embed-in-yaml):

$ ansible-vault encrypt_string
New Vault password:
Confirm New Vault password:
Reading plaintext input from stdin. (ctrl-d to end input)
new_user_password
!vault |
 $ANSIBLE_VAULT;1.1;AES256
 616364386438393262623139623561613539656664383834643338323966623836343737373361326134663232623861313338383534613865303864616364380a626365393665316133616462643831653332663263643734363863666632636464636563616265303665626364636562316635636462323135663163663331320a62356361326639333165393962663962306630303761656435633966633437613030326633336438366264626464366138323666376239656633623233353832

Encryption successful

然后可以将字符串放置在 playbook 文件中作为变量。在下一节中,我们将使用includeroles进一步优化我们的 playbook。

Ansible 包括和角色

处理复杂任务的最佳方法是将它们分解成更小的部分。当然,这种方法在 Python 和网络工程中都很常见。在 Python 中,我们将复杂的代码分解成函数、类、模块和包。在网络中,我们也将大型网络分成机架、行、集群和数据中心等部分。在 Ansible 中,我们可以使用rolesincludes将大型 playbook 分割和组织成多个文件。拆分大型 Ansible playbook 简化了结构,因为每个文件都专注于较少的任务。它还允许 playbook 的各个部分被重复使用。

Ansible 包含语句

随着 playbook 的规模不断增长,最终会显而易见,许多任务和操作可以在不同的 playbook 之间共享。Ansibleinclude语句类似于许多 Linux 配置文件,只是告诉机器扩展文件的方式与直接编写文件的方式相同。我们可以在 playbook 和任务中使用 include 语句。在这里,我们将看一个扩展我们任务的简单示例。

假设我们想要显示两个不同 playbook 的输出。我们可以制作一个名为show_output.yml的单独的 YAML 文件作为附加任务:

    ---
    - name: show output
        debug:
          var: output

然后,我们可以在多个 playbook 中重用此任务,例如在chapter5_11_1.yml中,它与上一个 playbook 几乎相同,只是在最后注册输出和包含语句方面有所不同:

    ---
    - name: Ansible Group and Host Varibles
      hosts: localhost

      tasks:
        - name: create router configuration files
          template:
            src=./nxos.j2
            dest=./{{ item.key }}.conf
          with_dict: "{{ nexus_devices }}"
          register: output

        - include: show_output.yml

另一个 playbook,chapter5_11_2.yml,可以以相同的方式重用show_output.yml

    ---
    - name: show users
      hosts: localhost

      tasks:
        - name: show local users
          command: who
          register: output

        - include: show_output.yml

请注意,两个 playbook 使用相同的变量名output,因为在show_output.yml中,我们为简单起见硬编码了变量名。您还可以将变量传递到包含的文件中。

Ansible 角色

Ansible 角色将逻辑功能与物理主机分开,以更好地适应您的网络。例如,您可以构建角色,如 spines、leafs、core,以及 Cisco、Juniper 和 Arista。同一物理主机可以属于多个角色;例如,设备可以同时属于 Juniper 和核心。这种灵活性使我们能够执行操作,例如升级所有 Juniper 设备,而不必担心设备在网络层中的位置。

Ansible 角色可以根据已知的文件基础结构自动加载某些变量、任务和处理程序。关键是这是一个已知的文件结构,我们会自动包含。实际上,您可以将角色视为 Ansible 预先制作的include语句。

Ansible playbook 角色文档(docs.ansible.com/ansible/playbooks_roles.html#roles)描述了我们可以配置的角色目录列表。我们不需要使用所有这些目录。在我们的示例中,我们只会修改“tasks 和 vars”文件夹。但是,了解 Ansible 角色目录结构中所有可用选项是很好的。

以下是我们将用作角色示例的内容:

├── chapter5_12.yml
├── chapter5_13.yml
├── hosts
└── roles
 ├── cisco_nexus
 │   ├── defaults
 │   ├── files
 │   ├── handlers
 │   ├── meta
 │   ├── tasks
 │   │   └── main.yml
 │   ├── templates
 │   └── vars
 │       └── main.yml
 └── spines
 ├── defaults
 ├── files
 ├── handlers
 ├── tasks
 │   └── main.yml
 ├── templates
 └── vars
 └── main.yml

您可以看到,在顶层,我们有主机文件以及 playbooks。我们还有一个名为roles的文件夹。在文件夹内,我们定义了两个角色:cisco_nexusspines。大多数角色下的子文件夹都是空的,除了“tasks 和 vars”文件夹。每个文件夹内都有一个名为main.yml的文件。这是默认行为:main.yml 文件是您在 playbook 中指定角色时自动包含的入口点。如果您需要拆分其他文件,可以在 main.yml 文件中使用 include 语句。

这是我们的情景:

  • 我们有两个 Cisco Nexus 设备,nxos-r1nxos-r2。我们将为它们所有配置日志服务器以及日志链路状态,利用cisco_nexus角色。

  • 此外,nxos-r1 也是一个脊柱设备,我们将希望配置更详细的日志记录,也许是因为脊柱在我们的网络中处于更关键的位置。

对于我们的cisco_nexus角色,我们在roles/cisco_nexus/vars/main.yml中有以下变量:

---
cli:
  host: "{{ ansible_host }}"
  username: cisco
  password: cisco
  transport: cli

我们在roles/cisco_nexus/tasks/main.yml中有以下配置任务:

---
- name: configure logging parameters
  nxos_config:
    lines:
      - logging server 191.168.1.100
      - logging event link-status default
    provider: "{{ cli }}"

我们的 playbook 非常简单,因为它只需要指定我们想要根据cisco_nexus角色配置的主机:

---
- name: playbook for cisco_nexus role
  hosts: "cisco_nexus"
  gather_facts: false
  connection: local

  roles:
    - cisco_nexus

当您运行 playbook 时,playbook 将包括在cisco_nexus角色中定义的任务和变量,并相应地配置设备。

对于我们的spine角色,我们将在roles/spines/tasks/mail.yml中有一个额外的更详细的日志记录任务:

---
- name: change logging level
  nxos_config:
    lines:
      - logging level local7 7
    provider: "{{ cli }}"

在我们的 playbook 中,我们可以指定它包含cisco_nexus角色和spines角色:

---
- name: playbook for spine role
  hosts: "spines"
  gather_facts: false
  connection: local

  roles:
    - cisco_nexus
    - spines

当我们按照这个顺序包括这两个角色时,cisco_nexus角色任务将被执行,然后是 spines 角色:

TASK [cisco_nexus : configure logging parameters] ******************************
changed: [nxos-r1]

TASK [spines : change logging level] *******************************************
ok: [nxos-r1]

Ansible 角色是灵活和可扩展的,就像 Python 函数和类一样。一旦您的代码增长到一定程度,将其分解成更小的部分以便维护几乎总是一个好主意。

您可以在 Ansible 示例 Git 存储库中找到更多角色的示例,网址为github.com/ansible/ansible-examples

Ansible Galaxy (docs.ansible.com/ansible/latest/reference_appendices/galaxy.html)是一个免费的社区网站,用于查找、共享和协作角色。您可以在 Ansible Galaxy 上查看由 Juniper 网络提供的 Ansible 角色的示例:

JUNOS Role on Ansible Galaxy ( galaxy.ansible.com/Juniper/junos)

在下一节中,我们将看一下如何编写我们自己的自定义 Ansible 模块。

编写您自己的自定义模块

到目前为止,您可能会感到 Ansible 中的网络管理在很大程度上取决于找到适合您任务的正确模块。这种逻辑中肯定有很多道理。模块提供了一种抽象管理主机和控制机之间交互的方式;它们允许我们专注于我们操作的逻辑。到目前为止,我们已经看到主要供应商为 Cisco、Juniper 和 Arista 提供了各种模块。

以 Cisco Nexus 模块为例,除了特定任务,如管理 BGP 邻居(nxos_bgp)和 aaa 服务器(nxos_aaa_server)。大多数供应商还提供了运行任意 show(nxos_config)和配置命令(nxos_config)的方法。这通常涵盖了我们大部分的用例。

从 Ansible 2.5 开始,还有网络事实模块的简化命名和用法。

如果您使用的设备当前没有您正在寻找的任务的模块怎么办?在本节中,我们将看一下几种方法,通过编写我们自己的自定义模块来解决这种情况。

第一个自定义模块

编写自定义模块并不需要复杂;实际上,它甚至不需要用 Python 编写。但是由于我们已经熟悉 Python,我们将使用 Python 来编写我们的自定义模块。我们假设该模块是我们自己和我们的团队将使用的,而不需要提交给 Ansible,因此我们将暂时忽略一些文档和格式。

如果您有兴趣开发可以提交到 Ansible 的模块,请参阅 Ansible 的模块开发指南(docs.ansible.com/ansible/latest/dev_guide/developing_modules.html)。

默认情况下,如果我们在与 playbook 相同的目录中创建一个名为library的文件夹,Ansible 将包括该目录在模块搜索路径中。因此,我们可以将我们的自定义模块放在该目录中,并且我们将能够在我们的 playbook 中使用它。自定义模块的要求非常简单:模块只需要返回 JSON 输出给 playbook。

回想一下,在第三章 API 和意图驱动的网络中,我们使用以下 NXAPI Python 脚本与 NX-OS 设备进行通信:

    import requests
    import json

    url='http://172.16.1.142/ins'
    switchuser='cisco'
    switchpassword='cisco'

    myheaders={'content-type':'application/json-rpc'}
    payload=[
     {
       "jsonrpc": "2.0",
       "method": "cli",
       "params": {
         "cmd": "show version",
         "version": 1.2
       },
       "id": 1
     }
    ]
    response = requests.post(url,data=json.dumps(payload),   
    headers=myheaders,auth=(switchuser,switchpassword)).json()

    print(response['result']['body']['sys_ver_str'])

当我们执行它时,我们只是收到了系统版本。我们可以简单地修改最后一行为 JSON 输出,如下面的代码所示:

    version = response['result']['body']['sys_ver_str']
    print json.dumps({"version": version})

我们将把这个文件放在library文件夹下:

$ ls -a library/
. .. custom_module_1.py

在我们的剧本中,我们可以使用动作插件(docs.ansible.com/ansible/dev_guide/developing_plugins.html) chapter5_14.yml来调用这个自定义模块:

    ---
    - name: Your First Custom Module
      hosts: localhost
      gather_facts: false
      connection: local

      tasks:
        - name: Show Version
          action: custom_module_1
          register: output

        - debug:
            var: output

请注意,就像ssh连接一样,我们正在本地执行模块,并且模块正在进行 API 调用。当你执行这个剧本时,你将得到以下输出:

$ ansible-playbook chapter5_14.yml
 [WARNING]: provided hosts list is empty, only localhost is available

PLAY [Your First Custom Module] ************************************************

TASK [Show Version] ************************************************************
ok: [localhost]

TASK [debug] *******************************************************************
ok: [localhost] => {
 "output": {
 "changed": false,
 "version": "7.3(0)D1(1)"
 }
}

PLAY RECAP *********************************************************************
localhost : ok=2 changed=0 unreachable=0 failed=0

正如你所看到的,你可以编写任何受 API 支持的模块,Ansible 将乐意接受任何返回的 JSON 输出。

第二个自定义模块

在上一个模块的基础上,让我们利用 Ansible 中的常见模块样板,该样板在模块开发文档中有说明(docs.ansible.com/ansible/dev_guide/developing_modules_general.html)。我们将修改最后一个自定义模块,并创建custom_module_2.py来接收剧本中的输入。

首先,我们将从ansible.module_utils.basic导入样板代码:

    from ansible.module_utils.basic import AnsibleModule

    if __name__ == '__main__':
        main()

然后,我们可以定义主要函数,我们将在其中放置我们的代码。AnsibleModule,我们已经导入了,提供了处理返回和解析参数的常见代码。在下面的示例中,我们将解析hostusernamepassword三个参数,并将它们作为必填字段:

    def main():
        module = AnsibleModule(
          argument_spec = dict(
          host = dict(required=True),
          username = dict(required=True),
          password = dict(required=True)
      )
    )

然后,可以检索这些值并在我们的代码中使用:

     device = module.params.get('host')
     username = module.params.get('username')
     password = module.params.get('password')

     url='http://' + host + '/ins'
     switchuser=username
     switchpassword=password

最后,我们将跟踪退出代码并返回值:

    module.exit_json(changed=False, msg=str(data))

我们的新剧本chapter5_15.yml将与上一个剧本相同,只是现在我们可以在剧本中为不同的设备传递值:

     tasks:
       - name: Show Version
         *action: custom_module_1 host="172.16.1.142" username="cisco"* 
 *password="cisco"*
         register: output

当执行时,这个剧本将产生与上一个剧本完全相同的输出。但是,因为我们在自定义模块中使用了参数,所以现在可以将自定义模块传递给其他人使用,而不需要他们了解我们模块的细节。他们可以在剧本中写入自己的用户名、密码和主机 IP。

当然,这是一个功能齐全但不完整的模块。首先,我们没有进行任何错误检查,也没有为使用提供任何文档。但是,这是一个很好的演示,展示了构建自定义模块有多么容易。额外的好处是,我们看到了如何使用我们已经制作的现有脚本,并将其转换为自定义的 Ansible 模块。

总结

在本章中,我们涵盖了很多内容。基于我们之前对 Ansible 的了解,我们扩展到了更高级的主题,如条件、循环和模板。我们看了如何通过主机变量、组变量、包含语句和角色使我们的剧本更具可扩展性。我们还看了如何使用 Ansible Vault 保护我们的剧本。最后,我们使用 Python 制作了自己的自定义模块。

Ansible 是一个非常灵活的 Python 框架,可以用于网络自动化。它提供了另一个抽象层,与 Pexpect 和基于 API 的脚本分开。它在性质上是声明式的,更具表达性,符合我们的意图。根据你的需求和网络环境,它可能是你可以用来节省时间和精力的理想框架。

在第六章 使用 Python 进行网络安全中,我们将使用 Python 进行网络安全。

第六章:使用 Python 进行网络安全

在我看来,网络安全是一个难以撰写的话题。原因不是技术上的,而是与设定正确的范围有关。网络安全的边界如此之广,以至于它们触及 OSI 模型的所有七层。从窃听的第 1 层到传输协议漏洞的第 4 层,再到中间人欺骗的第 7 层,网络安全无处不在。问题加剧了所有新发现的漏洞,有时似乎以每日的速度出现。这甚至没有包括网络安全的人为社会工程方面。

因此,在本章中,我想设定我们将讨论的范围。与迄今为止一样,我们将主要专注于使用 Python 来处理 OSI 第 3 和第 4 层的网络设备安全。我们将研究可以用于管理个别网络设备以实现安全目的的 Python 工具,以及使用 Python 作为连接不同组件的粘合剂。希望我们可以通过在不同的 OSI 层中使用 Python 来全面地处理网络安全。

在本章中,我们将研究以下主题:

  • 实验室设置

  • Python Scapy 用于安全测试

  • 访问列表

  • 使用 Python 进行 Syslog 和 UFW 的取证分析

  • 其他工具,如 MAC 地址过滤列表、私有 VLAN 和 Python IP 表绑定。

实验室设置

本章中使用的设备与之前的章节有些不同。在之前的章节中,我们通过专注于手头的主题来隔离特定的设备。对于本章,我们将在我们的实验室中使用更多的设备,以便说明我们将使用的工具的功能。连接和操作系统信息很重要,因为它们对我们稍后将展示的安全工具产生影响。例如,如果我们想应用访问列表来保护服务器,我们需要知道拓扑图是什么样的,客户端的连接方向是什么。Ubuntu 主机的连接与我们迄今为止看到的有些不同,因此如果需要,当您稍后看到示例时,请参考本实验室部分。

我们将使用相同的 Cisco VIRL 工具,其中包括四个节点:两个主机和两个网络设备。如果您需要关于 Cisco VIRL 的复习,请随时返回到第二章,低级网络设备交互,我们在那里首次介绍了这个工具:

实验拓扑图列出的 IP 地址在您自己的实验室中将是不同的。它们在这里列出,以便在本章的其余部分中进行简单参考。

如图所示,我们将把顶部的主机重命名为客户端,底部的主机重命名为服务器。这类似于互联网客户端试图在我们的网络中访问公司服务器。我们将再次使用共享平面网络选项来访问设备进行带外管理:

对于两个交换机,我将选择开放最短路径优先OSPF)作为IGP,并将两个设备放入区域0。默认情况下,BGP已打开,并且两个设备都使用 AS 1。从配置自动生成中,连接到 Ubuntu 主机的接口被放入 OSPF 区域1,因此它们将显示为区间路由。NX-OSv 的配置如下所示,IOSv 的配置和输出类似:

 interface Ethernet2/1
 description to iosv-1
 no switchport
 mac-address fa16.3e00.0001
 ip address 10.0.0.6/30
 ip router ospf 1 area 0.0.0.0
 no shutdown

 interface Ethernet2/2
 description to Client
 no switchport
 mac-address fa16.3e00.0002
 ip address 10.0.0.9/30
 ip router ospf 1 area 0.0.0.0
 no shutdown

 nx-osv-1# sh ip route
 <skip>
 10.0.0.12/30, ubest/mbest: 1/0
 *via 10.0.0.5, Eth2/1, [110/41], 04:53:02, ospf-1, intra
 192.168.0.2/32, ubest/mbest: 1/0
 *via 10.0.0.5, Eth2/1, [110/41], 04:53:02, ospf-1, intra
 <skip>

OSPF 邻居和 NX-OSv 的 BGP 输出如下所示,IOSv 的输出类似:

nx-osv-1# sh ip ospf neighbors
 OSPF Process ID 1 VRF default
 Total number of neighbors: 1
 Neighbor ID Pri State Up Time Address Interface
 192.168.0.2 1 FULL/DR 04:53:00 10.0.0.5 Eth2/1

nx-osv-1# sh ip bgp summary
BGP summary information for VRF default, address family IPv4 Unicast
BGP router identifier 192.168.0.1, local AS number 1
BGP table version is 5, IPv4 Unicast config peers 1, capable peers 1
2 network entries and 2 paths using 288 bytes of memory
BGP attribute entries [2/288], BGP AS path entries [0/0]
BGP community entries [0/0], BGP clusterlist entries [0/0]

Neighbor V AS MsgRcvd MsgSent TblVer InQ OutQ Up/Down State/PfxRcd
192.168.0.2 4 1 321 297 5 0 0 04:52:56 1

我们网络中的主机正在运行 Ubuntu 14.04,与迄今为止我们一直在使用的 Ubuntu VM 16.04 类似:

cisco@Server:~$ lsb_release -a
No LSB modules are available.
Distributor ID: Ubuntu
Description: Ubuntu 14.04.2 LTS
Release: 14.04
Codename: trusty

在两台 Ubuntu 主机上,有两个网络接口,eth0eth1eth0连接到管理网络(172.16.1.0/24),而eth1连接到网络设备(10.0.0.x/30)。设备环回的路由直接连接到网络块,远程主机网络通过默认路由静态路由到eth1

cisco@Client:~$ route -n
Kernel IP routing table
Destination Gateway Genmask Flags Metric Ref Use Iface
0.0.0.0 172.16.1.2 0.0.0.0 UG 0 0 0 eth0
10.0.0.4 10.0.0.9 255.255.255.252 UG 0 0 0 eth1
10.0.0.8 0.0.0.0 255.255.255.252 U 0 0 0 eth1
10.0.0.8 10.0.0.9 255.255.255.248 UG 0 0 0 eth1
172.16.1.0 0.0.0.0 255.255.255.0 U 0 0 0 eth0
192.168.0.1 10.0.0.9 255.255.255.255 UGH 0 0 0 eth1
192.168.0.2 10.0.0.9 255.255.255.255 UGH 0 0 0 eth1

为了验证客户端到服务器的路径,让我们 ping 和跟踪路由,确保我们的主机之间的流量通过网络设备而不是默认路由:

## Our server IP is 10.0.0.14 cisco@Server:~$ ifconfig
<skip>
eth1 Link encap:Ethernet HWaddr fa:16:3e:d6:83:02
 inet addr:10.0.0.14 Bcast:10.0.0.15 Mask:255.255.255.252

## From the client ping toward server
cisco@Client:~$ ping -c 1 10.0.0.14
PING 10.0.0.14 (10.0.0.14) 56(84) bytes of data.
64 bytes from 10.0.0.14: icmp_seq=1 ttl=62 time=6.22 ms

--- 10.0.0.14 ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 6.223/6.223/6.223/0.000 ms

## Traceroute from client to server
cisco@Client:~$ traceroute 10.0.0.14
traceroute to 10.0.0.14 (10.0.0.14), 30 hops max, 60 byte packets
 1 10.0.0.9 (10.0.0.9) 11.335 ms 11.745 ms 12.113 ms
 2 10.0.0.5 (10.0.0.5) 24.221 ms 41.635 ms 41.638 ms
 3 10.0.0.14 (10.0.0.14) 37.916 ms 38.275 ms 38.588 ms
cisco@Client:~$

太好了!我们有了实验室,现在准备使用 Python 来查看一些安全工具和措施。

Python Scapy

Scapy(scapy.net)是一个功能强大的基于 Python 的交互式数据包构建程序。除了一些昂贵的商业程序外,据我所知,很少有工具可以做到 Scapy 所能做的。这是我在 Python 中最喜欢的工具之一。

Scapy 的主要优势在于它允许您从非常基本的级别构建自己的数据包。用 Scapy 的创作者的话来说:

“Scapy 是一个功能强大的交互式数据包操作程序。它能够伪造或解码大量协议的数据包,将它们发送到网络上,捕获它们,匹配请求和响应,等等……与大多数其他工具不同,您不会构建作者没有想象到的东西。这些工具是为了特定的目标而构建的,不能偏离太多。”

让我们来看看这个工具。

安装 Scapy

在撰写本文时,Scapy 2.3.1 支持 Python 2.7。不幸的是,关于 Scapy 对 Python 3 的支持出现了一些问题,对于 Scapy 2.3.3 来说,这仍然是相对较新的。对于您的环境,请随时尝试使用版本 2.3.3 及更高版本的 Python 3。在本章中,我们将使用 Python 2.7 的 Scapy 2.3.1。如果您想了解选择背后的原因,请参阅信息侧栏。

关于 Scapy 在 Python 3 中的支持的长篇故事是,2015 年有一个独立的 Scapy 分支,旨在仅支持 Python 3。该项目被命名为Scapy3k。该分支与主要的 Scapy 代码库分道扬镳。如果您阅读本书的第一版,那是写作时提供的信息。关于 PyPI 上的python3-scapy和 Scapy 代码库的官方支持存在混淆。我们的主要目的是在本章中了解 Scapy,因此我选择使用较旧的基于 Python 2 的 Scapy 版本。

在我们的实验室中,由于我们正在从客户端向目标服务器构建数据包源,因此需要在客户端上安装 Scapy:

cisco@Client:~$ sudo apt-get update
cisco@Client:~$ sudo apt-get install git
cisco@Client:~$ git clone https://github.com/secdev/scapy
cisco@Client:~$ cd scapy/
cisco@Client:~/scapy$ sudo python setup.py install

这是一个快速测试,以确保软件包已正确安装:

cisco@Client:~/scapy$ python
Python 2.7.6 (default, Mar 22 2014, 22:59:56)
[GCC 4.8.2] on linux2
Type "help", "copyright", "credits" or "license" for more information.
>>> from scapy.all import *

交互式示例

在我们的第一个示例中,我们将在客户端上构建一个Internet 控制消息协议ICMP)数据包,并将其发送到服务器。在服务器端,我们将使用tcpdump和主机过滤器来查看传入的数据包:

## Client Side
cisco@Client:~/scapy$ sudo scapy
<skip>
Welcome to Scapy (2.3.3.dev274)
>>> send(IP(dst="10.0.0.14")/ICMP())
.
Sent 1 packets.
>>>

## Server Side
cisco@Server:~$ sudo tcpdump -i eth1 host 10.0.0.10
tcpdump: verbose output suppressed, use -v or -vv for full protocol decode
listening on eth1, link-type EN10MB (Ethernet), capture size 65535 bytes
02:45:16.400162 IP 10.0.0.10 > 10.0.0.14: ICMP echo request, id 0, seq 0, length 8
02:45:16.400192 IP 10.0.0.14 > 10.0.0.10: ICMP echo reply, id 0, seq 0, length 8

正如您所看到的,使用 Scapy 构建数据包非常简单。Scapy 允许您使用斜杠(/)作为分隔符逐层构建数据包。send函数在第 3 层级别操作,负责路由和第 2 层级别。还有一个sendp()替代方案,它在第 2 层级别操作,这意味着您需要指定接口和链路层协议。

让我们通过使用发送请求(sr)函数来捕获返回的数据包。我们使用sr的特殊变体,称为sr1,它只返回一个回答发送的数据包:

>>> p = sr1(IP(dst="10.0.0.14")/ICMP())
>>> p
<IP version=4L ihl=5L tos=0x0 len=28 id=26713 flags= frag=0L ttl=62 proto=icmp chksum=0x71 src=10.0.0.14 dst=10.0.0.10 options=[] |<ICMP type=echo-reply code=0 chksum=0xffff id=0x0 seq=0x0 |>>

需要注意的一点是,sr()函数本身返回一个包含已回答和未回答列表的元组:

>>> p = sr(IP(dst="10.0.0.14")/ICMP()) 
>>> type(p)
<type 'tuple'>

## unpacking
>>> ans,unans = sr(IP(dst="10.0.0.14")/ICMP())
>>> type(ans)
<class 'scapy.plist.SndRcvList'>
>>> type(unans)
<class 'scapy.plist.PacketList'>

如果我们只看已回答的数据包列表,我们可以看到它是另一个包含我们发送的数据包以及返回的数据包的元组:

>>> for i in ans:
...     print(type(i))
...
<type 'tuple'>
>>> for i in ans:
...     print i
...
(<IP frag=0 proto=icmp dst=10.0.0.14 |<ICMP |>>, <IP version=4L ihl=5L tos=0x0 len=28 id=27062 flags= frag=0L ttl=62 proto=icmp chksum=0xff13 src=10.0.0.14 dst=10.0.0.10 options=[] |<ICMP type=echo-reply code=0 chksum=0xffff id=0x0 seq=0x0 |>>)

Scapy 还提供了一个第 7 层的构造,比如DNS查询。在下面的例子中,我们正在查询一个开放的 DNS 服务器来解析www.google.com

>>> p = sr1(IP(dst="8.8.8.8")/UDP()/DNS(rd=1,qd=DNSQR(qname="www.google.com")))
>>> p
<IP version=4L ihl=5L tos=0x0 len=76 id=21743 flags= frag=0L ttl=128 proto=udp chksum=0x27fa src=8.8.8.8 dst=172.16.1.152 options=[] |<UDP sport=domain dport=domain len=56 chksum=0xc077 |<DNS id=0 qr=1L opcode=QUERY aa=0L tc=0L rd=1L ra=1L z=0L ad=0L cd=0L rcode=ok qdcount=1 ancount=1 nscount=0 arcount=0 qd=<DNSQR qname='www.google.com.' qtype=A qclass=IN |> an=<DNSRR rrname='www.google.com.' type=A rclass=IN ttl=299 rdata='172.217.3.164' |> ns=None ar=None |>>>
>>>

嗅探

Scapy 还可以用于轻松捕获网络上的数据包:

>>> a = sniff(filter="icmp and host 172.217.3.164", count=5)
>>> a.show()
0000 Ether / IP / TCP 192.168.225.146:ssh > 192.168.225.1:50862 PA / Raw
0001 Ether / IP / ICMP 192.168.225.146 > 172.217.3.164 echo-request 0 / Raw
0002 Ether / IP / ICMP 172.217.3.164 > 192.168.225.146 echo-reply 0 / Raw
0003 Ether / IP / ICMP 192.168.225.146 > 172.217.3.164 echo-request 0 / Raw
0004 Ether / IP / ICMP 172.217.3.164 > 192.168.225.146 echo-reply 0 / Raw
>>>

我们可以更详细地查看数据包,包括原始格式:

>>> for i in a:
...     print i.show()
...
<skip>
###[ Ethernet ]###
 dst= <>
 src= <>
 type= 0x800
###[ IP ]###
 version= 4L
 ihl= 5L
 tos= 0x0
 len= 84
 id= 15714
 flags= DF
 frag= 0L
 ttl= 64
 proto= icmp
 chksum= 0xaa8e
 src= 192.168.225.146
 dst= 172.217.3.164
 options
###[ ICMP ]###
 type= echo-request
 code= 0
 chksum= 0xe1cf
 id= 0xaa67
 seq= 0x1
###[ Raw ]###
 load= 'xd6xbfxb1Xx00x00x00x00x1axdcnx00x00x00x00x00x10x11x12x13x14x15x16x17x18x19x1ax1bx1cx1dx1ex1f !"#$%&'()*+,-./01234567'
None

我们已经看到了 Scapy 的基本工作原理。让我们继续看看如何使用 Scapy 进行一些常见的安全测试。

TCP 端口扫描

任何潜在黑客的第一步几乎总是尝试了解网络上开放的服务,这样他们就可以集中精力进行攻击。当然,我们需要打开某些端口以为客户提供服务;这是我们需要接受的风险的一部分。但我们还应该关闭任何不必要暴露更大攻击面的其他开放端口。我们可以使用 Scapy 对我们自己的主机进行简单的 TCP 开放端口扫描。

我们可以发送一个SYN数据包,看服务器是否会返回SYN-ACK

>>> p = sr1(IP(dst="10.0.0.14")/TCP(sport=666,dport=23,flags="S"))
>>> p.show()
###[ IP ]###
 version= 4L
 ihl= 5L
 tos= 0x0
 len= 40
 id= 25373
 flags= DF
 frag= 0L
 ttl= 62
 proto= tcp
 chksum= 0xc59b
 src= 10.0.0.14
 dst= 10.0.0.10
 options
###[ TCP ]###
 sport= telnet
 dport= 666
 seq= 0
 ack= 1
 dataofs= 5L
 reserved= 0L
 flags= RA
 window= 0
 chksum= 0x9907
 urgptr= 0
 options= {}

请注意,在这里的输出中,服务器对 TCP 端口23响应了RESET+ACK。然而,TCP 端口22(SSH)是开放的;因此返回了SYN-ACK

>>> p = sr1(IP(dst="10.0.0.14")/TCP(sport=666,dport=22,flags="S"))
>>> p.show()
###[ IP ]###
 version= 4L
<skip>
 proto= tcp
 chksum= 0x28b5
 src= 10.0.0.14
 dst= 10.0.0.10
 options
###[ TCP ]###
 sport= ssh
 dport= 666
<skip>
 flags= SA
<skip>

我们还可以扫描从2022的一系列目标端口;请注意,我们使用sr()进行发送-接收,而不是sr1()发送-接收一个数据包的变体:

>>> ans,unans = sr(IP(dst="10.0.0.14")/TCP(sport=666,dport=(20,22),flags="S"))
>>> for i in ans:
...     print i
...
(<IP frag=0 proto=tcp dst=10.0.0.14 |<TCP sport=666 dport=ftp_data flags=S |>>, <IP version=4L ihl=5L tos=0x0 len=40 id=4126 flags=DF frag=0L ttl=62 proto=tcp chksum=0x189b src=10.0.0.14 dst=10.0.0.10 options=[] |<TCP sport=ftp_data dport=666 seq=0 ack=1 dataofs=5L reserved=0L flags=RA window=0 chksum=0x990a urgptr=0 |>>)
(<IP frag=0 proto=tcp dst=10.0.0.14 |<TCP sport=666 dport=ftp flags=S |>>, <IP version=4L ihl=5L tos=0x0 len=40 id=4127 flags=DF frag=0L ttl=62 proto=tcp chksum=0x189a src=10.0.0.14 dst=10.0.0.10 options=[] |<TCP sport=ftp dport=666 seq=0 ack=1 dataofs=5L reserved=0L flags=RA window=0 chksum=0x9909 urgptr=0 |>>)
(<IP frag=0 proto=tcp dst=10.0.0.14 |<TCP sport=666 dport=ssh flags=S |>>, <IP version=4L ihl=5L tos=0x0 len=44 id=0 flags=DF frag=0L ttl=62 proto=tcp chksum=0x28b5 src=10.0.0.14 dst=10.0.0.10 options=[] |<TCP sport=ssh dport=666 seq=4187384571 ack=1 dataofs=6L reserved=0L flags=SA window=29200 chksum=0xaaab urgptr=0 options=[('MSS', 1460)] |>>)
>>>

我们还可以指定目标网络而不是单个主机。从10.0.0.8/29块中可以看到,主机10.0.0.910.0.0.1310.0.0.14返回了SA,这对应于两个网络设备和主机:

>>> ans,unans = sr(IP(dst="10.0.0.8/29")/TCP(sport=666,dport=(22),flags="S"))
>>> for i in ans:
...     print(i)
...
(<IP frag=0 proto=tcp dst=10.0.0.9 |<TCP sport=666 dport=ssh flags=S |>>, <IP version=4L ihl=5L tos=0x0 len=44 id=7304 flags= frag=0L ttl=64 proto=tcp chksum=0x4a32 src=10.0.0.9 dst=10.0.0.10 options=[] |<TCP sport=ssh dport=666 seq=541401209 ack=1 dataofs=6L reserved=0L flags=SA window=17292 chksum=0xfd18 urgptr=0 options=[('MSS', 1444)] |>>)
(<IP frag=0 proto=tcp dst=10.0.0.14 |<TCP sport=666 dport=ssh flags=S |>>, <IP version=4L ihl=5L tos=0x0 len=44 id=0 flags=DF frag=0L ttl=62 proto=tcp chksum=0x28b5 src=10.0.0.14 dst=10.0.0.10 options=[] |<TCP sport=ssh dport=666 seq=4222593330 ack=1 dataofs=6L reserved=0L flags=SA window=29200 chksum=0x6a5b urgptr=0 options=[('MSS', 1460)] |>>)
(<IP frag=0 proto=tcp dst=10.0.0.13 |<TCP sport=666 dport=ssh flags=S |>>, <IP version=4L ihl=5L tos=0x0 len=44 id=41992 flags= frag=0L ttl=254 proto=tcp chksum=0x4ad src=10.0.0.13 dst=10.0.0.10 options=[] |<TCP sport=ssh dport=666 seq=2167267659 ack=1 dataofs=6L reserved=0L flags=SA window=4128 chksum=0x1252 urgptr=0 options=[('MSS', 536)] |>>)

根据我们迄今为止学到的知识,我们可以编写一个简单的可重用脚本scapy_tcp_scan_1.py。我们从建议的导入scapysys模块开始,用于接收参数:

  #!/usr/bin/env python2

  from scapy.all import *
  import sys

tcp_scan()函数与我们到目前为止看到的类似:

  def tcp_scan(destination, dport):
      ans, unans = sr(IP(dst=destination)/TCP(sport=666,dport=dport,flags="S"))
      for sending, returned in ans:
          if 'SA' in str(returned[TCP].flags):
              return destination + " port " + str(sending[TCP].dport) + " is open"
          else:
              return destination + " port " + str(sending[TCP].dport) + " is not open"

然后我们可以从参数中获取输入,然后在main()中调用tcp_scan()函数:

  def main():
      destination = sys.argv[1]
      port = int(sys.argv[2])
      scan_result = tcp_scan(destination, port)
      print(scan_result)

  if __name__ == "__main__":
      main()

请记住,访问低级网络需要 root 访问权限;因此,我们的脚本需要以sudo执行:

cisco@Client:~$ sudo python scapy_tcp_scan_1.py "10.0.0.14" 23
<skip>
10.0.0.14 port 23 is not open
cisco@Client:~$ sudo python scapy_tcp_scan_1.py "10.0.0.14" 22
<skip>
10.0.0.14 port 22 is open

这是一个相对较长的 TCP 扫描脚本示例,演示了使用 Scapy 构建自己的数据包的能力。我们在交互式 shell 中测试了这些步骤,并用一个简单的脚本完成了使用。让我们看看 Scapy 在安全测试中的一些更多用法。

Ping 集合

假设我们的网络包含 Windows、Unix 和 Linux 机器的混合,用户添加了自己的自带设备BYOD);他们可能支持也可能不支持 ICMP ping。我们现在可以构建一个文件,其中包含我们网络中三种常见 ping 的 ICMP、TCP 和 UDP ping,在scapy_ping_collection.py

#!/usr/bin/env python2

from scapy.all import *

def icmp_ping(destination):
    # regular ICMP ping
    ans, unans = sr(IP(dst=destination)/ICMP())
    return ans

def tcp_ping(destination, dport):
    # TCP SYN Scan
    ans, unans = sr(IP(dst=destination)/TCP(dport=dport,flags="S"))
    return ans

def udp_ping(destination):
    # ICMP Port unreachable error from closed port
    ans, unans = sr(IP(dst=destination)/UDP(dport=0))
    return ans

在这个例子中,我们还将使用summary()sprintf()进行输出:

def answer_summary(answer_list):
 # example of lambda with pretty print
    answer_list.summary(lambda(s, r): r.sprintf("%IP.src% is alive"))

如果你想知道为什么在前面的answer_summary()函数中有一个 lambda,那是一种创建小型匿名函数的方法。基本上,它是一个没有名字的函数。关于它的更多信息可以在docs.python.org/3.5/tutorial/controlflow.html#lambda-expressions找到。

然后我们可以在一个脚本中执行网络上的三种 ping 类型:

def main():
    print("** ICMP Ping **")
    ans = icmp_ping("10.0.0.13-14")
    answer_summary(ans)
    print("** TCP Ping **")
    ans = tcp_ping("10.0.0.13", 22)
    answer_summary(ans)
    print("** UDP Ping **")
    ans = udp_ping("10.0.0.13-14")
    answer_summary(ans)

if __name__ == "__main__":
    main()

到目前为止,希望你会同意我的观点,通过拥有构建自己的数据包的能力,你可以控制你想要运行的操作和测试的类型。

常见攻击

在这个例子中,让我们看看如何构造我们的数据包来进行一些经典攻击,比如Ping of Death (en.wikipedia.org/wiki/Ping_of_death) 和 Land Attack (en.wikipedia.org/wiki/Denial-of-service_attack)。这可能是您以前必须使用类似的商业软件付费的网络渗透测试。使用 Scapy,您可以在保持完全控制的同时进行测试,并在将来添加更多测试。

第一次攻击基本上发送了一个带有虚假 IP 头的目标主机,例如长度为 2 和 IP 版本 3:

def malformed_packet_attack(host):
    send(IP(dst=host, ihl=2, version=3)/ICMP()) 

ping_of_death_attack由常规的 ICMP 数据包组成,其负载大于 65,535 字节:

def ping_of_death_attack(host):
    # https://en.wikipedia.org/wiki/Ping_of_death
    send(fragment(IP(dst=host)/ICMP()/("X"*60000)))

land_attack想要将客户端响应重定向回客户端本身,并耗尽主机的资源:

  def land_attack(host):
      # https://en.wikipedia.org/wiki/Denial-of-service_attack
      send(IP(src=host, dst=host)/TCP(sport=135,dport=135))

这些都是相当古老的漏洞或经典攻击,现代操作系统不再容易受到攻击。对于我们的 Ubuntu 14.04 主机,前面提到的攻击都不会使其崩溃。然而,随着发现更多安全问题,Scapy 是一个很好的工具,可以开始对我们自己的网络和主机进行测试,而不必等待受影响的供应商提供验证工具。这对于零日(未经事先通知发布的)攻击似乎在互联网上变得越来越常见尤其如此。

Scapy 资源

我们在本章中花了相当多的精力来使用 Scapy。这在一定程度上是因为我个人对这个工具的高度评价。我希望你同意 Scapy 是网络工程师工具箱中必备的伟大工具。Scapy 最好的部分是它在一个积极参与的用户社区的不断发展。

我强烈建议至少阅读 Scapy 教程 scapy.readthedocs.io/en/latest/usage.html#interactive-tutorial,以及您感兴趣的任何文档。

访问列表

网络访问列表通常是防范外部入侵和攻击的第一道防线。一般来说,路由器和交换机的数据包处理速度要比服务器快得多,因为它们利用硬件,如三态内容可寻址存储器TCAM)。它们不需要查看应用层信息,而只需检查第 3 层和第 4 层信息,并决定是否可以转发数据包。因此,我们通常将网络设备访问列表用作保护网络资源的第一步。

作为一个经验法则,我们希望将访问列表尽可能靠近源(客户端)。因此,我们也相信内部主机,不信任我们网络边界之外的客户端。因此,访问列表通常放置在外部网络接口的入站方向上。在我们的实验场景中,这意味着我们将在直接连接到客户端主机的 Ethernet2/2 上放置一个入站访问列表。

如果您不确定访问列表的方向和位置,以下几点可能会有所帮助:

  • 从网络设备的角度考虑访问列表

  • 简化数据包,只涉及源和目的地 IP,并以一个主机为例:

  • 在我们的实验室中,来自我们服务器的流量将具有源 IP10.0.0.14和目的 IP10.0.0.10

  • 来自客户端的流量将具有源 IP10.10.10.10和目的 IP10.0.0.14

显然,每个网络都是不同的,访问列表的构建方式取决于服务器提供的服务。但作为入站边界访问列表,您应该执行以下操作:

  • 拒绝 RFC 3030 特殊使用地址源,如127.0.0.0/8

  • 拒绝 RFC 1918 空间,如10.0.0.0/8

  • 拒绝我们自己的空间作为源 IP;在这种情况下,10.0.0.12/30

  • 允许入站 TCP 端口22(SSH)和80(HTTP)到主机10.0.0.14

  • 拒绝其他所有内容

使用 Ansible 实现访问列表

实现此访问列表的最简单方法是使用 Ansible。我们在过去的两章中已经看过 Ansible,但值得重申在这种情况下使用 Ansible 的优势:

  • 更容易管理:对于长访问列表,我们可以利用include语句将其分解为更易管理的部分。然后其他团队或服务所有者可以管理这些较小的部分。

  • 幂等性:我们可以定期安排 playbook,并且只会进行必要的更改。

  • 每个任务都是明确的:我们可以分开构造条目以及将访问列表应用到正确的接口。

  • 可重用性:将来,如果我们添加额外的面向外部的接口,我们只需要将设备添加到访问列表的设备列表中。

  • 可扩展性:您会注意到我们可以使用相同的 playbook 来构建访问列表并将其应用到正确的接口。我们可以从小处开始,根据需要在将来扩展到单独的 playbook。

主机文件非常标准。为简单起见,我们直接将主机变量放在清单文件中:

[nxosv-devices]
nx-osv-1 ansible_host=172.16.1.155 ansible_username=cisco ansible_password=cisco

我们暂时将在 playbook 中声明变量:

---
- name: Configure Access List
  hosts: "nxosv-devices"
  gather_facts: false
  connection: local

  vars:
    cli:
      host: "{{ ansible_host }}"
      username: "{{ ansible_username }}"
      password: "{{ ansible_password }}"
      transport: cli

为了节省空间,我们将仅说明拒绝 RFC 1918 空间。实施拒绝 RFC 3030 和我们自己的空间将与用于 RFC 1918 空间的步骤相同。请注意,我们在 playbook 中没有拒绝10.0.0.0/8,因为我们当前的配置使用了10.0.0.0网络进行寻址。当然,我们可以首先执行单个主机许可,然后在以后的条目中拒绝10.0.0.0/8,但在这个例子中,我们选择忽略它:

tasks:
  - nxos_acl:
      name: border_inbound
      seq: 20
      action: deny
      proto: tcp
      src: 172.16.0.0/12
      dest: any
      log: enable
      state: present
      provider: "{{ cli }}"
  - nxos_acl:
      name: border_inbound
      seq: 40
      action: permit
      proto: tcp
      src: any
      dest: 10.0.0.14/32
      dest_port_op: eq
      dest_port1: 22
      state: present
      log: enable
      provider: "{{ cli }}"
  - nxos_acl:
      name: border_inbound
      seq: 50
      action: permit
      proto: tcp
      src: any
      dest: 10.0.0.14/32
      dest_port_op: eq
      dest_port1: 80
      state: present
      log: enable
      provider: "{{ cli }}"
  - nxos_acl:
      name: border_inbound
      seq: 60
      action: permit
      proto: tcp
      src: any
      dest: any
      state: present
      log: enable
      established: enable
      provider: "{{ cli }}"
  - nxos_acl:
      name: border_inbound
      seq: 1000
      action: deny
      proto: ip
      src: any
      dest: any
      state: present
      log: enable
      provider: "{{ cli }}"

请注意,我们允许来自内部服务器的已建立连接返回。我们使用最终的显式deny ip any any语句作为高序号(1000),因此我们可以随后插入任何新条目。

然后我们可以将访问列表应用到正确的接口上:

- name: apply ingress acl to Ethernet 2/2
  nxos_acl_interface:
    name: border_inbound
    interface: Ethernet2/2
    direction: ingress
    state: present
    provider: "{{ cli }}"

VIRL NX-OSv 上的访问列表仅支持管理接口。您将看到此警告:警告:ACL 可能不会按预期行为,因为只支持管理接口,如果您通过 CLI 配置此ACL。这个警告没问题,因为我们的目的只是演示访问列表的配置自动化。

对于单个访问列表来说,这可能看起来是很多工作。对于有经验的工程师来说,使用 Ansible 执行此任务将比只是登录设备并配置访问列表需要更长的时间。但是,请记住,这个 playbook 可以在将来多次重复使用,因此从长远来看可以节省时间。

根据我的经验,通常情况下,长访问列表中的一些条目将用于一个服务,另一些条目将用于另一个服务,依此类推。访问列表往往会随着时间的推移而有机地增长,很难跟踪每个条目的来源和目的。我们可以将它们分开,从而使长访问列表的管理变得更简单。

MAC 访问列表

在 L2 环境或在以太网接口上使用非 IP 协议的情况下,您仍然可以使用 MAC 地址访问列表来允许或拒绝基于 MAC 地址的主机。步骤与 IP 访问列表类似,但匹配将基于 MAC 地址。请记住,对于 MAC 地址或物理地址,前六个十六进制符号属于组织唯一标识符OUI)。因此,我们可以使用相同的访问列表匹配模式来拒绝某个主机组。

我们正在使用ios_config模块在 IOSv 上进行测试。对于较旧的 Ansible 版本,更改将在每次执行 playbook 时推送出去。对于较新的 Ansible 版本,控制节点将首先检查更改,并且只在需要时进行更改。

主机文件和 playbook 的顶部部分与 IP 访问列表类似;tasks部分是使用不同模块和参数的地方:

<skip>
  tasks:
    - name: Deny Hosts with vendor id fa16.3e00.0000
      ios_config:
        lines:
          - access-list 700 deny fa16.3e00.0000 0000.00FF.FFFF
          - access-list 700 permit 0000.0000.0000 FFFF.FFFF.FFFF
        provider: "{{ cli }}"
    - name: Apply filter on bridge group 1
      ios_config:
        lines:
          - bridge-group 1
          - bridge-group 1 input-address-list 700
        parents:
          - interface GigabitEthernet0/1
        provider: "{{ cli }}"   

随着越来越多的虚拟网络变得流行,L3 信息有时对底层虚拟链接变得透明。在这些情况下,如果您需要限制对这些链接的访问,MAC 访问列表成为一个很好的选择。

Syslog 搜索

有大量记录的网络安全漏洞发生在较长的时间内。在这些缓慢的漏洞中,我们经常看到日志中有可疑活动的迹象。这些迹象可以在服务器和网络设备的日志中找到。这些活动之所以没有被检测到,不是因为信息不足,而是因为信息太多。我们正在寻找的关键信息通常深藏在难以整理的大量信息中。

除了 Syslog,Uncomplicated FirewallUFW)是服务器日志信息的另一个很好的来源。它是 iptables 的前端,是一个服务器防火墙。UFW 使管理防火墙规则变得非常简单,并记录了大量信息。有关 UFW 的更多信息,请参阅其他工具部分。

在这一部分,我们将尝试使用 Python 搜索 Syslog 文本,以便检测我们正在寻找的活动。当然,我们将搜索的确切术语取决于我们使用的设备。例如,思科提供了一个在 Syslog 中查找任何访问列表违规日志的消息列表。它可以在www.cisco.com/c/en/us/about/security-center/identify-incidents-via-syslog.html上找到。

要更好地理解访问控制列表日志记录,请访问www.cisco.com/c/en/us/about/security-center/access-control-list-logging.html

对于我们的练习,我们将使用一个包含大约 65,000 行日志消息的 Nexus 交换机匿名 Syslog 文件,该文件已包含在适应书籍 GitHub 存储库中供您使用:

$ wc -l sample_log_anonymized.log
65102 sample_log_anonymized.log

我们已经插入了一些来自思科文档(www.cisco.com/c/en/us/support/docs/switches/nexus-7000-series-switches/118907-configure-nx7k-00.html )的 Syslog 消息作为我们应该寻找的日志消息:

2014 Jun 29 19:20:57 Nexus-7000 %VSHD-5-VSHD_SYSLOG_CONFIG_I: Configured from vty by admin on console0
2014 Jun 29 19:21:18 Nexus-7000 %ACLLOG-5-ACLLOG_FLOW_INTERVAL: Src IP: 10.1 0.10.1,
 Dst IP: 172.16.10.10, Src Port: 0, Dst Port: 0, Src Intf: Ethernet4/1, Pro tocol: "ICMP"(1), Hit-count = 2589
2014 Jun 29 19:26:18 Nexus-7000 %ACLLOG-5-ACLLOG_FLOW_INTERVAL: Src IP: 10.1 0.10.1, Dst IP: 172.16.10.10, Src Port: 0, Dst Port: 0, Src Intf: Ethernet4/1, Pro tocol: "ICMP"(1), Hit-count = 4561

我们将使用简单的正则表达式示例。如果您已经熟悉 Python 中的正则表达式,请随时跳过本节的其余部分。

使用 RE 模块进行搜索

对于我们的第一个搜索,我们将简单地使用正则表达式模块来查找我们正在寻找的术语。我们将使用一个简单的循环来进行以下操作:

#!/usr/bin/env python3

import re, datetime

startTime = datetime.datetime.now()

with open('sample_log_anonymized.log', 'r') as f:
   for line in f.readlines():
       if re.search('ACLLOG-5-ACLLOG_FLOW_INTERVAL', line):
           print(line)

endTime = datetime.datetime.now()
elapsedTime = endTime - startTime
print("Time Elapsed: " + str(elapsedTime))

搜索日志文件大约花了 6/100 秒的时间:

$ python3 python_re_search_1.py
2014 Jun 29 19:21:18 Nexus-7000 %ACLLOG-5-ACLLOG_FLOW_INTERVAL: Src IP: 10.1 0.10.1,

2014 Jun 29 19:26:18 Nexus-7000 %ACLLOG-5-ACLLOG_FLOW_INTERVAL: Src IP: 10.1 0.10.1,

Time Elapsed: 0:00:00.065436

建议编译搜索术语以进行更有效的搜索。这不会对我们产生太大影响,因为脚本已经非常快速。实际上,Python 的解释性特性可能会使其变慢。但是,当我们搜索更大的文本主体时,这将产生影响,所以让我们做出改变:

searchTerm = re.compile('ACLLOG-5-ACLLOG_FLOW_INTERVAL')

with open('sample_log_anonymized.log', 'r') as f:
   for line in f.readlines():
       if re.search(searchTerm, line):
           print(line)

时间结果实际上更慢:

Time Elapsed: 0:00:00.081541

让我们扩展一下这个例子。假设我们有几个文件和多个要搜索的术语,我们将把原始文件复制到一个新文件中:

$ cp sample_log_anonymized.log sample_log_anonymized_1.log

我们还将包括搜索PAM: Authentication failure术语。我们将添加另一个循环来搜索这两个文件:

term1 = re.compile('ACLLOG-5-ACLLOG_FLOW_INTERVAL')
term2 = re.compile('PAM: Authentication failure')

fileList = ['sample_log_anonymized.log', 'sample_log_anonymized_1.log']

for log in fileList:
    with open(log, 'r') as f:
       for line in f.readlines():
           if re.search(term1, line) or re.search(term2, line):
               print(line) 

通过扩展我们的搜索术语和消息数量,我们现在可以看到性能上的差异:

$ python3 python_re_search_2.py
2016 Jun 5 16:49:33 NEXUS-A %DAEMON-3-SYSTEM_MSG: error: PAM: Authentication failure for illegal user AAA from 172.16.20.170 - sshd[4425]

2016 Sep 14 22:52:26.210 NEXUS-A %DAEMON-3-SYSTEM_MSG: error: PAM: Authentication failure for illegal user AAA from 172.16.20.170 - sshd[2811]

<skip>

2014 Jun 29 19:21:18 Nexus-7000 %ACLLOG-5-ACLLOG_FLOW_INTERVAL: Src IP: 10.1 0.10.1,

2014 Jun 29 19:26:18 Nexus-7000 %ACLLOG-5-ACLLOG_FLOW_INTERVAL: Src IP: 10.1 0.10.1,

<skip>

Time Elapsed: 0:00:00.330697

当涉及性能调优时,这是一个永无止境的、不可能达到零的竞赛,性能有时取决于您使用的硬件。但重要的是定期使用 Python 对日志文件进行审计,这样您就可以捕捉到任何潜在违规的早期信号。

其他工具

还有其他网络安全工具可以使用 Python 进行自动化。让我们看看其中一些。

私有 VLAN

虚拟局域网VLANs)已经存在很长时间了。它们本质上是一个广播域,所有主机都可以连接到一个交换机,但被划分到不同的域,所以我们可以根据哪个主机可以通过广播看到其他主机来分隔主机。让我们看一个基于 IP 子网的映射。例如,在企业大楼中,我可能会看到每个物理楼层一个 IP 子网:第一层的192.168.1.0/24,第二层的192.168.2.0/24,依此类推。在这种模式下,我们为每个楼层使用 1/24 块。这清晰地划分了我的物理网络和逻辑网络。想要与自己的子网之外通信的主机将需要通过其第 3 层网关,我可以使用访问列表来强制执行安全性。

当不同部门位于同一楼层时会发生什么?也许财务和销售团队都在二楼,我不希望销售团队的主机与财务团队的主机在同一个广播域中。我可以进一步分割子网,但这可能变得乏味,并且会破坏先前设置的标准子网方案。这就是私有 VLAN 可以帮助的地方。

私有 VLAN 本质上将现有的 VLAN 分成子 VLAN。私有 VLAN 中有三个类别:

  • 混杂(P)端口:此端口允许从 VLAN 上的任何其他端口发送和接收第 2 层帧;这通常属于连接到第 3 层路由器的端口

  • 隔离(I)端口:此端口只允许与 P 端口通信,并且它们通常连接到主机,当您不希望它与同一 VLAN 中的其他主机通信时

  • 社区(C)端口:此端口允许与同一社区中的其他 C 端口和 P 端口通信

我们可以再次使用 Ansible 或迄今为止介绍的任何其他 Python 脚本来完成这项任务。到目前为止,我们应该有足够的练习和信心通过自动化来实现这个功能,所以我不会在这里重复步骤。在需要进一步隔离 L2 VLAN 中的端口时,了解私有 VLAN 功能将会很有用。

使用 Python 的 UFW

我们简要提到了 UFW 作为 Ubuntu 主机上 iptables 的前端。以下是一个快速概述:

$ sudo apt-get install ufw
$ sudo ufw status
$ sudo ufw default outgoing
$ sudo ufw allow 22/tcp
$ sudo ufw allow www
$ sudo ufw default deny incoming

我们可以查看 UFW 的状态:

$ sudo ufw status verbose
Status: active
Logging: on (low)
Default: deny (incoming), allow (outgoing), disabled (routed)
New profiles: skip

To Action From
-- ------ ----
22/tcp ALLOW IN Anywhere
80/tcp ALLOW IN Anywhere
22/tcp (v6) ALLOW IN Anywhere (v6)
80/tcp (v6) ALLOW IN Anywhere (v6)

正如您所看到的,UFW 的优势在于提供一个简单的界面来构建否则复杂的 IP 表规则。有几个与 UFW 相关的 Python 工具可以使事情变得更简单:

UFW 被证明是保护您的网络服务器的好工具。

进一步阅读

Python 是许多安全相关领域中常用的语言。我推荐的一些书籍如下:

  • 暴力 Python:T.J. O'Connor 编写的黑客、取证分析师、渗透测试人员和安全工程师的食谱(ISBN-10:1597499579)

  • 黑帽 Python:Justin Seitz 编写的黑客和渗透测试人员的 Python 编程(ISBN-10:1593275900)

我个人在 A10 Networks 的分布式拒绝服务DDoS)研究工作中广泛使用 Python。如果您有兴趣了解更多信息,可以免费下载指南:www.a10networks.com/resources/ebooks/distributed-denial-service-ddos

总结

在本章中,我们使用 Python 进行了网络安全研究。我们使用 Cisco VIRL 工具在实验室中设置了主机和网络设备,包括 NX-OSv 和 IOSv 类型。我们对 Scapy 进行了介绍,它允许我们从头开始构建数据包。Scapy 可以在交互模式下进行快速测试。在交互模式完成后,我们可以将步骤放入文件进行更可扩展的测试。它可以用于执行已知漏洞的各种网络渗透测试。

我们还研究了如何使用 IP 访问列表和 MAC 访问列表来保护我们的网络。它们通常是我们网络保护的第一道防线。使用 Ansible,我们能够一致快速地部署访问列表到多个设备。

Syslog 和其他日志文件包含有用的信息,我们应该定期查看以检测任何早期入侵的迹象。使用 Python 正则表达式,我们可以系统地搜索已知的日志条目,这些条目可以指引我们注意的安全事件。除了我们讨论过的工具之外,私有 VLAN 和 UFW 是我们可以用于更多安全保护的其他一些有用工具。

在第七章中,使用 Python 进行网络监控-第 1 部分,我们将看看如何使用 Python 进行网络监控。监控可以让我们了解网络中正在发生的事情以及网络的状态。

第七章:使用 Python 进行网络监控-第 1 部分

想象一下,你在凌晨 2 点接到一个电话。电话那头的人说:“嗨,我们遇到了一个影响生产服务的困难问题。我们怀疑可能与网络有关。你能帮我们检查一下吗?”对于这种紧急的、开放式的问题,你会做什么?大多数情况下,脑海中浮现的第一件事是:在网络正常运行到出现问题之间发生了什么变化?很可能你会检查你的监控工具,看看最近几个小时内是否有任何关键指标发生了变化。更好的是,如果你收到了任何与指标基线偏差相关的监控警报。

在本书中,我们一直在讨论系统地对网络进行可预测的更改的各种方法,目标是尽可能使网络运行顺畅。然而,网络并不是静态的-远非如此-它们可能是整个基础设施中最流动的部分之一。根据定义,网络连接了基础设施的不同部分,不断地来回传递流量。有很多移动的部分可能导致您的网络停止按预期工作:硬件故障、软件错误、尽管有最好的意图,人为错误,等等。问题不在于事情是否会出错,而在于当它发生时,出了什么问题。我们需要监控我们的网络,以确保它按预期工作,并希望在它不按预期工作时得到通知。

在接下来的两章中,我们将看一些执行网络监控任务的各种方法。到目前为止,我们看到的许多工具可以通过 Python 进行绑定或直接管理。和我们看到的许多工具一样,网络监控涉及两个部分。首先,我们需要知道设备能够传输什么信息。其次,我们需要确定我们可以从中解释出什么有用的信息。

我们将看一些工具,让我们能够有效地监控网络:

  • 简单网络管理协议SNMP

  • Matplotlib 和 Pygal 可视化

  • MRTG 和 Cacti

这个列表并不详尽,网络监控领域显然没有缺乏商业供应商。然而,我们将要看的网络监控基础知识对于开源和商业工具都适用。

实验室设置

本章的实验室与第六章中的实验室类似,使用 Python 进行网络安全,但有一个区别:网络设备都是 IOSv 设备。以下是这一点的说明:

两台 Ubuntu 主机将用于在网络中生成流量,以便我们可以查看一些非零计数器。

SNMP

SNMP 是一种标准化的协议,用于收集和管理设备。尽管该标准允许你使用 SNMP 进行设备管理,但根据我的经验,大多数网络管理员更喜欢将 SNMP 仅作为信息收集机制。由于 SNMP 在 UDP 上运行,UDP 是无连接的,并且考虑到版本 1 和 2 中相对较弱的安全机制,通过 SNMP 进行设备更改往往会让网络运营商感到有些不安。SNMP 版本 3 增加了加密安全性和协议的新概念和术语,但技术的适应方式在网络设备供应商之间存在差异。

SNMP 在网络监控中被广泛使用,自 1988 年作为 RFC 1065 的一部分以来一直存在。操作很简单,网络管理器向设备发送GETSET请求,设备与 SNMP 代理响应每个请求的信息。最广泛采用的标准是 SNMPv2c,定义在 RFC 1901 - RFC 1908 中。它使用简单的基于社区的安全方案进行安全。它还引入了新功能,例如获取批量信息的能力。以下图显示了 SNMP 的高级操作:

SNMP 操作

设备中的信息存储在管理信息库MIB)中。MIB 使用包含对象标识符OID)的分层命名空间,表示可以读取并反馈给请求者的信息。当我们谈论使用 SNMP 查询设备信息时,我们实际上是在谈论使用管理站点查询代表我们所需信息的特定 OID。有一个常见的 OID 结构,例如系统和接口 OID,这在供应商之间是共享的。除了常见的 OID,每个供应商还可以提供特定于他们的企业级 OID。

作为操作员,您需要努力将信息整合到环境中的 OID 结构中,以检索有用的信息。有时这可能是一个繁琐的过程,一次找到一个 OID。例如,您可能会向设备 OID 发出请求,并收到一个值为 10,000。那个值是什么?那是接口流量吗?是字节还是位?或者可能是数据包的数量?我们怎么知道?我们需要查阅标准或供应商文档才能找到答案。有一些工具可以帮助这个过程,比如 MIB 浏览器可以为值提供更多的元数据。但至少在我的经验中,为您的网络构建基于 SNMP 的监控工具有时会感觉像是一场猫鼠游戏,试图找到那个缺失的值。

从操作中可以得出一些要点:

  • 实施严重依赖设备代理提供的信息量。这又取决于供应商如何对待 SNMP:作为核心功能还是附加功能。

  • SNMP 代理通常需要来自控制平面的 CPU 周期来返回一个值。这不仅对于具有大型 BGP 表的设备效率低下,而且在小间隔内使用 SNMP 查询数据也是不可行的。

  • 用户需要知道 OID 才能查询数据。

由于 SNMP 已经存在一段时间,我假设您已经有了一些经验。让我们直接跳到软件包安装和我们的第一个 SNMP 示例。

设置

首先,让我们确保我们的设置中有 SNMP 管理设备和代理工作。SNMP 捆绑包可以安装在我们实验室中的主机(客户端或服务器)或管理网络上的管理设备上。只要 SNMP 管理器可以通过 IP 与设备通信,并且受管设备允许入站连接,SNMP 就可以工作。在生产中,您应该只在管理主机上安装软件,并且只允许控制平面中的 SNMP 流量。

在这个实验中,我们在管理网络上的 Ubuntu 主机和实验室中的客户端主机上都安装了 SNMP 以测试安全性:

$ sudo apt-get install snmp

下一步将是在网络设备iosv-1iosv-2上打开和配置 SNMP 选项。您可以在网络设备上配置许多可选参数,例如联系人、位置、机箱 ID 和 SNMP 数据包大小。这些选项是特定于设备的,您应该查看设备的文档。对于 IOSv 设备,我们将配置一个访问列表,以限制只有所需的主机可以查询设备,并将访问列表与 SNMP 社区字符串绑定。在我们的情况下,我们将使用secret作为只读社区字符串,permit_snmp作为访问列表名称。

!
ip access-list standard permit_snmp
 permit 172.16.1.173 log
 deny any log
!
!
snmp-server community secret RO permit_snmp
!

SNMP 社区字符串充当管理器和代理之间的共享密码;因此,每次要查询设备时都需要包含它。

正如本章前面提到的,与 SNMP 一起工作时找到正确的 OID 往往是战斗的一半。我们可以使用诸如思科 IOS MIB 定位器(tools.cisco.com/ITDIT/MIBS/servlet/index)这样的工具来查找要查询的特定 OID。或者,我们可以从 Cisco 企业树的顶部.1.3.6.1.4.1.9开始遍历 SNMP 树。我们将执行遍历以确保 SNMP 代理和访问列表正在工作:

$ snmpwalk -v2c -c secret 172.16.1.189 .1.3.6.1.4.1.9
iso.3.6.1.4.1.9.2.1.1.0 = STRING: "
Bootstrap program is IOSv
"
iso.3.6.1.4.1.9.2.1.2.0 = STRING: "reload" iso.3.6.1.4.1.9.2.1.3.0 = STRING: "iosv-1"
iso.3.6.1.4.1.9.2.1.4.0 = STRING: "virl.info"
...

我们还可以更具体地说明我们需要查询的 OID:

$ snmpwalk -v2c -c secret 172.16.1.189 .1.3.6.1.4.1.9.2.1.61.0
iso.3.6.1.4.1.9.2.1.61.0 = STRING: "cisco Systems, Inc.
170 West Tasman Dr.
San Jose, CA 95134-1706
U.S.A.
Ph +1-408-526-4000
Customer service 1-800-553-6387 or +1-408-526-7208
24HR Emergency 1-800-553-2447 or +1-408-526-7209
Email Address tac@cisco.com
World Wide Web http://www.cisco.com"

作为演示,如果我们在最后一个 OID 的末尾输入错误的值,例如从011位数,我们会看到这样的情况:

$ snmpwalk -v2c -c secret 172.16.1.189 .1.3.6.1.4.1.9.2.1.61.1
iso.3.6.1.4.1.9.2.1.61.1 = No Such Instance currently exists at this OID

与 API 调用不同,没有有用的错误代码或消息;它只是简单地说明 OID 不存在。有时这可能非常令人沮丧。

最后要检查的是我们配置的访问列表将拒绝不需要的 SNMP 查询。因为我们在访问列表的允许和拒绝条目中都使用了log关键字,所以只有172.16.1.173被允许查询设备:

*Mar 3 20:30:32.179: %SEC-6-IPACCESSLOGNP: list permit_snmp permitted 0 172.16.1.173 -> 0.0.0.0, 1 packet
*Mar 3 20:30:33.991: %SEC-6-IPACCESSLOGNP: list permit_snmp denied 0 172.16.1.187 -> 0.0.0.0, 1 packet

正如您所看到的,设置 SNMP 的最大挑战是找到正确的 OID。一些 OID 在标准化的 MIB-2 中定义;其他的在树的企业部分下。尽管如此,供应商文档是最好的选择。有许多工具可以帮助,例如 MIB 浏览器;您可以将 MIBs(同样由供应商提供)添加到浏览器中,并查看基于企业的 OID 的描述。当您需要找到您正在寻找的对象的正确 OID 时,像思科的 SNMP 对象导航器(snmp.cloudapps.cisco.com/Support/SNMP/do/BrowseOID.do?local=en)这样的工具就变得非常有价值。

PySNMP

PySNMP 是由 Ilya Etingof 开发的跨平台、纯 Python SNMP 引擎实现(github.com/etingof)。它为您抽象了许多 SNMP 细节,正如优秀的库所做的那样,并支持 Python 2 和 Python 3。

PySNMP 需要 PyASN1 包。以下内容摘自维基百科:

"ASN.1 是一种标准和符号,描述了在电信和计算机网络中表示、编码、传输和解码数据的规则和结构。"

PyASN1 方便地提供了一个 Python 封装器,用于 ASN.1。让我们首先安装这个包:

cd /tmp
git clone https://github.com/etingof/pyasn1.git
cd pyasn1/
git checkout 0.2.3
sudo python3 setup.py install

接下来,安装 PySNMP 包:

git clone https://github.com/etingof/pysnmp
cd pysnmp/
git checkout v4.3.10
sudo python3 setup.py install

由于pysnmp.entity.rfc3413.oneliner从版本 5.0.0 开始被移除(github.com/etingof/pysnmp/blob/a93241007b970c458a0233c16ae2ef82dc107290/CHANGES.txt),我们使用了较旧版本的 PySNMP。如果您使用pip来安装包,示例可能会出现问题。

让我们看看如何使用 PySNMP 来查询与上一个示例中使用的相同的 Cisco 联系信息。我们将采取的步骤是从pysnmp.sourceforge.net/faq/response-values-mib-resolution.html中的 PySNMP 示例中略微修改的版本。我们将首先导入必要的模块并创建一个CommandGenerator对象:

>>> from pysnmp.entity.rfc3413.oneliner import cmdgen
>>> cmdGen = cmdgen.CommandGenerator()
>>> cisco_contact_info_oid = "1.3.6.1.4.1.9.2.1.61.0"

我们可以使用getCmd方法执行 SNMP。结果将被解包为各种变量;其中,我们最关心varBinds,其中包含查询结果:

>>> errorIndication, errorStatus, errorIndex, varBinds = cmdGen.getCmd(
...     cmdgen.CommunityData('secret'),
...     cmdgen.UdpTransportTarget(('172.16.1.189', 161)),
...     cisco_contact_info_oid
... )
>>> for name, val in varBinds:
...     print('%s = %s' % (name.prettyPrint(), str(val)))
...
SNMPv2-SMI::enterprises.9.2.1.61.0 = cisco Systems, Inc.
170 West Tasman Dr.
San Jose, CA 95134-1706
U.S.A.
Ph +1-408-526-4000
Customer service 1-800-553-6387 or +1-408-526-7208
24HR Emergency 1-800-553-2447 or +1-408-526-7209
Email Address tac@cisco.com
World Wide Web http://www.cisco.com
>>>

请注意,响应值是 PyASN1 对象。prettyPrint()方法将一些这些值转换为人类可读的格式,但由于我们的结果没有被转换,我们将手动将其转换为字符串。

我们可以基于前面的交互式示例编写一个脚本。我们将其命名为pysnmp_1.py并进行错误检查。我们还可以在getCmd()方法中包含多个 OID:

#!/usr/bin/env/python3

from pysnmp.entity.rfc3413.oneliner import cmdgen

cmdGen = cmdgen.CommandGenerator()

system_up_time_oid = "1.3.6.1.2.1.1.3.0"
cisco_contact_info_oid = "1.3.6.1.4.1.9.2.1.61.0"

errorIndication, errorStatus, errorIndex, varBinds = cmdGen.getCmd(
    cmdgen.CommunityData('secret'),
    cmdgen.UdpTransportTarget(('172.16.1.189', 161)),
    system_up_time_oid,
    cisco_contact_info_oid
)

# Check for errors and print out results
if errorIndication:
    print(errorIndication)
else:
    if errorStatus:
        print('%s at %s' % (
            errorStatus.prettyPrint(),
            errorIndex and varBinds[int(errorIndex)-1] or '?'
            )
        )
    else:
        for name, val in varBinds:
            print('%s = %s' % (name.prettyPrint(), str(val)))

结果将被解包并列出两个 OID 的值:

$ python3 pysnmp_1.py
SNMPv2-MIB::sysUpTime.0 = 660959
SNMPv2-SMI::enterprises.9.2.1.61.0 = cisco Systems, Inc.
170 West Tasman Dr.
San Jose, CA 95134-1706
U.S.A.
Ph +1-408-526-4000
Customer service 1-800-553-6387 or +1-408-526-7208
24HR Emergency 1-800-553-2447 or +1-408-526-7209
Email Address tac@cisco.com
World Wide Web http://www.cisco.com 

在接下来的示例中,我们将持久化我们从查询中收到的值,以便我们可以执行其他功能,比如使用数据进行可视化。在我们的示例中,我们将使用 MIB-2 树中的ifEntry来绘制与接口相关的值。您可以找到许多资源来映射ifEntry树;这里是我们之前访问过ifEntry的 Cisco SNMP 对象导航器网站的屏幕截图:

SNMP ifEntry OID tree

一个快速测试将说明设备上接口的 OID 映射:

$ snmpwalk -v2c -c secret 172.16.1.189 .1.3.6.1.2.1.2.2.1.2
iso.3.6.1.2.1.2.2.1.2.1 = STRING: "GigabitEthernet0/0"
iso.3.6.1.2.1.2.2.1.2.2 = STRING: "GigabitEthernet0/1"
iso.3.6.1.2.1.2.2.1.2.3 = STRING: "GigabitEthernet0/2"
iso.3.6.1.2.1.2.2.1.2.4 = STRING: "Null0"
iso.3.6.1.2.1.2.2.1.2.5 = STRING: "Loopback0"

从文档中,我们可以将ifInOctets(10)ifInUcastPkts(11)ifOutOctets(16)ifOutUcastPkts(17)的值映射到它们各自的 OID 值。通过快速检查 CLI 和 MIB 文档,我们可以看到GigabitEthernet0/0数据包输出的值映射到 OID1.3.6.1.2.1.2.2.1.17.1。我们将按照相同的过程来映射接口统计的其余 OID。在 CLI 和 SNMP 之间进行检查时,请记住,值应该接近但不完全相同,因为在 CLI 输出和 SNMP 查询时间之间可能有一些流量:

# Command Line Output
iosv-1#sh int gig 0/0 | i packets
 5 minute input rate 0 bits/sec, 0 packets/sec
 5 minute output rate 0 bits/sec, 0 packets/sec
 38532 packets input, 3635282 bytes, 0 no buffer
 53965 packets output, 4723884 bytes, 0 underruns

# SNMP Output
$ snmpwalk -v2c -c secret 172.16.1.189 .1.3.6.1.2.1.2.2.1.17.1
iso.3.6.1.2.1.2.2.1.17.1 = Counter32: 54070

如果我们处于生产环境中,我们可能会将结果写入数据库。但由于这只是一个例子,我们将把查询值写入一个平面文件。我们将编写pysnmp_3.py脚本来进行信息查询并将结果写入文件。在脚本中,我们已经定义了需要查询的各种 OID:

  # Hostname OID
  system_name = '1.3.6.1.2.1.1.5.0'

  # Interface OID
  gig0_0_in_oct = '1.3.6.1.2.1.2.2.1.10.1'
  gig0_0_in_uPackets = '1.3.6.1.2.1.2.2.1.11.1'
  gig0_0_out_oct = '1.3.6.1.2.1.2.2.1.16.1'
  gig0_0_out_uPackets = '1.3.6.1.2.1.2.2.1.17.1'

这些值在snmp_query()函数中被使用,输入为hostcommunityoid

  def snmp_query(host, community, oid):
      errorIndication, errorStatus, errorIndex, varBinds = cmdGen.getCmd(
      cmdgen.CommunityData(community),
      cmdgen.UdpTransportTarget((host, 161)),
      oid
      )

所有的值都被放在一个带有各种键的字典中,并写入一个名为results.txt的文件:

  result = {}
  result['Time'] = datetime.datetime.utcnow().isoformat()
  result['hostname'] = snmp_query(host, community, system_name)
  result['Gig0-0_In_Octet'] = snmp_query(host, community, gig0_0_in_oct)
  result['Gig0-0_In_uPackets'] = snmp_query(host, community, gig0_0_in_uPackets)
  result['Gig0-0_Out_Octet'] = snmp_query(host, community, gig0_0_out_oct)
  result['Gig0-0_Out_uPackets'] = snmp_query(host, community, gig0_0_out_uPackets)

  with open('/home/echou/Master_Python_Networking/Chapter7/results.txt', 'a') as f:
      f.write(str(result))
      f.write('n')

结果将是一个显示查询时接口数据包的文件:

# Sample output
$ cat results.txt
{'Gig0-0_In_Octet': '3990616', 'Gig0-0_Out_uPackets': '60077', 'Gig0-0_In_uPackets': '42229', 'Gig0-0_Out_Octet': '5228254', 'Time': '2017-03-06T02:34:02.146245', 'hostname': 'iosv-1.virl.info'}
{'Gig0-0_Out_uPackets': '60095', 'hostname': 'iosv-1.virl.info', 'Gig0-0_Out_Octet': '5229721', 'Time': '2017-03-06T02:35:02.072340', 'Gig0-0_In_Octet': '3991754', 'Gig0-0_In_uPackets': '42242'}
{'hostname': 'iosv-1.virl.info', 'Gig0-0_Out_Octet': '5231484', 'Gig0-0_In_Octet': '3993129', 'Time': '2017-03-06T02:36:02.753134', 'Gig0-0_In_uPackets': '42257', 'Gig0-0_Out_uPackets': '60116'}
{'Gig0-0_In_Octet': '3994504', 'Time': '2017-03-06T02:37:02.146894', 'Gig0-0_In_uPackets': '42272', 'Gig0-0_Out_uPackets': '60136', 'Gig0-0_Out_Octet': '5233187', 'hostname': 'iosv-1.virl.info'}
{'Gig0-0_In_uPackets': '42284', 'Time': '2017-03-06T02:38:01.915432', 'Gig0-0_In_Octet': '3995585', 'Gig0-0_Out_Octet': '5234656', 'Gig0-0_Out_uPackets': '60154', 'hostname': 'iosv-1.virl.info'}
...

我们可以使这个脚本可执行,并安排一个cron作业每五分钟执行一次:

$ chmod +x pysnmp_3.py

# Crontab configuration
*/5 * * * * /home/echou/Master_Python_Networking/Chapter7/pysnmp_3.py

如前所述,在生产环境中,我们会将信息放入数据库。对于 SQL 数据库,您可以使用唯一 ID 作为主键。在 NoSQL 数据库中,我们可能会使用时间作为主索引(或键),因为它总是唯一的,然后是各种键值对。

我们将等待脚本执行几次,以便值被填充。如果您是不耐烦的类型,可以将cron作业间隔缩短为一分钟。在results.txt文件中看到足够多的值以制作有趣的图表后,我们可以继续下一节,看看如何使用 Python 来可视化数据。

用于数据可视化的 Python

我们收集网络数据是为了深入了解我们的网络。了解数据含义的最佳方法之一是使用图形对其进行可视化。这对于几乎所有数据都是正确的,但特别适用于网络监控的时间序列数据。在过去一周内网络传输了多少数据?TCP 协议在所有流量中的百分比是多少?这些都是我们可以通过使用数据收集机制(如 SNMP)获得的值,我们可以使用一些流行的 Python 库生成可视化图形。

在本节中,我们将使用上一节从 SNMP 收集的数据,并使用两个流行的 Python 库 Matplotlib 和 Pygal 来对其进行图形化。

Matplotlib

Matplotlib (matplotlib.org/)是 Python 语言及其 NumPy 数学扩展的 2D 绘图库。它可以用几行代码生成出版质量的图形,如绘图、直方图和条形图。

NumPy 是 Python 编程语言的扩展。它是开源的,并广泛用于各种数据科学项目。您可以在en.wikipedia.org/wiki/NumPy了解更多信息。

安装

安装可以使用 Linux 软件包管理系统完成,具体取决于您的发行版:

$ sudo apt-get install python-matplotlib # for Python2
$ sudo apt-get install python3-matplotlib

Matplotlib – 第一个示例

在以下示例中,默认情况下,输出图形会显示为标准输出。在开发过程中,最好先尝试最初的代码,并首先在标准输出上生成图形,然后再用脚本完成代码。如果您一直通过虚拟机跟随本书,建议您使用虚拟机窗口而不是 SSH,这样您就可以看到图形。如果您无法访问标准输出,可以保存图形,然后在下载后查看(很快您将看到)。请注意,您需要在本节中的某些图形中设置$DISPLAY变量。

以下是本章可视化示例中使用的 Ubuntu 桌面的屏幕截图。在终端窗口中发出plt.show()命令后,Figure 1将出现在屏幕上。关闭图形后,您将返回到 Python shell:

使用 Ubuntu 桌面的 Matplotlib 可视化

让我们先看看折线图。折线图只是给出了两个与x轴和y轴值对应的数字列表:

>>> import matplotlib.pyplot as plt
>>> plt.plot([0,1,2,3,4], [0,10,20,30,40])
[<matplotlib.lines.Line2D object at 0x7f932510df98>]
>>> plt.ylabel('Something on Y')
<matplotlib.text.Text object at 0x7f93251546a0>
>>> plt.xlabel('Something on X')
<matplotlib.text.Text object at 0x7f9325fdb9e8>
>>> plt.show()

图形将显示为折线图:

Matplotlib 折线图

或者,如果您无法访问标准输出或者首先保存了图形,可以使用savefig()方法:

>>> plt.savefig('figure1.png')
or
>>> plt.savefig('figure1.pdf')

有了这些基本的图形绘制知识,我们现在可以绘制从 SNMP 查询中收到的结果了。

用于 SNMP 结果的 Matplotlib

在我们的第一个 Matplotlib 示例中,即matplotlib_1.py,我们将除了pyplot之外还导入dates模块。我们将使用matplotlib.dates模块而不是 Python 标准库dates模块。与 Pythondates模块不同,mapplotlib.dates库将在内部将日期值转换为 Matplotlib 所需的浮点类型:

  import matplotlib.pyplot as plt
  import matplotlib.dates as dates

Matplotlib 提供了复杂的日期绘图功能;您可以在matplotlib.org/api/dates_api.html找到更多信息。

在脚本中,我们将创建两个空列表,分别表示x-轴和y-轴的值。请注意,在第 12 行,我们使用内置的eval() Python 函数将输入读取为字典,而不是默认的字符串:

   x_time = []
   y_value = []

   with open('results.txt', 'r') as f:
       for line in f.readlines():
           line = eval(line)
           x_time.append(dates.datestr2num(line['Time']))
           y_value.append(line['Gig0-0_Out_uPackets'])

为了以人类可读的日期格式读取x-轴的值,我们需要使用plot_date()函数而不是plot()。我们还将微调图形的大小,并旋转x-轴上的值,以便我们可以完整地读取该值:

  plt.subplots_adjust(bottom=0.3)
  plt.xticks(rotation=80)

  plt.plot_date(x_time, y_value)
  plt.title('Router1 G0/0')
  plt.xlabel('Time in UTC')
  plt.ylabel('Output Unicast Packets')
  plt.savefig('matplotlib_1_result.png')
  plt.show()

最终结果将显示 Router1 Gig0/0 和输出单播数据包,如下所示:

Router1 Matplotlib 图

请注意,如果您喜欢直线而不是点,您可以在plot_date()函数中使用第三个可选参数:

     plt.plot_date(x_time, y_value, "-")

我们可以重复输出八进制、输入单播数据包和输入的步骤作为单独的图形。然而,在我们接下来的例子中,也就是matplotlib_2.py中,我们将向您展示如何在相同的时间范围内绘制多个值,以及其他 Matplotlib 选项。

在这种情况下,我们将创建额外的列表,并相应地填充值:

   x_time = []
   out_octets = []
   out_packets = []
   in_octets = []
   in_packets = []

   with open('results.txt', 'r') as f:
       for line in f.readlines():
   ...
           out_packets.append(line['Gig0-0_Out_uPackets'])
           out_octets.append(line['Gig0-0_Out_Octet'])
           in_packets.append(line['Gig0-0_In_uPackets'])
           in_octets.append(line['Gig0-0_In_Octet'])

由于我们有相同的x-轴值,我们可以将不同的y-轴值添加到同一图中:

  # Use plot_date to display x-axis back in date format
  plt.plot_date(x_time, out_packets, '-', label='Out Packets')
  plt.plot_date(x_time, out_octets, '-', label='Out Octets')
  plt.plot_date(x_time, in_packets, '-', label='In Packets')
  plt.plot_date(x_time, in_octets, '-', label='In Octets')

还要在图中添加网格和图例:

  plt.legend(loc='upper left')
  plt.grid(True)

最终结果将把所有值合并到一个图中。请注意,左上角的一些值被图例挡住了。您可以调整图形的大小和/或使用平移/缩放选项来在图形周围移动,以查看值:

Router 1 – Matplotlib 多线图

Matplotlib 中有许多其他绘图选项;我们当然不仅限于绘制图形。例如,我们可以使用以下模拟数据来绘制我们在线上看到的不同流量类型的百分比:

#!/usr/bin/env python3
# Example from http://matplotlib.org/2.0.0/examples/pie_and_polar_charts/pie_demo_features.html
import matplotlib.pyplot as plt

# Pie chart, where the slices will be ordered and plotted counter-clockwise:
labels = 'TCP', 'UDP', 'ICMP', 'Others'
sizes = [15, 30, 45, 10]
explode = (0, 0.1, 0, 0) # Make UDP stand out

fig1, ax1 = plt.subplots()
ax1.pie(sizes, explode=explode, labels=labels, autopct='%1.1f%%',
 shadow=True, startangle=90)
ax1.axis('equal') # Equal aspect ratio ensures that pie is drawn as a circle.

plt.show()

上述代码导致了从plt.show()生成的饼图:

Matplotlib 饼图

附加的 Matplotlib 资源

Matplotlib 是最好的 Python 绘图库之一,能够生成出版质量的图形。与 Python 一样,它的目标是使复杂的任务变得简单。在 GitHub 上有超过 7550 颗星(还在增加),它也是最受欢迎的开源项目之一。它的受欢迎程度直接转化为更快的错误修复、友好的用户社区和通用的可用性。学习这个包需要一点时间,但是非常值得努力。

在本节中,我们只是浅尝了 Matplotlib 的表面。您可以在matplotlib.org/2.0.0/index.html(Matplotlib 项目页面)和github.com/matplotlib/matplotlib(Matplotlib GitHub 存储库)找到更多资源。

在接下来的部分中,我们将看一下另一个流行的 Python 图形库:Pygal

Pygal

Pygal(www.pygal.org/)是一个用 Python 编写的动态 SVG 图表库。在我看来,Pygal 的最大优势是它能够轻松本地生成可伸缩矢量图形SVG)格式的图形。SVG 相对于其他图形格式有许多优势,但其中两个主要优势是它对 Web 浏览器友好,并且提供了可伸缩性而不会损失图像质量。换句话说,您可以在任何现代 Web 浏览器中显示生成的图像,并且可以放大和缩小图像,而不会丢失图形的细节。我提到了我们可以在几行 Python 代码中做到这一点吗?这有多酷?

安装

安装是通过pip完成的:

$ sudo pip install pygal #Python 2
$ sudo pip3 install pygal

Pygal - 第一个例子

让我们看一下 Pygal 文档中演示的线图示例,网址为pygal.org/en/stable/documentation/types/line.html

>>> import pygal
>>> line_chart = pygal.Line()
>>> line_chart.title = 'Browser usage evolution (in %)'
>>> line_chart.x_labels = map(str, range(2002, 2013))
>>> line_chart.add('Firefox', [None, None, 0, 16.6, 25, 31, 36.4, 45.5, 46.3, 42.8, 37.1])
<pygal.graph.line.Line object at 0x7fa0bb009c50>
>>> line_chart.add('Chrome', [None, None, None, None, None, None, 0, 3.9, 10.8, 23.8, 35.3])
<pygal.graph.line.Line object at 0x7fa0bb009c50>
>>> line_chart.add('IE', [85.8, 84.6, 84.7, 74.5, 66, 58.6, 54.7, 44.8, 36.2, 26.6, 20.1])
<pygal.graph.line.Line object at 0x7fa0bb009c50>
>>> line_chart.add('Others', [14.2, 15.4, 15.3, 8.9, 9, 10.4, 8.9, 5.8, 6.7, 6.8, 7.5])
<pygal.graph.line.Line object at 0x7fa0bb009c50>
>>> line_chart.render_to_file('pygal_example_1.svg')

在这个例子中,我们创建了一个带有x_labels的线对象,自动呈现为 11 个单位的字符串。每个对象都可以以列表格式添加标签和值,例如 Firefox、Chrome 和 IE。

这是在 Firefox 中查看的结果图:

Pygal 示例图

现在我们可以看到 Pygal 的一般用法,我们可以使用相同的方法来绘制我们手头上的 SNMP 结果。我们将在接下来的部分中进行这样做。

Pygal 用于 SNMP 结果

对于 Pygal 线图,我们可以大致按照 Matplotlib 示例的相同模式进行操作,其中我们通过读取文件创建值列表。我们不再需要将x-轴值转换为内部浮点数,就像我们为 Matplotlib 所做的那样;但是,我们确实需要将我们将在浮点数中收到的每个值中的数字转换为浮点数:

  #!/usr/bin/env python3

  import pygal

  x_time = []
  out_octets = []
  out_packets = []
  in_octets = []
  in_packets = []

  with open('results.txt', 'r') as f:
      for line in f.readlines():
          line = eval(line)
          x_time.append(line['Time'])
          out_packets.append(float(line['Gig0-0_Out_uPackets']))
          out_octets.append(float(line['Gig0-0_Out_Octet']))
          in_packets.append(float(line['Gig0-0_In_uPackets']))
          in_octets.append(float(line['Gig0-0_In_Octet']))

我们可以使用我们看到的相同机制来构建线图:

  line_chart = pygal.Line()
  line_chart.title = "Router 1 Gig0/0"
  line_chart.x_labels = x_time
  line_chart.add('out_octets', out_octets)
  line_chart.add('out_packets', out_packets)
  line_chart.add('in_octets', in_octets)
  line_chart.add('in_packets', in_packets)
  line_chart.render_to_file('pygal_example_2.svg')

结果与我们已经看到的类似,但是图表现在以 SVG 格式呈现,可以轻松地显示在网页上。它可以在现代 Web 浏览器中查看:

路由器 1—Pygal 多线图

就像 Matplotlib 一样,Pygal 为图表提供了更多的选项。例如,要在 Pygal 中绘制我们之前看到的饼图,我们可以使用pygal.Pie()对象:

#!/usr/bin/env python3

import pygal

line_chart = pygal.Pie()
line_chart.title = "Protocol Breakdown"
line_chart.add('TCP', 15)
line_chart.add('UDP', 30)
line_chart.add('ICMP', 45)
line_chart.add('Others', 10)
line_chart.render_to_file('pygal_example_3.svg')

生成的 SVG 文件将类似于 Matplotlib 生成的 PNG:

Pygal 饼图

其他 Pygal 资源

Pygal 为您从基本网络监控工具(如 SNMP)收集的数据提供了更多可定制的功能和图形能力。在本节中,我们演示了简单的线图和饼图。您可以在此处找到有关项目的更多信息:

在接下来的部分中,我们将继续使用 SNMP 主题进行网络监控,但使用一个名为Cacti的功能齐全的网络监控系统。

Cacti 的 Python

在我作为地区 ISP 的初级网络工程师工作的早期,我们使用开源跨平台多路由器流量图MRTG)(en.wikipedia.org/wiki/Multi_Router_Traffic_Grapher)工具来检查网络链路上的流量负载。我们几乎完全依赖于该工具进行流量监控。我真的很惊讶开源项目可以有多好和有用。这是第一个将 SNMP、数据库和 HTML 的细节抽象化为网络工程师的开源高级网络监控系统之一。然后出现了循环数据库工具RRDtool)(en.wikipedia.org/wiki/RRDtool)。在 1999 年的首次发布中,它被称为“正确的 MRTG”。它极大地改进了后端的数据库和轮询器性能。

Cacti([en.wikipedia.org/wiki/Cacti_(software)](https://en.wikipedia.org/wiki/Cacti_(software))于 2001 年发布,是一个开源的基于 Web 的网络监控和图形工具,旨在作为 RRDtool 的改进前端。由于 MRTG 和 RRDtool 的传承,您会注意到熟悉的图表布局、模板和 SNMP 轮询器。作为一个打包工具,安装和使用将需要保持在工具本身的范围内。但是,Cacti 提供了我们可以使用 Python 的自定义数据查询功能。在本节中,我们将看到如何将 Python 用作 Cacti 的输入方法。

安装

在 Ubuntu 上使用 APT 进行安装非常简单:

$ sudo apt-get install cacti

这将触发一系列安装和设置步骤,包括 MySQL 数据库、Web 服务器(Apache 或 lighttpd)和各种配置任务。安装完成后,导航到http://<ip>/cacti开始使用。最后一步是使用默认用户名和密码(admin/admin)登录;您将被提示更改密码。

一旦你登录,你可以按照文档添加设备并将其与模板关联。有一个预制的 Cisco 路由器模板可以使用。Cacti 在docs.cacti.net/上有关于添加设备和创建第一个图形的良好文档,所以我们将快速查看一些你可以期望看到的屏幕截图:

当你能看到设备的正常运行时间时,这是 SNMP 通信正在工作的一个标志:

你可以为设备添加接口流量和其他统计信息的图形:

一段时间后,你会开始看到流量,如下所示:

我们现在准备看一下如何使用 Python 脚本来扩展 Cacti 的数据收集功能。

Python 脚本作为输入源

在我们尝试将 Python 脚本作为输入源之前,有两份文档我们应该阅读:

有人可能会想知道使用 Python 脚本作为数据输入扩展的用例是什么。其中一个用例是为那些没有相应 OID 的资源提供监控,例如,如果我们想知道访问列表permit_snmp允许主机172.16.1.173进行 SNMP 查询的次数。我们知道我们可以通过 CLI 看到匹配的次数:

iosv-1#sh ip access-lists permit_snmp | i 172.16.1.173
 10 permit 172.16.1.173 log (6362 matches)

然而,很可能与这个值没有关联的 OID(或者我们可以假装没有)。这就是我们可以使用外部脚本生成一个可以被 Cacti 主机消耗的输出的地方。

我们可以重用我们在第二章中讨论的 Pexpect 脚本,chapter1_1.py。我们将其重命名为cacti_1.py。除了执行 CLI 命令并保存输出之外,一切都应该与原始脚本一样熟悉:

for device in devices.keys():
...
    child.sendline('sh ip access-lists permit_snmp | i 172.16.1.173')
    child.expect(device_prompt)
    output = child.before
...

原始形式的输出如下:

b'sh ip access-lists permit_snmp | i 172.16.1.173rn 10 permit 172.16.1.173 log (6428 matches)rn'

我们将使用split()函数对字符串进行处理,只留下匹配的次数并在脚本中将其打印到标准输出:

print(str(output).split('(')[1].split()[0])

为了测试这一点,我们可以执行脚本多次来查看增量的数量:

$ ./cacti_1.py
6428
$ ./cacti_1.py
6560
$ ./cacti_1.py
6758

我们可以将脚本设置为可执行,并将其放入默认的 Cacti 脚本位置:

$ chmod a+x cacti_1.py
$ sudo cp cacti_1.py /usr/share/cacti/site/scripts/

Cacti 文档,可在www.cacti.net/downloads/docs/html/how_to.html上找到,提供了如何将脚本结果添加到输出图形的详细步骤。这些步骤包括将脚本添加为数据输入方法,将输入方法添加到数据源,然后创建一个图形进行查看:

SNMP 是提供网络监控服务给设备的常见方式。RRDtool 与 Cacti 作为前端提供了一个良好的平台,可以通过 SNMP 用于所有的网络设备。

总结

在本章中,我们探讨了通过 SNMP 执行网络监控的方法。我们在网络设备上配置了与 SNMP 相关的命令,并使用了我们的网络管理 VM 与 SNMP 轮询程序来查询设备。我们使用了 PySNMP 模块来简化和自动化我们的 SNMP 查询。我们还学习了如何将查询结果保存在一个平面文件或数据库中,以便用于将来的示例。

在本章的后面,我们使用了两种不同的 Python 可视化包,即 Matplotlib 和 Pygal,来绘制 SNMP 结果的图表。每个包都有其独特的优势。Matplotlib 是一个成熟、功能丰富的库,在数据科学项目中被广泛使用。Pygal 可以原生生成灵活且适合网络的 SVG 格式图表。我们看到了如何生成对网络监控相关的折线图和饼图。

在本章的末尾,我们看了一个名为 Cacti 的全面网络监控工具。它主要使用 SNMP 进行网络监控,但我们看到当远程主机上没有 SNMP OID 时,我们可以使用 Python 脚本作为输入源来扩展平台的监控能力。

在第八章中,《使用 Python 进行网络监控-第 2 部分》,我们将继续讨论我们可以使用的工具来监控我们的网络,并了解网络是否表现如预期。我们将研究使用 NetFlow、sFlow 和 IPFIX 进行基于流的监控。我们还将使用诸如 Graphviz 之类的工具来可视化我们的网络拓扑,并检测任何拓扑变化。最后,我们将使用 Elasticsearch、Logstash 和 Kibana,通常被称为 ELK 堆栈,来监控网络日志数据以及其他与网络相关的输入。

第八章:使用 Python 进行网络监控-第 2 部分

在第七章中,使用 Python 进行网络监控-第 1 部分,我们使用 SNMP 从网络设备查询信息。我们通过使用 SNMP 管理器查询驻留在网络设备上的 SNMP 代理来实现这一点。SNMP 信息以层次结构格式化,具有特定的对象 ID 来表示对象的值。大多数时候,我们关心的值是一个数字,比如 CPU 负载、内存使用率或接口流量。这是我们可以根据时间绘制图表,以便让我们了解值随时间的变化。

我们通常可以将 SNMP 方法归类为“拉”方法,因为我们不断地向设备请求特定的答案。这种方法会给设备增加负担,因为它需要在控制平面上花费 CPU 周期从子系统中找到答案,将答案打包成一个 SNMP 数据包,并将答案传输回轮询器。如果你曾经参加过家庭聚会,有一个家庭成员一遍又一遍地问你同样的问题,那就相当于 SNMP 管理器不断轮询受管节点。

随着时间的推移,如果我们有多个 SNMP 轮询器每 30 秒查询同一个设备(你会惊讶地发现这种情况经常发生),管理开销将变得相当大。在我们给出的家庭聚会的例子中,想象一下不是一个家庭成员,而是许多其他人每 30 秒打断你问你一个问题。我不知道你怎么想,但我知道即使是一个简单的问题(或者更糟糕的是,如果所有人都问同样的问题),我也会感到非常恼火。

我们可以提供更有效的网络监控的另一种方法是将管理站与设备之间的关系从拉模型转变为推模型。换句话说,信息可以以约定的格式从设备推送到管理站。这个概念是基于基于流的监控。在基于流的模型中,网络设备将流量信息流向管理站。格式可以是思科专有的 NetFlow(版本 5 或版本 9),行业标准 IPFIX,或开源 sFlow 格式。在本章中,我们将花一些时间用 Python 来研究 NetFlow、IPFIX 和 sFlow。

并非所有的监控都以时间序列数据的形式出现。如果你真的愿意,你可以将网络拓扑和 Syslog 等信息表示为时间序列格式,但这并不理想。我们可以使用 Python 来检查网络拓扑信息,并查看拓扑是否随时间发生了变化。我们可以使用 Graphviz 等工具与 Python 包装器来说明拓扑。正如在第六章中已经看到的,使用 Python 进行网络安全,Syslog 包含安全信息。在本章中,我们将研究使用 ELK 堆栈(Elasticsearch、Logstash、Kibana)作为收集和索引网络日志信息的有效方法。

具体来说,在本章中,我们将涵盖以下主题:

  • Graphviz,这是一个开源的图形可视化软件,可以帮助我们快速高效地绘制网络图

  • 基于流的监控,如 NetFlow、IPFIX 和 sFlow

  • 使用 ntop 来可视化流量信息

  • 使用 Elasticsearch 来索引和分析我们收集的数据

让我们首先看看如何使用 Graphviz 作为监控网络拓扑变化的工具。

Graphviz

Graphviz 是一种开源的图形可视化软件。想象一下,如果我们不用图片的好处来描述我们的网络拓扑给同事。我们可能会说,我们的网络由三层组成:核心、分发和接入。核心层包括两台路由器用于冗余,并且这两台路由器都对四台分发路由器进行全网状连接;分发路由器也对接入路由器进行全网状连接。内部路由协议是 OSPF,外部使用 BGP 与服务提供商进行对等连接。虽然这个描述缺少一些细节,但对于您的同事来说,这可能足够绘制出您网络的一个相当不错的高层图像。

Graphviz 的工作方式类似于通过描述 Graphviz 可以理解的文本格式来描述图形,然后我们可以将文件提供给 Graphviz 程序来为我们构建图形。在这里,图形是用一种称为 DOT 的文本格式描述的([en.wikipedia.org/wiki/DOT_(graph_description_language)](https://en.wikipedia.org/wiki/DOT_(graph_description_language))),Graphviz 根据描述渲染图形。当然,因为计算机缺乏人类的想象力,语言必须非常精确和详细。

对于 Graphviz 特定的 DOT 语法定义,请查看www.graphviz.org/doc/info/lang.html

在本节中,我们将使用链路层发现协议LLDP)来查询设备邻居,并通过 Graphviz 创建网络拓扑图。完成这个广泛的示例后,我们将看到如何将新的东西,比如 Graphviz,与我们已经学到的东西结合起来解决有趣的问题。

让我们开始构建我们将要使用的实验室。

实验室设置

我们将使用 VIRL 来构建我们的实验室。与前几章一样,我们将组建一个包括多个路由器、一个服务器和一个客户端的实验室。我们将使用五个 IOSv 网络节点以及两个服务器主机:

如果您想知道我们选择 IOSv 而不是 NX-OS 或 IOS-XR 以及设备数量的原因,在构建自己的实验室时,请考虑以下几点:

  • 由 NX-OS 和 IOS-XR 虚拟化的节点比 IOS 更占用内存

  • 我使用的 VIRL 虚拟管理器有 8GB 的 RAM,似乎足够支持九个节点,但可能会有点不稳定(节点随机从可达到不可达)

  • 如果您希望使用 NX-OS,请考虑使用 NX-API 或其他 API 调用来返回结构化数据

对于我们的示例,我们将使用 LLDP 作为链路层邻居发现的协议,因为它是与厂商无关的。请注意,VIRL 提供了自动启用 CDP 的选项,这可以节省一些时间,并且在功能上类似于 LLDP;但是,它是一种思科专有技术,因此我们将在我们的实验室中禁用它:

实验室建立完成后,继续安装必要的软件包。

安装

可以通过apt获取 Graphviz:

$ sudo apt-get -y install graphviz

安装完成后,请注意使用dot命令进行验证:

$ dot -V
dot - graphviz version 2.38.0 (20140413.2041)~

我们将使用 Graphviz 的 Python 包装器,所以让我们现在安装它:

$ sudo pip install graphviz #Python 2
$ sudo pip3 install graphviz

$ python3
Python 3.5.2 (default, Nov 23 2017, 16:37:01)
[GCC 5.4.0 20160609] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import graphviz
>>> graphviz.__version__
'0.8.4'
>>> exit() 

让我们看看如何使用这个软件。

Graphviz 示例

像大多数流行的开源项目一样,Graphviz 的文档(www.graphviz.org/Documentation.php)是非常广泛的。对于新手来说,挑战通常在于从何处开始。对于我们的目的,我们将专注于绘制有向图的 dot 图,这是一种层次结构(不要与 DOT 语言混淆,DOT 语言是一种图描述语言)。

让我们从一些基本概念开始:

我们的第一个例子是一个无向点图,由四个节点(coredistributionaccess1access2)组成。边由破折号-符号表示,将核心节点连接到分布节点,以及将分布节点连接到两个访问节点:

$ cat chapter8_gv_1.gv
graph my_network {
 core -- distribution;
 distribution -- access1;
 distribution -- access2;
}

图表可以在命令行中输出为dot -T<format> source -o <output file>

$ dot -Tpng chapter8_gv_1.gv -o output/chapter8_gv_1.png

生成的图表可以从以下输出文件夹中查看:

就像第七章中的使用 Python 进行网络监控-第 1 部分一样,当处理这些图表时,可能更容易在 Linux 桌面窗口中工作,这样你就可以立即看到图表。

请注意,我们可以通过将图表指定为有向图,并使用箭头(->)符号来表示边来使用有向图。在节点和边的情况下,有几个属性可以修改,例如节点形状、边标签等。同一个图表可以修改如下:

$ cat chapter8_gv_2.gv
digraph my_network {
 node [shape=box];
 size = "50 30";
 core -> distribution [label="2x10G"];
 distribution -> access1 [label="1G"];
 distribution -> access2 [label="1G"];
}

这次我们将文件输出为 PDF:

$ dot -Tpdf chapter8_gv_2.gv -o output/chapter8_gv_2.pdf

看一下新图表中的方向箭头:

现在让我们看一下围绕 Graphviz 的 Python 包装器。

Python 与 Graphviz 示例

我们可以使用我们安装的 Python Graphviz 包再次生成与之前相同的拓扑图:

$ python3
Python 3.5.2 (default, Nov 17 2016, 17:05:23)
>>> from graphviz import Digraph
>>> my_graph = Digraph(comment="My Network")
>>> my_graph.node("core")
>>> my_graph.node("distribution")
>>> my_graph.node("access1")
>>> my_graph.node("access2")
>>> my_graph.edge("core", "distribution")
>>> my_graph.edge("distribution", "access1")
>>> my_graph.edge("distribution", "access2")

该代码基本上产生了您通常会用 DOT 语言编写的内容,但以更 Pythonic 的方式。您可以在生成图表之前查看图表的源代码:

>>> print(my_graph.source)
// My Network
digraph {
 core
 distribution
 access1
 access2
 core -> distribution
 distribution -> access1
 distribution -> access2
} 

图表可以通过render()方法呈现;默认情况下,输出格式为 PDF:

>>> my_graph.render("output/chapter8_gv_3.gv")
'output/chapter8_gv_3.gv.pdf'

Python 包装器紧密模仿了 Graphviz 的所有 API 选项。您可以在 Graphviz Read the Docs 网站(graphviz.readthedocs.io/en/latest/index.html)上找到有关选项的文档。您还可以在 GitHub 上查看源代码以获取更多信息(github.com/xflr6/graphviz)。我们现在准备使用这个工具来绘制我们的网络。

LLDP 邻居图

在本节中,我们将使用映射 LLDP 邻居的示例来说明多年来帮助我的问题解决模式:

  1. 如果可能的话,将每个任务模块化为更小的部分。在我们的例子中,我们可以合并几个步骤,但如果我们将它们分解成更小的部分,我们将能够更容易地重用和改进它们。

  2. 使用自动化工具与网络设备交互,但将更复杂的逻辑保留在管理站。例如,路由器提供了一个有点混乱的 LLDP 邻居输出。在这种情况下,我们将坚持使用可行的命令和输出,并在管理站使用 Python 脚本来解析我们需要的输出。

  3. 在面对相同任务的选择时,选择可以重复使用的选项。在我们的例子中,我们可以使用低级别的 Pexpect、Paramiko 或 Ansible playbooks 来查询路由器。在我看来,Ansible 是一个更可重用的选项,所以我选择了它。

要开始,因为路由器默认情况下未启用 LLDP,我们需要首先在设备上配置它们。到目前为止,我们知道我们有许多选择;在这种情况下,我选择了使用ios_config模块的 Ansible playbook 来完成任务。hosts文件包括五台路由器:

$ cat hosts
[devices]
r1 ansible_hostname=172.16.1.218
r2 ansible_hostname=172.16.1.219
r3 ansible_hostname=172.16.1.220
r5-tor ansible_hostname=172.16.1.221
r6-edge ansible_hostname=172.16.1.222

cisco_config_lldp.yml playbook 包括一个 play,其中嵌入了用于配置 LLDP 的变量:

<skip>
 vars:
   cli:
     host: "{{ ansible_hostname }}"
     username: cisco
     password: cisco
     transport: cli tasks:
  - name: enable LLDP run
       ios_config:
         lines: lldp run
         provider: "{{ cli }}"
<skip>

几秒钟后,为了允许 LLDP 交换,我们可以验证 LLDP 确实在路由器上处于活动状态:

$ ansible-playbook -i hosts cisco_config_lldp.yml

PLAY [Enable LLDP] ***********************************************************
...
PLAY RECAP *********************************************************************
r1 : ok=2 changed=1 unreachable=0 failed=0
r2 : ok=2 changed=1 unreachable=0 failed=0
r3 : ok=2 changed=1 unreachable=0 failed=0
r5-tor : ok=2 changed=1 unreachable=0 failed=0
r6-edge : ok=2 changed=1 unreachable=0 failed=0

## SSH to R1 for verification
r1#show lldp neighbors

Capability codes: (R) Router, (B) Bridge, (T) Telephone, (C) DOCSIS Cable Device (W) WLAN Access Point, (P) Repeater, (S) Station, (O) Other

Device ID Local Intf Hold-time Capability Port ID
r2.virl.info Gi0/0 120 R Gi0/0
r3.virl.info Gi0/0 120 R Gi0/0
r5-tor.virl.info Gi0/0 120 R Gi0/0
r5-tor.virl.info Gi0/1 120 R Gi0/1
r6-edge.virl.info Gi0/2 120 R Gi0/1
r6-edge.virl.info Gi0/0 120 R Gi0/0

Total entries displayed: 6

在输出中,您将看到G0/0配置为 MGMT 接口;因此,您将看到 LLDP 对等方,就好像它们在一个平坦的管理网络上一样。我们真正关心的是连接到其他对等方的G0/1G0/2接口。当我们准备解析输出并构建我们的拓扑图时,这些知识将派上用场。

信息检索

我们现在可以使用另一个 Ansible playbook,即cisco_discover_lldp.yml,在设备上执行 LLDP 命令,并将每个设备的输出复制到tmp目录中:

<skip>
 tasks:
   - name: Query for LLDP Neighbors
     ios_command:
       commands: show lldp neighbors
       provider: "{{ cli }}"
<skip>

./tmp 目录现在包含所有路由器的输出(显示 LLDP 邻居)的文件:

$ ls -l tmp/
total 20
-rw-rw-r-- 1 echou echou 630 Mar 13 17:12 r1_lldp_output.txt
-rw-rw-r-- 1 echou echou 630 Mar 13 17:12 r2_lldp_output.txt
-rw-rw-r-- 1 echou echou 701 Mar 12 12:28 r3_lldp_output.txt
-rw-rw-r-- 1 echou echou 772 Mar 12 12:28 r5-tor_lldp_output.txt
-rw-rw-r-- 1 echou echou 630 Mar 13 17:12 r6-edge_lldp_output.txt

r1_lldp_output.txt的内容是我们 Ansible playbook 中的output.stdout_lines变量:

$ cat tmp/r1_lldp_output.txt

[["Capability codes:", " (R) Router, (B) Bridge, (T) Telephone, (C) DOCSIS Cable Device", " (W) WLAN Access Point, (P) Repeater, (S) Station, (O) Other", "", "Device ID Local Intf Hold-time Capability Port ID", "r2.virl.info Gi0/0 120 R Gi0/0", "r3.virl.info Gi0/0 120 R Gi0/0", "r5-tor.virl.info Gi0/0 120 R Gi0/0", "r5-tor.virl.info Gi0/1 120 R Gi0/1", "r6-edge.virl.info Gi0/0 120 R Gi0/0", "", "Total entries displayed: 5", ""]]

Python 解析脚本

我们现在可以使用 Python 脚本解析每个设备的 LLDP 邻居输出,并从结果构建网络拓扑图。目的是自动检查设备,看看 LLDP 邻居是否由于链路故障或其他问题而消失。让我们看看cisco_graph_lldp.py文件,看看是如何做到的。

我们从包的必要导入开始:一个空列表,我们将用节点关系的元组填充它。我们也知道设备上的Gi0/0连接到管理网络;因此,我们只在show LLDP neighbors输出中搜索Gi0/[1234]作为我们的正则表达式模式:

import glob, re
from graphviz import Digraph, Source
pattern = re.compile('Gi0/[1234]')
device_lldp_neighbors = []

我们将使用glob.glob()方法遍历./tmp目录中的所有文件,解析出设备名称,并找到设备连接的邻居。脚本中有一些嵌入的打印语句,我们可以在最终版本中注释掉;如果取消注释,我们可以看到解析的结果:

device: r1
 neighbors: r5-tor
 neighbors: r6-edge
device: r5-tor
 neighbors: r2
 neighbors: r3
 neighbors: r1
device: r2
 neighbors: r5-tor
 neighbors: r6-edge
device: r3
 neighbors: r5-tor
 neighbors: r6-edge
device: r6-edge
 neighbors: r2
 neighbors: r3
 neighbors: r1

完全填充的边列表包含了由设备及其邻居组成的元组:

Edges: [('r1', 'r5-tor'), ('r1', 'r6-edge'), ('r5-tor', 'r2'), ('r5-tor', 'r3'), ('r5-tor', 'r1'), ('r2', 'r5-tor'), ('r2', 'r6-edge'), ('r3', 'r5-tor'), ('r3', 'r6-edge'), ('r6-edge', 'r2'), ('r6-edge', 'r3'), ('r6-edge', 'r1')]

我们现在可以使用 Graphviz 包构建网络拓扑图。最重要的部分是解压代表边关系的元组:

my_graph = Digraph("My_Network")
<skip>
# construct the edge relationships
for neighbors in device_lldp_neighbors:
    node1, node2 = neighbors
    my_graph.edge(node1, node2)

如果我们打印出结果的源 dot 文件,它将是我们网络的准确表示:

digraph My_Network {
   r1 -> "r5-tor"
   r1 -> "r6-edge"
   "r5-tor" -> r2
   "r5-tor" -> r3
   "r5-tor" -> r1
   r2 -> "r5-tor"
   r2 -> "r6-edge"
   r3 -> "r5-tor"
   r3 -> "r6-edge"
   "r6-edge" -> r2
   "r6-edge" -> r3
   "r6-edge" -> r1
}

有时,看到相同的链接两次会让人困惑;例如,r2r5-tor的链接在上一个图表中每个方向都出现了两次。作为网络工程师,我们知道有时物理链接故障会导致单向链接,我们希望看到这种情况。

如果我们按原样绘制图表,节点的放置会有点奇怪。节点的放置是自动渲染的。以下图表说明了默认布局以及neato布局的渲染,即有向图(My_Networkengine='neato'):

neato布局表示尝试绘制更少层次结构的无向图:

有时,工具提供的默认布局就很好,特别是如果你的目标是检测故障而不是使其视觉上吸引人。然而,在这种情况下,让我们看看如何将原始 DOT 语言旋钮插入源文件。通过研究,我们知道可以使用rank命令指定一些节点可以保持在同一级别。然而,在 Graphviz Python API 中没有提供这个选项。幸运的是,dot 源文件只是一个字符串,我们可以使用replace()方法插入原始 dot 注释,如下所示:

source = my_graph.source
original_text = "digraph My_Network {"
new_text = 'digraph My_Network {n{rank=same Client "r6-edge"}n{rank=same r1 r2 r3}n'
new_source = source.replace(original_text, new_text)
new_graph = Source(new_source)new_graph.render("output/chapter8_lldp_graph.gv")

最终结果是一个新的源文件,我们可以从中渲染最终的拓扑图:

digraph My_Network {
{rank=same Client "r6-edge"}
{rank=same r1 r2 r3}
                Client -> "r6-edge"
                "r5-tor" -> Server
                r1 -> "r5-tor"
                r1 -> "r6-edge"
                "r5-tor" -> r2
                "r5-tor" -> r3
                "r5-tor" -> r1
                r2 -> "r5-tor"
                r2 -> "r6-edge"
                r3 -> "r5-tor"
                r3 -> "r6-edge"
               "r6-edge" -> r2
               "r6-edge" -> r3
               "r6-edge" -> r1
}

图现在可以使用了:

最终 playbook

我们现在准备将这个新的解析脚本重新整合到我们的 playbook 中。我们现在可以添加渲染输出和图形生成的额外任务到cisco_discover_lldp.yml中:

  tasks:
    - name: Query for LLDP Neighbors
      ios_command:
        commands: show lldp neighbors
        provider: "{{ cli }}"

      register: output

    - name: show output
      debug:
        var: output

    - name: copy output to file
      copy: content="{{ output.stdout_lines }}" dest="./tmp/{{ inventory_hostname }}_lldp_output.txt"

    - name: Execute Python script to render output
      command: ./cisco_graph_lldp.py

这本 playbook 现在将包括四个任务,涵盖了在 Cisco 设备上执行show lldp命令的端到端过程,将输出显示在屏幕上,将输出复制到单独的文件,然后通过 Python 脚本呈现输出。

playbook 现在可以通过cron或其他方式定期运行。它将自动查询设备的 LLDP 邻居并构建图表,该图表将代表路由器所知的当前拓扑结构。

我们可以通过关闭r6-edge上的Gi0/1Go0/2接口来测试这一点。当 LLDP 邻居超时时,它们将从r6-edge的 LLDP 表中消失。

r6-edge#sh lldp neighbors
...
Device ID Local Intf Hold-time Capability Port ID
r2.virl.info Gi0/0 120 R Gi0/0
r3.virl.info Gi0/3 120 R Gi0/2
r3.virl.info Gi0/0 120 R Gi0/0
r5-tor.virl.info Gi0/0 120 R Gi0/0
r1.virl.info Gi0/0 120 R Gi0/0

Total entries displayed: 5

如果我们执行这个 playbook,图表将自动显示r6-edge只连接到r3,我们可以开始排查为什么会这样。

这是一个相对较长的例子。我们使用了书中学到的工具——Ansible 和 Python——来模块化和将任务分解为可重用的部分。然后我们使用了一个新工具,即 Graphviz,来帮助监视网络的非时间序列数据,如网络拓扑关系。

基于流的监控

正如章节介绍中提到的,除了轮询技术(如 SNMP)之外,我们还可以使用推送策略,允许设备将网络信息推送到管理站点。NetFlow 及其密切相关的 IPFIX 和 sFlow 就是从网络设备向管理站点推送的信息的例子。我们可以认为推送方法更具可持续性,因为网络设备本身负责分配必要的资源来推送信息。例如,如果设备的 CPU 繁忙,它可以选择跳过流导出过程,而优先路由数据包,这正是我们想要的。

根据 IETF 的定义,流是从发送应用程序到接收应用程序的一系列数据包。如果我们回顾 OSI 模型,流就是构成两个应用程序之间通信的单个单位。每个流包括多个数据包;有些流有更多的数据包(如视频流),而有些只有几个(如 HTTP 请求)。如果你思考一下流,你会注意到路由器和交换机可能关心数据包和帧,但应用程序和用户通常更关心网络流。

基于流的监控通常指的是 NetFlow、IPFIX 和 sFlow:

  • NetFlow:NetFlow v5 是一种技术,网络设备会缓存流条目,并通过匹配元组集(源接口、源 IP/端口、目的 IP/端口等)来聚合数据包。一旦流完成,网络设备会导出流特征,包括流中的总字节数和数据包计数,到管理站点。

  • IPFIX:IPFIX 是结构化流的提议标准,类似于 NetFlow v9,也被称为灵活 NetFlow。基本上,它是一个可定义的流导出,允许用户导出网络设备了解的几乎任何内容。灵活性往往是以简单性为代价的,与 NetFlow v5 相比,IPFIX 的配置更加复杂。额外的复杂性使其不太适合初学者学习。但是,一旦你熟悉了 NetFlow v5,你就能够解析 IPFIX,只要你匹配模板定义。

  • sFlow:sFlow 实际上没有流或数据包聚合的概念。它对数据包进行两种类型的抽样。它随机抽样n个数据包/应用程序,并具有基于时间的抽样计数器。它将信息发送到管理站,管理站通过参考接收到的数据包样本类型和计数器来推导网络流信息。由于它不在网络设备上执行任何聚合,可以说 sFlow 比 NetFlow 和 IPFIX 更具可扩展性。

了解每个模块的最佳方法可能是直接进入示例。

使用 Python 解析 NetFlow

我们可以使用 Python 解析在线上传输的 NetFlow 数据报。这为我们提供了一种详细查看 NetFlow 数据包以及在其工作不如预期时排除任何 NetFlow 问题的方法。

首先,让我们在 VIRL 网络的客户端和服务器之间生成一些流量。我们可以使用 Python 的内置 HTTP 服务器模块快速在充当服务器的 VIRL 主机上启动一个简单的 HTTP 服务器:

cisco@Server:~$ python3 -m http.server
Serving HTTP on 0.0.0.0 port 8000 ...

对于 Python 2,该模块的名称为SimpleHTTPServer;例如,python2 -m SimpleHTTPServer

我们可以在 Python 脚本中创建一个简短的while循环,不断向客户端的 Web 服务器发送HTTP GET

sudo apt-get install python-pip python3-pip
sudo pip install requests
sudo pip3 install requests

$ cat http_get.py
import requests, time
while True:
 r = requests.get('http://10.0.0.5:8000')
 print(r.text)
 time.sleep(5)

客户端应该得到一个非常简单的 HTML 页面:

cisco@Client:~$ python3 http_get.py
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 3.2 Final//EN"><html>
<title>Directory listing for /</title>
<body>
...
</body>
</html>

我们还应该看到客户端每五秒不断发出请求:

cisco@Server:~$ python3 -m http.server
Serving HTTP on 0.0.0.0 port 8000 ...
10.0.0.9 - - [15/Mar/2017 08:28:29] "GET / HTTP/1.1" 200 -
10.0.0.9 - - [15/Mar/2017 08:28:34] "GET / HTTP/1.1" 200 -

我们可以从任何设备导出 NetFlow,但由于r6-edge是客户端主机的第一跳,我们将使此路由器将 NetFlow 导出到端口9995的管理主机。

在此示例中,我们仅使用一个设备进行演示;因此,我们手动配置它所需的命令。在下一节中,当我们在所有设备上启用 NetFlow 时,我们将使用 Ansible playbook 一次性配置所有路由器。

在 Cisco IOS 设备上导出 NetFlow 需要以下配置:

!
ip flow-export version 5
ip flow-export destination 172.16.1.173 9995 vrf Mgmt-intf
!
interface GigabitEthernet0/4
 description to Client
 ip address 10.0.0.10 255.255.255.252
 ip flow ingress
 ip flow egress
...
!

接下来,让我们看一下 Python 解析器脚本。

Python socket 和 struct

脚本netFlow_v5_parser.py是从 Brian Rak 的博客文章blog.devicenull.org/2013/09/04/python-netflow-v5-parser.html修改而来。修改主要是为了 Python 3 兼容性以及解析额外的 NetFlow 版本 5 字段。我们选择 NetFlow v5 而不是 NetFlow v9 的原因是 v9 更复杂,使用模板来映射字段,使得在入门会话中更难学习。但是,由于 NetFlow 版本 9 是原始 NetFlow 版本 5 的扩展格式,本节介绍的所有概念都适用于它。

因为 NetFlow 数据包在线上传输时以字节表示,我们将使用标准库中包含的 Python struct 模块将字节转换为本机 Python 数据类型。

您可以在docs.python.org/3.5/library/socket.htmldocs.python.org/3.5/library/struct.html找到有关这两个模块的更多信息。

我们将首先使用 socket 模块绑定和监听 UDP 数据报。使用socket.AF_INET,我们打算监听 IPv4 地址套接字;使用socket.SOCK_DGRAM,我们指定将查看 UDP 数据报:

sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind(('0.0.0.0', 9995))

我们将启动一个循环,并每次从线上检索 1,500 字节的信息:

while True:
        buf, addr = sock.recvfrom(1500)

以下行是我们开始解构或解包数据包的地方。!HH的第一个参数指定了网络的大端字节顺序,感叹号表示大端字节顺序,以及 C 类型的格式(H = 2字节无符号短整数):

(version, count) = struct.unpack('!HH',buf[0:4])

前四个字节包括版本和此数据包中导出的流数。如果您没有记住 NetFlow 版本 5 标头(顺便说一句,这是一个玩笑;我只是在想要快速入睡时才会读标头),这里有一个快速浏览:

NetFlow v5 标头(来源:http://www.cisco.com/c/en/us/td/docs/net_mgmt/netflow_collection_engine/3-6/user/guide/format.html#wp1006108)

其余的标头可以根据字节位置和数据类型进行相应的解析:

 (sys_uptime, unix_secs, unix_nsecs, flow_sequence) = struct.unpack('!IIII', buf[4:20])
 (engine_type, engine_id, sampling_interval) = struct.unpack('!BBH', buf[20:24])

接下来的while循环将使用流记录填充nfdata字典,解包源地址和端口、目的地址和端口、数据包计数和字节计数,并在屏幕上打印出信息:

for i in range(0, count):
    try:
        base = SIZE_OF_HEADER+(i*SIZE_OF_RECORD)
        data = struct.unpack('!IIIIHH',buf[base+16:base+36])
        input_int, output_int = struct.unpack('!HH', buf[base+12:base+16])
        nfdata[i] = {}
        nfdata[i]['saddr'] = inet_ntoa(buf[base+0:base+4])
        nfdata[i]['daddr'] = inet_ntoa(buf[base+4:base+8])
        nfdata[i]['pcount'] = data[0]
        nfdata[i]['bcount'] = data[1]
...

脚本的输出允许您一目了然地查看标头以及流内容:

Headers:
NetFlow Version: 5
Flow Count: 9
System Uptime: 290826756
Epoch Time in seconds: 1489636168
Epoch Time in nanoseconds: 401224368
Sequence counter of total flow: 77616
0 192.168.0.1:26828 -> 192.168.0.5:179 1 packts 40 bytes
1 10.0.0.9:52912 -> 10.0.0.5:8000 6 packts 487 bytes
2 10.0.0.9:52912 -> 10.0.0.5:8000 6 packts 487 bytes
3 10.0.0.5:8000 -> 10.0.0.9:52912 5 packts 973 bytes
4 10.0.0.5:8000 -> 10.0.0.9:52912 5 packts 973 bytes
5 10.0.0.9:52913 -> 10.0.0.5:8000 6 packts 487 bytes
6 10.0.0.9:52913 -> 10.0.0.5:8000 6 packts 487 bytes
7 10.0.0.5:8000 -> 10.0.0.9:52913 5 packts 973 bytes
8 10.0.0.5:8000 -> 10.0.0.9:52913 5 packts 973 bytes

请注意,在 NetFlow 版本 5 中,记录的大小固定为 48 字节;因此,循环和脚本相对简单。但是,在 NetFlow 版本 9 或 IPFIX 的情况下,在标头之后,有一个模板 FlowSet(www.cisco.com/en/US/technologies/tk648/tk362/technologies_white_paper09186a00800a3db9.html),它指定了字段计数、字段类型和字段长度。这使得收集器可以在不事先知道数据格式的情况下解析数据。

通过在脚本中解析 NetFlow 数据,我们对字段有了很好的理解,但这非常繁琐且难以扩展。正如您可能已经猜到的那样,还有其他工具可以帮助我们避免逐个解析 NetFlow 记录的问题。让我们在接下来的部分看看这样的一个工具,名为ntop

ntop 流量监控

就像第七章中的 PySNMP 脚本,以及本章中的 NetFlow 解析器脚本一样,我们可以使用 Python 脚本来处理线路上的低级任务。但是,也有一些工具,比如 Cacti,它是一个包含数据收集(轮询器)、数据存储(RRD)和用于可视化的 web 前端的一体化开源软件包。这些工具可以通过将经常使用的功能和软件打包到一个软件包中来节省大量工作。

在 NetFlow 的情况下,有许多开源和商业 NetFlow 收集器可供选择。如果您快速搜索前 N 个开源 NetFlow 分析器,您将看到许多不同工具的比较研究。它们每个都有自己的优势和劣势;使用哪一个实际上是一种偏好、平台和您对定制的兴趣。我建议选择一个既支持 v5 又支持 v9,可能还支持 sFlow 的工具。其次要考虑的是工具是否是用您能理解的语言编写的;我想拥有 Python 可扩展性会是一件好事。

我喜欢并以前使用过的两个开源 NetFlow 工具是 NfSen(后端收集器为 NFDUMP)和ntop(或ntopng)。在这两者中,ntop是更为知名的流量分析器;它可以在 Windows 和 Linux 平台上运行,并且与 Python 集成良好。因此,在本节中,让我们以ntop为例。

我们的 Ubuntu 主机的安装很简单:

$ sudo apt-get install ntop

安装过程将提示输入必要的接口以进行监听,并设置管理员密码。默认情况下,ntop web 界面监听端口为3000,而探针监听 UDP 端口为5556。在网络设备上,我们需要指定 NetFlow 导出器的位置:

!
ip flow-export version 5
ip flow-export destination 172.16.1.173 5556 vrf Mgmt-intf
!

默认情况下,IOSv 创建一个名为Mgmt-intf的 VRF,并将Gi0/0放在 VRF 下。

我们还需要在接口配置下指定流量导出的方向,比如入口或出口:

!
interface GigabitEthernet0/0
...
 ip flow ingress
 ip flow egress
...

供您参考,我已经包含了 Ansible playbook,cisco_config_netflow.yml,用于配置实验设备进行 NetFlow 导出。

r5-torr6-edger1r2r3多两个接口。

执行 playbook 并确保设备上的更改已正确应用:

$ ansible-playbook -i hosts cisco_config_netflow.yml

TASK [configure netflow export station] ****************************************
changed: [r1]
changed: [r3]
changed: [r2]
changed: [r5-tor]
changed: [r6-edge]

TASK [configure flow export on Gi0/0] ******************************************
changed: [r2]
changed: [r1]
changed: [r6-edge]
changed: [r5-tor]
changed: [r3]
...
PLAY RECAP *********************************************************************
r1 : ok=4 changed=4 unreachable=0 failed=0
r2 : ok=4 changed=4 unreachable=0 failed=0
r3 : ok=4 changed=4 unreachable=0 failed=0
r5-tor : ok=6 changed=6 unreachable=0 failed=0
r6-edge : ok=6 changed=6 unreachable=0 failed=0

##Checking r2 for NetFlow configuration
r2#sh run | i flow
 ip flow ingress
 ip flow egress
 ip flow ingress
 ip flow egress
 ip flow ingress
 ip flow egress
ip flow-export version 5
ip flow-export destination 172.16.1.173 5556 vrf Mgmt-intf 

一切都设置好后,您可以检查 ntop web 界面以查看本地 IP 流量:

ntop 最常用的功能之一是使用它来查看最活跃的对话者图表:

ntop 报告引擎是用 C 编写的;它快速高效,但是需要对 C 有足够的了解才能做一些像改变 web 前端这样简单的事情,这并不符合现代敏捷开发的思维方式。

在 2000 年代中期,ntop 的人们在 Perl 上尝试了几次,最终决定将 Python 嵌入为可扩展的脚本引擎。让我们来看看。

ntop 的 Python 扩展

我们可以使用 Python 通过 ntop web 服务器来扩展 ntop。ntop web 服务器可以执行 Python 脚本。在高层次上,脚本将执行以下操作:

  • 访问 ntop 状态的方法

  • Python CGI 模块处理表单和 URL 参数

  • 制作生成动态 HTML 页面的模板

  • 每个 Python 脚本都可以从stdin读取并打印出stdout/stderr

  • stdout脚本是返回的 HTTP 页面

有几个资源对于 Python 集成非常有用。在 Web 界面下,您可以单击关于|显示配置,以查看 Python 解释器版本以及 Python 脚本的目录:

Python 版本

您还可以检查 Python 脚本应该驻留的各个目录:

插件目录

在关于|在线文档|Python ntop 引擎下,有 Python API 和教程的链接:

Python ntop 文档

如前所述,ntop web 服务器直接执行放置在指定目录下的 Python 脚本:

$ pwd
/usr/share/ntop/python

我们将把我们的第一个脚本,即chapter8_ntop_1.py,放在目录中。Python CGI模块处理表单并解析 URL 参数:

# Import modules for CGI handling
import cgi, cgitb
import ntop

# Parse URL
cgitb.enable();

ntop实现了三个 Python 模块;每个模块都有特定的目的:

  • ntop:此模块与ntop引擎交互

  • 主机:此模块用于深入了解特定主机的信息

  • 接口:此模块表示有关本地主机接口的信息

在我们的脚本中,我们将使用ntop模块来检索ntop引擎信息,并使用sendString()方法发送 HTML 正文文本:

form = cgi.FieldStorage();
name = form.getvalue('Name', default="Eric")

version = ntop.version()
os = ntop.os()
uptime = ntop.uptime()

ntop.printHTMLHeader('Mastering Python Networking', 1, 0)
ntop.sendString("Hello, "+ name +"<br>")
ntop.sendString("Ntop Information: %s %s %s" % (version, os, uptime))
ntop.printHTMLFooter()

我们将使用http://<ip>:3000/python/<script name>来执行 Python 脚本。这是我们的chapter8_ntop_1.py脚本的结果:

我们可以看另一个与接口模块交互的示例,chapter8_ntop_2.py。我们将使用 API 来遍历接口:

import ntop, interface, json

ifnames = []
try:
    for i in range(interface.numInterfaces()):
        ifnames.append(interface.name(i))

except Exception as inst:
    print type(inst) # the exception instance
    print inst.args # arguments stored in .args
    print inst # __str__ allows args to printed directly
...

生成的页面将显示 ntop 接口:

除了社区版本外,ntop 还提供了一些商业产品供您选择。凭借活跃的开源社区、商业支持和 Python 可扩展性,ntop 是您 NetFlow 监控需求的不错选择。

接下来,让我们来看看 NetFlow 的表兄弟:sFlow。

sFlow

sFlow 最初由 InMon(www.inmon.com)开发,后来通过 RFC 进行了标准化。当前版本是 v5。行业内许多人认为 sFlow 的主要优势是其可扩展性。sFlow 使用随机的一种n数据包流样本以及计数器样本的轮询间隔来推导出流量的估计;这比网络设备的 NetFlow 更节省 CPU。sFlow 的统计采样与硬件集成,并提供实时的原始导出。

出于可扩展性和竞争原因,sFlow 通常比 NetFlow 更受新供应商的青睐,例如 Arista Networks、Vyatta 和 A10 Networks。虽然思科在其 Nexus 产品线上支持 sFlow,但通常支持在思科平台上使用 sFlow。

SFlowtool 和 sFlow-RT 与 Python

很遗憾,到目前为止,sFlow 是我们的 VIRL 实验室设备不支持的东西(即使是 NX-OSv 虚拟交换机也不支持)。您可以使用思科 Nexus 3000 交换机或其他支持 sFlow 的供应商交换机,例如 Arista。实验室的另一个好选择是使用 Arista vEOS 虚拟实例。我碰巧可以访问运行 7.0(3)的思科 Nexus 3048 交换机,我将在本节中使用它作为 sFlow 导出器。

思科 Nexus 3000 的 sFlow 配置非常简单:

Nexus-2# sh run | i sflow
feature sflow
sflow max-sampled-size 256
sflow counter-poll-interval 10
sflow collector-ip 192.168.199.185 vrf management
sflow agent-ip 192.168.199.148
sflow data-source interface Ethernet1/48

摄取 sFlow 的最简单方法是使用sflowtool。有关安装说明,请参阅blog.sflow.com/2011/12/sflowtool.html上的文档:

$ wget http://www.inmon.com/bin/sflowtool-3.22.tar.gz
$ tar -xvzf sflowtool-3.22.tar.gz
$ cd sflowtool-3.22/
$ ./configure
$ make
$ sudo make install

安装完成后,您可以启动sflowtool并查看 Nexus 3048 发送到标准输出的数据报:

$ sflowtool
startDatagram =================================
datagramSourceIP 192.168.199.148
datagramSize 88
unixSecondsUTC 1489727283
datagramVersion 5
agentSubId 100
agent 192.168.199.148
packetSequenceNo 5250248
sysUpTime 4017060520
samplesInPacket 1
startSample ----------------------
sampleType_tag 0:4
sampleType COUNTERSSAMPLE
sampleSequenceNo 2503508
sourceId 2:1
counterBlock_tag 0:1001
5s_cpu 0.00
1m_cpu 21.00
5m_cpu 20.80
total_memory_bytes 3997478912
free_memory_bytes 1083838464
endSample ----------------------
endDatagram =================================

sflowtool GitHub 存储库(github.com/sflow/sflowtool)上有许多很好的用法示例;其中之一是使用脚本接收sflowtool输入并解析输出。我们可以使用 Python 脚本来实现这个目的。在chapter8_sflowtool_1.py示例中,我们将使用sys.stdin.readline接收输入,并使用正则表达式搜索仅打印包含单词agent的行当我们看到 sFlow 数据包时:

import sys, re
for line in iter(sys.stdin.readline, ''):
    if re.search('agent ', line):
        print(line.strip())

该脚本可以通过管道传输到sflowtool

$ sflowtool | python3 chapter8_sflowtool_1.py
agent 192.168.199.148
agent 192.168.199.148

还有许多其他有用的输出示例,例如tcpdump,以 NetFlow 版本 5 记录输出,以及紧凑的逐行输出。这使得sflowtool非常灵活,以适应您的监控环境。

ntop 支持 sFlow,这意味着您可以直接将您的 sFlow 导出到 ntop 收集器。如果您的收集器只支持 NetFlow,您可以在 NetFlow 版本 5 格式中使用sflowtool输出的-c选项:

$ sflowtool --help
...
tcpdump output:
   -t - (output in binary tcpdump(1) format)
   -r file - (read binary tcpdump(1) format)
   -x - (remove all IPV4 content)
   -z pad - (extend tcpdump pkthdr with this many zeros
                          e.g. try -z 8 for tcpdump on Red Hat Linux 6.2)

NetFlow output:
 -c hostname_or_IP - (netflow collector host)
 -d port - (netflow collector UDP port)
 -e - (netflow collector peer_as (default = origin_as))
 -s - (disable scaling of netflow output by sampling rate)
 -S - spoof source of netflow packets to input agent IP

或者,您也可以使用 InMon 的 sFlow-RT(www.sflow-rt.com/index.php)作为您的 sFlow 分析引擎。sFlow-RT 从操作员的角度来看,其主要优势在于其庞大的 REST API,可以定制以支持您的用例。您还可以轻松地从 API 中检索指标。您可以在www.sflow-rt.com/reference.php上查看其广泛的 API 参考。

请注意,sFlow-RT 需要 Java 才能运行以下内容:

$ sudo apt-get install default-jre
$ java -version
openjdk version "1.8.0_121"
OpenJDK Runtime Environment (build 1.8.0_121-8u121-b13-0ubuntu1.16.04.2-b13)
OpenJDK 64-Bit Server VM (build 25.121-b13, mixed mode)

安装完成后,下载和运行 sFlow-RT 非常简单(sflow-rt.com/download.php):

$ wget http://www.inmon.com/products/sFlow-RT/sflow-rt.tar.gz
$ tar -xvzf sflow-rt.tar.gz
$ cd sflow-rt/
$ ./start.sh
2017-03-17T09:35:01-0700 INFO: Listening, sFlow port 6343
2017-03-17T09:35:02-0700 INFO: Listening, HTTP port 8008

我们可以将 Web 浏览器指向 HTTP 端口8008并验证安装:

sFlow-RT about

一旦 sFlow-RT 接收到任何 sFlow 数据包,代理和其他指标将出现:

sFlow-RT agents

以下是使用 Python 请求从 sFlow-RT 的 REST API 中检索信息的两个示例:

>>> import requests
>>> r = requests.get("http://192.168.199.185:8008/version")
>>> r.text
'2.0-r1180'
>>> r = requests.get("http://192.168.199.185:8008/agents/json")
>>> r.text
'{"192.168.199.148": {n "sFlowDatagramsLost": 0,n "sFlowDatagramSource": ["192.168.199.148"],n "firstSeen": 2195541,n "sFlowFlowDuplicateSamples": 0,n "sFlowDatagramsReceived": 441,n "sFlowCounterDatasources": 2,n "sFlowFlowOutOfOrderSamples": 0,n "sFlowFlowSamples": 0,n "sFlowDatagramsOutOfOrder": 0,n "uptime": 4060470520,n "sFlowCounterDuplicateSamples": 0,n "lastSeen": 3631,n "sFlowDatagramsDuplicates": 0,n "sFlowFlowDrops": 0,n "sFlowFlowLostSamples": 0,n "sFlowCounterSamples": 438,n "sFlowCounterLostSamples": 0,n "sFlowFlowDatasources": 0,n "sFlowCounterOutOfOrderSamples": 0n}}'

咨询参考文档,了解可用于您需求的其他 REST 端点。接下来,我们将看看另一个工具,称为Elasticsearch,它正在成为 Syslog 索引和一般网络监控的相当流行的工具。

Elasticsearch(ELK 堆栈)

正如我们在本章中所看到的,仅使用我们已经使用的 Python 工具就足以监控您的网络,并具有足够的可扩展性,适用于各种规模的网络,无论大小。然而,我想介绍一个名为Elasticsearchwww.elastic.co/)的额外的开源、通用分布式搜索和分析引擎。它通常被称为ElasticELK 堆栈,用于将Elastic与前端和输入包LogstashKibana结合在一起。

如果您总体上看网络监控,实际上是分析网络数据并理解其中的意义。ELK 堆栈包含 Elasticsearch、Logstash 和 Kibana 作为完整的堆栈,使用 Logstash 摄取信息,使用 Elasticsearch 索引和分析数据,并通过 Kibana 呈现图形输出。它实际上是三个项目合而为一。它还具有灵活性,可以用其他输入替换 Logstash,比如Beats。或者,您可以使用其他工具,比如Grafana,而不是 Kibana 进行可视化。Elastic Co.的 ELK 堆栈还提供许多附加工具,称为X-Pack,用于额外的安全性、警报、监控等。

正如您可能从描述中可以看出,ELK(甚至仅是 Elasticsearch)是一个深入的主题,有许多关于这个主题的书籍。即使只涵盖基本用法,也会占用比我们在这本书中可以空出的更多空间。我曾考虑过将这个主题从书中删除,仅仅是因为它的深度。然而,ELK 已经成为我正在进行的许多项目中非常重要的工具,包括网络监控。我觉得不把它放在书中会对你造成很大的伤害。

因此,我将花几页时间简要介绍这个工具以及一些用例,以及一些信息,让您有兴趣深入了解。我们将讨论以下主题:

  • 建立托管的 ELK 服务

  • Logstash 格式

  • Logstash 格式的 Python 辅助脚本

建立托管的 ELK 服务

整个 ELK 堆栈可以安装为独立服务器或分布在多台服务器上。安装步骤可在www.elastic.co/guide/en/elastic-stack/current/installing-elastic-stack.html上找到。根据我的经验,即使只有少量数据,运行 ELK 堆栈的单个虚拟机通常也会耗尽资源。我第一次尝试将 ELK 作为单个虚拟机运行,仅持续了几天,几乎只有两三个网络设备向其发送日志信息。在作为初学者运行自己的集群的几次不成功尝试之后,我最终决定将 ELK 堆栈作为托管服务运行,这也是我建议您开始使用的方式。

作为托管服务,有两个提供商可以考虑:

目前,AWS 提供了一个免费的套餐,很容易开始使用,并且与当前的 AWS 工具套件紧密集成,例如身份服务(aws.amazon.com/iam/)和 lambda 函数(aws.amazon.com/lambda/)。然而,与 Elastic Cloud 相比,AWS 的 Elasticsearch 服务没有最新的功能,也没有扩展的 x-pack 集成。然而,由于 AWS 提供了免费套餐,我的建议是您从 AWS Elasticsearch 服务开始。如果您后来发现需要比 AWS 提供的更多功能,您总是可以转移到 Elastic Cloud。

设置服务很简单;我们只需要选择我们的区域并为我们的第一个域名命名。设置完成后,我们可以使用访问策略来通过 IP 地址限制输入;确保这是 AWS 将看到的源 IP 地址(如果您的主机 IP 地址在 NAT 防火墙后面被转换,请指定您的公司公共 IP):

Logstash 格式

Logstash 可以安装在您习惯发送网络日志的服务器上。安装步骤可在www.elastic.co/guide/en/logstash/current/installing-logstash.html找到。默认情况下,您可以将 Logstash 配置文件放在/etc/logstash/conf.d/下。该文件采用input-filter-output格式(www.elastic.co/guide/en/logstash/current/advanced-pipeline.html)。在下面的示例中,我们将输入指定为网络日志文件,并使用占位符过滤输入,输出为将消息打印到控制台以及将输出导出到我们的 AWS Elasticsearch 服务实例:

input {
  file {
    type => "network_log"
    path => "path to your network log file"
 }
}
filter {
  if [type] == "network_log" {
  }
}
output {
  stdout { codec => rubydebug }
  elasticsearch {
  index => "logstash_network_log-%{+YYYY.MM.dd}"
  hosts => ["http://<instance>.<region>.es.amazonaws.com"]
  }
}

现在让我们来看看我们可以用 Python 和 Logstash 做的其他事情。

用于 Logstash 格式的 Python 辅助脚本

前面的 Logstash 配置将允许我们摄取网络日志并在 Elasticsearch 上创建索引。如果我们打算放入 ELK 的文本格式不是标准的日志格式,会发生什么?这就是 Python 可以帮助的地方。在下一个示例中,我们将执行以下操作:

  1. 使用 Python 脚本检索 Spamhaus 项目认为是拒收列表的 IP 地址列表(www.spamhaus.org/drop/drop.txt

  2. 使用 Python 日志模块以 Logstash 可以摄取的方式格式化信息

  3. 修改 Logstash 配置文件,以便任何新输入都可以发送到 AWS Elasticsearch 服务

chapter8_logstash_1.py脚本包含我们将使用的代码。除了模块导入之外,我们将定义基本的日志配置。该部分直接配置输出,并且应该与 Logstash 格式匹配:

#!/usr/env/bin python

#https://www.spamhaus.org/drop/drop.txt

import logging, pprint, re
import requests, json, datetime
from collections import OrderedDict

#logging configuration
logging.basicConfig(filename='./tmp/spamhaus_drop_list.log', level=logging.INFO, format='%(asctime)s %(message)s', datefmt='%b %d %I:%M:%S')

我们将定义一些更多的变量,并将请求中的 IP 地址列表保存在一个变量中:

host = 'python_networking'
process = 'spamhause_drop_list'

r = requests.get('https://www.spamhaus.org/drop/drop.txt')
result = r.text.strip()

timeInUTC = datetime.datetime.utcnow().isoformat()
Item = OrderedDict()
Item["Time"] = timeInUTC

脚本的最后一部分是一个循环,用于解析输出并将其写入新的日志文件:

for line in result.split('n'):
    if re.match('^;', line) or line == 'r': # comments
        next
    else:
       ip, record_number = line.split(";")
       logging.warning(host + ' ' + process + ': ' + 'src_ip=' + ip.split("/")[0] + ' record_number=' + record_number.strip())

以下是日志文件条目的示例:

$ cat tmp/spamhaus_drop_list.log
...
Jul 14 11:35:26 python_networking spamhause_drop_list: src_ip=212.92.127.0 record_number=SBL352250
Jul 14 11:35:26 python_networking spamhause_drop_list: src_ip=216.47.96.0 record_number=SBL125132
Jul 14 11:35:26 python_networking spamhause_drop_list: src_ip=223.0.0.0 record_number=SBL230805
Jul 14 11:35:26 python_networking spamhause_drop_list: src_ip=223.169.0.0 record_number=SBL208009
...

然后我们可以相应地修改 Logstash 配置文件以适应我们的新日志格式,首先是添加输入文件位置:

input {
  file {
    type => "network_log"
    path => "path to your network log file"
 }
  file {
    type => "spamhaus_drop_list"
    path => "/home/echou/Master_Python_Networking/Chapter8/tmp/spamhaus_drop_list.log"
 }
}

我们可以使用grok添加更多的过滤配置:

filter { 
  if [type] == "spamhaus_drop_list" {
     grok {
       match => [ "message", "%{SYSLOGTIMESTAMP:timestamp} %{SYSLOGHOST:hostname} %{NOTSPACE:process} src_ip=%{IP:src_ip} %{NOTSPACE:record_number}.*"]
       add_tag => ["spamhaus_drop_list"]
     }
  }
}

我们可以将输出部分保持不变,因为额外的条目将存储在同一索引中。现在我们可以使用 ELK 堆栈来查询、存储和查看网络日志以及 Spamhaus IP 信息。

总结

在本章中,我们看了一些额外的方法,可以利用 Python 来增强我们的网络监控工作。我们首先使用 Python 的 Graphviz 包来创建实时 LLDP 信息报告的网络拓扑图。这使我们能够轻松地显示当前的网络拓扑,以及轻松地注意到任何链路故障。

接下来,我们使用 Python 来解析 NetFlow 版本 5 数据包,以增强我们对 NetFlow 的理解和故障排除能力。我们还研究了如何使用 ntop 和 Python 来扩展 ntop 以进行 NetFlow 监控。sFlow 是一种替代的数据包抽样技术,我们使用sflowtool和 sFlow-RT 来解释结果。我们在本章结束时介绍了一个通用的数据分析工具,即 Elasticsearch,或者 ELK 堆栈。

在第九章中,使用 Python 构建网络 Web 服务,我们将探讨如何使用 Python Web 框架 Flask 来构建网络 Web 服务。

第九章:使用 Python 构建网络 Web 服务

在之前的章节中,我们是各种工具提供的 API 的消费者。在第三章中,API 和意图驱动的网络,我们看到我们可以使用HTTP POST方法到http://<your router ip>/ins URL 上的 NX-API,其中CLI命令嵌入在主体中,以远程执行 Cisco Nexus 设备上的命令;然后设备返回命令执行输出。在第八章中,使用 Python 进行网络监控-第 2 部分,我们使用GET方法来获取我们 sFlow-RT 的http://<your host ip>:8008/version上的版本,主体为空。这些交换是 RESTful Web 服务的例子。

根据维基百科(en.wikipedia.org/wiki/Representational_state_transfer):

“表征状态转移(REST)或 RESTful Web 服务是提供互操作性的一种方式,用于互联网上的计算机系统。符合 REST 标准的 Web 服务允许请求系统使用一组统一和预定义的无状态操作来访问和操作 Web 资源的文本表示。”

如前所述,使用 HTTP 协议的 REST Web 服务只是网络上信息交换的许多方法之一;还存在其他形式的 Web 服务。然而,它是今天最常用的 Web 服务,具有相关的GETPOSTPUTDELETE动词作为信息交换的预定义方式。

使用 RESTful 服务的优势之一是它可以让您隐藏用户对内部操作的了解,同时仍然为他们提供服务。例如,在 sFlow-RT 的情况下,如果我们要登录安装了我们软件的设备,我们需要更深入地了解工具,才能知道在哪里检查软件版本。然而,通过以 URL 的形式提供资源,软件将版本检查操作从请求者中抽象出来,使操作变得更简单。抽象还提供了一层安全性,因为现在可以根据需要仅打开端点。

作为网络宇宙的大师,RESTful Web 服务提供了许多显着的好处,我们可以享受,例如以下:

  • 您可以将请求者与网络操作的内部细节分离。例如,我们可以提供一个 Web 服务来查询交换机版本,而无需请求者知道所需的确切 CLI 命令或 API 格式。

  • 我们可以整合和定制符合我们网络需求的操作,例如升级所有顶部交换机的资源。

  • 我们可以通过仅在需要时公开操作来提供更好的安全性。例如,我们可以为核心网络设备提供只读 URL(GET),并为访问级别交换机提供读写 URL(GET / POST / PUT / DELETE)。

在本章中,我们将使用最流行的 Python Web 框架之一Flask来为我们的网络创建自己的 REST Web 服务。在本章中,我们将学习以下内容:

  • 比较 Python Web 框架

  • Flask 简介

  • 静态网络内容的操作

  • 涉及动态网络操作的操作

让我们开始看看可用的 Python Web 框架以及为什么我们选择了 Flask。

比较 Python Web 框架

Python 以其众多的 web 框架而闻名。在 PyCon 上有一个笑话,即你永远不能成为全职 Python 开发者而不使用任何 Python web 框架。甚至为 Django 举办了一年一度的会议,这是最受欢迎的 Python 框架之一,叫做 DjangoCon。每年都吸引数百名与会者。如果你在hotframeworks.com/languages/python上对 Python web 框架进行排序,你会发现在 Python 和 web 框架方面选择是不缺乏的。

Python web 框架排名

有这么多选择,我们应该选择哪个框架呢?显然,自己尝试所有的框架将非常耗时。关于哪个 web 框架更好的问题也是网页开发者之间的一个热门话题。如果你在任何论坛上问这个问题,比如 Quora,或者在 Reddit 上搜索,准备好接受一些充满个人意见的答案和激烈的辩论。

说到 Quora 和 Reddit,这里有一个有趣的事实:Quora 和 Reddit 都是用 Python 编写的。Reddit 使用 Pylons(www.reddit.com/wiki/faq#wiki_so_what_python_framework_do_you_use.3F),而 Quora 最初使用 Pylons,但用他们自己的内部代码替换了部分框架(www.quora.com/What-languages-and-frameworks-are-used-to-code-Quora)。

当然,我对编程语言(Python!)和 web 框架(Flask!)有自己的偏见。在这一部分,我希望向你传达我选择一个而不是另一个的理由。让我们选择前面 HotFrameworks 列表中的前两个框架并进行比较:

  • Django:这个自称为“完美主义者与截止日期的 web 框架”是一个高级 Python web 框架,鼓励快速开发和清晰的实用设计(www.djangoproject.com/)。它是一个大型框架,提供了预先构建的代码,提供了管理面板和内置内容管理。

  • Flask:这是一个基于 Werkzeug,Jinja2 和良好意图的 Python 微框架(flask.pocoo.org/)。作为一个微框架,Flask 的目标是保持核心小,需要时易于扩展。微框架中的“微”并不意味着 Flask 功能不足,也不意味着它不能在生产环境中工作。

就我个人而言,我觉得 Django 有点难以扩展,大部分时间我只使用预先构建的代码的一小部分。Django 框架对事物应该如何完成有着强烈的意见;任何偏离这些意见的行为有时会让用户觉得他们在“与框架作斗争”。例如,如果你看一下 Django 数据库文档,你会注意到这个框架支持多种不同的 SQL 数据库。然而,它们都是 SQL 数据库的变体,比如 MySQL,PostgreSQL,SQLite 等。如果你想使用 NoSQL 数据库,比如 MongoDB 或 CouchDB 呢?这可能是可能的,但可能会让你自己摸索。成为一个有主见的框架当然不是坏事,这只是一个观点问题(无意冒犯)。

我非常喜欢保持核心代码简洁,并在需要时进行扩展的想法。文档中让 Flask 运行的初始示例只包含了八行代码,即使你没有任何经验,也很容易理解。由于 Flask 是以扩展为核心构建的,编写自己的扩展,比如装饰器,非常容易。尽管它是一个微框架,但 Flask 核心仍然包括必要的组件,比如开发服务器、调试器、与单元测试的集成、RESTful 请求分发等等,可以让你立即开始。正如你所看到的,除了 Django,Flask 是按某些标准来说第二受欢迎的 Python 框架。社区贡献、支持和快速发展带来的受欢迎程度有助于进一步扩大其影响力。

出于上述原因,我觉得 Flask 是我们在构建网络 Web 服务时的理想选择。

Flask 和实验设置

在本章中,我们将使用virtualenv来隔离我们将要工作的环境。顾名思义,virtualenv 是一个创建虚拟环境的工具。它可以将不同项目所需的依赖项保存在不同的位置,同时保持全局 site-packages 的清洁。换句话说,当你在虚拟环境中安装 Flask 时,它只会安装在本地virtualenv项目目录中,而不是全局 site-packages。这使得将代码移植到其他地方变得非常容易。

很有可能在之前使用 Python 时,你已经接触过virtualenv,所以我们会快速地浏览一下这个过程。如果你还没有接触过,可以随意选择在线的优秀教程之一,比如docs.python-guide.org/en/latest/dev/virtualenvs/

要使用,我们首先需要安装virtualenv

# Python 3
$ sudo apt-get install python3-venv
$ python3 -m venv venv

# Python 2
$ sudo apt-get install python-virtualenv
$ virtualenv venv-python2

下面的命令使用venv模块(-m venv)来获取一个带有完整 Python 解释器的venv文件夹。我们可以使用source venv/bin/activatedeactivate来进入和退出本地 Python 环境:

$ source venv/bin/activate
(venv) $ python
$ which python
/home/echou/Master_Python_Networking_second_edition/Chapter09/venv/bin/python
$ python
Python 3.5.2 (default, Nov 23 2017, 16:37:01)
[GCC 5.4.0 20160609] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>>
>>> exit()
(venv) $ deactivate

在本章中,我们将安装相当多的 Python 包。为了让生活更轻松,我在书的 GitHub 存储库中包含了一个requirements.txt文件;我们可以使用它来安装所有必要的包(记得激活你的虚拟环境)。在过程结束时,你应该看到包被下载并成功安装:

(venv) $ pip install -r requirements.txt
Collecting Flask==0.10.1 (from -r requirements.txt (line 1))
  Downloading https://files.pythonhosted.org/packages/db/9c/149ba60c47d107f85fe52564133348458f093dd5e6b57a5b60ab9ac517bb/Flask-0.10.1.tar.gz (544kB)
    100% |████████████████████████████████| 552kB 2.0MB/s
Collecting Flask-HTTPAuth==2.2.1 (from -r requirements.txt (line 2))
  Downloading https://files.pythonhosted.org/packages/13/f3/efc053c66a7231a5a38078a813aee06cd63ca90ab1b3e269b63edd5ff1b2/Flask-HTTPAuth-2.2.1.tar.gz
... <skip>
  Running setup.py install for Pygments ... done
  Running setup.py install for python-dateutil ... done
Successfully installed Flask-0.10.1 Flask-HTTPAuth-2.2.1 Flask-SQLAlchemy-1.0 Jinja2-2.7.3 MarkupSafe-0.23 Pygments-1.6 SQLAlchemy-0.9.6 Werkzeug-0.9.6 httpie-0.8.0 itsdangerous-0.24 python-dateutil-2.2 requests-2.3.0 six-1.11.0 

对于我们的网络拓扑,我们将使用一个简单的四节点网络,如下所示:

 实验拓扑

让我们在下一节中看一下 Flask。

请注意,从现在开始,我将假设你总是在虚拟环境中执行,并且已经安装了requirements.txt文件中的必要包。

Flask 简介

像大多数流行的开源项目一样,Flask 有非常好的文档,可以在flask.pocoo.org/docs/0.10/找到。如果任何示例不清楚,你可以肯定会在项目文档中找到答案。

我还强烈推荐 Miguel Grinberg(blog.miguelgrinberg.com/)关于 Flask 的工作。他的博客、书籍和视频培训让我对 Flask 有了很多了解。事实上,Miguel 的课程使用 Flask 构建 Web API启发了我写这一章。你可以在 GitHub 上查看他发布的代码:github.com/miguelgrinberg/oreilly-flask-apis-video

我们的第一个 Flask 应用程序包含在一个单独的文件chapter9_1.py中:

from flask import Flask
app = Flask(__name__)

@app.route('/')
def hello_networkers():
    return 'Hello Networkers!'

if __name__ == '__main__':
    app.run(host='0.0.0.0', debug=True)

这几乎总是 Flask 最初的设计模式。我们使用 Flask 类的实例作为应用程序模块包的第一个参数。在这种情况下,我们使用了一个单一模块;在自己操作时,输入您选择的名称,以指示它是作为应用程序启动还是作为模块导入。然后,我们使用路由装饰器告诉 Flask 哪个 URL 应该由hello_networkers()函数处理;在这种情况下,我们指定了根路径。我们以通常的名称结束文件(docs.python.org/3.5/library/__main__.html)。我们只添加了主机和调试选项,允许更详细的输出,并允许我们监听主机的所有接口(默认情况下,它只监听回环)。我们可以使用开发服务器运行此应用程序:

(venv) $ python chapter9_1.py
 * Running on http://0.0.0.0:5000/
 * Restarting with reloader

既然我们有一个运行的服务器,让我们用一个 HTTP 客户端测试服务器的响应。

HTTPie 客户端

我们已经安装了 HTTPie (httpie.org/) 作为从阅读requirements.txt文件安装的一部分。尽管本书是黑白文本打印的,所以这里看不到,但在您的安装中,您可以看到 HTTPie 对 HTTP 事务有更好的语法高亮。它还具有更直观的 RESTful HTTP 服务器命令行交互。我们可以用它来测试我们的第一个 Flask 应用程序(后续将有更多关于 HTTPie 的例子):

$ http GET http://172.16.1.173:5000/
HTTP/1.0 200 OK
Content-Length: 17
Content-Type: text/html; charset=utf-8
Date: Wed, 22 Mar 2017 17:37:12 GMT
Server: Werkzeug/0.9.6 Python/3.5.2

Hello Networkers!

或者,您也可以使用 curl 的-i开关来查看 HTTP 头:curl -i http://172.16.1.173:5000/

我们将在本章中使用HTTPie作为我们的客户端;值得花一两分钟来看一下它的用法。我们将使用免费的网站 HTTP Bin (httpbin.org/) 来展示HTTPie的用法。HTTPie的用法遵循这种简单的模式:

$ http [flags] [METHOD] URL [ITEM]

按照前面的模式,GET请求非常简单,就像我们在 Flask 开发服务器中看到的那样:

$ http GET https://httpbin.org/user-agent
...
{
 "user-agent": "HTTPie/0.8.0"
}

JSON 是HTTPie的默认隐式内容类型。如果您的 HTTP 主体只包含字符串,则不需要进行其他操作。如果您需要应用非字符串 JSON 字段,请使用:=或其他文档化的特殊字符:

$ http POST https://httpbin.org/post name=eric twitter=at_ericchou married:=true 
HTTP/1.1 200 OK
...
Content-Type: application/json
...
{
 "headers": {
...
 "User-Agent": "HTTPie/0.8.0"
 },
 "json": {
 "married": true,
 "name": "eric",
 "twitter": "at_ericchou"
 },
 ...
 "url": "https://httpbin.org/post"
}

正如您所看到的,HTTPie是传统 curl 语法的一个重大改进,使得测试 REST API 变得轻而易举。

更多的用法示例可在httpie.org/doc#usage找到。

回到我们的 Flask 程序,API 构建的一个重要部分是基于 URL 路由的流程。让我们更深入地看一下app.route()装饰器。

URL 路由

我们添加了两个额外的函数,并将它们与chapter9_2.py中的适当的app.route()路由配对:

$ cat chapter9_2.py
from flask import Flask
app = Flask(__name__)

@app.route('/')
def index():
    return 'You are at index()'

@app.route('/routers/')
def routers():
    return 'You are at routers()'

if __name__ == '__main__':
    app.run(host='0.0.0.0', debug=True)

结果是不同的端点传递给不同的函数。我们可以通过两个http请求来验证这一点:

# Server
$ python chapter9_2.py

# Client
$ http GET http://172.16.1.173:5000/
...

You are at index()

$ http GET http://172.16.1.173:5000/routers/
...

You are at routers()

当然,如果我们一直保持静态,路由将会非常有限。有办法将变量从 URL 传递给 Flask;我们将在接下来的部分看一个例子。

URL 变量

如前所述,我们也可以将变量传递给 URL,就像在chapter9_3.py中讨论的例子中看到的那样:

...
@app.route('/routers/<hostname>')
def router(hostname):
    return 'You are at %s' % hostname

@app.route('/routers/<hostname>/interface/<int:interface_number>')
def interface(hostname, interface_number):
    return 'You are at %s interface %d' % (hostname, interface_number)
...

请注意,在/routers/<hostname> URL 中,我们将<hostname>变量作为字符串传递;<int:interface_number>将指定该变量应该是一个整数:

$ http GET http://172.16.1.173:5000/routers/host1
...
You are at host1

$ http GET http://172.16.1.173:5000/routers/host1/interface/1
...
You are at host1 interface 1

# Throws exception
$ http GET http://172.16.1.173:5000/routers/host1/interface/one
HTTP/1.0 404 NOT FOUND
...
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN">
<title>404 Not Found</title>
<h1>Not Found</h1>
<p>The requested URL was not found on the server. If you entered the URL manually please check your spelling and try again.</p>

转换器包括整数、浮点数和路径(它接受斜杠)。

除了匹配静态路由之外,我们还可以动态生成 URL。当我们事先不知道端点变量,或者端点基于其他条件,比如从数据库查询的值时,这是非常有用的。让我们看一个例子。

URL 生成

chapter9_4.py中,我们想要在代码中动态创建一个形式为'/<hostname>/list_interfaces'的 URL:

from flask import Flask, url_for
...
@app.route('/<hostname>/list_interfaces')
def device(hostname):
    if hostname in routers:
        return 'Listing interfaces for %s' % hostname
    else:
        return 'Invalid hostname'

routers = ['r1', 'r2', 'r3']
for router in routers:
    with app.test_request_context():
        print(url_for('device', hostname=router))
...

执行后,您将得到一个漂亮而合乎逻辑的 URL,如下所示:

(venv) $ python chapter9_4.py
/r1/list_interfaces
/r2/list_interfaces
/r3/list_interfaces
 * Running on http://0.0.0.0:5000/
 * Restarting with reloader 

目前,您可以将app.text_request_context()视为一个虚拟的request对象,这对于演示目的是必要的。如果您对本地上下文感兴趣,请随时查看werkzeug.pocoo.org/docs/0.14/local/

jsonify 返回

Flask 中的另一个时间节省器是jsonify()返回,它包装了json.dumps()并将 JSON 输出转换为具有application/json作为 HTTP 标头中内容类型的response对象。我们可以稍微调整最后的脚本,就像我们将在chapter9_5.py中做的那样:

from flask import Flask, jsonify

app = Flask(__name__)

@app.route('/routers/<hostname>/interface/<int:interface_number>')
def interface(hostname, interface_number):
    return jsonify(name=hostname, interface=interface_number)

if __name__ == '__main__':
    app.run(host='0.0.0.0', debug=True)

我们将看到返回的结果作为JSON对象,并带有适当的标头:

$ http GET http://172.16.1.173:5000/routers/r1/interface/1
HTTP/1.0 200 OK
Content-Length: 36
Content-Type: application/json
...

{
 "interface": 1,
 "name": "r1"
}

在 Flask 中查看了 URL 路由和jsonify()返回后,我们现在准备为我们的网络构建 API。

网络资源 API

通常,您的网络由一旦投入生产就不经常更改的网络设备组成。例如,您将拥有核心设备、分发设备、脊柱、叶子、顶部交换机等。每个设备都有特定的特性和功能,您希望将这些信息存储在一个持久的位置,以便以后可以轻松检索。通常是通过将数据存储在数据库中来实现的。但是,您通常不希望将其他用户直接访问数据库;他们也不想学习所有复杂的 SQL 查询语言。对于这种情况,我们可以利用 Flask 和 Flask-SQLAlchemy 扩展。

您可以在flask-sqlalchemy.pocoo.org/2.1/了解更多关于 Flask-SQLAlchemy 的信息。

Flask-SQLAlchemy

当然,SQLAlchemy 和 Flask 扩展都是数据库抽象层和对象关系映射器。这是一种使用Python对象作为数据库的高级方式。为了简化事情,我们将使用 SQLite 作为数据库,它是一个充当独立 SQL 数据库的平面文件。我们将查看chapter9_db_1.py的内容,作为使用 Flask-SQLAlchemy 创建网络数据库并将表条目插入数据库的示例。

首先,我们将创建一个 Flask 应用程序,并加载 SQLAlchemy 的配置,比如数据库路径和名称,然后通过将应用程序传递给它来创建SQLAlchemy对象:

from flask import Flask
from flask_sqlalchemy import SQLAlchemy

# Create Flask application, load configuration, and create
# the SQLAlchemy object
app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///network.db'
db = SQLAlchemy(app)

然后,我们可以创建一个database对象及其关联的主键和各种列:

class Device(db.Model):
    __tablename__ = 'devices'
    id = db.Column(db.Integer, primary_key=True)
    hostname = db.Column(db.String(120), index=True)
    vendor = db.Column(db.String(40))

    def __init__(self, hostname, vendor):
        self.hostname = hostname
        self.vendor = vendor

    def __repr__(self):
        return '<Device %r>' % self.hostname

我们可以调用database对象,创建条目,并将它们插入数据库表中。请记住,我们添加到会话中的任何内容都需要提交到数据库中才能永久保存:

if __name__ == '__main__':
    db.create_all()
    r1 = Device('lax-dc1-core1', 'Juniper')
    r2 = Device('sfo-dc1-core1', 'Cisco')
    db.session.add(r1)
    db.session.add(r2)
    db.session.commit()

我们将运行 Python 脚本并检查数据库文件是否存在:

$ python chapter9_db_1.py
$ ls network.db
network.db

我们可以使用交互式提示来检查数据库表条目:

>>> from flask import Flask
>>> from flask_sqlalchemy import SQLAlchemy
>>>
>>> app = Flask(__name__)
>>> app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///network.db'
>>> db = SQLAlchemy(app)
>>> from chapter9_db_1 import Device
>>> Device.query.all()
[<Device 'lax-dc1-core1'>, <Device 'sfo-dc1-core1'>]
>>> Device.query.filter_by(hostname='sfo-dc1-core1')
<flask_sqlalchemy.BaseQuery object at 0x7f1b4ae07eb8>
>>> Device.query.filter_by(hostname='sfo-dc1-core1').first()
<Device 'sfo-dc1-core1'>

我们也可以以相同的方式创建新条目:

>>> r3 = Device('lax-dc1-core2', 'Juniper')
>>> db.session.add(r3)
>>> db.session.commit()
>>> Device.query.all()
[<Device 'lax-dc1-core1'>, <Device 'sfo-dc1-core1'>, <Device 'lax-dc1-core2'>]

网络内容 API

在我们深入代码之前,让我们花一点时间考虑我们要创建的 API。规划 API 通常更多是一种艺术而不是科学;这确实取决于您的情况和偏好。我建议的下一步绝不是正确的方式,但是现在,为了开始,跟着我走。

回想一下,在我们的图表中,我们有四个 Cisco IOSv 设备。假设其中两个,iosv-1iosv-2,是网络角色的脊柱。另外两个设备,iosv-3iosv-4,在我们的网络服务中作为叶子。这显然是任意选择,可以稍后修改,但重点是我们想要提供关于我们的网络设备的数据,并通过 API 公开它们。

为了简化事情,我们将创建两个 API:设备组 API 和单个设备 API:

网络内容 API

第一个 API 将是我们的http://172.16.1.173/devices/端点,支持两种方法:GETPOSTGET请求将返回当前设备列表,而带有适当 JSON 主体的POST请求将创建设备。当然,您可以选择为创建和查询设置不同的端点,但在这个设计中,我们选择通过 HTTP 方法来区分这两种情况。

第二个 API 将特定于我们的设备,形式为http://172.16.1.173/devices/<device id>。带有GET请求的 API 将显示我们输入到数据库中的设备的详细信息。PUT请求将修改更新条目。请注意,我们使用PUT而不是POST。这是 HTTP API 使用的典型方式;当我们需要修改现有条目时,我们将使用PUT而不是POST

到目前为止,您应该对您的 API 的外观有一个很好的想法。为了更好地可视化最终结果,我将快速跳转并展示最终结果,然后再看代码。

/devices/API 的POST请求将允许您创建一个条目。在这种情况下,我想创建我们的网络设备,其属性包括主机名、回环 IP、管理 IP、角色、供应商和运行的操作系统:

$ http POST http://172.16.1.173:5000/devices/ 'hostname'='iosv-1' 'loopback'='192.168.0.1' 'mgmt_ip'='172.16.1.225' 'role'='spine' 'vendor'='Cisco' 'os'='15.6'
HTTP/1.0 201 CREATED
Content-Length: 2
Content-Type: application/json
Date: Fri, 24 Mar 2017 01:45:15 GMT
Location: http://172.16.1.173:5000/devices/1
Server: Werkzeug/0.9.6 Python/3.5.2

{}

我可以重复前面的步骤来添加另外三个设备:

$ http POST http://172.16.1.173:5000/devices/ 'hostname'='iosv-2' 'loopback'='192.168.0.2' 'mgmt_ip'='172.16.1.226' 'role'='spine' 'vendor'='Cisco' 'os'='15.6'
...
$ http POST http://172.16.1.173:5000/devices/ 'hostname'='iosv-3', 'loopback'='192.168.0.3' 'mgmt_ip'='172.16.1.227' 'role'='leaf' 'vendor'='Cisco' 'os'='15.6'
...
$ http POST http://172.16.1.173:5000/devices/ 'hostname'='iosv-4', 'loopback'='192.168.0.4' 'mgmt_ip'='172.16.1.228' 'role'='leaf' 'vendor'='Cisco' 'os'='15.6'

如果我们可以使用相同的 API 和GET请求,我们将能够看到我们创建的网络设备列表:

$ http GET http://172.16.1.173:5000/devices/
HTTP/1.0 200 OK
Content-Length: 188
Content-Type: application/json
Date: Fri, 24 Mar 2017 01:53:15 GMT
Server: Werkzeug/0.9.6 Python/3.5.2

{
 "device": [
 "http://172.16.1.173:5000/devices/1",
 "http://172.16.1.173:5000/devices/2",
 "http://172.16.1.173:5000/devices/3",
 "http://172.16.1.173:5000/devices/4"
 ]
}

类似地,使用GET请求对/devices/<id>将返回与设备相关的特定信息:

$ http GET http://172.16.1.173:5000/devices/1
HTTP/1.0 200 OK
Content-Length: 188
Content-Type: application/json
...
{
 "hostname": "iosv-1",
 "loopback": "192.168.0.1",
 "mgmt_ip": "172.16.1.225",
 "os": "15.6",
 "role": "spine",
 "self_url": "http://172.16.1.173:5000/devices/1",
 "vendor": "Cisco"
}

假设我们将r1操作系统从15.6降级到14.6。我们可以使用PUT请求来更新设备记录:

$ http PUT http://172.16.1.173:5000/devices/1 'hostname'='iosv-1' 'loopback'='192.168.0.1' 'mgmt_ip'='172.16.1.225' 'role'='spine' 'vendor'='Cisco' 'os'='14.6'
HTTP/1.0 200 OK

# Verification
$ http GET http://172.16.1.173:5000/devices/1
...
{
 "hostname": "r1",
 "loopback": "192.168.0.1",
 "mgmt_ip": "172.16.1.225",
 "os": "14.6",
 "role": "spine",
 "self_url": "http://172.16.1.173:5000/devices/1",
 "vendor": "Cisco"
}

现在,让我们看一下chapter9_6.py中的代码,这些代码帮助创建了前面的 API。在我看来,很酷的是,所有这些 API 都是在单个文件中完成的,包括数据库交互。以后,当我们需要扩展现有的 API 时,我们总是可以将组件分离出来,比如为数据库类单独创建一个文件。

设备 API

chapter9_6.py文件以必要的导入开始。请注意,以下请求导入是来自客户端的request对象,而不是我们在之前章节中使用的 requests 包:

from flask import Flask, url_for, jsonify, request
from flask_sqlalchemy import SQLAlchemy
# The following is deprecated but still used in some examples
# from flask.ext.sqlalchemy import SQLAlchemy

我们声明了一个database对象,其id为主键,hostnameloopbackmgmt_iprolevendoros为字符串字段:

class Device(db.Model):
    __tablename__ = 'devices'
  id = db.Column(db.Integer, primary_key=True)
    hostname = db.Column(db.String(64), unique=True)
    loopback = db.Column(db.String(120), unique=True)
    mgmt_ip = db.Column(db.String(120), unique=True)
    role = db.Column(db.String(64))
    vendor = db.Column(db.String(64))
    os = db.Column(db.String(64))

get_url()函数从url_for()函数返回一个 URL。请注意,调用的get_device()函数尚未在'/devices/<int:id>'路由下定义:

def get_url(self):
    return url_for('get_device', id=self.id, _external=True)

export_data()import_data()函数是彼此的镜像。一个用于从数据库获取信息到用户(export_data()),当我们使用GET方法时。另一个用于将用户的信息放入数据库(import_data()),当我们使用POSTPUT方法时:

def export_data(self):
    return {
        'self_url': self.get_url(),
  'hostname': self.hostname,
  'loopback': self.loopback,
  'mgmt_ip': self.mgmt_ip,
  'role': self.role,
  'vendor': self.vendor,
  'os': self.os
    }

def import_data(self, data):
    try:
        self.hostname = data['hostname']
        self.loopback = data['loopback']
        self.mgmt_ip = data['mgmt_ip']
        self.role = data['role']
        self.vendor = data['vendor']
        self.os = data['os']
    except KeyError as e:
        raise ValidationError('Invalid device: missing ' + e.args[0])
    return self

有了database对象以及创建的导入和导出函数,设备操作的 URL 分发就变得简单了。GET请求将通过查询设备表中的所有条目返回设备列表,并返回每个条目的 URL。POST方法将使用全局request对象作为输入,使用import_data()函数,然后将设备添加到数据库并提交信息:

@app.route('/devices/', methods=['GET'])
def get_devices():
    return jsonify({'device': [device.get_url() 
                              for device in Device.query.all()]})

@app.route('/devices/', methods=['POST'])
def new_device():
    device = Device()
    device.import_data(request.json)
    db.session.add(device)
    db.session.commit()
    return jsonify({}), 201, {'Location': device.get_url()}

如果您查看POST方法,返回的主体是一个空的 JSON 主体,状态码为201(已创建),以及额外的标头:

HTTP/1.0 201 CREATED
Content-Length: 2
Content-Type: application/json
Date: ...
Location: http://172.16.1.173:5000/devices/4
Server: Werkzeug/0.9.6 Python/3.5.2

让我们来看一下查询和返回有关单个设备的信息的 API。

设备 ID API

单个设备的路由指定 ID 应该是一个整数,这可以作为我们对错误请求的第一道防线。这两个端点遵循与我们的/devices/端点相同的设计模式,我们在这里使用相同的importexport函数:

@app.route('/devices/<int:id>', methods=['GET'])
def get_device(id):
    return jsonify(Device.query.get_or_404(id).export_data())

@app.route('/devices/<int:id>', methods=['PUT'])
def edit_device(id):
    device = Device.query.get_or_404(id)
    device.import_data(request.json)
    db.session.add(device)
    db.session.commit()
    return jsonify({})

注意query_or_404()方法;如果数据库查询对传入的 ID 返回负值,它提供了一个方便的方法来返回404(未找到)。这是一个相当优雅的方式来快速检查数据库查询。

最后,代码的最后部分创建数据库表并启动 Flask 开发服务器:

if __name__ == '__main__':
    db.create_all()
    app.run(host='0.0.0.0', debug=True)

这是本书中较长的 Python 脚本之一,这就是为什么我们花了更多的时间详细解释它。该脚本提供了一种说明我们如何利用后端数据库来跟踪网络设备,并将它们仅作为 API 暴露给外部世界的方法,使用 Flask。

在下一节中,我们将看看如何使用 API 对单个设备或一组设备执行异步任务。

网络动态操作

我们的 API 现在可以提供关于网络的静态信息;我们可以将数据库中存储的任何内容返回给请求者。如果我们可以直接与我们的网络交互,比如查询设备信息或向设备推送配置更改,那将是很棒的。

我们将通过利用我们已经在第二章中看到的脚本,低级网络设备交互,来开始这个过程,通过 Pexpect 与设备进行交互。我们将稍微修改脚本,将其转换为一个我们可以在chapter9_pexpect_1.py中重复使用的函数:

# We need to install pexpect for our virtual env
$ pip install pexpect

$ cat chapter9_pexpect_1.py
import pexpect

def show_version(device, prompt, ip, username, password):
 device_prompt = prompt
 child = pexpect.spawn('telnet ' + ip)
 child.expect('Username:')
 child.sendline(username)
 child.expect('Password:')
 child.sendline(password)
 child.expect(device_prompt)
 child.sendline('show version | i V')
 child.expect(device_prompt)
 result = child.before
 child.sendline('exit')
 return device, result

我们可以通过交互式提示来测试新的函数:

$ pip3 install pexpect
$ python
>>> from chapter9_pexpect_1 import show_version
>>> print(show_version('iosv-1', 'iosv-1#', '172.16.1.225', 'cisco', 'cisco'))
('iosv-1', b'show version | i V\r\nCisco IOS Software, IOSv Software (VIOS-ADVENTERPRISEK9-M), Version 15.6(3)M2, RELEASE SOFTWARE (fc2)\r\n')
>>> 

确保您的 Pexpect 脚本在继续之前能够正常工作。以下代码假定您已经输入了前一节中的必要数据库信息。

我们可以在chapter9_7.py中添加一个新的 API 来查询设备版本:

from chapter9_pexpect_1 import show_version
...
@app.route('/devices/<int:id>/version', methods=['GET'])
def get_device_version(id):
    device = Device.query.get_or_404(id)
    hostname = device.hostname
    ip = device.mgmt_ip
    prompt = hostname+"#"
  result = show_version(hostname, prompt, ip, 'cisco', 'cisco')
    return jsonify({"version": str(result)})

结果将返回给请求者:

$ http GET http://172.16.1.173:5000/devices/4/version
HTTP/1.0 200 OK
Content-Length: 210
Content-Type: application/json
Date: Fri, 24 Mar 2017 17:05:13 GMT
Server: Werkzeug/0.9.6 Python/3.5.2

{
 "version": "('iosv-4', b'show version | i V\r\nCisco IOS Software, IOSv Software (VIOS-ADVENTERPRISEK9-M), Version 15.6(2)T, RELEASE SOFTWARE (fc2)\r\nProcessor board ID 9U96V39A4Z12PCG4O6Y0Q\r\n')"
}

我们还可以添加另一个端点,允许我们根据它们的共同字段对多个设备执行批量操作。在下面的示例中,端点将在 URL 中获取device_role属性,并将其与相应的设备匹配:

@app.route('/devices/<device_role>/version', methods=['GET'])
def get_role_version(device_role):
    device_id_list = [device.id for device in Device.query.all() if device.role == device_role]
    result = {}
    for id in device_id_list:
        device = Device.query.get_or_404(id)
        hostname = device.hostname
        ip = device.mgmt_ip
        prompt = hostname + "#"
  device_result = show_version(hostname, prompt, ip, 'cisco', 'cisco')
        result[hostname] = str(device_result)
    return jsonify(result)

当然,像在前面的代码中那样循环遍历所有的设备Device.query.all()是不高效的。在生产中,我们将使用一个专门针对设备角色的 SQL 查询。

当我们使用 REST API 时,可以同时查询所有的骨干和叶子设备:

$ http GET http://172.16.1.173:5000/devices/spine/version
HTTP/1.0 200 OK
...
{
 "iosv-1": "('iosv-1', b'show version | i V\r\nCisco IOS Software, IOSv Software (VIOS-ADVENTERPRISEK9-M), Version 15.6(2)T, RELEASE SOFTWARE (fc2)\r\n')",
 "iosv-2": "('iosv-2', b'show version | i V\r\nCisco IOS Software, IOSv Software (VIOS-ADVENTERPRISEK9-M), Version 15.6(2)T, RELEASE SOFTWARE (fc2)\r\nProcessor board ID 9T7CB2J2V6F0DLWK7V48E\r\n')"
}

$ http GET http://172.16.1.173:5000/devices/leaf/version
HTTP/1.0 200 OK
...
{
 "iosv-3": "('iosv-3', b'show version | i V\r\nCisco IOS Software, IOSv Software (VIOS-ADVENTERPRISEK9-M), Version 15.6(2)T, RELEASE SOFTWARE (fc2)\r\nProcessor board ID 9MGG8EA1E0V2PE2D8KDD7\r\n')",
 "iosv-4": "('iosv-4', b'show version | i V\r\nCisco IOS Software, IOSv Software (VIOS-ADVENTERPRISEK9-M), Version 15.6(2)T, RELEASE SOFTWARE (fc2)\r\nProcessor board ID 9U96V39A4Z12PCG4O6Y0Q\r\n')"
}

正如所示,新的 API 端点实时查询设备,并将结果返回给请求者。当您可以保证在事务的超时值(默认为 30 秒)内获得操作的响应,或者如果您可以接受 HTTP 会话在操作完成之前超时,这种方法相对有效。解决超时问题的一种方法是异步执行任务。我们将在下一节中看看如何做到这一点。

异步操作

在我看来,异步操作是 Flask 的一个高级主题。幸运的是,Miguel Grinberg(blog.miguelgrinberg.com/)是我非常喜欢的 Flask 工作的作者,他在博客和 GitHub 上提供了许多帖子和示例。对于异步操作,chapter9_8.py中的示例代码引用了 Miguel 在 GitHub 上的Raspberry Pi文件上的代码(github.com/miguelgrinberg/oreilly-flask-apis-video/blob/master/camera/camera.py)来使用 background 装饰器。我们将开始导入一些额外的模块:

from flask import Flask, url_for, jsonify, request,
    make_response, copy_current_request_context
...
import uuid
import functools
from threading import Thread

background 装饰器接受一个函数,并使用线程和 UUID 作为任务 ID 在后台运行它。它返回状态码202 accepted 和新资源的位置,供请求者检查。我们将创建一个新的 URL 用于状态检查:

@app.route('/status/<id>', methods=['GET'])
def get_task_status(id):   global background_tasks
    rv = background_tasks.get(id)
    if rv is None:
        return not_found(None)
   if isinstance(rv, Thread):
        return jsonify({}), 202, {'Location': url_for('get_task_status', id=id)}
   if app.config['AUTO_DELETE_BG_TASKS']:
        del background_tasks[id]
    return rv

一旦我们检索到资源,它就会被删除。这是通过在应用程序顶部将app.config['AUTO_DELETE_BG_TASKS']设置为true来完成的。我们将在我们的版本端点中添加这个装饰器,而不改变代码的其他部分,因为所有的复杂性都隐藏在装饰器中(这多酷啊!):

@app.route('/devices/<int:id>/version', methods=['GET'])
@**background** def get_device_version(id):
    device = Device.query.get_or_404(id)
...

@app.route('/devices/<device_role>/version', methods=['GET'])
@**background** def get_role_version(device_role):
    device_id_list = [device.id for device in Device.query.all() if device.role == device_role]
...

最终结果是一个两部分的过程。我们将为端点执行GET请求,并接收位置头:

$ http GET http://172.16.1.173:5000/devices/spine/version
HTTP/1.0 202 ACCEPTED
Content-Length: 2
Content-Type: application/json
Date: <skip>
Location: http://172.16.1.173:5000/status/d02c3f58f4014e96a5dca075e1bb65d4
Server: Werkzeug/0.9.6 Python/3.5.2

{}

然后我们可以发出第二个请求以检索结果的位置:

$ http GET http://172.16.1.173:5000/status/d02c3f58f4014e96a5dca075e1bb65d4
HTTP/1.0 200 OK
Content-Length: 370
Content-Type: application/json
Date: <skip>
Server: Werkzeug/0.9.6 Python/3.5.2

{
 "iosv-1": "('iosv-1', b'show version | i V\r\nCisco IOS Software, IOSv Software (VIOS-ADVENTERPRISEK9-M), Version 15.6(2)T, RELEASE SOFTWARE (fc2)\r\n')",
 "iosv-2": "('iosv-2', b'show version | i V\r\nCisco IOS Software, IOSv Software (VIOS-ADVENTERPRISEK9-M), Version 15.6(2)T, RELEASE SOFTWARE (fc2)\r\nProcessor board ID 9T7CB2J2V6F0DLWK7V48E\r\n')"
}

为了验证当资源尚未准备好时是否返回状态码202,我们将使用以下脚本chapter9_request_1.py立即向新资源发出请求:

import requests, time

server = 'http://172.16.1.173:5000' endpoint = '/devices/1/version'   # First request to get the new resource r = requests.get(server+endpoint)
resource = r.headers['location']
print("Status: {} Resource: {}".format(r.status_code, resource))

# Second request to get the resource status r = requests.get(resource)
print("Immediate Status Query to Resource: " + str(r.status_code))

print("Sleep for 2 seconds")
time.sleep(2)
# Third request to get the resource status r = requests.get(resource)
print("Status after 2 seconds: " + str(r.status_code))

如您在结果中所见,当资源仍在后台运行时,状态码以202返回:

$ python chapter9_request_1.py
Status: 202 Resource: http://172.16.1.173:5000/status/1de21f5235c94236a38abd5606680b92
Immediate Status Query to Resource: 202
Sleep for 2 seconds
Status after 2 seconds: 200

我们的 API 正在很好地进行中!因为我们的网络资源对我们很有价值,所以我们应该只允许授权人员访问 API。我们将在下一节为我们的 API 添加基本的安全措施。

安全

对于用户身份验证安全,我们将使用 Flask 的httpauth扩展,由 Miguel Grinberg 编写,以及 Werkzeug 中的密码函数。httpauth扩展应该已经作为requirements.txt安装的一部分。展示安全功能的新文件名为chapter9_9.py;我们将从几个模块导入开始:

...
from werkzeug.security import generate_password_hash, check_password_hash
from flask.ext.httpauth import HTTPBasicAuth
...

我们将创建一个HTTPBasicAuth对象以及用户数据库对象。请注意,在用户创建过程中,我们将传递密码值;但是,我们只存储password_hash而不是密码本身。这确保我们不会为用户存储明文密码:

auth = HTTPBasicAuth()

class User(db.Model):
    __tablename__ = 'users'
  id = db.Column(db.Integer, primary_key=True)
    username = db.Column(db.String(64), index=True)
    password_hash = db.Column(db.String(128))

    def set_password(self, password):
        self.password_hash = generate_password_hash(password)

    def verify_password(self, password):
        return check_password_hash(self.password_hash, password)

auth对象有一个verify_password装饰器,我们可以使用它,以及 Flask 的g全局上下文对象,该对象在请求开始时创建,用于密码验证。因为g是全局的,如果我们将用户保存到g变量中,它将在整个事务中存在:

@auth.verify_password def verify_password(username, password):
    g.user = User.query.filter_by(username=username).first()
    if g.user is None:
        return False
 return g.user.verify_password(password)

有一个方便的before_request处理程序,可以在调用任何 API 端点之前使用。我们将结合auth.login_required装饰器和before_request处理程序,将其应用于所有 API 路由:

@app.before_request @auth.login_required def before_request():
    pass 

最后,我们将使用未经授权错误处理程序返回401未经授权错误的response对象:

@auth.error_handler def unauthorized():
    response = jsonify({'status': 401, 'error': 'unauthorized', 
 'message': 'please authenticate'})

    response.status_code = 401
  return response

在我们测试用户身份验证之前,我们需要在我们的数据库中创建用户:

>>> from chapter9_9 import db, User
>>> db.create_all()
>>> u = User(username='eric')
>>> u.set_password('secret')
>>> db.session.add(u)
>>> db.session.commit()
>>> exit()

一旦启动 Flask 开发服务器,请尝试发出请求,就像我们之前做的那样。您应该看到,这次服务器将以401未经授权的错误拒绝请求:

$ http GET http://172.16.1.173:5000/devices/
HTTP/1.0 401 UNAUTHORIZED
Content-Length: 81
Content-Type: application/json
Date: <skip>
Server: Werkzeug/0.9.6 Python/3.5.2
WWW-Authenticate: Basic realm="Authentication Required"

{
 "error": "unauthorized",
 "message": "please authenticate",
 "status": 401
}

现在我们需要为我们的请求提供身份验证头:

$ http --auth eric:secret GET http://172.16.1.173:5000/devices/
HTTP/1.0 200 OK
Content-Length: 188
Content-Type: application/json
Date: <skip>
Server: Werkzeug/0.9.6 Python/3.5.2

{
 "device": [
 "http://172.16.1.173:5000/devices/1",
 "http://172.16.1.173:5000/devices/2",
 "http://172.16.1.173:5000/devices/3",
 "http://172.16.1.173:5000/devices/4"
 ]
}

现在我们已经为我们的网络设置了一个不错的 RESTful API。用户现在可以与 API 交互,而不是与网络设备。他们可以查询网络的静态内容,并为单个设备或一组设备执行任务。我们还添加了基本的安全措施,以确保只有我们创建的用户能够从我们的 API 中检索信息。很酷的是,这一切都在不到 250 行代码的单个文件中完成了(如果减去注释,不到 200 行)!

我们现在已经将底层供应商 API 从我们的网络中抽象出来,并用我们自己的 RESTful API 替换了它们。我们可以在后端自由使用所需的内容,比如 Pexpect,同时为我们的请求者提供统一的前端。

让我们看看 Flask 的其他资源,这样我们就可以继续构建我们的 API 框架。

其他资源

毫无疑问,Flask 是一个功能丰富的框架,功能和社区都在不断增长。在本章中,我们涵盖了许多主题,但我们仍然只是触及了框架的表面。除了 API,你还可以将 Flask 用于 Web 应用程序以及你的网站。我认为我们的网络 API 框架仍然有一些改进的空间:

  • 将数据库和每个端点分开放在自己的文件中,以使代码更清晰,更易于故障排除。

  • 从 SQLite 迁移到其他适用于生产的数据库。

  • 使用基于令牌的身份验证,而不是为每个交易传递用户名和密码。实质上,我们将在初始身份验证时收到一个具有有限过期时间的令牌,并在之后的交易中使用该令牌,直到过期。

  • 将 Flask API 应用程序部署在生产 Web 服务器后面,例如 Nginx,以及 Python WSGI 服务器用于生产环境。

  • 使用自动化过程控制系统,如 Supervisor (supervisord.org/),来控制 Nginx 和 Python 脚本。

显然,推荐的改进选择会因公司而异。例如,数据库和 Web 服务器的选择可能会对公司的技术偏好以及其他团队的意见产生影响。如果 API 仅在内部使用,并且已经采取了其他形式的安全措施,那么使用基于令牌的身份验证可能并不必要。因此,出于这些原因,我想为您提供额外的链接作为额外资源,以便您选择继续使用前述任何项目。

以下是一些我认为在考虑设计模式、数据库选项和一般 Flask 功能时有用的链接:

由于 Flask 的性质以及它依赖于其小核心之外的扩展,有时你可能会发现自己从一个文档跳到另一个文档。这可能令人沮丧,但好处是你只需要了解你正在使用的扩展,我觉得这在长远来看节省了时间。

摘要

在本章中,我们开始着手构建网络的 REST API。我们研究了不同流行的 Python Web 框架,即 Django 和 Flask,并对比了两者。选择 Flask,我们能够从小处着手,并通过使用 Flask 扩展来扩展功能。

在我们的实验室中,我们使用虚拟环境将 Flask 安装基础与全局 site-packages 分开。实验室网络由四个节点组成,其中两个被指定为脊柱路由器,另外两个被指定为叶子路由器。我们对 Flask 的基础知识进行了介绍,并使用简单的 HTTPie 客户端来测试我们的 API 设置。

在 Flask 的不同设置中,我们特别强调了 URL 分发以及 URL 变量,因为它们是请求者和我们的 API 系统之间的初始逻辑。我们研究了使用 Flask-SQLAlchemy 和 SQLite 来存储和返回静态网络元素。对于操作任务,我们还创建了 API 端点,同时调用其他程序,如 Pexpect,来完成配置任务。我们通过添加异步处理和用户身份验证来改进 API 的设置。在本章的最后,我们还查看了一些额外的资源链接,以便添加更多安全性和其他功能。

在第十章中,AWS 云网络,我们将转向使用Amazon Web ServicesAWS)进行云网络的研究。

第十章:AWS 云网络

云计算是当今计算领域的主要趋势之一。公共云提供商已经改变了高科技行业,以及从零开始推出服务的含义。我们不再需要构建自己的基础设施;我们可以支付公共云提供商租用他们资源的一部分来满足我们的基础设施需求。如今,在任何技术会议或聚会上,我们很难找到一个没有了解、使用或构建基于云的服务的人。云计算已经到来,我们最好习惯与之一起工作。

云计算有几种服务模型,大致分为软件即服务(SaaS)(en.wikipedia.org/wiki/Software_as_a_service)、平台即服务(PaaS)([en.wikipedia.org/wiki/Cloud_computing#Platform_as_a_service_(PaaS)](https://en.wikipedia.org/wiki/Cloud_computing#Platform_as_a_service_(PaaS))和基础设施即服务(IaaS)(en.wikipedia.org/wiki/Infrastructure_as_a_service)。每种服务模型从用户的角度提供了不同的抽象级别。对我们来说,网络是基础设施即服务提供的一部分,也是本章的重点。

亚马逊云服务(AWS)是第一家提供 IaaS 公共云服务的公司,也是 2018 年市场份额方面的明显领导者。如果我们将“软件定义网络”(SDN)定义为一组软件服务共同创建网络结构 - IP 地址、访问列表、网络地址转换、路由器 - 我们可以说 AWS 是世界上最大的 SDN 实现。他们利用全球网络、数据中心和主机的大规模来提供令人惊叹的各种网络服务。

如果您有兴趣了解亚马逊的规模和网络,我强烈建议您观看 James Hamilton 在 2014 年 AWS re:Invent 的演讲:www.youtube.com/watch?v=JIQETrFC_SQ。这是一个罕见的内部人员对 AWS 规模和创新的视角。

在本章中,我们将讨论 AWS 云服务提供的网络服务以及如何使用 Python 与它们一起工作:

  • AWS 设置和网络概述

  • 虚拟私有云

  • 直接连接和 VPN

  • 网络扩展服务

  • 其他 AWS 网络服务

AWS 设置

如果您还没有 AWS 账户并希望跟随这些示例,请登录aws.amazon.com/并注册。这个过程非常简单明了;您需要一张信用卡和某种形式的验证。AWS 在免费套餐中提供了许多服务(aws.amazon.com/free/),在一定水平上可以免费使用一些最受欢迎的服务。

列出的一些服务在第一年是免费的,其他服务在一定限额内是免费的,没有时间限制。请查看 AWS 网站获取最新的优惠。

AWS 免费套餐

一旦您有了账户,您可以通过 AWS 控制台(console.aws.amazon.com/)登录并查看 AWS 提供的不同服务。控制台是我们可以配置所有服务并查看每月账单的地方。

AWS 控制台

AWS CLI 和 Python SDK

我们也可以通过命令行界面管理 AWS 服务。AWS CLI 是一个可以通过 PIP 安装的 Python 包(docs.aws.amazon.com/cli/latest/userguide/installing.html)。让我们在 Ubuntu 主机上安装它:

$ sudo pip3 install awscli
$ aws --version
aws-cli/1.15.59 Python/3.5.2 Linux/4.15.0-30-generic botocore/1.10.58

安装了 AWS CLI 后,为了更轻松和更安全地访问,我们将创建一个用户并使用用户凭据配置 AWS CLI。让我们回到 AWS 控制台,选择 IAM 进行用户和访问管理:

 AWS IAM

我们可以在左侧面板上选择“用户”来创建用户:

选择编程访问并将用户分配给默认管理员组:

最后一步将显示访问密钥 ID 和秘密访问密钥。将它们复制到文本文件中并保存在安全的地方:

我们将通过终端中的aws configure完成 AWS CLI 身份验证凭据设置。我们将在接下来的部分中介绍 AWS 地区;现在我们将使用us-east-1,但随时可以返回并更改这个值:

$ aws configure
AWS Access Key ID [None]: <key>
AWS Secret Access Key [None]: <secret>
Default region name [None]: us-east-1
Default output format [None]: json

我们还将安装 AWS Python SDK,Boto3 (boto3.readthedocs.io/en/latest/):

$ sudo pip install boto3
$ sudo pip3 install boto3

# verification
$ python3
Python 3.5.2 (default, Nov 23 2017, 16:37:01)
[GCC 5.4.0 20160609] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import boto3
>>> exit()

我们现在准备继续进行后续部分,从介绍 AWS 云网络服务开始。

AWS 网络概述

当我们讨论 AWS 服务时,我们需要从地区和可用性区开始。它们对我们所有的服务都有重大影响。在撰写本书时,AWS 列出了 18 个地区、55 个可用性区和一个全球范围的本地地区。用 AWS 全球基础设施的话来说,(aws.amazon.com/about-aws/global-infrastructure/):

“AWS 云基础设施建立在地区和可用性区(AZ)周围。AWS 地区提供多个物理上分离和隔离的可用性区,这些区域通过低延迟、高吞吐量和高度冗余的网络连接在一起。”

AWS 提供的一些服务是全球性的,但大多数服务是基于地区的。对我们来说,这意味着我们应该在最接近我们预期用户的地区建立基础设施。这将减少服务对客户的延迟。如果我们的用户在美国东海岸,如果服务是基于地区的,我们应该选择us-east-1(北弗吉尼亚)或us-east-2(俄亥俄)作为我们的地区:

AWS 地区

并非所有地区都对所有用户可用,例如,GovCloud 和中国地区默认情况下对美国用户不可用。您可以通过aws ec2 describe-regions列出对您可用的地区:

$ aws ec2 describe-regions
{
 "Regions": 
 {
 "RegionName": "ap-south-1",
 "Endpoint": "ec2.ap-south-1.amazonaws.com"
 },
 {
 "RegionName": "eu-west-3",
 "Endpoint": "ec2.eu-west-3.amazonaws.com"
 },
...

所有地区都是完全独立的。大多数资源不会在地区之间复制。如果我们有多个地区,比如US-EastUS-West,并且需要它们之间的冗余,我们将需要自己复制必要的资源。选择地区的方式是在控制台右上角:

![如果服务是基于地区的,例如 EC2,只有在选择正确的地区时,门户才会显示该服务。如果我们的 EC2 实例在us-east-1,而我们正在查看 us-west-1 门户,则不会显示任何 EC2 实例。我犯过这个错误几次,并且想知道我的所有实例都去哪了!在前面的 AWS 地区截图中,地区后面的数字代表每个地区的 AZ 数量。每个地区有多个可用性区。每个可用性区都是隔离的,但地区中的可用性区通过低延迟的光纤连接在一起:AWS 地区和可用性区

我们构建的许多资源都会在可用性区复制。AZ 的概念非常重要,它的约束对我们构建的网络服务非常重要。

AWS 独立地为每个账户将可用区映射到标识符。例如,我的可用区 us-eas-1a 可能与另一个账户的us-east-1a不同。

我们可以使用 AWS CLI 检查一个区域中的可用区:

$ aws ec2 describe-availability-zones --region us-east-1
{
 "AvailabilityZones": [
 {
 "Messages": [],
 "RegionName": "us-east-1",
 "State": "available",
 "ZoneName": "us-east-1a"
 },
 {
 "Messages": [],
 "RegionName": "us-east-1",
 "State": "available",
 "ZoneName": "us-east-1b"
 },
...

为什么我们如此关心区域和可用区?正如我们将在接下来的几节中看到的,网络服务通常受区域和可用区的限制。例如,虚拟私有云(VPC)需要完全位于一个区域,每个子网需要完全位于一个可用区。另一方面,NAT 网关是与可用区相关的,因此如果我们需要冗余,就需要为每个可用区创建一个。我们将更详细地介绍这两项服务,但它们的用例在这里作为 AWS 网络服务提供的基础的例子。

AWS 边缘位置AWS CloudFront内容传递网络的一部分,分布在 26 个国家的 59 个城市。这些边缘位置用于以低延迟分发内容,比整个数据中心的占地面积小。有时,人们会误将边缘位置的出现地点误认为是完整的 AWS 区域。如果占地面积仅列为边缘位置,那么 AWS 服务,如 EC2 或 S3,将不会提供。我们将在AWS CloudFront部分重新讨论边缘位置。

AWS Transit Centers是 AWS 网络中最少有文档记录的方面之一。它在 James Hamilton 的 2014 年AWS re:Invent主题演讲中提到(www.youtube.com/watch?v=JIQETrFC_SQ),作为该区域不同可用区的聚合点。公平地说,我们不知道转换中心是否仍然存在并且在这些年后是否仍然起作用。然而,对于转换中心的位置以及它与我们将在本章后面看到的AWS Direct Connect服务的相关性,做出一个合理的猜测是公平的。

James Hamilton 是 AWS 的副总裁和杰出工程师之一,是 AWS 最有影响力的技术专家之一。如果有人在 AWS 网络方面具有权威性,那就是他。您可以在他的博客 Perspectives 上阅读更多关于他的愿景,网址为perspectives.mvdirona.com/

在一个章节中不可能涵盖所有与 AWS 相关的服务。有一些与网络直接相关的相关服务我们没有空间来涵盖,但我们应该熟悉:

  • 身份和访问管理IAM)服务,aws.amazon.com/iam/,是使我们能够安全地管理对 AWS 服务和资源的访问的服务。

  • Amazon 资源名称ARNs),docs.aws.amazon.com/general/latest/gr/aws-arns-and-namespaces.html,在整个 AWS 中唯一标识 AWS 资源。当我们需要识别需要访问我们的 VPC 资源的服务时,这个资源名称是重要的,比如 DynamoDB 和 API Gateway。

  • Amazon 弹性计算云EC2),aws.amazon.com/ec2/,是使我们能够通过 AWS 接口获取和配置计算能力,如 Linux 和 Windows 实例的服务。我们将在本章的示例中使用 EC2 实例。

为了学习的目的,我们将排除 AWS GovCloud(美国)和中国,它们都不使用 AWS 全球基础设施,并且有自己的限制。

这是对 AWS 网络概述的一个相对较长的介绍,但是非常重要。这些概念和术语将在本书的其余章节中被引用。在接下来的章节中,我们将看一下 AWS 网络中最重要的概念(在我看来):虚拟私有云。

虚拟私有云

亚马逊虚拟私有云(Amazon VPC)使客户能够将 AWS 资源启动到专门为客户账户提供的虚拟网络中。这是一个真正可定制的网络,允许您定义自己的 IP 地址范围,添加和删除子网,创建路由,添加 VPN 网关,关联安全策略,将 EC2 实例连接到自己的数据中心等等。在 VPC 不可用的早期,AZ 中的所有 EC2 实例都在一个共享的单一平面网络上。客户将把他们的信息放在云中会有多舒服呢?我想不会很舒服。从 2007 年 EC2 推出到 2009 年 VPC 推出之前,VPC 功能是 AWS 最受欢迎的功能之一。

在 VPC 中离开您的 EC2 主机的数据包将被 Hypervisor 拦截。Hypervisor 将使用了解我们 VPC 结构的映射服务对其进行检查。离开您的 EC2 主机的数据包将使用 AWS 真实服务器的源和目的地地址进行封装。封装和映射服务允许 VPC 的灵活性,但也有一些 VPC 的限制(多播,嗅探)。毕竟,这是一个虚拟网络。

自 2013 年 12 月以来,所有 EC2 实例都是 VPC-only。如果我们使用启动向导创建 EC2 实例,它将自动放入具有虚拟互联网网关以进行公共访问的默认 VPC。在我看来,除了最基本的用例,所有情况都应该使用默认 VPC。对于大多数情况,我们需要定义我们的非默认自定义 VPC。

让我们在us-east-1使用 AWS 控制台创建以下 VPC:

我们在美国东部的第一个 VPC

如果您还记得,VPC 是 AWS 区域绑定的,子网是基于可用性区域的。我们的第一个 VPC 将基于us-east-1;三个子网将分配给 1a、1b 和 1c 中的三个不同的可用性区域。

使用 AWS 控制台创建 VPC 和子网非常简单,AWS 在网上提供了许多很好的教程。我已经在 VPC 仪表板上列出了相关链接的步骤:

前两个步骤是点对点的过程,大多数网络工程师甚至没有先前的经验也可以完成。默认情况下,VPC 只包含本地路由10.0.0.0/16。现在,我们将创建一个互联网网关并将其与 VPC 关联:

然后,我们可以创建一个自定义路由表,其中包含指向互联网网关的默认路由。我们将把这个路由表与我们在us-east-1a的子网10.0.0.0/24关联,从而使其可以面向公众:

路由表

让我们使用 Boto3 Python SDK 来查看我们创建了什么;我使用标签mastering_python_networking_demo作为 VPC 的标签,我们可以将其用作过滤器:

$ cat Chapter10_1_query_vpc.py
#!/usr/bin/env python3

import json, boto3

region = 'us-east-1'
vpc_name = 'mastering_python_networking_demo'

ec2 = boto3.resource('ec2', region_name=region)
client = boto3.client('ec2')

filters = [{'Name':'tag:Name', 'Values':[vpc_name]}]

vpcs = list(ec2.vpcs.filter(Filters=filters))
for vpc in vpcs:
    response = client.describe_vpcs(
                 VpcIds=[vpc.id,]
                )
    print(json.dumps(response, sort_keys=True, indent=4))

此脚本将允许我们以编程方式查询我们创建的 VPC 的区域:

$ python3 Chapter10_1_query_vpc.py
{
 "ResponseMetadata": {
 "HTTPHeaders": {
 "content-type": "text/xml;charset=UTF-8",
 ...
 },
 "HTTPStatusCode": 200,
 "RequestId": "48e19be5-01c1-469b-b6ff-9c45f2745483",
 "RetryAttempts": 0
 },
 "Vpcs": [
 {
 "CidrBlock": "10.0.0.0/16",
 "CidrBlockAssociationSet": [
 {
 "AssociationId": "...",
 "CidrBlock": "10.0.0.0/16",
 "CidrBlockState": {
 "State": "associated"
 }
 }
 ],
 "DhcpOptionsId": "dopt-....",
 "InstanceTenancy": "default",
 "IsDefault": false,
 "State": "available",
 "Tags": [
 {
 "Key": "Name",
 "Value": "mastering_python_networking_demo"
 }
 ],
 "VpcId": "vpc-...."
 }
 ]
}

Boto3 VPC API 文档可以在boto3.readthedocs.io/en/latest/reference/services/ec2.html#vpc找到。

您可能想知道 VPC 中的子网如何相互到达。在物理网络中,网络需要连接到路由器才能到达其本地网络之外。在 VPC 中也是如此,只是它是一个具有本地网络默认路由表的隐式路由器,在我们的示例中是10.0.0.0/16。当我们创建 VPC 时,将创建此隐式路由器。

路由表和路由目标

路由是网络工程中最重要的主题之一。值得更仔细地研究它。我们已经看到在创建 VPC 时有一个隐式路由器和主路由表。从上一个示例中,我们创建了一个互联网网关,一个默认路由指向互联网网关的自定义路由表,并将自定义路由表与子网关联。

路由目标的概念是 VPC 与传统网络有些不同的地方。总之:

  • 每个 VPC 都有一个隐式路由器

  • 每个 VPC 都有一个带有本地路由的主路由表

  • 您可以创建自定义路由表

  • 每个子网可以遵循自定义路由表或默认的主路由表

  • 路由表路由目标可以是互联网网关、NAT 网关、VPC 对等连接等

我们可以使用 Boto3 查看自定义路由表和子网的关联:

$ cat Chapter10_2_query_route_tables.py
#!/usr/bin/env python3

import json, boto3

region = 'us-east-1'
vpc_name = 'mastering_python_networking_demo'

ec2 = boto3.resource('ec2', region_name=region)
client = boto3.client('ec2')

response = client.describe_route_tables()
print(json.dumps(response['RouteTables'][0], sort_keys=True, indent=4))

我们只有一个自定义路由表:

$ python3 Chapter10_2_query_route_tables.py
{
 "Associations": [
 {
 ....
 }
 ],
 "PropagatingVgws": [],
 "RouteTableId": "rtb-6bee5514",
 "Routes": [
 {
 "DestinationCidrBlock": "10.0.0.0/16",
 "GatewayId": "local",
 "Origin": "CreateRouteTable",
 "State": "active"
 },
 {
 "DestinationCidrBlock": "0.0.0.0/0",
 "GatewayId": "igw-...",
 "Origin": "CreateRoute",
 "State": "active"
 }
 ],
 "Tags": [
 {
 "Key": "Name",
 "Value": "public_internet_gateway"
 }
 ],
 "VpcId": "vpc-..."
}

通过点击左侧子网部分并按照屏幕上的指示进行操作,创建子网非常简单。对于我们的目的,我们将创建三个子网,10.0.0.0/24公共子网,10.0.1.0/2410.0.2.0/24私有子网。

现在我们有一个带有三个子网的工作 VPC:一个公共子网和两个私有子网。到目前为止,我们已经使用 AWS CLI 和 Boto3 库与 AWS VPC 进行交互。让我们看看另一个自动化工具CloudFormation

使用 CloudFormation 进行自动化

AWS CloudFomation (aws.amazon.com/cloudformation/),是我们可以使用文本文件描述和启动所需资源的一种方式。我们可以使用 CloudFormation 在us-west-1地区配置另一个 VPC:

美国西部的 VPC

CloudFormation 模板可以是 YAML 或 JSON;我们将使用 YAML 来创建我们的第一个配置模板:

$ cat Chapter10_3_cloud_formation.yml
AWSTemplateFormatVersion: '2010-09-09'
Description: Create VPC in us-west-1
Resources:
 myVPC:
 Type: AWS::EC2::VPC
 Properties:
 CidrBlock: '10.1.0.0/16'
 EnableDnsSupport: 'false'
 EnableDnsHostnames: 'false'
 Tags:
 - Key: Name
 Value: 'mastering_python_networking_demo_2'

我们可以通过 AWS CLI 执行模板。请注意,在我们的执行中指定了us-west-1地区:

$ aws --region us-west-1 cloudformation create-stack --stack-name 'mpn-ch10-demo' --template-body file://Chapter10_3_cloud_formation.yml
{
 "StackId": "arn:aws:cloudformation:us-west-1:<skip>:stack/mpn-ch10-demo/<skip>"
}

我们可以通过 AWS CLI 验证状态:

$ aws --region us-west-1 cloudformation describe-stacks --stack-name mpn-ch10-demo
{
 "Stacks": [
 {
 "CreationTime": "2018-07-18T18:45:25.690Z",
 "Description": "Create VPC in us-west-1",
 "DisableRollback": false,
 "StackName": "mpn-ch10-demo",
 "RollbackConfiguration": {},
 "StackStatus": "CREATE_COMPLETE",
 "NotificationARNs": [],
 "Tags": [],
 "EnableTerminationProtection": false,
 "StackId": "arn:aws:cloudformation:us-west-1<skip>"
 }
 ]
}

为了演示目的,最后一个 CloudFormation 模板创建了一个没有任何子网的 VPC。让我们删除该 VPC,并使用以下模板创建 VPC 和子网。请注意,在 VPC 创建之前我们将没有 VPC-id,因此我们将使用特殊变量来引用子网创建中的 VPC-id。这是我们可以用于其他资源的相同技术,比如路由表和互联网网关:

$ cat Chapter10_4_cloud_formation_full.yml
AWSTemplateFormatVersion: '2010-09-09'
Description: Create subnet in us-west-1
Resources:
 myVPC:
 Type: AWS::EC2::VPC
 Properties:
 CidrBlock: '10.1.0.0/16'
 EnableDnsSupport: 'false'
 EnableDnsHostnames: 'false'
 Tags:
 - Key: Name
 Value: 'mastering_python_networking_demo_2'

 mySubnet:
 Type: AWS::EC2::Subnet
 Properties:
 VpcId: !Ref myVPC
 CidrBlock: '10.1.0.0/24'
 AvailabilityZone: 'us-west-1a'
 Tags:
 - Key: Name
 Value: 'mpn_demo_subnet_1'

我们可以执行并验证资源的创建如下:

$ aws --region us-west-1 cloudformation create-stack --stack-name mpn-ch10-demo-2 --template-body file://Chapter10_4_cloud_formation_full.yml
{
 "StackId": "arn:aws:cloudformation:us-west-1:<skip>:stack/mpn-ch10-demo-2/<skip>"
}

$ aws --region us-west-1 cloudformation describe-stacks --stack-name mpn-ch10-demo-2
{
 "Stacks": [
 {
 "StackStatus": "CREATE_COMPLETE",
 ...
 "StackName": "mpn-ch10-demo-2",
 "DisableRollback": false
 }
 ]
}

我们还可以从 AWS 控制台验证 VPC 和子网信息。我们将首先从控制台验证 VPC:

VPC 在 us-west-1

我们还可以查看子网:

us-west-1 的子网

现在我们在美国两个海岸有两个 VPC。它们目前的行为就像两个孤立的岛屿。这可能是您期望的操作状态,也可能不是。如果您希望 VPC 能够相互连接,我们可以使用 VPC 对等连接(docs.aws.amazon.com/AmazonVPC/latest/PeeringGuide/vpc-peering-basics.html)来允许直接通信。

VPC 对等连接不限于同一帐户。只要请求被接受并且其他方面(安全性、路由、DNS 名称)得到处理,您就可以连接不同帐户的 VPC。

在接下来的部分,我们将看一下 VPC 安全组和网络访问控制列表。

安全组和网络 ACL

AWS 安全组和访问控制列表可以在 VPC 的安全部分找到:

VPC 安全

安全组是一个有状态的虚拟防火墙,用于控制资源的入站和出站访问。大多数情况下,我们将使用安全组来限制对我们的 EC2 实例的公共访问。当前限制是每个 VPC 中有 500 个安全组。每个安全组最多可以包含 50 个入站和 50 个出站规则。您可以使用以下示例脚本创建一个安全组和两个简单的入站规则:

$ cat Chapter10_5_security_group.py
#!/usr/bin/env python3

import boto3

ec2 = boto3.client('ec2')

response = ec2.describe_vpcs()
vpc_id = response.get('Vpcs', [{}])[0].get('VpcId', '')

# Query for security group id
response = ec2.create_security_group(GroupName='mpn_security_group',
 Description='mpn_demo_sg',
 VpcId=vpc_id)
security_group_id = response['GroupId']
data = ec2.authorize_security_group_ingress(
 GroupId=security_group_id,
 IpPermissions=[
 {'IpProtocol': 'tcp',
 'FromPort': 80,
 'ToPort': 80,
 'IpRanges': [{'CidrIp': '0.0.0.0/0'}]},
 {'IpProtocol': 'tcp',
 'FromPort': 22,
 'ToPort': 22,
 'IpRanges': [{'CidrIp': '0.0.0.0/0'}]}
 ])
print('Ingress Successfully Set %s' % data)

# Describe security group
#response = ec2.describe_security_groups(GroupIds=[security_group_id])
print(security_group_id)

我们可以执行脚本并收到有关创建可与其他 AWS 资源关联的安全组的确认:

$ python3 Chapter10_5_security_group.py
Ingress Successfully Set {'ResponseMetadata': {'RequestId': '<skip>', 'HTTPStatusCode': 200, 'HTTPHeaders': {'server': 'AmazonEC2', 'content-type': 'text/xml;charset=UTF-8', 'date': 'Wed, 18 Jul 2018 20:51:55 GMT', 'content-length': '259'}, 'RetryAttempts': 0}}
sg-<skip>

网络访问控制列表(ACL)是一个无状态的额外安全层。VPC 中的每个子网都与一个网络 ACL 相关联。由于 ACL 是无状态的,您需要指定入站和出站规则。

安全组和 ACL 之间的重要区别如下:

  • 安全组在网络接口级别操作,而 ACL 在子网级别操作

  • 对于安全组,我们只能指定允许规则,而 ACL 支持允许和拒绝规则

  • 安全组是有状态的;返回流量会自动允许。返回流量需要在 ACL 中明确允许

让我们来看看 AWS 网络中最酷的功能之一,弹性 IP。当我最初了解弹性 IP 时,我对动态分配和重新分配 IP 地址的能力感到震惊。

弹性 IP

弹性 IP(EIP)是一种使用可以从互联网访问的公共 IPv4 地址的方式。它可以动态分配给 EC2 实例、网络接口或其他资源。弹性 IP 的一些特点如下:

  • 弹性 IP 与账户关联,并且是特定于地区的。例如,us-east-1中的 EIP 只能与us-east-1中的资源关联。

  • 您可以取消与资源的弹性 IP 关联,并将其重新关联到不同的资源。这种灵活性有时可以用于确保高可用性。例如,您可以通过将相同的 IP 地址从较小的 EC2 实例重新分配到较大的 EC2 实例来实现迁移。

  • 弹性 IP 有与之相关的小额每小时费用。

您可以从门户请求弹性 IP。分配后,您可以将其与所需的资源关联:

弹性 IP 不幸的是,弹性 IP 在每个地区有默认限制,docs.aws.amazon.com/vpc/latest/userguide/amazon-vpc-limits.html

在接下来的部分,我们将看看如何使用 NAT 网关允许私有子网与互联网通信。

NAT 网关

为了允许我们的 EC2 公共子网中的主机从互联网访问,我们可以分配一个弹性 IP 并将其与 EC2 主机的网络接口关联。然而,在撰写本书时,每个 EC2-VPC 最多只能有五个弹性 IP 的限制(docs.aws.amazon.com/AmazonVPC/latest/UserGuide/VPC_Appendix_Limits.html#vpc-limits-eips)。有时,当需要时,允许私有子网中的主机获得出站访问权限而不创建弹性 IP 和 EC2 主机之间的永久一对一映射会很好。

这就是 NAT 网关可以帮助的地方,它允许私有子网中的主机通过执行网络地址转换(NAT)临时获得出站访问权限。这个操作类似于我们通常在公司防火墙上执行的端口地址转换(PAT)。要使用 NAT 网关,我们可以执行以下步骤:

  • 通过 AWS CLI、Boto3 库或 AWS 控制台在具有对互联网网关访问权限的子网中创建 NAT 网关。NAT 网关将需要分配一个弹性 IP。

  • 将私有子网中的默认路由指向 NAT 网关。

  • NAT 网关将遵循默认路由到互联网网关以进行外部访问。

这个操作可以用下图来说明:

NAT 网关操作

NAT 网关通常围绕着 NAT 网关应该位于哪个子网的最常见问题之一。经验法则是要记住 NAT 网关需要公共访问。因此,它应该在具有公共互联网访问权限的子网中创建,并分配一个可用的弹性 IP:

NAT 网关创建

在接下来的部分中,我们将看一下如何将我们在 AWS 中闪亮的虚拟网络连接到我们的物理网络。

直接连接和 VPN

到目前为止,我们的 VPC 是驻留在 AWS 网络中的一个自包含网络。它是灵活和功能齐全的,但要访问 VPC 内部的资源,我们需要使用它们的面向互联网的服务,如 SSH 和 HTTPS。

在本节中,我们将看一下 AWS 允许我们从私人网络连接到 VPC 的两种方式:IPSec VPN 网关和直接连接。

VPN 网关

将我们的本地网络连接到 VPC 的第一种方式是使用传统的 IPSec VPN 连接。我们需要一个可以与 AWS 的 VPN 设备建立 VPN 连接的公共可访问设备。客户网关需要支持基于路由的 IPSec VPN,其中 VPN 连接被视为可以在虚拟链路上运行路由协议的连接。目前,AWS 建议使用 BGP 交换路由。

在 VPC 端,我们可以遵循类似的路由表,可以将特定子网路由到虚拟私有网关目标:

VPC VPN 连接(来源:docs.aws.amazon.com/AmazonVPC/latest/UserGuide/VPC_VPN.html

除了 IPSec VPN,我们还可以使用专用电路进行连接。

直接连接

我们看到的 IPSec VPN 连接是提供本地设备与 AWS 云资源连接的简单方法。然而,它遭受了 IPSec 在互联网上总是遭受的相同故障:它是不可靠的,我们对它几乎没有控制。性能监控很少,直到连接到我们可以控制的互联网部分才有服务级别协议(SLA)。

出于所有这些原因,任何生产级别的、使命关键的流量更有可能通过亚马逊提供的第二个选项,即 AWS 直接连接。AWS 直接连接允许客户使用专用虚拟电路将他们的数据中心和机房连接到他们的 AWS VPC。这个操作通常比较困难的部分通常是将我们的网络带到可以与 AWS 物理连接的地方,通常是在一个承载商酒店。您可以在这里找到 AWS 直接连接位置的列表:aws.amazon.com/directconnect/details/。直接连接链接只是一个光纤补丁连接,您可以从特定的承载商酒店订购,将网络连接到网络端口并配置 dot1q 干线的连接。

还有越来越多的通过第三方承运商使用 MPLS 电路和聚合链路进行直接连接的连接选项。我发现并使用的最实惠的选择之一是 Equinix Cloud Exchange (www.equinix.com/services/interconnection-connectivity/cloud-exchange/)。通过使用 Equinix Cloud Exchange,我们可以利用相同的电路并以较低成本连接到不同的云提供商:

Equinix Cloud Exchange(来源:www.equinix.com/services/interconnection-connectivity/cloud-exchange/

在接下来的部分,我们将看一下 AWS 提供的一些网络扩展服务。

网络扩展服务

在本节中,我们将看一下 AWS 提供的一些网络服务。许多服务没有直接的网络影响,比如 DNS 和内容分发网络。由于它们与网络和应用性能的密切关系,它们与我们的讨论相关。

弹性负载均衡

弹性负载均衡ELB)允许来自互联网的流量自动分布到多个 EC2 实例。就像物理世界中的负载均衡器一样,这使我们能够在减少每台服务器负载的同时获得更好的冗余和容错。ELB 有两种类型:应用和网络负载均衡。

应用负载均衡器通过 HTTP 和 HTTPS 处理 Web 流量;网络负载均衡器在 TCP 层运行。如果您的应用程序在 HTTP 或 HTTPS 上运行,通常最好选择应用负载均衡器。否则,使用网络负载均衡器是一个不错的选择。

可以在aws.amazon.com/elasticloadbalancing/details/找到应用和网络负载均衡器的详细比较:

弹性负载均衡器比较(来源:aws.amazon.com/elasticloadbalancing/details/

弹性负载均衡器提供了一种在资源进入我们地区后平衡流量的方式。AWS Route53 DNS 服务允许在地区之间进行地理负载平衡。

Route53 DNS 服务

我们都知道域名服务是什么;Route53 是 AWS 的 DNS 服务。Route53 是一个全功能的域名注册商,您可以直接从 AWS 购买和管理域名。关于网络服务,DNS 允许通过在地理区域之间以轮询方式服务域名来实现负载平衡。

在我们可以使用 DNS 进行负载平衡之前,我们需要以下项目:

  • 每个预期的负载平衡地区中都有一个弹性负载均衡器。

  • 注册的域名。我们不需要 Route53 作为域名注册商。

  • Route53 是该域的 DNS 服务。

然后我们可以在两个弹性负载均衡器之间的主动-主动环境中使用 Route 53 基于延迟的路由策略和健康检查。

CloudFront CDN 服务

CloudFront 是亚马逊的内容分发网络CDN),通过在物理上为客户提供更接近的内容,减少了内容交付的延迟。内容可以是静态网页内容、视频、应用程序、API,或者最近的 Lambda 函数。CloudFront 边缘位置包括现有的 AWS 区域,还有全球许多其他位置。CloudFront 的高级操作如下:

  • 用户访问您的网站以获取一个或多个对象

  • DNS 将请求路由到距用户请求最近的 Amazon CloudFront 边缘位置

  • CloudFront 边缘位置将通过缓存提供内容或从源请求对象

AWS CloudFront 和 CDN 服务通常由应用程序开发人员或 DevOps 工程师处理。但是,了解它们的运作方式总是很好的。

其他 AWS 网络服务

还有许多其他 AWS 网络服务,我们没有空间来介绍。一些更重要的服务列在本节中:

  • AWS Transit VPC (aws.amazon.com/blogs/aws/aws-solution-transit-vpc/):这是一种连接多个虚拟私有云到一个作为中转中心的公共 VPC 的方式。这是一个相对较新的服务,但它可以最小化您需要设置和管理的连接。这也可以作为一个工具,当您需要在不同的 AWS 账户之间共享资源时。

  • Amazon GuardDuty (aws.amazon.com/guardduty/):这是一个托管的威胁检测服务,持续监视恶意或未经授权的行为,以帮助保护我们的 AWS 工作负载。它监视 API 调用或潜在的未经授权的部署。

  • AWS WAF(aws.amazon.com/waf/):这是一个 Web 应用程序防火墙,可以帮助保护 Web 应用程序免受常见的攻击。我们可以定义定制的 Web 安全规则来允许或阻止 Web 流量。

  • AWS Shield (aws.amazon.com/shield/):这是一个托管的分布式拒绝服务DDoS)保护服务,可保护在 AWS 上运行的应用程序。基本级别的保护服务对所有客户免费;AWS Shield 的高级版本是一项收费服务。

总结

在本章中,我们深入了解了 AWS 云网络服务。我们讨论了 AWS 网络中区域、可用区、边缘位置和中转中心的定义。通过了解整体的 AWS 网络,这让我们对其他 AWS 网络服务的一些限制和内容有了一个很好的了解。在本章的整个过程中,我们使用了 AWS CLI、Python Boto3 库以及 CloudFormation 来自动化一些任务。

我们深入讨论了 AWS 虚拟私有云,包括路由表和路由目标的配置。关于安全组和网络 ACL 控制我们 VPC 的安全性的示例。我们还讨论了弹性 IP 和 NAT 网关,以允许外部访问。

连接 AWS VPC 到本地网络有两种方式:直接连接和 IPSec VPN。我们简要地介绍了每种方式以及使用它们的优势。在本章的最后,我们了解了 AWS 提供的网络扩展服务,包括弹性负载均衡、Route53 DNS 和 CloudFront。

在第十一章中,使用 Git,我们将更深入地了解我们一直在使用的版本控制系统:Git。

第十一章:使用 Git

我们已经使用 Python、Ansible 和许多其他工具在网络自动化的各个方面进行了工作。如果您一直在阅读本书的前九章的示例,我们已经使用了超过 150 个文件,其中包含超过 5300 行代码。对于可能主要使用命令行界面的网络工程师来说,这是相当不错的!有了我们的新一套脚本和工具,我们现在准备好去征服我们的网络任务了,对吗?嗯,我的同行网络忍者们,不要那么快。

我们面对的第一个任务是如何将代码文件保存在一个位置,以便我们和其他人可以检索和使用。理想情况下,这个位置应该是保存文件的最新版本的唯一位置。在初始发布之后,我们可能会在未来添加功能和修复错误,因此我们希望有一种方式来跟踪这些更改并保持最新版本可供下载。如果新的更改不起作用,我们希望回滚更改并反映文件历史中的差异。这将给我们一个关于代码文件演变的良好概念。

第二个问题是我们团队成员之间的协作过程。如果我们与其他网络工程师合作,我们将需要共同在文件上工作。这些文件可以是 Python 脚本、Ansible Playbook、Jinja2 模板、INI 风格的配置文件等等。关键是任何一种基于文本的文件都应该被多方输入跟踪,以便团队中的每个人都能看到。

第三个问题是责任制。一旦我们有了一个允许多方输入和更改的系统,我们需要用适当的记录来标记这些更改,以反映更改的所有者。记录还应包括更改的简要原因,以便审查历史的人能够理解更改的原因。

这些是版本控制(或源代码控制)系统试图解决的一些主要挑战。公平地说,版本控制可以存在于专用系统以外的形式。例如,如果我打开我的 Microsoft Word 程序,文件会不断保存自身,并且我可以回到过去查看更改或回滚到以前的版本。我们在这里关注的版本控制系统是具有主要目的跟踪软件更改的独立软件工具。

在软件工程中,有各种不同的源代码控制工具,既有专有的也有开源的。一些更受欢迎的开源版本控制系统包括 CVS、SVN、Mercurial 和 Git。在本章中,我们将专注于源代码控制系统Git,这是我们在本书中使用的许多.software软件包中下载的工具。我们将更深入地了解这个工具。Git 是许多大型开源项目的事实上的版本控制系统,包括 Python 和 Linux 内核。

截至 2017 年 2 月,CPython 开发过程已经转移到 GitHub。自 2015 年 1 月以来一直在进行中。有关更多信息,请查看www.python.org/dev/peps/pep-0512/上的 PEP 512。

在我们深入了解 Git 的工作示例之前,让我们先来看看 Git 系统的历史和优势。

Git 简介

Git 是由 Linux 内核的创造者 Linus Torvalds 于 2005 年 4 月创建的。他幽默地称这个工具为“来自地狱的信息管理者”。在 Linux 基金会的一次采访中,Linus 提到他觉得源代码控制管理在计算世界中几乎是最不有趣的事情。然而,在 Linux 内核开发社区和当时他们使用的专有系统 BitKeeper 之间发生分歧后,他还是创建了这个工具。

Git 这个名字代表什么?在英国俚语中,Git 是一个侮辱性词语,表示一个令人不愉快、恼人、幼稚的人。Linus 以他的幽默说他是一个自负的混蛋,所以他把所有的项目都以自己的名字命名。首先是 Linux,现在是 Git。然而,也有人建议这个名字是全球信息跟踪器GIT)的缩写。你可以做出判断。

这个项目很快就成形了。在创建后大约十天(没错,你没看错),Linus 觉得 Git 的基本理念是正确的,开始用 Git 提交第一个 Linux 内核代码。其余的,就像他们说的那样,就成了历史。在创建十多年后,它仍然满足 Linux 内核项目的所有期望。尽管切换源代码控制系统存在固有的惯性,它已经成为许多其他开源项目的版本控制系统。在多年托管 Python 代码后,该项目于 2017 年 2 月在 GitHub 上切换到 Git。

Git 的好处

像 Linux 内核和 Python 这样的大型分布式开源项目的成功托管,证明了 Git 的优势。这尤其重要,因为 Git 是一个相对较新的源代码控制工具,人们不倾向于切换到新工具,除非它比旧工具有显著的优势。让我们看看 Git 的一些好处:

  • 分布式开发:Git 支持在私人仓库中进行并行、独立和同时的离线开发。与其他一些版本控制系统需要与中央仓库进行不断同步相比,这为开发人员提供了更大的灵活性。

  • 扩展以处理成千上万的开发人员:许多开源项目的开发人员数量达到了成千上万。Git 支持可靠地集成他们的工作。

  • 性能:Linus 决心确保 Git 快速高效。为了节省空间和传输时间,仅 Linux 内核代码的更新量就需要压缩和增量检查来使 Git 快速高效。

  • 责任和不可变性:Git 强制在每次更改文件的提交时记录更改日志,以便对所有更改和更改原因进行跟踪。Git 中的数据对象在创建并放入数据库后无法修改,使它们不可变。这进一步强化了责任。

  • 原子事务:确保仓库的完整性,不同但相关的更改要么一起执行,要么不执行。这将确保仓库不会处于部分更改或损坏的状态。

  • 完整的仓库:每个仓库都有每个文件的所有历史修订版本的完整副本。

  • 自由,就像自由:Git 工具的起源源于 Linux 内核的免费版本与 BitKeeper VCS 之间的分歧,因此这个工具有一个非常自由的使用许可证。

让我们来看看 Git 中使用的一些术语。

Git 术语

以下是一些我们应该熟悉的 Git 术语:

  • Ref:以refs开头指向对象的名称。

  • 存储库:包含项目所有信息、文件、元数据和历史记录的数据库。它包含了所有对象集合的ref

  • 分支:活跃的开发线。最近的提交是该分支的tipHEAD。存储库可以有多个分支,但您的工作树工作目录只能与一个分支关联。有时这被称为当前或checked out分支。

  • 检出:将工作树的全部或部分更新到特定点的操作。

  • 提交:Git 历史中的一个时间点,或者可以表示将新的快照存储到存储库中。

  • 合并:将另一个分支的内容合并到当前分支的操作。例如,我正在将development分支与master分支合并。

  • 获取:从远程存储库获取内容的操作。

  • 拉取:获取并合并存储库的内容。

  • 标签:存储库中某个时间点的标记。在第四章中,Python 自动化框架- Ansible 基础,我们看到标签用于指定发布点,v2.5.0a1

这不是一个完整的列表;请参考 Git 术语表,git-scm.com/docs/gitglossary,了解更多术语及其定义。

Git 和 GitHub

Git 和 GitHub 并不是同一回事。对于新手来说,这有时会让工程师感到困惑。Git 是一个版本控制系统,而 GitHub,github.com/,是 Git 存储库的集中式托管服务。

因为 Git 是一个分散的系统,GitHub 存储了我们项目的存储库的副本,就像其他任何开发人员一样。通常,我们将 GitHub 存储库指定为项目的中央存储库,所有其他开发人员将其更改推送到该存储库,并从该存储库拉取更改。

GitHub 通过使用forkpull requests机制,进一步将这个在分布式系统中的集中存储库的概念发扬光大。对于托管在 GitHub 上的项目,鼓励开发人员fork存储库,或者复制存储库,并在该复制品上工作作为他们的集中存储库。在做出更改后,他们可以向主项目发送pull request,项目维护人员可以审查更改,并在适当的情况下commit更改。GitHub 还除了命令行之外,还为存储库添加了 Web 界面;这使得 Git 更加用户友好。

设置 Git

到目前为止,我们只是使用 Git 从 GitHub 下载文件。在本节中,我们将进一步设置 Git 变量,以便开始提交我们的文件。我将在示例中使用相同的 Ubuntu 16.04 主机。安装过程有很好的文档记录;如果您使用的是不同版本的 Linux 或其他操作系统,快速搜索应该能找到正确的指令集。

如果您还没有这样做,请通过apt软件包管理工具安装 Git:

$ sudo apt-get update
$ sudo apt-get install -y git
$ git --version
git version 2.7.4

安装了git之后,我们需要配置一些东西,以便我们的提交消息可以包含正确的信息:

$ git config --global user.name "Your Name"
$ git config --global user.email "email@domain.com"
$ git config --list
user.name=Your Name
user.email=email@domain.com

或者,您可以修改~/.gitconfig文件中的信息:

$ cat ~/.gitconfig
[user]
 name = Your Name
 email = email@domain.com

Git 中还有许多其他选项可以更改,但是名称和电子邮件是允许我们提交更改而不会收到警告的选项。个人而言,我喜欢使用 VIM,而不是默认的 Emac,作为我的文本编辑器来输入提交消息:

(optional)
$ git config --global core.editor "vim"
$ git config --list
user.name=Your Name
user.email=email@domain.com
core.editor=vim

在我们继续使用 Git 之前,让我们先了解一下gitignore文件的概念。

Gitignore

有时,有些文件您不希望 Git 检查到 GitHub 或其他存储库中。这样做的最简单方法是在repository文件夹中创建.gitignore;Git 将使用它来确定在进行提交之前应该忽略哪些文件。这个文件应该提交到存储库中,以便与其他用户共享忽略规则。

这个文件可以包括特定于语言的文件,例如,让我们排除 Python 的Byte-compiled文件:

# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class

我们还可以包括特定于您的操作系统的文件:

# OSX
# =========================

.DS_Store
.AppleDouble
.LSOverride

您可以在 GitHub 的帮助页面上了解更多关于.gitignore的信息:help.github.com/articles/ignoring-files/。以下是一些其他参考资料:

我认为.gitignore文件应该与任何新存储库同时创建。这就是为什么这个概念尽早被引入的原因。我们将在下一节中看一些 Git 使用示例。

Git 使用示例

大多数时候,当我们使用 Git 时,我们会使用命令行:

$ git --help
usage: git [--version] [--help] [-C <path>] [-c name=value]
 [--exec-path[=<path>]] [--html-path] [--man-path] [--info-path]
 [-p | --paginate | --no-pager] [--no-replace-objects] [--bare]
 [--git-dir=<path>] [--work-tree=<path>] [--namespace=<name>]
 <command> [<args>]

我们将创建一个repository并在其中创建一个文件:

$ mkdir TestRepo
$ cd TestRepo/
$ git init
Initialized empty Git repository in /home/echou/Master_Python_Networking_second_edition/Chapter11/TestRepo/.git/
$ echo "this is my test file" > myFile.txt

当使用 Git 初始化存储库时,会在目录中添加一个新的隐藏文件夹.git。它包含所有与 Git 相关的文件:

$ ls -a
. .. .git myFile.txt

$ ls .git/
branches config description HEAD hooks info objects refs

Git 接收其配置的位置有几个层次结构。您可以使用git config -l命令来查看聚合配置:

$ ls .git/config
.git/config

$ ls ~/.gitconfig
/home/echou/.gitconfig

$ git config -l
user.name=Eric Chou
user.email=<email>
core.editor=vim
core.repositoryformatversion=0
core.filemode=true
core.bare=false
core.logallrefupdates=true

当我们在存储库中创建一个文件时,它不会被跟踪。为了让git意识到这个文件,我们需要添加这个文件:

$ git status
On branch master

Initial commit

Untracked files:
 (use "git add <file>..." to include in what will be committed)

 myFile.txt

nothing added to commit but untracked files present (use "git add" to track)

$ git add myFile.txt
$ git status
On branch master

Initial commit

Changes to be committed:
 (use "git rm --cached <file>..." to unstage)

 new file: myFile.txt

当您添加文件时,它处于暂存状态。为了使更改生效,我们需要提交更改:

$ git commit -m "adding myFile.txt"
[master (root-commit) 5f579ab] adding myFile.txt
 1 file changed, 1 insertion(+)
 create mode 100644 myFile.txt

$ git status
On branch master
nothing to commit, working directory clean

在上一个示例中,我们在发出提交语句时使用了-m选项来提供提交消息。如果我们没有使用该选项,我们将被带到一个页面上来提供提交消息。在我们的情况下,我们配置了文本编辑器为 vim,因此我们将能够使用 vim 来编辑消息。

让我们对文件进行一些更改并提交它:

$ vim myFile.txt
$ cat myFile.txt
this is the second iteration of my test file
$ git status
On branch master
Changes not staged for commit:
 (use "git add <file>..." to update what will be committed)
 (use "git checkout -- <file>..." to discard changes in working directory)

 modified: myFile.txt
$ git add myFile.txt
$ git commit -m "made modificaitons to myFile.txt"
[master a3dd3ea] made modificaitons to myFile.txt
 1 file changed, 1 insertion(+), 1 deletion(-)

git commit号是一个SHA1 哈希,这是一个重要的特性。如果我们在另一台计算机上按照相同的步骤操作,我们的SHA1 哈希值将是相同的。这就是 Git 知道这两个存储库在并行工作时是相同的方式。

我们可以使用git log来显示提交的历史记录。条目以相反的时间顺序显示;每个提交显示作者的姓名和电子邮件地址,日期,日志消息,以及提交的内部标识号:

$ git log
commit a3dd3ea8e6eb15b57d1f390ce0d2c3a03f07a038
Author: Eric Chou <echou@yahoo.com>
Date: Fri Jul 20 09:58:24 2018 -0700

 made modificaitons to myFile.txt

commit 5f579ab1e9a3fae13aa7f1b8092055213157524d
Author: Eric Chou <echou@yahoo.com>
Date: Fri Jul 20 08:05:09 2018 -0700

 adding myFile.txt

我们还可以使用提交 ID 来显示更改的更多细节:

$ git show a3dd3ea8e6eb15b57d1f390ce0d2c3a03f07a038
commit a3dd3ea8e6eb15b57d1f390ce0d2c3a03f07a038
Author: Eric Chou <echou@yahoo.com>
Date: Fri Jul 20 09:58:24 2018 -0700

 made modificaitons to myFile.txt

diff --git a/myFile.txt b/myFile.txt
index 6ccb42e..69e7d47 100644
--- a/myFile.txt
+++ b/myFile.txt
@@ -1 +1 @@
-this is my test file
+this is the second iteration of my test file

如果您需要撤消所做的更改,您可以选择revertreset之间。revert将特定提交的所有文件更改回到它们在提交之前的状态:

$ git revert a3dd3ea8e6eb15b57d1f390ce0d2c3a03f07a038
[master 9818f29] Revert "made modificaitons to myFile.txt"
 1 file changed, 1 insertion(+), 1 deletion(-)

# Check to verified the file content was before the second change. 
$ cat myFile.txt
this is my test file

revert命令将保留您撤消的提交并创建一个新的提交。您将能够看到到那一点的所有更改,包括撤消:

$ git log
commit 9818f298f477fd880db6cb87112b50edc392f7fa
Author: Eric Chou <echou@yahoo.com>
Date: Fri Jul 20 13:11:30 2018 -0700

 Revert "made modificaitons to myFile.txt"

 This reverts commit a3dd3ea8e6eb15b57d1f390ce0d2c3a03f07a038.

 modified: reverted the change to myFile.txt

commit a3dd3ea8e6eb15b57d1f390ce0d2c3a03f07a038
Author: Eric Chou <echou@yahoo.com>
Date: Fri Jul 20 09:58:24 2018 -0700

 made modificaitons to myFile.txt

commit 5f579ab1e9a3fae13aa7f1b8092055213157524d
Author: Eric Chou <echou@yahoo.com>
Date: Fri Jul 20 08:05:09 2018 -0700

 adding myFile.txt

reset选项将将存储库的状态重置为旧版本,并丢弃其中的所有更改:

$ git reset --hard a3dd3ea8e6eb15b57d1f390ce0d2c3a03f07a038
HEAD is now at a3dd3ea made modificaitons to myFile.txt

$ git log
commit a3dd3ea8e6eb15b57d1f390ce0d2c3a03f07a038
Author: Eric Chou <echou@yahoo.com>
Date: Fri Jul 20 09:58:24 2018 -0700

 made modificaitons to myFile.txt

commit 5f579ab1e9a3fae13aa7f1b8092055213157524d
Author: Eric Chou <echou@yahoo.com>
Date: Fri Jul 20 08:05:09 2018 -0700

 adding myFile.txt

就个人而言,我喜欢保留所有历史记录,包括我所做的任何回滚。因此,当我需要回滚更改时,我通常选择revert而不是reset

git中的分支是存储库内的开发线。Git 允许在存储库内有许多分支和不同的开发线。默认情况下,我们有主分支。分支的原因有很多,但大多数代表单个客户发布或开发阶段,即dev分支。让我们在我们的存储库中创建一个dev分支:

$ git branch dev
$ git branch
 dev
* master

要开始在分支上工作,我们需要检出该分支:

$ git checkout dev
Switched to branch 'dev'
$ git branch
* dev
 master

让我们在dev分支中添加第二个文件:

$ echo "my second file" > mySecondFile.txt
$ git add mySecondFile.txt
$ git commit -m "added mySecondFile.txt to dev branch"
[dev c983730] added mySecondFile.txt to dev branch
 1 file changed, 1 insertion(+)
 create mode 100644 mySecondFile.txt

我们可以回到master分支并验证两行开发是分开的:

$ git branch
* dev
 master
$ git checkout master
Switched to branch 'master'
$ ls
myFile.txt
$ git checkout dev
Switched to branch 'dev'
$ ls
myFile.txt mySecondFile.txt

dev分支中的内容写入master分支,我们需要将它们合并

$ git branch
* dev
 master
$ git checkout master
$ git merge dev master
Updating a3dd3ea..c983730
Fast-forward
 mySecondFile.txt | 1 +
 1 file changed, 1 insertion(+)
 create mode 100644 mySecondFile.txt
$ git branch
 dev
* master
$ ls
myFile.txt mySecondFile.txt

我们可以使用git rm来删除文件。让我们创建第三个文件并将其删除:

$ touch myThirdFile.txt
$ git add myThirdFile.txt
$ git commit -m "adding myThirdFile.txt"
[master 2ec5f7d] adding myThirdFile.txt
 1 file changed, 0 insertions(+), 0 deletions(-)
 create mode 100644 myThirdFile.txt
$ ls
myFile.txt mySecondFile.txt myThirdFile.txt
$ git rm myThirdFile.txt
rm 'myThirdFile.txt'
$ git status
On branch master
Changes to be committed:
 (use "git reset HEAD <file>..." to unstage)

 deleted: myThirdFile.txt
$ git commit -m "deleted myThirdFile.txt"
[master bc078a9] deleted myThirdFile.txt
 1 file changed, 0 insertions(+), 0 deletions(-)
 delete mode 100644 myThirdFile.txt

我们将能够在日志中看到最后两次更改:

$ git log
commit bc078a97e41d1614c1ba1f81f72acbcd95c0728c
Author: Eric Chou <echou@yahoo.com>
Date: Fri Jul 20 14:02:02 2018 -0700

 deleted myThirdFile.txt

commit 2ec5f7d1a734b2cc74343ce45075917b79cc7293
Author: Eric Chou <echou@yahoo.com>
Date: Fri Jul 20 14:01:18 2018 -0700

 adding myThirdFile.txt

我们已经了解了 Git 的大部分基本操作。让我们看看如何使用 GitHub 共享我们的存储库。

GitHub 示例

在这个例子中,我们将使用 GitHub 作为同步我们的本地存储库并与其他用户共享的集中位置。

我们将在 GitHub 上创建一个存储库。默认情况下,GitHub 有一个免费的公共存储库;在我的情况下,我支付一个小额的月费来托管私人存储库。在创建时,您可以选择创建许可证和.gitignore文件:

GitHub 私人存储库

存储库创建后,我们可以找到该存储库的 URL:

GitHub 存储库 URL

我们将使用此 URL 创建一个远程目标;我们将其命名为gitHubRepo

$ git remote add gitHubRepo https://github.com/ericchou1/TestRepo.git
$ git remote -v
gitHubRepo https://github.com/ericchou1/TestRepo.git (fetch)
gitHubRepo https://github.com/ericchou1/TestRepo.git (push)

由于我们选择在创建时创建README.mdLICENSE文件,远程存储库和当前存储库不同。如果我们将本地更改推送到 GitHub 存储库,将收到以下错误:

$ git push gitHubRepo master
Username for 'https://github.com': echou@yahoo.com
Password for 'https://echou@yahoo.com@github.com':
To https://github.com/ericchou1/TestRepo.git
 ! [rejected] master -> master (fetch first)

我们将继续使用git pull从 GitHub 获取新文件:

$ git pull gitHubRepo master
Username for 'https://github.com': <username>
Password for 'https://<username>@github.com':
From https://github.com/ericchou1/TestRepo
 * branch master -> FETCH_HEAD
Merge made by the 'recursive' strategy.
 .gitignore | 104 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 LICENSE | 21 +++++++++++++
 README.md | 2 ++
 3 files changed, 127 insertions(+)
 create mode 100644 .gitignore
 create mode 100644 LICENSE
 create mode 100644 README.md

现在我们将能够将内容推送到 GitHub:

$ git push gitHubRepo master
Username for 'https://github.com': <username>
Password for 'https://<username>@github.com':
Counting objects: 15, done.
Compressing objects: 100% (9/9), done.
Writing objects: 100% (15/15), 1.51 KiB | 0 bytes/s, done.
Total 15 (delta 1), reused 0 (delta 0)
remote: Resolving deltas: 100% (1/1), done.
To https://github.com/ericchou1/TestRepo.git
 a001b81..0aa362a master -> master

我们可以在网页上验证 GitHub 存储库的内容:

GitHub 存储库

现在另一个用户可以简单地制作存储库的副本,或克隆

[This is operated from another host]
$ cd /tmp
$ git clone https://github.com/ericchou1/TestRepo.git
Cloning into 'TestRepo'...
remote: Counting objects: 20, done.
remote: Compressing objects: 100% (13/13), done.
remote: Total 20 (delta 2), reused 15 (delta 1), pack-reused 0
Unpacking objects: 100% (20/20), done.
$ cd TestRepo/
$ ls
LICENSE myFile.txt
README.md mySecondFile.txt

这个复制的存储库将是我原始存储库的精确副本,包括所有提交历史:

$ git log
commit 0aa362a47782e7714ca946ba852f395083116ce5 (HEAD -> master, origin/master, origin/HEAD)
Merge: bc078a9 a001b81
Author: Eric Chou <echou@yahoo.com>
Date: Fri Jul 20 14:18:58 2018 -0700

 Merge branch 'master' of https://github.com/ericchou1/TestRepo

commit a001b816bb75c63237cbc93067dffcc573c05aa2
Author: Eric Chou <ericchou1@users.noreply.github.com>
Date: Fri Jul 20 14:16:30 2018 -0700

 Initial commit
...

我还可以在存储库设置下邀请另一个人作为项目的合作者:

存储库邀请

在下一个例子中,我们将看到如何分叉存储库并为我们不维护的存储库发起拉取请求。

通过拉取请求进行协作

如前所述,Git 支持开发人员之间的合作,用于单个项目。我们将看看当代码托管在 GitHub 上时是如何完成的。

在这种情况下,我将查看这本书的 GitHub 存储库。我将使用不同的 GitHub 句柄,所以我会以不同的用户身份出现。我将点击分叉按钮,在我的个人帐户中制作存储库的副本:

Git 分叉底部

制作副本需要几秒钟:

Git 正在进行分叉

分叉后,我们将在我们的个人帐户中拥有存储库的副本:

Git 分叉

我们可以按照之前使用过的相同步骤对文件进行一些修改。在这种情况下,我将对README.md文件进行一些更改。更改完成后,我可以点击“新拉取请求”按钮来创建一个拉取请求:

拉取请求

在发起拉取请求时,我们应尽可能填写尽可能多的信息,以提供更改的理由:

拉取请求详细信息

存储库维护者将收到拉取请求的通知;如果被接受,更改将传递到原始存储库:

拉取请求记录

GitHub 为与其他开发人员合作提供了一个出色的平台;这很快成为了许多大型开源项目的事实开发选择。在接下来的部分,让我们看看如何使用 Python 与 Git。

使用 Python 的 Git

有一些 Python 包可以与 Git 和 GitHub 一起使用。在本节中,我们将看一下 GitPython 和 PyGithub 库。

GitPython

我们可以使用 GitPython 包gitpython.readthedocs.io/en/stable/index.html来处理我们的 Git 存储库。我们将安装该包并使用 Python shell 来构建一个Repo对象。从那里,我们可以列出存储库中的所有提交:

$ sudo pip3 install gitpython
$ python3
>>> from git import Repo
>>> repo = Repo('/home/echou/Master_Python_Networking_second_edition/Chapter11/TestRepo')
>>> for commits in list(repo.iter_commits('master')):
... print(commits)
...
0aa362a47782e7714ca946ba852f395083116ce5
a001b816bb75c63237cbc93067dffcc573c05aa2
bc078a97e41d1614c1ba1f81f72acbcd95c0728c
2ec5f7d1a734b2cc74343ce45075917b79cc7293
c98373069f27d8b98d1ddacffe51b8fa7a30cf28
a3dd3ea8e6eb15b57d1f390ce0d2c3a03f07a038
5f579ab1e9a3fae13aa7f1b8092055213157524d

我们还可以查看索引条目:

>>> for (path, stage), entry in index.entries.items():
... print(path, stage, entry)
...
mySecondFile.txt 0 100644 75d6370ae31008f683cf18ed086098d05bf0e4dc 0 mySecondFile.txt
LICENSE 0 100644 52feb16b34de141a7567e4d18164fe2400e9229a 0 LICENSE
myFile.txt 0 100644 69e7d4728965c885180315c0d4c206637b3f6bad 0 myFile.txt
.gitignore 0 100644 894a44cc066a027465cd26d634948d56d13af9af 0 .gitignore
README.md 0 100644 a29fe688a14d119c20790195a815d078976c3bc6 0 README.md
>>>

GitPython 与所有 Git 功能集成良好。但是它并不是最容易使用的。我们需要了解 Git 的术语和结构,以充分利用 GitPython。但是要记住,以防我们需要它用于其他项目。

PyGitHub

让我们看看如何使用 PyGitHub 包pygithub.readthedocs.io/en/latest/与 GitHub 存储库进行交互。该包是围绕 GitHub APIv3 的包装器developer.github.com/v3/

$ sudo pip install pygithub
$ sudo pip3 install pygithub

让我们使用 Python shell 来打印用户当前的存储库:

$ python3
>>> from github import Github
>>> g = Github("ericchou1", "<password>")
>>> for repo in g.get_user().get_repos():
...     print(repo.name)
...
ansible
...
-Hands-on-Network-Programming-with-Python
Mastering-Python-Networking
Mastering-Python-Networking-Second-Edition
>>>

为了更多的编程访问,我们还可以使用访问令牌创建更细粒度的控制。Github 允许令牌与所选权限关联:

GitHub 令牌生成

如果使用访问令牌作为认证机制,输出会有些不同:

>>> from github import Github
>>> g = Github("<token>")
>>> for repo in g.get_user().get_repos():
...     print(repo)
...
Repository(full_name="oreillymedia/distributed_denial_of_service_ddos")
Repository(full_name="PacktPublishing/-Hands-on-Network-Programming-with-Python")
Repository(full_name="PacktPublishing/Mastering-Python-Networking")
Repository(full_name="PacktPublishing/Mastering-Python-Networking-Second-Edition")
...

现在我们熟悉了 Git、GitHub 和一些 Python 包,我们可以使用它们来处理技术。在接下来的部分,我们将看一些实际的例子。

自动化配置备份

在这个例子中,我们将使用 PyGithub 来备份包含我们路由器配置的目录。我们已经看到了如何使用 Python 或 Ansible 从我们的设备中检索信息;现在我们可以将它们检入 GitHub。

我们有一个子目录,名为config,其中包含我们的路由器配置的文本格式:

$ ls configs/
iosv-1 iosv-2

$ cat configs/iosv-1
Building configuration...

Current configuration : 4573 bytes
!
! Last configuration change at 02:50:05 UTC Sat Jun 2 2018 by cisco
!
version 15.6
service timestamps debug datetime msec
...

我们可以使用以下脚本从我们的 GitHub 存储库中检索最新的索引,构建我们需要提交的内容,并自动提交配置:

$ cat Chapter11_1.py
#!/usr/bin/env python3
# reference: https://stackoverflow.com/questions/38594717/how-do-i-push-new-files-to-github

from github import Github, InputGitTreeElement
import os

github_token = '<token>'
configs_dir = 'configs'
github_repo = 'TestRepo'

# Retrieve the list of files in configs directory
file_list = []
for dirpath, dirname, filenames in os.walk(configs_dir):
    for f in filenames:
        file_list.append(configs_dir + "/" + f)

g = Github(github_token)
repo = g.get_user().get_repo(github_repo)

commit_message = 'add configs'
master_ref = repo.get_git_ref('heads/master')
master_sha = master_ref.object.sha
base_tree = repo.get_git_tree(master_sha)

element_list = list()

for entry in file_list:
    with open(entry, 'r') as input_file:
        data = input_file.read()
    element = InputGitTreeElement(entry, '100644', 'blob', data)
    element_list.append(element)

# Create tree and commit
tree = repo.create_git_tree(element_list, base_tree)
parent = repo.get_git_commit(master_sha)
commit = repo.create_git_commit(commit_message, tree, [parent])
master_ref.edit(commit.sha)

我们可以在 GitHub 存储库中看到configs目录:

Configs 目录

提交历史显示了我们脚本的提交:

提交历史

GitHub 示例部分,我们看到了如何通过分叉存储库并发出拉取请求与其他开发人员合作。让我们看看如何进一步使用 Git 进行协作。

与 Git 协作

Git 是一种很棒的协作技术,而 GitHub 是一种非常有效的共同开发项目的方式。GitHub 为世界上任何有互联网访问权限的人提供了一个免费分享他们的想法和代码的地方。我们知道如何使用 Git 和一些基本的 GitHub 协作步骤,但是我们如何加入并为一个项目做出贡献呢?当然,我们想回馈给那些给予我们很多的开源项目,但是我们如何开始呢?

在本节中,我们将看一些关于使用 Git 和 GitHub 进行软件开发协作的要点:

  • 从小开始:理解的最重要的事情之一是我们在团队中可以扮演的角色。我们可能擅长网络工程,但是 Python 开发水平一般。有很多事情我们可以做,不一定要成为高技能的开发者。不要害怕从小事做起,文档编写和测试是成为贡献者的好方法。

  • 学习生态系统:对于任何项目,无论大小,都有一套已经建立的惯例和文化。我们都被 Python 的易于阅读的语法和初学者友好的文化所吸引;他们还有一个围绕这种意识形态的开发指南(devguide.python.org/)。另一方面,Ansible 项目还有一个广泛的社区指南(docs.ansible.com/ansible/latest/community/index.html)。它包括行为准则、拉取请求流程、如何报告错误以及发布流程。阅读这些指南,了解感兴趣项目的生态系统。

  • 创建分支:我犯了一个错误,分叉了一个项目并为主分支提出了拉取请求。主分支应该留给核心贡献者进行更改。我们应该为我们的贡献创建一个单独的分支,并允许在以后的某个日期合并该分支。

  • 保持分叉存储库同步:一旦您分叉了一个项目,就没有规则强制克隆存储库与主存储库同步。我们应该定期执行git pull(获取代码并在本地合并)或git fetch(获取本地任何更改的代码)以确保我们拥有主存储库的最新副本。

  • 友善相处:就像现实世界一样,虚拟世界也不容忍敌意。讨论问题时,要文明友好,即使意见不一致也是如此。

Git 和 GitHub 为任何有动力的个人提供了一种方式,使其易于在项目上进行协作,从而产生影响。我们都有能力为任何我们感兴趣的开源或私有项目做出贡献。

总结

在本章中,我们看了一下被称为 Git 的版本控制系统及其近亲 GitHub。Git 是由 Linus Torvolds 于 2005 年开发的,用于帮助开发 Linux 内核,后来被其他开源项目采用为源代码控制系统。Git 是一个快速、分布式和可扩展的系统。GitHub 提供了一个集中的位置在互联网上托管 Git 存储库,允许任何有互联网连接的人进行协作。

我们看了如何在命令行中使用 Git,以及它的各种操作,以及它们在 GitHub 中的应用。我们还研究了两个用于处理 Git 的流行 Python 库:GitPython 和 PyGitHub。我们以一个配置备份示例和关于项目协作的注释结束了本章。

在第十二章中,使用 Jenkins 进行持续集成,我们将看另一个流行的开源工具,用于持续集成和部署:Jenkins。

第十二章:使用 Jenkins 进行持续集成

网络触及技术堆栈的每个部分;在我工作过的所有环境中,它总是一个零级服务。它是其他服务依赖的基础服务。在其他工程师、业务经理、运营商和支持人员的心目中,网络应该只是工作。它应该始终可访问并且功能正常——一个好的网络是一个没有人听说过的网络。

当然,作为网络工程师,我们知道网络和其他技术堆栈一样复杂。由于其复杂性,构成运行网络的构件有时可能很脆弱。有时,我看着一个网络,想知道它怎么可能工作,更不用说它是如何在数月甚至数年内运行而没有对业务产生影响的。

我们对网络自动化感兴趣的部分原因是为了找到可靠和一致地重复我们的网络变更流程的方法。通过使用 Python 脚本或 Ansible 框架,我们可以确保所做的变更保持一致并可靠地应用。正如我们在上一章中看到的,我们可以使用 Git 和 GitHub 可靠地存储流程的组件,如模板、脚本、需求和文件。构成基础设施的代码是经过版本控制、协作和对变更负责的。但我们如何将所有这些部分联系在一起呢?在本章中,我们将介绍一个流行的开源工具,可以优化网络管理流程,名为 Jenkins。

传统的变更管理流程

对于在大型网络环境中工作过的工程师来说,他们知道网络变更出错的影响可能很大。我们可以进行数百次变更而没有任何问题,但只需要一个糟糕的变更就能导致网络对业务产生负面影响。

关于网络故障导致业务痛苦的故事数不胜数。2011 年最显著和大规模的 AWS EC2 故障是由于我们在 AWS US-East 地区的正常扩展活动中的网络变更引起的。变更发生在 PDT 时间 00:47,并导致各种服务出现 12 小时以上的停机,给亚马逊造成了数百万美元的损失。更重要的是,这个相对年轻的服务的声誉受到了严重打击。IT 决策者将这次故障作为“不要”迁移到 AWS 云的理由。花了多年时间才重建了其声誉。您可以在aws.amazon.com/message/65648/阅读更多关于事故报告的信息。

由于其潜在影响和复杂性,在许多环境中,都实施了网络变更咨询委员会(CAB)。典型的 CAB 流程如下:

  1. 网络工程师将设计变更并详细列出所需的步骤。这可能包括变更的原因、涉及的设备、将要应用或删除的命令、如何验证输出以及每个步骤的预期结果。

  2. 通常要求网络工程师首先从同行那里获得技术审查。根据变更的性质,可能需要不同级别的同行审查。简单的变更可能需要单个同行技术审查;复杂的变更可能需要高级指定工程师批准。

  3. CAB 会议通常按照固定时间安排,也可以临时召开紧急会议。

  4. 工程师将变更提交给委员会。委员会将提出必要的问题,评估影响,并批准或拒绝变更请求。

  5. 变更将在预定的变更窗口进行,由原始工程师或其他工程师执行。

这个过程听起来合理和包容,但在实践中证明有一些挑战:

  • 撰写文稿耗时:设计工程师通常需要花费很多时间来撰写文档,有时写作过程所需时间比应用变更的时间还长。这通常是因为所有网络更改都可能产生影响,我们需要为技术和非技术 CAB 成员记录过程。

  • 工程师专业知识:有不同水平的工程专业知识,有些经验更丰富,他们通常是最受欢迎的资源。我们应该保留他们的时间来解决最复杂的网络问题,而不是审查基本的网络更改。

  • 会议耗时:组织会议和让每个成员出席需要很多精力。如果需要批准的人员正在度假或生病会发生什么?如果您需要在预定的 CAB 时间之前进行网络更改呢?

这些只是基于人的 CAB 流程的一些更大的挑战。就我个人而言,我非常讨厌 CAB 流程。我不否认对同行审查和优先级排序的需求;但是,我认为我们需要尽量减少潜在的开销。让我们看看在软件工程流程中采用的潜在流程。

持续集成简介

在软件开发中的持续集成(CI)是一种快速发布对代码库的小更改的方式,同时进行测试和验证。关键是对可以进行 CI 兼容的更改进行分类,即不过于复杂,并且足够小,以便可以轻松撤销。测试和验证过程是以自动化方式构建的,以获得对其将被应用而不会破坏整个系统的信心基线。

在 CI 之前,对软件的更改通常是以大批量进行的,并且通常需要一个漫长的验证过程。开发人员可能需要几个月才能看到他们的更改在生产中生效,获得反馈并纠正任何错误。简而言之,CI 流程旨在缩短从想法到变更的过程。

一般的工作流程通常包括以下步骤:

  1. 第一位工程师获取代码库的当前副本并进行更改

  2. 第一位工程师向仓库提交变更

  3. 仓库可以通知需要的人员仓库的变化,以便一组工程师审查变化。他们可以批准或拒绝变更

  4. 持续集成系统可以持续地从仓库中获取变更,或者当变更发生时,仓库可以向 CI 系统发送通知。无论哪种方式,CI 系统都将获取代码的最新版本

  5. CI 系统将运行自动化测试,以尝试捕捉任何故障

  6. 如果没有发现故障,CI 系统可以选择将更改合并到主代码中,并可选择部署到生产系统

这是一个概括的步骤列表。对于每个组织,流程可能会有所不同;例如,可以在提交增量代码后立即运行自动化测试,而不是在代码审查后运行。有时,组织可能选择在步骤之间进行人工工程师参与进行理智检查。

在下一节中,我们将说明在 Ubuntu 16.04 系统上安装 Jenkins 的说明。

安装 Jenkins

在本章中我们将使用的示例中,我们可以在管理主机或单独的机器上安装 Jenkins。我个人偏好将其安装在单独的虚拟机上。到目前为止,虚拟机将具有与管理主机相似的网络设置,一个接口用于互联网连接,另一个接口用于 VMNet 2 连接到 VIRL 管理网络。

Jenkins 镜像和每个操作系统的安装说明可以在jenkins.io/download/找到。以下是我在 Ubuntu 16.04 主机上安装 Jenkins 所使用的说明:

$ wget -q -O - https://pkg.jenkins.io/debian-stable/jenkins.io.key | sudo apt-key add -

# added Jenkins to /etc/apt/sources.list
$ cat /etc/apt/sources.list | grep jenkins
deb https://pkg.jenkins.io/debian-stable binary/

# install Java8
$ sudo add-apt-repository ppa:webupd8team/java
$ sudo apt update; sudo apt install oracle-java8-installer

$ sudo apt-get update
$ sudo apt-get install jenkins

# Start Jenkins
$ /etc/init.d/jenkins start

在撰写本文时,我们必须单独安装 Java,因为 Jenkins 不适用于 Java 9;有关更多详细信息,请参阅issues.jenkins-ci.org/browse/JENKINS-40689。希望在您阅读本文时,该问题已得到解决。

Jenkins 安装完成后,我们可以将浏览器指向端口8080的 IP 地址以继续该过程:

解锁 Jenkins 屏幕

如屏幕上所述,从/var/lib/jenkins/secrets/initialAdminPassword获取管理员密码,并将输出粘贴到屏幕上。暂时,我们将选择“安装建议的插件”选项:

安装建议的插件

创建管理员用户后,Jenkins 将准备就绪。如果您看到 Jenkins 仪表板,则安装成功:

Jenkins 仪表板

我们现在准备使用 Jenkins 来安排我们的第一个作业。

Jenkins 示例

在本节中,我们将看一些 Jenkins 示例以及它们如何与本书中涵盖的各种技术联系在一起。Jenkins 之所以是本书的最后一章,是因为它将利用许多其他工具,例如我们的 Python 脚本、Ansible、Git 和 GitHub。如有需要,请随时参阅第十一章,使用 Git

在示例中,我们将使用 Jenkins 主服务器来执行我们的作业。在生产中,建议添加 Jenkins 节点来处理作业的执行。

在我们的实验中,我们将使用一个简单的带有 IOSv 设备的两节点拓扑结构:

第十二章实验拓扑

让我们构建我们的第一个作业。

Python 脚本的第一个作业

对于我们的第一个作业,让我们使用我们在第二章中构建的 Parmiko 脚本,低级网络设备交互chapter2_3.py。如果您还记得,这是一个使用Paramiko对远程设备进行ssh并获取设备的show runshow version输出的脚本:

$ ls
chapter12_1.py
$ python3 /home/echou/Chapter12/chapter12_1.py
...
$ ls
chapter12_1.py iosv-1_output.txt iosv-2_output.txt

我们将使用“创建新作业”链接来创建作业,并选择“自由风格项目”选项:

示例 1 自由风格项目

我们将保留所有默认设置和未选中的内容;选择“执行 shell”作为构建选项:

示例 1 构建步骤

当提示出现时,我们将输入与 shell 中使用的确切命令:

示例 1shell 命令

一旦我们保存了作业配置,我们将被重定向到项目仪表板。我们可以选择立即构建选项,作业将出现在构建历史下:

示例 1 构建

您可以通过单击作业并在左侧面板上选择“控制台输出”来检查构建的状态:

示例 1 控制台输出

作为可选步骤,我们可以按照固定间隔安排此作业,就像 cron 为我们所做的那样。作业可以在“构建触发器”下安排,选择“定期构建”并输入类似 cron 的计划。在此示例中,脚本将每天在 02:00 和 22:00 运行。

示例 1 构建触发器

我们还可以在 Jenkins 上配置 SMTP 服务器以允许构建结果的通知。首先,我们需要在主菜单下的“管理 Jenkins | 配置系统”中配置 SMTP 服务器设置:

示例 1 配置系统

我们将在页面底部看到 SMTP 服务器设置。单击“高级设置”以配置 SMTP 服务器设置以及发送测试电子邮件:

示例 1 配置 SMTP

我们将能够配置电子邮件通知作为作业的后续操作的一部分:

示例 1 电子邮件通知

恭喜!我们刚刚使用 Jenkins 创建了我们的第一个作业。从功能上讲,这并没有比我们的管理主机实现更多的功能。然而,使用 Jenkins 有几个优点:

  • 我们可以利用 Jenkins 的各种数据库认证集成,比如 LDAP,允许现有用户执行我们的脚本。

  • 我们可以使用 Jenkins 的基于角色的授权来限制用户。例如,一些用户只能执行作业而没有修改访问权限,而其他用户可以拥有完全的管理访问权限。

  • Jenkins 提供了一个基于 Web 的图形界面,允许用户轻松访问脚本。

  • 我们可以使用 Jenkins 的电子邮件和日志服务来集中我们的作业并收到结果通知。

Jenkins 本身就是一个很好的工具。就像 Python 一样,它有一个庞大的第三方插件生态系统,可以用来扩展其功能和功能。

Jenkins 插件

我们将安装一个简单的计划插件作为说明插件安装过程的示例。插件在“管理 Jenkins | 管理插件”下进行管理:

Jenkins 插件

我们可以使用搜索功能在可用选项卡下查找计划构建插件:

Jenkins 插件搜索

然后,我们只需点击“安装而不重启”,我们就能在接下来的页面上检查安装进度:

Jenkins 插件安装

安装完成后,我们将能够看到一个新的图标,允许我们更直观地安排作业:

Jenkins 插件结果

作为一个流行的开源项目的优势之一是能够随着时间的推移而增长。对于 Jenkins 来说,插件提供了一种为不同的客户需求定制工具的方式。在接下来的部分,我们将看看如何将版本控制和批准流程集成到我们的工作流程中。

网络持续集成示例

在这一部分,让我们将我们的 GitHub 存储库与 Jenkins 集成。通过集成 GitHub 存储库,我们可以利用 GitHub 的代码审查和协作工具。

首先,我们将创建一个新的 GitHub 存储库,我将把这个存储库称为chapter12_example2。我们可以在本地克隆这个存储库,并将我们想要的文件添加到存储库中。在这种情况下,我正在添加一个将show version命令的输出复制到文件中的 Ansible playbook:

$ cat chapter12_playbook.yml
---
- name: show version
  hosts: "ios-devices"
  gather_facts: false
  connection: local

  vars:
    cli:
      host: "{{ ansible_host }}"
      username: "{{ ansible_user }}"
      password: "{{ ansible_password }}"

  tasks:
    - name: show version
      ios_command:
        commands: show version
        provider: "{{ cli }}"

      register: output

    - name: show output
      debug:
        var: output.stdout

    - name: copy output to file
      copy: content="{{ output }}" dest=./output/{{ inventory_hostname }}.txt

到目前为止,我们应该已经非常熟悉了运行 Ansible playbook。我将跳过host_vars和清单文件的输出。然而,最重要的是在提交到 GitHub 存储库之前验证它在本地机器上运行:

$ ansible-playbook -i hosts chapter12_playbook.yml

PLAY [show version] **************************************************************

TASK [show version] **************************************************************
ok: [iosv-1]
ok: [iosv-2]
...
TASK [copy output to file] *******************************************************
changed: [iosv-1]
changed: [iosv-2]

PLAY RECAP ***********************************************************************
iosv-1 : ok=3 changed=1 unreachable=0 failed=0
iosv-2 : ok=3 changed=1 unreachable=0 failed=0

我们现在可以将 playbook 和相关文件推送到我们的 GitHub 存储库:

示例 2GitHub 存储库

让我们重新登录 Jenkins 主机安装git和 Ansible:

$ sudo apt-get install git
$ sudo apt-get install software-properties-common
$ sudo apt-get update
$ sudo apt-get install ansible

一些工具可以在全局工具配置下安装;Git 就是其中之一。然而,由于我们正在安装 Ansible,我们可以在同一个命令提示符下安装 Git:

全局工具配置

我们可以创建一个名为chapter12_example2的新自由样式项目。在源代码管理下,我们将指定 GitHub 存储库作为源:

示例 2 源代码管理

在我们进行下一步之前,让我们保存项目并运行构建。在构建控制台输出中,我们应该能够看到存储库被克隆,索引值与我们在 GitHub 上看到的匹配:

示例 2 控制台输出 1

现在我们可以在构建部分中添加 Ansible playbook 命令:

示例 2 构建 shell

如果我们再次运行构建,我们可以从控制台输出中看到 Jenkins 将在执行 Ansible playbook 之前从 GitHub 获取代码:

示例 2 构建控制台输出 2

将 GitHub 与 Jenkins 集成的好处之一是我们可以在同一个屏幕上看到所有 Git 信息:

示例 2 Git 构建数据

项目的结果,比如 Ansible playbook 的输出,可以在workspace文件夹中看到:

示例 2 工作空间

此时,我们可以按照之前的步骤使用周期性构建作为构建触发器。如果 Jenkins 主机是公开访问的,我们还可以使用 GitHub 的 Jenkins 插件将 Jenkins 作为构建的触发器。这是一个两步过程,第一步是在您的 GitHub 存储库上启用插件:

示例 2 GitHub Jenkins 服务

第二步是将 GitHub 挂钩触发器指定为我们项目的构建触发器:

示例 2 Jenkins 构建触发器

将 GitHub 存储库作为源,可以为处理基础设施提供全新的可能性。我们现在可以使用 GitHub 的分叉、拉取请求、问题跟踪和项目管理工具来高效地共同工作。一旦代码准备就绪,Jenkins 可以自动拉取代码并代表我们执行。

您会注意到我们没有提到任何关于自动化测试的内容。我们将在第十三章中讨论测试,网络驱动开发

Jenkins 是一个功能齐全的系统,可能会变得复杂。我们在本章中只是浅尝辄止。Jenkins 流水线、环境设置、多分支流水线等都是非常有用的功能,可以适应最复杂的自动化项目。希望本章能为您进一步探索 Jenkins 工具提供有趣的介绍。

使用 Python 与 Jenkins

Jenkins 为其功能提供了完整的 REST API:wiki.jenkins.io/display/JENKINS/Remote+access+API。还有许多 Python 包装器,使交互更加容易。让我们来看看 Python-Jenkins 包:

$ sudo pip3 install python-jenkins
$ python3
>>> import jenkins
>>> server = jenkins.Jenkins('http://192.168.2.123:8080', username='<user>', password='<pass>')
>>> user = server.get_whoami()
>>> version = server.get_version()
>>> print('Hello %s from Jenkins %s' % (user['fullName'], version))
Hello Admin from Jenkins 2.121.2

我们可以与服务器管理一起工作,比如插件

>>> plugin = server.get_plugins_info()
>>> plugin
[{'supportsDynamicLoad': 'MAYBE', 'downgradable': False, 'requiredCoreVersion': '1.642.3', 'enabled': True, 'bundled': False, 'shortName': 'pipeline-stage-view', 'url': 'https://wiki.jenkins-ci.org/display/JENKINS/Pipeline+Stage+View+Plugin', 'pinned': False, 'version': 2.10, 'hasUpdate': False, 'deleted': False, 'longName': 'Pipeline: Stage View Plugin', 'active': True, 'backupVersion': None, 'dependencies': [{'shortName': 'pipeline-rest-api', 'version': '2.10', 'optional': False}, {'shortName': 'workflow-job', 'version': '2.0', 'optional': False}, {'shortName': 'handlebars', 'version': '1.1', 'optional': False}...

我们还可以管理 Jenkins 作业:

>>> job = server.get_job_config('chapter12_example1')
>>> import pprint
>>> pprint.pprint(job)
("<?xml version='1.1' encoding='UTF-8'?>\n"
 '<project>\n'
 ' <actions/>\n'
 ' <description>Paramiko Python Script for Show Version and Show '
 'Run</description>\n'
 ' <keepDependencies>false</keepDependencies>\n'
 ' <properties>\n'
 ' <jenkins.model.BuildDiscarderProperty>\n'
 ' <strategy class="hudson.tasks.LogRotator">\n'
 ' <daysToKeep>10</daysToKeep>\n'
 ' <numToKeep>5</numToKeep>\n'
 ' <artifactDaysToKeep>-1</artifactDaysToKeep>\n'
 ' <artifactNumToKeep>-1</artifactNumToKeep>\n'
 ' </strategy>\n'
 ' </jenkins.model.BuildDiscarderProperty>\n'
 ' </properties>\n'
 ' <scm class="hudson.scm.NullSCM"/>\n'
 ' <canRoam>true</canRoam>\n'
 ' <disabled>false</disabled>\n'
 ' '
 '<blockBuildWhenDownstreamBuilding>false</blockBuildWhenDownstreamBuilding>\n'
 ' <blockBuildWhenUpstreamBuilding>false</blockBuildWhenUpstreamBuilding>\n'
 ' <triggers>\n'
 ' <hudson.triggers.TimerTrigger>\n'
 ' <spec>0 2,20 * * *</spec>\n'
 ' </hudson.triggers.TimerTrigger>\n'
 ' </triggers>\n'
 ' <concurrentBuild>false</concurrentBuild>\n'
 ' <builders>\n'
 ' <hudson.tasks.Shell>\n'
 ' <command>python3 /home/echou/Chapter12/chapter12_1.py</command>\n'
 ' </hudson.tasks.Shell>\n'
 ' </builders>\n'
 ' <publishers/>\n'
 ' <buildWrappers/>\n'
 '</project>')
>>>

使用 Python-Jenkins 使我们有一种以编程方式与 Jenkins 进行交互的方法。

网络连续集成

连续集成在软件开发领域已经被采用了一段时间,但在网络工程领域相对较新。我们承认,在网络基础设施中使用连续集成方面我们有些落后。毫无疑问,当我们仍在努力摆脱使用 CLI 来管理设备时,将我们的网络视为代码是一项挑战。

有许多很好的使用 Jenkins 进行网络自动化的例子。其中一个是由 Tim Fairweather 和 Shea Stewart 在 AnsibleFest 2017 网络跟踪中提出的:www.ansible.com/ansible-for-networks-beyond-static-config-templates。另一个用例是由 Dyn 的 Carlos Vicente 在 NANOG 63 上分享的:www.nanog.org/sites/default/files/monday_general_autobuild_vicente_63.28.pdf

即使持续集成对于刚开始学习编码和工具集的网络工程师来说可能是一个高级话题,但在我看来,值得努力学习和在生产中使用持续集成。即使在基本水平上,这种经验也会激发出更多创新的网络自动化方式,无疑会帮助行业向前发展。

总结

在本章中,我们研究了传统的变更管理流程,以及为什么它不适合当今快速变化的环境。网络需要与业务一起发展,变得更加敏捷,能够快速可靠地适应变化。

我们研究了持续集成的概念,特别是开源的 Jenkins 系统。Jenkins 是一个功能齐全、可扩展的持续集成系统,在软件开发中被广泛使用。我们安装并使用 Jenkins 来定期执行基于Paramiko的 Python 脚本,并进行电子邮件通知。我们还看到了如何安装 Jenkins 的插件来扩展其功能。

我们看了如何使用 Jenkins 与我们的 GitHub 存储库集成,并根据代码检查触发构建。通过将 Jenkins 与 GitHub 集成,我们可以利用 GitHub 的协作流程。

在第十三章中,《面向网络的测试驱动开发》,我们将学习如何使用 Python 进行测试驱动开发。

第十三章:网络的测试驱动开发

测试驱动开发(TDD)的想法已经存在一段时间了。美国软件工程师肯特·贝克等人通常被认为是带领 TDD 运动的人,同时也是敏捷软件开发的领导者。敏捷软件开发需要非常短的构建-测试-部署开发周期;所有的软件需求都被转化为测试用例。这些测试用例通常是在编写代码之前编写的,只有当测试通过时,软件代码才会被接受。

相同的想法也可以与网络工程并行。当我们面临设计现代网络的挑战时,我们可以将这个过程分解为以下步骤:

  • 我们从新网络的整体需求开始。为什么我们需要设计一个新的网络或部分新的网络?也许是为了新的服务器硬件,新的存储网络,或者新的微服务软件架构。

  • 新的需求被分解为更小、更具体的需求。这可以是查看新的交换机平台,更高效的路由协议,或者新的网络拓扑(例如,fat-tree)。每个更小的需求都可以分解为必须和可选的类别。

  • 我们制定测试计划,并根据潜在的解决方案进行评估。

  • 测试计划将按相反的顺序进行;我们将从测试功能开始,然后将新功能集成到更大的拓扑中。最后,我们将尽可能接近生产环境来运行我们的测试。

关键是,即使我们没有意识到,我们可能已经在网络工程中采用了测试驱动的开发方法。这是我在学习 TDD 思维方式时的一部分启示。我们已经在不正式规范方法的情况下隐式地遵循了这一最佳实践。

通过逐渐将网络的部分作为代码移动,我们可以更多地使用 TDD 来进行网络。如果我们的网络拓扑以 XML 或 JSON 的分层格式描述,每个组件都可以正确映射并以所需的状态表达。这是我们可以编写测试用例的期望状态。例如,如果我们的期望状态要求交换机的全网状,我们可以始终编写一个测试用例来检查我们的生产设备的 BGP 邻居数量。

测试驱动开发概述

TDD 的顺序大致基于以下六个步骤:

  1. 以结果为目标编写测试

  2. 运行所有测试,看新测试是否失败

  3. 编写代码

  4. 再次运行测试

  5. 如果测试失败,则进行必要的更改

  6. 重复

我只是松散地遵循指南。TDD 过程要求在编写任何代码之前编写测试用例,或者在我们的情况下,在构建网络的任何组件之前。出于个人偏好的原因,我总是喜欢在编写测试用例之前看到一个工作的网络或代码版本。这给了我更高的信心水平。我也在测试的级别之间跳来跳去;有时我测试网络的一小部分;其他时候我进行系统级的端到端测试,比如 ping 或 traceroute 测试。

关键是,我不认为在测试方面有一种适合所有情况的方法。这取决于个人偏好和项目的范围。这对我合作过的大多数工程师来说都是真的。牢记框架是个好主意,这样我们就有了一个可行的蓝图,但你是解决问题风格的最佳评判者。

测试定义

让我们来看看 TDD 中常用的一些术语:

  • 单元测试:检查小段代码。这是针对单个函数或类运行的测试。

  • 集成测试:检查代码库的多个组件;多个单元组合在一起并作为一个组进行测试。这可以是针对 Python 模块或多个模块的测试

  • 系统测试:端到端检查。这是一个尽可能接近最终用户所看到的测试

  • 功能测试:针对单个功能的检查

  • 测试覆盖:一个术语,用于确定我们的测试用例是否覆盖了应用程序代码。通常通过检查运行测试用例时有多少代码被执行来完成这一点。

  • 测试装置:形成运行测试的基线的固定状态。测试装置的目的是确保测试运行在一个众所周知和固定的环境中,以便它们是可重复的

  • 设置和拆卸:所有先决步骤都添加在设置中,并在拆卸中清理

这些术语可能看起来非常侧重于软件开发,并且有些可能与网络工程无关。请记住,这些术语是我们用来传达概念或步骤的一种方式,我们将在本章的其余部分中使用这些术语。随着我们在网络工程上下文中更多地使用这些术语,它们可能会变得更清晰。让我们深入探讨将网络拓扑视为代码。

拓扑作为代码

在我们宣称网络太复杂,无法总结成代码之前!让我们保持开放的心态。如果我告诉您,我们已经在本书中使用代码来描述我们的拓扑,这会有所帮助吗?

如果您查看本书中使用的任何 VIRL 拓扑图,它们只是包含节点之间关系描述的 XML 文件。

在本章中,我们将使用以下拓扑进行实验:

如果我们用文本编辑器打开拓扑文件chapter13_topology.virl,我们会看到该文件是一个描述节点和节点之间关系的 XML 文件。顶级根节点是带有<node>子节点的<topology>节点。每个子节点都包含各种扩展和条目。设备配置也嵌入在文件中:

<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<topology   schemaVersion="0.95" xsi:schemaLocation="http://www.cisco.com/VIRL https://raw.github.com/CiscoVIRL/schema/v0.95/virl.xsd">
    <extensions>
        <entry key="management_network" type="String">flat</entry>
    </extensions>
    <node name="iosv-1" type="SIMPLE" subtype="IOSv" location="182,162" ipv4="192.168.0.3">
        <extensions>
            <entry key="static_ip" type="String">172.16.1.20</entry>
            <entry key="config" type="string">! IOS Config generated on 2018-07-24 00:23
! by autonetkit_0.24.0
!
hostname iosv-1
boot-start-marker
boot-end-marker
!
...
    </node>
    <node name="nx-osv-1" type="SIMPLE" subtype="NX-OSv" location="281,161" ipv4="192.168.0.1">
        <extensions>
            <entry key="static_ip" type="String">172.16.1.21</entry>
            <entry key="config" type="string">! NX-OSv Config generated on 2018-07-24 00:23
! by autonetkit_0.24.0
!
version 6.2(1)
license grace-period
!
hostname nx-osv-1

...
<node name="host2" type="SIMPLE" subtype="server" location="347,66">
        <extensions>
            <entry key="static_ip" type="String">172.16.1.23</entry>
            <entry key="config" type="string">#cloud-config
bootcmd:
- ln -s -t /etc/rc.d /etc/rc.local
hostname: host2
manage_etc_hosts: true
runcmd:
- start ttyS0
- systemctl start getty@ttyS0.service
- systemctl start rc-local
    <annotations/>
    <connection dst="/virl:topology/virl:node[1]/virl:interface[1]" src="/virl:topology/virl:node[3]/virl:interface[1]"/>
    <connection dst="/virl:topology/virl:node[2]/virl:interface[1]" src="/virl:topology/virl:node[1]/virl:interface[2]"/>
    <connection dst="/virl:topology/virl:node[4]/virl:interface[1]" src="/virl:topology/virl:node[2]/virl:interface[2]"/>
</topology>

通过将网络表示为代码,我们可以为我们的网络声明一个真理源。我们可以编写测试代码来比较实际生产值与此蓝图。我们将使用此拓扑文件作为基础,并将生产网络值与其进行比较。但首先,我们需要从 XML 文件中获取我们想要的值。在chapter13_1_xml.py中,我们将使用ElementTree来解析virl拓扑文件,并构建一个包含我们设备信息的字典:

#!/usr/env/bin python3

import xml.etree.ElementTree as ET
import pprint

with open('chapter13_topology.virl', 'rt') as f:
    tree = ET.parse(f)

devices = {}

for node in tree.findall('./{http://www.cisco.com/VIRL}node'):
    name = node.attrib.get('name')
    devices[name] = {}
    for attr_name, attr_value in sorted(node.attrib.items()):
        devices[name][attr_name] = attr_value

# Custom attributes
devices['iosv-1']['os'] = '15.6(3)M2'
devices['nx-osv-1']['os'] = '7.3(0)D1(1)'
devices['host1']['os'] = '16.04'
devices['host2']['os'] = '16.04'

pprint.pprint(devices)

结果是一个 Python 字典,其中包含根据我们的拓扑文件的设备。我们还可以向字典中添加习惯项目:

$ python3 chapter13_1_xml.py
{'host1': {'location': '117,58',
           'name': 'host1',
           'os': '16.04',
           'subtype': 'server',
           'type': 'SIMPLE'},
 'host2': {'location': '347,66',
           'name': 'host2',
           'os': '16.04',
           'subtype': 'server',
           'type': 'SIMPLE'},
 'iosv-1': {'ipv4': '192.168.0.3',
            'location': '182,162',
            'name': 'iosv-1',
            'os': '15.6(3)M2',
            'subtype': 'IOSv',
            'type': 'SIMPLE'},
 'nx-osv-1': {'ipv4': '192.168.0.1',
              'location': '281,161',
              'name': 'nx-osv-1',
              'os': '7.3(0)D1(1)',
              'subtype': 'NX-OSv',
              'type': 'SIMPLE'}}

我们可以使用我们在第三章中的示例,API 和意图驱动网络cisco_nxapi_2.py,来检索 NX-OSv 版本。当我们结合这两个文件时,我们可以比较我们从拓扑文件中收到的值以及生产设备信息。我们可以使用 Python 内置的unittest模块编写测试用例。

我们稍后将讨论unittest模块。如果您愿意,可以跳过并回到这个例子。

以下是chapter13_2_validation.py中相关的unittest部分:

import unittest

# Unittest Test case
class TestNXOSVersion(unittest.TestCase):
    def test_version(self):
        self.assertEqual(nxos_version, devices['nx-osv-1']['os'])

if __name__ == '__main__':
    unittest.main()

当我们运行验证测试时,我们可以看到测试通过了,因为生产中的软件版本与我们预期的相匹配:

$ python3 chapter13_2_validation.py
.
----------------------------------------------------------------------
Ran 1 test in 0.000s

OK

如果我们手动更改预期的 NX-OSv 版本值以引入失败案例,我们将看到以下失败的输出:

$ python3 chapter13_3_test_fail.py
F
======================================================================
FAIL: test_version (__main__.TestNXOSVersion)
----------------------------------------------------------------------
Traceback (most recent call last):
 File "chapter13_3_test_fail.py", line 50, in test_version
 self.assertEqual(nxos_version, devices['nx-osv-1']['os'])
AssertionError: '7.3(0)D1(1)' != '7.4(0)D1(1)'
- 7.3(0)D1(1)
? ^
+ 7.4(0)D1(1)
? ^

----------------------------------------------------------------------
Ran 1 test in 0.004s

FAILED (failures=1)

我们可以看到测试用例的结果返回为失败;失败的原因是两个值之间的版本不匹配。

Python 的 unittest 模块

在前面的例子中,我们看到了如何使用assertEqual()方法来比较两个值,以返回TrueFalse。以下是内置的unittest模块比较两个值的示例:

$ cat chapter13_4_unittest.py
#!/usr/bin/env python3

import unittest

class SimpleTest(unittest.TestCase):
    def test(self):
        one = 'a'
        two = 'a'
        self.assertEqual(one, two)

使用python3命令行界面,unittest模块可以自动发现脚本中的测试用例:

$ python3 -m unittest chapter13_4_unittest.py
.
----------------------------------------------------------------------
Ran 1 test in 0.000s

OK

除了比较两个值之外,这里还有更多的例子,测试预期值是否为TrueFalse。当发生失败时,我们还可以生成自定义的失败消息:

$ cat chapter13_5_more_unittest.py
#!/usr/bin/env python3
# Examples from https://pymotw.com/3/unittest/index.html#module-unittest

import unittest

class Output(unittest.TestCase):
    def testPass(self):
        return

    def testFail(self):
        self.assertFalse(True, 'this is a failed message')

    def testError(self):
        raise RuntimeError('Test error!')

    def testAssesrtTrue(self):
        self.assertTrue(True)

    def testAssertFalse(self):
        self.assertFalse(False)

我们可以使用-v选项来显示更详细的输出:

$ python3 -m unittest -v chapter13_5_more_unittest.py
testAssertFalse (chapter13_5_more_unittest.Output) ... ok
testAssesrtTrue (chapter13_5_more_unittest.Output) ... ok
testError (chapter13_5_more_unittest.Output) ... ERROR
testFail (chapter13_5_more_unittest.Output) ... FAIL
testPass (chapter13_5_more_unittest.Output) ... ok

======================================================================
ERROR: testError (chapter13_5_more_unittest.Output)
----------------------------------------------------------------------
Traceback (most recent call last):
 File "/home/echou/Master_Python_Networking_second_edition/Chapter13/chapter13_5_more_unittest.py", line 14, in testError
 raise RuntimeError('Test error!')
RuntimeError: Test error!

======================================================================
FAIL: testFail (chapter13_5_more_unittest.Output)
----------------------------------------------------------------------
Traceback (most recent call last):
 File "/home/echou/Master_Python_Networking_second_edition/Chapter13/chapter13_5_more_unittest.py", line 11, in testFail
 self.assertFalse(True, 'this is a failed message')
AssertionError: True is not false : this is a failed message

----------------------------------------------------------------------
Ran 5 tests in 0.001s

FAILED (failures=1, errors=1)

从 Python 3.3 开始,unittest模块默认包含module对象库(docs.python.org/3/library/unittest.mock.html)。这是一个非常有用的模块,可以对远程资源进行假的 HTTP API 调用,而无需实际进行调用。例如,我们已经看到了使用 NX-API 来检索 NX-OS 版本号的示例。如果我们想运行我们的测试,但没有 NX-OS 设备可用怎么办?我们可以使用unittest模拟对象。

chapter13_5_more_unittest_mocks.py中,我们创建了一个简单的类,其中包含一个用于进行 HTTP API 调用并期望 JSON 响应的方法:

# Our class making API Call using requests
class MyClass:
    def fetch_json(self, url):
        response = requests.get(url)
        return response.json()

我们还创建了一个模拟两个 URL 调用的函数:

# This method will be used by the mock to replace requests.get
def mocked_requests_get(*args, **kwargs):
    class MockResponse:
        def __init__(self, json_data, status_code):
            self.json_data = json_data
            self.status_code = status_code

        def json(self):
            return self.json_data

    if args[0] == 'http://url-1.com/test.json':
        return MockResponse({"key1": "value1"}, 200)
    elif args[0] == 'http://url-2.com/test.json':
        return MockResponse({"key2": "value2"}, 200)

    return MockResponse(None, 404)

最后,我们在我们的测试用例中对两个 URL 进行 API 调用。然而,我们使用mock.patch装饰器来拦截 API 调用:

# Our test case class
class MyClassTestCase(unittest.TestCase):
    # We patch 'requests.get' with our own method. The mock object is passed in to our test case method.
    @mock.patch('requests.get', side_effect=mocked_requests_get)
    def test_fetch(self, mock_get):
        # Assert requests.get calls
        my_class = MyClass()
        # call to url-1
        json_data = my_class.fetch_json('http://url-1.com/test.json')
        self.assertEqual(json_data, {"key1": "value1"})
        # call to url-2
        json_data = my_class.fetch_json('http://url-2.com/test.json')
        self.assertEqual(json_data, {"key2": "value2"})
        # call to url-3 that we did not mock
        json_data = my_class.fetch_json('http://url-3.com/test.json')
        self.assertIsNone(json_data)

if __name__ == '__main__':
    unittest.main()

当我们运行测试时,我们会看到测试通过,而无需实际调用远程端点的 API:

$ python3 -m unittest -v chapter13_5_more_unittest_mocks.py
test_fetch (chapter13_5_more_unittest_mocks.MyClassTestCase) ... ok

----------------------------------------------------------------------
Ran 1 test in 0.001s

OK

有关unittest模块的更多信息,Doug Hellmann 的 Python 模块一周(pymotw.com/3/unittest/index.html#module-unittest)是一个关于unittest模块的简短而精确的示例的绝佳来源。一如既往,Python 文档也是一个很好的信息来源:docs.python.org/3/library/unittest.html

更多关于 Python 测试的内容

除了内置的unittest库之外,社区中还有许多其他 Python 测试框架。Pytest 是另一个强大的 Python 测试框架,值得一看。pytest可以用于各种类型和级别的软件测试。它可以被开发人员、QA 工程师、练习测试驱动开发的个人和开源项目使用。许多大型开源项目已经从unittestnose转换到pytest,包括 Mozilla 和 Dropbox。pytest的主要吸引力在于第三方插件模型、简单的装置模型和断言重写。

如果您想了解更多关于pytest框架的信息,我强烈推荐 Brian Okken 的Python Testing with PyTest(ISBN 978-1-68050-240-4)。另一个很好的来源是pytest文档:docs.pytest.org/en/latest/

pytest是命令行驱动的;它可以自动找到我们编写的测试并运行它们:

$ sudo pip install pytest
$ sudo pip3 install pytest
$ python3
Python 3.5.2 (default, Nov 23 2017, 16:37:01)
[GCC 5.4.0 20160609] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import pytest
>>> pytest.__version__
'3.6.3'

让我们看一些使用pytest的例子。

pytest 示例

第一个pytest示例将是对两个值的简单断言:

$ cat chapter13_6_pytest_1.py
#!/usr/bin/env python3

def test_passing():
    assert(1, 2, 3) == (1, 2, 3)

def test_failing():
    assert(1, 2, 3) == (3, 2, 1)

当您使用-v选项运行时,pytest将为我们提供一个相当强大的失败原因的答案:

$ pytest -v chapter13_6_pytest_1.py
============================== test session starts ===============================
platform linux -- Python 3.5.2, pytest-3.6.3, py-1.5.4, pluggy-0.6.0 -- /usr/bin/python3
cachedir: .pytest_cache
rootdir: /home/echou/Master_Python_Networking_second_edition/Chapter13, inifile:
collected 2 items

chapter13_6_pytest_1.py::test_passing PASSED [ 50%]
chapter13_6_pytest_1.py::test_failing FAILED [100%]

==================================== FAILURES ====================================
__________________________________ test_failing __________________________________

 def test_failing():
> assert(1, 2, 3) == (3, 2, 1)
E assert (1, 2, 3) == (3, 2, 1)
E At index 0 diff: 1 != 3
E Full diff:
E - (1, 2, 3)
E ? ^ ^
E + (3, 2, 1)
E ? ^ ^

chapter13_6_pytest_1.py:7: AssertionError
======================= 1 failed, 1 passed in 0.03 seconds =======================

在第二个示例中,我们将创建一个router对象。router对象将使用一些值进行初始化,其中一些值为None,另一些值为默认值。我们将使用pytest来测试一个具有默认值的实例和一个没有默认值的实例:

$ cat chapter13_7_pytest_2.py
#!/usr/bin/env python3

class router(object):
    def __init__(self, hostname=None, os=None, device_type='cisco_ios'):
        self.hostname = hostname
        self.os = os
        self.device_type = device_type
        self.interfaces = 24

def test_defaults():
    r1 = router()
    assert r1.hostname == None
    assert r1.os == None
    assert r1.device_type == 'cisco_ios'
    assert r1.interfaces == 24

def test_non_defaults():
    r2 = router(hostname='lax-r2', os='nxos', device_type='cisco_nxos')
    assert r2.hostname == 'lax-r2'
    assert r2.os == 'nxos'
    assert r2.device_type == 'cisco_nxos'
    assert r2.interfaces == 24

当我们运行测试时,我们将看到实例是否准确地应用了默认值:

$ pytest chapter13_7_pytest_2.py
============================== test session starts ===============================
platform linux -- Python 3.5.2, pytest-3.6.3, py-1.5.4, pluggy-0.6.0
rootdir: /home/echou/Master_Python_Networking_second_edition/Chapter13, inifile:
collected 2 items

chapter13_7_pytest_2.py .. [100%]

============================ 2 passed in 0.04 seconds ============================

如果我们要用pytest替换之前的unittest示例,在chapter13_8_pytest_3.py中,我们将有一个简单的测试用例:

# pytest test case
def test_version():
    assert devices['nx-osv-1']['os'] == nxos_version

然后我们使用pytest命令行运行测试:

$ pytest chapter13_8_pytest_3.py
============================== test session starts ===============================
platform linux -- Python 3.5.2, pytest-3.6.3, py-1.5.4, pluggy-0.6.0
rootdir: /home/echou/Master_Python_Networking_second_edition/Chapter13, inifile:
collected 1 item

chapter13_8_pytest_3.py . [100%]

============================ 1 passed in 0.19 seconds ============================

如果我们为自己编写测试,我们可以自由选择任何模块。在unittestpytest之间,我发现pytest是一个更直观的工具。然而,由于unittest包含在标准库中,许多团队可能更喜欢使用unittest模块进行测试。

编写网络测试

到目前为止,我们大多数时间都在为我们的 Python 代码编写测试。我们使用了unittestpytest库来断言True/Falseequal/Non-equal值。我们还能够编写模拟来拦截我们的 API 调用,当我们没有实际的 API 可用设备但仍想运行我们的测试时。

几年前,Matt Oswalt 宣布了Testing On Demand: DistributedToDD)验证工具,用于测试网络连接和分布式容量。这是一个旨在测试网络连通性和分布式容量的开源框架。您可以在其 GitHub 页面上找到有关该项目的更多信息:github.com/toddproject/todd。Oswalt 还在 Packet Pushers Priority Queue 81 上谈到了该项目,标题是 Network Testing with ToDD:packetpushers.net/podcast/podcasts/pq-show-81-network-testing-todd/

在这一部分,让我们看看如何编写与网络世界相关的测试。在网络监控和测试方面,商业产品并不少见。多年来,我接触过许多这样的产品。然而,在这一部分,我更喜欢使用简单的开源工具进行测试。

可达性测试

通常,故障排除的第一步是进行小范围的可达性测试。对于网络工程师来说,ping是我们在进行网络可达性测试时的好朋友。这是一种通过向目的地发送一个小数据包来测试 IP 网络上主机可达性的方法。

我们可以通过OS模块或subprocess模块自动化ping测试:

>>> import os
>>> host_list = ['www.cisco.com', 'www.google.com']
>>> for host in host_list:
...     os.system('ping -c 1 ' + host)
...
PING e2867.dsca.akamaiedge.net (69.192.206.157) 56(84) bytes of data.
64 bytes from a69-192-206-157.deploy.static.akamaitechnologies.com (69.192.206.157): icmp_seq=1 ttl=54 time=14.7 ms

--- e2867.dsca.akamaiedge.net ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 14.781/14.781/14.781/0.000 ms
0
PING www.google.com (172.217.3.196) 56(84) bytes of data.
64 bytes from sea15s12-in-f196.1e100.net (172.217.3.196): icmp_seq=1 ttl=54 time=12.8 ms

--- www.google.com ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 12.809/12.809/12.809/0.000 ms
0
>>>

subprocess模块提供了捕获输出的额外好处:

>>> import subprocess
>>> for host in host_list:
...     print('host: ' + host)
...     p = subprocess.Popen(['ping', '-c', '1', host], stdout=subprocess.PIPE)
...     print(p.communicate())
...
host: www.cisco.com
(b'PING e2867.dsca.akamaiedge.net (69.192.206.157) 56(84) bytes of data.\n64 bytes from a69-192-206-157.deploy.static.akamaitechnologies.com (69.192.206.157): icmp_seq=1 ttl=54 time=14.3 ms\n\n--- e2867.dsca.akamaiedge.net ping statistics ---\n1 packets transmitted, 1 received, 0% packet loss, time 0ms\nrtt min/avg/max/mdev = 14.317/14.317/14.317/0.000 ms\n', None)
host: www.google.com
(b'PING www.google.com (216.58.193.68) 56(84) bytes of data.\n64 bytes from sea15s07-in-f68.1e100.net (216.58.193.68): icmp_seq=1 ttl=54 time=15.6 ms\n\n--- www.google.com ping statistics ---\n1 packets transmitted, 1 received, 0% packet loss, time 0ms\nrtt min/avg/max/mdev = 15.695/15.695/15.695/0.000 ms\n', None)
>>>

这两个模块在许多情况下都非常有用。我们可以通过OSsubprocess模块执行在 Linux 和 Unix 环境中可以执行的任何命令。

网络延迟测试

网络延迟的话题有时可能是主观的。作为网络工程师,我们经常面对用户说网络很慢的情况。然而,慢是一个非常主观的词。如果我们能构建测试,将主观的词转化为客观的值,那将非常有帮助。我们应该始终如一地这样做,这样我们就可以比较一系列数据的值。

这有时可能很难做到,因为网络是无状态的设计。成功发送一个数据包并不保证下一个数据包也会成功。多年来我见过的最好的方法就是经常使用 ping 跨多个主机,并记录数据,进行 ping-mesh 图。我们可以利用前面示例中使用的相同工具,捕获返回结果的时间,并保留记录:

$ cat chapter13_10_ping.py
#!/usr/bin/env python3

import subprocess

host_list = ['www.cisco.com', 'www.google.com']

ping_time = []

for host in host_list:
    p = subprocess.Popen(['ping', '-c', '1', host], stdout=subprocess.PIPE)
    result = p.communicate()[0]
    host = result.split()[1]
    time = result.split()[14]
    ping_time.append((host, time))

print(ping_time)

在这种情况下,结果被保存在一个元组中,并放入一个列表中:

$ python3 chapter13_10_ping.py
[(b'e2867.dsca.akamaiedge.net', b'time=13.8'), (b'www.google.com', b'time=14.8')]

这绝不是完美的,只是监控和故障排除的起点。然而,在没有其他工具的情况下,这提供了一些客观值的基线。

安全测试

我们已经在第六章中看到了我认为是用 Python 进行网络安全测试的最佳工具,即 Scapy。有很多开源安全工具,但没有一个能提供构建数据包的灵活性。

网络安全测试的另一个很好的工具是hping3www.hping.org/)。它提供了一种简单的方法来一次生成大量的数据包。例如,您可以使用以下一行命令生成 TCP Syn flood:

# DON'T DO THIS IN PRODUCTION #
echou@ubuntu:/var/log$ sudo hping3 -S -p 80 --flood 192.168.1.202
HPING 192.168.1.202 (eth0 192.168.1.202): S set, 40 headers + 0 data bytes
hping in flood mode, no replies will be shown
^C
--- 192.168.1.202 hping statistic ---
2281304 packets transmitted, 0 packets received, 100% packet loss
round-trip min/avg/max = 0.0/0.0/0.0 ms
echou@ubuntu:/var/log$

同样,由于这是一个命令行工具,我们可以使用subprocess模块来自动化任何我们想要的hping3测试。

交易测试

网络是基础设施的重要组成部分,但它只是其中的一部分。用户关心的通常是运行在网络之上的服务。如果用户试图观看 YouTube 视频或收听播客却无法做到,他们会认为服务出现了故障。我们可能知道这不是网络传输的问题,但这并不能让用户感到安慰。

因此,我们应该实施尽可能接近用户体验的测试。在 YouTube 视频的例子中,我们可能无法 100%复制 YouTube 体验(除非你是 Google 的一部分),但我们可以尽可能接近网络边缘实现第七层服务。然后,我们可以模拟来自客户端的交易作为事务性测试。

Python 的HTTP标准库模块是我在需要快速测试第七层可达性的 Web 服务时经常使用的模块:

# Python 2
$ python -m SimpleHTTPServer 8080
Serving HTTP on 0.0.0.0 port 8080 ...
127.0.0.1 - - [25/Jul/2018 10:14:39] "GET / HTTP/1.1" 200 -

# Python 3 
$ python3 -m http.server 8080
Serving HTTP on 0.0.0.0 port 8080 ...
127.0.0.1 - - [25/Jul/2018 10:15:23] "GET / HTTP/1.1" 200 -

如果我们可以模拟预期服务的完整交易,那就更好了。但是标准库中的 Python 简单HTTP服务器模块始终是运行一些临时 Web 服务测试的好选择。

网络配置测试

在我看来,对网络配置的最佳测试是使用标准化模板生成配置并经常备份生产配置。我们已经看到了如何使用 Jinja2 模板来根据设备类型或角色标准化我们的配置。这将消除许多由人为错误引起的错误,比如复制和粘贴。

一旦配置生成,我们可以针对我们在将配置推送到生产设备之前期望的已知特征编写测试。例如,在网络的所有部分中,当涉及到环回 IP 时,IP 地址不应重叠,因此我们可以编写一个测试来查看新配置是否包含在我们的设备中唯一的环回 IP。

测试 Ansible

在我使用 Ansible 的时间里,我记不起来使用类似unittest的工具来测试 Playbook。在大多数情况下,Playbooks 使用了模块,这些模块是由模块开发人员测试过的。

Ansible 为他们的模块库提供单元测试。目前,Ansible 中的单元测试是从 Python 驱动测试的唯一方式。今天运行的单元测试可以在/test/units (github.com/ansible/ansible/tree/devel/test/units)下找到。

可以在以下文档中找到 Ansible 测试策略:

Ansible 测试框架中有一个有趣的工具是molecule (pypi.org/project/molecule/2.16.0/)。它旨在帮助开发和测试 Ansible 角色。Molecule 支持使用多个实例、操作系统和发行版进行测试。我没有使用过这个工具,但如果我想对我的 Ansible 角色进行更多测试,这是我会开始的地方。

Jenkins 中的 Pytest

持续集成CI)系统,如 Jenkins,经常用于在每次代码提交后启动测试。这是使用 CI 系统的主要好处之一。想象一下,有一个隐形的工程师一直在观察网络中的任何变化;在检测到变化后,工程师将忠实地测试一堆功能,以确保没有任何故障。谁不想要这样的工程师呢?

让我们看一个将pytest集成到 Jenkins 任务中的例子。

Jenkins 集成

在我们将测试用例插入到我们的持续集成之前,让我们安装一些可以帮助我们可视化操作的插件。我们将安装的两个插件是 build-name-setter 和 Test Result Analyzer:

Jenkins 插件安装

我们将运行的测试将会连接到 NXOS 设备并检索操作系统版本号。这将确保我们可以通过 API 访问 Nexus 设备。完整的脚本内容可以在chapter13_9_pytest_4.py中阅读,相关的pytest部分和结果如下:

def test_transaction():
     assert nxos_version != False

## Test Output
$ pytest chapter13_9_pytest_4.py
============================== test session starts ===============================
platform linux -- Python 3.5.2, pytest-3.6.3, py-1.5.4, pluggy-0.6.0
rootdir: /home/echou/Chapter13, inifile:
collected 1 item

chapter13_9_pytest_4.py . [100%]

============================ 1 passed in 0.13 seconds ============================

我们将使用--junit-xml=results.xml选项来生成 Jenkins 需要的文件:

$ pytest --junit-xml=results.xml chapter13_9_pytest_4.py
$ cat results.xml
<?xml version="1.0" encoding="utf-8"?><testsuite errors="0" failures="0" name="pytest" skips="0" tests="1" time="0.134"><testcase classname="chapter13_9_pytest_4" file="chapter13_9_pytest_4.py" line="25" name="test_transaction" time="0.0009090900421142578"></testcase></testsuite>

下一步将是将此脚本检入 GitHub 存储库。我倾向于将测试放在其目录下。因此,我创建了一个/test目录,并将测试文件放在那里:

项目存储库

我们将创建一个名为chapter13_example1的新项目:

第十三章示例 1

我们可以复制上一个任务,这样我们就不需要重复所有步骤:

从第十二章示例 2 复制任务

在执行 shell 部分,我们将添加pytest步骤:

项目执行 shell

我们将添加一个发布 JUnit 测试结果报告的构建后步骤:

构建后步骤

我们将指定results.xml文件作为 JUnit 结果文件:

测试报告 XML 位置

运行构建几次后,我们将能够看到测试结果分析器图表:

测试结果分析器

测试结果也可以在项目主页上看到。让我们通过关闭 Nexus 设备的管理接口来引入一个测试失败。如果有测试失败,我们将能够立即在项目仪表板上的测试结果趋势图上看到它:

测试结果趋势

这是一个简单但完整的例子。我们可以将测试集成到 Jenkins 中的许多方式。

总结

在本章中,我们看了测试驱动开发以及如何将其应用于网络工程。我们从 TDD 的概述开始;然后我们看了使用unittestpytest Python 模块的示例。Python 和简单的 Linux 命令行工具可以用来构建各种测试,包括网络可达性、配置和安全性。

我们还看了如何在 Jenkins 中利用测试,这是一个持续集成工具。通过将测试集成到我们的 CI 工具中,我们可以更加确信我们的更改是合理的。至少,我们希望在用户之前捕捉到任何错误。

简而言之,如果没有经过测试,就不能信任。我们网络中的一切都应尽可能地进行程序化测试。与许多软件概念一样,测试驱动开发是一个永无止境的服务轮。我们努力实现尽可能多的测试覆盖率,但即使在 100%的测试覆盖率下,我们总是可以找到新的方法和测试用例来实现。这在网络中尤其如此,网络通常是互联网,而互联网的 100%测试覆盖是不可能的。

posted @ 2025-09-23 21:57  绝不原创的飞龙  阅读(0)  评论(0)    收藏  举报