脚本幂等性:跑两次不应该出问题
./deploy.sh 第一次成功,第二次把目录删了,第三次开始报权限错误。这种脚本不是自动化,只是把手工事故改成了批量事故。
幂等的意思是:同一个脚本重复执行,结果应该稳定,不该越跑越歪。对脚本来说,这不是理论问题,而是“失败后能不能安全重跑”。
很多自动化脚本看起来能用,问题往往出在这里:能跑一次不难,能安全重跑才难。尤其是部署、初始化、同步、清理这类脚本,最怕半途失败后不知道该不该重试。
先看什么叫“不幂等”
最常见的几类:
1. 重复追加
1 | |
跑一次还行,跑十次就多十行。
更稳一点的写法是先检查再写:
1 | |
有就别加,没有再加。
2. 重复创建
1 | |
目录已经存在就报错。通常应该写成:
1 | |
-p 的意思是:存在也算成功。
3. 重复删除后依赖失效
1 | |
第一行已经删完了,第二行自然报错。更麻烦的是,脚本作者以为是“第二步失败”,实际是“第一步做得太狠”。
幂等脚本的一个核心习惯:先判断状态,再决定动作
别假设环境是干净的,也别假设上次一定成功。脚本要面对的是未知中间态。
比如安装二进制文件,不要每次都覆盖:
1 | |
如果确实要更新,就比较版本或校验值,而不是直接复制。
1 | |
这里的重点是:脚本不是“执行动作列表”,而是“把系统推到目标状态”。
比起“做了什么”,更该写“最后应当是什么样”
这两段看起来很像,但可靠性差很多。
面向动作
1 | |
面向状态
1 | |
mkdir -p /srv/app
if ! cmp -s ./config.yml /srv/app/config.yml 2>/dev/null; then
install -m 644 ./config.yml /srv/app/config.yml
fi
systemctl enable –now app
后者的意思是:用户存在、目录存在、配置一致、服务已启用并运行。脚本跑第二次,不会因为“已经做过”而失败。
有副作用的步骤,要么可检测,要么可回滚
幂等最怕两种东西:远程调用和数据库修改。因为它们往往不像本地文件那样容易判断状态。
例如建表,别直接执行可能失败的 SQL,优先用“如果不存在则创建”这一类语句。不同数据库写法不同,但原则一样:把目标状态写进命令里。
如果脚本要调用 HTTP 接口,最好带上可重复请求的标识。有些系统支持请求去重键,避免重试时创建两份资源。若接口本身是否支持未验证,这一步应标记为“未验证”或改为人工确认,不要默认它天然安全。
失败处理不要只靠 set -e
很多人喜欢开头先写:
1 | |
这没问题,但它解决的是“尽早停”,不是“可安全重跑”。
set -e:命令失败就退出-u:未定义变量直接报错pipefail:管道里任何一步失败都算失败
它们能减少沉默出错,但不能自动让脚本幂等。下面这些问题仍然要自己处理:
- 文件已存在怎么办
- 服务已启动怎么办
- 中途失败后留下一半结果怎么办
- 第二次执行时如何识别“做到哪一步了”
一个实用写法:给步骤加“完成标记”
遇到耗时操作,或者无法轻易判断状态时,可以用标记文件。
1 | |
if [ ! -f “$STEP_DIR/extract.done” ]; then
tar -xf app.tar.gz -C /opt/app
touch “$STEP_DIR/extract.done”
fi
if [ ! -f “$STEP_DIR/init.done” ]; then
/opt/app/bin/init.sh
touch “$STEP_DIR/init.done”
fi
这不是万能方案,但很适合初始化脚本。重点是:标记只能在步骤成功后写入,别提前 touch。
输出也要幂等:让人一眼看懂是否真的变更了
不要一律打印“success”。更好的做法是区分:
createdupdatedskippedalready exists
比如:
1 | |
这样排查时,能知道脚本是“真的改了东西”,还是“只是确认状态”。
检查一个脚本是否幂等
不要只跑一次,至少做这三步:
- 在干净环境跑一次
- 原地再跑一次
- 故意让中间一步失败,再重跑
第三步最容易暴露问题。现实里的脚本,很少死在第一行,更常见的是跑到中途失败,然后你开始犹豫:要不要再执行一遍?
如果一段脚本需要你每次重跑前先手工清现场,那它大概率还没写完。