第318篇:用 Python 开发自动化巡检脚本
关键词:Python、自动化巡检、设备健康检查、Netmiko、Paramiko、巡检报告、告警规则
一、引言
第317篇介绍了华为 MDA 框架的设备管理方式。在实际运维中,每日/每周对网络设备进行巡检是必不可少的工作:检查设备 CPU/内存利用率、接口错误计数、路由数量、协议状态、温度等指标,及时发现潜在故障。
传统的手工巡检(逐台登录设备执行 display 命令)耗时耗力、容易遗漏。用 Python 开发自动化巡检脚本,可以批量采集、自动分析、生成报告,大幅提升运维效率。
二、核心概念
2.1 巡检项目分类

2.2 巡检流程

2.3 巡检工具选型
| 工具 | 适用场景 | 优势 | 局限 |
|---|---|---|---|
| Netmiko | 多厂商 CLI 操作 | 跨厂商支持好,API 简洁 | 仅 CLI,无结构化数据 |
| Paramiko | 纯 SSH 操作 | 灵活,底层 SSH 控制 | 需自己处理 CLI 交互 |
| NAPALM | 统一 API | 厂商无关接口 | 支持的功能有限 |
| NETCONF/RESTCONF | 结构化数据 | 返回结构化数据 | 需设备支持 |
| eSDK | 华为设备专用 | 华为深度集成 | 仅限华为设备 |
三、技术细节
3.1 基于 Netmiko 的基础巡检
$ pip install netmiko jinja2 pyyaml
3.1.1 单设备巡检框架
# scripts/device_inspection.py
from netmiko import ConnectHandler
import re
import json
from datetime import datetime
class DeviceInspector:
"""设备巡检器"""
# 巡检命令定义(华为设备)
INSPECTION_COMMANDS = {
"cpu_memory": "display device",
"temperature": "display temperature",
"interface_status": "display interface brief",
"bgp_summary": "display bgp peer all",
"ospf_summary": "display ospf peer brief",
"isis_summary": "display isis peer brief",
"route_count": "display ip routing-table statistics",
"arp_count": "display arp statistics",
"uptime": "display version",
"ntp_status": "display ntp status",
"clock": "display clock",
"board_status": "display device",
"power_status": "display power-supply",
"fan_status": "display fan",
}
# 告警阈值
THRESHOLDS = {
"cpu_usage_warning": 70,
"cpu_usage_critical": 90,
"memory_usage_warning": 80,
"memory_usage_critical": 95,
"temperature_warning": 60,
"temperature_critical": 75,
"interface_error_warning": 100,
"interface_down_critical": True, # 接口 Down 即告警
}
def __init__(self, device):
self.device = device
self.results = {
"device": device.get("hostname", device["host"]),
"ip": device["host"],
"timestamp": datetime.now().isoformat(),
"checks": {},
"alerts": [],
}
self.connection = None
def connect(self):
"""建立 SSH 连接"""
self.connection = ConnectHandler(**self.device)
return self
def disconnect(self):
"""断开连接"""
if self.connection:
self.connection.disconnect()
def execute_command(self, command, description=""):
"""执行单条巡检命令"""
try:
output = self.connection.send_command(command, read_timeout=30)
return {"output": output, "status": "success"}
except Exception as e:
return {"output": str(e), "status": "error", "error": str(e)}
def run_all_checks(self):
"""执行全部巡检项"""
for check_name, command in self.INSPECTION_COMMANDS.items():
print(f" 执行: {check_name}...")
result = self.execute_command(command)
self.results["checks"][check_name] = result
# 解析并检查阈值
self._analyze_check(check_name, result)
return self.results
def _analyze_check(self, check_name, result):
"""分析巡检结果并生成告警"""
if result["status"] != "success":
self._add_alert(check_name, "critical", f"命令执行失败: {result.get('error')}")
return
output = result["output"]
if check_name == "cpu_memory":
self._check_cpu_memory(output)
elif check_name == "temperature":
self._check_temperature(output)
elif check_name == "interface_status":
self._check_interfaces(output)
elif check_name == "bgp_summary":
self._check_bgp_peers(output)
elif check_name == "ospf_summary":
self._check_ospf_peers(output)
def _check_cpu_memory(self, output):
"""检查 CPU 和内存利用率"""
# 解析 CPU 使用率
cpu_match = re.search(r'CPU\s+utilization:\s+(\d+)%', output, re.IGNORECASE)
if cpu_match:
cpu = int(cpu_match.group(1))
if cpu >= self.THRESHOLDS["cpu_usage_critical"]:
self._add_alert("cpu_usage", "critical", f"CPU 利用率 {cpu}%")
elif cpu >= self.THRESHOLDS["cpu_usage_warning"]:
self._add_alert("cpu_usage", "warning", f"CPU 利用率 {cpu}%")
# 解析内存使用率
mem_match = re.search(r'Memory\s+utilization:\s+(\d+)%', output, re.IGNORECASE)
if mem_match:
mem = int(mem_match.group(1))
if mem >= self.THRESHOLDS["memory_usage_critical"]:
self._add_alert("memory_usage", "critical", f"内存利用率 {mem}%")
elif mem >= self.THRESHOLDS["memory_usage_warning"]:
self._add_alert("memory_usage", "warning", f"内存利用率 {mem}%")
def _check_temperature(self, output):
"""检查温度"""
temp_pattern = re.compile(r'(\d+)\s*°?\s*C', re.IGNORECASE)
temps = temp_pattern.findall(output)
for temp_str in temps:
temp = int(temp_str)
if temp >= self.THRESHOLDS["temperature_critical"]:
self._add_alert("temperature", "critical", f"温度 {temp}°C")
elif temp >= self.THRESHOLDS["temperature_warning"]:
self._add_alert("temperature", "warning", f"温度 {temp}°C")
def _check_interfaces(self, output):
"""检查接口状态"""
lines = output.strip().split("\n")[2:] # 跳过表头
for line in lines:
parts = line.split()
if len(parts) < 4:
continue
iface = parts[0]
phys_status = parts[1] if len(parts) > 1 else ""
proto_status = parts[2] if len(parts) > 2 else ""
# 跳过管理接口和 Loopback
if iface.lower().startswith(("lo", "me", "inloop")):
continue
# 检查接口 Down
if proto_status.lower() == "down":
self._add_alert("interface_down", "critical",
f"接口 {iface} 协议状态 Down")
# 检查错误计数
if len(parts) > 6:
try:
errors = int(parts[6]) if parts[6].isdigit() else 0
if errors > self.THRESHOLDS["interface_error_warning"]:
self._add_alert("interface_errors", "warning",
f"接口 {iface} 错误计数 {errors}")
except (ValueError, IndexError):
pass
def _check_bgp_peers(self, output):
"""检查 BGP 邻居状态"""
lines = output.strip().split("\n")[2:]
for line in lines:
parts = line.split()
if len(parts) < 3:
continue
peer_ip = parts[0]
state = parts[-1] if parts else ""
if state.upper() != "Established" and state.upper() != "ESTABLISHED":
self._add_alert("bgp_peer", "critical",
f"BGP 对等体 {peer_ip} 状态: {state}")
def _check_ospf_peers(self, output):
"""检查 OSPF 邻居状态"""
lines = output.strip().split("\n")[2:]
for line in lines:
parts = line.split()
if len(parts) < 4:
continue
neighbor = parts[0]
state = parts[-1]
if state.upper() not in ("Full", "FULL", "2-Way", "2-WAY"):
self._add_alert("ospf_peer", "warning",
f"OSPF 邻居 {neighbor} 状态: {state}")
def _add_alert(self, check_name, severity, message):
"""添加告警"""
alert = {
"check": check_name,
"severity": severity,
"message": message,
"timestamp": datetime.now().isoformat(),
}
self.results["alerts"].append(alert)
def __enter__(self):
self.connect()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.disconnect()
3.1.2 多设备批量巡检
# scripts/batch_inspection.py
from device_inspection import DeviceInspector
import yaml
import json
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime
def run_batch_inspection(inventory_file="devices/inventory.yaml", max_workers=10):
"""批量巡检入口"""
with open(inventory_file, "r", encoding="utf-8") as f:
inventory = yaml.safe_load(f)
devices = inventory.get("devices", [])
all_results = []
total_alerts = {"critical": 0, "warning": 0, "info": 0}
print(f"开始巡检 {len(devices)} 台设备...")
print("=" * 60)
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = {
executor.submit(inspect_single_device, dev): dev
for dev in devices
}
for future in as_completed(futures):
device = futures[future]
try:
result = future.result()
all_results.append(result)
# 统计告警
for alert in result.get("alerts", []):
severity = alert.get("severity", "info")
total_alerts[severity] = total_alerts.get(severity, 0) + 1
# 输出摘要
alert_count = len(result.get("alerts", []))
status = "✓" if alert_count == 0 else f"⚠ {alert_count}告警"
print(f"{status} {result['device']}: {alert_count} 告警")
except Exception as e:
all_results.append({
"device": device.get("hostname", device["host"]),
"ip": device["host"],
"timestamp": datetime.now().isoformat(),
"checks": {},
"alerts": [{
"check": "connection",
"severity": "critical",
"message": f"连接失败: {e}",
}],
})
total_alerts["critical"] += 1
print(f"✗ {device.get('hostname', device['host'])}: 连接失败")
return all_results, total_alerts
def inspect_single_device(device):
"""巡检单台设备"""
with DeviceInspector(device) as inspector:
return inspector.run_all_checks()
# 执行批量巡检
results, alerts = run_batch_inspection()
# 保存结果
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
with open(f"inspection_reports/inspection_{timestamp}.json", "w",
encoding="utf-8") as f:
json.dump({
"timestamp": datetime.now().isoformat(),
"summary": alerts,
"results": results,
}, f, indent=2, ensure_ascii=False)
print(f"\n巡检完成!")
print(f" 严重告警: {alerts['critical']}")
print(f" 警告: {alerts['warning']}")
3.2 巡检报告生成(HTML 格式)
# scripts/inspection_report.py
from jinja2 import Template
import json
HTML_REPORT_TEMPLATE = Template("""
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>网络巡检报告 - {{ timestamp }}</title>
<style>
body { font-family: 'Microsoft YaHei', Arial, sans-serif; margin: 20px; }
.header { background: #2c3e50; color: white; padding: 20px; border-radius: 8px; }
.summary { display: flex; gap: 20px; margin: 20px 0; }
.summary-card {
flex: 1; padding: 15px; border-radius: 8px;
text-align: center; color: white;
}
.critical { background: #e74c3c; }
.warning { background: #f39c12; }
.normal { background: #27ae60; }
.device-card {
border: 1px solid #ddd; border-radius: 8px;
margin: 10px 0; padding: 15px;
}
.device-card.has-alert { border-left: 4px solid #e74c3c; }
.device-card.ok { border-left: 4px solid #27ae60; }
.alert-item {
padding: 8px; margin: 5px 0; border-radius: 4px;
background: #ffeaea;
}
.alert-item.warning { background: #fff3cd; }
table { width: 100%; border-collapse: collapse; margin: 10px 0; }
th, td { padding: 8px; border: 1px solid #ddd; text-align: left; }
th { background: #f5f5f5; }
</style>
</head>
<body>
<div class="header">
<h1>🔍 网络巡检报告</h1>
<p>巡检时间: {{ timestamp }}</p>
<p>设备总数: {{ results|length }} 台</p>
</div>
<div class="summary">
<div class="summary-card critical">
<h2>{{ summary.critical }}</h2>
<p>严重告警</p>
</div>
<div class="summary-card warning">
<h2>{{ summary.warning }}</h2>
<p>警告</p>
</div>
<div class="summary-card normal">
<h2>{{ normal_count }}</h2>
<p>正常设备</p>
</div>
</div>
{% for result in results %}
<div class="device-card {% if result.alerts %}has-alert{% else %}ok{% endif %}">
<h3>{{ result.device }} ({{ result.ip }})</h3>
{% if result.alerts %}
<p><strong>{{ result.alerts|length }} 条告警</strong></p>
{% for alert in result.alerts %}
<div class="alert-item {% if alert.severity == 'warning' %}warning{% endif %}">
<strong>[{{ alert.severity|upper }}]</strong>
{{ alert.check }}: {{ alert.message }}
</div>
{% endfor %}
{% else %}
<p>✅ 所有检查项正常</p>
{% endif %}
</div>
{% endfor %}
<div style="margin-top: 20px; color: #999; text-align: center;">
<p>报告生成时间: {{ generation_time }}</p>
</div>
</body>
</html>
""")
def generate_html_report(results, summary, output_file="inspection_report.html"):
"""生成 HTML 巡检报告"""
normal_count = sum(1 for r in results if not r.get("alerts"))
html = HTML_REPORT_TEMPLATE.render(
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
results=results,
summary=summary,
normal_count=normal_count,
generation_time=datetime.now().isoformat(),
)
with open(output_file, "w", encoding="utf-8") as f:
f.write(html)
print(f"HTML 报告已生成: {output_file}")
3.3 巡检告警通知
# scripts/inspection_alerts.py
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
import json
def send_alert_email(alerts, recipients=["admin@example.com"],
sender="inspection@example.com"):
"""发送告警邮件"""
if not alerts:
return
critical_alerts = [a for a in alerts if a["severity"] == "critical"]
warning_alerts = [a for a in alerts if a["severity"] == "warning"]
if not critical_alerts and not warning_alerts:
return
# 构建邮件正文
body = "<h2>网络巡检告警通知</h2>"
body += f"<p>巡检时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</p>"
if critical_alerts:
body += "<h3>🔴 严重告警</h3><ul>"
for alert in critical_alerts:
body += f"<li>{alert['device']}: {alert['message']}</li>"
body += "</ul>"
if warning_alerts:
body += "<h3>🟡 警告</h3><ul>"
for alert in warning_alerts:
body += f"<li>{alert['device']}: {alert['message']}</li>"
body += "</ul>"
msg = MIMEMultipart("alternative")
msg["Subject"] = f"网络巡检告警 - {len(critical_alerts)} 严重 / {len(warning_alerts)} 警告"
msg["From"] = sender
msg["To"] = ", ".join(recipients)
msg.attach(MIMEText(body, "html", "utf-8"))
try:
server = smtplib.SMTP("smtp.example.com", 587)
server.starttls()
server.login(sender, "password")
server.sendmail(sender, recipients, msg.as_string())
server.quit()
print(f"告警邮件已发送至: {recipients}")
except Exception as e:
print(f"邮件发送失败: {e}")
def send_wechat_alert(alerts, webhook_url):
"""发送企业微信告警"""
critical_alerts = [a for a in alerts if a["severity"] == "critical"]
if not critical_alerts:
return
content = "🔴 网络巡检严重告警\n\n"
for alert in critical_alerts:
content += f"• {alert['device']}: {alert['message']}\n"
payload = {
"msgtype": "text",
"text": {"content": content},
}
import requests
try:
requests.post(webhook_url, json=payload, timeout=10)
except Exception as e:
print(f"企微告警发送失败: {e}")
3.4 定时巡检调度
# scripts/scheduled_inspection.py
import schedule
import time
from batch_inspection import run_batch_inspection
from inspection_report import generate_html_report
from inspection_alerts import send_alert_email
def daily_inspection_job():
"""每日巡检任务"""
print(f"[{datetime.now()}] 开始每日巡检...")
results, summary = run_batch_inspection()
# 生成报告
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
report_file = f"inspection_reports/daily_{timestamp}.html"
generate_html_report(results, summary, report_file)
# 收集所有告警
all_alerts = []
for r in results:
for alert in r.get("alerts", []):
alert["device"] = r["device"]
all_alerts.append(alert)
# 发送告警
if summary["critical"] > 0:
send_alert_email(all_alerts)
print(f"巡检完成: {summary}")
# 调度配置
schedule.every().day.at("09:00").do(daily_inspection_job)
schedule.every().monday.at("08:00").do(lambda: print("周报提醒"))
print("巡检调度已启动...")
while True:
schedule.run_pending()
time.sleep(60)
四、实战配置/案例分析
4.1 案例一:综合巡检——接口 + 路由 + 协议
# scripts/comprehensive_inspection.py
from netmiko import ConnectHandler
import re
class ComprehensiveInspector:
"""综合巡检器"""
def __init__(self, device):
self.device = device
self.connection = None
self.results = {}
def connect(self):
self.connection = ConnectHandler(**self.device)
def inspect_all(self):
"""执行综合巡检"""
checks = {
"interface_errors": self._check_interface_errors,
"interface_flapping": self._check_interface_flapping,
"bgp_peer_count": self._check_bgp_peer_count,
"route_count": self._check_route_count,
"arp_table": self._check_arp_table,
"disk_usage": self._check_disk_usage,
}
for name, check_func in checks.items():
try:
self.results[name] = check_func()
except Exception as e:
self.results[name] = {"status": "error", "error": str(e)}
return self.results
def _check_interface_errors(self):
"""检查接口错误计数"""
output = self.connection.send_command("display interface")
errors = {}
for line in output.split("\n"):
# 匹配接口名
iface_match = re.match(r'^(\w+/\d+/\d+)', line)
current_iface = iface_match.group(1) if iface_match else None
# 匹配错误计数
if "Input errors:" in line:
count_match = re.search(r'Input errors:\s+(\d+)', line)
if count_match and current_iface:
count = int(count_match.group(1))
if count > 0:
errors[current_iface] = count
return {
"interfaces_with_errors": errors,
"total_affected": len(errors),
"status": "warning" if errors else "normal",
}
def _check_bgp_peer_count(self):
"""检查 BGP 对等体数量和状态"""
output = self.connection.send_command("display bgp peer all")
peers = {"established": 0, "other": 0, "details": []}
for line in output.split("\n")[2:]:
parts = line.split()
if len(parts) >= 3:
peer_ip = parts[0]
state = parts[-1]
if state.upper() == "ESTABLISHED":
peers["established"] += 1
else:
peers["other"] += 1
peers["details"].append(
f"{peer_ip}: {state}"
)
return {
**peers,
"status": "critical" if peers["other"] > 0 else "normal",
}
def _check_route_count(self):
"""检查路由表规模"""
output = self.connection.send_command("display ip routing-table statistics")
total_match = re.search(r'Total number:\s+(\d+)', output)
if total_match:
total = int(total_match.group(1))
status = "warning" if total > 100000 else "normal"
return {"total_routes": total, "status": status}
return {"status": "error", "error": "无法解析路由数量"}
def _check_disk_usage(self):
"""检查磁盘使用率"""
output = self.connection.send_command("display filesystem")
usage_match = re.search(r'Used:\s+(\d+)%', output)
if usage_match:
usage = int(usage_match.group(1))
status = "critical" if usage > 90 else ("warning" if usage > 80 else "normal")
return {"usage_percent": usage, "status": status}
return {"status": "error", "error": "无法解析磁盘使用率"}
# 使用
inspector = ComprehensiveInspector({
"device_type": "huawei",
"host": "10.1.1.1",
"username": "admin",
"password": "HuaWei@123",
})
inspector.connect()
results = inspector.inspect_all()
print(json.dumps(results, indent=2, ensure_ascii=False))
inspector.connection.disconnect()
4.2 案例二:故障案例——巡检脚本导致设备过载
故障现象:每日巡检时,50 台设备同时收到 SSH 连接请求,导致多台设备 CPU 飙升至 90% 以上,影响业务。
根因分析:
- 批量巡检时并发数过高(默认 max_workers=10),设备同时处理多个 SSH 会话
- 每条巡检命令都是独立的 SSH 交互,I/O 开销大
- 巡检时间窗口集中在同一时刻
解决方案:
# scripts/optimized_inspection.py
import time
from concurrent.futures import ThreadPoolExecutor
def optimized_batch_inspection(devices, max_workers=5, delay_between=2):
"""优化版批量巡检"""
results = []
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = []
for device in devices:
# 限制并发,逐批提交
futures.append(
executor.submit(inspect_single_device, device)
)
time.sleep(delay_between) # 设备间延迟,避免同时连接
for future in as_completed(futures):
results.append(future.result())
return results
# 优化参数
# - max_workers: 5(原 10)
# - delay_between: 2 秒(新增)
# - 巡检时间:避开业务高峰期(凌晨 2:00)
预防措施:
| 措施 | 说明 |
|---|---|
| 降低并发 | 将 max_workers 降至 3-5,避免设备过载 |
| 延迟间隔 | 每台设备连接前延迟 2-5 秒 |
| 错峰巡检 | 安排在凌晨 2:00-5:00 非业务高峰时段 |
| 增量巡检 | 仅对上次巡检有告警的设备做深度检查 |
| 优先 NETCONF | 使用 NETCONF 替代 SSH+CLI,减少设备 I/O 负担 |
五、总结
| 巡检类型 | 推荐方式 | 说明 |
|---|---|---|
| 日常巡检 | Netmiko SSH + CLI | 覆盖全面,不依赖 NETCONF |
| 深度巡检 | NETCONF/RESTCONF | 结构化数据,精确分析 |
| 性能监控 | Telemetry 流式 | 实时指标,秒级发现 |
| 安全审计 | 配置采集 + 规则匹配 | 定期合规检查 |
| 最佳实践 | 说明 |
|---|---|
| 阈值分级 | 设置 warning/critical 两级阈值,不同级别触发不同告警 |
| 增量巡检 | 仅检查上次有异常的设备,减少巡检压力 |
| 报告归档 | 按日期归档巡检报告,支持历史趋势分析 |
| 告警分级通知 | 严重告警即时通知(邮件/企微),警告汇总日报 |
| 幂等执行 | 巡检脚本支持重复执行,不因中断导致遗漏 |
| 连接超时 | 设置合理的连接超时和命令超时,避免脚本挂起 |
思考题
- 使用 Netmiko 和 NETCONF 进行巡检,各有什么优势和局限?什么场景下必须用 Netmiko?
- 如果设备返回的 CLI 输出格式因版本不同而变化,如何保证巡检脚本的兼容性?
- 大规模网络(1000+ 设备)巡检时,串行、并行和分时巡检各有什么优缺点?
下篇预告:第319篇:配置备份与合规检查自动化工具