Linux运维6 min read

Shell脚本编程进阶:从基础到生产级脚本实战

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脚本应该具备:

  1. 健壮性:完善的错误处理和重试机制
  2. 可观测性:结构化日志和告警
  3. 幂等性:可重复执行不产生副作用
  4. 安全性:输入验证、权限控制
  5. 可维护性:清晰的结构、充分的注释

记住:写好脚本,就是对自己最好的保护。

分享:

相关文章