<h2>一、写在前面:为什么要记录这次翻车</h2>
<p>本节交代写作动机:AI 编程助手在 Shell 脚本场景下看似高效,实际却容易在细节处埋雷。说明本文不是否定工具,而是把踩过的坑整理成可复用的检查清单。</p>
<ul>
<li>使用场景:日常运维脚本、批量文件处理、CI 流水线辅助脚本。</li>
<li>预期收益:减少重复劳动、快速产出初版脚本。</li>
<li>实际结果:初版能跑,边界场景接连出问题。</li>
</ul>
<h2>二、翻车现场总览:五个典型事故</h2>
<p>用一张表格快速建立全局印象,让读者先看到结论,再进入细节。</p>
<p style="text-align:center"><img src="https://upload-images.jianshu.io/upload_images/7925534-110660b5305e40cd.png" /></p>
<table>
<thead>
<tr>
<th>编号</th>
<th>事故现象</th>
<th>根因归类</th>
<th>影响程度</th>
</tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td>文件名带空格时批量处理全部失败</td>
<td>变量未加引号</td>
<td>高</td>
</tr>
<tr>
<td>2</td>
<td>脚本在本地正常,在服务器上误删目录</td>
<td>未做路径校验与 dry-run</td>
<td>极高</td>
</tr>
<tr>
<td>3</td>
<td>管道中间步骤失败但脚本仍返回成功</td>
<td>缺少 set -euo pipefail</td>
<td>高</td>
</tr>
<tr>
<td>4</td>
<td>循环读取文件时最后一行丢失</td>
<td>read 未处理无换行结尾</td>
<td>中</td>
</tr>
<tr>
<td>5</td>
<td>定时任务里环境变量全部为空</td>
<td>cron 环境与交互式 shell 不一致</td>
<td>中</td>
</tr>
</tbody>
</table>
<h2>三、事故一:变量不加引号,空格成了隐形杀手</h2>
<p>还原现场:一段遍历目录的 for 循环,在测试目录里跑得好好的,换到真实数据目录后大量报错。</p>
<h3>3.1 出问题的写法</h3>
<pre class="language-bash"><code>for f in f archive/$f
done</code></pre>
<h3>3.2 问题分析</h3>
<ul>
<li>命令替换结果会按空白字符再次分词,文件名中的空格被拆成多个参数。</li>
<li>变量未加双引号,路径中的空格和通配符会被二次解释。</li>
<li>用 <code>ls</code> 解析文件名本身就是反模式。</li>
</ul>
<h3>3.3 修正方案</h3>
<pre class="language-bash"><code>for f in *.log; do
[ -e "f" "archive/$f"
done</code></pre>
<p>小结:Shell 里凡是变量展开,默认加双引号;遍历文件优先用通配符或 <code>find -print0</code>。</p>
<h2>四、事故二:没有 dry-run,一条 rm 命令差点酿成大祸</h2>
<p>描述事故经过:AI 生成的清理脚本把变量拼进了 <code>rm -rf</code>,而该变量在异常分支下为空。</p>
<h3>4.1 危险写法</h3>
<pre class="language-bash"><code>rm -rf "$TARGET_DIR"/*</code></pre>
<h3>4.2 防御性改造</h3>
<ul>
<li>执行前校验变量非空:<code>[ -n "$TARGET_DIR" ] || exit 1</code>。</li>
<li>使用 <code>set -u</code> 让未定义变量直接报错。</li>
<li>先打印将要删除的路径,确认后再执行。</li>
<li>引入 <code>--dry-run</code> 参数,默认只输出不执行。</li>
</ul>
<blockquote>经验:任何破坏性操作都应该有「先看后做」的两段式设计。</blockquote>
<h3>4.3 完整的防御性清理脚本</h3>
<p>把上面四条原则落成一个可以直接复用的脚本 <code>safe_clean.sh</code>。它做了四件事:开启严格模式、校验 <code>TARGET_DIR</code> 非空且是真实目录、解析 <code>--dry-run</code> 参数、删除前打印待删路径并二次确认。</p>
<pre class="language-bash"><code>#!/usr/bin/env bash
safe_clean.sh —— 带 dry-run 与二次确认的目录清理脚本
set -euo pipefail
- 目标目录:优先取第一个位置参数,未传则视为空
TARGET_DIR="${1:-}"
DRY_RUN=0 - 解析参数:支持 --dry-run,其余参数视为目标目录
for arg in "arg" in
--dry-run)
DRY_RUN=1
;;
-*)
echo "未知选项:0 [--dry-run] <目标目录>" >>&2
exit 2
;;
*)
TARGET_DIR="$arg"
;;
esac
done - 非空校验:空值、根目录、相对路径一律拒绝
if [ -z "TARGET_DIR" = "/" ]; then
echo "错误: 不允许清理根目录" >&2
exit 1
fi
if [ ! -d "TARGET_DIR 不存在或不是目录" >&2
exit 1
fi - 统一成绝对路径,避免后续 cd 或相对路径带来的歧义
TARGET_DIR="TARGET_DIR" && pwd)"
- 收集待删文件,先打印再动手
mapfile -t FILES < <(find "{#FILES[@]}" -eq 0 ]; then
echo "目录TARGET_DIR"
echo "共{FILES[@]}"; do
echo " - $f"
done - dry-run 模式到此为止,只输出不执行
if [ "$DRY_RUN" -eq 1 ]; then
echo "[dry-run] 未执行任何删除操作"
exit 0
fi - 真实执行前二次确认
read -r -p "确认删除以上内容? 输入 yes 继续: " CONFIRM
if [ "{FILES[@]}"; do
rm -rf -- "$f"
done
echo "清理完成"</code></pre>
<p>几个关键点值得单独说明:</p>
<ul>
<li><code>set -euo pipefail</code> 让未定义变量、命令失败、管道失败都能立刻中断,避免脚本带着错误状态继续往下跑。</li>
<li><code>TARGET_DIR="f"</code> 中的 <code>--</code> 防止以 <code>-</code> 开头的文件名被当成选项。</li>
<li>二次确认要求输入完整的 <code>yes</code>,而不是 <code>y</code>,降低误触概率。</li>
</ul>
<p>为了更直观地看清 <code>safe_clean.sh</code> 的执行路径,下面用一张流程图把各个分支串起来。每个判断节点都标注了失败时的退出码与提示信息,方便对照脚本源码阅读。</p>
<pre class="language-mermaid"><code>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]</code></pre>
<p>对照流程图可以看到,脚本在真正执行 <code>rm</code> 之前一共设置了五道关卡:未知选项、空值、根目录、目录存在性、dry-run 与二次确认。任何一道关卡不通过,脚本都会带着明确的提示信息和非零(或安全的零)退出码提前结束,绝不会把删除动作交给一个状态不明的变量。</p>
<h3>4.4 两种运行输出示例</h3>
<p><strong>先跑 dry-run</strong>,确认待删清单符合预期:</p>
<pre class="language-text"><code>$ ./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] 未执行任何删除操作</code></pre>
<p><strong>确认无误后再真实执行</strong>,此时会要求二次确认:</p>
<pre class="language-text"><code>$ ./safe_clean.sh /data/tmp
即将清理目录: /data/tmp
共 3 项待删除:
- /data/tmp/app server.log
- /data/tmp/cache
- /data/tmp/old.tar.gz
确认删除以上内容? 输入 yes 继续: yes
清理完成</code></pre>
<p>如果 <code>TARGET_DIR</code> 因为上游变量异常而变成空值,脚本会在第一步就退出,而不是把 <code>rm -rf /*</code> 交给系统:</p>
<pre class="language-text"><code>$ ./safe_clean.sh
错误: TARGET_DIR 为空,拒绝执行</code></pre>
<blockquote>经验:dry-run 不是可选项,而是破坏性脚本的默认行为。先让脚本把「要做什么」完整打印出来,人确认之后再让它「真的做」,这一步的成本只有几秒钟,却能挡住绝大多数误删事故。</blockquote>
<h2>五、事故三:管道失败被吞掉,脚本假装成功</h2>
<p>说明现象:脚本退出码为 0,但下游数据实际是空的,导致后续任务连锁失败。这一节把事故完整复现一遍:先看原始脚本如何「假装成功」,再看加上 <code>set -euo pipefail</code> 之后如何立刻暴露问题,最后解释 <code>pipefail</code> 的作用机制。</p>
<h3>5.1 根因</h3>
<p>默认情况下,管道的退出码取最后一个命令的结果,中间环节失败不会中断脚本。也就是说,<code>cat | grep | awk</code> 里即使 <code>grep</code> 因为文件不存在而失败,只要最后的 <code>awk</code> 正常退出,整条管道的退出码仍然是 0。</p>
<h3>5.2 原始脚本:中间环节失败被吞掉</h3>
<p>下面这段脚本从访问日志里提取报错请求的路径,写入 <code>errors.txt</code>,然后打印完成提示。它没有开启严格模式:</p>
<pre class="language-bash"><code>#!/usr/bin/env bash
bad_pipeline.sh —— 缺少严格模式,管道失败会被吞掉
LOG_FILE="access.log"
cat "7}' > errors.txt
echo "done: errors.txt 已生成"</code></pre>
<p>假设 <code>access.log</code> 因为上游采集任务失败而根本不存在,此时 <code>cat</code> 会报错,<code>grep</code> 拿不到任何输入,<code>awk</code> 也只会输出空内容。但脚本的退出码依然是 0。</p>
<h3>5.3 修复版本:加上 set -euo pipefail</h3>
<p>修复只需要在脚本开头加一行严格模式,同时顺手去掉无用的 <code>cat</code>,让 <code>grep</code> 直接读文件:</p>
<pre class="language-bash"><code>#!/usr/bin/env bash
good_pipeline.sh —— 开启严格模式,管道任一环节失败都会中断
set -euo pipefail
LOG_FILE="access.log"
grep "ERROR" "7}' > errors.txt
echo "done: errors.txt 已生成"</code></pre>
<p>两段脚本的逻辑几乎一样,差别只在开头那一行 <code>set -euo pipefail</code>,以及去掉了 <code>cat</code>。但运行结果完全不同。</p>
<h3>5.4 两段运行输出对比</h3>
<p><strong>原始脚本</strong>:中间环节失败,脚本仍然返回 0,下游任务以为数据已经准备好:</p>
<pre class="language-text"><code> echo
wc -c errors.txt
0 errors.txt</code></pre>
<p><strong>修复后的脚本</strong>:同样的输入,脚本在管道失败处立刻中断,退出码非 0:</p>
<pre class="language-text"><code> echo
wc -c errors.txt
0 errors.txt</code></pre>
<p>对比可以看到:两次 <code>errors.txt</code> 都是空的,但退出码一个是 0、一个是 2。前者会让 CI 流水线、cron 任务、上游调度系统全部误判为成功,后者则能第一时间把问题暴露出来。</p>
<h3>5.5 pipefail 的作用机制</h3>
<p>要理解 <code>pipefail</code>,先要理解管道在 Shell 里是怎么执行的:</p>
<ul>
<li>Shell 会为管道中的每个命令创建一个子进程,并用匿名管道把它们串起来。</li>
<li>默认情况下,整条管道的退出码等于<strong>最后一个命令</strong>的退出码,前面命令的失败被直接丢弃。</li>
<li>开启 <code>set -o pipefail</code> 后,整条管道的退出码改为:<strong>最后一个非零退出码</strong>;如果所有命令都成功,才返回 0。</li>
<li>再配合 <code>set -e</code>,管道返回非零时脚本立即退出,不会继续执行后面的 <code>echo</code> 或写文件逻辑。</li>
</ul>
<p>用一张表把三种情况列清楚,更容易对照:</p>
<table>
<thead>
<tr>
<th>场景</th>
<th>默认行为(无 pipefail)</th>
<th>开启 pipefail 后</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>cat</code> 失败,<code>grep</code>、<code>awk</code> 正常退出</td>
<td>退出码 0,失败被吞掉</td>
<td>退出码非 0,脚本中断</td>
</tr>
<tr>
<td><code>grep</code> 失败(如文件不存在),<code>awk</code> 正常退出</td>
<td>退出码 0,失败被吞掉</td>
<td>退出码非 0,脚本中断</td>
</tr>
<tr>
<td>所有命令都成功</td>
<td>退出码 0</td>
<td>退出码 0</td>
</tr>
</tbody>
</table>
<p>还有两个容易踩的细节需要提醒:</p>
<ul>
<li><code>grep</code> 在「没有匹配到任何行」时返回 1,这属于正常业务结果,不是错误。如果脚本允许「无匹配」,应显式写成 <code>grep ... || true</code>,而不是关掉 <code>pipefail</code>。</li>
<li><code>set -e</code> 在 <code>if</code> 条件、<code>&&</code> / <code>||</code> 左侧、<code>while</code> 条件等位置不会触发退出,这是 POSIX 规定的例外,写判断逻辑时要心里有数。</li>
</ul>
<h3>5.6 标准开头模板</h3>
<p>把上面的经验固化成模板,新脚本一律从这几行开始:</p>
<pre class="language-bash"><code>#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'</code></pre>
<p>逐项说明:</p>
<ul>
<li><code>-e</code>:命令返回非零时立即退出,避免带着错误状态继续执行。</li>
<li><code>-u</code>:引用未定义变量时报错,防止空变量被拼进路径或命令。</li>
<li><code>-o pipefail</code>:管道中任一环节失败都向上传递,解决本节的核心问题。</li>
<li><code>IFS=$'\n\t'</code>:把默认的「空格、制表符、换行」分隔符收窄为「换行、制表符」,避免 <code>for</code> 循环按空格拆分文件名。</li>
</ul>
<blockquote>经验:管道是 Shell 里最容易被忽视的失败点。默认行为只关心最后一个命令,而真正出问题的往往是中间环节。一行 <code>set -euo pipefail</code> 的成本几乎为零,却能把「假装成功」变成「立刻失败」。</blockquote>
<h2>六、事故四:read 循环丢最后一行</h2>
<p>还原场景:逐行处理配置文件时,最后一行没有换行符,导致该行被静默丢弃。</p>
<pre class="language-bash"><code>while IFS= read -r line || [ -n "line"
done < config.txt</code></pre>
<p>补充说明 <code>-r</code> 防止反斜杠被转义、<code>IFS=</code> 保留行首尾空白。</p>
<h2>七、事故五:cron 里跑得好好的脚本突然失灵</h2>
<p>还原现场:一个每天凌晨执行的日志归档脚本,在终端里手动跑一切正常,挂到 crontab 之后却「什么都没发生」——没有报错邮件,也没有生成归档文件。排查半天才发现,cron 使用的是精简环境,<code>PATH</code>、<code>HOME</code> 等变量与登录 shell 完全不同,脚本里依赖相对路径和未声明的 <code>PATH</code> 时就会静默失败。</p>
<h3>7.1 出问题的写法</h3>
<p>下面这段脚本在本地终端里跑得好好的:它用相对路径读取 <code>./data/app.log</code>,并直接调用 <code>gzip</code>、<code>date</code> 等命令,完全依赖当前 shell 的 <code>PATH</code> 和工作目录。</p>
<pre class="language-bash"><code>#!/usr/bin/env bash
bad_cron.sh —— 依赖相对路径与未声明的 PATH
LOG_DIR="./data"
ARCHIVE_DIR="./archive"
依赖当前工作目录,cron 下工作目录是 LOG_DIR/app.log" "
(date +%F).log"
依赖 PATH 里能找到 gzip
gzip -f "(date +%F).log"
echo "归档完成"</code></pre>
<p>对应的 crontab 配置也很「朴素」,没有指定工作目录,也没有重定向任何输出:</p>
<pre class="language-bash"><code># 每天凌晨 2 点执行
0 2 * * * /home/deploy/scripts/bad_cron.sh</code></pre>
<p>问题在于:cron 执行任务时的工作目录是执行用户的家目录(<code>$HOME</code>),<code>PATH</code> 通常只有 <code>/usr/bin:/bin</code>,<code>./data</code> 会被解析成 <code>/home/deploy/data</code>,而 <code>gzip</code> 如果装在 <code>/usr/local/bin</code> 下就根本找不到。更糟的是,cron 默认会把输出通过邮件发送,很多环境没有配置 MTA,于是错误信息被直接丢弃,看起来就像「脚本没跑」。</p>
<h3>7.2 修复后的脚本</h3>
<p>修复思路只有三条:显式声明 <code>PATH</code>、所有路径改成绝对路径、把标准输出和标准错误都重定向到日志文件。</p>
<pre class="language-bash"><code>#!/usr/bin/env bash
good_cron.sh —— 显式声明 PATH、使用绝对路径、日志落盘
set -euo pipefail
- 显式声明 PATH,覆盖 cron 的精简环境
export PATH="/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin" - 所有路径使用绝对路径,不依赖当前工作目录
BASE_DIR="/home/deploy"
LOG_DIR="BASE_DIR/archive"
LOG_FILE="$BASE_DIR/logs/cron.log" - 确保目录存在
mkdir -p "BASE_DIR/logs"
- 关键步骤写入日志,便于事后排查
TODAY="(date '+%F %T')] 开始归档
LOG_FILE"
cp "ARCHIVE_DIR/app-
ARCHIVE_DIR/app-
(date '+%F %T')] 归档完成:
TODAY.log.gz" >> "$LOG_FILE"</code></pre>
<p>几个关键改动值得单独说明:</p>
<ul>
<li><code>export PATH=...</code> 把常用命令目录全部写死,不再依赖 cron 继承的环境;如果脚本调用了自定义命令,也要把它的目录加进来。</li>
<li>所有路径都以 <code>/home/deploy</code> 开头,无论 cron 在哪个工作目录下执行,解析结果都一致。</li>
<li><code>mkdir -p</code> 保证日志目录和归档目录存在,避免第一次运行时因为目录缺失而失败。</li>
<li>关键节点用 <code>echo ... >> "$LOG_FILE"</code> 记录时间戳,出问题时能直接看出脚本跑到哪一步。</li>
</ul>
<h3>7.3 crontab 中重定向 stderr 到日志文件</h3>
<p>脚本内部的日志只覆盖了「预期内」的输出,而命令本身报错(比如 <code>cp</code> 找不到源文件)是写到标准错误上的。最稳妥的做法是在 crontab 里把 <code>stdout</code> 和 <code>stderr</code> 一起重定向到日志文件:</p>
<pre class="language-bash"><code># 每天凌晨 2 点执行,stdout 与 stderr 全部追加到 cron.log
0 2 * * * /home/deploy/scripts/good_cron.sh >> /home/deploy/logs/cron.log 2>&1</code></pre>
<p>这里有两个细节:</p>
<ul>
<li><code>>></code> 用追加而不是 <code>></code> 覆盖,避免每次执行都把历史日志清空。</li>
<li><code>2>&1</code> 必须写在 <code>>></code> 之后,顺序反了会导致 <code>stderr</code> 仍然指向原来的终端(在 cron 下就是被丢弃)。</li>
</ul>
<p>如果希望 cron 环境变量也统一,可以在 crontab 顶部直接声明:</p>
<pre class="language-bash"><code>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</code></pre>
<h3>7.4 排查用的运行输出对比</h3>
<p><strong>错误脚本在 cron 下的表现</strong>:手动执行时一切正常,挂到 cron 后日志文件里只有一行 <code>cp: cannot stat './data/app.log': No such file or directory</code>,甚至因为没配 MTA 连这行都看不到:</p>
<pre class="language-text"><code> 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
归档完成
?
0</code></pre>
<p>注意最后一行:脚本明明失败了,退出码却是 0,因为 <code>cp</code> 和 <code>gzip</code> 的失败没有被严格模式捕获,最后的 <code>echo</code> 又把退出码覆盖成了 0。调度系统看到 0,就认为任务成功。</p>
<p><strong>修复后的脚本在同样的精简环境下</strong>:路径正确、命令能找到,日志文件里留下完整的时间线:</p>
<pre class="language-text"><code> echo
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</code></pre>
<p>如果源文件真的不存在,严格模式会让脚本立刻以非零退出码结束,并且错误信息通过 crontab 的 <code>2>&1</code> 落到同一个日志文件里:</p>
<pre class="language-text"><code> echo
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</code></pre>
<p>对比可以看到:错误脚本在 cron 下「静默成功」,退出码 0、日志为空;修复后的脚本要么留下完整的成功时间线,要么带着明确的错误信息和非零退出码结束。前者需要人肉猜测,后者打开日志就能定位问题。</p>
<blockquote>经验:cron 不是「把命令丢进去就行」的调度器,它运行在一个几乎空白的 shell 环境里。写定时任务脚本时,把 <code>PATH</code> 写死、把路径写成绝对路径、把输出重定向到日志文件,这三件事的成本只有几分钟,却能省掉一整个通宵的排查。</blockquote>
<h2>八、复盘:AI 生成 Shell 脚本的通用检查清单</h2>
<ol>
<li>是否设置了 <code>set -euo pipefail</code>。</li>
<li>所有变量展开是否加了双引号。</li>
<li>破坏性命令是否有 dry-run 与路径校验。</li>
<li>是否处理了文件名含空格、换行、特殊字符的情况。</li>
<li>是否在目标环境(而非本地)实际验证过。</li>
<li>是否有日志与退出码检查。</li>
</ol>
<p>为了把前文五个事故与 9.1-9.5 的检查手段串起来,下面给出一张速查表:左边是事故现场,中间是 ShellCheck 能拦下的告警,右边是修复后的写法与对应的 Bats 测试用例。日常排查时可以直接按行对照。</p>
<table>
<thead>
<tr>
<th>事故编号</th>
<th>错误写法关键片段</th>
<th>ShellCheck 告警编号</th>
<th>修复后写法关键片段</th>
<th>对应 Bats 测试用例名</th>
</tr>
</thead>
<tbody>
<tr>
<td>事故一</td>
<td><code>for f in f archive/
f" "archive/
TARGET_DIR"/*</code></td>
<td>SC2115(<code>rm -rf</code> 路径未做非空校验)、SC2086</td>
<td><code>[ -n "7}' > errors.txt</code></td>
<td>SC2002、SC2312</td>
<td><code>set -euo pipefail</code> + <code>grep "ERROR" access.log | awk '{print line" ]; do ...; done < config.txt</code></td>
<td>最后一行无换行符时仍能被处理</td>
</tr>
<tr>
<td>事故五</td>
<td>脚本内依赖相对路径与未声明的 <code>PATH</code></td>
<td>SC2154(引用未定义变量)、SC2164(<code>cd</code> 未检查返回值)</td>
<td>脚本内使用绝对路径、显式声明 <code>PATH</code>、日志重定向到文件</td>
<td>精简环境下脚本仍能定位命令并输出日志</td>
</tr>
</tbody>
</table>
<p>使用建议:先用 ShellCheck 把表中「告警编号」一列清零,再用 Bats 把「测试用例名」一列跑通。两列都通过,才说明这段 AI 生成的脚本既写得对,也跑得对。</p>
<h2>九、总结与延伸</h2>
<p>总结核心观点:AI 能显著提升脚本初稿的产出速度,但 Shell 的隐式规则多、破坏性强,人工审查不可省略。建议把上述清单固化为团队脚本模板与 Code Review 检查项。</p>
<p>延伸方向:ShellCheck 静态检查、Bats 单元测试、把复杂逻辑迁移到 Python 或 Go 的取舍讨论。其中 ShellCheck 成本最低、收益最直接,下面单独展开成一个可操作小节。</p>
<h3>9.1 用 ShellCheck 把事故挡在运行之前</h3>
<p>ShellCheck 是一个开源的 Shell 脚本静态分析工具,能在脚本执行前发现引号、分词、管道退出码等常见问题。本文事故一和事故三的写法,恰好都是它的典型命中场景。</p>
<p><strong>安装命令</strong>(按环境任选其一):</p>
<pre class="language-bash"><code># 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}.linux.x86_64.tar.xz"
| tar -xJ
sudo install "shellcheck-${VERSION}/shellcheck" /usr/local/bin/shellcheck
验证
shellcheck --version</code></pre>
<p>日常使用只需把脚本路径传进去,CI 中可用 <code>shellcheck -S warning script.sh</code> 控制告警级别,或用 <code>shellcheck -f gcc</code> 输出便于机器解析的格式。</p>
<h3>9.2 对事故一的脚本运行 ShellCheck</h3>
<p>把事故一的原始写法保存为 <code>bad1.sh</code>:</p>
<pre class="language-bash"><code>for f in f archive/$f
done</code></pre>
<p>执行 <code>shellcheck bad1.sh</code>,典型输出如下:</p>
<pre class="language-text"><code>In bad1.sh line 1:
for f in f archive/
f" archive/"$f"</code></pre>
<p><strong>告警解读与修复写法</strong>:</p>
<ul>
<li><strong>SC2045</strong>:提示不要用 <code>ls</code> 的输出做循环,因为文件名中的空格、换行会被再次分词。修复方式是改用通配符直接展开:<code>for f in *.log</code>。</li>
<li><strong>SC2086</strong>:提示变量展开未加双引号,会触发通配符展开和单词拆分。修复方式是给每个变量加双引号,并在 <code>mv</code> 后加 <code>--</code> 防止以 <code>-</code> 开头的文件名被当成选项。</li>
</ul>
<p>修复后的脚本再跑一次 ShellCheck,应当没有任何输出:</p>
<pre class="language-bash"><code>for f in *.log; do
[ -e "f" "archive/$f"
done</code></pre>
<h3>9.3 对事故三的脚本运行 ShellCheck</h3>
<p>事故三的核心问题是管道中间环节失败被吞掉。把缺少严格模式的脚本保存为 <code>bad3.sh</code>:</p>
<pre class="language-bash"><code>#!/usr/bin/env bash
cat access.log | grep "ERROR" | awk '{print $7}' > errors.txt
echo "done"</code></pre>
<p>执行 <code>shellcheck bad3.sh</code>,典型输出如下:</p>
<pre class="language-text"><code>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).</code></pre>
<p><strong>告警解读与修复写法</strong>:</p>
<ul>
<li><strong>SC2002</strong>:<code>cat file | cmd</code> 属于无用 cat,既多一个进程,也掩盖了真实的数据流向。修复方式是直接让命令读文件:<code>grep "ERROR" access.log | awk '{print $7}' > errors.txt</code>。</li>
<li><strong>SC2312</strong>:提示在管道或命令替换中调用命令时,其退出码可能被后续命令掩盖,导致脚本“假装成功”。修复方式是在脚本开头启用严格模式 <code>set -euo pipefail</code>,让管道中任一环节失败都向上传递;若确实要忽略某个命令的失败,显式写 <code>|| true</code>。</li>
</ul>
<p>修复后的写法:</p>
<pre class="language-bash"><code>#!/usr/bin/env bash
set -euo pipefail
grep "ERROR" access.log | awk '{print $7}' > errors.txt
echo "done"</code></pre>
<p>再次运行 ShellCheck 应无告警,同时管道失败也会让脚本以非零退出码结束,不再出现“日志是空的但任务显示成功”的情况。</p>
<h3>9.4 把 ShellCheck 接入日常流程</h3>
<ul>
<li><strong>本地提交前</strong>:用 <code>shellcheck -S warning *.sh</code> 做一次快速自检,warning 及以上级别必须清零。</li>
<li><strong>CI 流水线</strong>:增加一个 lint 阶段,失败即阻断合并,避免问题脚本进入生产。</li>
<li><strong>编辑器集成</strong>:VS Code 安装 ShellCheck 插件后可在编写时实时提示,AI 生成的初稿也能第一时间暴露问题。</li>
<li><strong>例外管理</strong>:确需忽略某条告警时,用 <code># shellcheck disable=SC2086</code> 精确标注并写明原因,不要全局关闭检查。</li>
</ul>
<blockquote>经验:ShellCheck 不能替代人工审查,但能把“引号、分词、管道退出码”这类高频低级错误变成零成本拦截,是 AI 生成脚本后最值得先跑的一步。</blockquote>
<h3>9.5 用 Bats 为修复后的脚本补最小回归测试</h3>
<p>ShellCheck 只能证明脚本“看起来没问题”,它不会真的创建带空格的文件、不会真的执行 <code>mv</code>,也无法验证脚本在真实文件系统上的行为。事故一这类问题,本质是运行时行为错误,必须靠实际执行来兜底。Bats(Bash Automated Testing System)就是为 Shell 脚本准备的轻量测试框架,语法接近 Shell 本身,学习成本很低。</p>
<p><strong>安装命令</strong>(按环境任选其一):</p>
<pre class="language-bash"><code># 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</code></pre>
<p>验证安装:</p>
<pre class="language-bash"><code>bats --version</code></pre>
<p><strong>被测脚本</strong>:把事故一修复后的逻辑抽成 <code>archive_logs.sh</code>,便于单独测试:</p>
<pre class="language-bash"><code>#!/usr/bin/env bash
set -euo pipefail
src_dir="src_dir/archive"
mkdir -p "src_dir"/*.log; do
[ -e "f" "$archive_dir/"
done</code></pre>
<p><strong>测试用例</strong>:新建 <code>test/archive_logs.bats</code>,重点覆盖“文件名含空格”这一事故场景:</p>
<pre class="language-bash"><code>#!/usr/bin/env bats
setup() {
每个用例使用独立临时目录,避免相互污染
TEST_DIR="{BATS_TEST_DIRNAME}/../archive_logs.sh"
}
teardown() {
rm -rf "TEST_DIR/app server.log"
touch "SCRIPT" "
status" -eq 0 ]
两个文件都应被移动到 archive 目录
[ -f "TEST_DIR/archive/normal.log" ]
源目录不应残留 .log 文件
run bash -c "ls "status" -ne 0 ]
}
@test "文件名以短横线开头时不会被当成选项" {
touch "SCRIPT" "
status" -eq 0 ]
[ -f "SCRIPT" "
status" -eq 0 ]
}</code></pre>
<p>本地执行:</p>
<pre class="language-bash"><code>bats test/archive_logs.bats</code></pre>
<p>典型输出:</p>
<pre class="language-text"><code> ✓ 文件名含空格时也能正确归档
✓ 文件名以短横线开头时不会被当成选项
✓ 目录中没有 .log 文件时不报错
3 tests, 0 failures</code></pre>
<p><strong>接入 CI</strong>:在流水线中把 Bats 作为独立阶段执行,与 ShellCheck 的 lint 阶段并列:</p>
<pre class="language-bash"><code># 安装依赖
sudo apt-get install -y shellcheck bats
静态检查
shellcheck -S warning scripts/.sh
运行时回归测试
bats test/.bats</code></pre>
<p>GitHub Actions 中可直接写成两个 step:</p>
<pre class="language-yaml"><code>- name: ShellCheck
run: shellcheck -S warning scripts/.sh
name: Bats
run: |
sudo apt-get install -y bats
bats test/.bats</code></pre>
<p><strong>为什么静态检查之外还需要运行时测试</strong>:</p>
<ul>
<li><strong>覆盖范围不同</strong>:ShellCheck 检查的是语法与常见反模式,无法判断“这个脚本在真实目录结构下是否把文件搬到了正确位置”。</li>
<li><strong>边界场景需要真实数据</strong>:空格、换行、以 <code>-</code> 开头的文件名、空目录、无换行结尾的文件,这些只有构造真实文件才能验证。</li>
<li><strong>防止回归</strong>:修复一次不代表永远正确,后续改动(比如把 <code>mv</code> 换成 <code>rsync</code>)可能重新引入问题,测试用例能第一时间拦住。</li>
<li><strong>可执行的文档</strong>:测试用例本身就是脚本行为的说明,比注释更可靠,也方便 AI 后续修改时对照验证。</li>
</ul>
<blockquote>经验:ShellCheck 负责“写得对不对”,Bats 负责“跑起来对不对”。两者叠加,才能把 AI 生成脚本的风险从“上线后才发现”提前到“提交前就暴露”。</blockquote>