set -euo pipefail 到底防住了什么:6 个把我坑到凌晨的细节
凌晨两点被告警叫醒:备份脚本白天手动跑得好好的,进了 cron 就"静悄悄什么都没做",日志目录连个空文件都没留下。查了半小时才发现,脚本第 37 行的 grep 没匹配到内容、退出码 1,然后 set -e 把整个脚本终止了,而写日志那一步排在被终止的命令后面。
那之后我把 set -euo pipefail 写进团队模板的头两行,也顺手踩完了它配套的坑。下面 6 个场景,每条都附能直接贴进终端跑的命令。
一、先搞清楚这行字各自管什么
参数 | 作用 | 它不管的事
-e | 未被检查的命令返回非 0 就退出 | 管道中间段失败不会被它拦住
-u | 引用未赋值变量直接报错退出 | 想取默认值的地方得自己写 ${1:-}
-o pipefail | 管道退出码取最右边非 0 的那个 | cmd | head 会冒出 141,它照抛不误
两条命令对比,看 -e 单独存在多无力:
bash -c 'set -e; false | true; echo "还是走到这了"'
bash -c 'set -e -o pipefail; false | true; echo "这句不会打印"'
二、坑 1:local 把退出码吃掉了
最阴的一个,看起来完全无害:
set -euo pipefail
fetch() {
local out=$(curl -sS --max-time 10 "$1") # 域名解析不了,curl 退出码 6
printf '%s\n' "${out:0:20}"
}
fetch https://example.com/health
curl 明明失败了,脚本却一路跑到底:local 是内建命令,自身退出码 0,把命令替换的失败状态盖住了。export、declare、readonly 同病。先声明再赋值就好:
fetch() {
local out
out=$(curl -sS --max-time 10 "$1")
printf '%s\n' "${out:0:20}"
}
对照实验:同一个函数把域名故意写错,local out=$(...) 版本退出码 0;拆开后立刻以 6 退出。0 和 6 的差别,决定半夜被叫醒的是我还是监控大盘。
三、坑 2:pipefail 撞上 head 和 grep -q 的 141
set -euo pipefail
zcat /var/log/app/app.log.gz | grep -m1 'FATAL' | head -n 1
日志里没有 FATAL,grep 返回 1,整个脚本死。grep -q 更狠:匹配到就立刻退出,前面的 zcat 收到 SIGPIPE,退出码 141,pipefail 把 141 当失败往上抛。脚本却挂了。
按语义分两种:
# 只关心数量:没有匹配就当 0
hits=$(zcat /var/log/app/app.log.gz | grep -c 'FATAL' || true)
echo "hits=$hits"
# 只关心有没有:失败状态显式吃掉
if zcat /var/log/app/app.log.gz | grep -q 'FATAL'; then echo "有 FATAL"; fi
-c 无匹配时退出码同样是 1,但它会打印 0,所以配 || true 才有意义。
四、坑 3:set -e 在这些位置完全不生效
这些位置 -e 会主动让位,一句都不说:
位置 | 举例 | 实际结果
if / elif 的条件 | if cmd; then | cmd 失败不退出,走 else 分支
while / until 条件 | while read -r line | 读到文件尾正常收尾
&& 或 || 左侧 | a && b | a 失败不退出,b 跳过
所以"加了 set -e 就安全"是错觉:真正需要判断的分支它一句都不提醒,只能自己写 else exit 1。
五、坑 4:trap ERR 不加 -E 抓不到函数里的错误
set -eEuo pipefail
trap 'rc=$?; echo "[FAIL] ${BASH_SOURCE[0]}:${LINENO} rc=$rc" >&2' ERR
-E(errtrace)让 ERR trap 继承进函数、子 shell 和命令替换,不加则函数内出错静默无声。LINENO 给的是出错那行,配 BASH_SOURCE 可直接定位。有个细节害我查了两天:trap 里的 $? 必须第一条语句就存下来。我最初写成 echo "fail" >&2; rc=$?,永远打印 rc=0,因为 echo 之后 $? 已刷成 0。
六、坑 5:set -u 撞上空数组
set -u
args=()
printf '%s\n' "${args[@]}" # bash 4.3 及更早:args[@]: unbound variable
4.4 起才修好,先确认版本别照搬:
bash --version | head -n 1 | grep -oE '[0-9]+\.[0-9]+'
还有老镜像在跑就用可移植形式 ${args[@]+"${args[@]}"}。位置参数一律兜底:可选值 "${1:-}",必填 "${1:?用法: $0 <目录>}",后者打印提示并以 1 退出,比空字符串传下去好看。
七、坑 6:set 出了当前 shell 就不作数
set -euo pipefail 只影响当前 shell 进程。下面这几处会把它完全丢掉:
bash script.sh # 新进程,只看脚本自己的 shebang 和首行
ssh "$host" 'cmd1; cmd2' # 远端 shell 不知道你本地设了什么
sudo -u deploy bash -c 'cmd' # 同上
xargs -n1 bash -c 'cmd' # 同上,失败被吞后汇总退出码还是 0
远程那条我会显式带上 ssh "$host" 'set -euo pipefail; cd /srv/app && ./release.sh'。xargs 那条最易忽略:第 3 个文件失败,退出码仍是 0。
八、接手脚本时的两分钟体检清单
1. 第 2 行是不是 set -euo pipefail
2. 全局搜 =$(,凡是 local/export/declare 开头的一律拆两行
3. bash -n script.sh 过语法,shellcheck -S warning script.sh 过静态检查
4. 用一份坏输入实跑一次,确认失败路径的退出码不是 0
一句话结论:set -euo pipefail 是止血带,不是体检报告。它只保证"明显该停的时候会停",那些被 if、local、子 shell 悄悄吞掉的失败,只能靠 trap ... ERR 加第 4 条的实跑兜住。
- 点赞
- 收藏
- 关注作者
评论(0)