
Shell 脚本常常从一两行命令开始,后来慢慢变成构建、发布、备份、迁移和运维入口。它的问题也在这里:看起来只是“把命令串起来”,一旦变量为空、管道中间失败、临时文件没清理,影响就会被放大。写稳 Shell 脚本,不是把 Bash 语法背全,而是给失败路径、输入边界和资源清理留出明确位置。
什么时候需要认真对待脚本
如果脚本只在本机临时跑一次,写得短一点问题不大。但下面几类脚本应当按工程代码处理:会进入 CI 的脚本、会删除或覆盖文件的脚本、会调用外部服务的脚本、会被多人复用的脚本,以及需要在不同目录或不同机器上执行的脚本。它们共同的风险是“默认成功”的假设太多。
| 风险 | 常见症状 | 稳定做法 |
|---|---|---|
| 变量为空 | rm -rf "$DIR" 中 DIR 没赋值 |
打开未定义变量检查,先验证参数 |
| 管道失败被吞 | `curl | jq` 只看到最后一步结果 |
| 临时文件残留 | 失败后留下半成品目录 | mktemp -d 配合 trap 清理 |
| 路径带空格 | 循环或拼接后文件找不到 | 始终引用变量,数组保存参数 |
| 日志太少 | CI 只显示最后一行失败 | 统一日志函数,错误信息带上下文 |
从脚本骨架开始

可靠脚本的第一步,是让默认行为更保守。下面这个骨架适合大多数 Bash 自动化入口:
1 |
|
set -e 让命令失败时尽快退出,-u 把未定义变量当成错误,pipefail 让管道中任一步失败都能反映到整体状态。-E 则让 ERR trap 在函数和子 shell 场景里更一致。它们不是银弹:有些命令失败是业务预期,例如检查文件是否存在,就要显式写进条件分支。
1 | if [[ ! -f "$config" ]]; then |
关键是把“允许失败”和“不能失败”区分开,而不是在脚本末尾才发现状态不对。
参数先校验,再执行动作
很多事故来自脚本入口太随意。参数不要边用边猜,先集中解析和校验。对复杂命令可以用 getopts,对少量参数则保持直白:
1 | usage() { |
这里的重点不是写出最短代码,而是让错误在入口处停止。脚本越靠近发布、迁移和清理任务,越应该早失败、说清楚、不要猜。
临时目录和清理要成对出现

需要中间产物时,优先用 mktemp -d 创建独立目录,再用 trap 保证退出时清理。不要手写固定路径,例如 /tmp/build,否则并发执行、权限残留和旧文件污染都会变成隐患。
1 | tmpdir="$(mktemp -d)" |
如果脚本需要保留现场排障,可以加一个开关,而不是临时注释清理逻辑:
1 | KEEP_TMP="${KEEP_TMP:-0}" |
这样 CI 默认干净,本地排障也有明确入口。
引用变量,用数组传参数
Shell 最容易误伤人的地方,是单词拆分和通配符展开。规则可以简单记:变量展开默认加双引号,多个参数用数组保存。
1 | files=("$@") |
-- 用来告诉命令“后面都是参数值,不再解析选项”,可以避免文件名以 - 开头时被误判。数组则能保留每个参数的边界,不会因为空格、换行或通配符把一个文件拆成多个片段。
给脚本留测试入口
Shell 脚本也应该能被检查。最小成本是把核心逻辑拆成函数,让主流程放在文件底部;然后在 CI 里跑语法检查、静态检查和一两个干跑场景。
1 | main() { |
常用检查可以放进 Makefile 或 CI:
1 | bash -n scripts/*.sh |
DRY_RUN 不必覆盖所有细节,但应能验证参数解析、路径拼接、日志输出和危险动作是否被拦住。
一份落地清单
给现有脚本补强,可以按这个顺序推进:先加 set -Eeuo pipefail 和统一错误日志;再把入口参数集中校验;随后把临时文件迁到 mktemp -d 并用 trap 清理;接着修正未加引号的变量和容易拆词的循环;最后把 bash -n、shellcheck 和一个干跑用例接到 CI。这样改动不大,却能覆盖 Shell 脚本最常见、也最难从日志里定位的失败模式。