Shell脚本编程进阶:从基础到生产级脚本实战
前言
Shell脚本是运维工程师的"瑞士军刀"。一个编写良好的Shell脚本可以节省大量重复劳动,减少人为失误。然而,很多运维人员编写的脚本仅仅停留在"能跑就行"的水平,缺乏错误处理、日志记录、幂等性等生产级特性。本文将带你从入门到精通,打造真正的生产级Shell脚本。
一、脚本规范与最佳实践
1.1 脚本头部模板
#!/bin/bash
#============================================
# Script: backup_mysql.sh
# Version: 1.2.0
# Author: ops-team
# Date: 2025-06-25
# Usage: ./backup_mysql.sh [--full|--incr]
# Description: MySQL数据库全量/增量备份
# Changelog:
# v1.2.0 - 添加增量备份支持
# v1.1.0 - 添加压缩加密功能
# v1.0.0 - 初始版本
#============================================
set -euo pipefail
# -e: 命令失败立即退出
# -u: 引用未定义变量报错
# -o pipefail: 管道中任一命令失败则整体失败
# 全局变量
readonly SCRIPT_NAME="$(basename "$0")"
readonly SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
readonly TIMESTAMP="$(date +%Y%m%d_%H%M%S)"
readonly LOG_FILE="/var/log/${SCRIPT_NAME%.sh}_${TIMESTAMP}.log"
# 配置
BACKUP_DIR="${BACKUP_DIR:-/data/backup/mysql}"
RETENTION_DAYS="${RETENTION_DAYS:-7}"
1.2 日志函数
# 日志级别定义
readonly LOG_DEBUG=0
readonly LOG_INFO=1
readonly LOG_WARN=2
readonly LOG_ERROR=3
readonly LOG_LEVEL="${LOG_LEVEL:-$LOG_INFO}"
log() {
local level="$1"; shift
local level_str=""
case "$level" in
$LOG_DEBUG) level_str="DEBUG" ;;
$LOG_INFO) level_str="INFO" ;;
$LOG_WARN) level_str="WARN" ;;
$LOG_ERROR) level_str="ERROR" ;;
esac
if [ "$level" -ge "$LOG_LEVEL" ]; then
local msg="[$(date '+%Y-%m-%d %H:%M:%S')] [$level_str] $*"
echo "$msg" | tee -a "$LOG_FILE"
fi
}
二、错误处理与重试机制
2.1 结构化错误处理
# 错误陷阱
trap 'error_handler $? $LINENO' ERR
error_handler() {
local exit_code="$1"
local line_no="$2"
log $LOG_ERROR "脚本异常退出 (exit=$exit_code) at line $line_no"
cleanup
exit "$exit_code"
}
cleanup() {
log $LOG_INFO "执行清理操作..."
# 删除临时文件
rm -f "${TEMP_DIR:-/tmp/$$}"/* 2>/dev/null
# 释放锁
if [ -n "${LOCK_FILE:-}" ] && [ -f "$LOCK_FILE" ]; then
rm -f "$LOCK_FILE"
fi
}
2.2 重试函数
retry() {
local max_attempts="$1"
local delay="$2"
shift 2
local cmd="$*"
local attempt=1
while [ $attempt -le "$max_attempts" ]; do
log $LOG_INFO "执行命令 (第${attempt}/${max_attempts}次): $cmd"
if eval "$cmd"; then
return 0
fi
log $LOG_WARN "命令执行失败, ${delay}秒后重试..."
sleep "$delay"
attempt=$((attempt + 1))
done
log $LOG_ERROR "命令执行失败, 已达最大重试次数"
return 1
}
# 使用示例
retry 5 10 "curl -sSf https://api.example.com/health"
三、并发与锁机制
3.1 文件锁防止重复执行
acquire_lock() {
LOCK_FILE="/var/run/${SCRIPT_NAME%.sh}.lock"
exec 200>"$LOCK_FILE"
if ! flock -n 200; then
log $LOG_ERROR "脚本已在运行中, PID: $(cat "$LOCK_FILE")"
exit 1
fi
echo $$ > "$LOCK_FILE"
log $LOG_INFO "获取锁成功, PID: $$"
}
# 脚本开头调用
acquire_lock
trap 'cleanup' EXIT # 确保脚本退出时释放锁
3.2 并发任务处理
# 并行处理多台服务器
run_parallel() {
local max_jobs="${1:-5}"
local servers=("${@:2}")
local pids=()
for server in "${servers[@]}"; do
# 控制并发数
while [ ${#pids[@]} -ge "$max_jobs" ]; do
for i in "${!pids[@]}"; do
if ! kill -0 "${pids[$i]}" 2>/dev/null; then
wait "${pids[$i]}"
unset 'pids[$i]'
fi
done
pids=("${pids[@]}")
sleep 0.5
done
(
ssh "$server" "uptime && df -h / && free -h"
) &
pids+=($!)
log $LOG_INFO "启动任务: $server (PID=$!)"
done
# 等待所有任务完成
wait
log $LOG_INFO "所有并发任务执行完成"
}
四、配置文件解析
4.1 INI风格配置
# config.ini
# [database]
# host=192.168.1.100
# port=3306
# user=backup
# password=secret
parse_ini() {
local ini_file="$1"
local section="${2:-}"
local key="$3"
awk -F '=' -v section="$section" -v key="$key" '
/^\[/ { in_section = ($0 == "[" section "]") }
in_section && $1 == key { gsub(/^[ \t]+|[ \t]+$/, "", $2); print $2; exit }
' "$ini_file"
}
# 使用
DB_HOST=$(parse_ini "config.ini" "database" "host")
DB_PORT=$(parse_ini "config.ini" "database" "port")
五、实战:数据库自动备份脚本
#!/bin/bash
set -euo pipefail
readonly BACKUP_BASE="/data/backup/mysql"
readonly MYSQL_USER="backup"
readonly MYSQL_PASS="secret"
readonly DATABASES=("mydb" "appdb" "logdb")
backup_database() {
local db="$1"
local backup_file="${BACKUP_BASE}/${db}_${TIMESTAMP}.sql.gz"
log $LOG_INFO "开始备份数据库: $db"
if mysqldump -u"$MYSQL_USER" -p"$MYSQL_PASS" \
--single-transaction \
--routines \
--triggers \
--events \
"$db" | gzip > "$backup_file"; then
local size=$(du -h "$backup_file" | cut -f1)
log $LOG_INFO "备份完成: $backup_file ($size)"
return 0
else
log $LOG_ERROR "备份失败: $db"
return 1
fi
}
rotate_backups() {
log $LOG_INFO "清理 ${RETENTION_DAYS} 天前的备份..."
find "$BACKUP_BASE" -name "*.sql.gz" -mtime +"$RETENTION_DAYS" -delete
}
# 主流程
main() {
log $LOG_INFO "===== 数据库备份开始 ====="
acquire_lock
mkdir -p "$BACKUP_BASE"
for db in "${DATABASES[@]}"; do
retry 3 5 "backup_database $db" || log $LOG_ERROR "跳过数据库: $db"
done
rotate_backups
log $LOG_INFO "===== 数据库备份结束 ====="
}
main "$@"
六、脚本测试与调试
# 调试模式运行
bash -x script.sh
# 语法检查(不执行)
bash -n script.sh
# 使用ShellCheck静态分析
shellcheck script.sh
# 单元测试框架 bats
# test_backup.bats
@test "backup directory exists" {
run mkdir -p /tmp/backup
[ "$status" -eq 0 ]
[ -d "/tmp/backup" ]
}
总结
一个优秀的生产级Shell脚本应该具备:
- 健壮性:完善的错误处理和重试机制
- 可观测性:结构化日志和告警
- 幂等性:可重复执行不产生副作用
- 安全性:输入验证、权限控制
- 可维护性:清晰的结构、充分的注释
记住:写好脚本,就是对自己最好的保护。