4. 错误处理与回滚机制:别让升级变成灾难
升级脚本跑了一半,突然网络断了。或者磁盘空间不够了。再或者,新版本配置和旧环境不兼容。
这时候怎么办?
我见过太多团队,脚本写到一半就上线。出问题了,只能人肉登录服务器,一行一行地修。说实话,这种场面我经历过几次,每次都头皮发麻。所以这一章,咱们聊聊怎么让脚本自己处理异常,怎么优雅地回滚。
4.1 异常捕获策略:别让脚本静默死掉
很多脚本出问题,不是因为逻辑复杂,而是因为异常没被捕获。进程默默退出了,连个日志都没留下。你想想看,第二天早上才发现升级失败了,那得多被动。
我个人习惯,在脚本入口处就设置全局异常捕获。Shell 脚本里用 trap,Python 里用 try...except 包住主流程。
Shell 脚本异常捕获模板:
#!/bin/bash
set -euo pipefail
# 定义错误处理函数
error_handler() {
local line_no=$1
local error_code=$2
echo "[FATAL] 脚本在第 ${line_no} 行出错,错误码: ${error_code}"
# 调用回滚函数
rollback
exit ${error_code}
}
# 注册错误捕获
trap 'error_handler ${LINENO} $?' ERR
# 主逻辑
echo "开始升级..."
# ... 升级步骤 ...
小提示:set -euo pipefail 这行一定要加。它能让脚本在遇到未定义变量、管道错误时立即退出。我见过有人没加这行,结果变量为空,脚本继续往下跑,把整个配置文件都清空了。
Python 脚本里,我习惯用自定义异常类。这样能区分“网络超时”和“配置错误”,方便后续处理。
class UpgradeError(Exception):
"""升级过程中的基础异常"""
def __init__(self, message, code, can_retry=False):
self.message = message
self.code = code
self.can_retry = can_retry
super().__init__(self.message)
class NetworkTimeoutError(UpgradeError):
def __init__(self, message="网络超时"):
super().__init__(message, code=1001, can_retry=True)
class ConfigValidationError(UpgradeError):
def __init__(self, message="配置校验失败"):
super().__init__(message, code=2001, can_retry=False)
4.2 错误码设计:一眼看出问题在哪
错误码不是随便写的。好的错误码,能让你在凌晨三点被叫起来时,看一眼就知道该按哪个按钮。
我建议按模块和严重程度来编码。比如:
| 错误码范围 | 模块 | 示例 |
|---|---|---|
| 1xxx | 前置检查 | 1001 - 磁盘空间不足 1002 - 端口被占用 |
| 2xxx | 备份阶段 | 2001 - 备份文件创建失败 2002 - 备份校验和不匹配 |
| 3xxx | 升级执行 | 3001 - 文件替换失败 3002 - 服务启动超时 |
| 4xxx | 回滚阶段 | 4001 - 回滚脚本未找到 4002 - 回滚后服务仍异常 |
| 9xxx | 未知/系统错误 | 9001 - 内存溢出 9999 - 未捕获异常 |
注意:错误码不要重复使用。我曾经在一个项目里看到,同一个错误码既表示“文件不存在”,又表示“权限不足”。排查的时候,两个团队吵了半天,最后发现是错误码定义冲突了。
4.3 回滚脚本编写原则:能进就能退
回滚不是事后补救,而是升级脚本的一部分。我写升级脚本时,有个硬性要求:每个升级步骤,都必须有对应的回滚步骤。
回滚脚本的编写原则,我总结了几条:
- 幂等性:回滚脚本可以重复执行,不会造成二次破坏。比如删除备份文件前,先检查文件是否存在。
- 原子性:要么全部回滚成功,要么全部失败。不要出现“配置文件回滚了,但数据库没回滚”的情况。
- 可审计:每次回滚都要记录日志,包括回滚原因、执行时间、操作人。
- 最小化:只回滚受影响的部分,不要一棍子打死。比如只回滚失败的模块,其他模块保持升级状态。
回滚脚本示例(Shell):
rollback() {
local step=$1
echo "[ROLLBACK] 开始回滚步骤: ${step}"
case ${step} in
"backup")
# 删除本次备份,恢复旧备份
if [ -f "${BACKUP_DIR}/old_backup.tar.gz" ]; then
mv "${BACKUP_DIR}/old_backup.tar.gz" "${BACKUP_DIR}/backup.tar.gz"
echo "[ROLLBACK] 已恢复旧备份"
fi
;;
"config")
# 恢复配置文件
if [ -f "${BACKUP_DIR}/config_backup/" ]; then
cp -r "${BACKUP_DIR}/config_backup/" "${CONFIG_DIR}/"
echo "[ROLLBACK] 配置文件已恢复"
fi
;;
"service")
# 重启旧版本服务
systemctl stop new_service 2>/dev/null || true
systemctl start old_service
echo "[ROLLBACK] 服务已回滚至旧版本"
;;
*)
echo "[ROLLBACK] 未知步骤: ${step}"
return 1
;;
esac
}
4.4 部分失败的处理逻辑:别让一颗老鼠屎坏了一锅粥
升级过程中,最头疼的就是部分失败。比如你有 10 台机器,9 台升级成功了,1 台失败了。怎么办?全部回滚?那 9 台就白干了。不回滚?那 1 台怎么办?
我处理这类问题的思路是:先隔离,再决策。
- 隔离失败节点:立即将失败节点从负载均衡中摘除,避免影响线上流量。
- 记录失败上下文:保存失败节点的日志、配置、状态快照。
- 自动重试:如果是网络超时等可重试错误,自动重试 3 次,每次间隔 5 秒。
- 人工介入:如果重试仍失败,标记为“待人工处理”,并发送告警。
- 决策回滚范围:根据失败原因,决定是单节点回滚,还是全量回滚。
经验之谈:我曾经遇到过一个案例,数据库升级时,某个索引创建失败了。当时脚本直接全部回滚,结果回滚过程中又把另一个正常表给搞坏了。后来我改成“只回滚失败的变更”,其他成功的变更保留。这样风险小很多。
4.5 核心逻辑:错误处理与回滚流程图
下面这张图,是我每次设计升级脚本时都会参考的。它把异常捕获、错误码、回滚、部分失败串在了一起。
4.6 避坑指南:我踩过的那些坑
- 回滚脚本没测试:我曾经写过一个回滚脚本,上线前没测试。结果真出问题时,回滚脚本本身就有 bug,导致服务彻底不可用。现在我的原则是:回滚脚本和升级脚本一样,必须经过完整测试。
- 错误码太笼统:早期我习惯用 0 表示成功,1 表示失败。后来发现,1 根本不够用。文件不存在是 1,权限不足也是 1,网络超时还是 1。排查时只能看日志,效率极低。
- 忽略幂等性:回滚脚本执行到一半,进程被 kill 了。再执行一次,结果因为上次留下了半成品,直接报错。现在我的回滚脚本,每一步都会检查前置条件。
- 部分失败时全量回滚:这个前面说过了。能局部回滚的,尽量不要全量回滚。全量回滚的风险,有时候比升级失败本身还大。
最后提醒一句:错误处理和回滚机制,不是锦上添花,而是保命符。别等到出事了才想起来写。我见过太多团队,升级脚本写得飞快,回滚脚本一个字没写。结果呢?凌晨三点,全员上线,手动回滚。那场面,真的不想再经历第二次。