摘要
在之前的章节中,我们给大家讲解了如何使用 SSH 方式连接设备,并采集相应的信息,除此之外还设计了一个完整的巡检框架,支持巡检项的灵活扩展,支持对设备输出的解析和导出。
虽然和设备交互的过程中 SSH 是使用最为广泛的,但也存在一定的弊端,而 SNMP 正好可以填补这部分能力。这个章节开始我会讲解 SNMP 相关的细节,以及如何用 SNMP 来采集数据。
SNMP vs SSH
SSH
优点
- 连接加密,数据传输也加密
- CLI 的理解成本低
- 容易做权限控制
- 可交互式
缺点
SSH 可以让程序与设备进行 CLI 交互,也就是说既可以做配置也可以做查询,这里我们主要指的是查询用途。
- 回显内容是非结构化的
- 基于 TCP 的建立连接较慢
- 设备存在 vty 数量限制
SNMP
SNMP 应该几乎做运维的朋友都有听过,它也是既可以做配置也可以做查询,但实际上一般都只用来做查询。
优点
- 基于 UDP 请求响应较快
- 结构化的回显 (回显仍然是文本,但内容更为结构化,几乎不需要解析)
- 可以对设备做较高频率的查询 (频率越高,对设备 CPU 占用越高,所以需要提前压测)
缺点
- 基于 UDP 连接,内容不可靠
- 鉴权能力比较弱
- 查询项与 OID 的对应关系较繁琐
一项技术肯定不可能全是优点没有缺点,所以 SNMP 也有一些非常显著的缺点,但它的优点是可以完美填补 SSH 查询信息的缺陷的,所以我们作为数据采集来说,SNMP 是必不可少的;
但它的缺点我们也不可忽视,也需要做一定的设计来尽量规避其缺点带来的问题。
SNMP 使用流程
确定目标设备的配置
目标设备的 SNMP 配置包含一些基本的信息,如版本号、网络地址、认证信息;
版本号目前广泛使用的是 v2 或 v3,v3 相比 v2 有更高的安全性,且传输的信息都是密文,但考虑到不是所有的设备都支持 v3,所以在只做查询的时候,通常会使用 v2 更为普遍。
v2 版本的认证是基于 Community 的,可以把其理解为一种用户名密码。
选择客户端发起的方法
- Get,可以从设备中提取一个或多个结果;
- GetNext,获取被管理设备MIB节点的下一个节点的结果;
- GetBulk,相当于对 GetNext 的封装,例如一个 GetNext 只能返回当前 OID 的下一个 OID 的值,GetBulk 可以直接返回下 N 个,这样就可以减少发 N 个 GetNext 带来的网络开销;但会对超出响应所能承载的数据做截断;
- Set/Trap/Inform,忽略
选择 OID
通常 OID 分为公有和私有,公有 OID 就是所有厂商都会遵守的,比如查询设备版本的 OID 就是所有厂商通用的,私有 OID 就是不同厂商针对自家设备的数据提供的特有标识,只能从厂商提供的 MIB 文档中查找。
OID 是和 MIB(Management Information Base) 有密不可分的关系的,设备的数据和各种会被维护的信息都会作为一个对象,在 MIB 数据库中被定义,每个对象会包含一些属性,如:对象名称、对象状态、访问权限、数据类型等。
由于设备上有非常多需要被维护的数据,且它们之间会有一定的关联关系,所以这么多数据对象在 MIB 库中会以树的形式存在,每个 OID 就对应树上的一个节点,树的结构如下所示:
从上图中可以看出,系统描述信息数据对象名称叫 sysDescr,OID 是 1.3.6.1.2.1.1.1;
这里需要大家注意的是,每个设备都有自己的一个数据库,数据库中的内容会对应到 MIB 中的某个节点。所以MIB文件不包含数据,而是类似于数据库中的表结构的概念。
私有 OID
其实大部分需要高频采集的都是公有 OID,比如端口名称,端口状态,端口流量,设备描述信息等,只有需要采集特定厂商的部分数据时才需要用到私有 OID。
私有 OID 可以直接到厂商的官方文档中寻找,原理其实也是 MIB 树,只不过是有一些自定义的节点而已。
MIB 可视化客户端
官方下载地址: http://www.ireasoning.com/download.shtml 提供个人免费版本下载: MIB Browser Free Personal Edition
确定请求的参数
上面也提到 SNMP 是基于 UDP 的,所以双方的交互是不可靠的,那必然就要考虑到各种异常情况导致的请求失败,所以在发起请求的时候,可以设置请求的最大超时时间,或者失败重试的最大次数。
处理响应内容
SNMP 的响应内容会包含几种不同的数据类型,常用的有整型、字符串、计数、时间戳;其结构也都非常清晰明了,所以对于响应内容的处理也非常简单。
Python 中使用 SNMP
第三方库的选择
PySNMP
有一个用纯 Python 实现 SNMP 协议的第三方库,叫 PySNMP,这个库对于协议的封装做的比较完善,但考虑到纯 Python 实现的性能问题,以及它创造出了不少新的对象和概念,会让刚接触的朋友有些难以理解,所以我们不计划采用 PySNMP,但这个库有一个优点就是支持异步。
NetSNMP
除此之外较为常用的还有 Net-SNMP,这也是一个 Python 第三方库,但它依赖操作系统中的 net-snmp 应用,所以本质上它其实是调用了操作系统上安装的 net-snmp 应用的接口;所以并发情况下,需要开多线程来进行请求,而不支持异步(如果大家对异步感兴趣,可以自行研究,或者找我交流)。
EasySNMP
Net-SNMP 可以看作是 net-snmp 应用的接口调用,那么就必然存在版本兼容问题,比如不同版本的 net-snmp 应用之间的差别可能会导致 Net-SNMP 出现不可预料的异常。
另外由于 net-snmp 是用 C/C++ 实现的,所以 Net-SNMP 基于此提供的封装也不够易用。
为了解决上述的问题,eaysnmp 出现了,据官方文档描述,easysnmp 0.2.5 版本支持 net-snmp 5.7 之后的所有版本,以及支持大多数的 Python3 和 Python 2.7 发行版;并且还提供了非常详细的使用文档。
EasySNMP 使用
安装 easysnmp
Linux 和 MacOS 安装 net-snmp 都相对简单,只需要一行命令即可,如下所示:
yum install net-snmp-devel # centos
brew install net-snmp # macos
pip install easysnmp==0.2.5 # 安装 Python 包,(提示,M1芯片的 Mac 需要 Python3.9 以上才能安装成功)
至于如何在 Windows 上安装 net-snmp,大家可以自行查找教程,我个人建议是既然要学习 SNMP,那么最终程序是一定要运行在服务器上的,所以开发阶段最好就用和服务器相似的环境来开发,避免环境切换导致的各种问题。
初步使用 easysnmp
from easysnmp import snmp_get, snmp_walk, snmp_bulkwalk, Session
resp = snmp_get("sysDescr.0", hostname="localhost", community="public", version=2)
resp = snmp_walk("ifDescr", hostname="localhost", community="public", version=2)
resp = snmp_bulkwalk("ifDescr", hostname="localhost", community="public", version=2)
?
session = Session(hostname="localhost", community="public", version=2)
resp = session.bulkwalk("ifDescr")
上述代码从 easysnmp 中直接引入了三种请求类型,分别是:
- snmp_get:发起单个查询请求
- snmp_walk:是对 GETNext 请求的封装
- snmp_bulkwalk:SNMP 版本 2 以上可以使用,是对 GETBULK 的封装
- snmp_set:用来对设备做配置,忽略不提
snmp_walk 和 snmp_bulkwalk 都可以起到一次性获取多个节点的效果,但 snmp_bulkwalk 与设备进行的 IO 交互更少。
snmp_get
Get 请求有一个值得注意的地方,大家应该可以发现上述代码中调用 snmp_get 函数的第一个参数传入的是 “sysDescr.0”,实际上查询设备描述的 OID 是“sysDescr”,这里为什么多了一个“.0”?
这是因为 SNMP 请求发送到指定设备后,会根据请求的 OID 去查询设备上的 MIB 树,根据 OID 对应找到 MIB 节点后进行实例化,实例化的节点对象中会包含:节点的 OID,索引,类型,以及结果。
MIB树的节点可以分为以下两种。
- 叶子节点:即在完整的MIB树中不存在子节点的节点。叶子节点又分为标量节点和表节点。
- 非叶子节点:用来表明该节点的子节点的相关性,不能通过SNMP协议直接访问。
我们通常进行查询的就是叶子节点;类似于 sysDescr 或者 sysUptime 这种节点就是标量对象,它只有一个实例化的值所以需要添加“.0”作为索引来进行请求;如果是 ifDescr 节点就是表对象,它会有多个实例值,可以使用 snmp_walk 请求将其所有实例一次性获取到,也可以通过指定索引来获取某个实例,比如“ifDescr.3”。
snmp_walk
Walk 请求会将该 OID 节点下的所有叶子节点全部请求回来,例如一次性获取所有的端口描述,实际工作中经常会用 snmp_walk 去请求端口的相关信息。
请求结果
Easysnmp 中对 SNMP 请求的结果进行了封装,将结果封装成 SNMPVariable,该对象中包含了:'oid', 'oid_index', 'snmp_type', 'value'四个属性。
snmp_get 函数返回的是一个 SNMPVariable 对象,snmp_walk 函数返回的是一个 SNMPVariable 的对象列表
SNMP 执行器
之前的章节中我们定义了一个 Action 对象如下:
class Action(db.Model):
__tablename__ = "action"
__table_args__ = {"extend_existing": True}
?
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
name = db.Column(db.String(64), nullable=False, comment="动作名称")
description = db.Column(db.String(256), comment="动作描述")
vendor = db.Column(db.String(64), comment="厂商")
model = db.Column(db.String(64), comment="型号")
cmd = db.Column(db.String(256), nullable=False, comment="命令行")
type = db.Column(db.String(8), comment="命令类型[show|config]")
parse_type = db.Column(db.String(8), comment="解析类型[regexp|textfsm]")
parse_content = db.Column(db.Text, comment="解析内容")
Action 对象之前表示的是巡检项,但从它的字段中可以发现,在不考虑 type/parse_type/parse_content 的同时,将 cmd 字段的内容用来存储某个 OID,那么这个对象就可以被用于表示 snmp 项了。
那么结合抽象的执行器以及复用 Action 对象就可以实现一个 SNMP 执行器,代码如下:
# junior/flaskProject/application/services/snmp_executor.py
import logging
from datetime import datetime
from typing import Optional, Dict, List
?
from easysnmp import Session, SNMPVariable
?
from junior.flaskProject.application.services.executor import Executor
from .action import Action
from .device import Device
?
?
class SNMPExecutor(Executor):
SNMP_Version = 2
SNMP_Port = 161
?
def __init__(
self,
community: str,
device: Device,
timeout: int = 10,
logger: Optional[logging.Logger] = None,
log_file: str = "snmp.log",
log_level: str = logging.INFO,
log_format: str = "%(asctime)s %(levelname)s %(name)s %(message)s",
retry_times: int = 3):
self.device = device
self.host = self.device.ip
self.session = Session(
hostname=self.device.ip, version=self.SNMP_Version, community=community,
timeout=timeout, retries=retry_times, remote_port=self.SNMP_Port)
?
self.logger = logger
if self.logger is None:
logging.basicConfig(filename=log_file, level=log_level, format=log_format)
self.logger = logging.getLogger(__name__)
?
self.result: List[Dict] = []
?
def __enter__(self) -> "SNMPExecutor":
return self
?
def __exit__(self, exc_type, exc_val, exc_tb):
if exc_type:
self.logger.error(exc_type)
self.logger.error(exc_val)
self.logger.error(exc_tb)
?
def execute(self, action: Action, last_variables: Optional[List[Dict]] = None) -> List[Dict]:
if ".0" in action.cmd:
self.logger.info(f"{self.session.hostname} snmp get action: {action.cmd}")
output = [self.session.get(action.cmd)]
else:
self.logger.info(f"{self.session.hostname} snmp bulkwalk action: {action.cmd}")
output = self.session.bulkwalk(action.cmd)
return self.parse(output, last_variables)
?
def parse(self, current_variables: List[SNMPVariable], last_variables: List[Dict]) -> List[Dict]:
oid_idx_map = {item.oid_index: item for item in current_variables}
last_oid_idx_map = {}
if last_variables:
last_oid_idx_map = {item["oid_index"]: item for item in last_variables}
for idx in oid_idx_map:
if oid_idx_map[idx].snmp_type == "COUNTER" and last_oid_idx_map:
if idx not in last_oid_idx_map:
self.logger.error(f"not found oid index {idx} in last_variable")
continue
last_var = last_oid_idx_map[idx]
oid_idx_map[idx].value = (int(oid_idx_map[idx].value) - int(last_var["value"])) / (datetime.now().timestamp() - last_var["timestamp"])
return self.save(oid_idx_map)
?
def save(self, variables: Dict[str, SNMPVariable]) -> List[Dict]:
res = []
for item in variables.values():
value = item.value
if item.snmp_type in ["COUNTER", "INTEGER", "unsigned INTEGER", "TIMETICKS", "unsigned int64", "signed int64", "float", "double"]:
value = float(value)
res.append({
"oid_index": item.oid_index,
"oid": item.oid,
"value": value,
"snmp_type": item.snmp_type,
"timestamp": int(datetime.now().timestamp()),
})
return res
上述代码中的三个方法简单阐述一下:
- self.execute 方法:该方法通过判断 OID 中是否包含“.0”来决定使用的 snmp 请求类型
- self.parse 方法:该方法中接受了一个 last_variables 参数,这是为了处理 COUNTER 类型的信息,因为对于流量之类的采集项,是通过计数器来进行计算的,所以通过传入上一次采集的结果,来对这一段时间的计数器差值做计算,再除以时间差。
- self.save 方法,该方法是为了将 SNMVariable 类型不透出底层的执行器,在将它转成字典的同时,添加上此刻的时间戳。
测试
import json
?
from junior.flaskProject.application.models import Action
from junior.flaskProject.application.services.snmp_executor import SNMPExecutor
from junior.flaskProject.application.services.device import Device
?
device = Device.to_model(**{"ip": "192.168.31.149"})
action = Action.to_model(**{"cmd": "ifOutOctets"})
?
with SNMPExecutor("public", device) as snmp:
last = snmp.execute(action)
resp = snmp.execute(action, last)
print(json.dumps(resp, indent=2))
总结
这一章节主要介绍了在自动化运维中不可或缺的 SNMP 信息采集相关的内容,包括 SNMP 的基本原理,以及在 Python 中如何进行 SNMP 请求,最后实现了一个 SNMPExecutor 来丰富我们底层执行器的功能,这样可以为后续上层的功能提供更好的复用能力。
后面的章节我们会尝试使用 SSHExecutor 和 SNMPExecutor 来实现 CMDB 信息的更新,以此来让 CMDB 不止具有单纯的记录作用,并且还具备实效性。
附录
联系我时,请说是在公司转让壳看到的,谢谢!