如何批量执行服务器命令?SSH密钥与自动化运维方案解析

管一台服务器和管几十台,工作方式完全不同。前者的日常是登录、敲命令、退出;后者的日常是"同样的事做很多遍"。比如想看看所有机器的磁盘还剩多少,或者给一批机器统一装个软件,一台台登录显然不现实。批量执行命令是从"单机运维"迈向"规模运维"的第一道坎。

这篇文章讲清楚三件事:怎么配置 SSH 免密登录让批量操作不需要每次输密码;有哪些批量执行命令的方式,从最简单的 shell 循环到并发的 xargs -P、专业工具 pdsh 和 Ansible;批量操作的结果怎么收集和格式化。同时必须讲透一个容易被忽略的话题——免密登录的安全风险,以及如何用最小权限把风险控制住。工欲善其事,先把 SSH 密钥这件事做对,后面所有批量手段都建立在这个基础上。

先配好 SSH 密钥登录

批量执行的前提是"不用输密码就能连上每台机器"。这就是 SSH 公钥认证:把本地的公钥放进每台目标机的 ~/.ssh/authorized_keys,之后登录就不需要密码了。

第一步,在控制机上生成密钥(如果还没有):

ssh-keygen -t ed25519 -C "ops@control" -f ~/.ssh/id_ed25519

推荐用 ed25519,它比 rsa 更短、更快、更安全,现代系统都支持。如果你需要兼容很老的设备,才退而使用 rsa 并至少 3072 位。生成时如果不想设密码短语(passphrase),直接回车;但生产环境建议设一个,再用 ssh-agent 免得每次输入:

eval "$(ssh-agent -s)"
ssh-add ~/.ssh/id_ed25519

第二步,把公钥分发到目标机。最简单的是 ssh-copy-id:

ssh-copy-id -i ~/.ssh/id_ed25519.pub ops@192.0.2.20
ssh-copy-id -i ~/.ssh/id_ed25519.pub -p 2222 ops@192.0.2.21   # 非默认端口

它会把公钥追加到目标机的 ~/.ssh/authorized_keys,并自动设好目录和文件权限。目标机如果还没配好目录权限,可能出现"文件名太长"之类的报错,这时手动处理:

# 在目标机上
mkdir -p ~/.ssh && chmod 700 ~/.ssh
touch ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys

第三步,配 ~/.ssh/config 简化连接。机器多了以后,记 IP、端口、用户名很麻烦,配置文件能省事:

Host web-01
    HostName 192.0.2.20
    User ops
    IdentityFile ~/.ssh/id_ed25519

Host web-0*
    User ops
    IdentityFile ~/.ssh/id_ed25519

Host *
    ServerAliveInterval 30
    ServerAliveCountMax 3

之后直接 ssh web-01 即可。ServerAliveInterval 能避免长时间空闲连接被防火墙断开。

风险提示: SSH 密钥等同于密码,且往往权限更大。私钥文件权限必须是 600,绝不要提交进 Git、不要放共享目录。生成密钥时建议设密码短语,并限制控制机的登录用户。下一节会专门讲最小权限的做法。

最简单的批量:shell 循环

有了免密登录,最朴素的批量方式就是一个 for 循环:

#!/bin/bash
HOSTS="web-01 web-02 web-03 db-01"
for h in $HOSTS; do
  echo "===== $h ====="
  ssh "$h" "uptime; df -h / | tail -1"
done

这个写法直观,适合三五台机器。但它的问题也很明显:

  • 串行执行,十台机器一台台等,慢。
  • 没有错误处理,某台连不上会卡住或报错,但循环继续,容易漏。
  • 输出混在一起,分不清哪行是哪台机器的(虽然加了分隔符,但长输出会乱)。
  • 交互式命令有问题,某些命令会因为不是 tty 而行为异常。

改进版可以加超时和错误捕获:

#!/bin/bash
HOSTS="web-01 web-02 web-03"
for h in $HOSTS; do
  out=$(ssh -o ConnectTimeout=5 -o BatchMode=yes "$h" "uptime" 2>&1)
  rc=$?
  if [ $rc -ne 0 ]; then
    echo "[FAIL] $h: $out"
  else
    echo "[ OK ] $h: $out"
  fi
done

ConnectTimeout=5 避免长时间卡住,BatchMode=yes 让它不弹密码提示(没配好密钥就直接失败,而不是挂在那等输入)。这两个参数在批量场景里几乎是必加的。

并发执行:xargs -P 与 GNU parallel

机器多了,串行太慢,需要并发。有两种常见做法。

用 xargs -P 并发。 思路是把主机列表通过管道传给 xargs,让它并行处理:

cat hosts.txt | xargs -P 10 -I {} sh -c '
  out=$(ssh -o ConnectTimeout=5 -o BatchMode=yes {} "uptime" 2>&1)
  echo "===== {} ====="
  echo "$out"
'

-P 10 表示最多同时十个进程,-I {} 指定替换符。注意 -I 会隐含每个输入单独执行一次,sh -c 里用 {} 替换主机名。并发数要合理:控制机的 CPU、SSH 连接数和目标机的负载都要考虑,十到二十是一个常见起点,别一上来开一百。

用 GNU parallel,表达力更强:

# Debian、Ubuntu
sudo apt install -y parallel
# RHEL 系需要 EPEL
sudo dnf install -y parallel

parallel -j 10 --tag --timeout 30 \
  'ssh -o ConnectTimeout=5 -o BatchMode=yes {} "uptime"' ::: $(cat hosts.txt)

-j 10 是并发数,--tag 会给每行输出打上主机名前缀(解决输出混淆问题),--timeout 30 是超时。GNU parallel 还有 --joblog 记录执行结果,很适合做批量任务。

并发方案的核心收益是速度,但代价是输出交错、错误定位变难。所以并发时务必用 --tag 或自己加分隔符,让输出可追溯。

专业工具:pdsh 与 cluster-ssh

如果批量操作的场景很固定,可以用专门为集群设计的工具。

pdsh(Parallel Distributed Shell)是高性能集群里常见的工具,一条命令并发跑遍所有节点:

# RHEL、Rocky、CentOS
sudo dnf install -y pdsh pdsh-rcmd-ssh
# Debian、Ubuntu
sudo apt install -y pdsh

pdsh -w ^hosts.txt -R ssh "uptime"
pdsh -w web-[01-10] -R ssh "df -h /"
pdsh -w ^hosts.txt -R ssh -l ops "systemctl is-active nginx"

-w 指定主机列表(^文件 表示从文件读,web-[01-10] 是范围展开),-R ssh 指定用 SSH 作为远程执行方式,-l 指定用户。pdsh 默认并发执行,输出带主机名前缀。它的定位就是"快速把一条命令铺到一堆机器",比手写循环省事。

cluster-ssh(clush)是另一个同类工具,来自 clustershell 包,支持分组配置文件,也支持把输出按主机名聚合:

# RHEL、Rocky、CentOS
sudo dnf install -y clustershell
# Debian、Ubuntu
sudo apt install -y clustershell

clush -w web-[01-10] -b "uptime"        # -b 显示每台结果
clush -w @web "df -h /"                 # 用分组配置

用分组的做法是在 /etc/clustershell/groups.d/local.cfg 里定义:

[web]
web-[01-10]
[db]
db-[01-03]

之后 clush -w @web 就能操作整组。这让日常操作更接近"按角色运维"的思路。

与 Ansible ad-hoc 的对比

前面讲过 Ansible,它的 ad-hoc 命令本质上也是一种批量执行方式。那什么时候用 SSH 循环,什么时候用 Ansible?这张表讲清楚:

方案 依赖 幂等 并发 适合场景
shell for 循环 仅 SSH 否 否 两三台,临时一次性
xargs -P / parallel 仅 SSH 否 是 有 SSH 环境,想快速并发
pdsh / clush 需安装工具 否 是 集群固定,命令式批量
Ansible ad-hoc 需 Ansible 部分是(用模块) 是 有 Ansible 环境,想用模块
Ansible playbook 需 Ansible 是 是 需要可复用、可版本管理的变更

简单结论:纯查询、看一眼(比如看所有机器的磁盘),SSH 循环或 pdsh 就够了,快、轻、不引入额外依赖;要改配置、装软件、管服务,优先用 Ansible,因为它的模块幂等,重复执行安全。用 shell 循环去改配置是最危险的组合——没有幂等、没有一致性校验、失败也没有回滚。

举两个对照例子,同一件事的两种做法。看内存:

# 方式一:SSH 循环
for h in $(cat hosts.txt); do echo "== $h =="; ssh "$h" "free -h | head -2"; done

# 方式二:Ansible ad-hoc
ansible all -i hosts -m shell -a "free -h | head -2"

装 htop 并保证存在:

# 危险做法:shell 循环,重复执行会重复装、可能报错
for h in $(cat hosts.txt); do ssh "$h" "yum install -y htop"; done

# 正确做法:Ansible 模块,幂等
ansible all -i hosts -m package -a "name=htop state=present"

装包这件事在 shell 循环里就是 yum install -y,重复跑没坏处但也没保障;用 Ansible 的 package 模块,已经装了就显示 ok 不动它,这叫幂等。

结果收集与格式化

批量执行最容易失控的部分是输出。二十台机器的输出混在一起,找不到重点。几个实用技巧:

加分隔符和主机名:

for h in $(cat hosts.txt); do
  echo "########## $h ##########"
  ssh "$h" "命令" 2>&1
  echo
done

只输出关键行,减少噪音。比如看磁盘只取使用率大于百分之八十的:

for h in $(cat hosts.txt); do
  line=$(ssh -o ConnectTimeout=5 "$h" "df -P / | tail -1" 2>/dev/null)
  pct=$(echo "$line" | awk '{print $5}' | tr -d '%')
  if [ "${pct:-0}" -gt 80 ] 2>/dev/null; then
    echo "[$h] 根分区使用率 ${pct}%"
  fi
done

汇总成表格,用 awk 加工:

for h in $(cat hosts.txt); do
  ssh -o ConnectTimeout=5 "$h" "awk '/MemAvailable/{a=\$2} /MemTotal/{t=\$2} END{printf \"%.1f\",100-a*100/t}' /proc/meminfo" 2>/dev/null \
    | awk -v host="$h" '{print host, $1"%"}'
done | sort -k2 -nr

sort -k2 -nr 按第二列数值倒序,一眼看出哪台内存最紧张。

用 Ansible 的结构化输出:Ansible 返回 JSON,可以用 -o 压成一行,或配合 jq 处理:

ansible all -i hosts -m shell -a "uptime" -o | sed 's/ =>.*//'

结构化的好处是能程序化处理,比如导出成 CSV、喂给监控系统。这也是 Ansible 相对裸 SSH 的一大优势。

免密登录的安全风险与最小权限

批量执行让效率提升的同时,也让风险放大了:一把私钥能连几十台机器,一旦泄露,攻击者可以一次性拿下整个集群。所以安全措施必须跟上。

第一,控制机的私钥加密码短语。 配合 ssh-agent 使用,密钥文件即使泄露,没有短语也难用。

第二,不要用 root 直接免密登录,而是用普通运维账号加 sudo。 在目标机给运维账号配置精细的 sudo 权限,只允许执行需要的命令:

# /etc/sudoers.d/ops(用 visudo 编辑,避免语法错误锁死系统)
ops ALL=(root) NOPASSWD: /bin/systemctl restart nginx, /bin/systemctl status nginx

NOPASSWD 只给必要的几条命令,而不是 ALL。这样即使密钥泄露,攻击者能做的也有限。

第三,从网络的层面限制。 控制机应该只能从固定 IP 访问目标机的 SSH,防火墙层面限定来源:

# 只允许控制机访问 22 端口
sudo firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="192.0.2.10" port port="22" protocol="tcp" accept'
sudo firewall-cmd --permanent --remove-service=ssh
sudo firewall-cmd --reload

风险提示: 上面的命令会先删除默认的 ssh 服务放行规则,再加一条只允许控制机 IP 的规则。执行前必须确认控制机 IP 正确且当前会话不会被切断,否则可能把自己锁在门外。更稳妥的做法是保留一个备用登录方式(比如云控制台的 VNC),改完立即用新会话验证能登录,再关闭旧会话。一旦配错,回滚方式是进 VNC 控制台把规则删掉。

第四,定期轮换和审计。 定期更换密钥,在目标机开启 SSH 登录审计(记录谁在什么时候用什么密钥登录、执行了什么),发现异常能追溯。authorized_keys 里可以给关键公钥加 from= 限制来源 IP:

from="192.0.2.10" ssh-ed25519 AAAA... ops@control

这样这把公钥只能从控制机使用,进一步收窄了风险面。

第五,敏感操作走跳板机(堡垒机)。 规模再大一些时,不让运维直接连生产机,而是统一从跳板机登录,所有操作留痕。跳板机是审计的集中点,也是权限的收口。

常见问题

ssh-copy-id 报错 "No such file or directory" 或权限错误?

多数是目标机 ~/.ssh 目录或 authorized_keys 权限不对。SSH 要求 .ssh 是 700、authorized_keys 是 600、家目录不能被组或其他用户写。登录时会检查权限,不合格就拒绝使用公钥。在目标机执行 chmod 700 ~/.ssh && chmod 600 ~/.ssh/authorized_keys && chmod g-w,o-w ~ 修复。

并发执行时大量 "Permission denied" 或连接被拒?

可能是目标机 SSH 的并发限制,比如 MaxStartups 参数(默认 10:30:100,即超过十个未认证连接开始随机拒绝)。并发数开太大就会撞上。降低 -P 的并发数,或调整目标机 /etc/ssh/sshd_config 的 MaxStartups。也可能是触发了 fail2ban 这类防护,被临时封禁。

命令在远程执行时找不到,但登录手动敲就行?

这是环境变量差异。非交互 SSH 不会加载 ~/.bashrc 里的 PATH 设置。解决方法是命令里用绝对路径,或者显式加载环境:ssh host "source /etc/profile; 你的命令"。注意 bash -lc "命令" 也能加载登录环境,但要注意它可能引入额外的输出。

怎样才能让批量执行"失败就停"而不是继续?

xargs 里加 -P 后不容易停,但可以检查退出码。更可控的做法是用脚本收集所有失败主机,最后统一报告并返回非零退出码。Ansible 则天然支持 any_errors_fatal 和 max_fail_percentage,失败到一定程度自动中止,所以复杂场景还是推荐 Ansible。

总结

  • 批量执行的前提是 SSH 公钥认证,先用 ssh-keygen 加 ssh-copy-id 配好免密登录,~/.ssh/config 能简化连接。
  • 三台以内用 shell 循环加 ConnectTimeout 和 BatchMode 就够;机器多了用 xargs -P 或 GNU parallel 并发,用 --tag 保证输出可追溯。
  • 固定的集群场景可以用 pdsh 或 clush,一条命令并发铺开,比手写循环省事。
  • 纯查询用 SSH 循环或 pdsh 足够,改配置、装软件、管服务一定用 Ansible 的幂等模块,不要用非幂等的 shell 循环去改生产配置。
  • 结果收集要加分隔符、只输出关键行、能汇总成表格,Ansible 的 JSON 输出便于程序化处理。
  • 免密登录放大了风险,必须用最小权限:私钥加密码短语、不用 root 免密、sudo 最小授权、网络层限定来源、定期轮换审计,规模大时上跳板机。