第 9 章 · 05 健壮性第一件:set -euo pipefail 本节摘要:前三节你学会了脚本的语法(变量、条件、循环、函数),能写出「能跑」的脚本。但「能跑」和「能上线」之间差着一条护城河。本节讲健壮性三件套的第一件——脚本开头那行神秘的 。这三个选项(其实是 、 、 四个概念)各防一类错误: 让脚本「遇错即停」,不再继续执行出错后的命令; 让脚本「拒绝使用未定义变量」,把拼写错误变成致命错误而不是悄悄传播空值; 让管道「任一段失败则整体失败」,补上默认只看管道最后一段退出码的漏洞。理解了它们,你就会明白为什么几乎所有生产级 Bash 脚本的开头都有这一行——它把 Shell 从一个「宽容到危险」的语言,变成一个「严格到可靠」的语言。
本节摘要:前三节你学会了脚本的语法(变量、条件、循环、函数),能写出「能跑」的脚本。但「能跑」和「能上线」之间差着一条护城河。本节讲健壮性三件套的第一件——脚本开头那行神秘的
set -euo pipefail。这三个选项(其实是-e、-u、-o pipefail四个概念)各防一类错误:-e让脚本「遇错即停」,不再继续执行出错后的命令;-u让脚本「拒绝使用未定义变量」,把拼写错误变成致命错误而不是悄悄传播空值;-o pipefail让管道「任一段失败则整体失败」,补上默认只看管道最后一段退出码的漏洞。理解了它们,你就会明白为什么几乎所有生产级 Bash 脚本的开头都有这一行——它把 Shell 从一个「宽容到危险」的语言,变成一个「严格到可靠」的语言。
内容来源:综合 Shell 脚本知识与原项目「Linux 命令大全」整理,套用体系化模板。
阅读完本节,你应当能够:
set -e、set -u、set -o pipefail 各自防的是什么错。set -euo pipefail,并说清它为什么用 set 而不是 #!/bin/bash 选项。-e 的例外情况:命令在 if/&&/||/! 后面时不会触发退出。|| true 或 || : 显式「吞掉」非零退出码,避免误触发 -e。set -e 和「想让脚本继续跑」的场景,灵活控制严格程度。看下面这个脚本:
#!/usr/bin/env bash cd /nonexistent_dir rm -rf *.tmp echo "清理完成"
直觉上,cd 失败了,脚本应该停下来报错。但Bash 默认不是这样——cd 失败只是打印一条错误,然后继续执行下一行 rm -rf *.tmp。这一下就可能删错目录的临时文件。这就是 Bash 的「宽容」:任何命令失败,它都不会停,除非你显式检查。
这种设计的初衷是「交互式使用要尽可能继续」,但在脚本里它是灾难。一个命令失败,后续命令往往建立在错误的前提上,继续跑只会雪上加霜。
-e:遇错即停set -e(equivalently set -o errexit)改变了这个默认:任何命令以非零退出码结束时,脚本立刻停止。
#!/usr/bin/env bash set -e cd /nonexistent_dir # 失败,脚本在此停止 rm -rf *.tmp # 不会执行 echo "清理完成" # 不会执行
这就是「防御性编程」的第一原则:出错就停,不要带着错误往前走。
关键概念:
-e的哲学是「失败即异常」。和 Python/Java 的异常类似——一旦发生,立刻中断当前流程,避免错误被默默传播。没有-e的 Shell 脚本,相当于每个函数调用都不检查返回值,bug 会一层层累积。
-u:拒绝未定义变量#!/usr/bin/env bash rm -rf $DESTDIR/backup # 如果 DESTDIR 没定义……
如果 DESTDIR 拼写错了或没设置,$DESTDIR 会展开成空,这行变成 rm -rf /backup——删根目录下的 backup。这是经典的生产事故。
set -u(set -o nounset)让脚本遇到未定义变量立刻报错停止:
set -u echo "备份到 $DESTDIR" # 报错: DESTDIR: unbound variable,脚本停止
💡 技巧:
-u把「拼写错误」和「忘记初始化」从隐性 bug 升级成显性错误。配合双引号"$DESTDIR",能消灭 Shell 里最危险的一类事故。
-o pipefail:管道失败要穿透默认情况下,管道的退出码是最后一段的退出码:
grep ERROR big.log | head # 如果 grep 失败(文件不存在), echo $? # 输出 0!因为 head 成功了
-e 也救不了这种情况——它只看每条命令的退出码,而「这条管道」整体退出码是 0。set -o pipefail 改变了这个默认:管道里任一段失败,整体退出码就是失败。
set -eo pipefail grep ERROR big.log | head # 如果 grep 失败,整条管道失败 # 加上 -e,脚本立刻停
set -euo pipefail#!/usr/bin/env bash set -euo pipefail
这是 Bash 脚本开头的「黄金一行」。它把三个严格选项同时打开,让你的脚本拥有「遇错即停、拒未定义、管道穿透」三重保险。第 08 节的综合备份脚本就会有这一行。
#!/usr/bin/env bash set -euo pipefail # 你的脚本逻辑
把这一行放在 #!/usr/bin/env bash 之后的第二行,是 Bash 脚本的现代标配。
-e 的例外:命令在条件位置不触发-e 有个重要规则:如果命令出现在「条件判断」的位置,它的失败不会触发 -e。具体包括:
if cmd; then ...while cmd; do ...cmd && othercmd || other! cmd因为「条件位置」的命令失败本来就是预期行为——你正是在判断它成功还是失败。
set -e if grep -q error log; then # grep 没找到匹配返回 1,但不会触发 -e echo "发现错误" fi # 用 || 给命令一个「失败时的退路」 mkdir /opt/myapp || sudo mkdir /opt/myapp # mkdir 失败不会触发 -e
💡 技巧:当某个命令「允许失败」,但
-e会让它整段停掉时,在它后面加|| true(或|| :)显式声明「失败也算成功」:grep warning log || true # 即使没匹配也不停
-e:临时关掉再打开有时某一段确实需要「即使失败也继续」,可以临时关掉:
set +e # 暂时关闭 -e optional_cleanup # 这条命令失败了也没关系 set -e # 重新打开
更优雅的写法是用 ||:
optional_cleanup || true # 等价:失败也被吞掉
-u 配合默认值展开-u 会让未定义变量报错,但有时你想要「未定义就用默认值」。这时用参数展开的默认值语法 ${var:-default}:
set -u echo "端口: ${PORT:-8080}" # PORT 未定义就用 8080,不触发 -u
这一行既享受了 -u 的严格(防止拼写错),又能优雅处理「可选变量」。
-e-e 适合「步骤型」脚本(备份、部署、安装),不适合「轮询型」脚本(每次循环都可能失败的监控):
# 这种场景 -e 反而碍事 while true; do check_service || true # 服务没起来是正常的,不该停 sleep 5 done
判断标准:如果「失败后该停下来人工介入」,用 -e;如果「失败是常态、该继续重试」,不用或局部关掉。
-e 被 || 悄悄关掉,错误被吞set -e dangerous_cmd || echo "失败了" # 即使 dangerous_cmd 失败,脚本也会继续,因为 || 让整行总返回 0
如果你本来想「失败就停」,却顺手加了 || echo,意图就被改写了。用 || true 是显式吞错,用 || echo 是「既吞错又打印」,两者效果一样都会让 -e 失效。
-u 配合 $1/$2 时,参数没传就报错set -u echo "源: $1" # 如果调用时不传参数,报错 unbound variable
解法:用 ${1:-默认值} 或先检查 $#:
src=${1:-} [[ -z $src ]] && { echo "用法: $0 源目录"; exit 1; }
# 没 pipefail:grep 失败被 head 的成功掩盖 cat missing.txt | grep x | head # cat 失败但退出码是 0 set -o pipefail cat missing.txt | grep x | head # 失败,退出码非 0
要严格判断管道,必须配 set -o pipefail,否则 -e 也救不了管道中间的错误。
return 触发 -eset -e check() { [[ -f /etc/hosts ]] && return 0 return 1 } check || exit 1 # 这里 || 让 check 的失败被吞,正常 other_func # 但如果 other_func 里 return 1,-e 会停
注意:函数里的 return N(N 非 0)在没有 ||/if 包裹时也会触发 -e。所以「返回失败码的函数」要么用 if func; then,要么用 func || handle。
-e 污染调用者source mylib.sh # 如果 mylib.sh 里有 set -e,会污染当前 Shell
如果脚本既要「直接跑」(要 -e)又要「被 source」(不该污染),用这个守卫:
# 只在直接执行时启用严格模式 if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then set -euo pipefail fi
set -e(errexit)让脚本「遇错即停」——任何命令非零退出,立刻中断,避免错误滚雪球。set -u(nounset)让脚本「拒绝未定义变量」——把拼写错和忘记初始化从隐性 bug 变成显性报错,消灭 rm -rf $空/ 这类事故。set -o pipefail 让「管道任一段失败则整体失败」——补上默认只看管道最后一段的漏洞,和 -e 配合才能真正保护管道。set -euo pipefail 放在脚本开头第二行,是现代 Bash 脚本的标配,也是业余与生产的分水岭。-e 在条件位置不触发:if/while/&&/||/! 后面的命令失败是预期的,不会让脚本停。|| true 显式吞错,用 ${var:-default} 配合 -u 处理可选变量,用 set +e/set -e 临时切换严格度。-e 适合步骤型脚本,不适合轮询型脚本;判断标准是「失败该不该停下来人工介入」。set -euo pipefail 让脚本「出错就停」,但停的时候往往留下临时文件、锁、半成品。下一节讲健壮性第二件:trap——它让脚本「无论怎么退出(正常结束、出错、Ctrl+C、被 kill)都能先跑一段清理代码」。这是写「不留垃圾」的脚本的关键。