
上个月帮一家做设备巡检的团队收拾脚本核心诉求很朴素一台跳板机后面挂着四十多台服务器每天要跑一遍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 用的是纯前者ptySSH_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 StrictHostKeyCheckingaccept-new ops10.0.0.11 uptime这里有两个细节必须说清楚。第一StrictHostKeyCheckingaccept-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 ops10.0.0.11 uptime # 方式二从环境变量读脚本里不出现密码字面量 export SSHPASSMyPassw0rd sshpass -e ssh ops10.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 ops10.0.0.11 uptime # 方式四从指定文件描述符读进程列表和文件系统里都看不到 exec 3MyPassw0rd sshpass -d 3 ssh ops10.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 admin192.168.1.1 show version-P的语义是“密码提示中包含这个子串”所以给个足够独特的片段就行不需要写完整句子。另外建议在 ssh 那一侧显式加超时避免脚本被一个不响应的设备挂死sshpass -f /run/ops.pass ssh -o ConnectTimeout5 -o BatchModeno ops10.0.0.11 uptimeConnectTimeout5管的是 TCP 连接建立阶段五秒连不上直接返回不会卡住整个循环。批量脚本里这个参数是刚需后面第 8 节还会再说一次。2.4 退出码与常见报错对照sshpass 本身出错时返回 1 到 6正常执行时会把 ssh 的退出码原样透传。这个特性很重要意味着你在脚本里可以按 ssh 的语义判断成功失败但也要注意区分“sshpass 挂了”和“远端命令返回非零”。现象大致原因处理动作Permission denied, please try again连续三次密码错或认证方式不匹配先手动登录验证一次确认不是 keyboard-interactiveHost key verification failed首次连接且未预置指纹加StrictHostKeyCheckingaccept-new或预灌 known_hosts卡住不动直到超时提示串不匹配sshpass 在等用-P调整或加ConnectTimeoutsshpass: 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 MyPassw0rdchmod 700 /opt/ops/askpass.sh # 关键必须让 ssh 拿不到当前终端 setsid -w env SSH_ASKPASS/opt/ops/askpass.sh SSH_ASKPASS_REQUIREforce \ DISPLAY:0 ssh -o StrictHostKeyCheckingaccept-new ops10.0.0.11 uptime这里三个东西缺一不可。setsid让 ssh 脱离当前控制终端否则 ssh 会优先用/dev/tty而不是 askpass 程序DISPLAY在旧版本里是硬性条件askpass 机制来自图形化场景SSH_ASKPASS_REQUIREforce是新版本的关键。3.2 SSH_ASKPASS_REQUIREforce新版本里更省事的写法OpenSSH 8.4 引入了SSH_ASKPASS_REQUIRE环境变量取值有三个auto默认等价于旧行为、force无视终端强制走 askpass、never禁止使用 askpass。有了它前面那串绕来绕去的写法可以简化SSH_ASKPASS/opt/ops/askpass.sh SSH_ASKPASS_REQUIREforce \ ssh -o StrictHostKeyCheckingaccept-new ops10.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 StrictHostKeyCheckingaccept-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 ConnectTimeout5 -o StrictHostKeyCheckingaccept-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 opsbatch -f ~/.ssh/id_ed25519_ops -N # 下发公钥需要最后一次输入密码 ssh-copy-id -i ~/.ssh/id_ed25519_ops.pub ops10.0.0.11 # 没有 ssh-copy-id 的环境手动追加 cat ~/.ssh/id_ed25519_ops.pub | ssh ops10.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让脚本要么成功要么立刻失败BatchModeyes的含义是禁止一切交互式提示不弹密码、不弹主机确认、不弹 passphrase。加上它之后脚本的行为就变成二元的——认证能过就跑过不了立刻返回失败不会挂在提示上等一整天。ssh -o BatchModeyes -o ConnectTimeout5 web01 uptime || echo web01 需要人工介入我在流水线里的标准配置是批量巡检任务加BatchModeyes主动去发现那些密钥没配好、或者被误改配置的主机人工临时操作的时候不加保留输密码的余地。这两种模式分开能避免很多“流水线凌晨挂了没人知道”的事故。6. 把 SSH 写进代码paramiko、Fabric、Ansible 各自的位置6.1 paramiko 的最小可用代码与两个隐性坑当需求变成“把结果解析成结构化数据”“在应用里调用”“要处理二进制输出”shell 脚本就开始吃力了这时候用 Python 库更合适。paramiko 是最轻的选择纯 Python 实现不依赖系统 ssh。import paramiko def run(host, user, password, cmd, timeout15): cli paramiko.SSHClient() cli.set_missing_host_key_policy(paramiko.AutoAddPolicy()) try: cli.connect( hostnamehost, usernameuser, passwordpassword, timeouttimeout, banner_timeouttimeout, auth_timeouttimeout, look_for_keysFalse, allow_agentFalse, ) stdin, stdout, stderr cli.exec_command(cmd, timeouttimeout) 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_keysFalse, allow_agentFalse明确关掉密钥和 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( hosthost, userops, connect_kwargs{key_filename: /home/ops/.ssh/id_ed25519_ops}, ) as c: c.run(df -h, hideTrue, warnTrue) c.put(app.tar.gz, /tmp/app.tar.gz) r c.sudo(systemctl restart app, warnTrue) return host, r.ok hosts [10.0.0.11, 10.0.0.12, 10.0.0.13] group ThreadingGroup(*hosts, userops, connect_kwargs{key_filename: /home/ops/.ssh/id_ed25519_ops}) results group.run(uptime, hideTrue) for conn, res in results.items(): print(conn.host, res.ok, res.stdout.strip())warnTrue表示命令返回非零不抛异常把判断权留给调用方不加的话 Fabric 会在第一个非零退出码上中断。批量场景一定要加否则五十台机器里有一台服务没装整个流程就断了。ThreadingGroup自带并发几行代码就是一个小型并行执行器比手写线程池干净得多。6.3 Ansible超过五台机器就别手写循环了机器数量上到两位数、任务开始有幂等性要求、还要区分不同机型执行不同命令的时候继续手写循环就是在造轮子。Ansible 用 SSH 做传输层密码认证这一环它也是靠 sshpass 实现的所以宿主机上还是得装 sshpass但一旦切到密钥整个链路就干净了。# inventory.ini [web] web01 ansible_host10.0.0.11 web02 ansible_host10.0.0.12 [web:vars] ansible_userops ansible_ssh_private_key_file/home/ops/.ssh/id_ed25519_ops ansible_ssh_common_args-o ConnectTimeout5 -o StrictHostKeyCheckingaccept-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_password6.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这种写法你 CtrlC 断开之后远端进程通常还在跑。但它有个隐患进程的标准输出还连在那条已经断掉的通道上一旦它尝试写日志就会收到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 21 # 2. setsid新建会话彻底脱离控制终端 ssh host setsid /opt/bin/train.sh /var/log/train.log 21 /dev/null # 3. systemd-run交给服务管理器能查状态能重启 ssh host systemd-run --unittrain-001 --collect /opt/bin/train.sh # 4. tmux适合需要事后进去看现场的交互式任务 ssh host tmux new-session -d -s train cd /opt ./train.sh 21 | 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 21 echo \$? /var/log/train.exit /dev/null 任务结束后/var/log/train.exit里就是退出码轮询这个文件比反复登进去ps更省事。日志文件用一个带时间戳的名字避免多次执行互相覆盖。检查点则要看任务本身支不支持训练类任务通常框架自带业务脚本就得自己在关键步骤写状态文件。断点续跑的实践思路是任务启动时先读状态文件判断从哪一步继续每个阶段结束时更新状态文件。这样即使任务因为机器重启或者手工杀掉而中断重新跑起来也能接着上一阶段继续而不是从头再来。7.4 怎么验证“它真的还活着”别信感觉动手测一次最靠谱。开一个终端执行ssh host setsid sleep 600 /dev/null /dev/null 21 # 立刻 CtrlC 或者关掉终端然后重新登录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 ConnectTimeout5 ops10.0.0.11 uptime 21 | 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-clientWindows 启用 OpenSSH 客户端功能服务端完全没有响应sshd 未安装或未启动检查systemctl status ssh确认端口和监听地址国产化系统上还常见两个一是 sshd 升级之后配置文件里新增了默认项老的配置段不兼容导致启动失败看sshd -t的输出就知道二是系统自带的 ssh 版本较低不支持accept-new这类参数需要改用预灌 known_hosts 的方式。8.3 批量脚本里必须常驻的三个参数不管用什么工具我在所有批量脚本里都会加上这三个参数它们能挡掉八成“脚本莫名其妙挂着”的问题。-o ConnectTimeout5 # 连不上就五秒返回不卡住整个循环 -o StrictHostKeyCheckingaccept-new # 首次连接自动接受指纹变更仍然拒绝 -o BatchModeyes # 禁止一切交互提示要失败就立刻失败如果用了连接复用或者长任务再加一对心跳参数-o ServerAliveInterval30 -o ServerAliveCountMax3三十秒没收到响应就发一次探测连续三次没回应就断开。这样即使中间有防火墙静默丢包脚本也能在一个可控的时间内失败而不是无限期地挂着。8.4 并发执行和编辑器远程连接的两类冷门问题批量执行上到几十上百台时第一个撞上的是并发数。用xargs -P或者 GNU parallel 都很方便grep -v ^# hosts.txt | xargs -P 20 -I{} ssh -o ConnectTimeout5 -o BatchModeyes {} uptime二十路并发是个比较稳的经验值。再往上加可能会碰到服务端的MaxStartups默认 10:30:100意思是超过十个未认证连接后开始按 30% 概率丢弃。症状是随机几台报连接被拒重跑一次又好了非常误导人。这时候要么降并发要么让运维调整服务端参数。另一类问题是编辑器远程开发场景。用远程开发插件连服务器时偶尔会遇到“此扩展在此工作区中被禁用因为它被定义为在远程扩展主机中运行”这类提示。这个不是 SSH 本身的问题而是插件被安装在了本地、却需要运行在远程主机上。解决方式是把它装到远程侧或者在远程窗口里重新安装一次。同时要注意远程开发会占用一条持续的 SSH 连接跑大批量脚本之前先确认自己的连接数和复用配置不会互相抢资源。密码这块我最后再强调一句所有涉及明文密码的写法——sshpass 的-p、expect 的参数、askpass 脚本、inventory 里的ansible_password——在正式环境里都只能算过渡方案。密码该轮换就轮换该进密钥就进密钥临时文件用完立刻删。我在几套脚本上吃过这个亏三个月前图省事写下的密码后来机器换了、人走了、密码没改那份脚本就成了一个谁也不敢动的定时炸弹。真正让我省心的从来不是哪个工具的一行命令而是把密钥下发、连接复用、配置文件这三件事老老实实做完的那一个小时。