Shell 脚本进阶:从能跑到写得优雅

前置阅读Shell 脚本入门:写第一个自动化脚本只需10分钟


文章目录

    • 引言:能跑,和能用,之间隔着什么
    • 整体知识框架
    • [一、参数解析:从 1/2 到真正的 CLI 接口](#一、参数解析:从 1/2 到真正的 CLI 接口)
      • [1.1 `1/2` 写法的问题](#1.1 $1/$2 写法的问题)
      • [1.2 getopts:POSIX 标准短选项解析](#1.2 getopts:POSIX 标准短选项解析)
      • [1.3 子命令路由架构](#1.3 子命令路由架构)
    • 二、工程化日志库:让脚本能说清楚发生了什么
      • [2.1 日志库的五个必要能力](#2.1 日志库的五个必要能力)
      • [2.2 完整日志库实现](#2.2 完整日志库实现)
      • [2.3 日志库的使用方式](#2.3 日志库的使用方式)
    • [三、并发控制:不只是 `&` 加 `wait`](#三、并发控制:不只是 &wait)
      • [3.1 基础模式与问题](#3.1 基础模式与问题)
      • [3.2 信号量限流:受控并发](#3.2 信号量限流:受控并发)
      • [3.3 超时熔断:防止任务无限挂起](#3.3 超时熔断:防止任务无限挂起)
      • [3.4 子进程树清理:防止僵尸残留](#3.4 子进程树清理:防止僵尸残留)
    • 四、健壮性保障:让脚本在真实环境中不崩
      • [4.1 幂等性设计](#4.1 幂等性设计)
      • [4.2 原子操作:避免文件操作中断](#4.2 原子操作:避免文件操作中断)
      • [4.3 错误上下文保留:让调试不靠猜](#4.3 错误上下文保留:让调试不靠猜)
    • [五、可测试性:Shell 脚本也能做单元测试](#五、可测试性:Shell 脚本也能做单元测试)
      • [5.1 函数纯化:分离"决策"与"执行"](#5.1 函数纯化:分离"决策"与"执行")
      • [5.2 使用 bats 进行单元测试](#5.2 使用 bats 进行单元测试)
    • 六、工程化项目结构:可维护的脚本项目
      • [6.1 推荐目录结构](#6.1 推荐目录结构)
      • [6.2 统一入口模板](#6.2 统一入口模板)
    • 七、真实案例:多机巡检脚本完整重构
      • [7.1 Before:能跑但不能用的初版](#7.1 Before:能跑但不能用的初版)
      • [7.2 After:工程化重构版](#7.2 After:工程化重构版)
    • 总结

引言:能跑,和能用,之间隔着什么

一段脚本的生命周期通常分三个阶段。

第一阶段:写出来,能跑,目的达到。这时候脚本是给当天的自己用的------逻辑在脑子里,边界条件知道,环境也熟。

第二阶段:三个月后,脚本出了问题,需要改。这时候打开文件,发现根本看不懂自己写了什么。变量叫 tmp,函数叫 do_stuff,没有注释,没有错误处理,出了错也不知道在哪一行。

第三阶段:脚本跑在生产环境,不止一台机器,不止一个人用,需要在 CI/CD 里执行,需要接收参数,需要留下审计日志,需要在失败时自动清理临时文件。

大多数"Shell 脚本入门"文章帮助读者从零到第一阶段。本文关注的是从第一阶段到第三阶段的完整跃升------不是命令更多,而是写法不同。


整体知识框架

Shell 脚本进阶
参数体系
getopts 短选项解析
长选项映射方案
子命令路由架构
参数校验与互斥
并发控制
后台作业与 wait
信号量限流模式
超时熔断机制
子进程树清理
工程化日志
五级日志体系
彩色终端输出
结构化 JSON 日志
日志文件轮转
健壮性保障
trap 退出钩子
临时文件管理
幂等性设计
错误上下文保留
可测试性
函数纯化原则
依赖注入模式
Mock 与 stub
bats 单元测试


一、参数解析:从 1/2 到真正的 CLI 接口

1.1 $1/$2 写法的问题

初学阶段的参数处理往往是这样:

bash 复制代码
#!/usr/bin/env bash
HOST=$1
PORT=$2
USER=$3

ssh "$USER@$HOST" -p "$PORT" "do something"

这段代码没有任何参数校验,顺序必须对,少一个就静默用空值,脚本跑起来行为未定义。三个月后接手的人(包括自己)完全不知道该怎么调用它。

1.2 getopts:POSIX 标准短选项解析

getopts 是 POSIX 标准内置命令,比手动解析 $@ 更健壮,支持选项合并(-abc 等价于 -a -b -c)。

bash 复制代码
#!/usr/bin/env bash
set -euo pipefail

# ─── 默认值 ─────────────────────────────────────────────────
HOST=""
PORT=22
VERBOSE=false
DRY_RUN=false

# ─── 用法说明 ────────────────────────────────────────────────
usage() {
    cat <<EOF
用法: $(basename "$0") [选项] <命令>

选项:
  -h HOST       目标主机(必填)
  -p PORT       SSH 端口(默认: 22)
  -v            详细输出模式
  -n            演习模式,只打印不执行
  --help        显示帮助

示例:
  $(basename "$0") -h 192.168.1.10 -p 2222 -v check
  $(basename "$0") -h db.prod.example.com deploy
EOF
}

# ─── 参数解析 ────────────────────────────────────────────────
while getopts ":h:p:vn-:" opt; do
    case "$opt" in
        h) HOST="$OPTARG" ;;
        p) PORT="$OPTARG" ;;
        v) VERBOSE=true ;;
        n) DRY_RUN=true ;;
        -)  # 处理 --help 这类长选项
            case "$OPTARG" in
                help) usage; exit 0 ;;
                *) echo "未知选项: --$OPTARG" >&2; exit 1 ;;
            esac ;;
        :) echo "选项 -$OPTARG 需要参数" >&2; exit 1 ;;
        \?) echo "未知选项: -$OPTARG" >&2; exit 1 ;;
    esac
done
shift $((OPTIND - 1))

# ─── 参数校验 ────────────────────────────────────────────────
[[ -z "$HOST" ]] && { echo "错误: -h HOST 为必填项" >&2; usage; exit 1; }
[[ ! "$PORT" =~ ^[0-9]+$ ]] && { echo "错误: PORT 必须为数字" >&2; exit 1; }
[[ "$PORT" -lt 1 || "$PORT" -gt 65535 ]] && { echo "错误: PORT 范围 1-65535" >&2; exit 1; }

# ─── 剩余位置参数(子命令)───────────────────────────────────
COMMAND="${1:-}"
[[ -z "$COMMAND" ]] && { echo "错误: 缺少子命令" >&2; usage; exit 1; }

关键细节:

  • getopts 的选项字符串以 : 开头,表示自行处理错误(silent mode)
  • : 后缀表示该选项需要参数(如 h: 表示 -h HOST
  • - 作为选项字符,配合 $OPTARG 可以处理 -- 形式的长选项

1.3 子命令路由架构

当脚本功能增多,参数结构变成 script [全局选项] <子命令> [子命令选项] 时,需要路由架构。
deploy
check
rollback
help
未知
脚本入口
全局选项解析
子命令路由
cmd_deploy
cmd_check
cmd_rollback
show_usage
报错退出
子命令参数解析

bash 复制代码
# ─── 子命令实现 ──────────────────────────────────────────────
cmd_deploy() {
    local target_env=""
    local tag=""
    while getopts ":e:t:" opt; do
        case "$opt" in
            e) target_env="$OPTARG" ;;
            t) tag="$OPTARG" ;;
            *) echo "deploy: 未知选项 -$OPTARG" >&2; return 1 ;;
        esac
    done
    shift $((OPTIND - 1))
    [[ -z "$target_env" ]] && { echo "deploy: -e ENV 为必填" >&2; return 1; }
    log_info "开始部署 tag=${tag:-latest} 至 env=$target_env"
    # ... 实际部署逻辑
}

cmd_check() {
    log_info "健康检查: $HOST:$PORT"
    # ... 检查逻辑
}

# ─── 路由 ───────────────────────────────────────────────────
case "$COMMAND" in
    deploy)   cmd_deploy "$@" ;;
    check)    cmd_check "$@" ;;
    rollback) cmd_rollback "$@" ;;
    help)     usage; exit 0 ;;
    *)        echo "未知子命令: $COMMAND" >&2; usage; exit 1 ;;
esac

二、工程化日志库:让脚本能说清楚发生了什么

2.1 日志库的五个必要能力

能力 说明
分级过滤 DEBUG/INFO/WARN/ERROR/FATAL,生产只打 INFO 以上
时间戳 每行日志有精确时间,方便事后关联
彩色终端 交互环境下颜色区分级别,管道/文件输出则不加颜色
同步写文件 同时输出到 stdout 和日志文件,文件不含 ANSI 颜色码
结构化选项 可选支持 JSON 格式输出,方便日志收集系统(ELK/Loki)解析

2.2 完整日志库实现

bash 复制代码
# lib/logging.sh
# 使用方式: source "$(dirname "${BASH_SOURCE[0]}")/logging.sh"

# ─── 日志级别常量 ────────────────────────────────────────────
declare -rg LOG_DEBUG=0
declare -rg LOG_INFO=1
declare -rg LOG_WARN=2
declare -rg LOG_ERROR=3
declare -rg LOG_FATAL=4

# ─── 配置(外部可覆盖)──────────────────────────────────────
LOG_LEVEL="${LOG_LEVEL:-$LOG_INFO}"
LOG_FILE="${LOG_FILE:-}"           # 空则不写文件
LOG_FORMAT="${LOG_FORMAT:-text}"   # text 或 json

# ─── ANSI 颜色码(自动检测终端)────────────────────────────
if [[ -t 2 ]]; then
    _C_RESET='\033[0m'
    _C_DEBUG='\033[0;36m'   # 青色
    _C_INFO='\033[0;32m'    # 绿色
    _C_WARN='\033[0;33m'    # 黄色
    _C_ERROR='\033[0;31m'   # 红色
    _C_FATAL='\033[1;35m'   # 亮紫色
else
    _C_RESET='' _C_DEBUG='' _C_INFO='' _C_WARN='' _C_ERROR='' _C_FATAL=''
fi

# ─── 核心写入函数 ────────────────────────────────────────────
_log_write() {
    local level_int="$1"
    local level_name="$2"
    local color="$3"
    shift 3
    local msg="$*"

    [[ "$level_int" -lt "$LOG_LEVEL" ]] && return 0

    local ts
    ts=$(date '+%Y-%m-%dT%H:%M:%S%z')
    local caller_info="${BASH_SOURCE[2]##*/}:${BASH_LINENO[1]}"

    if [[ "$LOG_FORMAT" == "json" ]]; then
        # 结构化 JSON 输出(不含颜色)
        local json_msg
        json_msg=$(printf '{"ts":"%s","level":"%s","caller":"%s","msg":"%s"}\n' \
            "$ts" "$level_name" "$caller_info" "${msg//\"/\\\"}")
        echo "$json_msg" >&2
        [[ -n "$LOG_FILE" ]] && echo "$json_msg" >> "$LOG_FILE"
    else
        # 人类可读输出
        local line
        line=$(printf '[%s] [%-5s] %s' "$ts" "$level_name" "$msg")
        printf "${color}%s${_C_RESET}\n" "$line" >&2
        # 写文件时去掉颜色码
        [[ -n "$LOG_FILE" ]] && echo "$line" >> "$LOG_FILE"
    fi
}

log_debug() { _log_write "$LOG_DEBUG" "DEBUG" "$_C_DEBUG" "$@"; }
log_info()  { _log_write "$LOG_INFO"  "INFO"  "$_C_INFO"  "$@"; }
log_warn()  { _log_write "$LOG_WARN"  "WARN"  "$_C_WARN"  "$@"; }
log_error() { _log_write "$LOG_ERROR" "ERROR" "$_C_ERROR" "$@"; }
log_fatal() { _log_write "$LOG_FATAL" "FATAL" "$_C_FATAL" "$@"; exit 1; }

# ─── 便捷函数 ───────────────────────────────────────────────
log_separator() {
    log_info "─────────────────────────────────────────────────"
}

log_step() {
    local step="$1"; shift
    log_info "[$step] $*"
}

2.3 日志库的使用方式

bash 复制代码
# main.sh
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/logging.sh"

# 设置日志级别(可通过环境变量覆盖)
export LOG_LEVEL=$LOG_DEBUG
export LOG_FILE="/var/log/myapp/deploy-$(date +%Y%m%d).log"

log_info "部署流程启动"
log_debug "目标主机: $HOST, 端口: $PORT"
log_warn "当前为演习模式,实际不执行"
log_error "镜像拉取失败: $IMAGE"
log_fatal "致命错误,流程终止"  # 直接 exit 1

输出示例(终端彩色):

复制代码
[2026-05-08T14:23:11+0800] [INFO ] 部署流程启动
[2026-05-08T14:23:11+0800] [DEBUG] 目标主机: 192.168.1.10, 端口: 22
[2026-05-08T14:23:12+0800] [WARN ] 当前为演习模式,实际不执行
[2026-05-08T14:23:13+0800] [ERROR] 镜像拉取失败: myapp:1.2.3

JSON 格式(export LOG_FORMAT=json,用于日志收集):

json 复制代码
{"ts":"2026-05-08T14:23:11+0800","level":"INFO","caller":"main.sh:12","msg":"部署流程启动"}
{"ts":"2026-05-08T14:23:11+0800","level":"DEBUG","caller":"main.sh:13","msg":"目标主机: 192.168.1.10, 端口: 22"}

三、并发控制:不只是 &wait

3.1 基础模式与问题

&wait 是 Bash 并发的基础,但朴素写法有严重问题:

bash 复制代码
# 朴素并发:一次性启动所有任务(失控)
for server in "${servers[@]}"; do
    check_server "$server" &   # 同时启动 100 个进程!
done
wait

如果 servers 数组有 100 个元素,这段代码会同时启动 100 个后台进程,耗尽系统资源,反而比串行更慢,甚至导致 OOM。

3.2 信号量限流:受控并发

工作进程 3 工作进程 2 工作进程 1 FIFO 信号量 主进程 工作进程 3 工作进程 2 工作进程 1 FIFO 信号量 主进程 初始令牌数 = MAX_JOBS(3) 令牌耗尽,阻塞等待 写入 3 个初始令牌 读取令牌(获取许可) 启动 server-01 检查 & 读取令牌(获取许可) 启动 server-02 检查 & 读取令牌(获取许可) 启动 server-03 检查 & 任务完成,归还令牌 读取令牌(解除阻塞) 启动 server-04 检查 &

bash 复制代码
# lib/concurrency.sh

# ─── 信号量实现 ──────────────────────────────────────────────
# 使用 FIFO + 文件描述符实现令牌池(信号量)
_SEMAPHORE_FIFO=""
_SEMAPHORE_FD=9

semaphore_init() {
    local max_jobs="${1:-4}"
    _SEMAPHORE_FIFO=$(mktemp -u)
    mkfifo "$_SEMAPHORE_FIFO"
    # 打开 fd 9 用于读写(keep it open,否则 FIFO 会一直阻塞)
    eval "exec ${_SEMAPHORE_FD}<>${_SEMAPHORE_FIFO}"
    rm -f "$_SEMAPHORE_FIFO"  # 删除 inode,fd 还在
    # 写入初始令牌
    for ((i=0; i<max_jobs; i++)); do
        printf '.' >&${_SEMAPHORE_FD}
    done
}

semaphore_acquire() {
    # 读取一个字节(令牌)。令牌耗尽时阻塞
    read -r -n 1 -u ${_SEMAPHORE_FD}
}

semaphore_release() {
    # 归还令牌
    printf '.' >&${_SEMAPHORE_FD}
}

semaphore_destroy() {
    eval "exec ${_SEMAPHORE_FD}>&-"
}

# ─── 受控并发执行 ────────────────────────────────────────────
run_parallel() {
    local max_jobs="${1:-4}"
    shift
    local items=("$@")

    semaphore_init "$max_jobs"

    # 记录所有子进程 PID,便于 trap 清理
    declare -a child_pids=()

    for item in "${items[@]}"; do
        semaphore_acquire             # 等待令牌(限流点)
        (
            process_item "$item"      # 工作函数
            semaphore_release         # 完成后归还令牌
        ) &
        child_pids+=($!)
    done

    # 等待所有子进程结束,收集退出码
    local failed=0
    for pid in "${child_pids[@]}"; do
        wait "$pid" || ((failed++))
    done

    semaphore_destroy
    return "$failed"
}

3.3 超时熔断:防止任务无限挂起

bash 复制代码
# 带超时的任务执行
run_with_timeout() {
    local timeout_sec="$1"
    shift
    local cmd=("$@")

    # 启动命令,记录 PID
    "${cmd[@]}" &
    local cmd_pid=$!

    # 启动超时监控(后台 sleep + kill)
    (
        sleep "$timeout_sec"
        if kill -0 "$cmd_pid" 2>/dev/null; then
            log_warn "任务超时 (${timeout_sec}s),强制终止 PID=$cmd_pid"
            kill -TERM "$cmd_pid" 2>/dev/null
            sleep 2
            kill -KILL "$cmd_pid" 2>/dev/null  # 确保终止
        fi
    ) &
    local watchdog_pid=$!

    # 等待命令完成
    wait "$cmd_pid"
    local exit_code=$?

    # 任务已完成,取消超时监控
    kill "$watchdog_pid" 2>/dev/null
    wait "$watchdog_pid" 2>/dev/null

    return "$exit_code"
}

调用示例:

bash 复制代码
run_with_timeout 30 ssh "$HOST" "apt-get update"

3.4 子进程树清理:防止僵尸残留





脚本收到 SIGINT/SIGTERM
trap 触发 cleanup 函数
获取当前进程组 PGID
是否有子进程?
kill -TERM -PGID 发送给整个进程组
等待 2 秒
进程组仍在?
kill -KILL -PGID 强制终止
清理临时文件
退出

bash 复制代码
# ─── trap 与进程树清理 ───────────────────────────────────────

# 临时文件注册表
declare -a _TEMP_FILES=()
declare -a _TEMP_DIRS=()

register_temp_file() { _TEMP_FILES+=("$1"); }
register_temp_dir()  { _TEMP_DIRS+=("$1"); }

cleanup() {
    local exit_code="${1:-$?}"
    log_info "清理临时资源..."

    # 清理临时文件
    for f in "${_TEMP_FILES[@]}"; do
        [[ -f "$f" ]] && rm -f "$f" && log_debug "已删除: $f"
    done
    for d in "${_TEMP_DIRS[@]}"; do
        [[ -d "$d" ]] && rm -rf "$d" && log_debug "已删除目录: $d"
    done

    # 终止整个进程组(包括所有后台子进程)
    # kill 负数 PID 会向整个进程组发送信号
    local pgid
    pgid=$(ps -o pgid= -p $$ | tr -d ' ')
    if [[ "$pgid" != "$$" ]]; then
        # 本进程已经是进程组领导,直接 kill 进程组
        kill -TERM "-$pgid" 2>/dev/null || true
        sleep 1
        kill -KILL "-$pgid" 2>/dev/null || true
    fi

    exit "$exit_code"
}

# 注册 trap:正常退出、Ctrl+C、SIGTERM、错误退出
trap 'cleanup $?' EXIT
trap 'cleanup 130' INT   # 128 + 2
trap 'cleanup 143' TERM  # 128 + 15

四、健壮性保障:让脚本在真实环境中不崩

4.1 幂等性设计

幂等的含义是:运行一次和运行多次,结果相同。生产脚本必须幂等,因为网络超时、人工重试、CI 重跑都会导致脚本被执行多次。

bash 复制代码
# 非幂等写法(会导致重复创建)
mkdir /opt/myapp
cp config.conf /opt/myapp/

# 幂等写法
mkdir -p /opt/myapp                # -p 不报错如果已存在
cp -n config.conf /opt/myapp/      # -n 不覆盖已存在文件
# 或者显式判断
if [[ ! -f /opt/myapp/config.conf ]]; then
    cp config.conf /opt/myapp/
    log_info "配置文件已复制"
else
    log_debug "配置文件已存在,跳过"
fi

4.2 原子操作:避免文件操作中断

写配置文件时,不要直接写目标路径,先写临时文件再原子替换:

bash 复制代码
# 非原子写法(中断后文件损坏)
generate_config > /etc/myapp/config.conf

# 原子写法(使用 mv 原子替换)
tmp=$(mktemp /etc/myapp/config.conf.XXXXXX)
register_temp_file "$tmp"       # 注册临时文件,确保清理
generate_config > "$tmp"
# mv 在同一文件系统内是原子的
mv "$tmp" /etc/myapp/config.conf
log_info "配置文件已更新"

4.3 错误上下文保留:让调试不靠猜

set -e 在命令失败时退出,但默认不告诉你失败在哪一行、是什么命令。通过 ERR trap 可以捕获完整上下文:

bash 复制代码
# lib/error_handler.sh

on_error() {
    local exit_code="$1"
    local line_no="$2"
    local command="$3"

    log_error "命令执行失败"
    log_error "  退出码 : $exit_code"
    log_error "  失败行 : $line_no"
    log_error "  命令   : $command"
    log_error "  调用栈 :"

    # 打印调用栈(从当前函数向上)
    local frame=0
    while caller $frame; do
        ((frame++))
    done | while read -r line func file; do
        log_error "    at $func ($file:$line)"
    done
}

# 注册 ERR trap
# BASH_COMMAND 是触发错误的命令字符串
trap 'on_error $? $LINENO "$BASH_COMMAND"' ERR

输出示例:

复制代码
[2026-05-08T14:30:22+0800] [ERROR] 命令执行失败
[2026-05-08T14:30:22+0800] [ERROR]   退出码 : 1
[2026-05-08T14:30:22+0800] [ERROR]   失败行 : 47
[2026-05-08T14:30:22+0800] [ERROR]   命令   : docker pull myapp:1.2.3
[2026-05-08T14:30:22+0800] [ERROR]   调用栈 :
[2026-05-08T14:30:22+0800] [ERROR]     at cmd_deploy (main.sh:47)
[2026-05-08T14:30:22+0800] [ERROR]     at main (main.sh:112)

五、可测试性:Shell 脚本也能做单元测试

5.1 函数纯化:分离"决策"与"执行"

可测试函数的关键是将"计算逻辑"与"副作用"分开。

bash 复制代码
# 难以测试:逻辑与副作用混在一起
backup_database() {
    local db_name="$1"
    local backup_dir="/var/backup"
    local filename="${db_name}-$(date +%Y%m%d%H%M%S).sql.gz"
    # 直接执行,无法 mock
    mysqldump "$db_name" | gzip > "${backup_dir}/${filename}"
    echo "备份完成: $filename"
}

# 易于测试:逻辑与执行分开
get_backup_filename() {
    local db_name="$1"
    local timestamp="${2:-$(date +%Y%m%d%H%M%S)}"
    echo "${db_name}-${timestamp}.sql.gz"
}

do_backup() {
    local db_name="$1"
    local backup_dir="${2:-/var/backup}"
    local filename
    filename=$(get_backup_filename "$db_name")
    # 执行层:调用具体工具
    ${MYSQLDUMP_CMD:-mysqldump} "$db_name" | gzip > "${backup_dir}/${filename}"
    echo "$filename"
}

get_backup_filename 是纯函数,无副作用,可以直接测试输出是否符合预期。
do_backup 中的 MYSQLDUMP_CMD 支持注入 mock 命令。

5.2 使用 bats 进行单元测试

bats 是 Bash Automated Testing System,语法简洁,CI 友好。

bash 复制代码
# tests/test_backup.bats
#!/usr/bin/env bats

# 加载被测文件
setup() {
    source "${BATS_TEST_DIRNAME}/../lib/backup.sh"
}

@test "get_backup_filename 输出格式正确" {
    run get_backup_filename "mydb" "20260508143000"
    [ "$status" -eq 0 ]
    [ "$output" = "mydb-20260508143000.sql.gz" ]
}

@test "get_backup_filename 包含 db 名称" {
    run get_backup_filename "production_db" "20260508000000"
    [[ "$output" == *"production_db"* ]]
}

@test "do_backup 使用 mock 命令时正常执行" {
    # 使用 mock 替代真实的 mysqldump
    MYSQLDUMP_CMD="cat /dev/null"
    local tmpdir
    tmpdir=$(mktemp -d)
    run do_backup "testdb" "$tmpdir"
    [ "$status" -eq 0 ]
    rm -rf "$tmpdir"
}

执行测试:

bash 复制代码
# 安装
npm install -g bats
# 或
brew install bats-core

# 运行测试
bats tests/

六、工程化项目结构:可维护的脚本项目

6.1 推荐目录结构

project-scripts/
bin/
lib/
conf/
tests/
docs/
deploy.sh
check.sh
rollback.sh
logging.sh
concurrency.sh
error_handler.sh
utils.sh
prod.conf
staging.conf
test_logging.bats
test_utils.bats
README.md
CHANGELOG.md

6.2 统一入口模板

所有 bin/ 下的脚本使用统一模板,保证行为一致性:

bash 复制代码
#!/usr/bin/env bash
# ═══════════════════════════════════════════════════════════════
# deploy.sh --- 服务部署脚本
# 作者: ops-team
# 更新: 2026-05-08
# 用法: bin/deploy.sh [选项] <环境>
# ═══════════════════════════════════════════════════════════════
set -euo pipefail
IFS=$'\n\t'  # 更安全的字段分隔符

# ─── 项目根目录定位 ──────────────────────────────────────────
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"

# ─── 加载公共库 ──────────────────────────────────────────────
source "$PROJECT_ROOT/lib/logging.sh"
source "$PROJECT_ROOT/lib/error_handler.sh"
source "$PROJECT_ROOT/lib/concurrency.sh"

# ─── 配置 ───────────────────────────────────────────────────
readonly CONFIG_DIR="$PROJECT_ROOT/conf"
readonly LOG_DIR="/var/log/deploy"
mkdir -p "$LOG_DIR"
export LOG_FILE="$LOG_DIR/deploy-$(date +%Y%m%d-%H%M%S).log"

# ─── 主逻辑 ─────────────────────────────────────────────────
main() {
    log_info "脚本启动: $(basename "$0") $*"
    log_info "项目根目录: $PROJECT_ROOT"
    log_info "执行用户: $(whoami)"

    # ... 参数解析与业务逻辑

    log_info "脚本正常完成"
}

main "$@"

七、真实案例:多机巡检脚本完整重构

7.1 Before:能跑但不能用的初版

bash 复制代码
#!/bin/bash
# 初版巡检脚本(问题版本)

SERVERS="server1 server2 server3 server4 server5"
for s in $SERVERS; do
    ssh $s "df -h && free -m && uptime" >> check.log
done
echo "done"

问题:

  • 串行执行,5台机器依次检查,慢
  • SSH 失败直接报错并退出,不继续其他机器
  • 输出混在一起,不知道哪行属于哪台机器
  • 没有超时控制,某台机器卡住会阻塞整个脚本
  • 日志写到当前目录,没有时间戳

7.2 After:工程化重构版

bash 复制代码
#!/usr/bin/env bash
# bin/health_check.sh --- 多机健康巡检(工程化版)
set -euo pipefail
IFS=$'\n\t'

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/../lib/logging.sh"
source "$SCRIPT_DIR/../lib/concurrency.sh"
source "$SCRIPT_DIR/../lib/error_handler.sh"

# ─── 配置 ───────────────────────────────────────────────────
declare -a SERVERS=()
SSH_USER="ops"
SSH_PORT=22
MAX_JOBS=5           # 最大并发数
SSH_TIMEOUT=15       # 单次 SSH 超时(秒)
REPORT_DIR="/var/report/health"
DATE_TAG=$(date +%Y%m%d-%H%M%S)

export LOG_FILE="$REPORT_DIR/check-$DATE_TAG.log"
mkdir -p "$REPORT_DIR"

# ─── 参数解析 ────────────────────────────────────────────────
usage() {
    echo "用法: $(basename "$0") [-u USER] [-p PORT] [-j MAX_JOBS] -f server_list.txt"
    echo "  -f FILE    服务器列表文件(每行一个 IP/主机名)"
    echo "  -u USER    SSH 用户名(默认: ops)"
    echo "  -p PORT    SSH 端口(默认: 22)"
    echo "  -j JOBS    最大并发数(默认: 5)"
}

while getopts ":f:u:p:j:h" opt; do
    case "$opt" in
        f) mapfile -t SERVERS < "$OPTARG" ;;
        u) SSH_USER="$OPTARG" ;;
        p) SSH_PORT="$OPTARG" ;;
        j) MAX_JOBS="$OPTARG" ;;
        h) usage; exit 0 ;;
        :) log_fatal "选项 -$OPTARG 需要参数"; ;;
        \?) log_fatal "未知选项: -$OPTARG" ;;
    esac
done

[[ ${#SERVERS[@]} -eq 0 ]] && { log_error "服务器列表为空,使用 -f 指定文件"; usage; exit 1; }

# ─── 单机检查函数 ────────────────────────────────────────────
check_server() {
    local server="$1"
    local report_file="$REPORT_DIR/$server-$DATE_TAG.txt"

    log_info "开始检查: $server"

    # 带超时的 SSH 检查
    local ssh_result=0
    timeout "$SSH_TIMEOUT" ssh \
        -o ConnectTimeout=5 \
        -o StrictHostKeyChecking=no \
        -o BatchMode=yes \
        -p "$SSH_PORT" \
        "$SSH_USER@$server" \
        'echo "=== HOSTNAME ===" && hostname
         echo "=== UPTIME ===" && uptime
         echo "=== CPU ===" && top -bn1 | grep "Cpu(s)" | head -1
         echo "=== MEMORY ===" && free -m
         echo "=== DISK ===" && df -h --output=source,pcent,target | grep -v tmpfs
         echo "=== LOAD ===" && cat /proc/loadavg' \
        > "$report_file" 2>&1 || ssh_result=$?

    if [[ "$ssh_result" -eq 0 ]]; then
        log_info "✓ $server 检查完成 -> $report_file"
    elif [[ "$ssh_result" -eq 124 ]]; then
        log_warn "⚠ $server SSH 超时 (${SSH_TIMEOUT}s)"
        echo "TIMEOUT after ${SSH_TIMEOUT}s" >> "$report_file"
    else
        log_error "✗ $server SSH 失败 (exit=$ssh_result)"
        echo "SSH_FAILED exit=$ssh_result" >> "$report_file"
    fi

    return 0  # 单台失败不影响其他
}

# ─── 主流程 ──────────────────────────────────────────────────
main() {
    log_separator
    log_info "多机健康巡检启动"
    log_info "目标服务器: ${#SERVERS[@]} 台"
    log_info "最大并发: $MAX_JOBS"
    log_separator

    semaphore_init "$MAX_JOBS"

    declare -a pids=()
    for server in "${SERVERS[@]}"; do
        [[ -z "$server" || "$server" == \#* ]] && continue  # 跳过空行和注释
        semaphore_acquire
        (
            check_server "$server"
            semaphore_release
        ) &
        pids+=($!)
    done

    local failed=0
    for pid in "${pids[@]}"; do
        wait "$pid" || ((failed++)) || true
    done

    semaphore_destroy

    log_separator
    log_info "巡检完成。报告目录: $REPORT_DIR"
    log_info "成功: $((${#pids[@]} - failed)) / 失败: $failed / 总计: ${#pids[@]}"

    # 生成汇总报告
    local summary="$REPORT_DIR/summary-$DATE_TAG.txt"
    {
        echo "巡检时间: $(date)"
        echo "服务器总数: ${#pids[@]}"
        echo ""
        for server in "${SERVERS[@]}"; do
            [[ -z "$server" || "$server" == \#* ]] && continue
            local f="$REPORT_DIR/$server-$DATE_TAG.txt"
            if grep -q "SSH_FAILED\|TIMEOUT" "$f" 2>/dev/null; then
                echo "FAIL  $server"
            else
                echo "OK    $server"
            fi
        done
    } > "$summary"

    log_info "汇总报告: $summary"
}

main "$@"

重构后与初版对比:

维度 初版 重构版
执行速度 串行,5台×10秒=50秒 并发,5台同时=10秒
错误处理 一台失败全部中断 单台失败不影响其他
超时控制 15秒超时,自动标记
日志 混在一起 每台独立报告+汇总
参数化 硬编码 完整 CLI 接口
可维护性 只有作者能改 有注释、有结构、有测试

总结

Shell 脚本进阶核心
工程化参数
getopts 短选项
长选项映射
子命令路由
参数校验
可观测日志
五级分级
彩色终端
JSON 格式
文件落盘
受控并发
信号量限流
超时熔断
进程树清理
失败隔离
可靠性保障
幂等设计
原子操作
错误上下文
trap 清理
可测试性
函数纯化
依赖注入
bats 测试
CI 集成

脚本工程化的本质不是引入更多工具,而是把意外转化为已处理情况------超时了知道怎么清理,失败了知道在哪失败,并发了知道怎么收口。

从"能跑"到"能用",核心是:让脚本在自己不在场的情况下,也能做正确的事、说清楚发生了什么、不留烂摊子。

相关推荐
xiaoshuaishuai82 小时前
C# 实现“superpowers进化
运维·服务器·windows·c#
孙同学_3 小时前
【项目篇】高并发服务器 - 从 Buffer 到 TcpServer 构建高并发服务器引擎
运维·服务器
SilentSamsara3 小时前
Linux磁盘与存储管理:分区、LVM 与 IO 性能全栈分析
linux·运维·服务器·ssh·shell
IMPYLH10 小时前
Linux 的 pinky 命令
linux·运维·服务器·bash
HelloWorld_SDK11 小时前
Docker安装OpenClaw
运维·docker·容器·openclaw
REDcker11 小时前
Linux iptables 与 Netfilter:原理、路径与运维要点
linux·运维·服务器
KKKlucifer13 小时前
零信任融合实践:国内堡垒机如何落地动态权限与实时阻断
运维
嵌入式×边缘AI:打怪升级日志13 小时前
Linux 驱动开发入门:从最简单的 hello 驱动到硬件交互
linux·驱动开发·交互
Bert.Cai14 小时前
Linux useradd命令详解
linux·运维