SSH 管混合集群的 8 个坑:paramiko 自动化里最贵的不是报错,是误判

2026-10-19 · 爱智工坊 · 远程运维实录

我们的集群是七台机器:五台 Ubuntu、两台 Windows,跑数字人、TTS、视频渲染和一堆常驻服务。每天要做的事很朴素——巡检端口、看进程、拉日志、改配置、重启服务。用 paramiko 写成一个脚本,理论上二十行就够。

真跑起来,第一周几乎每天都在处理同一类问题:脚本说"超时",但机器上进程明明已经起来了。后来我们把这类现象整理成一张对照表,才发现「超时」「空输出」「连不上」这三种看起来一样的失败,背后的原因完全不同,而且其中一半以上的"故障"根本不该去修。

下面八个坑按「连不上 → 连上了但命令不生效 → 输出不对 → 传不动」的顺序排,每个都给出实测现象、真因和「别做什么」。所有数字来自我们自己的机器,不含任何凭据与真实地址。

一、先分清两件事:命令行 ssh 走不通,不等于网络走不通

最早卡住我们的是执行环境。在自动化沙箱里直接调 ssh 命令行,会被拦下来;但同一时刻用 Python 的 paramiko 连内网同一台机器,是通的。也就是说被挡的是那个可执行文件,不是 22 端口。

这条区分很重要,因为它决定了你该往哪查:

我们现在的做法是:所有 SSH 操作一律走 paramiko,不碰命令行 ssh。代价是每次要多写十行,收益是失败信息终于能区分开。

import paramiko

client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect(host, 22, user, pkey=key, timeout=10)

def run(cmd, timeout=60):
    stdin, stdout, stderr = client.exec_command(cmd, timeout=timeout)
    return stdout.read().decode("utf-8", "replace") + stderr.read().decode("utf-8", "replace")

二、密钥文件:那个 0 字节的 authorized_keys

有一台 Ubuntu 节点,密钥登录失败了整整几周,一直只能用密码。我们反复确认过:公钥加进去了、权限是 600、sshd 也重启了。最后发现问题在两个地方同时出错:

  1. ~/.ssh/authorized_keys 是一个 0 字节的空文件;
  2. 真正的公钥被写进了两个拼错的文件名:autherizedkeys 和 authorizedkeys。

sshd 只读标准文件名,所以那两行公钥从来没有被加载过。而「文件存在」这个检查是通过的——这正是它难查的原因。

排障顺序应该是:先看 authorized_keys 是否非空(wc -c),再看权限是不是 600,最后才看内容里有没有你的公钥。只看"有没有 .pub 文件"或"目录里有没有 authorized_keys 这个名字",会得到假阳性。
ls -la ~/.ssh/
wc -c ~/.ssh/authorized_keys      # 0 就是没生效
stat -c '%a %U' ~/.ssh/authorized_keys   # 期望 600 属主本人

顺带一个反面教训:不要在自动化里用「读文件 + 写文件」的方式搬运私钥。我们试过用文件读写工具把私钥内容转存到工作目录,base64 被中途改写,paramiko 报 Invalid base64。正确做法是直接复制文件(cp / shutil.copy),复制完 chmod 600,用完删掉。

三、IPv4 的 22 端口 refused,但 sshd 明明在监听

有一台节点出现过这种情况:ss -lntp 显示 sshd 监听 0.0.0.0:22 和 [::]:22,本机自己连自己没问题,但从别的机器连 IPv4 地址就是 Connection refused。

refused 这个错误码的含义很具体:包到了,但没人接——通常意味着被规则 drop 掉并回了 RST,而不是路由不通(那是 timeout)。所以第一步不该去改 sshd_config,而是看包过滤:

sudo nft list ruleset | grep -A5 'chain input'
sudo iptables -S | head -30

我们当时的临时通道是走 IPv6 地址连进去(同一台机器 IPv6 可达,IPv4 不可达),先把活干完,再从容查规则。这个做法的价值在于:不要为了"能连上"去改一个本来正确的 sshd 配置,那会把一个问题变成两个。

顺带记住这个分辨法:refused = 有人拒绝;timeout = 没人应答;Permission denied = 应答了但不认你。三种错误的排查路径完全不同,混在一起查会浪费一整天。

四、最贵的一个坑:后台进程会把 SSH 通道挂死

这是我们在自动化里踩得最深的一次。要在一台节点上起一个常驻服务,写法很自然:

bash ./start_service.sh & sleep 2; pgrep -af service_name

结果:命令跑了两分多钟没有任何输出,被上层判为超时。但登上去一看,进程好好地在那儿跑着。

原因是子进程继承了 SSH channel 的管道。只要它不退出,channel 就不会 EOF,stdout.read() 就一直等。paramiko 本身没坏,是我们把"启动"和"验证"塞进了同一条命令。

现在的规矩是三条:

最重要的一条认知:卡住不等于失败。遇到"超时"先新开一条连接 pgrep -af 确认进程是否已经起来,再决定要不要重试。我们曾经因为没做这一步,把同一个服务起了三遍,后两个抢端口失败,日志里一片 EADDRINUSE,反而掩盖了真正的问题。

五、Windows 节点:四个会让你怀疑人生的差异

同一套脚本换到 Windows 目标机上,几乎每一行都要改。以下四条是我们实际踩过的。

1)多行 PowerShell 返回空字符串

powershell -Command "<多行脚本>" 通过 SSH 执行时,经常返回空——不是报错,就是什么都没有,连事件日志都查不到。改成 base64 编码的 -EncodedCommand 立刻正常:

import base64
def ps(client, script, timeout=300):
    b64 = base64.b64encode(script.encode("utf-16-le")).decode("ascii")
    cmd = ("powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass "
           "-EncodedCommand " + b64)
    i, o, e = client.exec_command(cmd, timeout=timeout)
    return (o.read().decode("cp936", "replace"),
            e.read().decode("cp936", "replace"))

2)中文必须按 cp936 解码

Linux 节点是 UTF-8,Windows 中文版控制台输出是 GBK。用 UTF-8 解会得到一串 ????,你会以为是编码坏了或者程序崩了,其实只是解错了。规则很简单:目标是 Linux 用 utf-8,目标是 Windows 用 cp936,两边不要混。

3)Invoke-WebRequest 在 SSH 会话里必崩

报 ReadConsoleOutput ... 拒绝访问 (0x5)。原因是非交互会话没有控制台缓冲区,而这个 cmdlet 会去读它。改用 .NET 的 System.Net.WebClient(DownloadString / DownloadFile / DownloadData)即可。

4)Start-Process 起的进程会随会话一起死

这条最阴。表现是:重启守护进程后日志轮询几行就停了,进程悄悄消失,功能静默失效——没有任何报错。因为 SSH 会话结束时,它启动的子进程被一起收走了。

正确做法是交给计划任务托管:schtasks /run /tn "任务名" 或 Start-ScheduledTask。执行前先 Get-ScheduledTask 确认任务名拼写。

还有一个坑中坑:在某些虚拟终端里执行 schtasks /run,命令会打印错误但退出码仍然是 0。所以判成功不能只看 rc,要看随后进程/端口是否真的起来了。

六、传文件:SFTP 9 秒断,内网 HTTP 32 秒传完

往 Windows 节点传一个几百 MB 的安装包,SFTP 大约 9 秒就 EOFError 加 Socket exception 10054,稳定复现。换成"本机起一个临时 HTTP 服务、目标机自己下载",同样的文件 32 秒传完,约 11.1 MB/s。

# 发送端
python -m http.server 8899 --directory ./pkgs --bind 0.0.0.0
# 目标机(PowerShell)
$wc = New-Object System.Net.WebClient
$wc.DownloadFile('http://发送端内网IP:8899/pkg.zip', 'C:\tmp\pkg.zip')

这个改法还有个附带好处:断点重传的成本从"整包重来"变成"重下一次",而且不占用 SSH 通道,巡检脚本可以继续跑。

七、用 heredoc 写远端配置文件,会得到一个空文件

这是远程改配置时最容易被忽略的一处。通过 exec_command 传 heredoc,某些情况下远端拿到的是空文件,命令还不报错。我们在给一台节点写模型配置文件时踩到,程序报的是"找不到配置文件",很容易误判成路径写错。

两个可靠替代:

写完一定要回读校验:wc -c 看字节数、head -3 看首行。远端写配置这件事,"命令没报错"和"文件是对的"是两件独立的事。

八、一张分诊表:先看现象,再决定查什么

现象最可能的真因别做什么
ssh 命令被拒 / 退出执行环境策略拦截可执行文件别去查防火墙和 sshd
Connection refused包到了但被规则拒绝,回了 RST别急着改 sshd_config
连接超时无响应路由 / 主机不可达 / 端口被 drop别当"服务挂了"重启服务
密钥登录 Permission deniedauthorized_keys 为空或文件名拼错别反复重生成密钥
命令跑两分钟无输出子进程持有 channel 管道别直接判失败并重试
Windows 上多行脚本返回空未用 -EncodedCommand别怀疑脚本逻辑
中文变 ????用 UTF-8 解了 GBK 输出别去改目标机区域设置
SFTP 传输中途 10054大文件被目标端 sshd 断连别反复重试同一个方法
远端配置文件是空的heredoc 未被正确解析别去查程序读文件的逻辑

这张表的价值不在"正确答案",而在把现象和原因一对一钉死。我们前期的浪费几乎都发生在"看到一个失败就从头查一遍",而实际上每种失败都有它唯一合理的下一步。

九、脚本骨架:把易错点封在函数里

最后是我们现在实际在用的结构。核心思路是:连接、执行、Windows 专用执行三件事分开,每次调用都显式带超时,输出永远带上 stderr。

class Node:
    def __init__(self, host, user, key=None, password=None, windows=False):
        self.windows = windows
        self.c = paramiko.SSHClient()
        self.c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
        self.c.connect(host, 22, user,
                       pkey=key, password=password,
                       timeout=15, banner_timeout=20,
                       look_for_keys=False, allow_agent=False)
        # Windows 目标必须显式关掉密钥探测,否则会先白等一轮

    def run(self, cmd, timeout=60):
        i, o, e = self.c.exec_command(cmd, timeout=timeout)
        enc = "cp936" if self.windows else "utf-8"
        out = o.read().decode(enc, "replace")
        err = e.read().decode(enc, "replace")
        return out + err

    def ps(self, script, timeout=300):
        b64 = base64.b64encode(script.encode("utf-16-le")).decode("ascii")
        return self.run("powershell -NoProfile -NonInteractive "
                        "-ExecutionPolicy Bypass -EncodedCommand " + b64, timeout)

三处细节值得抄:look_for_keys=False, allow_agent=False(Windows 密码认证时必须,否则会先试本机密钥白等);超时分级(巡检 60 秒、装包 300 秒);输出合并 stderr——很多失败信息只在 stderr 里,只看 stdout 会得到"命令成功但什么都没发生"。

十、什么时候该停下来问人

自动化能覆盖的边界其实很清晰。遇到下面这些,别硬试:

把这些写清楚不是偷懒,而是让「卡住」这件事本身可以被定位。工程里最贵的从来不是难题,而是分不清"在等外部"和"我没做对"——我们在这上面浪费的时间,比在真正的技术难点上多得多。

常见问题

为什么 paramiko 连 Windows 一定要关掉 look_for_keys?

paramiko 默认会先尝试本机的 ~/.ssh/id_rsa 等密钥,失败后再回退密码。对着只开密码认证的 Windows sshd,这一轮尝试纯属浪费,还会拉长超时甚至直接失败。显式传 look_for_keys=False, allow_agent=False 可以一步到位。

exec_command 已经设了 timeout,为什么还会卡住?

timeout 管的是通道读写,不是进程生命周期。子进程只要还持有 channel 的管道,stdout.read() 就会一直等到 EOF。所以真正要改的是启动方式(setsid + 三个 fd 全脱开),而不是把 timeout 调大。

怎么快速判断一个"连不上"是网络问题还是认证问题?

看错误类型:refused 是包到了被拒绝(规则/端口未监听);timeout 是没有任何回应(路由/丢包/被 drop);Authentication failed / Permission denied 说明 TCP 已经通了,问题在凭据。三者排查路径完全不同。

远端执行完怎么确认配置真的写进去了?

回读三件事:字节数(wc -c)、首行内容(head -3)、文件属主与权限(stat)。尤其是用 heredoc 或重定向写文件时,"命令退出码为 0"完全不能证明文件内容正确——我们遇到过写出 0 字节且不报错的情况。

混合集群巡检脚本应该多久跑一次?

取决于你要发现什么。端口与进程存活建议 5~15 分钟一次(成本极低);磁盘、日志增长、证书到期这类慢变量每天一次足够。关键是把"巡检"和"修复"分开:巡检只读不写,修复单独走,否则一次误判就会自动改坏线上配置。

远程运维做到最后,拼的不是命令写得多花,而是对"失败"的分类有多细。同样是超时,一个是网络不通、一个是进程已起、一个是通道没释放——分得清,一天能干完;分不清,一周都在原地。

需要一套能落地的运维巡检方案?看看我们的服务 →