概述
Bash 是运维世界使用最广泛的脚本语言——几乎每台 Linux 服务器都自带解释器,无需安装任何运行时。但 Bash 也是最容易写出"能跑但不能信赖"脚本的语言:没有类型检查、错误默认静默、变量不加引号就分词、管道中的失败被吞掉。一个线上故障的根因往往就是某个 Bash 脚本没做错误处理。本文逐步梳理生产级 Bash 脚本的编写规范,让你写出的脚本不只是"能跑",而是"可信赖"。
一、脚本骨架: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 就能消除分词和注入隐患。回顾本文核心要点:
- 三行头是底线:
set -euo pipefail+IFS=$'\n\t'是每个生产脚本的标配,没有商量的余地 - 错误处理是核心:
trap ERR捕获错误、run_cmd包装器记录日志、retry机制应对临时故障——这三层防御覆盖了 90% 的错误场景 - 信号处理保证优雅:
SIGTERM触发优雅退出而非直接 kill,正在处理的任务能安全完成 - 并行执行要可控:
xargs -P是最简洁的并行方案,wait -n是 Bash 4+ 的原语,两者结合能实现有限并发的任务池 - 参数解析要规范:长选项用 case 手动解析,短选项用 getopts,
--分隔选项和位置参数 - 测试和检查不可省:bats 做单元测试、ShellCheck 做静态检查,两者纳入 CI 就能把脚本质量管控起来
- 安全编码是红线:路径验证防遍历、输入验证防注入、临时文件用 mktemp、关键操作加确认
Bash 的优势是"到处都有",劣势是"容易写出不安全的代码"。当你把这套规范内化后,Bash 脚本可以和任何语言写的运维工具一样可靠——而且部署成本为零。
参考资料与致谢
本文在撰写过程中参考了以下资料,感谢原作者的贡献:
- Google Shell Style Guide — Google,参考了Google Shell Style Guide相关内容
- ShellCheck — Shellcheck,参考了ShellCheck相关内容