如果你在运维、测试或者开发岗位待过一段时间,一定有过这种体验:每天重复敲同样的命令、检查同样的日志、备份同样目录、批量登录服务器做同样的事情。一开始还能忍,等服务器数量过了十台、二十台,手工操作就成了纯粹的体力活,不仅累,还容易漏。
我自己的转折点是有一次需要在几十台服务器上同步修改一个配置项,用 Shell 写循环勉强能做,但一涉及远程连接、异常重试、结果汇总,Shell 脚本就越写越别扭。后来我用 Python 重写了整个流程,几十行代码搞定,还顺手加了失败重试和结果报表。从那以后,我就把 Python 当成了做自动化运维的第一选择。
这篇内容就是写给那些想从零开始用 Python 解决实际运维问题的人。我不会讲太多语法基础,而是直接把核心库和三个最典型的实战场景拆开揉碎,讲清楚每一步为什么这么做。你不需要提前成为 Python 高手,只需要跟着场景走一遍,就能建立起自己的工具库。内容适合运维工程师、测试工程师、后端开发,也适合刚学完 Python 基础但不知道能做什么的初学者。
1. 为什么用 Python 做自动化开发:选型与学习路线的思考
先聊一个很多人会纠结的问题:自动化脚本到底该用 Shell、Perl、Go 还是 Python?
Shell 当然是运维的基本功,处理文件和简单命令非常快。但它的弱点也明显:复杂逻辑写起来难受、跨平台能力差、对数据结构的支持弱。当你需要处理 JSON、写多线程并发、做正则匹配复杂文本、对接 API 的时候,Shell 的代码量会迅速膨胀,维护成本直线上升。
Python 在这类场景里的优势是生态和开发效率。它自带的功能模块覆盖了文件操作、网络请求、数据解析、并发处理这些高频需求,第三方库更是应有尽有。做远程服务器操作有 paramiko,做 HTTP 请求有 requests,做数据清洗有 pandas,几乎没有什么是需要从零开始造的轮子。
但这并不意味着你要把 Python 的所有语法都学完才开始干活。我见过不少人买了一本几百页的书,从第一章看到第十章,看到面向对象就看不下去了,然后就没有然后了。我的建议是换个思路:直接带着问题学。先跑通一个最小案例,遇到不懂的语法再查再补。这样学到的每个知识点都是带着场景记忆的,比单纯看书牢靠得多。
举个例子,你不需要先精通装饰器才能写自动化脚本,但你得先学会打开文件、读取内容、正则搜索、远程执行命令。这些东西半天就能上手,剩下的都是查文档的事。
学习路线上我个人推荐这样走:先掌握基础数据结构(字符串、列表、字典)、文件读写、异常处理、函数定义,这五个基本功够你用很久;然后进入核心库阶段,重点掌握 os、shutil、subprocess、re、json 这几个模块;接着进入场景实战,把 paramiko、requests 这类第三方库用起来。三个步骤走完,你已经有能力解决真实世界里的自动化问题了。
2. 核心库速览:先把这些高频武器认全
在进入实战之前,先把高频用到的几个库过一遍。这不是系统性的参考手册,而是从运维开发角度挑出来的重点:每个库解决什么问题、最常见的坑是什么。
2.1 文件与目录操作:os 与 shutil
os 模块是 Python 操作文件和目录的基础。你写自动化脚本时,几乎每个脚本都会用到它。最常用的几个功能包括:拼接路径、判断文件是否存在、创建目录、遍历目录、获取文件大小和修改时间。
import os # 拼接路径,不要在 Windows 上手工拼反斜杠 log_dir = os.path.join("var", "log", "app") if not os.path.exists(log_dir): os.makedirs(log_dir, exist_ok=True) # 遍历目录下的所有文件 for root, dirs, files in os.walk("/home/app/logs"): for name in files: if name.endswith(".log"): full_path = os.path.join(root, name) size = os.path.getsize(full_path) print(full_path, size)shutil 是 os 模块的加强版,主要用来处理文件和目录的复制、移动、归档。做备份脚本时非常实用。
import shutil # 复制单个文件并保留元数据 shutil.copy2("app.py", "backup/app.py") # 递归复制整个目录 shutil.copytree("config", "backup/config", dirs_exist_ok=True) # 压缩归档 shutil.make_archive("backup_20250101", "zip", root_dir="/home/app")在实际场景里,我经常用这两个模块组合做增量备份定位:先遍历文件,比较修改时间,然后把新增或变更的文件拷贝过去。逻辑非常简单,但比用 Shell 的 cp 命令灵活得多。
2.2 执行本地命令:subprocess
subprocess 是 Python 执行本地 Shell 命令的标准方式。早期很多人用 os.system,但 os.system 拿不到命令输出,也没法精细控制参数,生产环境还是用 subprocess 更靠谱。
import subprocess # 执行命令并把输出捕获到变量 result = subprocess.run( ["df", "-h"], capture_output=True, text=True, timeout=10, ) print(result.stdout) print(result.returncode)这里有几个容易踩的坑。第一是 shell=True 和字符串参数的问题:如果命令包含管道符、通配符、环境变量,就需要加上 shell=True,但前提是你对命令内容有绝对控制,否则会有注入风险。第二是超时设置:某些命令可能因为资源问题卡住不返回,不加 timeout 你的脚本就会挂在那里。第三是 capture_output=True 才能拿到输出,但注意 stdout 和 stderr 要分开看,很多排查问题都靠区分这两个输出。
2.3 远程操作服务器:paramiko
paramiko 是 Python 操作 SSH 协议的第三方库,只要你能 SSH 到的地方,它基本都能管。批量运维的核心大多建立在它之上。
import paramiko client = paramiko.SSHClient() client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) # 连接服务器 client.connect( hostname="192.168.1.10", port=22, username="ops", password="your_password", timeout=10, ) # 执行命令 stdin, stdout, stderr = client.exec_command("uptime") print(stdout.read().decode()) client.close()生产环境里,我强烈建议用密钥认证而不是密码认证。密钥不在代码里出现,安全性高一个量级。另外 exec_command 默认不能直接执行需要交互的命令,比如 sudo 可能要密码,这种情况下需要用 transport 通道配合 invoke_shell,后面实战部分我会细说。
2.4 文本处理:re 正则表达式
正则表达式是做日志分析、配置解析绕不开的工具。Python 的 re 模块用法稳定,核心就几个函数。
import re log_msg = "ERROR: disk usage is 95% (partition /dev/vda1)" m = re.search(r"(\d+)%", log_msg) if m: percent = int(m.group(1)) print(f"磁盘使用率 {percent}%")正则容易写复杂,但运维场景里常用的大多是简单模式匹配。我自己的习惯是,能用字符串方法的尽量用字符串方法,比如 startswith、find、split,只有需要模糊匹配时才上正则。这样代码可读性好很多,也少了很多转义符号带来的麻烦。
2.5 数据交互格式:json 与 yaml
脚本之间传数据、写配置、做报表,最常见的格式就是 JSON 和 YAML。运维场景里你可能要先写 JSON 配置文件然后再解析。
import json cfg = { "hosts": ["192.168.1.10", "192.168.1.11"], "timeout": 10, } # 写入 JSON 文件 with open("config.json", "w", encoding="utf-8") as f: json.dump(cfg, f, ensure_ascii=False, indent=2) # 读取 JSON 文件 with open("config.json", "r", encoding="utf-8") as f: data = json.load(f)JSON 的坑主要在于:中文写文件时如果不用 ensure_ascii=False,会被转成 \uXXXX 形式的 Unicode 码,阅读体验很差;写文件时如果不指定 encoding="utf-8",在 Windows 上默认可能是 GBK,会导致乱码。这个坑我踩过不止一次。
2.6 日志与异常处理
脚本跑起来之后,记录运行日志和捕获异常,直接决定了你排查问题的时候是十分钟能解决还是半小时起步。
import logging logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", filename="auto_task.log", ) logger = logging.getLogger() try: result = 10 / 0 except ZeroDivisionError as e: logger.error("发生错误: %s", e)日志是自动化运维中的刚需。脚本跑完看一眼日志就知道哪里出了岔子,远胜过在终端里反复打印调试信息。异常处理要重点了解 try-except-finally 的结构,把它当成兜底网,不要让任何一次异常把整个任务打崩。
3. 实战场景一:批量服务器信息采集与巡检
这个场景可以说是自动化运维的入门经典:统一采集一批服务器的 CPU、内存、磁盘、进程状态,汇总成报告。手工做法是登录每台机器敲命令,机器一多就非常痛苦,而且容易漏看关键指标。
3.1 需求拆解与设计思路
先把这个需求拆成四个子任务:
- 定义服务器清单:哪些 IP、用户名、密钥文件、角色是什么
- 远程执行命令:获取 uptime、free、df、进程等关键信息
- 并发控制:不能一台一台串行跑,太慢;也不能一次并发太多,把目标机器压垮
- 结果收集与展示:把结果汇总到一个可读的格式(表格/JSON/文本报告),并标出异常项
设计思路上有几个关键的选型判断。
第一,服务器清单用什么格式存?我推荐用 YAML 或 JSON 配置文件,而不是直接硬编码在脚本里。这样加机器、改分组,都不用动代码。
第二,并发用什么方案?标准库的 concurrent.futures.ThreadPoolExecutor 就够了,不需要上复杂的异步框架。SSH 连接本身是 IO 密集型操作,用线程就够了,每个线程里建立一条连接执行几条命令,效率已经非常可观。
第三,结果怎么存?我习惯用 JSON 保留原始数据,再用一个简单的渲染函数生成人读的文本报告。JSON 方便后续接别的系统,文本报告直接发群、发邮件都行。
3.2 服务器清单与主脚本框架
服务器清单用 YAML 写会比较清爽:
servers: - host: 192.168.1.10 name: web01 role: web user: ops key_file: ~/.ssh/id_rsa - host: 192.168.1.11 name: db01 role: database user: ops key_file: ~/.ssh/id_rsa主脚本从配置文件读取清单,逐个发起 SSH 连接,执行一组预定义命令,把 stdout 和 stderr 分别记录下来。这里有个细节:命令建议做成列表,按顺序执行,每条命令打一个标签,方便后面解析结果。
import argparse import json import os import yaml import paramiko from concurrent.futures import ThreadPoolExecutor, as_completed COMMANDS = [ ("uptime", "uptime"), ("memory", "free -m"), ("disk", "df -h"), ("cpu_model", "grep 'model name' /proc/cpuinfo | head -n 1"), ("running_process", "ps -ef | wc -l"), ] def collect_one(server): result = {"host": server["host"], "name": server.get("name", ""), "data": {}} try: client = paramiko.SSHClient() client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) client.connect( hostname=server["host"], username=server["user"], key_filename=os.path.expanduser(server["key_file"]), timeout=10, ) for tag, cmd in COMMANDS: stdin, stdout, stderr = client.exec_command(cmd, timeout=15) out = stdout.read().decode(errors="ignore").strip() err = stderr.read().decode(errors="ignore").strip() result["data"][tag] = {"stdout": out, "stderr": err} client.close() except Exception as e: result["error"] = str(e) return result这一步的每个细节都有它的道理。timeout 必须设置,避免某台机器网络异常导致整个任务卡死在等待上;decode 的时候加上 errors="ignore",可以防止个别非 UTF-8 字符让整个输出解析失败;key_filename 用 os.path.expanduser 展开,才能正确处理波浪号路径。
3.3 并发采集线程池的使用与边界控制
并发采集是性能提升的关键,但并发数控制不好容易出事。线程池的写法很简单,关键是你得想清楚 max_workers 设多少合适。
def main(): with open("servers.yaml", "r", encoding="utf-8") as f: config = yaml.safe_load(f) servers = config["servers"] results = [] max_workers = 10 with ThreadPoolExecutor(max_workers=max_workers) as executor: future_map = {executor.submit(collect_one, s): s for s in servers} for future in as_completed(future_map): server = future_map[future] try: res = future.result() results.append(res) status = "OK" if "error" not in res else "FAIL" print(f"[{status}] {server['host']} 采集完成") except Exception as e: print(f"[FAIL] {server['host']} 异常: {e}") with open("collect_report.json", "w", encoding="utf-8") as f: json.dump(results, f, ensure_ascii=False, indent=2)并发数从 5 到 20 都有不少人用过。我个人的经验是:如果是几百台服务器以内的规模,10 到 15 线程是一个稳妥区间;如果目标机器配置较差,比如只是一台 1C2G 的小云主机,并发太猛可能把机器的 SSH 服务都压得响应变慢。稳妥的做法是把 max_workers 做成配置项,根据实际环境调节。另外 as_completed 是边完成边处理,不是等全部完成再统一拿结果,这样能及时发现异常机器,也方便打日志。
3.4 结果汇总与异常标记
采集完成只是第一步,怎么把原始输出变成“一眼就能看出问题”的巡检报告,才是真正体现价值的地方。我一般是先解析纯文本输出,提取关键数值,再设置阈值做异常判断。
拿磁盘举例,df -h 的输出是一个表格,你需要把使用率那一列的百分数取出来。这一步建议用一个简单的正则采样,不要试图把整张表解析成完美结构,够用就行。
def parse_disk_usage(df_output): lines = df_output.strip().split("\n") if len(lines) < 2: return [] records = [] for line in lines[1:]: parts = line.split() if len(parts) >= 5 and "%" in parts[4]: records.append({ "mount": parts[5], "usage": int(parts[4].replace("%", "")), }) return records ALERT_DISK_USAGE = 85 for r in results: disk_records = parse_disk_usage(r["data"].get("disk", {}).get("stdout", "")) alerts = [d for d in disk_records if d["usage"] >= ALERT_DISK_USAGE] if alerts: r["alerts"] = [f"{a['mount']} 磁盘使用 {a['usage']}%" for a in alerts]这一段逻辑虽然简单,但解决了实际的巡检痛点:不是所有数据都要人工盯,只有超阈值的东西才需要关注。报告的逻辑应该是“默认正常,异常单独标记”,而不是把所有原始信息堆上去让读者自己翻。
最后输出的时候,我会同时存 JSON 原始数据和一份 Markdown 摘要。Markdown 摘要可以直接贴在内部文档或者发到群里,保持信息紧凑。
4. 实战场景二:日志监控与告警脚本
日志监控是另一个高频需求。生产环境里服务会出各种问题:磁盘满了、接口超时、数据库连接池耗尽、报错堆栈刷屏。靠人去翻日志根本不现实,你需要的是一个能自动盯日志、命中规则就发告警的小工具。
4.1 需求拆解与方案选择
日志告警涉及三个子问题:
- 日志从哪来:本地文件读取,还是要跑到远端去抓
- 怎么判断异常:简单关键字匹配,还是复杂正则规则
- 告警怎么发:本地输出,还是发到 IM 机器人 Webhook
这里我先把方案定位在“轻量级轮询”而不是复杂日志平台。复杂日志平台有专门的开源方案和商业产品,但很多中小团队的需求其实没那么庞大,一个几百行的 Python 脚本反而更接地气、更好维护。
轮询方案的核心思路是:每隔一段时间检查日志文件有没有新内容出现,如果有,就按配置好的正则规则逐条匹配,命中后进入告警队列,最后统一推送。
4.2 增量读取与文件游标管理
文件监控最大的坑是重复读取。你如果每次都从头扫描整个文件,日志一大了效率就是灾难。正确做法是用 seek 记住上次读到的文件位置,下次只读新增的部分。
import os import time from pathlib import Path LOG_FILE = "/home/app/logs/app.log" CURSOR_FILE = "/tmp/log_cursor.txt" def get_last_position(): if os.path.exists(CURSOR_FILE): with open(CURSOR_FILE, "r", encoding="utf-8") as f: content = f.read().strip() if content.isdigit(): return int(content) return 0 def set_last_position(pos): with open(CURSOR_FILE, "w", encoding="utf-8") as f: f.write(str(pos)) def tail_new_lines(): pos = get_last_position() file_size = os.path.getsize(LOG_FILE) if file_size < pos: # 文件可能被截断/轮转,从头开始 pos = 0 with open(LOG_FILE, "r", encoding="utf-8", errors="ignore") as f: f.seek(pos) new_lines = f.readlines() set_last_position(f.tell()) return new_lines这里我处理了一个轮转场景:日志文件被 logrotate 或人工清空后,新文件的大小小于旧游标位置,此时直接 seek 到旧位置会失败或读不到有效内容,所以要做一个回退处理,从头开始读。这个细节不处理,脚本运行几天后就会“失明”。
4.3 定义告警规则与命中检测
告警规则我用 JSON 配置来管理,每一条规则包含名称、匹配模式、级别和说明。
{ "rules": [ { "name": "磁盘满", "pattern": "No space left on device", "level": "critical", "description": "磁盘空间不足" }, { "name": "慢查询", "pattern": "slow query.*\\d+s", "level": "warning", "description": "SQL 执行超时" }, { "name": "OOM", "pattern": "OutOfMemory|Out of memory", "level": "critical", "description": "内存溢出" } ] }检测逻辑就是遍历新行,依次编译并匹配所有规则。这里有两个性能建议:预编译正则,不要每行都重新 compile;规则数量控制在合理范围内,太多规则反而难以维护。
import json import re def load_rules(path): with open(path, "r", encoding="utf-8") as f: data = json.load(f) rules = [] for item in data["rules"]: rules.append({ "name": item["name"], "pattern": re.compile(item["pattern"]), "level": item["level"], "description": item["description"], }) return rules def match_rules(lines, rules): alerts = [] for line in lines: if not line.strip(): continue for rule in rules: if rule["pattern"].search(line): alerts.append({ "rule": rule["name"], "level": rule["level"], "description": rule["description"], "content": line.strip()[:200], "time": time.strftime("%Y-%m-%d %H:%M:%S"), }) break return alerts正则规则命中后要及时跳出循环,避免一条日志同时命中多条规则时产生重复告警。实际使用中,一条日志只归入最先命中的规则,这个处理逻辑可以减少告警噪音。
4.4 告警聚合与 Webhook 推送
告警不是越多越好,告警风暴是所有监控系统的大忌。如果一分钟内同一个规则连续命中 50 次,你发 50 条消息,群里很快就没人看了。所以我加了一个聚合机制:相同规则在聚合窗口期(比如 5 分钟)内只推一次,内容里带上命中次数和最近一条日志摘要。
import requests class AlertAggregator: def __init__(self, window=300): self.window = window self.buffer = {} def add(self, alert): key = (alert["rule"], alert["level"]) now = time.time() if key not in self.buffer: self.buffer[key] = {"count": 0, "first": alert, "last_ts": now} item = self.buffer[key] item["count"] += 1 item["last_ts"] = now item["first"]["content"] = alert["content"] def flush(self, webhook_url): now = time.time() for key in list(self.buffer): item = self.buffer[key] if now - item["last_ts"] >= self.window or item["count"] >= 20: payload = { "msg_type": "text", "content": f"[{item['first']['level'].upper()}] " f"{item['first']['rule']} 在 {self.window}s 内命中 {item['count']} 次\n" f"最近日志: {item['first']['content']}" } try: requests.post(webhook_url, json=payload, timeout=5) except Exception as e: print(f"推送失败: {e}") del self.buffer[key]推送这块,建议不要用自建 SMTP,而是用现成的 IM 机器人 Webhook。只要有一个 HTTP 接口,把 JSON POST 过去就行。超时和失败重试必须加,否则网络抖动可能导致告警丢失。
实际使用中还要考虑告警自恢复:命中一次关键规则后,如果之后几分钟内不再命中,可以补发一条“已恢复”的消息。这个功能对值班人非常重要,能减少大量不必要的确认工作。
5. 实战场景三:自动化发布与回滚脚本
发布上线是运维里风险最高的操作之一。无脑把代码传上去执行重启,出问题再手工回滚,不仅慢而且容易错。用 Python 做发布脚本,核心目标不是“花哨”,而是流程可预期、每一步可回溯、出问题能快速恢复。
5.1 发布流程拆解
一个典型的单机/多机发布流程,我习惯拆成五步:
- 备份:把当前运行目录打包,带着时间戳存下来
- 传输:把新的代码包传到目标服务器
- 校验:比对文件大小或者 MD5,确认传输完整
- 执行:停服务、解压/替换文件、启动服务、检查进程状态
- 回滚:如果启动失败或健康检查不过,立刻恢复备份
整体设计上要支持两种触发方式:本地直接跑脚本一键发布,以及指定某个历史版本号做回滚。回滚和发布共用一套备份数据,这让运维同学在出问题时不会手忙脚乱。
5.2 备份与远程传输实现
这里继续用 paramiko,但我也要介绍一下 SFTP 的用法。备份的本质就是把远端目录打成 tar.gz,然后拉回到本地或直接保留在远端。
import os import tarfile import time import paramiko def make_backup(client, remote_dir, backup_root): ts = time.strftime("%Y%m%d_%H%M%S") backup_name = f"{os.path.basename(remote_dir)}_{ts}.tar.gz" backup_path = os.path.join(backup_root, backup_name) # 远端执行 tar 命令,排除日志和缓存目录 cmd = f"tar czf {backup_path} -C {os.path.dirname(remote_dir)} {os.path.basename(remote_dir)} --exclude=logs --exclude=cache" stdin, stdout, stderr = client.exec_command(cmd, timeout=60) code = stdout.channel.recv_exit_status() if code != 0: err = stderr.read().decode(errors="ignore") raise RuntimeError(f"打包备份失败: {err}") return backup_path传输文件用 SFTP 比直接执行 cat 重定向靠谱,自带断点和管理能力谈不上,但至少 API 简洁、错误可控。
def upload_file(client, local_path, remote_path): sftp = client.open_sftp() try: sftp.put(local_path, remote_path) finally: sftp.close()上传之后必须做完整性校验。校验方式有两种:简单看文件大小,严格用 MD5。我建议至少用 MD5,成本很低但能拦住大部分网络传输损坏的问题。
import hashlib def local_md5(path): h = hashlib.md5() with open(path, "rb") as f: for chunk in iter(lambda: f.read(8192), b""): h.update(chunk) return h.hexdigest() def remote_md5(client, path): stdin, stdout, stderr = client.exec_command(f"md5sum {path}") data = stdout.read().decode().strip().split() return data[0]把本地和远端的 MD5 对比一下,一致才继续,否则直接中止并报警。这一步会显著降低“传了个损坏包上线”的概率。
5.3 远程执行与健康检查
发布过程中绕不开重启服务这一步。我习惯把远程执行命令封装成一个函数,统一处理超时、退出码判断和输出记录。
def remote_exec(client, command, timeout=30): stdin, stdout, stderr = client.exec_command(command, timeout=timeout) code = stdout.channel.recv_exit_status() out = stdout.read().decode(errors="ignore") err = stderr.read().decode(errors="ignore") if code != 0: logger.error(f"命令失败: {command}\n{err}") return code, out, err重启服务不是执行完就完事了,必须做健康检查。最简单的办法是执行完 start 命令后,sleep 几秒,然后检查进程是否存在、端口是否监听、对应的健康检查 URL 是否返回预期状态码。这三个层面的检查一个比一个严格,建议至少做到端口和进程两层检查。
def health_check(client, port, url=None): # 检查端口监听 cmd_port = f"ss -tlnp | grep -E ':{port}\\s' | wc -l" code, out, _ = remote_exec(client, cmd_port) if int(out.strip()) == 0: return False if url: cmd_http = f"curl -s -o /dev/null -w '%{{http_code}}' --max-time 5 {url}" code, out, _ = remote_exec(client, cmd_http) if out.strip() != "200": return False return True这里有一点要注意:curl 命令在远端执行时,建议加 --max-time,否则服务假死时 curl 也可能一直挂着。健康检查失败后不要马上回滚,先重试一两次,因为服务启动几秒内可能处于半就绪状态。
5.4 回滚机制与发布确认
回滚的逻辑要简单粗暴:把备份包解压回原目录,恢复文件权限,重启服务,再做一次同样的健康检查。这个流程和发布流程高度重合,所以做发布脚本时我建议直接复用相同的函数。
def rollback_to_backup(client, backup_path, remote_dir): # 恢复备份 cmd = f"cd /tmp && tar xzf {backup_path} -C / && cd {remote_dir} && chown -R app:app ." code, out, err = remote_exec(client, cmd, timeout=60) if code != 0: raise RuntimeError(f"回滚失败: {err}") # 重启服务 remote_exec(client, "systemctl restart myapp") time.sleep(5) return health_check(client, port=8080)在回滚之前,我还会加一个人工确认门禁:当健康检查失败时,脚本打印出失败原因和备份包路径,询问操作者是否执行回滚。这个设计可能看起来有点“不自动化”,但发布场景里保留最后一道人工判断闸门,能避免脚本在同一次发布故障中反复执行“尝试-失败-回滚”的循环。
你完全可以把这套发布逻辑扩展成多机执行的序列,比如先发布一批灰度机器,健康检查通过后再发布剩余机器。核心思路不变:每台机器执行一样的发布流程,但批次之间留有人工观察窗口。
6. 常见问题排查与避坑实录
这部分我把自己踩过的一些坑集中写出来,很多问题不跑一次真实环境真的很难注意到。
6.1 编码问题:中文乱码与字符集
最常遇到的是 Windows 下处理远程命令返回值时中文乱码。原因是远程服务器输出的是 UTF-8,本地终端或 Python 默认编码可能是 GBK。解决方式统一为:所有读取到的字节流都做 .decode("utf-8", errors="ignore"),所有写入文件都显式指定 encoding="utf-8"。
另一个坑是 Windows 上直接 print 中文到 CMD 会报 UnicodeEncodeError。这不是你程序逻辑的问题,而是 Python 在 Windows 控制台默认用 GBK 编码输出。临时的解决方法是脚本开头加 sys.stdout.reconfigure(encoding="utf-8"),或者干脆把输出重定向到文件里,不依赖控制台编码。
6.2 远程命令卡死与超时
paramiko 的 exec_command 如果不设置超时,遇到服务无响应、命令等待用户输入、sleep 很长时间这种场景,脚本就会一直挂住。有个容易忽略的点:exec_command 里单独设 timeout 只能在初始连接和读取部分操作时生效,如果命令本身持续输出大量内容而你一直没有读取 stdout,缓冲可能占满导致系统层面假死。
解决方法是执行完命令后尽快调用 stdout.read() 或者 stdout.channel.recv_exit_status(),不要“发完命令就去看微信”。对长期运行的命令,用两端都设 timeout,外层用 client.connect 的 timeout,命令层用 exec_command 的 timeout。
6.3 并发过高导致目标机器拒绝连接
第一次做批量采集时我试过把线程池开到 50,结果有几台机器报 “Connection refused”。排查下来不是网络问题,而是目标服务器默认的 MaxStartups 参数限制了同时建立的 SSH 连接数。后来我把并发控制在 10 左右,问题消失。
如果你的场景确实需要高并发,建议在目标机器上适当调大 SSH 服务的并发参数,而不是盲目加大本地线程池。另外,连接超时时间也不要设得太长,10 秒是一个比较合理的初始值。
6.4 密钥认证优先于密码
写代码时图省事把密码硬编码进去,这个习惯一定要改。推荐的做法是用密钥对 + ssh-agent,脚本里通过 paramiko 加载指定的 key 文件。如果密钥文件有密码保护,设置好环境变量,然后从环境变量读取,不要把密码写进代码仓库。配置文件里也不要出现任何明文密码,哪怕配置本身就在内网,也要养成好习惯。
6.5 日志信息不要全部打印到终端
脚本写多了你会发现,终端输出在脚本运行结束后根本找不到。与其依赖终端界面,不如一开始就把 logger 配好,同时输出到文件和终端。日志文件本身做好大小管理,logging.handlers.RotatingFileHandler 可以按字节数自动切割,避免日志无限制增长占满磁盘。
6.6 不要重复造轮子:多看看已有工具
有时候使用场景合适,直接用开源工具或现成平台比从零写脚本更快。比如标准化配置管理有现成的方案,日志采集也有成熟引擎。我们的重点应该放在和自身流程紧密结合的胶水代码上,而不是把基础设施再造一遍。
7. 后续还能怎么进阶
写完这三个场景,你的落点已经不低了,如果想继续往前走,可以这样扩展:
第一个方向是把脚本改造成带简单 Web UI 的工具,点按钮执行巡检或发布,结果直接展示在页面上。这个可以用轻量级 Web 框架来做,前后端不复杂,但实用价值很高。
第二个方向是引入任务调度。把巡检、日志告警、备份这些脚本挂到定时任务里,到了时间自动跑,结果自动汇总。这本质不是一个技术难题,而是工程习惯:脚本必须稳定、日志必须完整、失败必须重试。
第三个方向是扩展 CMDB 概念。把服务器清单、负责人、业务分组、配置快照统一管理,再让脚本从 CMDB 拉数据而不是手工维护 YAML。当服务器上百台之后,这一步几乎是必经之路。
第四个方向是向容器化方向靠。现在很多服务跑在容器里,自动化脚本未必都要 SSH 进去执行命令,调用容器管理 API 做操作会更新式。但底层的编程能力、文件处理能力、异常处理能力是通用的,换个对象而已。
我自己的体会是,从零开始学 Python 做自动化开发,最重要的不是背语法、刷题,而是抓住一个真实的痛点场景,把脚本写完、跑通、用起来。第一次写出来的代码大概率丑陋,没关系,用一两周之后你会自然想重构。带着真实数据重构出来的代码,才是真正有质量的代码。
最后再分享一个小技巧:每完成一个脚本,花十分钟把关键参数和运行方式写进 README,放到团队共享文档里。这不是写给别人看的,是写给三个月后的自己看的。很多时候你忘了脚本怎么运行,不是回忆消失得早,而是当初没留记录。把脚本做成可持续使用的小工具,比一次性跑通的 demo 有价值得多。