3. Shell脚本批量运维核心技巧
批量运维脚本与一次性命令的最大区别,在于它必须能稳定地处理“一批不确定的对象”。主机列表可能长短不一,某台机器可能临时不可达,某个服务可能已经处于目标状态。如果脚本只会从上到下顺序执行,任何一次失败都会让整批任务中断,或者更糟——静默跳过却让人误以为全部成功。因此,一个可复用的批量脚本需要五个支柱:循环负责遍历对象,条件负责判断状态,函数负责封装重复逻辑,参数负责把目标与策略外置,错误处理与日志负责让结果可追溯、可判断。
循环:把“一台”扩展为“一批”
批量操作的第一层抽象是循环。最常用的是 for 遍历主机清单,配合 while read 逐行读取文件,可以避免把主机名硬编码在脚本里。下面是一个读取清单并逐台探测连通性的骨架:
#!/bin/bash
HOSTS_FILE="${1:-/etc/ops/hosts.list}"
while IFS= read -r host; do
[[ -z "$host" || "$host" =~ ^# ]] && continue
if ping -c1 -W1 "$host" >/dev/null 2>&1; then
echo "[OK] $host reachable"
else
echo "[FAIL] $host unreachable"
fi
done < "$HOSTS_FILE"
这里有两个细节值得注意:IFS= 防止行首尾空白被吞掉,-r 防止反斜杠被解释;continue 跳过空行和以 # 开头的注释行,让清单文件本身可以带说明。循环体内不要直接 exit,否则第一台失败就会终止整批任务。
条件控制:判断“是否需要做”和“是否做成了”
条件控制分两类。一类是前置判断,决定是否执行操作,例如“服务未运行才启动”“文件不存在才创建”,这类判断能减少无谓变更,也让脚本具备幂等性。另一类是结果判断,根据命令退出码决定后续走向。Shell 中 $? 保存上一条命令的退出码,0 表示成功,非 0 表示失败。常见写法是:
if systemctl is-active --quiet httpd; then
echo "httpd already running"
else
systemctl start httpd && echo "httpd started" || echo "start failed"
fi
注意 && 与 || 的短路特性:前者在成功时执行,后者在失败时执行。它们适合简短分支,复杂逻辑仍应使用 if/elif/else,以免可读性下降。
函数封装:让重复逻辑只写一次
当脚本里出现多次“记录日志 + 执行命令 + 判断结果”的组合时,就应该抽成函数。函数把变化的部分通过参数传入,把不变的部分固化下来。例如统一的任务执行函数:
run_task() {
local host="$1"; shift
local desc="$1"; shift
log_info "$host" "START $desc"
if ssh -o BatchMode=yes -o ConnectTimeout=5 "$host" "$@"; then
log_info "$host" "OK $desc"
return 0
else
log_error "$host" "FAIL $desc (rc=$?)"
return 1
fi
}
local 限定变量作用域,避免污染全局;shift 把已消费的位置参数移出,剩余参数原样传给 ssh。这样调用方只需写 run_task web01 "restart nginx" systemctl restart nginx,逻辑清晰且易于复用。
参数传递:把目标与策略外置
可复用脚本不应把主机、路径、阈值写死。推荐用“位置参数 + 选项”的组合:位置参数给必填项,getopts 处理可选项。同时用 ${VAR:-default} 提供默认值,用 ${VAR:?message} 在缺失时直接报错退出。例如:
usage() { echo "Usage: $0 -f hostfile [-t timeout] [-n dry-run]"; }
while getopts ":f:t:n" opt; do
case $opt in
f) HOSTS_FILE="$OPTARG" ;;
t) TIMEOUT="$OPTARG" ;;
n) DRY_RUN=1 ;;
\?) usage; exit 2 ;;
esac
done
: "${HOSTS_FILE:?host file is required}"
这样同一份脚本既能用于生产主机清单,也能用于测试清单;-n 干跑模式让操作者先看清将要做什么,再决定是否真正执行。
错误处理与日志:让结果可判断
批量脚本最忌讳“看起来跑完了,但不知道哪台成功”。建议在脚本开头设置 set -u 防止未定义变量,谨慎使用 set -e(它会因单条命令失败而整体退出,可能不适合需要继续处理其余主机的场景)。更稳妥的做法是显式捕获退出码并累计失败数:
FAILED=0
for host in $(cat "$HOSTS_FILE"); do
run_task "$host" "check disk" "df -h /" || ((FAILED++))
done
echo "total failed: $FAILED"
[[ $FAILED -eq 0 ]] && exit 0 || exit 1
日志函数应统一格式,包含时间、主机、级别和消息,并同时输出到终端与文件:
LOG_FILE="/var/log/ops/batch-$(date +%F).log"
log() {
local level="$1" host="$2" msg="$3"
printf '%s [%s] [%s] %s\n' "$(date '+%F %T')" "$level" "$host" "$msg" | tee -a "$LOG_FILE"
}
log_info() { log INFO "$1" "$2"; }
log_error() { log ERROR "$1" "$2"; }
最终判断依据不是“脚本有没有报错”,而是退出码与失败计数:退出码 0 且 FAILED=0 表示整批成功;否则应查看日志中 [ERROR] 行定位具体主机与任务。