强曰为道
与天地相似,故不违。知周乎万物,而道济天下,故不过。旁行而不流,乐天知命,故不忧.
文档目录

Unix 设计哲学教程 / 第 11 章:Shell 脚本与自动化

第 11 章:Shell 脚本与自动化

“The best way to automate is to first do it manually, then script it.”

Shell 脚本是 Unix 哲学的直接实践——将多个小工具通过管道和控制流组合,完成复杂的自动化任务。从系统管理到 DevOps,从数据处理到 CI/CD,Shell 脚本无处不在。


11.1 Shell 脚本基础

脚本结构

#!/bin/bash
# 脚本说明:简要描述脚本功能
# 作者:你的名字
# 日期:2026-05-10

set -euo pipefail  # 严格模式
# -e: 命令失败立即退出
# -u: 使用未定义变量报错
# -o pipefail: 管道中任一命令失败即失败

# 常量定义
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly SCRIPT_NAME="$(basename "$0")"
readonly LOG_FILE="/var/log/${SCRIPT_NAME%.sh}.log"

# 函数定义
log() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"
}

error() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: $*" >&2
    exit 1
}

cleanup() {
    # 清理临时文件
    rm -f "${TMP_FILE:-}"
}

# 设置清理陷阱
trap cleanup EXIT

# 主逻辑
main() {
    log "脚本开始执行"
    # ... 业务逻辑 ...
    log "脚本执行完成"
}

main "$@"

变量与数据类型

# 变量赋值(等号两边不能有空格)
name="Alice"
age=30
readonly PI=3.14159  # 只读变量

# 使用变量
echo "$name"
echo "${name}_suffix"  # 使用花括号避免歧义

# 字符串操作
str="Hello, World!"
echo "${#str}"          # 长度: 13
echo "${str:0:5}"       # 截取: Hello
echo "${str/World/Unix}" # 替换: Hello, Unix!
echo "${str,,}"         # 转小写 (Bash 4+)
echo "${str^^}"         # 转大写 (Bash 4+)

# 数组 (Bash)
arr=("apple" "banana" "cherry")
echo "${arr[0]}"        # 第一个元素: apple
echo "${arr[@]}"        # 所有元素
echo "${#arr[@]}"       # 数组长度: 3
arr+=("date")           # 追加元素

# 关联数组 (Bash 4+)
declare -A map
map[name]="Alice"
map[age]=30
echo "${map[name]}"
echo "${!map[@]}"       # 所有键

# 命令替换
current_date=$(date +%Y-%m-%d)
file_count=$(find . -type f | wc -l)

# 算术运算
a=10; b=3
echo $((a + b))         # 加法: 13
echo $((a - b))         # 减法: 7
echo $((a * b))         # 乘法: 30
echo $((a / b))         # 除法: 3
echo $((a % b))         # 取余: 1
echo $((a ** b))        # 幂: 1000

# 浮点运算(需要 bc)
result=$(echo "scale=2; $a / $b" | bc)
echo "$result"           # 3.33

条件判断

# 文件测试
[ -f file ]      # 文件存在且是普通文件
[ -d dir ]       # 目录存在
[ -r file ]      # 文件可读
[ -w file ]      # 文件可写
[ -x file ]      # 文件可执行
[ -s file ]      # 文件存在且大小 > 0
[ -L file ]      # 文件是符号链接
[ file1 -nt file2 ]  # file1 比 file2 新
[ file1 -ot file2 ]  # file1 比 file2 旧

# 字符串测试
[ -z "$str" ]    # 字符串为空
[ -n "$str" ]    # 字符串不为空
[ "$a" = "$b" ]  # 字符串相等
[ "$a" != "$b" ] # 字符串不等
[ "$a" \< "$b" ] # 字符串字典序小于

# 数值测试
[ "$a" -eq "$b" ]  # 等于
[ "$a" -ne "$b" ]  # 不等于
[ "$a" -gt "$b" ]  # 大于
[ "$a" -ge "$b" ]  # 大于等于
[ "$a" -lt "$b" ]  # 小于
[ "$a" -le "$b" ]  # 小于等于

# 逻辑运算
[ "$a" -gt 0 ] && [ "$a" -lt 100 ]   # AND
[ "$a" -eq 0 ] || [ "$a" -eq 1 ]      # OR
[ ! -f file ]                          # NOT

# [[ ]] 扩展测试(Bash)
[[ "$str" =~ ^[0-9]+$ ]]     # 正则匹配
[[ "$str" == *.txt ]]         # 通配符匹配
[[ -f file && -r file ]]      # 组合条件(不用 [ ] 的话更简洁)

# if-elif-else
if [ "$count" -gt 100 ]; then
    echo "大于100"
elif [ "$count" -gt 50 ]; then
    echo "大于50"
else
    echo "小于等于50"
fi

# case 语句
case "$action" in
    start)
        start_service
        ;;
    stop)
        stop_service
        ;;
    restart)
        stop_service
        start_service
        ;;
    *)
        echo "Usage: $0 {start|stop|restart}"
        exit 1
        ;;
esac

循环

# for 循环
for i in 1 2 3 4 5; do
    echo "$i"
done

# 范围
for i in {1..10}; do
    echo "$i"
done

# C 风格
for ((i=0; i<10; i++)); do
    echo "$i"
done

# 遍历文件
for file in *.txt; do
    echo "Processing: $file"
done

# 遍历命令输出
for user in $(cat /etc/passwd | cut -d: -f1); do
    echo "User: $user"
done

# while 循环
while read -r line; do
    echo "Line: $line"
done < file.txt

# 读取管道
cat file.txt | while IFS= read -r line; do
    echo "$line"
done

# until 循环
until ping -c 1 server &>/dev/null; do
    echo "等待 server 上线..."
    sleep 5
done

# 循环控制
for i in {1..100}; do
    [ "$i" -eq 50 ] && continue  # 跳过本次
    [ "$i" -eq 90 ] && break      # 退出循环
    echo "$i"
done

11.2 函数

函数定义与调用

# 函数定义方式 1
greet() {
    local name="$1"  # local 变量
    echo "Hello, $name!"
}

# 函数定义方式 2
function greet {
    echo "Hello, $1!"
}

# 调用函数
greet "Alice"

# 带返回值的函数
is_valid_email() {
    local email="$1"
    if [[ "$email" =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]; then
        return 0  # 成功
    else
        return 1  # 失败
    fi
}

if is_valid_email "[email protected]"; then
    echo "有效的邮箱"
fi

# 返回字符串(通过 stdout)
get_hostname() {
    hostname -f
}
my_host=$(get_hostname)

# 返回多个值(通过 stdout 和全局变量)
get_user_info() {
    local username="$1"
    USER_NAME=$(getent passwd "$username" | cut -d: -f5)
    USER_HOME=$(getent passwd "$username" | cut -d: -f6)
    USER_SHELL=$(getent passwd "$username" | cut -d: -f7)
}

get_user_info "root"
echo "Name: $USER_NAME, Home: $USER_HOME, Shell: $USER_SHELL"

11.3 错误处理

严格模式与陷阱

#!/bin/bash
set -euo pipefail

# trap 命令:捕获信号和退出
TMP_DIR=""
cleanup() {
    if [ -n "$TMP_DIR" ] && [ -d "$TMP_DIR" ]; then
        rm -rf "$TMP_DIR"
        echo "清理临时目录: $TMP_DIR"
    fi
}

# EXIT 陷阱:脚本退出时执行
trap cleanup EXIT

# ERR 陷阱:命令出错时执行
trap 'echo "错误发生在第 $LINENO 行,命令: $BASH_COMMAND" >&2' ERR

# 信号陷阱
trap 'echo "收到 SIGINT,正在退出..."; exit 130' INT
trap 'echo "收到 SIGTERM,正在退出..."; exit 143' TERM

# 创建临时目录
TMP_DIR=$(mktemp -d)
echo "临时目录: $TMP_DIR"

# 主逻辑
main() {
    # 如果这里出错,cleanup 会自动执行
    cp /some/file "$TMP_DIR/"
    process_files "$TMP_DIR"
}

main

常见错误模式

# 1. 检查命令是否存在
command_exists() {
    command -v "$1" &>/dev/null
}

if ! command_exists docker; then
    echo "错误: docker 未安装" >&2
    exit 1
fi

# 2. 检查文件是否存在
require_file() {
    if [ ! -f "$1" ]; then
        echo "错误: 文件不存在: $1" >&2
        exit 1
    fi
}

# 3. 重试机制
retry() {
    local max_attempts=$1
    shift
    local attempt=1
    local delay=5

    until "$@"; do
        if [ "$attempt" -ge "$max_attempts" ]; then
            echo "错误: 命令失败 $max_attempts 次: $*" >&2
            return 1
        fi
        echo "尝试 $attempt/$max_attempts 失败,${delay}秒后重试..."
        sleep "$delay"
        ((attempt++))
    done
}

retry 3 curl -s "https://api.example.com/data"

11.4 系统管理脚本

服务管理脚本

#!/bin/bash
# 简单的服务管理脚本

readonly SERVICE_NAME="myapp"
readonly PID_FILE="/var/run/${SERVICE_NAME}.pid"
readonly LOG_FILE="/var/log/${SERVICE_NAME}.log"
readonly APP_BIN="/opt/${SERVICE_NAME}/bin/${SERVICE_NAME}"

start() {
    if [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
        echo "${SERVICE_NAME} 已经在运行 (PID: $(cat "$PID_FILE"))"
        return 1
    fi

    echo "启动 ${SERVICE_NAME}..."
    nohup "$APP_BIN" >> "$LOG_FILE" 2>&1 &
    echo $! > "$PID_FILE"
    echo "${SERVICE_NAME} 已启动 (PID: $!)"
}

stop() {
    if [ ! -f "$PID_FILE" ]; then
        echo "${SERVICE_NAME} 未运行"
        return 0
    fi

    local pid
    pid=$(cat "$PID_FILE")
    echo "停止 ${SERVICE_NAME} (PID: $pid)..."

    kill "$pid" 2>/dev/null
    # 等待进程退出
    local timeout=30
    while kill -0 "$pid" 2>/dev/null && [ "$timeout" -gt 0 ]; do
        sleep 1
        ((timeout--))
    done

    if kill -0 "$pid" 2>/dev/null; then
        echo "进程未退出,强制终止..."
        kill -9 "$pid"
    fi

    rm -f "$PID_FILE"
    echo "${SERVICE_NAME} 已停止"
}

status() {
    if [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
        echo "${SERVICE_NAME} 正在运行 (PID: $(cat "$PID_FILE"))"
    else
        echo "${SERVICE_NAME} 未运行"
    fi
}

case "${1:-}" in
    start)   start ;;
    stop)    stop ;;
    restart) stop; start ;;
    status)  status ;;
    *)       echo "用法: $0 {start|stop|restart|status}" ;;
esac

日志轮转脚本

#!/bin/bash
# 简单的日志轮转

readonly LOG_DIR="/var/log/myapp"
readonly MAX_SIZE=$((100 * 1024 * 1024))  # 100MB
readonly KEEP_COUNT=5

rotate_log() {
    local logfile="$1"

    if [ ! -f "$logfile" ]; then
        return
    fi

    local size
    size=$(stat -f%z "$logfile" 2>/dev/null || stat -c%s "$logfile" 2>/dev/null)

    if [ "$size" -gt "$MAX_SIZE" ]; then
        echo "轮转日志: $logfile ($size bytes)"

        # 移动旧的轮转文件
        for i in $(seq $((KEEP_COUNT - 1)) -1 1); do
            [ -f "${logfile}.${i}.gz" ] && mv "${logfile}.${i}.gz" "${logfile}.$((i + 1)).gz"
        done

        # 压缩当前日志
        cp "$logfile" "${logfile}.1"
        gzip -f "${logfile}.1"
        : > "$logfile"  # 清空原文件
    fi
}

# 轮转所有日志
find "$LOG_DIR" -name "*.log" -type f | while read -r logfile; do
    rotate_log "$logfile"
done

# 清理过旧的日志
find "$LOG_DIR" -name "*.gz" -mtime +30 -delete

磁盘空间监控

#!/bin/bash
# 磁盘空间监控与告警

readonly THRESHOLD=90  # 告警阈值百分比
readonly ALERT_EMAIL="[email protected]"

check_disk_space() {
    local filesystem usage mount_point

    df -h | tail -n +2 | while read -r filesystem _ _ _ usage mount_point; do
        usage=${usage%\%}  # 移除百分号

        if [ "$usage" -ge "$THRESHOLD" ]; then
            local message="磁盘空间告警: ${mount_point} 使用率 ${usage}%"
            echo "$message"
            echo "$message" | mail -s "磁盘空间告警" "$ALERT_EMAIL"

            # 自动清理临时文件
            if [ "$mount_point" = "/" ]; then
                find /tmp -type f -mtime +7 -delete
                journalctl --vacuum-time=7d
                apt-get clean 2>/dev/null || yum clean all 2>/dev/null
            fi
        fi
    done
}

check_disk_space

11.5 定时任务(Cron)

Crontab 语法

# 编辑 crontab
crontab -e

# 格式: 分 时 日 月 星期 命令
# ┌───── 分钟 (0-59)
# │ ┌───── 小时 (0-23)
# │ │ ┌───── 日 (1-31)
# │ │ │ ┌───── 月 (1-12)
# │ │ │ │ ┌───── 星期 (0-7, 0和7都是周日)
# │ │ │ │ │
# * * * * * command

# 常用示例
0 2 * * * /opt/scripts/backup.sh           # 每天凌晨 2 点
*/5 * * * * /opt/scripts/check.sh           # 每 5 分钟
0 0 * * 0 /opt/scripts/weekly-report.sh    # 每周日午夜
0 9 1 * * /opt/scripts/monthly-cleanup.sh  # 每月 1 号上午 9 点
30 18 * * 1-5 /opt/scripts/weekday.sh      # 工作日下午 6:30

# 特殊字符串
@reboot   command    # 系统启动时
@yearly   command    # 每年 (0 0 1 1 *)
@monthly  command    # 每月 (0 0 1 * *)
@weekly   command    # 每周 (0 0 * * 0)
@daily    command    # 每天 (0 0 * * *)
@hourly   command    # 每小时 (0 * * * *)

# 查看 crontab
crontab -l

# 删除 crontab
crontab -r

# 其他用户的 crontab
sudo crontab -u alice -e

最佳实践

# 1. 使用绝对路径
0 2 * * * /usr/bin/python3 /opt/scripts/backup.py

# 2. 设置环境变量
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
SHELL=/bin/bash
MAILTO=[email protected]
0 2 * * * /opt/scripts/backup.sh

# 3. 记录日志
0 2 * * * /opt/scripts/backup.sh >> /var/log/backup.log 2>&1

# 4. 防止并发(使用 flock)
0 2 * * * flock -n /tmp/backup.lock /opt/scripts/backup.sh

# 5. 使用 systemd timer(更现代的替代方案)
# /etc/systemd/system/backup.timer
# [Unit]
# Description=Daily Backup
#
# [Timer]
# OnCalendar=*-*-* 02:00:00
# Persistent=true
#
# [Install]
# WantedBy=timers.target

11.6 DevOps 实践

CI/CD 管道脚本

#!/bin/bash
# 部署脚本示例

set -euo pipefail

readonly APP_NAME="myapp"
readonly DEPLOY_DIR="/opt/${APP_NAME}"
readonly BACKUP_DIR="/opt/backups/${APP_NAME}"
readonly REPO_URL="[email protected]:user/${APP_NAME}.git"
readonly TIMESTAMP=$(date +%Y%m%d_%H%M%S)

log() { echo "[$(date '+%H:%M:%S')] $*"; }

# 1. 备份当前版本
backup() {
    log "备份当前版本..."
    mkdir -p "$BACKUP_DIR"
    if [ -d "$DEPLOY_DIR" ]; then
        tar czf "${BACKUP_DIR}/${APP_NAME}_${TIMESTAMP}.tar.gz" -C "$DEPLOY_DIR" .
        # 只保留最近 5 个备份
        ls -t "${BACKUP_DIR}"/*.tar.gz | tail -n +6 | xargs rm -f
    fi
}

# 2. 拉取代码
pull_code() {
    log "拉取最新代码..."
    local tmp_dir
    tmp_dir=$(mktemp -d)
    git clone --depth 1 "$REPO_URL" "$tmp_dir"
    echo "$tmp_dir"
}

# 3. 构建
build() {
    local source_dir="$1"
    log "构建应用..."
    cd "$source_dir"
    make build
    # 或 npm build, go build, cargo build 等
}

# 4. 测试
test() {
    local source_dir="$1"
    log "运行测试..."
    cd "$source_dir"
    make test
}

# 5. 部署
deploy() {
    local source_dir="$1"
    log "部署应用..."
    mkdir -p "$DEPLOY_DIR"
    cp -r "${source_dir}/build/." "$DEPLOY_DIR/"
    chown -R www-data:www-data "$DEPLOY_DIR"
}

# 6. 健康检查
health_check() {
    log "执行健康检查..."
    local retries=10
    local url="http://localhost:8080/health"

    for ((i=1; i<=retries; i++)); do
        if curl -sf "$url" >/dev/null; then
            log "健康检查通过 ✓"
            return 0
        fi
        log "等待服务启动... ($i/$retries)"
        sleep 3
    done

    log "健康检查失败 ✗"
    return 1
}

# 7. 回滚
rollback() {
    log "回滚到上一个版本..."
    local latest_backup
    latest_backup=$(ls -t "${BACKUP_DIR}"/*.tar.gz 2>/dev/null | head -1)
    if [ -n "$latest_backup" ]; then
        rm -rf "${DEPLOY_DIR:?}"/*
        tar xzf "$latest_backup" -C "$DEPLOY_DIR"
        log "回滚完成"
    else
        log "没有可用的备份"
        return 1
    fi
}

# 主流程
main() {
    local source_dir

    backup

    source_dir=$(pull_code)
    trap "rm -rf '$source_dir'" EXIT

    test "$source_dir"
    build "$source_dir"
    deploy "$source_dir"

    if ! health_check; then
        log "部署失败,开始回滚..."
        rollback
        exit 1
    fi

    log "部署成功 ✓"
}

main "$@"

日志分析与监控

#!/bin/bash
# 实时监控脚本

readonly ALERT_THRESHOLD=100  # 每分钟错误数阈值
readonly CHECK_INTERVAL=60    # 检查间隔(秒)

monitor_errors() {
    local logfile="$1"
    local start_line=0

    while true; do
        local total_lines
        total_lines=$(wc -l < "$logfile")

        if [ "$total_lines" -gt "$start_line" ]; then
            local error_count
            error_count=$(tail -n +"$((start_line + 1))" "$logfile" | grep -c "ERROR" || true)

            if [ "$error_count" -gt "$ALERT_THRESHOLD" ]; then
                local sample
                sample=$(tail -n +"$((start_line + 1))" "$logfile" | grep "ERROR" | head -5)
                echo "告警: 最近 ${CHECK_INTERVAL} 秒内有 ${error_count} 个错误"
                echo "示例:"
                echo "$sample"
                # 发送告警
                # echo "$sample" | mail -s "错误频率告警" [email protected]
            fi

            start_line=$total_lines
        fi

        sleep "$CHECK_INTERVAL"
    done
}

monitor_errors "/var/log/app/error.log"

容器管理脚本

#!/bin/bash
# Docker 容器管理

set -euo pipefail

readonly CONTAINER_NAME="myapp"
readonly IMAGE_NAME="myregistry/myapp:latest"
readonly HEALTH_URL="http://localhost:8080/health"

deploy_container() {
    echo "拉取最新镜像..."
    docker pull "$IMAGE_NAME"

    echo "停止旧容器..."
    docker stop "$CONTAINER_NAME" 2>/dev/null || true
    docker rm "$CONTAINER_NAME" 2>/dev/null || true

    echo "启动新容器..."
    docker run -d \
        --name "$CONTAINER_NAME" \
        --restart unless-stopped \
        -p 8080:8080 \
        -v /data/app:/app/data \
        -e "DB_HOST=db.example.com" \
        -e "DB_PORT=5432" \
        --memory="512m" \
        --cpus="1.0" \
        "$IMAGE_NAME"

    echo "等待容器启动..."
    sleep 10

    if curl -sf "$HEALTH_URL" >/dev/null; then
        echo "容器部署成功 ✓"
    else
        echo "容器健康检查失败 ✗"
        docker logs --tail 50 "$CONTAINER_NAME"
        exit 1
    fi
}

cleanup_images() {
    echo "清理未使用的镜像..."
    docker image prune -f
    echo "清理未使用的卷..."
    docker volume prune -f
}

case "${1:-deploy}" in
    deploy)  deploy_container ;;
    cleanup) cleanup_images ;;
    logs)    docker logs -f "$CONTAINER_NAME" ;;
    status)  docker ps -f "name=$CONTAINER_NAME" ;;
    *)       echo "用法: $0 {deploy|cleanup|logs|status}" ;;
esac

11.7 脚本调试技巧

调试方法

# 1. 使用 set -x 打印执行的命令
set -x
command1
command2
set +x

# 2. 在 shebang 中启用调试
#!/bin/bash -x

# 3. 部分调试
debug_section() {
    set -x
    # 需要调试的代码
    set +x
}

# 4. 使用 shellcheck 静态分析
shellcheck myscript.sh

# 5. 使用 bashdb 调试器
# apt install bashdb
bashdb myscript.sh

# 6. 打印变量值
echo "DEBUG: var=$var" >&2

# 7. 使用 PS4 自定义调试前缀
export PS4='+${BASH_SOURCE}:${LINENO}:${FUNCNAME[0]:+${FUNCNAME[0]}():} '
set -x

常见陷阱

# 陷阱 1: 变量未加引号
# ❌ 错误
files="file1.txt file2.txt"
rm $files          # 如果文件名有空格会出错

# ✅ 正确
rm "$files"        # 不对,这会把整个字符串当一个文件名
# 应该使用数组
files=("file1.txt" "file2.txt")
rm "${files[@]}"

# 陷阱 2: 命令替换中的换行
# ❌ 可能出错
files=$(ls *.txt)
for f in $files; do  # 如果文件名有空格会出错

# ✅ 正确
while IFS= read -r f; do
    echo "$f"
done < <(ls *.txt)

# 陷阱 3: [ ] 中的变量未加引号
# ❌ 错误
if [ $var = "yes" ]; then  # 如果 var 为空,语法错误

# ✅ 正确
if [ "$var" = "yes" ]; then

# 陷阱 4: 管道中的子 Shell
# ❌ 变量不传递
count=0
echo "1 2 3" | while read -r n; do ((count++)); done
echo "$count"  # 仍然是 0

# ✅ 使用进程替换
count=0
while read -r n; do ((count++)); done < <(echo "1 2 3")
echo "$count"  # 3

注意事项

  1. ShellCheck 是你的朋友:在提交脚本之前,始终运行 shellcheck 检查。
  2. set -euo pipefail 是标准开头:这能捕获大多数常见错误。
  3. $() 替代反引号$(command)`command` 更易读,且可以嵌套。
  4. 避免使用 evaleval 存在安全风险,几乎总有更好的替代方案。
  5. 脚本过长时考虑用 Python:当脚本超过 200 行或需要复杂数据结构时,Python 可能是更好的选择。
  6. 不要用 Shell 做数学计算:Shell 不擅长数学运算,复杂的计算应使用 bcawk 或 Python。

扩展阅读