Shell 脚本健壮性实战:set -euo pipefail、trap 清理与那些年踩过的坑
运维脚本最怕的不是写不出来,而是“看起来跑完了,其实中间早就出错了”。一条 cp 失败了,后面的 rm 照样执行;一个变量拼错了,rm -rf $DIR/ 变成了 rm -rf /。这篇文章把我在日常写 Bash 脚本时最常用的几招防御写法整理出来,配上可以直接复制的例子。
一、脚本开头的“三件套”
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
set -e:任何命令返回非 0 就立即退出,避免错误被悄悄吞掉。set -u:引用未定义变量时直接报错,拼错变量名不再变成空字符串。set -o pipefail:管道中任意一段失败,整个管道就算失败。默认情况下false | true的返回值是 0,加了以后是 1。IFS=$'\n\t':把字段分隔符改成换行和制表符,文件名里带空格时不容易被拆开。
需要注意,set -e 有几个“例外场景”:在 if、while 的条件里,&& / || 左侧的命令,以及被 ! 取反的命令失败时不会触发退出。所以别把它当成万能保险,关键步骤还是要显式检查。
二、允许失败的命令要写明白
有些命令失败是正常的,比如 grep 没匹配到会返回 1。开了 set -e 之后,应该明确表达“这里允许失败”:
count=$(grep -c "ERROR" app.log || true)
if ! systemctl is-active --quiet nginx; then
echo "nginx 未运行,尝试启动"
systemctl start nginx
fi
用 || true 或者放进 if 条件里,读脚本的人一眼就知道作者是有意为之。
三、变量一律加引号,并给默认值
backup_dir="${BACKUP_DIR:-/data/backup}"
target="${1:?用法: $0 <目标目录>}"
rm -rf -- "${backup_dir:?}/tmp"
"${VAR:-默认值}":变量为空时使用默认值。"${1:?提示}":参数缺失时打印提示并退出,比自己写if [ -z "$1" ]更简洁。"${backup_dir:?}":在rm -rf这类危险命令里,变量为空就直接报错,杜绝误删根目录。--:告诉命令后面不再是选项,防止以-开头的文件名被当成参数。
四、用 trap 做清理和报错定位
脚本中途退出时,临时文件、锁文件经常留在现场。trap 可以保证无论成功还是失败都执行清理:
tmpdir=$(mktemp -d)
cleanup() {
rm -rf -- "$tmpdir"
}
trap cleanup EXIT
on_error() {
echo "出错:第 $1 行,命令:$2" >&2
}
trap 'on_error $LINENO "$BASH_COMMAND"' ERR
EXIT 在脚本结束时一定触发;ERR 在命令失败时触发,配合 $LINENO 和 $BASH_COMMAND 能直接看到是哪一行、哪条命令出了问题,排查定时任务尤其方便。
五、防止脚本重复运行
定时任务间隔短、单次执行时间长时,很容易出现两个实例同时跑。用 flock 加一把文件锁最省心:
exec 200>/var/lock/backup.lock
if ! flock -n 200; then
echo "已有实例在运行,退出"
exit 0
fi
文件描述符在脚本退出时自动关闭,锁也随之释放,不需要手动删除锁文件,也不怕异常退出留下“死锁”。
六、日志带上时间戳
log() {
printf '[%s] %s\n' "$(date '+%F %T')" "$*" >&2
}
log "开始备份 $target"
日志统一输出到标准错误,标准输出留给真正的数据,这样脚本既能被管道调用,日志又不会混进结果。
七、几个常见的坑
cd失败后继续执行:cd /data/app && rm -rf ./*比两行分开写安全得多;开了set -e也建议这样写。- 用
for f in $(ls)遍历文件:文件名带空格就会出错,改用for f in ./*或find ... -print0 | xargs -0。 [ $a == $b ]不加引号:变量为空时会报语法错误,推荐用[[ "$a" == "$b" ]]。- 在子 shell 里修改变量:
cat file | while read line; do n=$((n+1)); done结束后n不变,因为管道右侧在子 shell 中运行,可改成while read -r line; do ...; done < file。 read不加-r:反斜杠会被当成转义字符吞掉。
八、交给工具把关:ShellCheck
再细心也会漏,推荐把 ShellCheck 加进日常流程:
sudo apt install shellcheck
shellcheck backup.sh
它能发现未加引号的变量、无用的 cat、错误的比较写法等几百种问题,还会给出规则编号(如 SC2086)方便查文档。放进 CI 或 Git 的 pre-commit 钩子里,团队里的脚本质量会明显提升。
九、一个完整的模板
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
log() { printf '[%s] %s\n' "$(date '+%F %T')" "$*" >&2; }
tmpdir=$(mktemp -d)
trap 'rm -rf -- "$tmpdir"' EXIT
trap 'log "失败:第 $LINENO 行:$BASH_COMMAND"' ERR
exec 200>/var/lock/"$(basename "$0")".lock
flock -n 200 || { log "已在运行"; exit 0; }
src="${1:?用法: $0 <源目录> <目标目录>}"
dst="${2:?用法: $0 <源目录> <目标目录>}"
log "打包 $src"
tar -czf "$tmpdir/backup.tgz" -C "$src" .
mv -- "$tmpdir/backup.tgz" "$dst/backup-$(date +%F).tgz"
log "完成"
小结
健壮的 Shell 脚本靠的不是技巧堆砌,而是几条固定习惯:开头三件套、变量加引号、危险操作前做空值检查、用 trap 收尾、用 flock 防并发、用 ShellCheck 兜底。把上面的模板存成自己的脚手架,下次写运维脚本时直接复制,能省下不少半夜排障的时间。
- 点赞
- 收藏
- 关注作者
评论(0)