运维脚本规范:shellcheck与dry-run

为什么"能跑就行"的脚本最后都会失控

我的博客仓库里有一个 `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 行代码,却是我作为个人站长在运维上投入产出比最高的一件事。

Last modification:October 29th, 2026 at 01:26 pm

Leave a Comment