SNMP有哪些优点?
摘要
在之前的章节中,我们给大家讲解了如何使用 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 不止具有单纯的记录作用,并且还具备实效性。