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. 自测

  1. 一个脚本三个月只跑过一次、且只读不写,要不要进仓库并配测试?
  2. local out=$(cmd) 在 set -e 下为什么不能拦住 cmd 的失败?
  3. 管道 a | head -n 1 里 a 收到 141,是 bug 还是正常信号?
  4. trap 里清理目录的顺序为什么要与创建顺序相反?
  5. 脚本被 $(...) 捕获后下游报解析错误,最可能少了什么?
  6. 什么样的脚本本来就该包成镜像,什么样的不该?
S01-A08 · Shell 脚本的工程化:可测、可复用、可回滚
https://www.clxhxhhr.top/posts/6200/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。