为什么"能跑就行"的脚本最后都会失控
我的博客仓库里有一个 `ops/` 目录,早年放的都是"救火脚本":查磁盘的、清缓存的、批量改文件的。写了三年之后,里面躺着 60 多个 `.sh`,风格完全不一——有的用 `basename $1`,有的用 `$(basename "$f")`;有的路径硬编码成 `/var/www/blog`,有的读环境变量。最麻烦的是没人知道哪些还能安全执行。
转折点是某次误操作:一个两年前写的 `clean_logs.sh` 里写的是 find /var/log -name "*.log" -delete,我本意是在测试机上清测试日志,结果在一个生产 shell 里 tab 补全跑了起来,把 `auth.log` 和 `syslog` 一起删了。事后复盘,问题不在于手滑,而在于这个脚本本身就允许"在任意目录以任意身份执行任意删除"。
从那以后我给运维脚本定了一套规范:必须有严格模式、必须有参数校验、必须有 dry-run、必须能被静态检查工具扫过。这套规范不复杂,一个下午就能把存量脚本改造完,收益是后面两年的运维事故里再没出现过"脚本跑错地方"。
本文讲的就是这套规范怎么落地:从 set -euo pipefail 的细节,到 shellcheck 的常见告警处置,再到一段可直接复用的脚本骨架和一次 CI 集成。全部示例在 Debian 12 + bash 5.2 下测试通过。
第一步:严格模式不是加一行就完事
几乎所有教程都会说"脚本开头加 `set -euo pipefail`",但很少有人讲清三个开关各自会漏掉什么。
set -e 的实际语义
`set -e` 的规则是:"任何未处于条件上下文中的命令返回非零就退出"。它对下面这些情况完全无效:
# 1) 命令在 if / while / || / && 里 —— 不退出
grep -q pattern file || echo "not found"
# 2) 命令被取反 —— 不退出
! grep -q pattern file
# 3) 子 shell 里的失败要显式传播
set -e
( cd /nonexistent && ls ) # 整体返回非零 → 退出,OK
( false ) || true # 被 || 保护 → 不退出
# 4) 管道默认只看最后一个命令
false | true # 返回 0,不退出!这就是要 pipefail 的原因
set -o pipefail
false | true # 返回 1 → 退出
所以 `pipefail` 不是"锦上添花",而是补上 `set -e` 最大的洞。至于 `-u`,它让引用未定义变量直接报错,能拦住 rm -rf "$DIR"/* 中 DIR 拼错导致的灾难——这是最有价值的一个开关。
即使全开了,也还有三个盲区
严格模式的盲区,是运维脚本最危险的地方:
# 盲区一:在 for 的列表里,命令替换失败不会中断
for f in $(find /data -name "*.tmp"); do rm -f "$f"; done
# find 报错时 $(...) 返回空,循环静默执行 0 次,脚本"成功"退出
# 盲区二:while read 循环里 set -e 失效(循环体是条件上下文)
while read -r line; do
process "$line" # process 失败不会退出脚本
done < input.txt
# 盲区三:函数内 set -e 不传播给调用者(除非函数本身返回值被检查)
do_thing() { false; echo "still here"; } # 直接调用时会退出,但在 $(...) 里不会
对应的修法很朴素:取命令替换的结果先落变量再判空;`while read` 里手动检查返回值;函数末尾显式 `return 0` 或让调用方检查 `$?`。这几条判断加起来不到十行,却能挡住大部分"脚本明明失败了却报成功"的问题。
第二步:一个可以直接抄的脚本骨架
我把所有运维脚本统一成下面这个结构,新脚本直接从模板复制:
#!/usr/bin/env bash
# clean_cache.sh - 清理站点缓存目录(支持 dry-run)
set -euo pipefail
IFS=$'\n\t'
readonly SELF="${0##*/}"
readonly CACHE_DIR="/var/www/blog/var/cache"
DRY_RUN=0
VERBOSE=0
log() { printf '[%s] %s\n' "$(date +%H:%M:%S)" "$*" >&2; }
die() { log "ERROR: $*"; exit 1; }
usage() {
cat <<EOF
用法: $SELF [-n] [-v] [-d 目录]
-n dry-run,只打印将删除的文件
-v 详细输出
-d 覆盖缓存目录(默认 $CACHE_DIR)
}
while getopts ":nvd:h" opt; do
case "$opt" in
n) DRY_RUN=1 ;;
v) VERBOSE=1 ;;
d) CACHE_DIR_OVERRIDE="$OPTARG" ;;
h) usage; exit 0 ;;
:) die "选项 -$OPTARG 需要参数" ;;
*) die "未知选项 -$OPTARG(用 -h 查看帮助)" ;;
esac
done
readonly TARGET="${CACHE_DIR_OVERRIDE:-$CACHE_DIR}"
# 三道防线:存在、是目录、不是根或 /var 这类危险路径
[[ -d "$TARGET" ]] || die "目录不存在: $TARGET"
[[ "$TARGET" != "/" && "$TARGET" != "/var" ]] || die "拒绝操作危险路径: $TARGET"
command -v find >/dev/null || die "缺少 find 命令"
count=0
while IFS= read -r -d '' f; do
(( VERBOSE )) && log "将删除: $f"
(( DRY_RUN )) || rm -f -- "$f" || die "删除失败: $f"
count=$((count + 1))
done < <(find "$TARGET" -type f -mtime +7 -print0)
log "处理完成,共 ${count} 个文件,dry-run=${DRY_RUN}"
这段骨架里有几个刻意的设计,值得逐条说明。
为什么用 find -print0 配合 read -d ''
文件名里可能有空格、换行甚至反引号。用 `for f in $(find ...)` 会按空白切分,一个带空格的文件会被拆成两个参数,`rm` 就会删错东西。`-print0` + `read -r -d ''` 是唯一能正确处理任意文件名的组合。这也是 shellcheck 报 SC2044 的场景。
为什么 rm 用 --
如果某个文件名以 `-` 开头(比如 `-rf`),`rm -f "-rf"` 会被当作选项解析。`rm -f -- "$f"` 里的 `--` 表示"后面的都不当选项",这是必须养成的习惯。同理 `mkdir --`、`chown --` 也都支持。
为什么判空、判根、判类型都要写
如果 `TARGET` 因为某种原因变成空字符串,`rm -f -- /` 这种写法就极其危险(虽然有空字符串保护,但 `find ""` 的行为随版本不同)。加上 `readonly` 和目录存在性检查,等于给"变量拼错"这个最常见的失误装了三道保险。我在真实事故里见过 `$CACHE_DIR` 被误写成 `$CACHE_DIR/` 而在未定义时变成 "/" 的案例。
第三步:用 shellcheck 把问题在提交前找出来
shellcheck 是 shell 脚本的"编译器警告",能抓出引号、变量、逻辑上的大多数坑。安装很简单:
apt-get install -y shellcheck
shellcheck --version
# 单文件检查
shellcheck -x ops/clean_cache.sh
# 整个目录
find ops -name "*.sh" -print0 | xargs -0 -n1 shellcheck
最常见的高频告警与处置
# SC2086: 未加引号的变量展开(最常见,也最危险)
rm -rf $DIR/* # BAD
rm -rf -- "$DIR"/* # GOOD
# SC2046: 命令替换未加引号
cp $(find . -name "*.conf") /tmp # BAD,文件名带空格就崩
mapfile -t files < <(find . -name "*.conf" -print0 | xargs -0 -n1 echo)
# SC2002: 无用的 cat
cat file | grep foo # BAD
grep foo file # GOOD
# SC2181: 用 $? 判断应改为直接判断
if [ $? -ne 0 ]; then ... fi # BAD
if ! command; then ... fi # GOOD
# SC2164: cd 未判失败
cd /some/dir # BAD
cd /some/dir || exit 1 # GOOD
处置原则:能改代码就不要加 `# shellcheck disable=`。禁用注释一旦多了,检查就形同虚设。确实需要保留(比如故意做了 word splitting)的地方,在注释里写清原因,方便后来人判断。
把 shellcheck 放进 CI
我用的是最简单的 GitHub Actions 方案,成本几乎为零:
# .github/workflows/shellcheck.yml
name: shellcheck
on: [push, pull_request]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install shellcheck
run: sudo apt-get update && sudo apt-get install -y shellcheck
- name: Lint ops scripts
run: |
find ops -name '*.sh' -print0 \
| xargs -0 -n1 shellcheck -x -S warning
用 `-S warning` 表示只有 warning 及以上级别才让构建失败,info 级(如 SC2016 单引号里的变量)可以作为提示。这样既不会因为风格问题天天红,又能拦住真正会出事的问题。如果不想依赖 GitHub,本地用 pre-commit 钩子也行,见本文最后一节的落地建议。
第四步:dry-run 与幂等,让脚本可以放心重跑
运维脚本的第二个事故源头是"重跑"。一个不幂等的脚本,第一次跑正常,第二次就可能把数据搞坏。判据是:同一个脚本连续跑两次,第二次不应该产生新变化。
# 不幂等:每次都会追加
echo "$(date) start" >> /var/log/maintenance.log
# 幂等:先检查再动作
if ! grep -q "^$(date -d yesterday +%Y-%m-%d) rotated$" /var/log/maintenance.log; then
echo "$(date -d yesterday +%Y-%m-%d) rotated" >> /var/log/maintenance.log
fi
# 更稳的做法:把状态放在文件系统里
mkdir -p /var/lib/myscript
[[ -f /var/lib/myscript/step1.done ]] || { do_step1; touch /var/lib/myscript/step1.done; }
dry-run 的实现我已经写进骨架(`-n` 参数只是跳过 `rm`),但更彻底的做法是"默认 dry-run,显式加 `--yes` 才执行"。对于删除类脚本,我强烈推荐这个反直觉的默认值:危险操作必须显式确认,而不是靠记住加参数。
另一个实用技巧是给脚本加 `--max-delete` 上限:
MAX_DELETE="${MAX_DELETE:-500}"
(( count > MAX_DELETE )) && die "命中 ${count} 项,超过上限 ${MAX_DELETE},疑似路径错误,请检查后加 -f 覆盖"
这个上限救过我一次:某次 `-mtime +7` 错写成 `-mtime -7`,本来只想清 20 个旧文件,结果命中 3800 个新文件,上限直接拦住了。
第五步:日志、锁与退出码
脚本跑在 cron 里时没有人看着,所以"脚本自己会说话"很重要。三条约定:
# 1) 有意义的退出码:0 成功、1 参数错误、2 环境问题、3 部分失败
# 2) 单实例锁,防止 cron 重叠执行
exec 9>/var/lock/clean_cache.lock
flock -n 9 || { echo "已有实例在运行" >&2; exit 0; }
# 3) 日志带级别和时间,便于 grep
exec >>/var/log/ops/clean_cache.log 2>&1
echo "== $(date -Is) start =="
关于锁,`flock -n` 失败时返回 0 退出还是非零,取决于业务语义:如果是"另一个实例正在做同样的事",退出 0 不算错误(避免监控告警刷屏);如果是"任务没跑成功要人工介入",就退出非零。我一般选前者,并在日志里留一行说明。
退出码分级的好处是可以让监控系统区分处理:exit 1(参数错)不告警只记账,exit 2(环境问题)立刻告警,exit 3(部分失败)发邮件。这个约定写进 `docs/ops-conventions.md`,团队里谁写脚本都照这个来,两年下来没有因为"退出码含糊"耽误过排查。
把规范变成习惯
规范写在文档里没人看,所以我把它塞进了两个地方:一是仓库里放一份 `ops/README.md` 和 `ops/_template.sh`,新脚本从模板开始写,天然符合约定;二是在 pre-commit 钩子里跑 shellcheck 与 `bash -n`,不合格就提交不了。
存量脚本的改造不必一次做完。我的路径是:先找出被 cron 和 systemd 调用的那些(`crontab -l`、`systemctl list-timers`、`grep -r /usr/local/bin /etc/cron*`),这些是真正在跑的,优先级最高;然后按"是否含 rm / mv / dd / 重定向写入"筛出危险脚本;最后剩下的纯查询脚本可以慢慢改。
回头看,这套规范的核心其实只有一句话:脚本要对"错误输入"和"重复执行"这两个最普通的场景免疫。严格模式防错误输入,dry-run 与幂等防重复执行,shellcheck 防写的时候眼瞎。三件事加起来不到 50 行代码,却是我作为个人站长在运维上投入产出比最高的一件事。