从 Windows 管理 Linux 服务器:paramiko 脚本化运维实践

工作机是 Windows,被管的服务器有一堆 Debian。交互式 SSH 在自动化场景下全是坑(密码没法程序化输入、输出没法结构化解析),最后固化出一套 paramiko runner 模式,这篇把模式和坑一次讲清。

为什么不用交互式 ssh

试过在脚本里驱动 ssh,三个问题直接劝退:

  1. 密码登录没法非交互:sshpass 在 Windows 下难用,公钥认证又要管 keyring;
  2. 输出解析靠正则捞:命令输出一人一个样,换个语言环境就崩;
  3. 错误处理是黑盒:退出码拿到了,但”哪一步失败的”说不清。

paramiko 是纯 Python 的 SSH 实现,exec_command 直接拿 stdout/stderr/退出码三件套,错误处理完全可控。

paramiko 是纯 Python 的 SSH 实现,exec_command 直接拿 stdout/stderr/退出码三件套,错误处理完全可控。一条SSH 连接上可以开出多个 channel,命令执行和文件传输互不干扰。

Windows 到 SSH 再到 Linux 的paramiko 执行链路与三连检流程

基础封装:一个 Runner 类打天下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import paramiko

class ServerRunner:
def __init__(self, host: str, user: str, password: str, port: int = 22):
self.client = paramiko.SSHClient()
self.client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
self.client.connect(host, port=port, username=user, password=password, timeout=10)

def run(self, cmd: str) -> tuple[int, str, str]:
"""执行命令,返回 (退出码, stdout, stderr)"""
stdin, stdout, stderr = self.client.exec_command(cmd, timeout=60)
out = stdout.read().decode()
err = stderr.read().decode()
return stdout.channel.recv_exit_status(), out, err

def put(self, local: str, remote: str) -> None:
with self.client.open_sftp() as sftp:
sftp.put(local, remote)

def close(self):
self.client.close()

用法永远是三件套检查:

1
2
3
4
5
runner = ServerRunner("10.0.0.8", "deploy", "***")
code, out, err = runner.run("systemctl is-active msgstackapi")
if code != 0 or out.strip() != "active":
raise RuntimeError(f"服务异常: code={code} out={out} err={err}")
runner.close()

凭据不要硬编码在脚本里,放环境变量或本地不提交的配置文件(.gitignore 是最后一道防线,但最好别让它有发挥的机会)。

部署三连检

任何一次服务器变更,都按这个顺序,缺一步就是在给半夜报警攒机会:

1
① 本地语法检查 → ② 原子上传 → ③ 重启并验证

① 本地语法检查——Python 代码上传前先过一遍编译:

1
python -m py_compile scripts/deploy.py && echo "语法 OK"

Python 没有编译期,py_compile 是最便宜的保险,能挡住”少个冒号传上去服务起不来”的惨案。

② 原子上传——先传临时名,验证无误再换:

1
2
runner.put("deploy.py", "/tmp/deploy.py.new")
code, out, _ = runner.run("python3 -m py_compile /tmp/deploy.py.new && mv /tmp/deploy.py.new /opt/app/deploy.py")

mv 在同一文件系统内是原子操作,不会出现”传一半的部署脚本”。

③ 重启并验证——重启只是开始,验证才算完:

1
code, out, err = runner.run("systemctl restart msgstackapi && sleep 2 && systemctl is-active msgstackapi && journalctl -u msgstackapi --since '10 seconds ago' --no-pager | tail -5")

一步命令拿全:重启 → 等就绪 → 查状态 → 看日志。任何一个环节不对,退出码立刻暴露。

Windows 特有的坑:路径转换

在 Git Bash / MSYS 环境里跑 paramiko,传给 sftp.put 的本地路径一定要用原生 Windows 路径:

1
2
3
4
5
# 对:原生路径
runner.put(r"D:\build\msgstackapi.exe", "/opt/app/bin/msgstackapi")

# 错:MSYS 会把它脑补成别的东西
runner.put("/d/build/msgstackapi.exe", "/opt/app/bin/msgstackapi")

症状很诡异:本地明明存在的文件报 No such file。根因是 MSYS 的路径自动转换在后台改了参数。规律:Windows 侧路径给 C:\... 形式,Linux 侧路径给 /... 形式,中间不搞花活。

服务管理模板

被管的服务统一用 systemd,/etc/systemd/user 不碰,全放系统级:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[Unit]
Description=MyApp
After=network-online.target

[Service]
Type=simple
WorkingDirectory=/opt/app
ExecStart=/opt/app/bin/server
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target

配合上面的 runner,日常运维就四句话:

1
2
3
4
runner.run("systemctl status myapp --no-pager")       # 看状态
runner.run("journalctl -u myapp -n 100 --no-pager") # 看日志
runner.run("systemctl restart myapp") # 重启
runner.put(local_file, "/opt/app/bin/server.new") # 更新

小结

paramiko runner 的价值是把运维动作变成可重放、可检查的代码:凭据托管在一处、输出结构化、失败可定位。配合”语法检查 → 原子上传 → 重启验证”三连检,Windows 管一堆 Linux 服务器可以做到不翻车。