大半夜被短信轰炸叫起来看告警的日子,我估计不少运维兄弟都经历过。我刚接手几十台服务器的时候,每天早上一睁眼第一件事就是挨个登上去看CPU、磁盘、内存,碰上业务高峰期还要盯着日志。后来我实在受不了这种重复劳动,花了两周时间写了一套自动化巡检工具。那之后不光早上能多睡半小时,很多潜在故障反而比用户先发现。这篇就把这套工具从设计思路、代码实现到落地部署的所有过程,包括踩过的坑,一次性说清楚。
先说清楚这套工具能干什么:它能按照你设定的频率,批量采集服务器的CPU、内存、磁盘、网络、关键进程这些核心指标,自动判断状态是否健康,把异常情况推送到你的手机上,最后还会生成一份可以直接发给领导的巡检日报。整个过程完全无人值守,跑起来之后你只需要在收到告警的时候看一眼。文章适合有一定基础、开始独立维护多台机器的技术人员,不涉及复杂平台,全程用Python标准库加少量第三方库就能实现。
1. 整体设计与思路拆解
1.1 先搞清楚一个问题:为什么不用现成的监控系统
每个人问我自动化运维工具开发,第一反应都是“你装个开源监控平台不就行了,干嘛自己写”。这话对了一半。对于大型集群,直接上成熟的监控平台确实更划算,组件多、生态全、图表漂亮。但现实往往是我当初那个处境:一个机房几十台机器,业务规模不算大,开放平台要么占用资源偏重,想完成跑通最少也得折腾两三天,给非技术同事看报告还得教他们怎么打开平台。而且最烦的一点是,平台给你预留的告警规则和报告模板,改起来很痛苦。
所以我在设计整个方案之前先问了自己三个问题:这工具核心要解决什么?答:日常巡检和异常通知。工具运行在哪里?答:在运维跳板机上,用SSH协议去采集所有目标服务器状态。输出给谁看?答:给执行巡检的工程师看,也给不写代码的业务负责人看。基于这三个答案,设计出了一个很明确的思路——轻量、低依赖、规则透明、报告可读。
1.2 模块划分与数据流动
把整个工具按照功能拆成四个独立模块,这个设计在后面维护时省了无数事:
- 采集模块:负责建立SSH连接,远程执行命令,收集原始指标数据。
- 解析模块:把每台机器返回的字符串结果,解析成统一格式的字典数据。
- 判定模块:读取配置中的阈值,对照数据做出健康/警告/严重判定。
- 通知与报告模块:把判定结果写入日志,推送告警信息到IM,生成当日巡检报告。
这四个模块之间用最简单的函数调用连接,不用消息队列,不用数据库,全部结果直接存在内存中,最后一次性落盘。数据流动是:配置解析 → 采集循环 → 逐台判定 → 汇总输出。这套流程是我在所有自动化运维工具中反复验证过的最稳定路径。
2. 核心模块划分与目录结构
2.1 项目目录怎么组织最合理
我当时建项目目录时,特意把代码、配置、输出分开,避免日志和报告跟源码混在一起。目录结构用了一个很经典的布局:
ops_tool/ ├── config.yaml # 所有可调整参数集中在此 ├── requirements.txt # 依赖清单 ├── main.py # 入口,负责调度整个流程 ├── hosts.yaml # 服务器清单 ├── collectors/ │ ├── __init__.py │ ├── ssh_client.py # SSH连接与命令执行 │ └── metric_reader.py # 各指标的具体采集函数 ├── parsers/ │ ├── __init__.py │ └── linux_parser.py # 解析Linux命令行输出 ├── checks/ │ ├── __init__.py │ └── threshold.py # 阈值判定逻辑 └── notify/ ├── __init__.py ├── messenger.py # 通知渠道抽象 └── report.py # 生成Markdown和HTML报告这个目录结构看起来简单,但它解决了一个核心问题:职责清晰。每个目录只干一件事,后续想加Windows支持,只需要在collectors下面加一个win_metric_reader,想加短信通知,只需在notify下面新增一个channel。我后来加新功能时,基本只需要动两个文件。
2.2 配置文件设计的巧与坑
所有参数集中管理是这类工具的第一原则。我在配置里不写死任何阈值,全部用YAML维护:
# config.yaml job: name: "daily-inspect" ssh_timeout: 10 connect_retries: 2 thresholds: cpu_warn: 75 cpu_critical: 90 mem_warn: 80 mem_critical: 92 disk_warn: 85 disk_critical: 95 notify: channel: "wecom" # wecom / dingtalk / email webhook_url: "https://example.invalid/hook" alert_interval: 300 # 相同告警最小重发间隔(秒) report: output_dir: "./reports" date_format: "%Y-%m-%d"这里有一个非常关键的细节:告警重发间隔。刚开始我没设计这个参数,结果有一次某台机器磁盘持续满了两个小时,我的手机每五分钟被轰炸一次。这个参数的作用就是记录每条告警指纹,在指定时间窗口内重复出现就不再推送,既不会漏,又不会烦。
2.3 依赖取舍的底层逻辑
整个工具我只用了三个第三方依赖:PyYAML用于解析配置,paramiko用于SSH连接,jinja2用于渲染HTML报告模板。其余全部使用Python标准库。有人可能觉得paramiko有点重,换subprocess调系统ssh命令可行,但那样要额外处理密钥、known_hosts、超时,反而更麻烦。paramiko直接支持私钥加载、超时控制、命令执行,是SSH自动化方案的目前最优解。
关于版本锁定,我在requirements.txt里全部锁到了具体的补丁版本:
pyyaml==6.0.1 paramiko==3.4.0 jinja2==3.1.4锁版本是因为这类工具往往在跳板机上常年不升级,有一次paramiko从2.x升到3.x,算法的默认行为有变化,直接导致几十台机器批量连接失败。从那以后,凡是部署长期运行的脚本,我都锁死版本。
3. 核心采集逻辑与判定标准实现
3.1 SSH批量连接的正确姿势
采集的第一步,就是把hosts.yaml里定义的服务器列表逐个连接。hosts.yaml长这样:
servers: - name: "web-01" host: "192.168.10.11" port: 22 user: "ops" key_path: "/home/ops/.ssh/id_ed25519" - name: "db-01" host: "192.168.10.12" port: 22 user: "ops" key_path: "/home/ops/.ssh/id_ed25519"写SSH采集的时候,最需要控制的是批量并发量和超时时间。如果几十台机器串行跑,每台耗时三秒,一轮下来就是几分钟,性能很差;但如果同时并发,又容易把跳板机连接数打满。我最后采用了信号量控制并发为5,配合单机超时10秒的方案。
以下是核心的连接执行代码,这个函数是我抽出来给所有采集指令用的公共方法:
# collectors/ssh_client.py import paramiko from concurrent.futures import ThreadPoolExecutor, as_completed import threading class SSHClientPool: """带并发控制的SSH客户端池,避免瞬间创建太多连接""" def __init__(self, max_workers=5): self.semaphore = threading.Semaphore(max_workers) def execute(self, server, command, timeout=10): with self.semaphore: client = paramiko.SSHClient() client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) try: client.connect( hostname=server["host"], port=server.get("port", 22), username=server["user"], key_filename=server["key_path"], timeout=timeout, banner_timeout=timeout, auth_timeout=timeout ) stdin, stdout, stderr = client.exec_command(command, timeout=timeout) out = stdout.read().decode("utf-8", errors="ignore") err = stderr.read().decode("utf-8", errors="ignore") return {"server": server["name"], "ok": True, "stdout": out, "stderr": err} except Exception as exc: return {"server": server["name"], "ok": False, "error": str(exc)} finally: client.close() def run_all(self, servers, command): results = {} with ThreadPoolExecutor(max_workers=self.MAX_WORKERS) as pool: futures = {pool.submit(self.execute, srv, command): srv["name"] for srv in servers} for fut in as_completed(futures): res = fut.result() results[res["server"]] = res return results这段代码里有几个细节值得说明。connect时把banner_timeout和auth_timeout都设置成跟timeout一致,是因为有些机器在SSH握手阶段会卡住,只设置connect的timeout不够,容易发生长时间挂起。读取输出时用了errors="ignore",实际工作中遇到过语言环境不同,有个别机器返回了非UTF-8编码导致脚本崩溃,直接忽略编码错误最省事。
3.2 用什么命令采集指标才最可靠
采集指标时,我全部选择Linux系统自带命令,没有用psutil。有两个原因:一是很多服务器为了安全起见面没有Python的psutil库,也不能随意装包;二是在远程环境里,自带命令标准输出更容易被解析。
这一套命令是我调试多次后稳定可靠的方案:
# collectors/metric_reader.py COMMANDS = { "cpu_usage": "top -bn1 | grep 'Cpu(s)' | awk -F'id,' '{print 100 - $2}'", "mem_usage": "free -m | awk 'NR==2{printf \"%.1f\", $3*100/$2}'", "disk_usage": "df -P / | awk 'NR==2{print $5}' | tr -d '%'", "load_avg": "cat /proc/loadavg | awk '{print $1}'", "tcp_estab": "ss -ant | grep ESTAB | wc -l", }这里有一处比较容易踩坑的是CPU使用率。很多参考代码用top取第一行没有加循环次数,在某些系统上top第一次运行的不是进程状态而是平均值,表现不稳定。用top -bn1,并且只用CPU空闲比例倒推,实测下来是兼容性最好的方案。
内存使用率用free的第三列除以第二列,输出加了一位小数,方便后面做数值判定。磁盘用df -P /,这个-P参数很有用,它会强制使用POSIX格式输出,避免因为路径折叠导致解析错行。
3.3 阈值判定背后的算法思路
拿到指标之后,判定模块要做的事很简单:数值比较,但比较逻辑要设计好。每个判定返回三个字段:状态level、当前值value、阈值limit。
# checks/threshold.py def check_cpu(usage, warn=75, critical=90): if usage >= critical: return {"level": "critical", "value": usage, "threshold": critical} if usage >= warn: return {"level": "warning", "value": usage, "threshold": warn} return {"level": "ok", "value": usage, "threshold": critical}关键思想是每个阈值规则放到独立函数里,传参用配置值。这样无论是想加内存阈值还是磁盘阈值,逻辑完全一致。判定完成后把所有结果汇总成一个列表,每个元素包含服务器名、指标名、level、value、message。这张列表就是后续通知和报告的唯一数据源。
4. 通知机制与报告生成实操
4.1 多通道消息推送的抽象设计
这一节可能是大家最关心的。告警消息推送,我设计了一个统一接口,定义了send方法,子类分别实现企业微信、钉钉、邮件的具体逻辑。以最容易调试的企业微信机器人为例:
# notify/messenger.py import requests, time, hashlib class ChannelBase: def send(self, title, content): raise NotImplementedError class WecomChannel(ChannelBase): def __init__(self, webhook_url): self.webhook_url = webhook_url def send(self, title, content): payload = { "msgtype": "markdown", "markdown": { "content": f"### {title}\n{content}" } } resp = requests.post(self.webhook_url, json=payload, timeout=5) return resp.status_code == 200由于工具部署在内网环境,要确保跳板机能访问到webhook地址。实际测试中会碰到webhook地址配置错误、端口被防火墙挡掉等情况,所以send方法必须有返回值,并且调用方要把发送失败记录到日志。这里还要提一个项目里必须实现的方法——告警去重。实现思路是维护一个字典,key是告警指纹(服务器名+指标名),value是上次发送时间,秒级差值小于配置的alert_interval就直接跳过。这套“指纹去重”机制在连续抖动场景下非常关键,日志抖动和真实故障的最大区别就是消息频率,没有这个机制你会被重复警报淹没。
4.2 自动生成日报:从Markdown到HTML
巡检报告是这套工具的另一大产出。每天9点跑完后生成一份报告,Markdown用于留存查阅,HTML用于发送给需要看结果的人。这里的关键是让报告生成函数接收一个统一的结果列表,而不是每个模块单独去读文件。
# notify/report.py from jinja2 import Environment, FileSystemLoader from datetime import datetime def build_markdown(results): lines = ["# 巡检日报", f"生成时间:{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}", ""] for item in results: lines.append(f"- [{item['level'].upper()}] {item['server']}: {item['metric']} {item['value']}% (阈值 {item['threshold']}%)") return "\n".join(lines) def build_html(markdown_text, output_path): env = Environment(loader=FileSystemLoader("./templates")) template = env.get_template("report.html.j2") html = template.render(content=markdown_text, title="巡检日报") with open(output_path, "w", encoding="utf-8") as f: f.write(html)HTML模板我用了jinja2,模板文件里只需要一个渲染content的位置即可。Markdown转HTML这一步,如果你想做得更严谨,可以用markdown库把表格和列表转成真正的HTML,但我实测下来,用空格保留格式的原始Markdown文本放在<pre>标签里,反而在可读性和兼容性上更好,尤其内容包含多行代码时渲染不容易乱。
4.3 Crontab定时调度的完整配置
工具跑起来之后,剩下的就是定时调度问题。我直接用了Cron,因为这台工具所在跳板机本身有在运行。
# 每天上午9点执行巡检并生成日报 0 9 * * * cd /opt/ops_tool && /usr/bin/python3 main.py --daily >> /var/log/ops_tool.log 2>&1 # 每5分钟执行一次快速健康检查,只有异常才会推送 */5 * * * * cd /opt/ops_tool && /usr/bin/python3 main.py --quick >> /var/log/ops_tool.log 2>&1注意main.py要有参数解析逻辑,区分全量巡检和快速检查。快速检查意味着只采集CPU、磁盘两个指标,其余跳过。这样可以大大降低采集频率对目标机器的干扰,因为每五分钟全量采集一次相对较重,对DB机器业务会有一定影响。
我把主程序入口写成下面这种形式,支持--daily与--quick两种模式:
# main.py import argparse, yaml, logging from collectors.metric_reader import collect_metrics from checks.threshold import judge_all from notify.messenger import get_channel from notify.report import build_markdown, build_html def load_config(path="config.yaml"): with open(path, "r", encoding="utf-8") as f: return yaml.safe_load(f) def run_daily(cfg): hosts = load_hosts() raw = collect_metrics(hosts, cfg) results = judge_all(raw, cfg["thresholds"]) md = build_markdown(results) html = build_html(md) channel = get_channel(cfg["notify"]) issues = [r for r in results if r["level"] != "ok"] if issues: channel.send("巡检发现异常", md) save_report(md, html, cfg["report"]["output_dir"]) def run_quick(cfg): # 快速模式仅检测cpu和disk,异常时直接推送 pass if __name__ == "__main__": parser = argparse.ArgumentParser() parser.add_argument("--daily", action="store_true") parser.add_argument("--quick", action="store_true") args = parser.parse_args() cfg = load_config() logging.basicConfig(level=logging.INFO) if args.daily: run_daily(cfg) elif args.quick: run_quick(cfg)这里还有一个小细节:日志输出中特别打印一条“巡检完成,正常N台,异常M台”,方便每天早上去看一眼日志确认工具真的执行了。如果哪一天没看到这条日志,说明调度或脚本有问题,这个比看监控面板还可靠。
5. 常见问题与排查技巧实录
5.1 SSH连接卡死或超时的终极排查
这个工具上线后,最常见的问题是某几台机器总是连接超时。排查顺序我整理了一份,基本可以解决95%的问题:
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 所有机器超时 | 跳板机出网受限 | 在跳板机上手动ssh一台测试,检查防火墙和安全组 |
| 单台机器超时 | 目标机器负载过高 | 登录目标机器,看是否内存耗尽导致sshd拒绝握手 |
| 可以登录但exec_command挂起 | 命令本身执行慢 | 把timeout从10秒调大到30秒,或者改用异步方式 |
| 偶发连接失败 | ssh密钥权限不对 | 检查.ssh目录和私钥文件权限是否为600 |
其实还有一个经验丰富的排查技巧:在SSH连接失败时,立刻手动执行ssh -v user@host观察握手日志,比任何代码内日志都快。可以用paramiko的日志级别调整为DEBUG打印更多细节。
5.2 采集命令在个别机器上返回乱码
这个问题在我维护的机器中真实遇到过。有一些机器的语言环境是中文的,df输出的表头中文导致第五列解析错位。解决方案是在命令前加LANG=C,强制所有命令使用英文输出环境。修改后的磁盘命令变成:
LANG=C df -P / | awk 'NR==2{print $5}' | tr -d '%'最后我在所有采集命令前都统一加上LANG=C,这样避免了后续所有解析端的语言环境差异问题。这是自动化脚本的通用经验:跨机器执行任何命令,先强制固定环境变量,避免因为系统语言不同导致解析结果不可预测。
5.3 告警风暴与漏报的平衡技巧
报警阈值定得不好,工具会被打入冷宫。我第一版阈值参考某文档设了cpu警告50%,结果每天全是告警,因为很多批处理任务在夜里会瞬时跑满CPU。后来我学到一个办法:统计一周的基础指标,取均值加上2倍标准差作为警告阈值,这个“基线漂移”的思路让告警准确率高了很多。
同时还要设计“连续N次才告警”的机制,防止瞬时尖峰误报。我在判定模块中加了一个计数逻辑,同一台机器同一指标连续两次触发warning以上才会推送。实现代码很简单,只要维护一个全局计数器,在快速检查模式下实时更新,达到连续次数才触发。
5.4 报告乱码或模板渲染失败
报告模板渲染失败的原因九成是缺失变量。比如模板中写了{{ item.metric }},但数据里字段名拼写有误。这类报错很好查,毕竟有明确的NameError。乱码问题则来自写入HTML文件时的编码,必须显式指定encoding="utf-8"。有不少人写的代码只顾着打开文件却忘了指定编码,生成的报告在Windows浏览器打开就会乱成一片。
5.5 日志文件不断膨胀的清理策略
工具一旦长期运行,日志文件不清理会吃满磁盘。我在设计方案时特意加了一个rotate逻辑:每天归档当天的日志,保留最近30天,超期自动删除。最简单的写法是Crontab加一行:
# 每月1号清理超过30天的日志,保留最新的压缩包 0 3 1 * * find /var/log/ops_tool* -mtime +30 -delete这里要说明一点,不要在启用日志rotate前让工具的日志文件无限增长。我见过最惨的例子是监控工具自身把磁盘吃完,导致被监控的服务也受影响,那真是黑色幽默。
6. 我应该从哪里开始改造这套工具
做完第一版后我就意识到,这套工具的扩展点非常多。第一个方向是资产管理:把后端稳定运行的主机清单接入配置生成流程,让新机器开完机自动纳入巡检,这是目前投入产出比最高的改造。第二个方向是SQLite存储历史指标:目前巡检数据只存在当天内存中,一旦有了历史库,就能画趋势图和做更好的异常检测。第三个方向是权限收敛:目前所有跳板机都用同一把私钥,后期可以切换到每个目标机一个临时密钥、定期轮换的方案,让自动化运维所在的基础安全环境更可控。
我自己在实际运行这套工具半年后最大的体会是:不要一开始就追求大而全,先让“每天能自动跑、异常能收到、报告能看懂”这三件事稳定运转,再逐步扩展。说实话,自动化运维工具的价值不在于它多复杂,而在于它替你省掉了多少重复劳动,以及帮你在故障发生前抢到多少时间。现在每天早上我做的第一件事只是拿手机看一眼有没有异常推送,没有就安心开始一天的工作,这种感觉,比任何技术指标都来得实在。