Python 运维自动化系列 · 第 01 篇:← 系列总目录 | 下一篇:02 日志分析
一篇面向运维新手的实战教程:从 psutil 读系统指标,到日志轮转、定时调度,最后落地一个完整可用的主机监控告警脚本。文中所有代码和输出都来自真实运行,环境为 Python 3.13 + Windows。
一、为什么是 Python
运维日常里有一大类活儿,本质是「重复 + 有明确判断标准」:每天看一眼 CPU、内存、磁盘;日志满了要清;服务挂了要发现。这类活儿交给 shell 脚本也行,但 Python 有三个实在的优势:
- 标准库全:文件、目录、压缩、网络、子进程调用,开箱即用;
- 生态对口:
psutil(系统指标)、requests(调 Webhook)、schedule(定时)这几个库加起来不到 1MB,却覆盖了监控自动化 80% 的场景; - 可读性好:三个月后回看,Python 脚本大概率还看得懂,这一点对运维脚本尤其重要——运维脚本的生命周期往往比想象中长。
本文的依赖只有三个,安装方式:
pip install psutil requests schedule
二、第一步:用 psutil 把系统指标「读」出来
一切监控的起点是能拿到数据。psutil 是 Python 生态里读系统指标的事实标准,跨平台(Linux/Windows/macOS 通用),一段代码走天下。
来看一个系统信息一览脚本(demos/system_tour.py):
from datetime import datetime
import psutil
print("== CPU ==")
print("逻辑核心数:", psutil.cpu_count())
print("物理核心数:", psutil.cpu_count(logical=False))
print(f"CPU 使用率(采样1秒): {psutil.cpu_percent(interval=1)}%")
print("\n== 内存 ==")
mem = psutil.virtual_memory()
print(f"总计 {mem.total / 1024**3:.1f} GB,已用 {mem.used / 1024**3:.1f} GB,使用率 {mem.percent}%")
print("\n== 磁盘 ==")
for part in psutil.disk_partitions():
if "cdrom" in part.opts or not part.fstype:
continue
u = psutil.disk_usage(part.mountpoint)
print(f"{part.device:<10} 总 {u.total / 1024**3:7.1f} GB,已用 {u.percent}%")
print("\n== 开机时间 ==")
boot = datetime.fromtimestamp(psutil.boot_time())
print("本次开机于:", boot.strftime("%Y-%m-%d %H:%M:%S"))
print("\n== 内存占用 Top 5 进程 ==")
for p in sorted(
psutil.process_iter(["name", "memory_percent"]),
key=lambda x: x.info["memory_percent"] or 0,
reverse=True,
)[:5]:
print(f" {p.info['name'] or '?':<30} {p.info['memory_percent'] or 0:.1f}%")
在我机器上的真实输出:
== CPU ==
逻辑核心数: 16
物理核心数: 8
CPU 使用率(采样1秒): 11.0%
== 内存 ==
总计 15.3 GB,已用 10.2 GB,使用率 66.5%
== 磁盘 ==
C:\ 总 200.0 GB,已用 92.2%
D:\ 总 276.6 GB,已用 70.4%
E:\ 总 952.7 GB,已用 5.6%
== 开机时间 ==
本次开机于: 2026-09-27 16:20:48
== 内存占用 Top 5 进程 ==
Qoder.exe 5.2%
MemCompression 5.0%
QQ.exe 2.5%
Qoder.exe 2.5%
AutoClaw2.exe 2.0%
三个新手容易踩的点:
cpu_percent()必须给interval。不带参数调用时,两次调用之间才有意义——第一次调用永远返回 0。给它interval=1表示阻塞采样 1 秒,最省心。- 磁盘分区要过滤光驱和虚拟分区。
part.opts里带cdrom、或fstype为空的分区,disk_usage()会直接抛异常,循环里必须先过滤。 process_iter里进程可能中途消失。本文演示为了简洁直接取p.info;生产代码里更稳妥的写法是包一层try/except psutil.NoSuchProcess——进程列表是动态的,你迭代它的时候进程随时可能退出。
三、第二步:日志别再自己 print 到文件了
监控脚本自己也是要留痕的。「出了问题查日志」是运维的本能,但很多新手的做法是 print(..., file=open('log.txt','a'))——文件越写越大,最后把磁盘写满,监控脚本自己变成事故。
标准库 logging 的 RotatingFileHandler 一行配置就能解决「日志无限增长」:
import logging
import logging.handlers
import os
os.makedirs("logs", exist_ok=True)
logger = logging.getLogger("rot_demo")
logger.setLevel(logging.INFO)
handler = logging.handlers.RotatingFileHandler(
"logs/app.log", maxBytes=10_000, backupCount=3, encoding="utf-8"
)
handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s"))
logger.addHandler(handler)
line = "订单 10086 处理成功,耗时 0.42 秒 " + "x" * 400 # 约 460 字节一行
for i in range(40):
logger.info("第 %d 条: %s", i, line)
print("写完 40 条日志,logs 目录里现在有:")
for f in sorted(os.listdir("logs")):
print(f" {f:<12} {os.path.getsize(os.path.join('logs', f)):>6} 字节")
设定单个文件上限 10KB、最多保留 3 个备份,连写 40 条(每条约 460 字节,总计约 18KB)之后:
写完 40 条日志,logs 目录里现在有:
app.log 9780 字节
app.log.1 9770 字节
效果符合预期:app.log 写到接近 10KB 就轮转,旧内容挪到 app.log.1,最老的直接删除。日志总量被永远锁死在 10KB × 4 个文件以内,无论脚本跑多久。Linux 上还可以换 TimedRotatingFileHandler 按天轮转,思路相同。
记住这个模式,下面的监控脚本会直接用它。
四、第三步:定时执行,两种姿势
姿势一:进程内调度(schedule 库)
schedule 是一个人造语法糖库,写出来近乎自然语言:
import time
import schedule
def heartbeat():
print(time.strftime("%H:%M:%S"), "心跳:监控进程还活着")
schedule.every(2).seconds.do(heartbeat)
# 以下写法真实可用:
# schedule.every().hour.do(heartbeat) # 每小时
# schedule.every().day.at("09:00").do(heartbeat) # 每天 09:00
# schedule.every().monday.at("08:30").do(heartbeat) # 每周一 08:30
while True:
schedule.run_pending()
time.sleep(0.5)
(演示脚本设了 7 秒后自动退出,方便截取输出;真实使用就是最后那个 while True 死循环。)
20:39:46 心跳:监控进程还活着
20:39:48 心跳:监控进程还活着
20:39:50 心跳:监控进程还活着
每 2 秒一次,精确触发。注意 time.sleep(0.5) 不能省——run_pending() 只检查「到点了吗」,不让出 CPU 的话这个循环会吃满一个核。
姿势二:交给操作系统调度
脚本自己常驻(姿势一)意味着:脚本崩了就没人管了,机器重启后也没人拉起来。更稳的做法是把「什么时候跑」交给操作系统,Python 只负责「跑一次」:
- Windows:任务计划程序(
schtasks),设为「不管用户是否登录都运行」+「失败后重启」; - Linux:
cron每分钟拉起一次,配合锁文件防重入;常驻型脚本用systemd托管(Restart=always)。
我的建议:一次性巡检任务用系统调度,进程内 schedule 只用于轻量常驻场景(比如笔记本上的个人小工具)。监控告警这种「不许漏」的活,交给 systemd 或任务计划程序更可靠。
五、实战:把上面的积木拼成一个监控告警脚本
现在把三块积木——psutil 取指标、RotatingFileHandler 留日志、schedule 定时——拼成一个完整脚本 monitor.py。设计目标:
- 一次运行检查 CPU / 内存 / 磁盘三项指标;
- 超过阈值就组装告警文本,附上「当前谁最吃内存」,发到群机器人;没配置机器人就写日志;
- 阈值可以用环境变量覆盖,不写死在代码里;
- 支持
--loop 60进入常驻模式,每 60 秒巡检一轮。
import argparse
import logging
import logging.handlers
import os
import time
from datetime import datetime
import psutil
import requests
# ---------- 配置:默认值 + 环境变量覆盖 ----------
CPU_THRESHOLD = float(os.environ.get("MONITOR_CPU_THRESHOLD", "80"))
MEM_THRESHOLD = float(os.environ.get("MONITOR_MEM_THRESHOLD", "85"))
DISK_THRESHOLD = float(os.environ.get("MONITOR_DISK_THRESHOLD", "90"))
WEBHOOK_URL = os.environ.get("MONITOR_WEBHOOK_URL", "")
# -------------------------------------------------
LOG_DIR = "logs"
os.makedirs(LOG_DIR, exist_ok=True)
logger = logging.getLogger("monitor")
logger.setLevel(logging.INFO)
_file_handler = logging.handlers.RotatingFileHandler(
os.path.join(LOG_DIR, "monitor.log"),
maxBytes=5 * 1024 * 1024,
backupCount=3,
encoding="utf-8",
)
_file_handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s"))
logger.addHandler(_file_handler)
_console_handler = logging.StreamHandler()
_console_handler.setFormatter(logging.Formatter("%(message)s"))
logger.addHandler(_console_handler)
def notify(text: str) -> None:
"""把告警发到群机器人;未配置 WEBHOOK_URL 时只写日志。"""
logger.warning(text)
if not WEBHOOK_URL:
print("(未配置 MONITOR_WEBHOOK_URL,以上告警只写入了日志)")
return
try:
resp = requests.post(
WEBHOOK_URL,
json={"msgtype": "text", "text": {"content": text}},
timeout=10, # 网络请求必须给超时,否则告警卡住会拖垮整个监控
)
resp.raise_for_status()
except requests.RequestException as exc:
# 告警发不出去也要留下痕迹,但不能让监控本身挂掉
logger.error("告警发送失败: %s", exc)
def check_cpu() -> str | None:
usage = psutil.cpu_percent(interval=1)
print(f"CPU 使用率: {usage:.1f}%")
if usage >= CPU_THRESHOLD:
return f"- CPU 使用率 {usage:.1f}%,超过阈值 {CPU_THRESHOLD}%"
return None
def check_memory() -> str | None:
mem = psutil.virtual_memory()
print(
f"内存使用率: {mem.percent:.1f}%"
f"(已用 {mem.used / 1024**3:.1f} GB / 共 {mem.total / 1024**3:.1f} GB)"
)
if mem.percent >= MEM_THRESHOLD:
return f"- 内存使用率 {mem.percent:.1f}%,超过阈值 {MEM_THRESHOLD}%"
return None
def check_disks() -> list[str]:
alerts = []
for part in psutil.disk_partitions():
if "cdrom" in part.opts or not part.fstype:
continue
usage = psutil.disk_usage(part.mountpoint)
print(f"磁盘 {part.device:<8} 已用 {usage.percent:.1f}%")
if usage.percent >= DISK_THRESHOLD:
alerts.append(f"- 磁盘 {part.device} 已用 {usage.percent:.1f}%,超过阈值 {DISK_THRESHOLD}%")
return alerts
def top_by_memory(n: int = 5) -> list[dict]:
procs = []
for p in psutil.process_iter(["pid", "name", "memory_percent"]):
procs.append(p.info)
procs.sort(key=lambda x: x["memory_percent"] or 0, reverse=True)
return procs[:n]
def run_once() -> None:
print(f"== 检查开始 {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} ==")
alerts = [a for a in (check_cpu(), check_memory()) if a]
alerts.extend(check_disks())
if alerts:
lines = [f"[告警] {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"]
lines.extend(alerts)
lines.append("当前内存占用最高的进程:")
for info in top_by_memory():
mp = info["memory_percent"] or 0
lines.append(f" {info['name'] or '?'} (pid {info['pid']}, {mp:.1f}%)")
notify("\n".join(lines))
else:
logger.info("一切正常,未触发告警")
print("== 检查结束 ==\n")
def main() -> None:
parser = argparse.ArgumentParser(description="简单实用的主机监控告警脚本")
parser.add_argument(
"--loop", type=int, default=0, metavar="秒",
help="每 N 秒检查一轮;不传则只检查一次",
)
args = parser.parse_args()
if args.loop <= 0:
run_once()
return
import schedule # 只在常驻模式下需要
schedule.every(args.loop).seconds.do(run_once)
print(f"已进入常驻模式:每 {args.loop} 秒检查一轮,Ctrl+C 退出")
while True:
schedule.run_pending()
time.sleep(1)
if __name__ == "__main__":
main()
真实运行:正常巡检
== 检查开始 2026-09-28 20:39:51 ==
CPU 使用率: 10.4%
内存使用率: 66.7%(已用 10.2 GB / 共 15.3 GB)
磁盘 C:\ 已用 92.2%
磁盘 D:\ 已用 70.4%
磁盘 E:\ 已用 5.6%
(未配置 MONITOR_WEBHOOK_URL,以上告警只写入了日志)
== 检查结束 ==
真实运行:它当场抓到了一个真告警
有意思的是,第一次运行这个脚本,它就触发了一条真实告警——我的 C 盘确实已经用了 92.2%,越过了默认的 90% 磁盘阈值:
[告警] 2026-09-28 20:39:52
- 磁盘 C:\ 已用 92.2%,超过阈值 90.0%
当前内存占用最高的进程:
Qoder.exe (pid 19292, 5.3%)
MemCompression (pid 4016, 5.0%)
QQ.exe (pid 272, 2.5%)
Qoder.exe (pid 3044, 2.5%)
AutoClaw2.exe (pid 5428, 2.0%)
告警文本同时写进了 logs/monitor.log。这正是这类脚本的价值:磁盘涨满是渐进式的,人眼巡检最容易漏,而脚本不会。
验证告警路径:低阈值压测
没法等真出事,用环境变量把阈值压到 1% 强制触发,验证整条告警链路:
$env:MONITOR_CPU_THRESHOLD='1'; $env:MONITOR_MEM_THRESHOLD='1'; python monitor.py
[告警] 2026-09-28 20:39:55
- CPU 使用率 10.6%,超过阈值 1.0%
- 内存使用率 66.6%,超过阈值 1.0%
- 磁盘 C:\ 已用 92.2%,超过阈值 90.0%
当前内存占用最高的进程:
Qoder.exe (pid 19292, 5.2%)
...
三项指标全部命中,进程排行也正确附上。上告警机器人时,配一个 MONITOR_WEBHOOK_URL(企业微信/钉钉/飞书群机器人的 Webhook 地址,文中用的是企业微信的 {"msgtype": "text", ...} 报文格式,钉钉飞书只是 JSON 字段不同)即可。
六、上生产前,把这几件事想清楚
脚本能跑和能上生产之间,隔着几道坎,这里提前替你踩了:
- 告警风暴(flapping):CPU 在 79% 和 81% 之间抖动时,上面的脚本每一轮都会告警。生产上要加「连续 N 次超标才报」或「同类告警 X 分钟内不重复」的去抖逻辑——一个简单的做法是把上次告警时间和内容存内存里比对。
- 阈值不该一刀切:磁盘 90% 对 200GB 的 C 盘很危险,对 2TB 的数据盘也许还很富余。更好的做法是按「剩余绝对空间」+「百分比」双条件判断。
- 单机脚本的宿命:它只能监控「自己这台机器」。机器本身断电、断网,它就沉默了——而你收不到任何消息。所以监控脚本自己也需要被监控(外部拨测、或每轮巡检向远端报心跳,远端发现心跳断了就告警,业内叫 dead man's switch)。
- 常驻进程交给守护者:
--loop模式下脚本崩了就静默消失。Linux 用 systemd 的Restart=always,Windows 用任务计划程序或 NSSM 注册成服务,别用「开个 cmd 窗口挂着」。 - 告警发送必须有超时:
requests.post不带timeout是新手最经典的坑——Webhook 端点一旦变慢,监控进程会被无限期挂起,从此既不告警也不巡检。本文脚本里写死了timeout=10。
七、下一步可以往哪走
这个 100 行的脚本是一切监控体系的种子,往上生长的方向:
- 接 Prometheus:用
prometheus_client把指标暴露成/metrics端点,让专业监控系统来抓、存、画图、告警; - 按服务监控:不只看资源,还探测具体端口/HTTP 健康检查(
requests.get('http://localhost:8000/health', timeout=5)); - 自愈动作:告警之外自动执行预案,比如磁盘超限自动清理临时目录、进程消失自动拉起;
- 集中化:多台机器把心跳和指标推送到同一个接收端(哪怕先是一个简单的 Flask 接口),从「每台机器自己喊」进化成「一处看全局」。
监控的本质不是工具多高级,而是坏消息能比用户更早到达你面前。从这个脚本开始,就够了。
本文是《Python 运维自动化系列》第 01 篇;所有脚本与演示输出均来自真实运行环境(Python 3.13.15,Windows),源码包括 monitor.py 与 demos/ 下的三个演示脚本,可直接取用。下一篇:02 日志文件几个 G,先别慌。