第 9 章 · 05 健壮性第一件:set -euo pipefail


文档摘要

第 9 章 · 05 健壮性第一件:set -euo pipefail 本节摘要:前三节你学会了脚本的语法(变量、条件、循环、函数),能写出「能跑」的脚本。但「能跑」和「能上线」之间差着一条护城河。本节讲健壮性三件套的第一件——脚本开头那行神秘的 。这三个选项(其实是 、 、 四个概念)各防一类错误: 让脚本「遇错即停」,不再继续执行出错后的命令; 让脚本「拒绝使用未定义变量」,把拼写错误变成致命错误而不是悄悄传播空值; 让管道「任一段失败则整体失败」,补上默认只看管道最后一段退出码的漏洞。理解了它们,你就会明白为什么几乎所有生产级 Bash 脚本的开头都有这一行——它把 Shell 从一个「宽容到危险」的语言,变成一个「严格到可靠」的语言。

第 9 章 · 05 健壮性第一件:set -euo pipefail

本节摘要:前三节你学会了脚本的语法(变量、条件、循环、函数),能写出「能跑」的脚本。但「能跑」和「能上线」之间差着一条护城河。本节讲健壮性三件套的第一件——脚本开头那行神秘的 set -euo pipefail。这三个选项(其实是 -e-u-o pipefail 四个概念)各防一类错误:-e 让脚本「遇错即停」,不再继续执行出错后的命令;-u 让脚本「拒绝使用未定义变量」,把拼写错误变成致命错误而不是悄悄传播空值;-o pipefail 让管道「任一段失败则整体失败」,补上默认只看管道最后一段退出码的漏洞。理解了它们,你就会明白为什么几乎所有生产级 Bash 脚本的开头都有这一行——它把 Shell 从一个「宽容到危险」的语言,变成一个「严格到可靠」的语言。

内容来源:综合 Shell 脚本知识与原项目「Linux 命令大全」整理,套用体系化模板。

学习目标

阅读完本节,你应当能够:

  1. 解释 set -eset -uset -o pipefail 各自防的是什么错。
  2. 在脚本顶部正确写出 set -euo pipefail,并说清它为什么用 set 而不是 #!/bin/bash 选项。
  3. 描述 -e 的例外情况:命令在 if/&&/||/! 后面时不会触发退出。
  4. 知道何时该用 || true|| : 显式「吞掉」非零退出码,避免误触发 -e
  5. 区分 set -e 和「想让脚本继续跑」的场景,灵活控制严格程度。
  6. 解释为什么这条规则是「业余脚本与生产脚本的分水岭」。

一、设计动机(为什么这样设计)

Bash 的默认行为:宽容到危险

看下面这个脚本:

#!/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 -uset -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 节的综合备份脚本就会有这一行。

二、高频组合与实战

1. 标准脚本骨架

#!/usr/bin/env bash set -euo pipefail # 你的脚本逻辑

把这一行放在 #!/usr/bin/env bash 之后的第二行,是 Bash 脚本的现代标配。

2. -e 的例外:命令在条件位置不触发

-e 有个重要规则:如果命令出现在「条件判断」的位置,它的失败不会触发 -e。具体包括:

  • if cmd; then ...
  • while cmd; do ...
  • cmd && other
  • cmd || 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 # 即使没匹配也不停

3. 局部放松 -e:临时关掉再打开

有时某一段确实需要「即使失败也继续」,可以临时关掉:

set +e # 暂时关闭 -e optional_cleanup # 这条命令失败了也没关系 set -e # 重新打开

更优雅的写法是用 ||

optional_cleanup || true # 等价:失败也被吞掉

4. 用 -u 配合默认值展开

-u 会让未定义变量报错,但有时你想要「未定义就用默认值」。这时用参数展开的默认值语法 ${var:-default}

set -u echo "端口: ${PORT:-8080}" # PORT 未定义就用 8080,不触发 -u

这一行既享受了 -u 的严格(防止拼写错),又能优雅处理「可选变量」。

5. 一个反例:什么时候不该用 -e

-e 适合「步骤型」脚本(备份、部署、安装),不适合「轮询型」脚本(每次循环都可能失败的监控):

# 这种场景 -e 反而碍事 while true; do check_service || true # 服务没起来是正常的,不该停 sleep 5 done

判断标准:如果「失败后该停下来人工介入」,用 -e;如果「失败是常态、该继续重试」,不用或局部关掉

三、踩坑与排错

坑 1:-e|| 悄悄关掉,错误被吞

set -e dangerous_cmd || echo "失败了" # 即使 dangerous_cmd 失败,脚本也会继续,因为 || 让整行总返回 0

如果你本来想「失败就停」,却顺手加了 || echo,意图就被改写了。|| true 是显式吞错,用 || echo 是「既吞错又打印」,两者效果一样都会让 -e 失效

坑 2:-u 配合 $1/$2 时,参数没传就报错

set -u echo "源: $1" # 如果调用时不传参数,报错 unbound variable

解法:用 ${1:-默认值} 或先检查 $#

src=${1:-} [[ -z $src ]] && { echo "用法: $0 源目录"; exit 1; }

坑 3:管道里中间命令失败,默认看不出来

# 没 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 也救不了管道中间的错误。

坑 4:函数里的 return 触发 -e

set -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

坑 5:脚本被 source 时 -e 污染调用者

source mylib.sh # 如果 mylib.sh 里有 set -e,会污染当前 Shell

如果脚本既要「直接跑」(要 -e)又要「被 source」(不该污染),用这个守卫:

# 只在直接执行时启用严格模式 if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then set -euo pipefail fi

本节要点回顾

  1. set -e(errexit)让脚本「遇错即停」——任何命令非零退出,立刻中断,避免错误滚雪球。
  2. set -u(nounset)让脚本「拒绝未定义变量」——把拼写错和忘记初始化从隐性 bug 变成显性报错,消灭 rm -rf $空/ 这类事故。
  3. set -o pipefail 让「管道任一段失败则整体失败」——补上默认只看管道最后一段的漏洞,和 -e 配合才能真正保护管道。
  4. 黄金一行 set -euo pipefail 放在脚本开头第二行,是现代 Bash 脚本的标配,也是业余与生产的分水岭。
  5. -e 在条件位置不触发if/while/&&/||/! 后面的命令失败是预期的,不会让脚本停。
  6. || true 显式吞错,用 ${var:-default} 配合 -u 处理可选变量,用 set +e/set -e 临时切换严格度。
  7. -e 适合步骤型脚本,不适合轮询型脚本;判断标准是「失败该不该停下来人工介入」。

set -euo pipefail 让脚本「出错就停」,但停的时候往往留下临时文件、锁、半成品。下一节讲健壮性第二件:trap——它让脚本「无论怎么退出(正常结束、出错、Ctrl+C、被 kill)都能先跑一段清理代码」。这是写「不留垃圾」的脚本的关键。


发布者: 作者: 灏天文库 转发
评论区 (0)
U