上个月帮一家做设备巡检的团队收拾脚本,核心诉求很朴素:一台跳板机后面挂着四十多台服务器,每天要跑一遍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'权限这件事每年都要栽一批人,我把该有的值列成表,照着设基本不会出问题。
| 路径 | 权限 | 说明 |
|---|---|---|
~/.ssh | 700 | 目录太开放 sshd 直接拒绝 |
~/.ssh/id_ed25519_ops | 600 | 私钥不能被同组或其他人读 |
~/.ssh/id_ed25519_ops.pub | 644 | 公钥无所谓 |
~/.ssh/authorized_keys | 600 | 服务端检查的是文件本身和上层目录 |
| 家目录本身 | 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 refused | sshd 没起或端口不对 | 检查监听端口、服务状态 |
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——在正式环境里都只能算过渡方案。密码该轮换就轮换,该进密钥就进密钥,临时文件用完立刻删。我在几套脚本上吃过这个亏:三个月前图省事写下的密码,后来机器换了、人走了、密码没改,那份脚本就成了一个谁也不敢动的定时炸弹。真正让我省心的从来不是哪个工具的一行命令,而是把密钥下发、连接复用、配置文件这三件事老老实实做完的那一个小时。