Claude Code 写 Shell 脚本翻车实录:一份避坑大纲

一、写在前面:为什么要记录这次翻车

本节交代写作动机:AI 编程助手在 Shell 脚本场景下看似高效,实际却容易在细节处埋雷。说明本文不是否定工具,而是把踩过的坑整理成可复用的检查清单。

  • 使用场景:日常运维脚本、批量文件处理、CI 流水线辅助脚本。
  • 预期收益:减少重复劳动、快速产出初版脚本。
  • 实际结果:初版能跑,边界场景接连出问题。

二、翻车现场总览:五个典型事故

用一张表格快速建立全局印象,让读者先看到结论,再进入细节。

编号 事故现象 根因归类 影响程度
1 文件名带空格时批量处理全部失败 变量未加引号 高
2 脚本在本地正常,在服务器上误删目录 未做路径校验与 dry-run 极高
3 管道中间步骤失败但脚本仍返回成功 缺少 set -euo pipefail 高
4 循环读取文件时最后一行丢失 read 未处理无换行结尾 中
5 定时任务里环境变量全部为空 cron 环境与交互式 shell 不一致 中

三、事故一:变量不加引号,空格成了隐形杀手

还原现场:一段遍历目录的 for 循环,在测试目录里跑得好好的,换到真实数据目录后大量报错。

3.1 出问题的写法

for f in $(ls *.log); do
    mv $f archive/$f
done

3.2 问题分析

  • 命令替换结果会按空白字符再次分词,文件名中的空格被拆成多个参数。
  • 变量未加双引号,路径中的空格和通配符会被二次解释。
  • 用 ls 解析文件名本身就是反模式。

3.3 修正方案

for f in *.log; do
    [ -e "$f" ] || continue
    mv -- "$f" "archive/$f"
done

小结:Shell 里凡是变量展开,默认加双引号;遍历文件优先用通配符或 find -print0。

四、事故二:没有 dry-run,一条 rm 命令差点酿成大祸

描述事故经过:AI 生成的清理脚本把变量拼进了 rm -rf,而该变量在异常分支下为空。

4.1 危险写法

rm -rf "$TARGET_DIR"/*

4.2 防御性改造

  • 执行前校验变量非空:[ -n "$TARGET_DIR" ] || exit 1。
  • 使用 set -u 让未定义变量直接报错。
  • 先打印将要删除的路径,确认后再执行。
  • 引入 --dry-run 参数,默认只输出不执行。

经验:任何破坏性操作都应该有「先看后做」的两段式设计。

4.3 完整的防御性清理脚本

把上面四条原则落成一个可以直接复用的脚本 safe_clean.sh。它做了四件事:开启严格模式、校验 TARGET_DIR 非空且是真实目录、解析 --dry-run 参数、删除前打印待删路径并二次确认。

#!/usr/bin/env bash
# safe_clean.sh —— 带 dry-run 与二次确认的目录清理脚本
set -euo pipefail
1. 目标目录:优先取第一个位置参数,未传则视为空
TARGET_DIR="${1:-}"
DRY_RUN=0
2. 解析参数:支持 --dry-run,其余参数视为目标目录
for arg in "$@"; do
case "$arg" in
--dry-run)
DRY_RUN=1
;;
-*)
echo "未知选项: $arg" >&2
echo "用法: $0 [--dry-run] <目标目录>" >>&2
exit 2
;;
*)
TARGET_DIR="$arg"
;;
esac
done
3. 非空校验:空值、根目录、相对路径一律拒绝
if [ -z "$TARGET_DIR" ]; then
echo "错误: TARGET_DIR 为空,拒绝执行" >&2
exit 1
fi
if [ "$TARGET_DIR" = "/" ]; then
echo "错误: 不允许清理根目录" >&2
exit 1
fi
if [ ! -d "$TARGET_DIR" ]; then
echo "错误: $TARGET_DIR 不存在或不是目录" >&2
exit 1
fi
4. 统一成绝对路径,避免后续 cd 或相对路径带来的歧义
TARGET_DIR="$(cd -- "$TARGET_DIR" && pwd)"
5. 收集待删文件,先打印再动手
mapfile -t FILES < <(find "$TARGET_DIR" -mindepth 1 -maxdepth 1 -print)
if [ "${#FILES[@]}" -eq 0 ]; then
echo "目录 $TARGET_DIR 为空,无需清理"
exit 0
fi
echo "即将清理目录: $TARGET_DIR"
echo "共 ${#FILES[@]} 项待删除:"
for f in "${FILES[@]}"; do
echo "  - $f"
done
6. dry-run 模式到此为止,只输出不执行
if [ "$DRY_RUN" -eq 1 ]; then
echo "[dry-run] 未执行任何删除操作"
exit 0
fi
7. 真实执行前二次确认
read -r -p "确认删除以上内容? 输入 yes 继续: " CONFIRM
if [ "$CONFIRM" != "yes" ]; then
echo "已取消"
exit 0
fi
for f in "${FILES[@]}"; do
rm -rf -- "$f"
done
echo "清理完成"

几个关键点值得单独说明:

  • set -euo pipefail 让未定义变量、命令失败、管道失败都能立刻中断,避免脚本带着错误状态继续往下跑。
  • TARGET_DIR="${1:-}" 配合 set -u,即使调用方漏传参数也不会因为未定义变量而崩溃,而是走到后面的非空校验分支。
  • find ... -mindepth 1 -maxdepth 1 -print 只取一层子项,且用 mapfile 读入数组,文件名含空格、换行都不会被拆开。
  • rm -rf -- "$f" 中的 -- 防止以 - 开头的文件名被当成选项。
  • 二次确认要求输入完整的 yes,而不是 y,降低误触概率。

为了更直观地看清 safe_clean.sh 的执行路径,下面用一张流程图把各个分支串起来。每个判断节点都标注了失败时的退出码与提示信息,方便对照脚本源码阅读。

flowchart TD A[启动 safe_clean.sh] --> B[参数解析: 遍历 $@] B --> B1{遇到未知选项 -x?} B1 -- 是 --> B2[输出: 未知选项 + 用法说明<br/>exit 2] B1 -- 否 --> C{--dry-run 参数?} C -- 是 --> C1[DRY_RUN=1] C -- 否 --> C2[作为 TARGET_DIR] C1 --> D{TARGET_DIR 为空?} C2 --> D D -- 是 --> D1[输出: TARGET_DIR 为空,拒绝执行<br/>exit 1] D -- 否 --> E{TARGET_DIR 等于 / ?} E -- 是 --> E1[输出: 不允许清理根目录<br/>exit 1] E -- 否 --> F{目录存在且是目录?} F -- 否 --> F1[输出: 不存在或不是目录<br/>exit 1] F -- 是 --> G[统一为绝对路径<br/>find 收集待删文件] G --> H{待删列表为空?} H -- 是 --> H1[输出: 目录为空,无需清理<br/>exit 0] H -- 否 --> I[打印目录与待删清单] I --> J{DRY_RUN == 1?} J -- 是 --> J1[输出: dry-run 未执行删除<br/>exit 0] J -- 否 --> K{二次确认输入 yes?} K -- 否 --> K1[输出: 已取消<br/>exit 0] K -- 是 --> L[逐项 rm -rf -- 删除] L --> M[输出: 清理完成<br/>exit 0]

对照流程图可以看到,脚本在真正执行 rm 之前一共设置了五道关卡:未知选项、空值、根目录、目录存在性、dry-run 与二次确认。任何一道关卡不通过,脚本都会带着明确的提示信息和非零(或安全的零)退出码提前结束,绝不会把删除动作交给一个状态不明的变量。

4.4 两种运行输出示例

先跑 dry-run,确认待删清单符合预期:

$ ./safe_clean.sh --dry-run /data/tmp
即将清理目录: /data/tmp
共 3 项待删除:
  - /data/tmp/app server.log
  - /data/tmp/cache
  - /data/tmp/old.tar.gz
[dry-run] 未执行任何删除操作

确认无误后再真实执行,此时会要求二次确认:

$ ./safe_clean.sh /data/tmp
即将清理目录: /data/tmp
共 3 项待删除:
  - /data/tmp/app server.log
  - /data/tmp/cache
  - /data/tmp/old.tar.gz
确认删除以上内容? 输入 yes 继续: yes
清理完成

如果 TARGET_DIR 因为上游变量异常而变成空值,脚本会在第一步就退出,而不是把 rm -rf /* 交给系统:

$ ./safe_clean.sh
错误: TARGET_DIR 为空,拒绝执行

经验:dry-run 不是可选项,而是破坏性脚本的默认行为。先让脚本把「要做什么」完整打印出来,人确认之后再让它「真的做」,这一步的成本只有几秒钟,却能挡住绝大多数误删事故。

五、事故三:管道失败被吞掉,脚本假装成功

说明现象:脚本退出码为 0,但下游数据实际是空的,导致后续任务连锁失败。这一节把事故完整复现一遍:先看原始脚本如何「假装成功」,再看加上 set -euo pipefail 之后如何立刻暴露问题,最后解释 pipefail 的作用机制。

5.1 根因

默认情况下,管道的退出码取最后一个命令的结果,中间环节失败不会中断脚本。也就是说,cat | grep | awk 里即使 grep 因为文件不存在而失败,只要最后的 awk 正常退出,整条管道的退出码仍然是 0。

5.2 原始脚本:中间环节失败被吞掉

下面这段脚本从访问日志里提取报错请求的路径,写入 errors.txt,然后打印完成提示。它没有开启严格模式:

#!/usr/bin/env bash
# bad_pipeline.sh —— 缺少严格模式,管道失败会被吞掉
LOG_FILE="access.log"
cat "$LOG_FILE" | grep "ERROR" | awk '{print $7}' > errors.txt
echo "done: errors.txt 已生成"

假设 access.log 因为上游采集任务失败而根本不存在,此时 cat 会报错,grep 拿不到任何输入,awk 也只会输出空内容。但脚本的退出码依然是 0。

5.3 修复版本:加上 set -euo pipefail

修复只需要在脚本开头加一行严格模式,同时顺手去掉无用的 cat,让 grep 直接读文件:

#!/usr/bin/env bash
# good_pipeline.sh —— 开启严格模式,管道任一环节失败都会中断
set -euo pipefail
LOG_FILE="access.log"
grep "ERROR" "$LOG_FILE" | awk '{print $7}' > errors.txt
echo "done: errors.txt 已生成"

两段脚本的逻辑几乎一样,差别只在开头那一行 set -euo pipefail,以及去掉了 cat。但运行结果完全不同。

5.4 两段运行输出对比

原始脚本:中间环节失败,脚本仍然返回 0,下游任务以为数据已经准备好:

$ ./bad_pipeline.sh
cat: access.log: No such file or directory
done: errors.txt 已生成
$ echo $?
0
$ wc -c errors.txt
0 errors.txt

修复后的脚本:同样的输入,脚本在管道失败处立刻中断,退出码非 0:

$ ./good_pipeline.sh
grep: access.log: No such file or directory
$ echo $?
2
$ wc -c errors.txt
0 errors.txt

对比可以看到:两次 errors.txt 都是空的,但退出码一个是 0、一个是 2。前者会让 CI 流水线、cron 任务、上游调度系统全部误判为成功,后者则能第一时间把问题暴露出来。

5.5 pipefail 的作用机制

要理解 pipefail,先要理解管道在 Shell 里是怎么执行的:

  • Shell 会为管道中的每个命令创建一个子进程,并用匿名管道把它们串起来。
  • 默认情况下,整条管道的退出码等于最后一个命令的退出码,前面命令的失败被直接丢弃。
  • 开启 set -o pipefail 后,整条管道的退出码改为:最后一个非零退出码;如果所有命令都成功,才返回 0。
  • 再配合 set -e,管道返回非零时脚本立即退出,不会继续执行后面的 echo 或写文件逻辑。

用一张表把三种情况列清楚,更容易对照:

场景 默认行为(无 pipefail) 开启 pipefail 后
cat 失败,grep、awk 正常退出 退出码 0,失败被吞掉 退出码非 0,脚本中断
grep 失败(如文件不存在),awk 正常退出 退出码 0,失败被吞掉 退出码非 0,脚本中断
所有命令都成功 退出码 0 退出码 0

还有两个容易踩的细节需要提醒:

  • grep 在「没有匹配到任何行」时返回 1,这属于正常业务结果,不是错误。如果脚本允许「无匹配」,应显式写成 grep ... || true,而不是关掉 pipefail。
  • set -e 在 if 条件、&& / || 左侧、while 条件等位置不会触发退出,这是 POSIX 规定的例外,写判断逻辑时要心里有数。

5.6 标准开头模板

把上面的经验固化成模板,新脚本一律从这几行开始:

#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'

逐项说明:

  • -e:命令返回非零时立即退出,避免带着错误状态继续执行。
  • -u:引用未定义变量时报错,防止空变量被拼进路径或命令。
  • -o pipefail:管道中任一环节失败都向上传递,解决本节的核心问题。
  • IFS=$'\n\t':把默认的「空格、制表符、换行」分隔符收窄为「换行、制表符」,避免 for 循环按空格拆分文件名。

经验:管道是 Shell 里最容易被忽视的失败点。默认行为只关心最后一个命令,而真正出问题的往往是中间环节。一行 set -euo pipefail 的成本几乎为零,却能把「假装成功」变成「立刻失败」。

六、事故四:read 循环丢最后一行

还原场景:逐行处理配置文件时,最后一行没有换行符,导致该行被静默丢弃。

while IFS= read -r line || [ -n "$line" ]; do
    echo "处理: $line"
done < config.txt

补充说明 -r 防止反斜杠被转义、IFS= 保留行首尾空白。

七、事故五:cron 里跑得好好的脚本突然失灵

还原现场:一个每天凌晨执行的日志归档脚本,在终端里手动跑一切正常,挂到 crontab 之后却「什么都没发生」——没有报错邮件,也没有生成归档文件。排查半天才发现,cron 使用的是精简环境,PATH、HOME 等变量与登录 shell 完全不同,脚本里依赖相对路径和未声明的 PATH 时就会静默失败。

7.1 出问题的写法

下面这段脚本在本地终端里跑得好好的:它用相对路径读取 ./data/app.log,并直接调用 gzip、date 等命令,完全依赖当前 shell 的 PATH 和工作目录。

#!/usr/bin/env bash
# bad_cron.sh —— 依赖相对路径与未声明的 PATH
LOG_DIR="./data"
ARCHIVE_DIR="./archive"
依赖当前工作目录,cron 下工作目录是 $HOME
cp "$LOG_DIR/app.log" "$ARCHIVE_DIR/app-$(date +%F).log"
依赖 PATH 里能找到 gzip
gzip -f "$ARCHIVE_DIR/app-$(date +%F).log"
echo "归档完成"

对应的 crontab 配置也很「朴素」,没有指定工作目录,也没有重定向任何输出:

# 每天凌晨 2 点执行
0 2 * * * /home/deploy/scripts/bad_cron.sh

问题在于:cron 执行任务时的工作目录是执行用户的家目录($HOME),PATH 通常只有 /usr/bin:/bin,./data 会被解析成 /home/deploy/data,而 gzip 如果装在 /usr/local/bin 下就根本找不到。更糟的是,cron 默认会把输出通过邮件发送,很多环境没有配置 MTA,于是错误信息被直接丢弃,看起来就像「脚本没跑」。

7.2 修复后的脚本

修复思路只有三条:显式声明 PATH、所有路径改成绝对路径、把标准输出和标准错误都重定向到日志文件。

#!/usr/bin/env bash
# good_cron.sh —— 显式声明 PATH、使用绝对路径、日志落盘
set -euo pipefail
1. 显式声明 PATH,覆盖 cron 的精简环境
export PATH="/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
2. 所有路径使用绝对路径,不依赖当前工作目录
BASE_DIR="/home/deploy"
LOG_DIR="$BASE_DIR/data"
ARCHIVE_DIR="$BASE_DIR/archive"
LOG_FILE="$BASE_DIR/logs/cron.log"
3. 确保目录存在
mkdir -p "$ARCHIVE_DIR" "$BASE_DIR/logs"
4. 关键步骤写入日志,便于事后排查
TODAY="$(date +%F)"
echo "[$(date '+%F %T')] 开始归档 $LOG_DIR/app.log" >> "$LOG_FILE"
cp "$LOG_DIR/app.log" "$ARCHIVE_DIR/app-$TODAY.log"
gzip -f "$ARCHIVE_DIR/app-$TODAY.log"
echo "[$(date '+%F %T')] 归档完成: $ARCHIVE_DIR/app-$TODAY.log.gz" >> "$LOG_FILE"

几个关键改动值得单独说明:

  • export PATH=... 把常用命令目录全部写死,不再依赖 cron 继承的环境;如果脚本调用了自定义命令,也要把它的目录加进来。
  • 所有路径都以 /home/deploy 开头,无论 cron 在哪个工作目录下执行,解析结果都一致。
  • mkdir -p 保证日志目录和归档目录存在,避免第一次运行时因为目录缺失而失败。
  • 关键节点用 echo ... >> "$LOG_FILE" 记录时间戳,出问题时能直接看出脚本跑到哪一步。

7.3 crontab 中重定向 stderr 到日志文件

脚本内部的日志只覆盖了「预期内」的输出,而命令本身报错(比如 cp 找不到源文件)是写到标准错误上的。最稳妥的做法是在 crontab 里把 stdout 和 stderr 一起重定向到日志文件:

# 每天凌晨 2 点执行,stdout 与 stderr 全部追加到 cron.log
0 2 * * * /home/deploy/scripts/good_cron.sh >> /home/deploy/logs/cron.log 2>&1

这里有两个细节:

  • >> 用追加而不是 > 覆盖,避免每次执行都把历史日志清空。
  • 2>&1 必须写在 >> 之后,顺序反了会导致 stderr 仍然指向原来的终端(在 cron 下就是被丢弃)。

如果希望 cron 环境变量也统一,可以在 crontab 顶部直接声明:

SHELL=/bin/bash
PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin
HOME=/home/deploy
0 2 * * * /home/deploy/scripts/good_cron.sh >> /home/deploy/logs/cron.log 2>&1

7.4 排查用的运行输出对比

错误脚本在 cron 下的表现:手动执行时一切正常,挂到 cron 后日志文件里只有一行 cp: cannot stat './data/app.log': No such file or directory,甚至因为没配 MTA 连这行都看不到:

$ crontab -l
0 2 * * * /home/deploy/scripts/bad_cron.sh
手动模拟 cron 的精简环境
$ env -i HOME=/home/deploy PATH=/usr/bin:/bin /home/deploy/scripts/bad_cron.sh
cp: cannot stat './data/app.log': No such file or directory
gzip: ./archive/app-2026-11-01.log: No such file or directory
归档完成
$ echo $?
0

注意最后一行:脚本明明失败了,退出码却是 0,因为 cp 和 gzip 的失败没有被严格模式捕获,最后的 echo 又把退出码覆盖成了 0。调度系统看到 0,就认为任务成功。

修复后的脚本在同样的精简环境下:路径正确、命令能找到,日志文件里留下完整的时间线:

$ env -i HOME=/home/deploy PATH=/usr/bin:/bin /home/deploy/scripts/good_cron.sh
$ echo $?
0
$ cat /home/deploy/logs/cron.log
[2026-11-01 02:00:01] 开始归档 /home/deploy/data/app.log
[2026-11-01 02:00:01] 归档完成: /home/deploy/archive/app-2026-11-01.log.gz

如果源文件真的不存在,严格模式会让脚本立刻以非零退出码结束,并且错误信息通过 crontab 的 2>&1 落到同一个日志文件里:

$ env -i HOME=/home/deploy PATH=/usr/bin:/bin /home/deploy/scripts/good_cron.sh
$ echo $?
1
$ tail -n 3 /home/deploy/logs/cron.log
[2026-11-01 02:00:01] 开始归档 /home/deploy/data/app.log
cp: cannot stat '/home/deploy/data/app.log': No such file or directory

对比可以看到:错误脚本在 cron 下「静默成功」,退出码 0、日志为空;修复后的脚本要么留下完整的成功时间线,要么带着明确的错误信息和非零退出码结束。前者需要人肉猜测,后者打开日志就能定位问题。

经验:cron 不是「把命令丢进去就行」的调度器,它运行在一个几乎空白的 shell 环境里。写定时任务脚本时,把 PATH 写死、把路径写成绝对路径、把输出重定向到日志文件,这三件事的成本只有几分钟,却能省掉一整个通宵的排查。

八、复盘:AI 生成 Shell 脚本的通用检查清单

  1. 是否设置了 set -euo pipefail。
  2. 所有变量展开是否加了双引号。
  3. 破坏性命令是否有 dry-run 与路径校验。
  4. 是否处理了文件名含空格、换行、特殊字符的情况。
  5. 是否在目标环境(而非本地)实际验证过。
  6. 是否有日志与退出码检查。

为了把前文五个事故与 9.1-9.5 的检查手段串起来,下面给出一张速查表:左边是事故现场,中间是 ShellCheck 能拦下的告警,右边是修复后的写法与对应的 Bats 测试用例。日常排查时可以直接按行对照。

事故编号 错误写法关键片段 ShellCheck 告警编号 修复后写法关键片段 对应 Bats 测试用例名
事故一 for f in $(ls *.log); do mv $f archive/$f; done SC2045、SC2086 for f in *.log; do mv -- "$f" "archive/$f"; done 文件名含空格时也能正确归档
事故二 rm -rf "$TARGET_DIR"/* SC2115(rm -rf 路径未做非空校验)、SC2086 `[ -n "$TARGET_DIR" ]
事故三 `cat access.log grep "ERROR" awk '{print $7}' > errors.txt` SC2002、SC2312
事故四 while read line; do ...; done < config.txt SC2162(read 未加 -r) `while IFS= read -r line
事故五 脚本内依赖相对路径与未声明的 PATH SC2154(引用未定义变量)、SC2164(cd 未检查返回值) 脚本内使用绝对路径、显式声明 PATH、日志重定向到文件 精简环境下脚本仍能定位命令并输出日志

使用建议:先用 ShellCheck 把表中「告警编号」一列清零,再用 Bats 把「测试用例名」一列跑通。两列都通过,才说明这段 AI 生成的脚本既写得对,也跑得对。

九、总结与延伸

总结核心观点:AI 能显著提升脚本初稿的产出速度,但 Shell 的隐式规则多、破坏性强,人工审查不可省略。建议把上述清单固化为团队脚本模板与 Code Review 检查项。

延伸方向:ShellCheck 静态检查、Bats 单元测试、把复杂逻辑迁移到 Python 或 Go 的取舍讨论。其中 ShellCheck 成本最低、收益最直接,下面单独展开成一个可操作小节。

9.1 用 ShellCheck 把事故挡在运行之前

ShellCheck 是一个开源的 Shell 脚本静态分析工具,能在脚本执行前发现引号、分词、管道退出码等常见问题。本文事故一和事故三的写法,恰好都是它的典型命中场景。

安装命令(按环境任选其一):

# Debian / Ubuntu
sudo apt-get install -y shellcheck
RHEL / CentOS / Fedora
sudo dnf install -y shellcheck
macOS
brew install shellcheck
无 root 权限时,直接下载官方二进制
VERSION=v0.10.0
curl -fsSL "https://github.com/koalaman/shellcheck/releases/download/${VERSION}/shellcheck-${VERSION}.linux.x86_64.tar.xz" 
| tar -xJ
sudo install "shellcheck-${VERSION}/shellcheck" /usr/local/bin/shellcheck
验证
shellcheck --version

日常使用只需把脚本路径传进去,CI 中可用 shellcheck -S warning script.sh 控制告警级别,或用 shellcheck -f gcc 输出便于机器解析的格式。

9.2 对事故一的脚本运行 ShellCheck

把事故一的原始写法保存为 bad1.sh:

for f in $(ls *.log); do
    mv $f archive/$f
done

执行 shellcheck bad1.sh,典型输出如下:

In bad1.sh line 1:
for f in $(ls *.log); do
         ^----------^ SC2045 (warning): Iterating over ls output is fragile. Use globs.
In bad1.sh line 2:
mv $f archive/$f
^-- SC2086 (info): Double quote to prevent globbing and word splitting.
Did you mean:
mv "$f" archive/"$f"

告警解读与修复写法:

  • SC2045:提示不要用 ls 的输出做循环,因为文件名中的空格、换行会被再次分词。修复方式是改用通配符直接展开:for f in *.log。
  • SC2086:提示变量展开未加双引号,会触发通配符展开和单词拆分。修复方式是给每个变量加双引号,并在 mv 后加 -- 防止以 - 开头的文件名被当成选项。

修复后的脚本再跑一次 ShellCheck,应当没有任何输出:

for f in *.log; do
    [ -e "$f" ] || continue
    mv -- "$f" "archive/$f"
done

9.3 对事故三的脚本运行 ShellCheck

事故三的核心问题是管道中间环节失败被吞掉。把缺少严格模式的脚本保存为 bad3.sh:

#!/usr/bin/env bash
cat access.log | grep "ERROR" | awk '{print $7}' > errors.txt
echo "done"

执行 shellcheck bad3.sh,典型输出如下:

In bad3.sh line 2:
cat access.log | grep "ERROR" | awk '{print $7}' > errors.txt
^-- SC2002 (style): Useless cat. Consider 'cmd < file | ...' or 'cmd file | ...' instead.
In bad3.sh line 3:
echo "done"
^-- SC2312 (info): Consider invoking this command separately to avoid masking its return value (or use '|| true' to ignore).

告警解读与修复写法:

  • SC2002:cat file | cmd 属于无用 cat,既多一个进程,也掩盖了真实的数据流向。修复方式是直接让命令读文件:grep "ERROR" access.log | awk '{print $7}' > errors.txt。
  • SC2312:提示在管道或命令替换中调用命令时,其退出码可能被后续命令掩盖,导致脚本“假装成功”。修复方式是在脚本开头启用严格模式 set -euo pipefail,让管道中任一环节失败都向上传递;若确实要忽略某个命令的失败,显式写 || true。

修复后的写法:

#!/usr/bin/env bash
set -euo pipefail
grep "ERROR" access.log | awk '{print $7}' > errors.txt
echo "done"

再次运行 ShellCheck 应无告警,同时管道失败也会让脚本以非零退出码结束,不再出现“日志是空的但任务显示成功”的情况。

9.4 把 ShellCheck 接入日常流程

  • 本地提交前:用 shellcheck -S warning *.sh 做一次快速自检,warning 及以上级别必须清零。
  • CI 流水线:增加一个 lint 阶段,失败即阻断合并,避免问题脚本进入生产。
  • 编辑器集成:VS Code 安装 ShellCheck 插件后可在编写时实时提示,AI 生成的初稿也能第一时间暴露问题。
  • 例外管理:确需忽略某条告警时,用 # shellcheck disable=SC2086 精确标注并写明原因,不要全局关闭检查。

经验:ShellCheck 不能替代人工审查,但能把“引号、分词、管道退出码”这类高频低级错误变成零成本拦截,是 AI 生成脚本后最值得先跑的一步。

9.5 用 Bats 为修复后的脚本补最小回归测试

ShellCheck 只能证明脚本“看起来没问题”,它不会真的创建带空格的文件、不会真的执行 mv,也无法验证脚本在真实文件系统上的行为。事故一这类问题,本质是运行时行为错误,必须靠实际执行来兜底。Bats(Bash Automated Testing System)就是为 Shell 脚本准备的轻量测试框架,语法接近 Shell 本身,学习成本很低。

安装命令(按环境任选其一):

# Debian / Ubuntu
sudo apt-get install -y bats
macOS
brew install bats-core
从源码安装(任意 Linux 环境通用)
git clone https://github.com/bats-core/bats-core.git
cd bats-core
sudo ./install.sh /usr/local

验证安装:

bats --version

被测脚本:把事故一修复后的逻辑抽成 archive_logs.sh,便于单独测试:

#!/usr/bin/env bash
set -euo pipefail
src_dir="${1:?用法: archive_logs.sh <源目录>}"
archive_dir="$src_dir/archive"
mkdir -p "$archive_dir"
for f in "$src_dir"/*.log; do
[ -e "$f" ] || continue
mv -- "$f" "$archive_dir/"
done

测试用例:新建 test/archive_logs.bats,重点覆盖“文件名含空格”这一事故场景:

#!/usr/bin/env bats
setup() {
# 每个用例使用独立临时目录,避免相互污染
TEST_DIR="$(mktemp -d)"
SCRIPT="${BATS_TEST_DIRNAME}/../archive_logs.sh"
}
teardown() {
rm -rf "$TEST_DIR"
}
@test "文件名含空格时也能正确归档" {
touch "$TEST_DIR/app server.log"
touch "$TEST_DIR/normal.log"
run bash "$SCRIPT" "$TEST_DIR"
[ "$status" -eq 0 ]
两个文件都应被移动到 archive 目录
[ -f "$TEST_DIR/archive/app server.log" ]
[ -f "$TEST_DIR/archive/normal.log" ]
源目录不应残留 .log 文件
run bash -c "ls "$TEST_DIR"/*.log 2&gt;/dev/null"
[ "$status" -ne 0 ]
}
@test "文件名以短横线开头时不会被当成选项" {
touch "$TEST_DIR/-weird.log"
run bash "$SCRIPT" "$TEST_DIR"
[ "$status" -eq 0 ]
[ -f "$TEST_DIR/archive/-weird.log" ]
}
@test "目录中没有 .log 文件时不报错" {
run bash "$SCRIPT" "$TEST_DIR"
[ "$status" -eq 0 ]
}

本地执行:

bats test/archive_logs.bats

典型输出:

 ✓ 文件名含空格时也能正确归档
 ✓ 文件名以短横线开头时不会被当成选项
 ✓ 目录中没有 .log 文件时不报错
3 tests, 0 failures

接入 CI:在流水线中把 Bats 作为独立阶段执行,与 ShellCheck 的 lint 阶段并列:

# 安装依赖
sudo apt-get install -y shellcheck bats
静态检查
shellcheck -S warning scripts/*.sh
运行时回归测试
bats test/*.bats

GitHub Actions 中可直接写成两个 step:

- name: ShellCheck
  run: shellcheck -S warning scripts/*.sh
name: Bats
run: |
sudo apt-get install -y bats
bats test/*.bats

为什么静态检查之外还需要运行时测试:

  • 覆盖范围不同:ShellCheck 检查的是语法与常见反模式,无法判断“这个脚本在真实目录结构下是否把文件搬到了正确位置”。
  • 边界场景需要真实数据:空格、换行、以 - 开头的文件名、空目录、无换行结尾的文件,这些只有构造真实文件才能验证。
  • 防止回归:修复一次不代表永远正确,后续改动(比如把 mv 换成 rsync)可能重新引入问题,测试用例能第一时间拦住。
  • 可执行的文档:测试用例本身就是脚本行为的说明,比注释更可靠,也方便 AI 后续修改时对照验证。

经验:ShellCheck 负责“写得对不对”,Bats 负责“跑起来对不对”。两者叠加,才能把 AI 生成脚本的风险从“上线后才发现”提前到“提交前就暴露”。

posted @ 2026-11-01 00:35  anye1991  阅读(7)  评论(0)    收藏  举报