复盘与重构:我把之前的Shell脚本指南,推翻重写了

一、 纠偏:为什么我不建议你无脑set -e

上篇我把set -euo pipefail吹上了天,说这是"标配"。这其实是不负责任的。

复杂的生产脚本 中,set -e是一把双刃剑。

问题出在哪?

set -e会在任何命令返回非0时立刻退出。但在Shell逻辑里,"返回非0"并不总是代表"失败"。

比如这个场景:

复制代码
# 检查某个进程是否存在,不存在就启动它
pgrep -x "nginx" > /dev/null

if [ $? -ne 0 ]; then
    systemctl start nginx
fi

如果用了set -epgrep没找到进程返回1,脚本直接就退出了,if语句根本没机会执行。你不得不写成这样:

复制代码
pgrep -x "nginx" > /dev/null || true # 强行让这一行返回0
if [ $? -ne 0 ]; then
    systemctl start nginx
fi

满屏的|| true会让脚本变得极其丑陋且难以阅读。

企业级修正方案:

  1. 局部屏蔽:只在关键逻辑块开启严格模式。

    复制代码
    set +e # 关闭严格模式
    pgrep -x "nginx"
    result=$?
    set -e # 重新开启
    
    if [ $result -ne 0 ]; then
        systemctl start nginx
    fi
  2. 更优雅的写法(推荐) :利用逻辑运算符,根本不用set -e

    复制代码
    # 如果pgrep失败(返回非0),则执行后面的启动命令
    pgrep -x "nginx" > /dev/null || systemctl start nginx

    ||的意思是:如果左边失败,执行右边。这比set -e更符合直觉,也更安全。

结论 :不要把set -e当成保险丝,要把显式的错误判断 (如if语句、逻辑运算符)当成你的驾驶技术。


二、 重构:那个"自动回滚"的脚本太重了

上篇我举了一个带trap和回滚逻辑的部署脚本,很多读者反馈:"太复杂了,看不懂,也不敢用。"

确实,那是高级运维玩的,不适合日常脚本。对于90%的日常自动化任务,我们只需要做到**"失败即停止,不破坏现场"**就够了,不需要自动回滚。

简化版的企业级部署脚本:

复制代码
#!/usr/bin/env bash

APP_DIR="/opt/myapp"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_DIR="${APP_DIR}_bak_${TIMESTAMP}"

echo "==> 开始部署 [${TIMESTAMP}]"

# 1. 防御性检查:目录必须存在
if [ ! -d "$APP_DIR" ]; then
    echo "Error: 应用目录不存在,请检查环境。"
    exit 1
fi

# 2. 备份(仅当目录非空时)
if [ "$(ls -A $APP_DIR)" ]; then
    echo "==> 备份当前版本至 ${BACKUP_DIR}"
    cp -a "$APP_DIR" "$BACKUP_DIR"
else
    echo "==> 目录为空,跳过备份"
fi

# 3. 核心逻辑:拉取代码
echo "==> 更新代码..."
if ! git -C "$APP_DIR" pull origin main; then
    echo "Error: Git拉取失败,请检查网络或权限。"
    echo "Info: 备份文件位于 ${BACKUP_DIR},请手动恢复。"
    exit 1
fi

# 4. 重启服务
echo "==> 重启服务..."
if ! systemctl restart myapp; then
    echo "Error: 服务重启失败!"
    echo "Info: 请检查 journalctl -u myapp。备份文件位于 ${BACKUP_DIR}。"
    exit 1
fi

echo "==> 部署成功!"

改进点

  • 去掉了晦涩的trapROLLBACK变量。

  • 每个关键步骤(git pull, systemctl restart)后都紧跟着显式的错误判断if ! ...)。

  • 失败后,打印清晰的错误原因和恢复指引(告诉人在哪找备份),而不是盲目地自动回滚(自动回滚可能掩盖更深的错误)。


三、 进阶:数组与映射,告别混乱的字符串拼接

上篇讲了for循环,但没讲数组。在Shell里处理列表数据,数组比字符串拼接靠谱一万倍。

错误示范(处理IP列表):

复制代码
ips="192.168.1.1 192.168.1.2 192.168.1.3"
for ip in $ips; do
    ping -c 1 $ip
done

如果IP里带了空格或者其他特殊字符,这就崩了。

正确姿势(使用数组):

复制代码
ips=("192.168.1.1" "192.168.1.2" "192.168.1.3")
for ip in "${ips[@]}"; do
    ping -c 1 "$ip"
done

"${ips[@]}"会将数组中的每个元素作为一个独立的字符串传递,完美保留了参数边界。

关联数组(模拟字典/Map):

如果你用的是 Bash 4.0+(现在基本都是),可以用关联数组处理键值对,这在处理配置文件时非常有用。

复制代码
declare -A config
config["port"]=8080
config["user"]="admin"

echo "端口是: ${config["port"]}"
echo "用户是: ${config["user"]}"

四、 实战:写一个"人话"版的日志清理脚本

上篇提到了日志清理,但没有给出完美的实现。这是一个非常常见的需求,也是最容易出事故的脚本。

需求 :清理/var/log/myapp下超过7天的.log文件,但保留最近3个文件(无论是否过期)。

复制代码
#!/usr/bin/env bash

LOG_DIR="/var/log/myapp"
DAYS=7
KEEP=3

echo "==> 清理 ${LOG_DIR} 中超过 ${DAYS} 天的日志..."

# 1. 检查目录是否存在
if [ ! -d "$LOG_DIR" ]; then
    echo "Error: 日志目录不存在。"
    exit 1
fi

# 2. 统计文件总数
file_count=$(find "$LOG_DIR" -maxdepth 1 -name "*.log" -type f | wc -l)

# 3. 如果文件数少于等于保留数,只做过期清理,不干涉数量
if [ "$file_count" -le "$KEEP" ]; then
    echo "==> 文件数(${file_count})小于等于保留数(${KEEP}),仅清理过期文件。"
    find "$LOG_DIR" -maxdepth 1 -name "*.log" -type f -mtime +"$DAYS" -delete
else
    # 4. 文件数较多,先按时间排序,跳过最新的KEEP个,再删过期的
    echo "==> 文件数(${file_count})大于保留数(${KEEP}),执行清理策略。"
    # ls -t: 按时间排序,-r: 反转(最旧的在前)
    # tail -n +$((KEEP+1)): 跳过前KEEP个(最新的)
    ls -t "$LOG_DIR"/*.log | tail -n +$((KEEP + 1)) | while read -r file; do
        # 再次检查文件是否过期,双重保险
        if [ -f "$file" ] && [ "$(find "$file" -mtime +"$DAYS" -print)" ]; then
            echo "删除: $file"
            rm -f "$file"
        fi
    done
fi

echo "==> 清理完成。"

这个脚本的亮点

  1. 逻辑严密:考虑了文件数量保护和过期时间保护的双重逻辑。

  2. 安全删除 :使用while read循环处理文件名,避免了空格问题。

  3. 信息透明 :每一步都有echo输出,你知道它在干什么。


五、 总结:Shell脚本的"及格线"

经过这次复盘,我认为一个合格的、能上生产环境的Shell脚本,不需要多么花哨的技巧,只需要守住这几条底线:

  1. 拒绝魔法 :不要用set -e来掩盖逻辑缺陷,用if||来处理错误。

  2. 防御性编程:凡是外部输入(参数、文件、命令返回值),一律做校验。

  3. 人话输出:脚本出错时,告诉人是哪里错了,备份在哪,怎么恢复,而不是只返回一个非0代码。

  4. 数据结构化:处理列表用数组,处理键值用关联数组,别玩字符串拼接。

  5. KISS原则:Keep It Simple, Stupid。能写10行的逻辑,别写50行。自动回滚这种事,交给专业的配置管理工具(如Ansible)去做,Shell脚本做好执行者和报告者。

Shell脚本是胶水,不是水泥。它的目的是把简单的逻辑粘合起来,而不是构建复杂的系统。理解了这一点,你的脚本水平就真正入门了。

关于Shell脚本,你还有哪些觉得"拧巴"的地方?或者有哪些"一直这么写,不知道对不对"的习惯?评论区聊聊,我们一起迭代。