4.12 Python 编写运维自动化脚本

预计阅读时间:15 分钟

📖 目录

学习目标

  • 掌握 Python 运维脚本的核心库:argparse、subprocess、requests、paramiko、psutil、logging
  • 理解何时该从 Shell 切换到 Python,以及两者的权衡
  • 能用 Python 编写实用的运维自动化脚本(命令包装、SSH 批量操作、系统健康检查)
  • 学会使用 virtualenv / Poetry 管理项目依赖和环境隔离

核心知识

库 / 工具用途关联知识
argparse命令行参数解析对应 2.1:Shell 脚本入门 $1/getopts
subprocess执行系统命令、捕获输出对应 2.1:Shell 脚本入门 命令执行、2.8:Shell 脚本进阶 管道组合
requestsHTTP API 调用对应 4.5:网络故障排查 curl/nc 网络排查
paramiko / FabricSSH 远程执行与控制对应 2.11:SSH 深入 SSH 深入、2.8:Shell 脚本进阶 批量脚本
psutil系统资源采集(CPU/内存/磁盘)对应 3.12:系统监控与告警 系统监控、2.7:日志与故障排查 故障排查
logging结构化日志输出对应 2.7:日志与故障排查 syslog/logger
virtualenv / PoetryPython 环境隔离与依赖管理对应 ch8 软件包管理思路

常用库速查表

关键函数/对象一句话用法
osos.path.join / os.walk / os.makedirs / os.listdiros.walk(root) 递归遍历目录树
shutilshutil.move / copy2 / rmtree / disk_usageshutil.disk_usage("/") 直接取磁盘总量/已用
subprocessrun / Popen / CalledProcessErrorsubprocess.run(cmd, capture_output=True, check=True)
recompile / match / search / findall / subre.findall(r"\d{4}-\d{2}-\d{2}", text) 提取日期
jsondumps / loads / dump / loadjson.dumps(data, ensure_ascii=False, indent=2) 中文友好输出
requestsget / post / Session / Timeoutrequests.Session() 复用连接池,必带 timeout
datetimenow / strptime / timedeltadatetime.now() - timedelta(days=7) 算 7 天前的时刻
concurrent.futuresThreadPoolExecutor并发跑 SSH/HTTP 批量任务,IO 密集型提速显著

知识关联

原理讲解

当 Shell 不够用时——何时该换 Python 写运维脚本?

Shell 脚本(2.1:Shell 脚本入门2.8:Shell 脚本进阶)擅长文件操作、管道组合和快速原型,但在以下场景力不从心:

  • 复杂逻辑:多层循环、条件分支、数据结构(字典/列表)
  • 异常处理:网络超时、SSH 断开、JSON/XML 解析失败
  • 跨平台:Shell 命令因发行版/系统而异,Python 标准库统一
  • 依赖管理:多脚本共享函数库时,Shell 的 source 机制脆弱
  • 性能:大量文本解析或 HTTP 调用时 Python 更高效稳定

决策原则:单次手动的任务用 Shell;需要重试、告警、持久化、结构化数据的自动化任务用 Python。

Python 运维场景清单

实践中哪些任务值得用 Python 而不是 Shell?按出现频率排序的运维场景清单:

场景典型输入Python 优势
日志分析GB 级 access.log / 应用日志正则 + 计数器单次扫描,内存可控
文件批处理批量重命名、归档、清理过期文件os/shutil 跨平台,异常可控可回滚
自动化巡检多台服务器指标采集psutil + 线程池并发,输出结构化 JSON
API 调用云厂商/监控/告警平台 REST APIrequests 处理认证、重试、分页
数据清洗汇总导出的 CSV/JSON 生成报表csv/json 标准库直接结构化处理
故障自愈编排多步骤回滚、条件分支决策完整编程语言,逻辑可写单元测试

Python vs Shell 选型对比

维度ShellPython
启动速度毫秒级百毫秒级(解释器启动开销)
文本管道处理极佳(awk/sed/grep 组合)好(re 模块 + 正则)
数据结构只有字符串/数组,复杂逻辑痛苦字典/列表/集合/类,天然适合
异常处理无原生 try/catch,靠 exit codetry/except/finally 完整
第三方生态依赖系统命令是否安装pip 安装即用(requests/psutil/paramiko)
跨平台命令差异大(如 sed -i 各发行版不同)标准库行为统一
维护性超过 100 行难以维护函数/类组织,可写测试
适合任务单次手工操作、简单管道定时自动化、需重试/告警/持久化

经验法则:脚本 30 行以内且一次性使用 → Shell;需要长期维护、会重试/告警/解析结构化数据 → Python。两者也常组合:Shell 管道里用 python3 -c 处理中间数据。

subprocess 详解:run / Popen / shell 安全

subprocess 是 Python 运维脚本的"shell",掌握三个层次即可覆盖绝大多数场景:

函数适用场景关键参数
subprocess.run()执行命令、等待结束、取结果(首选)capture_output, text, check, timeout
subprocess.Popen()持续交互、实时读输出流stdout=PIPE, stderr=STDOUT, stdin=PIPE
subprocess.check_output()只要标准输出,出错即抛异常(Python 3.5+ 推荐用 subprocess.run(capture_output=True) 替代)stderr=subprocess.STDOUT
import subprocess

# run():一次性执行(90% 的场景用这个)
r = subprocess.run(["systemctl", "is-active", "nginx"],
                   capture_output=True, text=True)
print(r.returncode, r.stdout.strip())   # 输出: 0 active

# Popen():实时读日志流(等价于 tail -f 并过滤)
p = subprocess.Popen(["journalctl", "-f", "-u", "nginx"],
                     stdout=subprocess.PIPE, text=True)
for line in p.stdout:
    if "error" in line:
        print("发现错误行:", line.strip())
        break
p.terminate()

# shell=True 的危害演示:命令注入
filename = "foo; rm -rf /tmp/evil"
subprocess.run(f"ls {filename}", shell=True)    # 危险!文件名里的分号会被 shell 执行
subprocess.run(["ls", filename])                # 安全:列表参数不经 shell 解释

shell=True 安全警告:一旦用了 shell=True,字符串里的 ;|>$() 都会被 shell 解释。脚本输入来自外部(文件名、URL 参数、API 返回值)时,必须用列表参数形式让 Python 直接 exec。真需要管道时用 Popen(stdout=PIPE) 串联两个进程,而不是拼 shell 字符串——后者既是安全漏洞也是引号地狱。

为什么用 Python 而不是 Bash 写运维脚本

Bash 脚本在简单任务上无可替代——启动快、管道组合优雅、系统命令直接调用。但当脚本超过 100 行或需要处理复杂逻辑时,Bash 的局限暴露:数据结构缺失——只有字符串和数组,没有字典/集合/对象,解析 JSON 需要 jq 等外部工具;错误处理脆弱——set -e 只能捕获命令返回码,无法区分"命令不存在"和"命令执行失败";跨平台差异——sed -i 在 macOS 和 Linux 上语法不同,readlink -f 在 BSD 上不存在;可测试性差——Bash 函数难以单元测试。Python 标准库统一了这些差异,subprocess 提供安全的命令调用,try/except 提供完整的异常处理链,argparse 自动生成帮助文档。经验法则:脚本 30 行以内且一次性 → Bash;需要长期维护、会重试/告警/解析结构化数据 → Python。

为什么 paramiko 而不是直接 subprocess 调用 ssh

subprocess.run(["ssh", "host", "cmd"]) 调用 SSH 有几个问题:输出解析困难——SSH 的连接信息、警告、错误混在 stdout/stderr 中,难以程序化分离;连接复用困难——每次 subprocess 调用都是独立的 SSH 连接,批量操作 100 台服务器需要建立 100 次 TCP 连接+密钥交换;认证管理复杂——密码认证需要交互式输入或 Expect 脚本,密钥认证需要管理 agent 转发。paramiko 作为纯 Python SSH 库解决了所有问题:原生 API 控制连接生命周期、支持连接复用(SSHClient.get_transport())、内置多种认证方式(密钥/密码/agent)、输出以 file-like 对象返回便于程序化处理。Fabric 是 paramiko 的上层封装,进一步简化了批量操作语法。

示例代码

示例 1:subprocess 命令包装器

#!/usr/bin/env python3
"""命令安全执行包装器 —— 含超时、日志、重试"""
import subprocess, sys, logging

logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")

def run_cmd(cmd, timeout=30, retries=2):
    for attempt in range(1, retries + 2):
        try:
            r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, check=True)
            logging.info("OK: %s", " ".join(cmd))
            return r.stdout.strip()
        except subprocess.TimeoutExpired:
            logging.warning("超时 (attempt %d): %s", attempt, cmd)
        except subprocess.CalledProcessError as e:
            logging.error("失败 (attempt %d): %s\n%s", attempt, e, e.stderr)
        if attempt <= retries:
            import time; time.sleep(2 ** attempt)
    sys.exit(1)

if __name__ == "__main__":
    print(run_cmd(["df", "-h"]))  # 输出: Filesystem      Size  Used Avail Use% Mounted on ...

示例 2:paramiko SSH 批量命令

#!/usr/bin/env python3
"""批量 SSH 执行命令,收集结果"""
import paramiko, argparse
from concurrent.futures import ThreadPoolExecutor

def ssh_run(host, cmd, key_path="~/.ssh/id_ed25519"):
    client = paramiko.SSHClient()
    client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
    client.connect(host, username="root", key_filename=key_path, timeout=10)
    _, stdout, stderr = client.exec_command(cmd, timeout=15)
    rc = stdout.channel.recv_exit_status()
    out = stdout.read().decode().strip()
    err = stderr.read().decode().strip()
    client.close()
    return host, rc, out, err

def main():
    ap = argparse.ArgumentParser(description="批量 SSH 执行命令")
    ap.add_argument("hosts", nargs="+", help="目标主机列表")
    ap.add_argument("-c", "--cmd", default="uptime", help="要执行的命令")
    ap.add_argument("-k", "--key", default="~/.ssh/id_ed25519", help="SSH 密钥路径")
    args = ap.parse_args()

    with ThreadPoolExecutor(max_workers=8) as pool:
        futures = [pool.submit(ssh_run, h, args.cmd, args.key) for h in args.hosts]
        for f in futures:
            host, rc, out, err = f.result()
            status = "OK" if rc == 0 else "FAIL"
            print(f"[{status}] {host}: {out or err}")  # 输出: [OK] 10.0.0.1: 09:30:00 up 10 days

if __name__ == "__main__":
    main()

示例 3:psutil 系统健康检查与告警

#!/usr/bin/env python3
"""采集系统指标,超阈值时输出 JSON 告警"""
import psutil, json, socket, logging
from datetime import datetime

logging.basicConfig(level=logging.WARNING, format="%(message)s")
alerts = []
host = socket.gethostname()
now = datetime.now().isoformat()

def check(metric, value, warn, crit):
    if value >= crit:
        alerts.append({"level": "CRITICAL", "host": host, "metric": metric, "value": value, "threshold": crit, "time": now})
    elif value >= warn:
        alerts.append({"level": "WARNING", "host": host, "metric": metric, "value": value, "threshold": warn, "time": now})

check("cpu_percent", psutil.cpu_percent(interval=2), 70, 90)
check("mem_percent", psutil.virtual_memory().percent, 80, 95)
check("disk_root_percent", psutil.disk_usage("/").percent, 80, 92)
check("disk_data_percent", psutil.disk_usage("/data").percent, 85, 95)

if alerts:
    for a in alerts:
        logging.warning(json.dumps(a))  # 输出: {"level": "WARNING", "host": "...", "metric": "cpu_percent", ...}
else:
    print(json.dumps({"level": "OK", "host": host, "time": now}))  # 输出: {"level": "OK", "host": "...", "time": "..."}

示例 4:日志分析脚本(正则 + 统计)

#!/usr/bin/env python3
"""Nginx 访问日志分析:状态码分布、TOP IP、总请求量"""
import re, sys, collections

LOG_PAT = re.compile(
    r'^(?P<ip>[\d.]+) .*? \[(?P<time>[^\]]+)\] '
    r'"(?P<req>[^"]*)" (?P<status>\d{3}) (?P<size>\d+) '
    r'"(?P<ref>[^"]*)" "(?P<ua>[^"]*)"'
)

def analyze(path):
    statuses, ips = collections.Counter(), collections.Counter()
    total = 0
    with open(path, errors="replace") as f:      # errors="replace" 容忍日志中的乱码字节
        for line in f:
            m = LOG_PAT.match(line)
            if not m:
                continue
            total += 1
            statuses[m.group("status")] += 1
            ips[m.group("ip")] += 1
    print(f"总请求数: {total}")
    print("状态码分布:", dict(statuses))
    print("TOP 5 IP:")
    for ip, c in ips.most_common(5):
        print(f"  {ip}: {c}")

if __name__ == "__main__":
    analyze(sys.argv[1] if len(sys.argv) > 1 else "/var/log/nginx/access.log")
    # 输出: 总请求数: 123456
    # 输出: 状态码分布: {'200': 118000, '404': 3456, '500': 12, ...}
    # 输出: TOP 5 IP: 1.2.3.4: 89012 ...

示例 5:批量重命名脚本(干跑模式)

#!/usr/bin/env python3
"""批量重命名:backup_20260101.tar.gz → 20260101.tar.gz
--dry-run 先预览,确认无误再去掉参数真正执行"""
import os, re, sys, argparse

def main():
    ap = argparse.ArgumentParser(description="批量重命名备份文件")
    ap.add_argument("dir", help="目标目录")
    ap.add_argument("--dry-run", action="store_true", help="只预览不执行")
    ap.add_argument("--pattern", default=r"^backup_(\d{8})\.tar\.gz$",
                    help="匹配正则,捕获组用于拼新文件名")
    args = ap.parse_args()

    renamed = 0
    for name in sorted(os.listdir(args.dir)):
        m = re.match(args.pattern, name)
        if not m:
            continue
        new_name = f"{m.group(1)}.tar.gz"
        old, new = os.path.join(args.dir, name), os.path.join(args.dir, new_name)
        if os.path.exists(new):
            print(f"跳过(目标已存在): {name} -> {new_name}")
            continue
        if args.dry_run:
            print(f"[DRY-RUN] {name} -> {new_name}")
        else:
            os.rename(old, new)
            print(f"已重命名: {name} -> {new_name}")
        renamed += 1
    print(f"共处理 {renamed} 个文件")

if __name__ == "__main__":
    main()

# 用法: ./rename.py /data/backups --dry-run
# 输出: [DRY-RUN] backup_20260101.tar.gz -> 20260101.tar.gz ...

示例 6:服务器健康检查脚本(巡检 + 告警推送)

#!/usr/bin/env python3
"""巡检脚本:检查服务、端口、磁盘、负载,异常时推送到企业微信 webhook"""
import json, subprocess, socket, datetime
import psutil, requests

WEBHOOK = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=XXXX"
HOST = socket.gethostname()

def check_service(name):
    r = subprocess.run(["systemctl", "is-active", name],
                       capture_output=True, text=True)
    return r.stdout.strip() == "active"

def check_port(port, host="127.0.0.1"):
    with socket.socket() as s:
        s.settimeout(2)
        return s.connect_ex((host, port)) == 0

def main():
    problems = []
    for svc in ["nginx", "mysql", "redis-server"]:
        if not check_service(svc):
            problems.append(f"服务 {svc} 不在运行")
    if not check_port(6379):
        problems.append("Redis 端口 6379 不通")
    load = psutil.getloadavg()
    if load[0] > 4:
        problems.append(f"负载过高: {load[0]:.2f}")
    disk = psutil.disk_usage("/")
    if disk.percent > 85:
        problems.append(f"磁盘 / 使用率 {disk.percent}%")

    now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    if problems:
        msg = {"msgtype": "text",
               "text": {"content": f"[{HOST}] {now}\n" + "\n".join(problems)}}
        requests.post(WEBHOOK, json=msg, timeout=5)   # 输出: 异常项推送成功
        print("异常项已推送:", problems)
    else:
        print(f"[{now}] {HOST} 健康检查通过")   # 输出: [2026-07-31 09:00:00] web-01 健康检查通过

if __name__ == "__main__":
    main()

定时运行:cron 与 systemd timer

巡检脚本要定时跑,两个坑必须先处理:cron 的 PATH 不包含 /usr/local/bin,python3 必须写绝对路径;输出必须重定向,否则脚本里 print 的告警无人看到。

# crontab -e
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
# 每 5 分钟健康检查,输出追加到日志
*/5 * * * * /usr/bin/python3 /opt/scripts/health_check.py >> /var/log/health_check.log 2>&1
# 每天 3 点跑日志分析
0 3 * * * /usr/bin/python3 /opt/scripts/log_analyzer.py >> /var/log/log_analyzer.log 2>&1
# 更推荐的 systemd timer 方式(输出进 journalctl,天然统一管理)
# /etc/systemd/system/health-check.service
[Unit]
Description=Health Check Script

[Service]
Type=oneshot
ExecStart=/usr/bin/python3 /opt/scripts/health_check.py

# /etc/systemd/system/health-check.timer
[Unit]
Description=Run health check every 5 minutes

[Timer]
OnCalendar=*:0/5
Persistent=true

[Install]
WantedBy=timers.target

# 启用并验证
sudo systemctl daemon-reload
sudo systemctl enable --now health-check.timer
# 输出: Created symlink /etc/systemd/system/timers.target.wants/health-check.timer
sudo systemctl list-timers health-check    # 输出: NEXT 09:05:00 5min left health-check.timer
journalctl -u health-check.service -S "10 min ago"   # 查看上次执行输出

常见错误

错误原因解决
FileNotFoundErrorsubprocess 调了不存在的命令(如 shell=True 但 PATH 不对)shutil.which() 检查或写全路径
AuthenticationExceptionparamiko 密钥路径不对或未加载 agent显式指定 key_filename,或先 ssh-add
TimeoutExpired远程命令执行太久未返回设置 timeout 参数,配合重试逻辑
PermissionErrorpsutil 查某些进程信息需 root检查是否用 sudo 运行
ModuleNotFoundError未安装依赖或未激活虚拟环境pip install -r requirements.txt 或激活 venv
json.decoder.JSONDecodeErrorAPI 返回非 JSON(如 502/503)先检查 resp.status_code,再 resp.json()

最佳实践

实践说明
始终用 capture_output=True, text=Truesubprocess 默认输出 bytes,转文本统一处理
优先 check=True 或主动检查 returncode避免静默失败
SSH 连接放入 with 或 try/finally确保用完关闭,防止连接泄漏
敏感信息走环境变量或 python-dotenv切忌密码/密钥硬编码
每个脚本提供 --help(argparse)降低使用门槛
结构化日志用 logging 而非 print便于后续对接日志中心
用 Poetry / virtualenv 隔离环境防止全局 pip 污染,方便 CI/CD 复现
脚本入口用 if __name__ == "__main__"让模块可测试、可 import

练习题

  1. 日志清理器:用 subprocess 找到 /var/log 下 7 天前的 *.log 文件并压缩(gzip),保留原始文件列表到 JSON。
  2. 端口扫描器:用 socket + argparse 批量扫描指定主机的 22/80/443/3306/6379 端口,输出开放列表。
  3. 批量 SSH 磁盘检查:用 paramiko 对一批服务器执行 df -h,解析结果,打印出磁盘使用率超过 85% 的挂载点。
  4. 进程树观察者:用 psutil 每 10 秒采集 CPU top-5 进程,当某个进程连续 3 次 CPU > 80% 时发出警告。
  5. Poetry 项目打包:用 poetry new myops 创建项目,将练习 3 的脚本放入,加依赖 paramiko,编写对应单元测试。
点击查看答案
  1. subprocess.run(["find", "/var/log", "-name", "*.log", "-mtime", "+7", "-type", "f"], capture_output=True, text=True) 获取文件列表,subprocess.run(["gzip", f]) 逐个压缩,将原始文件路径写入 JSON。
  2. argparse 定义主机和端口参数;socket.connect_ex() 返回 0 表示端口开放。批量扫描后打印 host:port OPEN 列表。
  3. paramiko 连接每台主机执行 df -h,解析输出中 Use% 列,打印超过 85% 的挂载点:if int(line.split()[-2].strip('%')) > 85: print(...)
  4. psutil 每 10 秒取 psutil.process_iter() 按 CPU 排序取 top 5。用 deque 记录每个进程最近 3 次 CPU%,若均 > 80% 则 logging.warning() 发出告警。
  5. poetry new myops && cd myops && poetry add paramiko。将批量 SSH 脚本放入 myops/ 目录,编写 tests/test_ssh.py 用 mock 测试 SSH 连接逻辑。运行 poetry run pytest 验证。

学习检查点

学完本章后,请检验自己是否掌握以下内容:

检查项自测问题验证方法
概念理解能用自己的话解释 Python 在运维自动化中的优势尝试向他人讲解
命令操作能不查文档完成 Python 脚本编写、库安装、调试执行在终端实际执行
原理掌握能说出 Python 的异常处理、文件操作、进程管理机制画出流程图
故障排查能独立排查 Python 脚本语法错误、依赖问题、权限错误模拟故障并修复
最佳实践能说明为什么需要为 Python 脚本添加日志记录和配置管理对比不同方案

本章总结

速查表

任务类型推荐库一句话用法
命令行参数argparseap.add_argument()ap.parse_args()
执行系统命令subprocesssubprocess.run(cmd, capture_output=True, check=True)
HTTP APIrequestsrequests.get/post(url, headers=..., json=...)
SSH 远程paramiko / FabricSSHClient().connect()exec_command(cmd)
系统指标采集psutilpsutil.cpu_percent() / virtual_memory() / disk_usage('/')
日志输出logginglogging.basicConfig()logging.info/warning/error()
环境隔离Poetry / venvpoetry add <pkg> / python -m venv .venv

学习路径2.1:Shell 脚本入门/2.8:Shell 脚本进阶 Shell 基础 → 本章 Python 运维脚本 → 4.1:Ansible 自动化 Ansible(更高层抽象)→ 4.9:CI/CD 持续部署 CI/CD 集成。

延伸阅读

常见问题

Python 运维脚本怎么处理命令行参数?
标准库 argparse 是最可靠的方式:import argparse; parser.add_argument('--host', required=True); args = parser.parse_args()。简单脚本可用 sys.argv,复杂工具建议用 click 库(装饰器风格,自动生成 --help)。argparse 内置类型检查、选择列表、子命令支持。
paramiko 和 Fabric 怎么选?
paramiko 是 SSHv2 协议的底层实现,需要手动处理连接、认证、命令执行、输出读取。Fabric 在 paramiko 之上封装了更友好的 API:from fabric import Connection; c = Connection('host'); c.run('uptime')。简单的远程执行用 Fabric,需要细粒度 SSH 控制则用 paramiko。
Python 脚本打包和依赖管理用什么?
个人运维脚本用 pip install -r requirements.txt + venv 虚拟环境即可。需要分发的工具建议用 Poetry(pyproject.toml 管理依赖/构建/发布)或 PyInstaller 打包为单文件 exe。避免用 pip install --user 全局安装,不同项目依赖冲突时很麻烦。
↑ 回到顶部