Shell 脚本健壮性实战:set -euo pipefail、trap 清理与那些年踩过的坑

举报
yd_281047095 发表于 2026/11/05 09:13:36 2026/11/05
【摘要】 介绍 Bash 脚本常用的防御式写法: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"

日志统一输出到标准错误,标准输出留给真正的数据,这样脚本既能被管道调用,日志又不会混进结果。

七、几个常见的坑

  1. cd 失败后继续执行:cd /data/app && rm -rf ./* 比两行分开写安全得多;开了 set -e 也建议这样写。
  2. 用 for f in $(ls) 遍历文件:文件名带空格就会出错,改用 for f in ./* 或 find ... -print0 | xargs -0。
  3. [ $a == $b ] 不加引号:变量为空时会报语法错误,推荐用 [[ "$a" == "$b" ]]。
  4. 在子 shell 里修改变量:cat file | while read line; do n=$((n+1)); done 结束后 n 不变,因为管道右侧在子 shell 中运行,可改成 while read -r line; do ...; done < file。
  5. 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 兜底。把上面的模板存成自己的脚手架,下次写运维脚本时直接复制,能省下不少半夜排障的时间。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。