05 篇的 TCP 体检能回答「端口活着吗」,但回答不了「内存还剩多少」「那个进程跑起来没」。这些答案在机器里面,得登录进去看。这篇讲 paramiko——Python 的 SSH2 协议实现,用它把「逐台 ssh 手敲」变成一条命令管 N 台机器。
一、最小可用:六行执行一条远程命令
paramiko 的核心用法一屏放得下:
import paramiko
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) # 演示从简;生产见下节
client.connect("10.0.0.11", port=22, username="ops",
key_filename="/home/ops/.ssh/id_rsa", timeout=5)
_, stdout, stderr = client.exec_command("uptime", timeout=10)
print(stdout.read().decode())
client.close()
exec_command 返回的 stdout/stderr 是文件对象,stdout.channel.recv_exit_status() 拿退出码——把这三样都收好,远程命令的成败就有完整凭据了。
二、安全三原则(比代码更重要)
- 密钥优先,密码靠边。
connect(key_filename=...)用 SSH 密钥认证,不要在代码里出现密码;连密码都省不了的内部环境,也用环境变量注入,绝不硬编码——运维脚本迟早会进 git。 - host key 校验别图省事。
AutoAddPolicy()等于「任何陌生主机都信任」,演示可以,生产应该client.load_system_host_keys()+ 默认RejectPolicy,把「服务器指纹变了」这个强信号留给人工判断。 - 凭据进配置要分层。主机清单(IP/用户/密钥路径)放 JSON/YAML,凭据引用放环境变量,代码里只有变量名。本系列的演示代码一律不携带真实凭据。
三、超时与错误处理:真实演示(demos/remote_ops_demo.py)
网络代码的铁律是「所有等待都必须有上限」。paramiko 的超时至少有四个:connect 的 timeout、banner_timeout、auth_timeout,以及 exec_command 的读超时。下面的演示脚本先探测 22 端口,再真实走一遍「连不上」的错误路径:
import socket
import subprocess
import sys
HOST, PORT, USER = "127.0.0.1", 22, "ops"
def port_open(host: str, port: int, timeout: float = 2.0) -> bool:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.settimeout(timeout)
return s.connect_ex((host, port)) == 0
def run_remote(host: str, port: int, user: str, password: str, command: str) -> str:
"""真实部署里用的函数:连上、执行、只取 stdout。"""
import paramiko
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) # 演示从简
try:
client.connect(
host, port=port, username=user, password=password,
timeout=5, banner_timeout=5, auth_timeout=5,
)
_, stdout, stderr = client.exec_command(command, timeout=10)
out = stdout.read().decode("utf-8", errors="replace")
err = stderr.read().decode("utf-8", errors="replace")
code = stdout.channel.recv_exit_status()
if code != 0:
raise RuntimeError(f"远端命令退出码 {code}: {err.strip()}")
return out
finally:
client.close()
if __name__ == "__main__":
print(f"探测 {HOST}:{PORT} ...")
if port_open(HOST, PORT):
print("端口开放,走真实 paramiko 连接:")
print(run_remote(HOST, PORT, USER, "密码请用密钥或环境变量", "hostname && python --version"))
else:
print(f"[本机 {PORT} 端口未开放——没有 SSH 服务,属 Windows 默认状态]")
print("\n(1) 真实执行一次连接,看错误处理路径拿到的异常:")
try:
run_remote(HOST, PORT, USER, "not-a-real-password", "hostname")
except Exception as exc:
print(f" 连接失败(符合预期): {type(exc).__name__}: {exc}")
print("\n(2) 连接成功时你会看到的输出,用本地子进程模拟:")
for cmd in ("hostname", "python --version"):
r = subprocess.run(cmd, capture_output=True, text=True, shell=True)
print(f" $ {cmd}\n {r.stdout.strip()}")
真实运行输出:
探测 127.0.0.1:22 ...
[本机 22 端口未开放——没有 SSH 服务,属 Windows 默认状态]
(1) 真实执行一次连接,看错误处理路径拿到的异常:
连接失败(符合预期): NoValidConnectionsError: [Errno None] Unable to connect to port 22 on 127.0.0.1
(2) 连接成功时你会看到的输出,用本地子进程模拟:
$ hostname
LH
$ python --version
Python 3.13.15
说明两点:第一,写作机器是 Windows(默认没有 sshd),所以异常路径是真实跑出来的 NoValidConnectionsError——你的批量脚本里就该捕获它和 AuthenticationException、socket.timeout 这三类最常见失败;第二,连接成功后 hostname、python --version 这类命令的输出形态就是 (2) 那样,多台机器的输出会以同样格式汇总。
四、批量执行骨架:复用 05 篇的并发思路
把「单台执行」和「批量编排」分开,后者直接搬 05 篇的 ThreadPoolExecutor:
import logging
from concurrent.futures import ThreadPoolExecutor, as_completed
import paramiko
logger = logging.getLogger("batch_ssh")
def run_on_host(host_cfg: dict, command: str, retries: int = 1) -> dict:
"""在单台主机上执行命令;失败按 retries 重试。返回结构化结果,不抛异常。"""
last_err = None
for attempt in range(retries + 1):
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) # 演示从简
try:
client.connect(
host_cfg["host"], port=host_cfg.get("port", 22),
username=host_cfg["username"],
key_filename=host_cfg.get("key"), # 密钥优先
timeout=5, banner_timeout=5, auth_timeout=5,
)
_, stdout, stderr = client.exec_command(command, timeout=30)
code = stdout.channel.recv_exit_status()
return {
"host": host_cfg["name"],
"ok": code == 0,
"stdout": stdout.read().decode().strip(),
"stderr": stderr.read().decode().strip(),
"exit_code": code,
}
except Exception as exc:
last_err = exc
logger.warning("%s 第 %d 次失败: %s", host_cfg["name"], attempt + 1, exc)
finally:
client.close()
return {"host": host_cfg["name"], "ok": False, "error": str(last_err)}
def batch_run(hosts: list[dict], command: str, workers: int = 8) -> list[dict]:
results = []
with ThreadPoolExecutor(max_workers=workers) as pool:
futures = {pool.submit(run_on_host, h, command): h["name"] for h in hosts}
for fut in as_completed(futures):
results.append(fut.result())
return results
设计要点,全是实战换来的:
- 单机结果结构化(
ok/stdout/stderr/exit_code),失败也是结果的一种——和 05 篇的probe()同一个设计哲学; - 重试要有次数上限(这里 1 次),重试只适合「网络抖动」类瞬时故障;认证失败重试八次只会换来账号锁定;
- 连接必须放
finally: close()——SSH 连接是稀缺资源,批量脚本最容易泄漏的就是它; - 幂等意识:批量命令可能因为重试而在同一台机器上执行两次,写类命令(改配置、发文件)要保证「执行两次和执行一次结果一样」,具体做法 08 篇的模板渲染就是其一。
五、传文件:SFTP 三行
paramiko 自带 SFTP,批量分发脚本或拉回日志都是它:
sftp = client.open_sftp()
sftp.put("configs/app.yaml", "/etc/myapp/app.yaml") # 推
sftp.get("/var/log/myapp/error.log", "pull/error.log") # 拉
sftp.close()
配合 08 篇的模板渲染,就能做到「一份模板 + 各机器参数 → 渲染成各自的配置 → SFTP 推到对应机器」,这是批量配置管理最小的自研闭环。
六、什么时候不该用 paramiko
诚实地说,机器到了几十台的规模,paramiko 自研脚本的边际收益开始下降:
- 几十台以上、变更类操作:上 Ansible——声明式、幂等、playbook 可评审,等于把本篇+08 篇的活儿产品化了;
- 已有堡垒机/跳板体系:paramiko 的 ProxyCommand/跳板配置比较繁琐,能用现成通道就用现成通道;
- 大文件分发:SSH 通道不适合搬 GB 级文件,rsync/对象存储更专业。
paramiko 的甜区是:5–30 台规模的自研轻量工具(批量巡检、配置检查、日志拉取),以及作为库被更大的系统(监控平台、CMDB 钩子)集成调用。
下一篇解决批量配置管理的前置问题:几十份环境配置,怎么不再复制粘贴。
本篇代码:demos/remote_ops_demo.py,已在 Python 3.13.15 / Windows(paramiko 5.0.0)真实运行,输出原样收录;演示不含真实凭据。