个人站长的自动化脚本,绝大多数都是用 Bash 写的:备份脚本、日志清理脚本、健康检查脚本、部署脚本。很多人学到的第一课是「在脚本开头写上 set -euo pipefail」——这确实是正确的方向,但只写这一行远远不够。真实情况是:set -e 有一堆静默生效的例外,set -u 会在某些合法场景下直接架空脚本,而 pipefail 会让一句原本正常的管道突然失败。更糟的是,这些问题的共同点是脚本不会报错,只是行为跟你想的不一样。
本文把 Bash 脚本健壮性的四个静默陷阱和 trap 清理机制讲清楚,附一个可以直接用在生产备份脚本里的完整模板。
一、set -e 的四个静默例外
set -e 的语义是「任何命令返回非零状态码就让脚本退出」。听起来很安全,但 Bash 有一整套例外规则:
例外一:命令位于条件上下文中时不触发
set -e
if grep -q "error" /var/log/app.log; then
echo "found"
fi
echo "脚本继续执行" # 即使 grep 没找到(返回 1),脚本也不会退出这是设计如此,因为条件表达式的非零返回是「正常的判断结果」而不是「错误」。但问题在于 &&、|| 的左侧、while 的条件、! 取反,全部属于这类例外。也就是说:
set -e
# 这一行不会因为 rm 失败而退出!
[ -f /tmp/lock ] && rm /tmp/lock
# 这一行同样不会
rm /tmp/foo || true实战影响:你以为 set -e 保住了脚本,其实关键的清理、删除、上传动作都是「失败即静默跳过」。判断一个命令是否受 set -e 保护,方法很简单——问自己「它的返回值有没有被别的东西消费」。
例外二:函数内部,只要调用方在条件里,函数中所有命令都失去保护
set -e
do_backup() {
tar czf /backup/site.tgz /www/zz1984 # 这里失败
scp /backup/site.tgz backup@host:/data/ # 这一行照样会执行!
}
do_backup || echo "备份失败"因为 do_backup 出现在 || 左侧,它整体进入「条件上下文」,函数内部所有命令的 set -e 保护全部失效。这是最阴的一个坑:你越是写「友好处理错误」的调用方式,脚本就越没有错误保护。
正确做法是函数内部自己校验:
do_backup() {
tar czf /backup/site.tgz /www/zz1984 || return 1
scp /backup/site.tgz backup@host:/data/ || return 1
return 0
}例外三:子 shell 里的失败不会传播
set -e
(
cd /www/zz1984
tar czf /backup/site.tgz . # 失败
)
echo "这里还是会执行"子 shell ( ... ) 是一个独立进程,内部的 set -e 只在内部生效,它会带着退出码返回,但如果这个子 shell 本身位于管道中或者返回值被忽略,外层就完全不知道里面炸了。凡是用了 ( ... ) 或 $( ... ) 的地方,都要单独检查退出码。
例外四:命令替换会吞掉退出码
set -e
# DATE 会是空字符串,脚本继续跑
DATE=$(date -d "yesterday" +%F)
# 更危险:这个赋值如果失败,NEWEST 为空,后面的 rm 可能删错东西
NEWEST=$(ls -t /backup/*.tgz | head -1)
rm -f "$NEWEST"这是数据丢失事故的高发点。NEWEST 为空时 rm -f "" 虽然不会删文件,但如果路径拼接成了 /backup/ 目录本身,或者用了通配符,后果就很严重。凡是用于删除、覆盖的变量,取值后必须校验非空:
NEWEST=$(ls -t /backup/*.tgz 2>/dev/null | head -1)
if [ -z "$NEWEST" ] || [ ! -f "$NEWEST" ]; then
echo "未找到有效备份文件,中止" >&2
exit 1
fi
rm -f -- "$NEWEST"注意 rm -f --:-- 结束选项解析,防止文件名以 - 开头被当成参数。这个习惯能免掉一类很隐蔽的误删。
二、set -u 在合法场景下会误伤
set -u(等价 set -o nounset)的作用是「引用未定义变量时报错退出」。它能抓住大量拼写错误,但下面几种写法是合法的、却被它直接判死刑:
set -euo pipefail
# 场景一:可选参数,用户可能只传一个参数
NAME=$1 # 用户不传参数时:$1: unbound variable,脚本直接死
# 正确写法
NAME=${1:-default}
# 场景二:环境变量可能未设置
: "${BACKUP_DIR:?必须设置 BACKUP_DIR 环境变量}" # 明确要求,可控
ORIGIN=${ORIGIN:-https://www.zz1984.com}
# 场景三:关联数组的键可能不存在
declare -A cfg
echo "${cfg[timeout]:-30}" # 直接 ${cfg[timeout]} 在 nounset 下会报错
# 注意:Bash 4.4 之前,关联数组即使没设 nounset 也报错,必须用默认值语法
# 场景四:数组为空时 ${arr[@]} 被视为未定义
files=()
for f in "${files[@]}"; do :; done
# Bash 4.3 及以前会报 unbound variable,4.4+ 已修复
for f in ${files[@]+"${files[@]}"}; do :; done # 兼容旧版本的写法结论:set -u 保留,但要养成所有变量都带默认值或显式校验的习惯。凡是来自外部(参数、环境变量、读文件)的值,一律用 ${VAR:-default} 或 ${VAR:?错误信息},这是唯一能同时拿到「拼写错误保护」和「正常可选参数」的方案。
三、pipefail 带来的新问题
set -o pipefail 让管道的退出码等于「最后一个失败命令的退出码」,而不是只取最后一个命令。它修掉了 cmd1 | cmd2 里 cmd1 失败被忽略的问题,但也带来两个新麻烦:
麻烦一:SIGPIPE 导致上游被误判为失败
set -eo pipefail
# 期望取前 10 行,实际可能报错退出
cat /var/log/huge.log | head -10head 读够 10 行就关闭管道退出了,cat 在写入时收到 SIGPIPE,退出码是 141(非零)。在 pipefail 下,整个管道返回 141,脚本退出。这就是「加了 pipefail 之后原本正常的日志脚本突然失败」的根因。
解决办法是给可能提前关闭的环节明确容忍非零:
# 方案 A:只容忍 141
{ cat /var/log/huge.log || [ $? -eq 141 ]; } | head -10
# 方案 B:更常用——避免无谓的大文件管道,直接用 head 读文件
head -10 /var/log/huge.log
# 方案 C:对已知可容忍的环节单独关掉 pipefail
set +o pipefail
cat huge.log | head -10
set -o pipefail麻烦二:grep 没匹配到就是「失败」
set -euo pipefail
COUNT=$(grep -c "ERROR" /var/log/app.log | tr -d '\n')
# 如果日志里一个 ERROR 都没有,grep 返回 1,经过 pipefail 传播
# 整个脚本在这一行退出——而这恰恰是最正常的情况!这是个经典陷阱,很多「健康检查脚本莫名其妙退出」都源于此。修正方式:
COUNT=$(grep -c "ERROR" /var/log/app.log 2>/dev/null || true)
# 或者
COUNT=$(awk '/ERROR/{n++} END{print n+0}' /var/log/app.log)用 awk 做计数更稳:它天然返回 0,不会被「没匹配到」影响退出码。凡是「匹配计数」类需求,优先用 awk 而不是 grep -c。
四、trap:让脚本无论如何都做清理
健壮脚本和普通脚本最大的差别,就是异常退出时有没有清理现场。锁文件残留会让下一次 cron 任务永远跑不起来,临时目录残留会慢慢吃掉磁盘。
#!/usr/bin/env bash
set -euo pipefail
LOCK=/var/run/site-backup.lock
TMPDIR=$(mktemp -d /tmp/backup.XXXXXX)
cleanup() {
local rc=$?
rm -rf "$TMPDIR"
if [ "$rc" -ne 0 ]; then
echo "[$(date '+%F %T')] 备份失败,退出码 $rc" >&2
fi
exit "$rc"
}
# EXIT 覆盖正常退出和 set -e 触发的退出
trap cleanup EXIT
# 额外捕获信号,保证被 kill 时也清理
trap 'exit 130' INT
trap 'exit 143' TERM三个要点:
第一,trap cleanup EXIT 是核心。它在脚本退出时必定执行,无论是正常结束还是 set -e 引发的提前退出。注意 trap ... EXIT 的处理器里最后要 exit "$rc",否则会覆盖原始退出码,导致监控系统看到 0 而漏报故障。
第二,cleanup 里第一行 local rc=$? 必须放在最前面。trap 触发时 $? 还保存着上一条命令的退出码,但你一旦执行了别的命令(比如 echo),它就被覆盖了。
第三,单独捕获 INT / TERM 并把退出码规范成 130 / 143,是运维规范。这样日志和监控里能区分「正常失败」和「被人为中断」。
五、实战模板:一个真正可靠的备份脚本
把上面所有要点合起来,就是可以直接用的版本:
#!/usr/bin/env bash
set -euo pipefail
export LC_ALL=C
BACKUP_DIR=${BACKUP_DIR:-/backup}
KEEP_DAYS=${KEEP_DAYS:-7}
STAMP=$(date +%F_%H%M)
LOCK=/var/run/site-backup.lock
TARGET="$BACKUP_DIR/site_${STAMP}.tgz"
log() { echo "[$(date '+%F %T')] $*"; }
# 用 flock 防重叠,比自己写 pid 文件可靠
exec 9>"$LOCK"
if ! flock -n 9; then
log "上一次备份尚未结束,本次跳过"
exit 0
fi
cleanup() {
local rc=$?
[ "$rc" -ne 0 ] && log "备份异常退出,rc=$rc"
exit "$rc"
}
trap cleanup EXIT
mkdir -p "$BACKUP_DIR"
# 校验源目录存在,避免打包出空包却"成功"
if [ ! -d /www/zz1984 ]; then
log "源目录不存在,中止"
exit 1
fi
log "开始打包 → $TARGET"
tar czf "$TARGET" -C /www zz1984
# 校验产物非空,这一步很多人漏掉
if [ ! -s "$TARGET" ]; then
log "备份文件为空,视为失败"
exit 1
fi
log "打包完成,大小 $(du -h "$TARGET" | cut -f1)"
# 清理旧备份
find "$BACKUP_DIR" -name 'site_*.tgz' -type f -mtime "+$KEEP_DAYS" -print -delete || true
log "备份成功"这个脚本里有几处刻意的设计值得说明:flock -n 9 用文件描述符而不是锁文件内容,避免了「脚本被 kill 后锁文件残留导致永久阻塞」的老问题;tar 后用 [ -s "$TARGET" ] 校验产物大小,防住了「命令返回 0 但生成的包是空的」这种假成功;find ... -delete 后面接 || true,因为清理旧备份失败不应该让整个备份流程被判为失败。
最后一条经验:写完脚本一定要用 bash -n script.sh 做语法检查,再故意制造一次失败(比如把源目录改名)跑一遍,看退出码是不是非零、清理有没有执行。没做过故障演练的备份脚本,等于没有备份脚本。个人站长的自动化体系,可靠性就是这样一点点攒出来的。
六、上生产前的自检清单
把前面所有要点收敛成一份可以照着核对的清单,每次写新脚本跑一遍:
- 脚本开头是否
#!/usr/bin/env bash加set -euo pipefail,并用export LC_ALL=C固定 locale。 - 所有来自外部的变量(参数、环境变量)是否都用了
${VAR:-默认值}或${VAR:?提示}。 - 关键函数的每条命令是否显式
|| return 1,而不是依赖调用方处在条件上下文中的set -e。 - 管道中的
grep -c、ls | head这类「可能因无匹配或 SIGPIPE 返回非零」的命令,是否已加容错。 - 是否用
trap cleanup EXIT做清理,且cleanup第一行取了$?、末尾exit了原退出码。 - 用于
rm的路径变量是否做了非空校验,并统一使用rm -f --。 - 关键产物是否有「非空 / 存在」校验,避免命令返回 0 但产物为空的假成功。
- 是否用
flock而不是自建 pid 文件防重叠(自建 pid 文件在进程被 kill 后会残留)。 - 如果脚本跑在 cron 里,是否有完整的绝对路径(cron 的 PATH 与登录 shell 不同),并确认
LC_ALL、TZ已固定。 - 是否做过一次故意失败演练,确认退出码非零且清理逻辑生效。
这十条里,最容易被忽视的是第 3 条和第 8 条。前者是「越写错误处理越没保护」的悖论,后者是「脚本被 kill 后永久阻塞」的经典故障。做好这两条,加上第 5 条的 trap 清理和 flock 防重叠,脚本的可靠性会有质的提升。运维自动化的价值不在于脚本跑得多快,而在于它失败时你能第一时间知道,并且不会留下烂摊子。
七、调试验证:让脚本自己说话
脚本出问题时的第一工具是 bash -x,但它输出量巨大,全站脚本跑一遍可能刷满终端。更实用的方式是局部开启跟踪:
# 只跟踪关键函数体
trap 'echo "+ $BASH_COMMAND"' DEBUG
# 或者用 PS4 增加文件名和行号,便于定位
export PS4='+${BASH_SOURCE##*/}:${LINENO}: '
set -x
# 干跑模式:不动真实数据,只打印将要执行的动作
DRY_RUN=${DRY_RUN:-0}
run() {
if [ "$DRY_RUN" = "1" ]; then
echo "[dry-run] $*"
else
"$@"
fi
}
run rm -f -- /tmp/old-file
把危险动作统一包进 run() 包装函数,是个人站长很值得养成的习惯:先在 DRY_RUN=1 下跑一遍确认动作清单,再切换到真实执行。它比「备份后再试」更省事,也比「凭记忆确认没问题」更可靠。PS4 加上文件名和行号之后,出错时 set -x 的输出不再是一堆命令流水,而是可以直接跳到对应行的定位线索。对于每天凌晨无人值守跑的一批脚本,这几点投入换来的是「出问题时能在几分钟内定位,而不是第二天才发现备份根本没跑成」。