2023 年,我栽在一个"成功"的部署上。我有一个上线脚本 deploy.sh,十来个步骤:拉代码、装依赖、编译、把产物拷到目标目录、重启服务。那天我跑这个脚本,屏幕上的日志一行行往下滚,最后干干净净地打出一行 部署完成,脚本退出,退出码 0。我看着那个 0,心里踏实——脚本说成功了,那就是成功了。可几分钟后,告警炸了:线上服务起不来,用户访问 502。我懵了:脚本明明跑完了,还说"部署完成"啊。我回头一行行翻那几百行日志,翻到中间,找到一行我之前滚动时完全没注意到的字:npm install 那一步,报了一个网络错误,失败了。依赖根本没装上。可诡异的是——那一步失败之后,脚本【没有停】。它就像什么都没发生一样,继续往下跑了编译、跑了拷贝、跑了重启,一路绿灯,最后心安理得地打出"部署完成"。我一直以为,脚本里只要有一步失败,脚本就会【停下来】,带着错误退出。这是我对脚本最根深蒂固的信任。可这次,一个中间步骤明明白白地失败了,脚本却像没看见一样跑完了全程,还给了我一个退出码 0。一个内部已经断裂的流程,却向我汇报"一切正常"。我盯着那行被我忽略的 npm install 错误,第一次开始怀疑:一个脚本,它到底凭什么"在出错时停下来"?会不会它根本就【没有】这个本能?这件事逼着我把 Bash 的错误处理、set -e、set -u、set -o pipefail 这一整套彻底理清了。本文复盘这次实战。
问题背景
环境:CentOS 7,一个十来步的 Bash 部署脚本 deploy.sh
事故现象:
- 脚本跑完,打印"部署完成",退出码 0
- ★ 但中间的 npm install 步骤其实失败了,日志里有错
- ★ 失败之后脚本没停,继续跑完了后面所有步骤
- 结果:线上服务起不来,502
现场排查:
# 1. 复现:把脚本里那段单独拎出来看
$ cat deploy.sh
#!/bin/bash
echo "1. 拉代码"
git pull
echo "2. 装依赖"
npm install # ★ 这一步会失败
echo "3. 编译"
npm run build
echo "4. 部署"
cp -r dist/* /var/www/app/
echo "5. 重启"
systemctl restart myapp
echo "部署完成"
# 2. ★ 手动模拟 npm install 失败,看脚本行为
$ bash deploy.sh
1. 拉代码
2. 装依赖
npm ERR! network request failed # ★ 失败了
3. 编译 # ★ 它居然继续了!
npm ERR! missing dependencies
4. 部署 # ★ 还在继续!
5. 重启
部署完成 # ★ 一路跑到底
# 3. ★ 看脚本的退出码
$ echo $?
0 # ★ 整个脚本退出码是 0!
# 4. ★ 单独验证:一条失败的命令,后面的还会跑吗
$ false; echo "我还在跑"
我还在跑 # ★ false 失败了,echo 照样执行
根因(后来想清楚的):
1. ★ Bash 脚本的【默认行为】,根本不是"出错就停"。
它是【一行执行完,不管成败,接着执行下一行】。
一条命令失败,对脚本的"往下走"毫无影响。
2. ★ 我以为的"出错就停",是一个【我臆想出来的】
本能。Bash 默认【没有】这个本能。要它有,
必须我亲手用 set -e 把这个开关【打开】。
3. ★ 脚本的退出码,默认等于【最后一条命令】的
退出码。我脚本最后是 echo "部署完成",echo
永远成功 -> 所以整个脚本退出码是 0。
中间死了多少步,都被这个 0 盖住了。
4. 于是:npm install 失败 -> 脚本没停 -> 后面
基于"依赖已装好"的步骤全在错误的前提上跑 ->
最后 echo 成功 -> 脚本报退出码 0、打印"部署完成"。
不是脚本骗我,是我从没给过它"出错就停"的指令。
修复 1:Bash 脚本默认不会"出错就停"
# === ★ 先打碎一个最危险的误解 ===
# === ★ 误解:"脚本里有一步失败,脚本就会停" ===
# 这是我深信多年、却完全错误的想象。
# ★ Bash 脚本的真实默认行为是:
# 【顺序执行每一行,一行执行完,无论成功失败,
# 都接着执行下一行】,直到文件结束。
# 一条命令失败,默认【不会】中断脚本。
# === ★ 一眼看清这个行为 ===
$ cat demo.sh
#!/bin/bash
echo "第一步"
false # false 是个命令,它什么都不做,只返回失败
echo "第二步"
ls /不存在的目录 # 这条也会失败
echo "第三步"
$ bash demo.sh
第一步
第二步 # ★ false 失败了,第二步照样执行
ls: 无法访问'/不存在的目录': 没有那个文件或目录
第三步 # ★ ls 失败了,第三步照样执行
# ★ 三步全跑完了。中间两条命令都失败了,毫无影响。
# === ★ 每条命令都有"退出码",0=成功,非0=失败 ===
$ ls /tmp ; echo "退出码=$?"
退出码=0 # ★ 成功
$ ls /不存在 ; echo "退出码=$?"
退出码=2 # ★ 失败,非 0
# ★ $? 是"上一条命令的退出码"。脚本本可以靠它
# 判断成败 —— 但默认情况下,脚本【根本不去看】$?,
# 它只管一行行往下念。
# === ★ 脚本自己的退出码 = 最后一条命令的退出码 ===
$ cat demo2.sh
#!/bin/bash
false # 失败
echo "结束" # ★ 最后一条命令,echo 总是成功
$ bash demo2.sh; echo "脚本退出码=$?"
结束
脚本退出码=0 # ★ 整个脚本是 0!尽管中间 false 失败了
# ★ 这就是我那个"部署完成"的真相:最后一句 echo
# 成功了,它的 0 成了整个脚本的退出码,把中间
# 所有失败全盖住了。
# === 认知 ===
# ★ Bash 脚本默认【不是】"出错就停"的。它是
# "一行行念到底"的。"出错就停"不是天生的,
# 是要你亲手开启的功能。下一节就是那个开关。
修复 2:set -e——任一步失败立刻退出
# === ★ set -e:给脚本装上"出错就停"的本能 ===
# === ★ set -e 是什么 ===
# 在脚本开头加一行 set -e,意思是:
# ★ 从此刻起,只要有【任何一条命令】返回了非 0
# (失败),脚本【立刻终止】,不再往下执行。
# 它把"默认一行行念到底"改成了"一遇错就刹车"。
# === ★ 加上 set -e 前后对比 ===
$ cat demo3.sh
#!/bin/bash
set -e # ★ 就加这一行
echo "第一步"
false # ★ 这里失败
echo "第二步" # ★ 不会被执行了
$ bash demo3.sh; echo "脚本退出码=$?"
第一步
脚本退出码=1 # ★ 在 false 处就停了,
# 退出码=1(那条失败命令的码)
# ★ "第二步"没有打印 —— 脚本在 false 处果断刹车了。
# === ★ 我那个部署脚本,正确的开头 ===
$ cat deploy.sh
#!/bin/bash
set -e # ★ 第一件事:开启 出错即停
echo "1. 拉代码"; git pull
echo "2. 装依赖"; npm install # ★ 这步失败 -> 脚本立刻停
echo "3. 编译"; npm run build # 后面这些根本不会跑
echo "4. 部署"; cp -r dist/* /var/www/app/
echo "5. 重启"; systemctl restart myapp
echo "部署完成" # ★ 只有全成功才会走到这
# ★ 现在 npm install 一失败,脚本立刻退出,退出码
# 非 0 —— 我立刻就知道"部署没成功",而不是被
# 一句假的"部署完成"骗过去。
# === ★ 写法:set -e 也可以写成 set -o errexit ===
set -e # 简写
set -o errexit # ★ 等价,完整写法,可读性更好
# === ★ 它不止用在脚本,交互式排查也能用 ===
# 在终端里一连串命令,想"错了就别往下"时:
$ set -e # 当前 shell 开启
$ 一连串命令 ...
$ set +e # ★ 用 + 号关闭
# === 认知 ===
# ★ set -e 是写 Bash 脚本的【第一行】该写的东西。
# 它让脚本从"装作没事继续跑"变成"出错立刻喊停"。
# 一个没有 set -e 的部署/运维脚本,本质上是个
# "失败了也不告诉你"的脚本 —— 极度危险。
修复 3:set -u——用了没定义的变量就报错
# === ★ set -u:堵住"空变量"这个更阴的坑 ===
# === ★ Bash 默认:用一个【没定义】的变量,不报错 ===
$ echo "目标目录是 [$TARGET_DIR]"
目标目录是 [] # ★ TARGET_DIR 没定义,
# Bash 默默把它当成【空字符串】
# ★ 不报错、不警告,空就空。这是个巨大的隐患。
# === ★ 这个坑有多致命 —— 一个真实的灾难写法 ===
$ cat danger.sh
#!/bin/bash
# 本意:清空某个目录
rm -rf "$APP_DIR/" # ★ 如果 APP_DIR 没定义...
# ★ APP_DIR 为空时,这条命令变成了:
# rm -rf "/"
# ★ —— 它会去删根目录!一个变量名打错、或者
# 忘了赋值,就可能酿成删库级别的事故。
# === ★ set -u:用未定义变量,直接报错退出 ===
$ cat safe.sh
#!/bin/bash
set -u # ★ 开启:未定义变量 = 报错
echo "目标是 [$TARGET_DIR]"
$ bash safe.sh
safe.sh: line 3: TARGET_DIR: unbound variable # ★ 当场报错!
# ★ 它不再把未定义变量当空字符串放过去,而是
# 立刻喊停。变量名打错、忘赋值,马上暴露。
# === ★ set -u 配合 set -e,标准的脚本开头 ===
#!/bin/bash
set -eu # ★ -e 出错即停 + -u 空变量即报错
# 或写全:
set -o errexit
set -o nounset # ★ nounset 就是 -u
# === ★ 那"我就是想允许某个变量为空"怎么办 ===
# 用 ${VAR:-默认值} 语法,显式给个默认值:
DEPLOY_ENV="${DEPLOY_ENV:-prod}" # ★ 没传就用 prod
echo "环境=$DEPLOY_ENV"
# ★ 这样写,意图很明确:"我【知道】它可能没定义,
# 我【主动】给个默认值"。而不是稀里糊涂地用空值。
# === ★ 检查变量"必须有值,否则报错并说明原因" ===
: "${APP_DIR:?APP_DIR 必须设置}" # ★ 没定义就报这句话退出
# rm -rf "$APP_DIR/" # 走到这,APP_DIR 一定有值了
# === 认知 ===
# ★ set -u 防的是"空变量"这个隐形杀手。Bash 默认
# 把未定义变量当空字符串,这在 rm -rf "$DIR" 这类
# 命令里是致命的。set -eu 一起开,是脚本的安全底座。
修复 4:set -o pipefail——管道里的失败别被吞掉
# === ★ pipefail:堵住"管道"这个 set -e 也漏掉的洞 ===
# === ★ 一个反直觉的事实:管道的退出码 ===
# 一条管道 a | b | c,它的退出码,默认【只看最后
# 一个命令 c】的退出码。前面 a、b 失没失败,
# 完全不影响整条管道的成败判定。
$ cat /不存在的文件 | grep "abc" | wc -l
0
$ echo $?
0 # ★ 管道退出码是 0 —— "成功"!
# ★ 可是 cat 明明失败了(文件不存在)!但因为最后
# 的 wc 成功了,整条管道就被判成"成功"。
# === ★ 这个坑会怎么害你 ===
$ cat process.sh
#!/bin/bash
set -e # ★ 即使开了 set -e
DATA=$(curl -s http://api/data | jq '.list') # curl 失败了...
echo "拿到数据: $DATA" # ★ 还是会执行!
# ★ curl 失败(网络挂了),但管道最后的 jq 对着
# 空输入"成功"返回了 -> 整条管道退出码 0 ->
# set -e 认为"这步成功了" -> 脚本继续 ->
# 你拿着一份空数据往下跑。
# === ★ set -o pipefail:管道里任一环失败,整条就算失败 ===
$ cat process2.sh
#!/bin/bash
set -e
set -o pipefail # ★ 加这一行
DATA=$(curl -s http://api/data | jq '.list')
echo "拿到数据: $DATA"
# ★ 现在:curl 一失败,整条管道的退出码就是非 0,
# set -e 立刻把脚本停掉 —— 不会再拿空数据往下跑。
$ cat /不存在 | grep abc | wc -l
$ echo $?
1 # ★ 开了 pipefail,现在是 1 了
# === ★ 三件套:Bash 脚本的"安全头" ===
#!/bin/bash
set -euo pipefail # ★ 一行搞定三个开关
# 拆开就是:
# set -e 出错即停
# set -u 空变量即报错
# set -o pipefail 管道失败不被吞
# ★ 这一行,几乎应该出现在你写的每一个 Bash 脚本的
# 开头。它是把"业余脚本"变"可靠脚本"的最低成本。
# === ★ 再加一个调试利器:set -x ===
set -x # ★ 把每条要执行的命令都打印出来
# 排查脚本时临时加上 -x,能看到脚本"实际在跑什么"
# (变量展开成什么了),定位问题极快。
# === 认知 ===
# ★ set -e 管不到管道内部 —— 这是它的盲区。pipefail
# 就是来补这个盲区的。凡是脚本里有 | 管道,又靠
# 它的结果做判断,就必须配上 set -o pipefail。
修复 5:set -e 不是万能的——它的几个"例外"
# === ★ 重要:set -e 有它"管不到"的地方,别盲目信任 ===
# === ★ 例外 1:命令出现在 if / while 的条件里 ===
# 在 if、while、until 的【判断条件】里的命令,
# 就算失败,set -e 也【不会】触发退出 —— 这是
# 故意的,因为判断条件本来就是要"试成败"。
set -e
if grep "abc" file.txt; then # ★ grep 没找到(失败),
echo "找到了" # 不会让脚本退出 ——
fi # 它只是让 if 走 else 分支
echo "继续" # ★ 这行会正常执行
# ★ 这是合理的,但要心里有数:放进 if 条件的命令,
# 失败了不会被 set -e 拦下。
# === ★ 例外 2:命令后面跟了 || 或 && ===
set -e
some_command || true # ★ 加了 || ,即使 some_command
# 失败,整体也"成功",set -e
# 不触发。这常被用来"故意
# 允许某条命令失败"。
some_command || echo "这步失败了但我不在乎"
# ★ 反过来,这也是个坑:你以为 set -e 会拦,结果
# 因为后面跟了 || 就被放过了。
# === ★ 例外 3:命令不是"最后一条"时,在 && 链里 ===
set -e
cmd1 && cmd2 && cmd3 # ★ 如果 cmd1 失败,
# 因为整个 && 链作为一条语句,只看最终结果,中间
# cmd1 失败会让整条语句失败 -> set -e 这时【会】触发。
# 但行为微妙,复杂逻辑别依赖这种隐式行为。
# === ★ 例外 4:函数里的失败 + 函数被用在条件中 ===
set -e
myfunc() {
false # ★ 函数内一条命令失败
echo "函数后半段"
}
if myfunc; then echo ok; fi # ★ myfunc 被放进 if 条件,
# 于是【整个 myfunc 内部】的
# set -e 都失效了 —— "函数
# 后半段"会被执行!
# ★ 这是 set -e 最反直觉的坑之一:一旦函数被用在
# 条件位置,函数体内的 set -e 保护就整体失效。
# === ★ 例外 5:子 shell、命令替换里的行为也有差异 ===
set -e
result=$(false; echo "还在跑") # ★ 命令替换内 false 失败,
echo "result=$result" # 行为依版本和写法而异,
# 别依赖
# === ★ 稳妥的做法:关键命令,自己显式检查 ===
# 不要把身家性命全押在 set -e 上。最关键的几步,
# 老老实实自己判断:
npm install || { echo "依赖安装失败,中止" >&2; exit 1; }
cd /var/www/app || { echo "目录不存在,中止" >&2; exit 1; }
# ★ "cd 失败要显式检查" 尤其重要 —— cd 失败后如果
# 还接着 rm/cp,就是在错误的目录里动手,后果严重。
# === 认知 ===
# ★ set -e 是个好东西,但它【不是】一道密不透风的墙。
# if 条件里、|| 后面、被当条件用的函数里 —— 它都
# 管不到。所以:set -euo pipefail 打底,但最关键
# 的几步(尤其 cd、rm 前),一定要自己显式检查。
修复 6:Bash 脚本健壮性排查纪律
# === 这次事故暴露的认知盲区,定几条纪律 ===
# === 1. ★ Bash 脚本默认行为是"一行行念到底",不是"出错就停" ===
# === 2. ★ 每个脚本开头第一行写 set -euo pipefail ===
#!/bin/bash
set -euo pipefail
# === 3. set -e 出错即停,set -u 空变量即报错,pipefail 管道失败不被吞 ===
# === 4. ★ 脚本退出码默认 = 最后一条命令的退出码,别让一句 echo 把失败盖住 ===
# === 5. ★ rm -rf "$DIR" 这类命令前,务必确认 DIR 有值(set -u 或 :?) ===
: "${APP_DIR:?APP_DIR 未设置}"
# === 6. ★ cd 之后再做 rm/cp,cd 必须显式检查成败 ===
cd "$APP_DIR" || { echo "cd 失败" >&2; exit 1; }
# === 7. set -e 管不到 if 条件里、|| 后面、被当条件用的函数里 —— 关键步骤自己查 ===
# === 8. ★ 想"故意允许某命令失败",显式写 || true,让意图可见 ===
optional_step || true
# === 9. 排查脚本时临时加 set -x,看每条命令实际展开成什么 ===
$ bash -x deploy.sh
# === 10. 排查"脚本说成功其实失败"的步骤链 ===
$ echo $? # ① 真实退出码,别只信日志文字
$ bash -x 脚本 # ② 加 -x 看实际执行了什么
$ 在开头加 set -euo pipefail # ③ 让脚本出错即停
$ 关键步骤补 || { ...; exit 1; } # ④ set -e 盲区处手动兜底
$ 重跑,确认失败时退出码非 0 # ⑤ 验证
# 按这个顺序,"假装成功的脚本"基本能现形、能根治。
命令速查
需求 命令 / 写法
=============================================================
脚本安全头(必写) set -euo pipefail
出错即停 set -e / set -o errexit
用未定义变量即报错 set -u / set -o nounset
管道任一环失败即失败 set -o pipefail
打印每条实际执行的命令 set -x / bash -x 脚本
关闭某个开关 set +e / set +u
看上条命令退出码 echo $?
变量带默认值 ${VAR:-默认值}
变量未设置就报错退出 : "${VAR:?VAR 未设置}"
故意允许某命令失败 命令 || true
关键命令失败就中止 命令 || { echo 错误 >&2; exit 1; }
cd 安全写法 cd 目录 || exit 1
口诀:Bash 默认出错也往下跑,脚本第一行必写 set -euo pipefail
set -e 有盲区(if 条件 / 竖线管道 / 函数),关键步骤要自己显式兜底
避坑清单
- Bash 脚本默认行为是一行行念到底,一条命令失败默认不会中断脚本,出错就停不是天生的
- 每个 Bash 脚本开头第一行就写 set -euo pipefail,这是把业余脚本变可靠脚本的最低成本
- set -e 让任一命令失败立刻退出,set -o errexit 是它的完整写法
- set -u 让用未定义变量直接报错,Bash 默认把未定义变量当空字符串是致命隐患
- rm -rf "$DIR" 里 DIR 若为空会变成 rm -rf /,务必用 set -u 或 ${DIR:?} 兜住
- set -o pipefail 让管道里任一环失败整条都算失败,默认管道只看最后一个命令的退出码
- 脚本退出码默认等于最后一条命令的退出码,末尾一句 echo 会把中间所有失败盖成 0
- set -e 管不到 if/while 条件里的命令、|| 后面的命令、被当条件用的函数体内
- cd 之后再做 rm/cp 操作,cd 必须显式检查成败,否则可能在错误目录里动手
- 想故意允许某命令失败就显式写 || true 让意图可见,排查脚本用 bash -x 看实际执行
总结
这次"脚本中间步骤失败了、却一路绿灯跑完还报成功"的事故,纠正了我一个关于"工具"的、近乎一厢情愿的信任。在我心里,我写的脚本,是一个【尽职尽责的助手】。我默认它和我站在一边,默认它和我有着一样的目标——把这次部署【正确地】完成。所以我理所当然地以为,它会像一个上心的人那样:每做完一步,都回头看一眼这步到底成没成;一旦发现某步砸了,它会立刻停下来,转过头对我说"出事了,我不敢往下走了"。这个"出错就停"的本能,在我心里,是脚本作为"助手"天经地义该有的品质。正因为抱着这份信任,这次的现象才会让我那么错愕:我的助手,在 npm install 那一步明明白白地摔了一跤,可它【爬起来,拍拍土,什么也没说,继续往下走了】。它走完了编译、走完了拷贝、走完了重启,每一步都踩在"依赖已经装好"这个早已不成立的前提上,最后,它平静地、甚至可以说是得意地,向我报告:"部署完成。"复盘到根上,我才明白,我从一开始就把它【拟人化】得太厉害了。我的脚本,根本不是什么"和我一条心的助手"。它是一台严格的、字面意义上的执行机器。我在文件里写下五行命令,它做的事,就是不多不少地、把这五行【依次念一遍】。"念"——仅此而已。它从来没有义务、也从来没有被我授权,去关心"上一行念出来的结果是好是坏"。它没有目标,它不知道什么叫"部署成功",它甚至不知道这五行命令合起来是要干一件什么事。我以为它在"帮我完成部署",其实它只是在"逐行朗读我的文件"。"出错就停"——那个我以为是它本能的东西——根本不是它自带的。那是一个需要我【亲手按下】的开关(set -e)。我从来没按过那个开关,却一直埋怨它"怎么不停下来";这就像我从没给一个人下过"出事就喊我"的指令,却怪他出了事不喊我。他不喊,不是失职,是我从没交代过。这次最大的收获,是我对"默认行为"这四个字,生出了一种新的敬畏。我过去用一个工具,总是先在脑子里给它脑补一套"它应该会怎样"——它应该会校验、它应该会保护我、它应该会在危险的时候拦住我。然后我就抱着这套脑补出来的"应该",放心地用下去。可这次让我看清:一个工具真实的"默认行为",和我脑补的那个"应该",常常是两回事,有时甚至【完全相反】。工具的默认行为,是它的设计者基于某种取舍定下的,那个取舍里,不一定包含"保护此刻的我"。所以下一次,当我开始依赖一个工具、并把某种"它应该会替我兜底"的期待寄托在它身上时,我会先停下来,把这个期待变成一个明确的问题去查证:这个工具,在我【什么都不额外设置】的情况下,它到底会怎么做?它会不会在我以为它该刹车的地方,其实是一脚油门踩到底?——很多最惨烈的事故,不是因为工具背叛了我们,而是因为我们从一开始,就把自己脑补出来的那个"贴心助手",错当成了它本来的样子;而它,只是在忠实地、毫无感情地,做着它【被设计成】要做的事。
—— 别看了 · 2026