一个 100 行的部署脚本,跑到第 30 行创建目录失败,剩下 70 行照样执行,最后打印「部署完成」。bash 默认行为就是这样:任何一条命令失败,脚本继续往下跑,退出码永远取最后一条。手敲命令时这没问题,输错了终端立刻报错;换成脚本,错误被静默吞掉,等你在第 90 行发现备份文件没生成,中间隔了 60 行无意义的执行。
本文只解决一个问题:怎么让脚本在出错的第一现场停下来。围绕 set -euo pipefail 这一行展开,同时把三个高频翻车点逐个拆开。定时任务怎么调度这些脚本,见 cron 与 systemd timer 选型;更全面的加固动作清单见服务器安全加固。
set -e:出错即退,但有四类盲区
脚本第二行写上 set -e,效果是:任何一条命令返回非零,整个脚本立即终止,退出码继承那条失败命令。这是防御性编程的第一道门。
它不是万能开关,官方手册列出了四类「不退出」的场景,每类都值得单独记住:
if条件和while、until的循环条件里的命令失败,不触发退出——判断失败本身就是一种正常流程。&&或||链条中,跟在最后一个&&/||前面的命令失败不触发。false && true不会让脚本退出,因为整个链条被短路吞掉了。- 管道中只有最后一条命令的退出码被检查——没有
pipefail时,curl 失败 | grep只看 grep 的返回值。 - 取反过的命令不触发:
! false视为成功。
这些盲区各有对应的补法。第 1 类是合理设计,需要绕过时用 cmd || true 显式声明「这条失败没关系」。第 3 类是 pipefail 存在的理由,下一节展开。
pipefail:管道中间藏不住失败
这是三个选项里最容易被忽略的一个。管道的默认退出码取最后一条命令,前面的命令失败与否没人管:
#!/usr/bin/env bash
curl -sf https://api.example.com/health | gzip > health.gz
echo "退出码: $?"
curl 连不上时,gzip 收到空输入照样正常压缩,退出码 0。日志文件是空的,脚本却报成功。加上 set -o pipefail 后,管道返回第一个非零退出码——curl 的失败终于能被看见。
一条配套经验:管道里每条命令都该有自己的失败防线。curl 加 -f 让 HTTP 4xx/5xx 转成非零退出码,wget 对应 --server-response 配合判断。防护做到管道的每一环,pipefail 才有非零码可报。日常接口监控脚本里这三件套的组合用法,见 curl 调试接口实战。
set -u:未定义变量当场报错
第三个选项管变量。set -u(等价写法 set -o nounset)让脚本在引用未定义变量时立即报错退出,退出码非零。
没有它,rm -rf "$BACKUP_DIR" 里的 BACKUP_DIR 漏定义时,实际执行的是 rm -rf ""。rm 会抗议一个无效参数——运气好,但你通常没这么走运:如果命令还拼了别的路径,比如 rm -rf "$BACKUP_DIR/sub/"*,路径退化成 /sub/*,后果自己掂量。set -u 把这类事故从「运行时删错文件」提前到「启动即报错」,一行换一层保险。
参数有默认值的场景用 ${VAR:-默认值},它不受 -u 影响。脚本自身的可选参数用 ${1:-} 读,必填参数则主动校验:
#!/usr/bin/env bash
set -euo pipefail
: "${BACKUP_DIR:?必须设置 BACKUP_DIR 环境变量}"
${VAR:?提示} 在变量为空或未定义时直接报错退出,比事后 if 判断短,且报错信息里带上变量名。
陷阱一:rm -rf “$var” 少了引号
set -u 拦住未定义变量,但拦不住「变量有值、值里有空格」。变量没加双引号时,bash 会做分词和通配符展开,这个行为在 rm 上是经典事故:
TARGET_DIR="/var/backups/my app"
rm -rf $TARGET_DIR
# 实际执行:rm -rf /var/backups/my app
# 删掉的是 /var/backups/my 和当前目录下所有匹配 app 的文件
规则简单:所有变量引用都套双引号,"$TARGET_DIR"。需要按空格拆分列表的场景(比如遍历多个路径),用数组加 "$@",这是唯一能安全持有带空格条目的写法:
DIRS=(
"/var/backups/my app"
"/var/log/myapp"
)
for dir in "${DIRS[@]}"; do
rm -rf -- "$dir"
done
rm 后面加 --,防止以 - 开头的路径被当成选项。这两条写法没什么理解成本,纯肌肉记忆,养成后事故率直线下降。
陷阱二:cd 失败之后,路径全体漂移
cd "$DEPLOY_DIR"
tar -czf backup.tar.gz ./conf
DEPLOY_DIR 写错、目录被删、权限不对,任何一种情况都会让 cd 失败。有 set -e 时脚本会退,没开 set -e 的老脚本里,下一行 tar 就在原目录执行,把不相干的文件打了包。cd 命令的 man page 明确要求调用方检查退出码,脚本里的标准写法是把检查焊死在命令里:
cd "$DEPLOY_DIR" || exit 1
set -euo pipefail 环境下单写 cd "$DEPLOY_DIR" 也够,但 || exit 1 的好处是兼容没开 set -e 的存量脚本。另一个可选项 CDPATH 环境变量会让 cd 去 CDPATH 目录里搜目标,行为不可预期,脚本里明确 unset CDPATH 或从不依赖它。
陷阱三:子 shell 与函数里的 set -e 失忆
set -e 的作用范围按子环境隔离。命令替换、子 shell、函数体各有各的开关状态,两条经验先记下:
# 命令替换里的失败,set -e 看不见
RESULT=$(false)
echo "继续执行" # 会被打印
命令替换的退出码记在 $? 里,但外面那条赋值语句本身返回 0。修法是「赋值 + 显式判断」一步到位:
if ! RESULT=$(generate_config); then
echo "配置生成失败" >&2
exit 1
fi
ERR trap 默认不继承进函数和子 shell,set -E(-o errtrace)补上这一点。想给所有失败统一挂清理逻辑(删临时文件、发告警)时,标准组合是 trap + set -E:
set -euEo pipefail
trap 'echo "脚本在 $LINENO 行失败退出" >&2; rm -rf "$TMP_DIR"' ERR
一行防御头 + 五个动作
把前文收成一个可直接复制的脚本骨架:
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t' # 防分词:按换行和 Tab 切分,空格不再拆参数
# 1. 必填变量前置校验
: "${DEPLOY_DIR:?必须设置 DEPLOY_DIR}"
# 2. 所有变量引用加双引号
cd "$DEPLOY_DIR" || exit 1
# 3. 临时目录用 mktemp,退出时清理
TMP_DIR=$(mktemp -d)
trap 'rm -rf "$TMP_DIR"' EXIT
# 4. 管道中间命令的失败逃不掉(pipefail 已开)
curl -sf https://api.example.com/health | gzip > "$TMP_DIR/health.gz"
# 5. 依赖前置命令产物时,先确认产物存在
[[ -s "$TMP_DIR/health.gz" ]] || { echo "health.gz 为空"; exit 1; }
IFS=$'\n\t' 这行有争议,它改变了所有未加引号展开的分词行为,对老脚本可能引入新 bug。新脚本可以从第一行就带上,存量脚本建议先开 -eu、管道按需开 pipefail,跑稳了再考虑 IFS。
什么脚本不用这么严
一次性敲完就删的临时脚本,开 -u 反而碍事;交互式 shell 更不该设 -e,一条命令失败直接踢你下线。这套防御的适用对象是:进 cron/systemd timer 的定时任务、CI 流水线步骤、部署与备份脚本——凡是「失败后必须停、停了必须有人知道」的场合。判断标准就一条:这个脚本夜里失败了,早上你能不能第一时间发现。-e 给你退出码,trap 给你告警钩子,剩下的通知渠道(邮件、webhook)接在 trap 里即可。
常见问题
Q:set -e 开了之后脚本偶尔莫名退出,怎么定位是哪一行?
A:加 set -x 打印每条执行的命令,或用 PS4='+ $BASH_SOURCE:$LINENO: ' 让 trace 带行号。配合上一节的 ERR trap,失败现场(文件、行号、命令)都能打出来。
Q:set -e 和 || true 大量混用,脚本报错退出的意义还剩多少?
A:|| true 是显式豁免,写在命令边上,一眼可见。防御性编程要的是默认严格、豁免显式,这个方向没错。反过来,把 set -e 关掉然后用 if 逐条包住,代码量翻倍且容易漏。
Q:sh 脚本用 #!/bin/sh 执行,set -o pipefail 报错?
A:pipefail 在 dash(Ubuntu 的 /bin/sh)里不存在。shebang 改成 #!/usr/bin/env bash,或移植到 POSIX 语法并用逐段检查替代管道退出码。
Q:set -u 下数组为空,"${arr[@]}" 报 unbound variable?
A:bash 4.4 之前,空数组在 -u 模式下展开会报错。Ubuntu 24.04 是 bash 5.2,已修复此行为。维护老系统(CentOS 7 的 bash 4.2)时,用 ${arr[@]+"${arr[@]}"} 这个惯用法绕过。
Q:想验证脚本有没有语法错误,不想真的执行?
A:bash -n script.sh,只解析不执行。参数层面的错误它抓不到,但括号不配对、fi 缺失这类语法问题当场报。
结尾回到开头那个场景:100 行脚本在第 30 行断下来,不是脚本的失败,是脚本的胜利——它准确报出了出错的命令和行号,而不是让你在第 90 行面对一个凭空缺失的文件。set -euo pipefail 加双引号加 || exit 1,三件事,一行防御头加两个肌肉记忆,覆盖日常脚本九成的事故面。