概述

Bash 是运维世界使用最广泛的脚本语言——几乎每台 Linux 服务器都自带解释器,无需安装任何运行时。但 Bash 也是最容易写出"能跑但不能信赖"脚本的语言:没有类型检查、错误默认静默、变量不加引号就分词、管道中的失败被吞掉。一个线上故障的根因往往就是某个 Bash 脚本没做错误处理。本文逐步梳理生产级 Bash 脚本的编写规范,让你写出的脚本不只是"能跑",而是"可信赖"。

参考来源:Google Shell Style GuideShellCheck

一、脚本骨架:set -euo pipefail

1.1 三行头

每个生产级 Bash 脚本的开头应该是这样的:

#!/usr/bin/env bash
set -euo pipefail

这三行做的事情:

选项作用不加的后果
-e (errexit)命令失败时立即退出错误被忽略,脚本继续执行,可能导致灾难性后果
-u (nounset)使用未定义变量时报错拼写错误的变量名静默返回空字符串
-o pipefail管道中任一命令失败则整体失败管道中前面的命令失败被忽略,只看最后一个命令的退出码

一个经典反面案例:

#!/bin/bash
# 没有任何安全设置

cd /nonexistent/directory     # 失败,但脚本继续
rm -rf *                      # 在当前目录执行了!灾难

加上 set -euo pipefail 后:

#!/usr/bin/env bash
set -euo pipefail

cd /nonexistent/directory     # 失败,脚本立即退出
rm -rf *                      # 永远不会执行到这里

1.2 完整脚本模板

#!/usr/bin/env bash
#
# script_name.sh - 一句话描述脚本用途
#
# 用法: script_name.sh [选项] <参数>
#
# 作者: 徐保金
# 创建: 2026-07-10
#

set -euo pipefail
IFS=$'\n\t'

# === 全局变量 ===
SCRIPT_NAME="$(basename "$0")"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VERSION="1.0.0"
VERBOSE=0
DRY_RUN=false

# === 颜色定义 ===
if [[ -t 1 ]]; then
    RED='\033[0;31m'
    GREEN='\033[0;32m'
    YELLOW='\033[0;33m'
    BLUE='\033[0;34m'
    NC='\033[0m' # No Color
else
    RED='' GREEN='' YELLOW='' BLUE='' NC=''
fi

# === 日志函数 ===
log_info()  { echo -e "${GREEN}[INFO]${NC}  $(date '+%Y-%m-%d %H:%M:%S') $*"; }
log_warn()  { echo -e "${YELLOW}[WARN]${NC}  $(date '+%Y-%m-%d %H:%M:%S') $*" >&2; }
log_error() { echo -e "${RED}[ERROR]${NC} $(date '+%Y-%m-%d %H:%M:%S') $*" >&2; }
log_debug() { [[ "${VERBOSE}" -ge 1 ]] && echo -e "${BLUE}[DEBUG]${NC} $(date '+%Y-%m-%d %H:%M:%S') $*" >&2; }

# === 清理函数 ===
cleanup() {
    local exit_code=$?
    log_debug "清理临时文件..."
    rm -f "${TMP_FILE:-}" 2>/dev/null || true
    exit "$exit_code"
}
trap cleanup EXIT

# === 用法说明 ===
usage() {
    cat <<EOF
用法: ${SCRIPT_NAME} [选项] <参数>

选项:
    -h, --help          显示帮助信息
    -v, --verbose       详细输出
    -n, --dry-run       只打印不执行
    -V, --version       显示版本号
    -c, --config FILE   指定配置文件

示例:
    ${SCRIPT_NAME} -v deploy production
    ${SCRIPT_NAME} --config /etc/app.conf deploy staging
EOF
    exit "${1:-0}"
}

# === 参数解析 ===
TEMP_FILE="$(mktemp)"
log_info "脚本启动: ${SCRIPT_NAME} v${VERSION}"

# 主逻辑...

二、错误处理与日志

2.1 错误处理函数

#!/usr/bin/env bash
set -euo pipefail

# 错误处理:打印出错位置
error_handler() {
    local exit_code=$?
    local line_no=$1
    local command=$2

    echo ""
    echo "========================================" >&2
    echo " 脚本执行失败" >&2
    echo "========================================" >&2
    echo " 退出码: ${exit_code}" >&2
    echo " 行号:   ${line_no}" >&2
    echo " 命令:   ${command}" >&2
    echo " 时间:   $(date '+%Y-%m-%d %H:%M:%S')" >&2
    echo "========================================" >&2

    # 发送告警
    if command -v curl &>/dev/null && [[ -n "${WEBHOOK_URL:-}" ]]; then
        curl -sf -X POST "${WEBHOOK_URL}" \
            -H 'Content-Type: application/json' \
            -d "{\"text\":\"脚本失败: ${SCRIPT_NAME}${line_no} 命令: ${command}\"}" \
            2>/dev/null || true
    fi

    exit "$exit_code"
}

trap 'error_handler ${LINENO} "${BASH_COMMAND}"' ERR

2.2 安全执行包装器

#!/usr/bin/env bash
set -euo pipefail

# run_cmd: 带日志和错误处理的命令执行器
run_cmd() {
    local desc="$1"; shift
    local cmd=("$@")

    log_info "执行: ${desc}"
    log_debug "命令: ${cmd[*]}"

    if [[ "${DRY_RUN}" == "true" ]]; then
        log_warn "[DRY-RUN] ${cmd[*]}"
        return 0
    fi

    local start_time end_time duration exit_code
    start_time=$(date +%s)

    if "${cmd[@]}"; then
        exit_code=0
    else
        exit_code=$?
    fi

    end_time=$(date +%s)
    duration=$((end_time - start_time))

    if [[ ${exit_code} -eq 0 ]]; then
        log_info "完成: ${desc} (${duration}s)"
    else
        log_error "失败: ${desc} (退出码=${exit_code}, ${duration}s)"
        return ${exit_code}
    fi
}

# safe_eval: 执行字符串命令,捕获输出
safe_eval() {
    local cmd="$1"
    local output exit_code

    output=$(eval "$cmd" 2>&1) && exit_code=0 || exit_code=$?

    echo "$output"
    return ${exit_code}
}

# retry: 带重试的命令执行
retry() {
    local max_attempts=$1
    local delay=$2
    shift 2
    local attempt=1
    local exit_code=0

    while [[ ${attempt} -le ${max_attempts} ]]; do
        log_debug "尝试 ${attempt}/${max_attempts}: $*"
        if "$@"; then
            return 0
        fi
        exit_code=$?
        attempt=$((attempt + 1))
        if [[ ${attempt} -le ${max_attempts} ]]; then
            log_warn "失败 (退出码=${exit_code}),${delay}秒后重试..."
            sleep "${delay}"
        fi
    done

    log_error "重试 ${max_attempts} 次后仍失败"
    return ${exit_code}
}

# 使用示例
run_cmd "安装依赖" apt-get install -y nginx
retry 3 5 curl -sf http://example.com/health

2.3 日志系统

#!/usr/bin/env bash
set -euo pipefail

# === 日志配置 ===
LOG_FILE="/var/log/${SCRIPT_NAME:-script}.log"
LOG_LEVEL="INFO"  # DEBUG, INFO, WARN, ERROR
LOG_TO_FILE=true
LOG_TO_STDOUT=true
LOG_MAX_SIZE="10M"
LOG_KEEP=5

# 日志级别数字映射
_log_level_num() {
    case "$1" in
        DEBUG) echo 0 ;;
        INFO)  echo 1 ;;
        WARN)  echo 2 ;;
        ERROR) echo 3 ;;
        *)     echo 1 ;;
    esac
}

# 日志输出
_log() {
    local level="$1"; shift
    local msg="$*"
    local timestamp
    timestamp=$(date '+%Y-%m-%d %H:%M:%S')

    local level_num msg_level_num
    msg_level_num=$(_log_level_num "${level}")
    level_num=$(_log_level_num "${LOG_LEVEL}")

    # 低于配置级别的不输出
    [[ ${msg_level_num} -lt ${level_num} ]] && return 0

    local line="[${timestamp}] [${level}] ${msg}"

    if [[ "${LOG_TO_STDOUT}" == "true" ]]; then
        case "${level}" in
            ERROR) echo -e "${RED}${line}${NC}" >&2 ;;
            WARN)  echo -e "${YELLOW}${line}${NC}" >&2 ;;
            INFO)  echo -e "${GREEN}${line}${NC}" ;;
            DEBUG) echo -e "${BLUE}${line}${NC}" ;;
        esac
    fi

    if [[ "${LOG_TO_FILE}" == "true" ]]; then
        echo "${line}" >> "${LOG_FILE}"
    fi
}

log_debug() { _log DEBUG "$@"; }
log_info()  { _log INFO "$@"; }
log_warn()  { _log WARN "$@"; }
log_error() { _log ERROR "$@"; }

# 日志轮转
log_rotate() {
    local file="$1"
    local max_size="$2"
    local keep="$3"

    [[ ! -f "${file}" ]] && return 0

    local size
    size=$(stat -c %s "${file}" 2>/dev/null || echo 0)
    local max_bytes
    max_bytes=$(numfmt --from=iec "${max_size}" 2>/dev/null || echo 10485760)

    if [[ ${size} -gt ${max_bytes} ]]; then
        for ((i = keep; i >= 1; i--)); do
            [[ -f "${file}.$((i-1))" ]] && mv "${file}.$((i-1))" "${file}.${i}"
        done
        mv "${file}" "${file}.0"
        gzip "${file}.0" 2>/dev/null || true
        touch "${file}"
        log_info "日志轮转完成: ${file}"
    fi
}

三、信号处理

3.1 捕获信号

#!/usr/bin/env bash
set -euo pipefail

# 全局状态
RUNNING=true
CHILD_PIDS=()

# 信号处理函数
handle_interrupt() {
    echo ""
    log_warn "收到中断信号 (SIGINT/SIGTERM),正在清理..."

    RUNNING=false

    # 终止所有子进程
    for pid in "${CHILD_PIDS[@]}"; do
        if kill -0 "$pid" 2>/dev/null; then
            log_debug "终止子进程: $pid"
            kill -TERM "$pid" 2>/dev/null || true
        fi
    done

    # 等待子进程退出
    wait 2>/dev/null

    log_info "清理完成,退出"
    exit 130
}

handle_exit() {
    local exit_code=$?
    # 清理临时文件
    rm -f "${TMP_DIR:-}"/* 2>/dev/null || true
    rm -rf "${TMP_DIR:-}" 2>/dev/null || true
    exit "$exit_code"
}

# 注册信号处理器
trap handle_interrupt SIGINT SIGTERM
trap handle_exit EXIT

3.2 优雅退出模式

#!/usr/bin/env bash
set -euo pipefail

# 优雅退出:先完成当前任务再退出
GRACEFUL_SHUTDOWN=false
CURRENT_TASK=""

handle_sigterm() {
    log_warn "收到 SIGTERM,启动优雅退出..."
    GRACEFUL_SHUTDOWN=true
}

trap handle_sigterm SIGTERM

process_items() {
    local items=("$@")
    local total=${#items[@]}
    local current=0

    for item in "${items[@]}"; do
        # 检查是否需要优雅退出
        if [[ "${GRACEFUL_SHUTDOWN}" == "true" ]]; then
            log_warn "优雅退出: 已处理 ${current}/${total}"
            break
        fi

        CURRENT_TASK="处理 ${item}"
        log_info "${CURRENT_TASK} (${current}/${total})"

        # 模拟处理
        sleep 2

        current=$((current + 1))
    done

    log_info "处理完成: ${current}/${total}"
}

process_items "task1" "task2" "task3" "task4" "task5"

四、并行执行

4.1 后台任务管理

#!/usr/bin/env bash
set -euo pipefail

# 并行执行函数
parallel_run() {
    local max_jobs=$1
    shift

    local pids=()
    local jobs_running=0

    for cmd in "$@"; do
        # 等待空位
        while [[ ${jobs_running} -ge ${max_jobs} ]]; do
            wait -n 2>/dev/null || {
                # wait -n 不可用时的兼容方案
                for pid in "${pids[@]}"; do
                    if ! kill -0 "$pid" 2>/dev/null; then
                        pids=("${pids[@]/$pid}")
                        jobs_running=$((jobs_running - 1))
                        break
                    fi
                done
                sleep 0.1
            }
            jobs_running=$(jobs -r | wc -l)
        done

        # 启动后台任务
        eval "$cmd" &
        pids+=($!)
        jobs_running=$((jobs_running + 1))
        log_debug "启动任务: $cmd (PID: $!)"
    done

    # 等待所有任务完成
    local fail_count=0
    for pid in "${pids[@]}"; do
        if ! wait "$pid"; then
            fail_count=$((fail_count + 1))
            log_error "任务失败: PID $pid"
        fi
    done

    return ${fail_count}
}

# 使用 xargs 并行
parallel_xargs() {
    local max_jobs=$1
    shift
    local cmd="$1"
    shift

    printf '%s\n' "$@" | xargs -P "${max_jobs}" -I {} bash -c "${cmd} {}"
}

4.2 批量 SSH 并行执行

#!/usr/bin/env bash
set -euo pipefail

# 配置
HOSTS_FILE="hosts.txt"
MAX_PARALLEL=10
SSH_TIMEOUT=30
SSH_USER="deploy"
COMMAND="$*"

if [[ -z "${COMMAND}" ]]; then
    echo "用法: $0 <command>"
    exit 1
fi

# 结果目录
RESULT_DIR=$(mktemp -d)
trap "rm -rf ${RESULT_DIR}" EXIT

# 并行 SSH 执行
run_on_host() {
    local host=$1
    local result_file="${RESULT_DIR}/${host}.result"

    ssh -o ConnectTimeout="${SSH_TIMEOUT}" \
        -o StrictHostKeyChecking=no \
        -o BatchMode=yes \
        "${SSH_USER}@${host}" "${COMMAND}" \
        > "${result_file}.out" 2> "${result_file}.err"

    local exit_code=$?
    echo "${exit_code}" > "${result_file}.code"

    if [[ ${exit_code} -eq 0 ]]; then
        log_info "[OK] ${host}"
    else
        log_error "[FAIL] ${host} (exit=${exit_code})"
    fi
}

export -f run_on_host
export SSH_TIMEOUT SSH_USER COMMAND RESULT_DIR
export -f log_info log_error

# 使用 xargs 并行执行
cat "${HOSTS_FILE}" | xargs -P "${MAX_PARALLEL}" -I {} bash -c 'run_on_host "{}'

# 汇总结果
echo ""
echo "=== 执行结果汇总 ==="
total=0
success=0
failed=0

for host in $(cat "${HOSTS_FILE}"); do
    total=$((total + 1))
    code_file="${RESULT_DIR}/${host}.result.code"
    if [[ -f "${code_file}" ]] && [[ "$(cat "${code_file}")" == "0" ]]; then
        success=$((success + 1))
    else
        failed=$((failed + 1))
        echo "  [FAIL] ${host}"
        [[ -f "${RESULT_DIR}/${host}.result.err" ]] && \
            head -5 "${RESULT_DIR}/${host}.result.err" | sed 's/^/    /'
    fi
done

echo ""
echo "总计: ${total}  成功: ${success}  失败: ${failed}"

五、配置管理

5.1 配置文件加载

#!/usr/bin/env bash
set -euo pipefail

# 默认配置
DEFAULT_CONFIG="/etc/myapp/config.conf"
USER_CONFIG="${HOME}/.config/myapp/config.conf"
LOCAL_CONFIG="./config.local"

# 加载配置文件(后面的覆盖前面的)
load_config() {
    local config_files=("$@")

    for config_file in "${config_files[@]}"; do
        if [[ -f "${config_file}" ]]; then
            log_debug "加载配置: ${config_file}"
            # 安全加载:只允许 KEY=VALUE 格式
            while IFS='=' read -r key value; do
                # 跳过注释和空行
                [[ "$key" =~ ^[[:space:]]*# ]] && continue
                [[ -z "$key" ]] && continue
                # 去除首尾空格
                key="${key// /}"
                value="${value# }"
                value="${value% }"
                # 去除引号
                value="${value#\"}"
                value="${value%\"}"
                value="${value#\'}"
                value="${value%\'}"
                # 导出为环境变量
                export "${key}=${value}"
            done < "${config_file}"
        fi
    done
}

# 加载配置(优先级从低到高)
load_config "${DEFAULT_CONFIG}" "${USER_CONFIG}" "${LOCAL_CONFIG}"

5.2 环境变量覆盖

#!/usr/bin/env bash
set -euo pipefail

# 默认值(可被环境变量覆盖)
: "${APP_PORT:=8080}"
: "${APP_HOST:=0.0.0.0}"
: "${APP_ENV:=development}"
: "${DB_HOST:=localhost}"
: "${DB_PORT:=5432}"
: "${DB_NAME:=myapp}"
: "${LOG_LEVEL:=info}"
: "${MAX_WORKERS:=4}"

# 配置验证
validate_config() {
    local errors=0

    # 端口范围检查
    if ! [[ "${APP_PORT}" =~ ^[0-9]+$ ]] || \
       [[ "${APP_PORT}" -lt 1 || "${APP_PORT}" -gt 65535 ]]; then
        log_error "APP_PORT 无效: ${APP_PORT}"
        errors=$((errors + 1))
    fi

    # 环境检查
    if ! [[ "${APP_ENV}" =~ ^(development|staging|production)$ ]]; then
        log_error "APP_ENV 无效: ${APP_ENV} (允许: development|staging|production)"
        errors=$((errors + 1))
    fi

    # 生产环境额外检查
    if [[ "${APP_ENV}" == "production" ]]; then
        if [[ "${LOG_LEVEL}" == "debug" ]]; then
            log_warn "生产环境不建议使用 debug 日志级别"
        fi
        if [[ "${MAX_WORKERS}" -lt 2 ]]; then
            log_error "生产环境 MAX_WORKERS 不能小于 2"
            errors=$((errors + 1))
        fi
    fi

    if [[ ${errors} -gt 0 ]]; then
        log_error "配置验证失败,共 ${errors} 个错误"
        exit 1
    fi

    log_info "配置验证通过"
}

validate_config

六、参数解析

6.1 getopts 短选项

#!/usr/bin/env bash
set -euo pipefail

# 默认值
ENVIRONMENT="development"
VERBOSE=0
DRY_RUN=false
CONFIG_FILE=""
OUTPUT_DIR="."

# 使用 getopts 解析短选项
while getopts ":e:vc:dno:h" opt; do
    case ${opt} in
        e ) ENVIRONMENT="$OPTARG" ;;
        v ) VERBOSE=$((VERBOSE + 1)) ;;
        c ) CONFIG_FILE="$OPTARG" ;;
        d ) DRY_RUN=true ;;
        n ) DRY_RUN=true ;;
        o ) OUTPUT_DIR="$OPTARG" ;;
        h ) usage; exit 0 ;;
        \? ) echo "无效选项: -$OPTARG" >&2; usage; exit 1 ;;
        : ) echo "选项 -$OPTARG 需要参数" >&2; usage; exit 1 ;;
    esac
done
shift $((OPTIND - 1))

# 剩余参数
ACTION="${1:-}"
[[ -z "${ACTION}" ]] && { echo "缺少操作参数"; usage; exit 1; }

6.2 长选项解析

#!/usr/bin/env bash
set -euo pipefail

# 长选项解析函数
parse_args() {
    while [[ $# -gt 0 ]]; do
        case "$1" in
            --help|-h)
                usage; exit 0 ;;
            --version|-V)
                echo "${VERSION}"; exit 0 ;;
            --verbose|-v)
                VERBOSE=$((VERBOSE + 1)); shift ;;
            --dry-run|-n)
                DRY_RUN=true; shift ;;
            --env|-e)
                ENVIRONMENT="${2:-}"; shift 2 ;;
            --env=*)
                ENVIRONMENT="${1#*=}"; shift ;;
            --config|-c)
                CONFIG_FILE="${2:-}"; shift 2 ;;
            --config=*)
                CONFIG_FILE="${1#*=}"; shift ;;
            --output|-o)
                OUTPUT_DIR="${2:-}"; shift 2 ;;
            --output=*)
                OUTPUT_DIR="${1#*=}"; shift ;;
            --port)
                PORT="${2:-}"; shift 2 ;;
            --port=*)
                PORT="${1#*=}"; shift ;;
            --)
                shift; break ;;  # -- 后的参数不再解析
            -*)
                echo "未知选项: $1" >&2; usage; exit 1 ;;
            *)
                break ;;
        esac
    done

    # 剩余位置参数
    POSITIONAL_ARGS=("$@")
}

parse_args "$@"

七、单元测试(bats)

7.1 安装 bats

# macOS
brew install bats-core

# Ubuntu/Debian
apt-get install bats

# 从源码安装(推荐,获取最新版)
git clone https://github.com/bats-core/bats-core.git
cd bats-core
./install.sh "$HOME/.local"

7.2 编写测试

#!/usr/bin/env bats
# test_deploy.bats - 部署脚本测试

# 加载被测试的脚本(只加载函数,不执行主逻辑)
load 'test_helper'

setup() {
    # 每个测试前的准备工作
    export TEST_DIR=$(mktemp -d)
    export APP_NAME="test-app"
    export DEPLOY_DIR="${TEST_DIR}/deploy"
    mkdir -p "${DEPLOY_DIR}"
}

teardown() {
    # 每个测试后的清理
    rm -rf "${TEST_DIR}"
}

@test "create_release_dir 应创建版本目录" {
    create_release_dir "1.0.0"
    [ -d "${DEPLOY_DIR}/releases/1.0.0" ]
}

@test "create_release_dir 目录已存在时应失败" {
    create_release_dir "1.0.0"
    run create_release_dir "1.0.0"
    [ "$status" -ne 0 ]
}

@test "switch_symlink 应正确切换符号链接" {
    mkdir -p "${DEPLOY_DIR}/releases/1.0.0"
    mkdir -p "${DEPLOY_DIR}/releases/2.0.0"
    ln -sfn "${DEPLOY_DIR}/releases/1.0.0" "${DEPLOY_DIR}/current"

    switch_symlink "2.0.0"

    [ "$(readlink ${DEPLOY_DIR}/current)" = "${DEPLOY_DIR}/releases/2.0.0" ]
}

@test "rollback 应回退到上一个版本" {
    mkdir -p "${DEPLOY_DIR}/releases/1.0.0"
    mkdir -p "${DEPLOY_DIR}/releases/1.1.0"
    ln -sfn "${DEPLOY_DIR}/releases/1.1.0" "${DEPLOY_DIR}/current"

    run rollback
    [ "$status" -eq 0 ]
    [ "$(readlink ${DEPLOY_DIR}/current)" = "${DEPLOY_DIR}/releases/1.0.0" ]
}

@test "validate_config 无效端口应失败" {
    export APP_PORT="99999"
    run validate_config
    [ "$status" -ne 0 ]
    [[ "$output" == *"APP_PORT"* ]]
}

@test "health_check 服务正常时应返回0" {
    # mock 一个健康的服务
    start_mock_server 8080 &
    local mock_pid=$!
    sleep 1

    run health_check "localhost:8080"
    [ "$status" -eq 0 ]

    kill "$mock_pid" 2>/dev/null
}

7.3 运行测试

# 运行所有测试
bats test/

# 运行指定测试文件
bats test/test_deploy.bats

# 只运行匹配的测试
bats --filter "symlink" test/

# 输出 TAP 格式(适合 CI 集成)
bats --tap test/ | tee test-results.tap

# 输出 JUnit 格式
bats --formatter junit test/ > test-results.xml

7.4 GitHub Actions 集成

# .github/workflows/test.yml
name: Shell Tests
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Install bats
        run: |
          git clone https://github.com/bats-core/bats-core.git
          cd bats-core && ./install.sh "$HOME/.local"          
      - name: Install bats-support and bats-assert
        run: |
          git clone https://github.com/bats-core/bats-support.git test/test_helper/bats-support
          git clone https://github.com/bats-core/bats-assert.git test/test_helper/bats-assert          
      - name: Run ShellCheck
        uses: ludeeus/action-shellcheck@master
        with:
          severity: warning
      - name: Run bats tests
        run: export PATH="$HOME/.local/bin:$PATH" && bats test/

八、ShellCheck

8.1 安装与使用

# 安装
apt-get install shellcheck      # Debian/Ubuntu
brew install shellcheck          # macOS
# 或通过 Docker
docker run --rm -v "$PWD:/mnt" koalaman/shellcheck:stable check.sh

# 基本使用
shellcheck script.sh

# 指定严重级别
shellcheck --severity=error script.sh    # 只显示 error
shellcheck --severity=warning script.sh  # error + warning
shellcheck --severity=info script.sh     # error + warning + info
shellcheck --severity=style script.sh    # 全部(默认)

# 输出格式
shellcheck --format=gcc script.sh        # file:line:col: message(默认)
shellcheck --format=json script.sh       # JSON 格式
shellcheck --format=checkstyle script.sh # CheckStyle XML(CI 集成)

8.2 常见问题与修复

# === SC2086: 双引号防止分词 ===

# ❌ 错误
cp $file $destination

# ✅ 正确
cp "$file" "$destination"

# === SC2046: 命令替换要加引号 ===

# ❌ 错误
for f in $(ls *.txt); do

# ✅ 正确
for f in *.txt; do
    # 直接使用 glob

# === SC2004: 不需要 $ 在算术表达式中 ===

# ❌ 错误
if [[ $(( $a + $b )) -gt 10 ]]; then

# ✅ 正确
if (( a + b > 10 )); then

# === SC2181: 直接检查命令退出码 ===

# ❌ 错误
mkdir /opt/app
if [ $? -ne 0 ]; then
    echo "失败"
fi

# ✅ 正确
if ! mkdir /opt/app; then
    echo "失败"
fi

# 或者用 set -e 让它自动失败
set -euo pipefail
mkdir /opt/app

# === SC2155: 声明和赋值分开 ===

# ❌ 错误(命令替换的退出码被掩盖)
local var=$(some_command)

# ✅ 正确
local var
var=$(some_command)

# === SC2230: 用 command -v 替代 which ===

# ❌ 错误
if which docker > /dev/null; then

# ✅ 正确
if command -v docker > /dev/null; then

# === SC1090/SC1091: 不要 source 不确定来源的文件 ===

# ❌ 危险
source /tmp/downloaded_script.sh

# ✅ 安全:验证后再 source
if [[ -f /etc/app/config.sh ]] && [[ -O /etc/app/config.sh ]]; then
    source /etc/app/config.sh
fi

8.3 内联禁用

# 禁用单行检查
echo "不需要检查的行" # shellcheck disable=SC2086

# 禁用整个文件
# shellcheck disable=SC1090
source "$CONFIG_FILE"

# 禁用多个规则
# shellcheck disable=SC1090,SC2154,SC2034

8.4 .shellcheckrc 配置

# .shellcheckrc
disable=SC1090,SC1091,SC2154,SC2034
external-sources=true

九、安全编码

9.1 路径安全

#!/usr/bin/env bash
set -euo pipefail

# 安全的路径处理
safe_path() {
    local path="$1"
    local base_dir="$2"

    # 转换为绝对路径
    local abs_path
    abs_path=$(realpath -m "$path" 2>/dev/null || readlink -f "$path" 2>/dev/null || echo "$path")

    # 检查路径遍历攻击
    if [[ "$path" == *".."* ]]; then
        log_error "路径包含 .. : $path"
        return 1
    fi

    # 检查路径是否在允许的基目录下
    case "$abs_path" in
        "$base_dir"/*)
            echo "$abs_path"
            return 0
            ;;
        *)
            log_error "路径越界: $abs_path (基目录: $base_dir)"
            return 1
            ;;
    esac
}

# 安全删除:防止 rm -rf / 灾难
safe_rm() {
    local target="$1"

    # 绝对禁止删除根目录或家目录
    case "$target" in
        "/"|"/home"|"$HOME"|"/usr"|"/var"|"/etc"|"/bin"|"/sbin")
            log_error "拒绝删除关键目录: $target"
            return 1
            ;;
    esac

    # 检查路径是否以 / 开头但不是 /
    if [[ "$target" == /* ]] && [[ "$target" != "/" ]]; then
        # 确认路径存在
        if [[ ! -e "$target" ]]; then
            log_warn "路径不存在: $target"
            return 0
        fi

        # 执行删除
        log_warn "删除: $target"
        if [[ "${DRY_RUN:-false}" != "true" ]]; then
            rm -rf -- "$target"
        fi
    else
        log_error "路径必须是绝对路径: $target"
        return 1
    fi
}

9.2 输入验证

#!/usr/bin/env bash
set -euo pipefail

# 验证 IP 地址
validate_ip() {
    local ip="$1"
    if [[ "$ip" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then
        local IFS='.'
        read -ra octets <<< "$ip"
        for octet in "${octets[@]}"; do
            if [[ "$octet" -gt 255 ]]; then
                return 1
            fi
        done
        return 0
    fi
    return 1
}

# 验证端口号
validate_port() {
    local port="$1"
    if [[ "$port" =~ ^[0-9]+$ ]] && [[ "$port" -ge 1 ]] && [[ "$port" -le 65535 ]]; then
        return 0
    fi
    return 1
}

# 验证主机名
validate_hostname() {
    local host="$1"
    # RFC 1123 主机名规则
    if [[ "$host" =~ ^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$ ]]; then
        return 0
    fi
    return 1
}

# 安全的 SQL 值转义(防止注入)
escape_sql() {
    local value="$1"
    # 转义单引号
    echo "${value//\'/\'\'}"
}

# 验证文件名安全性
validate_filename() {
    local filename="$1"
    # 只允许字母、数字、下划线、横线、点
    if [[ "$filename" =~ ^[a-zA-Z0-9._-]+$ ]]; then
        return 0
    fi
    return 1
}

9.3 临时文件安全

#!/usr/bin/env bash
set -euo pipefail

# 安全创建临时文件
create_temp_file() {
    local prefix="${1:-tmp}"
    local tmp_file

    # mktemp 自动创建唯一文件,权限 600
    tmp_file=$(mktemp "/tmp/${prefix}.XXXXXX")
    echo "$tmp_file"
}

# 安全创建临时目录
create_temp_dir() {
    local prefix="${1:-tmp}"
    local tmp_dir

    # mktemp -d 创建唯一目录,权限 700
    tmp_dir=$(mktemp -d "/tmp/${prefix}.XXXXXX")
    echo "$tmp_dir"
}

# 使用示例
main() {
    local tmp_file tmp_dir

    tmp_file=$(create_temp_file "deploy")
    tmp_dir=$(create_temp_dir "build")

    # 确保退出时清理
    trap "rm -f '${tmp_file}'; rm -rf '${tmp_dir}'" EXIT

    # 使用临时文件...
    echo "data" > "$tmp_file"

    # 临时文件权限已为 600,但可以显式设置
    chmod 600 "$tmp_file"
}

十、生产级脚本完整示例

#!/usr/bin/env bash
#
# safe_deploy.sh - 安全部署脚本
#
# 用法: safe_deploy.sh --env <env> --app <name> [--version <ver>] [--dry-run]
#
# 特性:
#   - 完整的错误处理和日志
#   - 信号处理和优雅退出
#   - 参数验证和配置管理
#   - 回滚机制
#   - 健康检查
#

set -euo pipefail
IFS=$'\n\t'

# === 常量 ===
readonly SCRIPT_NAME="$(basename "$0")"
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly VERSION="2.0.0"
readonly LOCK_FILE="/tmp/${SCRIPT_NAME}.lock"

# === 全局变量 ===
ENVIRONMENT=""
APP_NAME=""
APP_VERSION=""
DRY_RUN=false
VERBOSE=0
BACKUP_DIR=""
RELEASE_DIR=""
CURRENT_LINK=""
KEEP_RELEASES=5

# === 颜色 ===
if [[ -t 1 ]]; then
    readonly RED=$'\033[0;31m'
    readonly GREEN=$'\033[0;32m'
    readonly YELLOW=$'\033[0;33m'
    readonly BLUE=$'\033[0;34m'
    readonly NC=$'\033[0m'
else
    readonly RED='' GREEN='' YELLOW='' BLUE='' NC=''
fi

# === 日志 ===
log_info()  { echo "${GREEN}[INFO]${NC}  $(date '+%H:%M:%S') $*"; }
log_warn()  { echo "${YELLOW}[WARN]${NC}  $(date '+%H:%M:%S') $*" >&2; }
log_error() { echo "${RED}[ERROR]${NC} $(date '+%H:%M:%S') $*" >&2; }
log_debug() { [[ $VERBOSE -ge 1 ]] && echo "${BLUE}[DEBUG]${NC} $(date '+%H:%M:%S') $*" >&2; }

# === 错误处理 ===
on_error() {
    local exit_code=$?
    local line=$1
    log_error "脚本失败: 行 ${line}, 退出码 ${exit_code}"
    if [[ -n "${CURRENT_LINK:-}" ]] && [[ -n "${BACKUP_DIR:-}" ]]; then
        log_warn "尝试自动回滚..."
        if [[ -d "${BACKUP_DIR}" ]]; then
            ln -sfn "${BACKUP_DIR}" "${CURRENT_LINK}" 2>/dev/null || true
            log_info "已回滚到上一版本"
        fi
    fi
    release_lock
    exit ${exit_code}
}
trap 'on_error ${LINENO}' ERR

# === 信号处理 ===
on_interrupt() {
    log_warn "收到中断信号,正在退出..."
    release_lock
    exit 130
}
trap on_interrupt SIGINT SIGTERM

# === 文件锁 ===
acquire_lock() {
    if [[ -f "${LOCK_FILE}" ]]; then
        local pid
        pid=$(cat "${LOCK_FILE}" 2>/dev/null || echo "")
        if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then
            log_error "另一个实例正在运行 (PID: $pid)"
            exit 1
        fi
        log_warn "发现过期锁文件,已清除"
        rm -f "${LOCK_FILE}"
    fi
    echo $$ > "${LOCK_FILE}"
}

release_lock() {
    rm -f "${LOCK_FILE}" 2>/dev/null || true
}
trap release_lock EXIT

# === 用法 ===
usage() {
    cat <<EOF
用法: ${SCRIPT_NAME} [选项]

必需选项:
    -e, --env ENV        环境 (staging|production)
    -a, --app NAME       应用名称

可选选项:
    -v, --version VER    部署版本 (默认: 最新 git tag)
    -n, --dry-run        只打印不执行
    -V, --verbose        详细输出
    -k, --keep N         保留版本数 (默认: 5)
    -h, --help           显示帮助

示例:
    ${SCRIPT_NAME} --env production --app myapp
    ${SCRIPT_NAME} -e staging -a api --version v1.2.3 --dry-run
EOF
    exit "${1:-0}"
}

# === 参数解析 ===
parse_args() {
    while [[ $# -gt 0 ]]; do
        case "$1" in
            -e|--env)       ENVIRONMENT="$2"; shift 2 ;;
            --env=*)        ENVIRONMENT="${1#*=}"; shift ;;
            -a|--app)       APP_NAME="$2"; shift 2 ;;
            --app=*)        APP_NAME="${1#*=}"; shift ;;
            -v|--version)   APP_VERSION="$2"; shift 2 ;;
            --version=*)    APP_VERSION="${1#*=}"; shift ;;
            -n|--dry-run)   DRY_RUN=true; shift ;;
            -V|--verbose)   VERBOSE=$((VERBOSE+1)); shift ;;
            -k|--keep)      KEEP_RELEASES="$2"; shift 2 ;;
            -h|--help)      usage 0 ;;
            *)              log_error "未知选项: $1"; usage 1 ;;
        esac
    done
}

# === 验证 ===
validate() {
    local errors=0

    [[ -z "${ENVIRONMENT}" ]] && { log_error "缺少 --env 参数"; errors=$((errors+1)); }
    [[ -z "${APP_NAME}" ]] && { log_error "缺少 --app 参数"; errors=$((errors+1)); }

    if [[ "${ENVIRONMENT}" != "staging" && "${ENVIRONMENT}" != "production" ]]; then
        log_error "无效环境: ${ENVIRONMENT}"
        errors=$((errors+1))
    fi

    if ! [[ "${KEEP_RELEASES}" =~ ^[0-9]+$ ]] || [[ "${KEEP_RELEASES}" -lt 1 ]]; then
        log_error "保留版本数无效: ${KEEP_RELEASES}"
        errors=$((errors+1))
    fi

    [[ ${errors} -gt 0 ]] && exit 1

    # 设置路径
    local base_dir="/opt/${APP_NAME}"
    RELEASE_DIR="${base_dir}/releases"
    CURRENT_LINK="${base_dir}/current"

    if [[ "${APP_VERSION}" == "" ]]; then
        APP_VERSION=$(git describe --tags --always 2>/dev/null || echo "unknown-$(date +%s)")
    fi

    log_info "部署配置:"
    log_info "  环境: ${ENVIRONMENT}"
    log_info "  应用: ${APP_NAME}"
    log_info "  版本: ${APP_VERSION}"
    log_info "  Dry-run: ${DRY_RUN}"
}

# === 部署步骤 ===
pre_deploy_check() {
    log_info "部署前检查..."

    # 检查磁盘空间
    local available
    available=$(df -m /opt | awk 'NR==2{print $4}')
    if [[ ${available} -lt 1024 ]]; then
        log_error "磁盘空间不足: ${available}MB (需要 >1024MB)"
        return 1
    fi
    log_debug "磁盘空间: ${available}MB"

    # 检查当前版本是否已在运行
    if [[ -L "${CURRENT_LINK}" ]]; then
        local current_ver
        current_ver=$(basename "$(readlink "${CURRENT_LINK}")")
        if [[ "${current_ver}" == "${APP_VERSION}" ]]; then
            log_error "版本 ${APP_VERSION} 已部署"
            return 1
        fi
        BACKUP_DIR="$(readlink "${CURRENT_LINK}")"
        log_debug "当前版本: ${current_ver}"
    fi
}

deploy() {
    log_info "开始部署 ${APP_NAME} ${APP_VERSION}"

    local target_dir="${RELEASE_DIR}/${APP_VERSION}"

    if [[ "${DRY_RUN}" == "true" ]]; then
        log_warn "[DRY-RUN] 创建目录: ${target_dir}"
        log_warn "[DRY-RUN] 切换链接: ${CURRENT_LINK} -> ${target_dir}"
        return 0
    fi

    # 创建版本目录
    mkdir -p "${target_dir}"
    log_debug "创建目录: ${target_dir}"

    # 复制文件(此处简化,实际从 artifact 存储拉取)
    log_info "拉取制品包..."
    # cp -r build/* "${target_dir}/"

    # 切换符号链接
    ln -sfn "${target_dir}" "${CURRENT_LINK}"
    log_info "切换链接: ${CURRENT_LINK} -> ${target_dir}"

    # 清理旧版本
    cleanup_old_releases
}

cleanup_old_releases() {
    log_info "清理旧版本 (保留 ${KEEP_RELEASES} 个)..."

    local releases=()
    while IFS= read -r dir; do
        releases+=("$dir")
    done < <(ls -1dt "${RELEASE_DIR}"/*/ 2>/dev/null | sed 's|/$||')

    local count=${#releases[@]}
    if [[ ${count} -le ${KEEP_RELEASES} ]]; then
        log_debug "版本数 ${count} <= ${KEEP_RELEASES},无需清理"
        return 0
    fi

    local to_delete=$((count - KEEP_RELEASES))
    log_info "清理 ${to_delete} 个旧版本"

    for ((i = KEEP_RELEASES; i < count; i++)); do
        local dir="${releases[$i]}"
        # 不删除当前正在使用的版本
        if [[ "$(readlink "${CURRENT_LINK}")" != "${dir}" ]]; then
            log_debug "删除: ${dir}"
            [[ "${DRY_RUN}" != "true" ]] && rm -rf "${dir}"
        fi
    done
}

health_check() {
    log_info "健康检查..."

    local max_retries=10
    local retry_interval=3

    for ((i = 1; i <= max_retries; i++)); do
        if curl -sf "http://localhost:8080/health" > /dev/null 2>&1; then
            log_info "健康检查通过"
            return 0
        fi
        log_debug "等待服务启动... (${i}/${max_retries})"
        sleep "${retry_interval}"
    done

    log_error "健康检查失败"
    return 1
}

rollback() {
    log_warn "执行回滚..."

    if [[ -z "${BACKUP_DIR}" ]] || [[ ! -d "${BACKUP_DIR}" ]]; then
        log_error "无可回滚的版本"
        return 1
    fi

    ln -sfn "${BACKUP_DIR}" "${CURRENT_LINK}"
    log_info "已回滚到: ${BACKUP_DIR}"
}

# === 主流程 ===
main() {
    parse_args "$@"
    validate
    acquire_lock

    pre_deploy_check
    deploy
    health_check

    log_info "部署完成: ${APP_NAME} ${APP_VERSION}"
    release_lock
}

main "$@"

总结

Bash 脚本的"生产级"不在于功能多复杂,而在于在意外发生时是否安全。一个 set -e 就能避免 rm -rf 在错误目录执行的灾难,一个 trap 就能保证临时文件被清理,一个 ShellCheck 就能消除分词和注入隐患。回顾本文核心要点:

  1. 三行头是底线set -euo pipefail + IFS=$'\n\t' 是每个生产脚本的标配,没有商量的余地
  2. 错误处理是核心trap ERR 捕获错误、run_cmd 包装器记录日志、retry 机制应对临时故障——这三层防御覆盖了 90% 的错误场景
  3. 信号处理保证优雅SIGTERM 触发优雅退出而非直接 kill,正在处理的任务能安全完成
  4. 并行执行要可控xargs -P 是最简洁的并行方案,wait -n 是 Bash 4+ 的原语,两者结合能实现有限并发的任务池
  5. 参数解析要规范:长选项用 case 手动解析,短选项用 getopts,-- 分隔选项和位置参数
  6. 测试和检查不可省:bats 做单元测试、ShellCheck 做静态检查,两者纳入 CI 就能把脚本质量管控起来
  7. 安全编码是红线:路径验证防遍历、输入验证防注入、临时文件用 mktemp、关键操作加确认

Bash 的优势是"到处都有",劣势是"容易写出不安全的代码"。当你把这套规范内化后,Bash 脚本可以和任何语言写的运维工具一样可靠——而且部署成本为零。

参考资料与致谢

本文在撰写过程中参考了以下资料,感谢原作者的贡献:

  1. Google Shell Style Guide — Google,参考了Google Shell Style Guide相关内容
  2. ShellCheck — Shellcheck,参考了ShellCheck相关内容