☰
SSH远程执行命令自动输入密码:批量运维方案与避坑
2026/9/29 23:03:21 网站建设 项目流程

上个月帮一家做设备巡检的团队收拾脚本,核心诉求很朴素:一台跳板机后面挂着四十多台服务器,每天要跑一遍df -h、uptime和几个业务侧的探活命令,现在是一条一条手动敲,密码输到手酸。他们试过echo 密码 | ssh,结果命令要么卡住,要么远端程序拿到的输入全是乱码。这个场景在 ssh 远程执行命令自动输入密码这块属于最典型的入门坑:SSH 的密码提示不是从标准输入读的,是从终端设备读的,所以管道喂密码天然不成立。下面我把这几年在批量巡检、自动化发布、临时救火几个场景里攒下来的做法完整摊开:从 sshpass、expect 两条“快路子”,到密钥、ssh-agent、连接复用这条“正路子”,再到 Python paramiko、Fabric、Ansible 三种代码化方案,最后补上两个几乎没人提前告诉你的问题——连接断掉之后远端任务到底还活不活,以及ssh -vvv那几百行输出该从哪一行开始看。适合正在写运维脚本、做批量执行、或者刚被一堆服务器密码磨到没脾气的人。

1. 密码为什么喂不进 ssh 的标准输入

1.1 从一次三十分钟的重复劳动说起

绝大多数人第一次做 ssh 远程执行命令自动输入密码,思路都是“密码不就是一行字符串吗,管道塞进去不就完了”。我最早也是这么想的,写了个 for 循环:

for h in 10.0.0.11 10.0.0.12 10.0.0.13; do echo "MyPassw0rd" | ssh ops@$h 'uptime' done

结果三种情况随机出现:一是每个主机都提示Permission denied, please try again三次然后退出;二是密码侥幸被读到了,但远端uptime完全没输出,因为标准输入已经被那行密码占掉了;三是脚本在 CI 里跑的时候直接挂在密码提示上,一直到流水线超时被杀掉。后来翻 OpenSSH 的源码才明白,密码认证路径走的是read_passphrase(prompt, RP_ALLOW_STDIN),这个函数在有/dev/tty的时候优先从终端读,只有在拿不到终端的情况下才会考虑标准输入或者SSH_ASKPASS程序。也就是说,你在终端里跑脚本,ssh 会去抢那个终端;你在 cron 或流水线里跑,它又会去找别的来源。行为随环境变化,这就是这类脚本脆弱的根源。

1.2 /dev/tty 和 stdin 不是一回事

要理解后面的所有方案,先把这两个东西分开。stdin是进程的标准输入,可以被重定向到文件、管道、另一个进程;/dev/tty是当前进程所关联的控制终端设备,重定向它基本没用。ssh 的密码提示刻意走了/dev/tty,目的很明确:防止有人把密码写在管道里明文传递,也防止密码被远端命令意外读到。

这个设计带来两个直接后果。第一,任何“把密码从 stdin 塞进去”的写法都不稳定,能跑通也是运气;第二,任何想绕过它的工具,本质上都得做同一件事——伪造一个终端,或者伪造一个 askpass 程序。sshpass 用的是前者加后者,expect 用的是纯前者(pty),SSH_ASKPASS用的是后者。理解了这一点,后面选工具就不用靠背命令了,看它在哪一层做手脚就行。

顺带说一个容易被忽略的点:ssh加-t参数强制分配伪终端,加-T明确禁止分配。很多人写批量脚本的时候随手加-t,理由是“加了之后 top 之类的命令能跑”,但批量场景下加-t会让输出里混入一堆控制字符,grep、awk解析全靠运气,而且退出码的语义也会变。批量执行探活命令,一律用默认(不分配),需要交互式命令的时候才单独处理。

1.3 三条技术路线的成本对照

把可选方案摆到一张表里,选型就变成一道算术题了。我在实际项目里的判断标准是:临时性 × 机器数量 × 环境限制,三个变量里只要有一个极端,答案基本就定了。

路线实现方式依赖暴露面适合场景
sshpass伪造 pty + 自动应答需额外安装中等,取决于传参方式临时救火、受限设备、几十台以内
expect完整模拟交互多数系统自带中等,密码写在脚本里登录流程复杂、需要多步交互
密钥认证公钥下发,无密码无最低长期运维、任何规模
SSH_ASKPASS让 ssh 自己调外部程序取密码无中等装不了第三方工具的环境
代码化(paramiko 等)库内实现认证Python 环境依实现而定要嵌入应用、要拿结构化结果

表里没写“哪种最好”,因为这个问题没有统一答案。我见过客户的交换机只开放了密码登录、sshd 配置改不动、还不让装任何额外软件,那 expect 就是唯一解;也见过新集群,密钥加连接复用跑五十台机器只要几秒钟,这时候再回头用 sshpass 就是自己给自己找麻烦。

2. sshpass:一行命令搞定,但有四个坑要提前埋掉

2.1 安装与第一条能跑通的命令

sshpass 是绕开密码提示最省事的工具,它内部会分配一个 pty,把密码在合适的时机写进去。

# Debian/Ubuntu apt-get install -y sshpass # RHEL/CentOS,需要先启用 EPEL yum install -y epel-release && yum install -y sshpass # macOS brew install hudochenkov/sshpass/sshpass

装完第一条最小可用命令长这样:

sshpass -p 'MyPassw0rd' ssh -o StrictHostKeyChecking=accept-new ops@10.0.0.11 'uptime'

这里有两个细节必须说清楚。第一,StrictHostKeyChecking=accept-new在 OpenSSH 7.6 之后才支持,它的含义是“第一次见到的主机自动接受并记进 known_hosts,之后指纹变了照样拒绝”。比常见的no要克制得多,也比no加UserKnownHostsFile=/dev/null的组合安全——后者等于彻底关掉主机校验,中间人换一台机器你都不知道。如果你的 OpenSSH 版本太老,用ssh-keyscan -H 10.0.0.11 >> ~/.ssh/known_hosts提前把指纹灌进去,比关校验靠谱。

第二,-p后面直接跟密码,是最方便也最不该长期使用的方式。原因在下一节。

2.2 四种传密码方式的暴露面排序

sshpass 提供了四种传密码的入口,很多人只知道-p,其实后三种在脚本里更值得用。

# 方式一:命令行明文,ps 一查就能看到 sshpass -p 'MyPassw0rd' ssh ops@10.0.0.11 'uptime' # 方式二:从环境变量读,脚本里不出现密码字面量 export SSHPASS='MyPassw0rd' sshpass -e ssh ops@10.0.0.11 'uptime' # 方式三:从文件第一行读,文件权限 600 install -m 600 /dev/null /run/ops.pass printf '%s' 'MyPassw0rd' > /run/ops.pass sshpass -f /run/ops.pass ssh ops@10.0.0.11 'uptime' # 方式四:从指定文件描述符读,进程列表和文件系统里都看不到 exec 3<<<'MyPassw0rd' sshpass -d 3 ssh ops@10.0.0.11 'uptime' exec 3<&-

按暴露面从高到低排是:-p>-e>-f>-d。-p的问题在于同机器上任何用户执行ps -ef都能看到完整命令行,这在多租户跳板机上基本等于把密码贴在公告栏。-e稍好,环境变量在/proc/<pid>/environ里,普通用户读不到别人的,但同用户的其他进程、以及任何能 dump 内存的东西都有机会;而且它会把密码留在 shell 的环境里,子进程全部继承。-f是最常用的折中方案,注意两点:文件权限必须 600,而且 sshpass 只读第一行,行尾不要留换行以外的垃圾;用完记得shred -u或者直接在 tmpfs 目录里操作,别落到磁盘上。-d最干净,密码只存在于当前 shell 的一个文件描述符里,缺点是写法略绕,适合封装进函数。

提示:不管用哪种方式,密码一旦进了脚本或配置文件,就默认它已经泄露了。真正在乎安全的场景,正确做法是把密钥认证配上,而不是研究怎么把密码藏得更深。

2.3 密码提示串和超时:-P 参数的真正用途

sshpass 默认在输出里找assword这个片段来匹配密码提示,所以它能同时命中Password:和password:。但现实里总有例外:某些设备固件写的是Passcode:,某些本地化系统写的是别的词,这时候 sshpass 会一直等,直到 ssh 自己超时。

# 明确告诉 sshpass 提示串里含有哪些字符 sshpass -P 'asscode' -p 'MyPassw0rd' ssh admin@192.168.1.1 'show version'

-P的语义是“密码提示中包含这个子串”,所以给个足够独特的片段就行,不需要写完整句子。另外建议在 ssh 那一侧显式加超时,避免脚本被一个不响应的设备挂死:

sshpass -f /run/ops.pass ssh -o ConnectTimeout=5 -o BatchMode=no ops@10.0.0.11 'uptime'

ConnectTimeout=5管的是 TCP 连接建立阶段,五秒连不上直接返回,不会卡住整个循环。批量脚本里这个参数是刚需,后面第 8 节还会再说一次。

2.4 退出码与常见报错对照

sshpass 本身出错时返回 1 到 6,正常执行时会把 ssh 的退出码原样透传。这个特性很重要,意味着你在脚本里可以按 ssh 的语义判断成功失败,但也要注意区分“sshpass 挂了”和“远端命令返回非零”。

现象大致原因处理动作
Permission denied, please try again连续三次密码错,或认证方式不匹配先手动登录验证一次,确认不是 keyboard-interactive
Host key verification failed首次连接且未预置指纹加StrictHostKeyChecking=accept-new或预灌 known_hosts
卡住不动直到超时提示串不匹配,sshpass 在等用-P调整,或加ConnectTimeout
sshpass: Failed to run command: No such file or directory目标命令或 ssh 本身不在 PATH 里用绝对路径,或检查 PATH
远端命令返回码和预期不符sshpass 透传了远端退出码在脚本里显式判断$?,别只判断 sshpass 是否执行成功

我踩过最阴的一次是目标设备开了二次验证,登录时会额外提示一个动态口令。sshpass 把密码填进去之后一直在等下一个提示,脚本看起来像“卡住”,实际是认证流程没走完。这种场景 sshpass 和 expect 都救不了(expect 理论上能配合,但拿动态口令等于把验证机制废掉),老老实实换成密钥或者走带外通道。

3. 不装第三方工具,用 SSH_ASKPASS 让 ssh 自己去取密码

3.1 触发条件:没有 tty 的时候 ssh 才会去找它

有些环境限制很死:生产容器镜像不允许装额外包,审计要求只能使用系统自带的 ssh 二进制。这时候SSH_ASKPASS是唯一的出路。它的机制是:当 ssh 无法从终端读取密码时,会去执行SSH_ASKPASS指定的程序,把提示串作为参数传给它,然后读它的标准输出当密码。

#!/bin/bash # /opt/ops/askpass.sh printf '%s\n' 'MyPassw0rd'
chmod 700 /opt/ops/askpass.sh # 关键:必须让 ssh 拿不到当前终端 setsid -w env SSH_ASKPASS=/opt/ops/askpass.sh SSH_ASKPASS_REQUIRE=force \ DISPLAY=:0 ssh -o StrictHostKeyChecking=accept-new ops@10.0.0.11 'uptime'

这里三个东西缺一不可。setsid让 ssh 脱离当前控制终端,否则 ssh 会优先用/dev/tty而不是 askpass 程序;DISPLAY在旧版本里是硬性条件(askpass 机制来自图形化场景);SSH_ASKPASS_REQUIRE=force是新版本的关键。

3.2 SSH_ASKPASS_REQUIRE=force:新版本里更省事的写法

OpenSSH 8.4 引入了SSH_ASKPASS_REQUIRE环境变量,取值有三个:auto(默认,等价于旧行为)、force(无视终端,强制走 askpass)、never(禁止使用 askpass)。有了它,前面那串绕来绕去的写法可以简化:

SSH_ASKPASS=/opt/ops/askpass.sh SSH_ASKPASS_REQUIRE=force \ ssh -o StrictHostKeyChecking=accept-new ops@10.0.0.11 'uptime'

省掉setsid和DISPLAY之后,这个方案就相当好用了:不需要任何第三方包,脚本逻辑也直观。我在几个只能跑官方二进制的基础镜像里就是用这个方式做巡检的。判断版本的办法很简单,ssh -V看输出,8.4 以上就可以放心用force。低于 8.4 的版本还是得回到setsid那套写法。

3.3 这条路走不通的几种情况

SSH_ASKPASS不是万能的,有三个明确的边界。

第一,它能填的只有密码和 passphrase,填不了 yes/no。首次连接遇到Are you sure you want to continue connecting的时候,这个机制不参与,所以主机指纹还是得靠accept-new或者预灌 known_hosts 解决。

第二,git、rsync、scp 这类会自己调用 ssh 的程序,行为取决于它们怎么传递环境变量。有的会把SSH_ASKPASS清掉,有的不会。我遇到过git clone在脚本里卡住,单独测 ssh 又完全正常的情况,最后是用GIT_SSH_COMMAND指定包装脚本来解决的。这类问题排查起来很费时间,所以在决定用 askpass 之前,先确认调用链路上每一环会不会清环境。

第三,askpass 脚本本身就是个明文密码文件。它和被审计禁止的 sshpass 在安全性上没有本质区别,只是少了一个依赖。要真在乎这个,就别在这两条路上纠结,去配密钥。

4. expect:把手工登录过程录一遍

4.1 最小可用脚本的四个关键动作

expect 的思路最贴近人的操作方式:开一个伪终端,等出现某个字符串,就把密码打进去。一个能用的脚本必然包含四个动作——spawn(拉起进程)、expect(等提示)、send(发送内容)、expect eof(等结束)。

#!/usr/bin/expect -f set timeout 20 set host [lindex $argv 0] set user [lindex $argv 1] set pass [lindex $argv 2] spawn ssh -o StrictHostKeyChecking=accept-new $user@$host "uptime" expect { "yes/no" { send "yes\r"; exp_continue } "assword:" { send "$pass\r" } timeout { puts "TIMEOUT: $host"; exit 2 } } expect eof catch wait result exit [lindex $result 3]

最后三行是这个脚本里最有含金量的部分:catch wait result取出 spawn 出去的进程的退出状态,exit [lindex $result 3]把远端命令的真实退出码透传给 shell。少了这三行,脚本永远返回 0,批量执行里就没法判断哪台机器失败了。很多人写 expect 卡在这一步,以为是 expect 的限制,其实是没做这层转换。

exp_continue是为了处理首次连接时的 yes/no 提示,让同一个 expect 块继续等待下一个匹配。这是个很好用的小技巧,能避免写嵌套 expect。

4.2 多主机循环里的超时与分支

单主机脚本没什么意思,expect 的价值在批量。批量场景下第一原则是:任何一次等待都必须有超时,任何一次超时都必须能让脚本继续往下走。

#!/usr/bin/expect -f set timeout 15 set pass [lindex $argv 0] set hosts [lrange $argv 1 end] set failed {} foreach h $hosts { if {[catch { spawn ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new ops@$h "df -h" expect { "assword:" { send "$pass\r"; exp_continue } timeout { error "auth timeout" } eof {} } expect eof } err]} { lappend failed $h puts "FAIL $h : $err" } else { puts "OK $h" } } puts "failed: $failed"

用catch把整段包起来,出错的机器记进列表,其它机器不受影响。这套结构我用了好几年,改一改就能适配各种设备。真实项目里还可以把结果写进文件、计算成功率、给失败清单发通知,但骨架就是这一层。

4.3 首次登录的 yes/no 和 log_user

两个实用细节。其一,log_user 0可以关掉 expect 默认把交互内容打印到标准输出的行为,批量跑几十台机器的时候输出会清爽很多,需要调试的时候再打开。其二,send的内容一定要带\r(回车),只写\n在某些设备上不会触发确认。这个坑我在国产化设备上踩过,现象是密码发出去了但毫无反应,最后发现必须用\r。

还有一个容易被忽略的点:expect 脚本里的密码通常以命令行参数形式传入,同样会被ps看到。解法是用文件或者环境变量传入,脚本内部用$env(SSHPASS)读取。原则和 sshpass 那节完全一致。

5. 密钥 + ssh-agent + 连接复用:批量运维的效率开关

5.1 密钥下发与权限那张表

前面三节讲的都是“怎么把密码送进去”,但正确的做法是“根本不需要密码”。密钥认证一次配置,长期受益,而且它顺便解决了批量执行的性能问题——每次登录省掉了密码校验的往返。

# 生成密钥,ed25519 更短更快,兼容性现在也足够了 ssh-keygen -t ed25519 -C "ops@batch" -f ~/.ssh/id_ed25519_ops -N "" # 下发公钥(需要最后一次输入密码) ssh-copy-id -i ~/.ssh/id_ed25519_ops.pub ops@10.0.0.11 # 没有 ssh-copy-id 的环境,手动追加 cat ~/.ssh/id_ed25519_ops.pub | ssh ops@10.0.0.11 \ 'mkdir -p ~/.ssh && chmod 700 ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys'

权限这件事每年都要栽一批人,我把该有的值列成表,照着设基本不会出问题。

路径权限说明
~/.ssh700目录太开放 sshd 直接拒绝
~/.ssh/id_ed25519_ops600私钥不能被同组或其他人读
~/.ssh/id_ed25519_ops.pub644公钥无所谓
~/.ssh/authorized_keys600服务端检查的是文件本身和上层目录
家目录本身755 或更严家目录组可写也会被拒

最后一行很多人不知道。用户家目录如果组可写,sshd 会认为authorized_keys不可信,直接跳到密码认证。现象是密钥明明配对了,日志里却写着Authentication refused: bad ownership or modes。遇到这种情况,chmod g-w ~就好了。

5.2 ~/.ssh/config 才是真正的效率来源

密钥配好只是起点,真正让批量执行变轻松的是配置文件。它把主机名、用户、密钥、超时、复用全部固化下来,脚本里就只剩ssh web01 '命令'。

# ~/.ssh/config Host web0* User ops IdentityFile ~/.ssh/id_ed25519_ops IdentitiesOnly yes StrictHostKeyChecking accept-new ServerAliveInterval 30 ServerAliveCountMax 3 ConnectTimeout 5 Host web01 HostName 10.0.0.11 Host web02 HostName 10.0.0.12

三个参数值得单独解释。IdentitiesOnly yes让 ssh 只用这里指定的密钥,不去挨个试~/.ssh下的所有私钥。批量执行时这个差别很明显:默认行为下 ssh 会逐个尝试每个可用身份,认证失败的次数多了可能被服务端的MaxAuthTries拦掉。ServerAliveInterval 30加ServerAliveCountMax 3是心跳,防止长时间执行的任务被中间的防火墙静默断开。ConnectTimeout 5前面提过,批量场景下它是让脚本快速失败的关键。

5.3 ControlMaster:把 30 秒的批量操作压到 3 秒

连接复用是 SSH 批量执行里性价比最高的一个开关,没有之一。原理是第一条连接建立后,后续连接复用同一条 TCP 通道,省掉 TCP 握手、密钥交换、认证的全部开销。

Host * ControlMaster auto ControlPath ~/.ssh/cm-%r@%h-%p ControlPersist 10m

实测数据摆出来比较直观。五十台主机各跑一次uptime,跨机房、单程延迟 8 毫秒左右:

配置总耗时说明
默认密钥认证,串行约 31 秒每台约 600 毫秒,主要是握手和认证
加 ControlMaster 复用约 4 秒首次建立,后续每台几十毫秒
加复用 + 20 路并发约 1.2 秒需要控制并发数,别把 sshd 打满

ControlPath里的%r、%h、%p分别是用户名、主机、端口,用它们拼出唯一路径,避免多主机互相干扰。第一次用完之后,如果怀疑有残留的复用连接影响调试,ssh -O exit web01可以主动关掉。

有一个前提要注意:复用连接的生命周期内,所有请求共用一条通道,所以它不适合跑那些会互相干扰的长任务。批量探活、拉配置、改文件这类短命令是它的最佳场景。

5.4 BatchMode:让脚本要么成功要么立刻失败

BatchMode=yes的含义是禁止一切交互式提示:不弹密码、不弹主机确认、不弹 passphrase。加上它之后,脚本的行为就变成二元的——认证能过就跑,过不了立刻返回失败,不会挂在提示上等一整天。

ssh -o BatchMode=yes -o ConnectTimeout=5 web01 'uptime' || echo "web01 需要人工介入"

我在流水线里的标准配置是:批量巡检任务加BatchMode=yes,主动去发现那些密钥没配好、或者被误改配置的主机;人工临时操作的时候不加,保留输密码的余地。这两种模式分开,能避免很多“流水线凌晨挂了没人知道”的事故。

6. 把 SSH 写进代码:paramiko、Fabric、Ansible 各自的位置

6.1 paramiko 的最小可用代码与两个隐性坑

当需求变成“把结果解析成结构化数据”“在应用里调用”“要处理二进制输出”,shell 脚本就开始吃力了,这时候用 Python 库更合适。paramiko 是最轻的选择,纯 Python 实现,不依赖系统 ssh。

import paramiko def run(host, user, password, cmd, timeout=15): cli = paramiko.SSHClient() cli.set_missing_host_key_policy(paramiko.AutoAddPolicy()) try: cli.connect( hostname=host, username=user, password=password, timeout=timeout, banner_timeout=timeout, auth_timeout=timeout, look_for_keys=False, allow_agent=False, ) stdin, stdout, stderr = cli.exec_command(cmd, timeout=timeout) out = stdout.read().decode("utf-8", "replace") err = stderr.read().decode("utf-8", "replace") code = stdout.channel.recv_exit_status() return code, out, err finally: cli.close()

这段代码里有三个地方是新手最容易漏的。set_missing_host_key_policy(AutoAddPolicy())相当于accept-new,不设的话首次连接直接抛异常。look_for_keys=False, allow_agent=False明确关掉密钥和 agent,避免“我只想用密码认证,结果它偷偷去试了一堆密钥”导致认证次数超限。stdout.channel.recv_exit_status()必须显式调用才能拿到远端退出码,它是个阻塞调用,所以read()要在它之前完成,否则可能死锁——这是 paramiko 最经典的一个坑,缓冲区满了之后双方互相等。

用密钥认证就是把password换成key_filename="~/.ssh/id_ed25519_ops",并且记得展开成绝对路径,paramiko 不处理~。另外提醒一句,paramiko 不读~/.ssh/config,你在 config 里配的别名、跳板机、超时它一概不认,所有东西都要在代码里写全。很多“命令行能连、代码连不上”的问题都出在这里。

6.2 Fabric:把命令序列写成函数

Fabric 建在 paramiko 之上,价值在于把“一串命令 + 结果判断 + 文件传输”组织成可读的函数。

from fabric import Connection, ThreadingGroup def deploy(host): with Connection( host=host, user="ops", connect_kwargs={"key_filename": "/home/ops/.ssh/id_ed25519_ops"}, ) as c: c.run("df -h", hide=True, warn=True) c.put("app.tar.gz", "/tmp/app.tar.gz") r = c.sudo("systemctl restart app", warn=True) return host, r.ok hosts = ["10.0.0.11", "10.0.0.12", "10.0.0.13"] group = ThreadingGroup(*hosts, user="ops", connect_kwargs={"key_filename": "/home/ops/.ssh/id_ed25519_ops"}) results = group.run("uptime", hide=True) for conn, res in results.items(): print(conn.host, res.ok, res.stdout.strip())

warn=True表示命令返回非零不抛异常,把判断权留给调用方;不加的话 Fabric 会在第一个非零退出码上中断。批量场景一定要加,否则五十台机器里有一台服务没装,整个流程就断了。ThreadingGroup自带并发,几行代码就是一个小型并行执行器,比手写线程池干净得多。

6.3 Ansible:超过五台机器就别手写循环了

机器数量上到两位数、任务开始有幂等性要求、还要区分不同机型执行不同命令的时候,继续手写循环就是在造轮子。Ansible 用 SSH 做传输层,密码认证这一环它也是靠 sshpass 实现的,所以宿主机上还是得装 sshpass;但一旦切到密钥,整个链路就干净了。

# inventory.ini [web] web01 ansible_host=10.0.0.11 web02 ansible_host=10.0.0.12 [web:vars] ansible_user=ops ansible_ssh_private_key_file=/home/ops/.ssh/id_ed25519_ops ansible_ssh_common_args=-o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new
# ansible.cfg [defaults] host_key_checking = False forks = 20 timeout = 15

临时执行命令用 ad-hoc 模式就够了:

ansible web -i inventory.ini -m shell -a 'df -h' --tree /tmp/out

--tree会把每台主机的输出分别落盘,比在终端里翻滚动条舒服得多。如果确实只能用密码,用ansible_password配上ansible-vault加密的变量文件,别写在 inventory 明文里:

ansible-vault encrypt_string 'MyPassw0rd' --name 'ansible_password'

6.4 规模与工具选择的对照

把前面几种方式按规模排一下,选型基本不需要犹豫。

规模推荐方式理由
1 到 3 台,临时sshpass 或手动配密钥的时间比干活还长
3 到 10 台,重复执行密钥 + config + 连接复用一次性投入,收益立刻体现
10 到 50 台Fabric 或 shell + GNU parallel需要并发和结果汇聚
50 台以上Ansible 之类的配置管理工具需要幂等、分组、编排、审计
嵌入应用paramiko要结构化结果和异常控制

这张表不是教条。我见过三台机器的项目上 Ansible 的,也见过两百台机器用 shell 加一个 for 循环硬扛的——后者能跑,但加一台机器要改代码,换一批密码要全量重跑,维护成本迟早会还回来。

7. 命令发出去之后把连接断掉,远端任务还活着吗

7.1 有没有分配 tty,决定了进程会不会收到 SIGHUP

这是搜索量很高但答案经常互相矛盾的一个问题,值得单独讲清楚。结论是:取决于 ssh 有没有给远端分配伪终端。

不分配 tty(默认,相当于-T)时,ssh 连接断开只是关闭了 TCP 通道,远端没有终端设备需要挂断,内核不会发送 SIGHUP。所以像ssh host 'long_task.sh'这种写法,你 Ctrl+C 断开之后,远端进程通常还在跑。但它有个隐患:进程的标准输出还连在那条已经断掉的通道上,一旦它尝试写日志,就会收到SIGPIPE或者写入错误。很多脚本没处理写失败,直接在那一步挂掉,表现出来就是“任务跑了一半莫名其妙停了”。

分配了 tty(-t或-tt)时,远端 sshd 会创建一个伪终端,进程成为该终端的前台进程组。连接断开后伪终端被关闭,内核向该进程组发送SIGHUP,绝大多数前台进程就此结束。这也是为什么ssh -t host 'top'关掉窗口之后 top 就没了。

7.2 想让任务跑到底:nohup、setsid、systemd-run、tmux

知道原理之后,做法就很清楚了:要么脱离终端,要么脱离会话,要么把输出重定向到文件。四个常用手段从轻到重排列:

# 1. nohup,最简单,忽略 SIGHUP,输出重定向到文件 ssh host 'nohup /opt/bin/train.sh > /var/log/train.log 2>&1 &' # 2. setsid,新建会话,彻底脱离控制终端 ssh host 'setsid /opt/bin/train.sh > /var/log/train.log 2>&1 < /dev/null &' # 3. systemd-run,交给服务管理器,能查状态能重启 ssh host 'systemd-run --unit=train-001 --collect /opt/bin/train.sh' # 4. tmux,适合需要事后进去看现场的交互式任务 ssh host 'tmux new-session -d -s train "cd /opt && ./train.sh 2>&1 | tee /var/log/train.log"'

四个里我最常用的是setsid和tmux。setsid的好处是顺手,< /dev/null把标准输入也切断了,进程不会因为读到 EOF 而异常退出。tmux的好处是任务跑完之后还能tmux attach -t train进去看现场,排查长任务问题的时候特别省事。systemd-run最正规,能看到任务状态、能收日志、能重启,适合固化下来的定时任务。

nohup ... &这个写法有一处小陷阱:通过 ssh 执行时,&后面的命令有可能因为通道立即关闭而被截断。稳妥一点可以写成两条命令,或者干脆用setsid。

7.3 长任务的日志、检查点和事后取证

把任务丢到后台只是第一步,怎么知道它跑完了、跑得对不对,是更实际的问题。我的习惯是给每个长任务配三样东西:日志文件、退出码文件、检查点目录。

ssh host 'setsid bash -c " /opt/bin/train.sh > /var/log/train.log 2>&1 echo \$? > /var/log/train.exit " < /dev/null &'

任务结束后/var/log/train.exit里就是退出码,轮询这个文件比反复登进去ps更省事。日志文件用一个带时间戳的名字,避免多次执行互相覆盖。检查点则要看任务本身支不支持,训练类任务通常框架自带,业务脚本就得自己在关键步骤写状态文件。

断点续跑的实践思路是:任务启动时先读状态文件,判断从哪一步继续;每个阶段结束时更新状态文件。这样即使任务因为机器重启或者手工杀掉而中断,重新跑起来也能接着上一阶段继续,而不是从头再来。

7.4 怎么验证“它真的还活着”

别信感觉,动手测一次最靠谱。开一个终端执行:

ssh host 'setsid sleep 600 < /dev/null > /dev/null 2>&1 &' # 立刻 Ctrl+C 或者关掉终端

然后重新登录,ps -ef | grep sleep看进程还在不在,ps -o ppid= -p <pid>看它的父进程是不是变成了 1(被 init 收养)。如果立刻消失了,说明你的写法没失效,重新检查setsid或者< /dev/null有没有漏。

同样的方法也可以反过来验证ssh -t的行为:ssh -t host 'sleep 600',断开之后再登录,进程应该已经不在了。这个对比做一次,以后就不会再纠结这个问题了。

8. 排查链路:从 ssh -vvv 的三层输出里定位问题

8.1 -vvv 怎么读

ssh -vvv输出几百行,看着头晕,其实只要盯三个位置。第一层是连接阶段,看Connecting to ... port 22有没有出现、有没有Connection established;第二层是认证阶段,看Authentications that can continue:后面列了哪些方法,以及Offering public key:后面跟的是哪个密钥文件;第三层是会话阶段,看命令有没有被正确下发、通道有没有正常关闭。

ssh -vvv -o ConnectTimeout=5 ops@10.0.0.11 'uptime' 2>&1 | grep -E \ 'Connecting|Connection established|Authentications that can continue|Offering|Next authentication|Authenticated|channel 0'

加了 grep 之后输出会短很多。如果看到Authentications that can continue: publickey,password,说明服务端同时接受两种方式,接下来要看 ssh 试了哪些密钥——Offering public key出现的次数和文件路径能直接告诉你它有没有用对密钥。如果最后出现Next authentication method: password,那就说明密钥这条路没走通,脚本里又没配密码,自然会失败。

8.2 高频报错对照表

把常见报错和处理动作列出来,遇到问题直接查表比翻文档快。

报错或现象大概率原因处理动作
Host key verification failed首次连接或指纹变更ssh-keygen -R host清旧记录,再用accept-new重连
REMOTE HOST IDENTIFICATION HAS CHANGED!机器重装、IP 被复用确认是预期变更后清 known_hosts,别直接关校验
Permission denied (publickey)密钥没下发或权限不对检查authorized_keys、家目录和.ssh权限
Permission denied (password)密码错或用错认证方式确认不是 keyboard-interactive 或二次验证
Connection refusedsshd 没起或端口不对检查监听端口、服务状态
Connection timed out网络或安全组拦截加ConnectTimeout,分层排查网络可达性
ssh: command not found/ Windows 下“不是内部或外部命令”客户端没装或 PATH 不对Linux 装 openssh-client,Windows 启用 OpenSSH 客户端功能
服务端完全没有响应sshd 未安装或未启动检查systemctl status ssh,确认端口和监听地址

国产化系统上还常见两个:一是 sshd 升级之后配置文件里新增了默认项,老的配置段不兼容导致启动失败,看sshd -t的输出就知道;二是系统自带的 ssh 版本较低,不支持accept-new这类参数,需要改用预灌 known_hosts 的方式。

8.3 批量脚本里必须常驻的三个参数

不管用什么工具,我在所有批量脚本里都会加上这三个参数,它们能挡掉八成“脚本莫名其妙挂着”的问题。

-o ConnectTimeout=5 # 连不上就五秒返回,不卡住整个循环 -o StrictHostKeyChecking=accept-new # 首次连接自动接受,指纹变更仍然拒绝 -o BatchMode=yes # 禁止一切交互提示,要失败就立刻失败

如果用了连接复用或者长任务,再加一对心跳参数:

-o ServerAliveInterval=30 -o ServerAliveCountMax=3

三十秒没收到响应就发一次探测,连续三次没回应就断开。这样即使中间有防火墙静默丢包,脚本也能在一个可控的时间内失败,而不是无限期地挂着。

8.4 并发执行和编辑器远程连接的两类冷门问题

批量执行上到几十上百台时,第一个撞上的是并发数。用xargs -P或者 GNU parallel 都很方便:

grep -v '^#' hosts.txt | xargs -P 20 -I{} ssh -o ConnectTimeout=5 -o BatchMode=yes {} 'uptime'

二十路并发是个比较稳的经验值。再往上加,可能会碰到服务端的MaxStartups(默认 10:30:100,意思是超过十个未认证连接后开始按 30% 概率丢弃)。症状是随机几台报连接被拒,重跑一次又好了,非常误导人。这时候要么降并发,要么让运维调整服务端参数。

另一类问题是编辑器远程开发场景。用远程开发插件连服务器时,偶尔会遇到“此扩展在此工作区中被禁用,因为它被定义为在远程扩展主机中运行”这类提示。这个不是 SSH 本身的问题,而是插件被安装在了本地、却需要运行在远程主机上。解决方式是把它装到远程侧,或者在远程窗口里重新安装一次。同时要注意远程开发会占用一条持续的 SSH 连接,跑大批量脚本之前先确认自己的连接数和复用配置不会互相抢资源。

密码这块我最后再强调一句:所有涉及明文密码的写法——sshpass 的-p、expect 的参数、askpass 脚本、inventory 里的ansible_password——在正式环境里都只能算过渡方案。密码该轮换就轮换,该进密钥就进密钥,临时文件用完立刻删。我在几套脚本上吃过这个亏:三个月前图省事写下的密码,后来机器换了、人走了、密码没改,那份脚本就成了一个谁也不敢动的定时炸弹。真正让我省心的从来不是哪个工具的一行命令,而是把密钥下发、连接复用、配置文件这三件事老老实实做完的那一个小时。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询