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] 行定位具体主机与任务。

批量运维脚本执行链:输入 → 循环 → 条件 → 函数 → 结果判定 输入 hosts.list 主机清单 -f / -t / -n 参数 循环遍历 while read host 跳过空行 / # 注释 条件判断 前置:是否需执行 结果:退出码 $? 函数封装 run_task local 变量 / shift 传参 ssh 执行远程命令 成功:rc=0 log_info OK,继续下一台 失败:rc≠0 log_error FAIL,FAILED++ 统一日志 log() 时间 + 级别 + 主机 + 消息 tee 同时输出终端与文件 /var/log/ops/batch-日期.log 失败计数 FAILED 汇总 最终判定 FAILED=0 → exit 0 关键约定: ① 循环体内不 exit,单台失败不影响整批;② 条件判断区分“前置幂等判断”与“结果退出码判断”; ③ 函数用 local 隔离变量、shift 透传参数;④ 日志统一格式并落盘;⑤ 以 FAILED 计数与退出码作为最终判据。
可复用脚本的验收标准不是“能跑”,而是“换一份主机清单、换一个任务描述,仍然能跑,并且失败时能明确指出是哪台主机、哪个任务、什么原因”。把循环、条件、函数、参数、日志这五件事分别做扎实,批量运维脚本才具备长期维护价值。