2827 字
约 9 分钟
18
S01-A08 · Shell 脚本的工程化:可测、可复用、可回滚
一个脚本传到第三个人手里就没人敢改。症状固定三种:没人敢在线上跑,因为不知道它会删什么;没人敢改,因为不知道改完会碰到哪条分支;跑完没人知道它做过什么,因为日志滚在终端里。这三种症状分别对应可回滚、可测、可观测——也正好是"这个脚本要不要工程化"的三把尺子。先量,再决定投入。
1. 先判定:这个脚本要不要工程化
| 判据 | 问法 | 结论 |
|---|---|---|
| 会被重复执行 | 一周内跑过第二次吗 | 必须进仓库、进评审 |
| 有破坏性 | 会删、覆盖或改远端状态吗 | 必须有回滚路径与 dry-run |
| 要交接 | 第二个人会读它吗 | 必须有用法、退出码、日志约定 |
| 只在本地跑一次 | 是 | 不必,存下来就是负债 |
| 已有成熟工具 | rsync、systemctl、ansible 能做 |
不写,只写配置 |
2. 可测的三层:成本递增,收益递减
| 层 | 手段 | 拦得住什么 | 成本 |
|---|---|---|---|
| 1 纯函数式片段 | 抽成"参数进、字符串出"的函数,可 source 单独调用 |
分支漏写、边界值、空输入 | 低,只要求作者有拆分意识 |
| 2 静态检查 | shellcheck、shfmt |
未加引号的展开、未定义变量、误用 [ ] |
一次性接入 CI,几分钟 |
| 3 行为测试 | bats 类框架 |
真实执行下的副作用、退出码、幂等性 | 高,要造夹具和临时目录 |
第 2 层是性价比拐点:接入成本接近零,收益覆盖大部分低级的现场事故。
#!/usr/bin/env bash
# 纯函数:不读写外部状态,只吃参数吐字符串
target_dir() {
local env=$1
case $env in
prod) printf '%s\n' /srv/app/prod ;;
*) printf '%s\n' "/srv/app/$env" ;;
esac
}
shellcheck -S warning -f gcc scripts/*.sh # CI 用 -d 只报告
shfmt -i 2 -ci -d scripts/ # 本地改回写用 -w
@test "部署失败时保留上一版目录" {
run deploy.sh --env staging --fail-on-purpose
[ "$status" -ne 0 ]
[ -d "$TMPDIR/releases/previous" ]
}
3. set -euo pipefail 的真实语义与四个盲区
| 开关 | 字面语义 | 不保证什么 |
|---|---|---|
-e |
简单命令失败即退出 | 条件上下文、管道非末位、被 local 吞掉的状态 |
-u |
引用未定义变量即退出 | 有默认值的 ${x:-};空数组展开的版本差异 |
-o pipefail |
管道返回首个失败命令的状态 | 不改变 grep 无匹配返回 1、head 关管道导致 141 |
set -euo pipefail
local out=$(some_cmd) # 盲区一:local/declare 的返回值是 0,失败被吞
grep -q foo file.txt # 盲区二:无匹配返回 1,整个脚本在此退出
some_cmd | head -n 1 # 盲区三:head 提前关闭管道,some_cmd 收到 SIGPIPE
((count++)) # 盲区四:算术结果为 0 时返回 1
local out
out=$(some_cmd) || { log_error "some_cmd failed"; return 1; }
grep -q foo file.txt || true # 语义上"没有就是没有"
count=$((count + 1)) # 不依赖算术命令的状态
set -e 在命令替换内部默认不生效,bash 4.4 起才有 shopt -s inherit_errexit。版本差异要实测。
4. 参数解析与错误处理
| 方式 | 适合 | 代价 |
|---|---|---|
位置参数 $1 $2 |
参数不超过两个、含义固定 | 加一个参数就动全部调用点 |
getopts |
短选项、可组合、要 POSIX 兼容 | 不支持长选项 |
while + case |
长选项、需要 -- 分隔 |
错误处理要自己写 |
| 环境变量 | 由调度方注入(容器、systemd) | 隐式,排错时看不见 |
usage() { printf 'usage: %s -e ENV [-n] [-t SEC]\n' "${0##*/}" >&2; exit 64; }
env=''; dry=0; timeout=30
while getopts ':e:nt:' opt; do
case $opt in
e) env=$OPTARG ;;
n) dry=1 ;;
t) timeout=$OPTARG ;;
:) printf 'missing arg for -%s\n' "$OPTARG" >&2; usage ;;
\?) printf 'unknown option: -%s\n' "$OPTARG" >&2; usage ;;
esac
done
shift $((OPTIND - 1))
[ -n "$env" ] || usage
开头那个 : 让 getopts 区分"缺参数"和"未知选项"。退出码约定:64 用法错误、65 输入数据不对、74 IO 失败、78 配置错误。只分 0 与非 0 的脚本,出事后人得重跑一遍才知道该修命令还是修逻辑。
5. 幂等与可重入
| 破坏性动作 | 幂等写法 | 判据 |
|---|---|---|
| 建目录 | mkdir -p |
存在即成功 |
| 建软链 | ln -sfn |
覆盖而非报错 |
| 写配置 | 先写 .tmp 再 mv |
读端不会读到半个文件 |
| 起服务 | 先查 is-active |
已在跑就不重启 |
| 追加行 | grep -qxF 先查 |
不重复追加 |
install_unit() {
local src=$1 dst=/etc/systemd/system/app.service
cmp -s "$src" "$dst" && { log_info "unit unchanged"; return 0; }
install -m 0644 "$src" "$dst.tmp" && mv -f "$dst.tmp" "$dst"
systemctl daemon-reload
}
cmp -s 判断优于"先删后写",因为中间没有服务不可用的窗口;mv 在同一文件系统内是原子 rename。可重入比幂等更强:中断后能接着跑——每一步从当前状态判断"做完了没",不靠内存标志位。
6. 失败处理与回滚
trap 的位置决定它能清什么。清理函数写在脚本开头且引用了尚未赋值的变量,在 set -u 下会提前炸掉。
cleanup() {
local rc=$?
[ -n "${TMPDIR_CREATED:-}" ] && rm -rf -- "$TMPDIR_CREATED"
[ "$rc" -ne 0 ] && log_error "failed rc=$rc at ${BASH_SOURCE[0]}:${BASH_LINENO[0]:-?}"
return "$rc"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
| 事件 | 写法 | 注意 |
|---|---|---|
| 正常退出/失败退出 | trap ... EXIT |
唯一必写,覆盖两条路径 |
| 被 Ctrl-C / kill | INT 转 130、TERM 转 143 |
让调度方区分"被中断"与"自己失败" |
| 清理顺序 | 逆序 | 先摘流量再停进程,先解挂载再删目录 |
| 部分成功 | 写状态记录 | 回滚读记录,不靠人回忆 |
# 部署记录是回滚的唯一依据
printf '%s\t%s\t%s\n' "$(date -Is)" "$release" "$previous" >> "$STATE/deploys.tsv"
7. 输出契约:stdout 给数据,stderr 给诊断
log_info() { printf '[%s] %s\n' "$(date +%H:%M:%S)" "$*" >&2; }
log_error() { printf '[%s] ERROR %s\n' "$(date +%H:%M:%S)" "$*" >&2; }
emit() { jq -nc --arg p "$1" --arg s "$2" '{path:$p,sha:$s}'; }
log_info 少写一个 >&2,就是"脚本被 $(...) 捕获后输出污染"的根因:日志混进数据,下游解析器报非法 JSON,而人看的终端一切正常。JSON 不该手拼:路径里有引号就坏。
8. 配置与密钥
| 位置 | 谁可读 | 适合 | 不适合 |
|---|---|---|---|
| 脚本内常量 | 能读仓库的所有人 | 路径、超时默认值 | 任何随环境变化的值 |
同目录 .env(600) |
运行用户 | 单机部署 | 进仓库 |
| 环境变量注入 | 进程及其子进程 | 容器、CI | 排错时肉眼不可见 |
| 受控文件/密钥服务 | 受控主体 | 生产凭据 | 小规模单机 |
: "${APP_DB_PASSWORD:?APP_DB_PASSWORD is required}"
log_info "db auth: env provided (len=${#APP_DB_PASSWORD})" # 只记长度,不记原文
set +x; pw=$APP_DB_PASSWORD; set -x # 敏感赋值前后关掉跟踪
set -x 会把展开后的变量打进 stderr,包括密码——跟踪要按段开关。
9. 跨平台边界
| 写法 | 在 sh/BSD 上 |
替代 |
|---|---|---|
[[ ]] |
dash 不支持 | [ ] 加引号 |
数组 arr=(a b) |
不支持 | 换行分隔字符串 + while read |
${var//x/y} |
不支持 | sed、tr |
sed -i 's/a/b/' |
BSD 需 -i '' |
sed -i.bak 两边都能跑 |
date -d '-1 day' |
BSD 需 -v-1d |
让上层传时间戳进来 |
grep -P |
BSD grep 无 | -E |
判据一条:shebang 写 bash 就得确认没人用 sh script.sh 调它(那会忽略 shebang)。
10. 调试手段与噪声控制
| 手段 | 用处 | 代价 |
|---|---|---|
bash -n |
只做语法检查,不执行 | 查不出逻辑错 |
set -x + PS4 |
看展开后的真实命令 | 噪声大,泄露变量值 |
| 分段执行 | 注释掉后半段,跑到断点 | 有副作用时不可用 |
PS4='+${BASH_SOURCE##*/}:${LINENO}: '
bash -n script.sh && bash -x script.sh 2>trace.log
11. 脚本与容器
| 场景 | 建议 | 理由 |
|---|---|---|
| 依赖一堆非默认版本工具 | 包成镜像 | 依赖可复现,不污染宿主机 |
| 要改宿主机文件或路径 | 不包 | 挂载与权限反而更复杂 |
| 定时执行 | 先看调度方 | 已有 systemd timer 就不必再套一层 |
| 多机一致执行 | 镜像 + 配置管理 | 镜像给运行时,配置管理给状态 |
| 是发布流水的一环 | 放 CI runner | 不必自带运行时 |
12. 版本管理与部署
| 问题 | 做法 | 反例 |
|---|---|---|
| 放哪 | 与它操作的代码同仓,或独立 ops 仓 | 散在个人 home 目录 |
| 怎么发版 | tag + 校验和 | scp 覆盖,无版本可回退 |
| 怎么执行 | 固定入口脚本转发 | 各处硬编码库内路径 |
readonly ROOT=/opt/ops/releases
exec "$ROOT/$(readlink "$ROOT/current")/bin/deploy" "$@"
13. 排错
| 现象 | 原因 | 处理 |
|---|---|---|
if 里命令失败但没退出 |
set -e 在条件上下文不生效 |
显式 ` |
local x=$(cmd) 失败被忽略 |
local 的返回状态覆盖了命令替换 |
先声明再赋值 |
| 管道中途失败但整体成功 | 缺 pipefail |
补上,并处理 grep 的 1、head 的 141 |
set -u 报 unbound variable |
空数组展开或未传参 | ${arr[@]+"${arr[@]}"}、${1:-} |
| 本地能跑、CI 不行 | sh/bash 差异、BSD 工具 |
固定 shebang,统一镜像 |
14. 边界
| 场景 | 更合适的做法 | 理由 |
|---|---|---|
| 一次性命令 | 直接敲,别存 | 存下来就要维护 |
| 少于 20 行且无破坏性 | 一行命令或 Makefile 目标 | 工程化的固定成本收不回 |
| 已有成熟工具 | 用工具 + 配置 | 自建脚本要跟着上游变 |
| 复杂流程编排 | 配置管理、工作流引擎 | 顺序、重试、并发、状态机不该手写 |
| 需要跨平台分发 | 换 Python 或 Go | shell 的可移植性是负资产 |
15. 自测
- 一个脚本三个月只跑过一次、且只读不写,要不要进仓库并配测试?
local out=$(cmd)在set -e下为什么不能拦住cmd的失败?- 管道
a | head -n 1里a收到 141,是 bug 还是正常信号? trap里清理目录的顺序为什么要与创建顺序相反?- 脚本被
$(...)捕获后下游报解析错误,最可能少了什么? - 什么样的脚本本来就该包成镜像,什么样的不该?
S01-A08 · Shell 脚本的工程化:可测、可复用、可回滚
https://www.clxhxhhr.top/posts/6200/ 评论
0 条
还没有评论,先写一条吧。