脚本和应用程序的一个关键区别是:脚本通常直接操作真实环境——删文件、改配置、调接口、重启服务。它没有编译期检查,没有类型系统兜底,一个拼写错误就是一次真实的生产变更。所以对脚本的要求不是”能跑通”,而是”跑错了会立刻停、能看出错在哪、重跑不会更糟”。
把几件事约定好,脚本的可靠性会有量级上的差别:
set -euo pipefail 的四件事与副作用
set -euo pipefail
IFS=$'nt'
-e:任意命令返回非零就退出脚本。副作用是它会让一些”预期内的非零返回”变成致命错误。-u:引用未定义变量视为错误。这条能挡住最常见的 typo 和”环境变量没传进来”。-o pipefail:管道的退出码取最后一个非零的命令,而不是只取最后一个命令。IFS=$'nt':默认把空格从分隔符里去掉,避免带空格的路径被拆开。
-e 有几个必须知道的坑:
# 1. 条件判断上下文里 -e 失效,这是设计如此,不是 bug
if grep -q "pattern" file; then # grep 找不到返回 1,但不会终止脚本
echo "found"
fi
# 2. 命令替换赋值给 local 时会吞掉退出码
local out
out=$(might_fail) # 正确:先声明再赋值,退出码是命令的退出码
# local out=$(might_fail) 错误:退出码是 local 的,永远是 0
# 3. 需要允许失败时显式声明
rm -f "$tmp" || true
# 4. 取反或用 || 连接的命令处于"被检查"状态
[[ -f "$f" ]] || die "missing file"
# 5. 条件上下文会临时关掉 -e,包括函数内部
if some_function; then ... # 函数里的 -e 在 if 判断期间失效
# 规则:不要把有副作用的命令放进条件上下文
还有一条通用纪律:所有变量引用都加双引号。rm -rf $DIR/* 在 DIR 未设置时会变成 rm -rf /*,而 -u 能挡住它;但变量含空格时,不加引号同样会被拆成多个参数。
pipefail 之前,cmd | grep foo | wc -l 这类管道里 cmd 失败会被完全忽略,因为退出码只看 wc。加上之后才会暴露。反过来,如果确实要容忍管道中某一段失败(比如 head -1 提前关闭管道导致上游收到 SIGPIPE),需要显式处理退出码。
trap:清理与错误回溯
trap 用来在退出或出错时执行动作,两件事最值得做:清理临时资源,打印出错位置。
cleanup() {
local rc=$?
[[ -n "${WORKDIR:-}" && -d "$WORKDIR" ]] && rm -rf "$WORKDIR"
return "$rc" # 保留原始退出码
}
trap cleanup EXIT
on_error() {
local rc=$?
echo "ERROR: line $1 failed with exit code $rc" >&2
exit "$rc"
}
trap 'on_error $LINENO' ERR
ERR trap 配合 set -e 使用,能把”脚本在第几行挂了”直接告诉你。没有它,失败时只有一个退出码,排查时只能靠二分加 echo。
另一个实用技巧是打印失败命令本身:
trap 'echo "failed: ${BASH_COMMAND} (line $LINENO)" >&2' ERR
除了 EXIT 和 ERR,长时间运行的脚本还应该捕获 INT 和 TERM:被 Ctrl-C 或被调度系统终止时,清理逻辑同样要执行,否则临时文件、锁、半写入的目录会留在原地。
EXIT trap 里必须保留原始退出码(local rc=$? 然后 return "$rc"),否则脚本失败也会被清理函数覆盖成成功退出,CI 会误判为通过。
临时文件
WORKDIR=$(mktemp -d "${TMPDIR:-/tmp}/deploy.XXXXXX")
mktemp 解决两个问题:名字冲突(可预测的文件名是竞争条件和安全隐患),以及权限(默认 0700)。临时目录统一创建在一个地方,由 EXIT trap 统一删除,脚本里就不要再散落 rm -f $tmpfile。
不要把临时文件放在脚本所在目录或当前工作目录——脚本可能没有写权限,也可能污染用户的仓库。
日志函数
日志要带时间戳、带级别、输出到 stderr。输出到 stderr 的原因是:脚本的 stdout 常常被当作数据通道(调用方 $(script.sh) 取结果),混杂日志会污染数据。
log() {
printf '[%s] [%s] %sn' "$(date '+%Y-%m-%d %H:%M:%S')" "$1" "${*:2}" >&2
}
log_info() { log INFO "$@"; }
log_warn() { log WARN "$@"; }
log_error() { log ERROR "$@"; }
die() { log_error "$@"; exit 1; }
用 printf 而不是 echo,因为 echo 对 -n、-e 以及反斜杠的处理在不同 shell 下不一致。
级别不必复杂,三档够用:INFO 记录关键步骤,WARN 记录可继续的异常,ERROR 记录终止原因。需要落盘时把 stderr 重定向到文件,带上 PID 便于区分并发执行:exec 2>>"$LOG_FILE"。
退出码约定
调用方(CI、cron、上游脚本)只看退出码,所以退出码应该有语义,而不是一律 exit 1。一套够用的约定:
0 成功
1 通用失败
2 参数或用法错误(usage 已打印)
3 前置条件不满足(依赖缺失、环境不对)
4 已被其他实例占用(锁冲突)
把每一类写成常量或注释,避免脚本里散落着含义不明的 exit 1。锁冲突单独给一个码尤其有用:调度系统可以据此判定”这是正常的并发跳过,不需要告警”,把噪音从值班手机上拿掉。
幂等设计
脚本会被重跑:失败了重试、定时任务重叠、人手工多执行了一次、执行到一半失败后从中间继续。不幂等的脚本在这些场景下会造成重复写入、重复发送通知、重复创建资源,而这类问题往往要过很久才被发现。幂等的意思是”跑一次和跑 N 次结果一致”。
先检查后执行。创建资源前先查是否存在,写入前先比对内容。
ensure_user() {
if id "$1" >/dev/null 2>&1; then
log_info "user $1 already exists, skipping"
return 0
fi
useradd -m "$1"
}
加锁防并发。用 flock 拿文件锁,防止定时任务上一次没跑完下一次又启动:
LOCK_FILE=/var/lock/deploy.lock
exec 9>"$LOCK_FILE"
if ! flock -n 9; then
die "another instance is running"
fi
exec 9> 让文件描述符随进程生命周期存在,flock -n 拿不到锁立即失败而不是挂住。-w 30 可以改为等待 30 秒,适合需要排队的场景。
可重入的分步状态。长流程脚本可以把每一步完成情况记录到状态文件,重跑时跳过已完成步骤。这比”要么全跑要么报错”更适合部署、迁移这类操作。
优先选用天然幂等的命令。mkdir -p、rm -f、ln -sfn、kubectl apply、ALTER TABLE ... IF NOT EXISTS 这类命令重复执行不改变结果,比自己写 if 判断更可靠——判断逻辑本身可能有竞态(检查之后、执行之前状态被别人改了)。
参数解析与 usage
usage() {
cat >&2 <<EOF
Usage: $(basename "$0") -e <env> [-n] [-h]
-e 目标环境 (dev|staging|prod),必填
-n dry-run,只打印将要执行的动作
-h 显示帮助
EOF
}
ENV=""
DRY_RUN=0
while getopts ":e:nh" opt; do
case "$opt" in
e) ENV="$OPTARG" ;;
n) DRY_RUN=1 ;;
h) usage; exit 0 ;;
:) usage; die "option -$OPTARG requires an argument" ;;
?) usage; die "unknown option -$OPTARG" ;;
esac
done
shift $((OPTIND - 1))
[[ -n "$ENV" ]] || { usage; die "-e is required"; }
[[ "$ENV" =~ ^(dev|staging|prod)$ ]] || die "invalid env: $ENV"
参数校验放在解析之后立刻做,用白名单(正则匹配)而不是黑名单。getopts 只支持短选项,需要长选项时用 getopt 或手写循环。
默认值优先从环境变量取(DRY_RUN=${DRY_RUN:-0}),这样脚本既能手工跑,也能在 CI 里用同一份配置跑。敏感信息不要用命令行参数传入——命令行会完整出现在进程列表里,用环境变量或从文件读取更安全。
-n(dry-run)值得每个有副作用的脚本都实现:把实际执行命令包装成 run() 函数,dry-run 模式下只打印不执行。
可复用骨架
#!/usr/bin/env bash
set -euo pipefail
IFS=$'nt'
readonly SCRIPT_NAME=$(basename "$0")
WORKDIR=""
DRY_RUN=${DRY_RUN:-0}
log() { printf '[%s] [%s] %sn' "$(date '+%F %T')" "$1" "${*:2}" >&2; }
info() { log INFO "$@"; }
warn() { log WARN "$@"; }
error() { log ERROR "$@"; }
die() { error "$@"; exit 1; }
run() {
if [[ "$DRY_RUN" == "1" ]]; then
log DRY-RUN "$*"
else
log EXEC "$*"
"$@"
fi
}
cleanup() {
local rc=$?
[[ -n "$WORKDIR" && -d "$WORKDIR" ]] && rm -rf "$WORKDIR"
return "$rc"
}
trap cleanup EXIT
trap 'error "failed at line $LINENO: ${BASH_COMMAND}"' ERR
main() {
WORKDIR=$(mktemp -d "${TMPDIR:-/tmp}/${SCRIPT_NAME}.XXXXXX")
info "start $SCRIPT_NAME, workdir=$WORKDIR"
# ... 实际逻辑 ...
info "done"
}
main "$@"
这些约定分别对应一类真实事故:-u 对应变量拼错,pipefail 对应静默失败的管道,ERR trap 对应”不知道挂在哪一行”,EXIT trap 对应磁盘上堆积的临时目录,run() 对应误操作,flock 对应定时任务重叠,mktemp 对应文件名冲突。
写完一个脚本,可以照着过一遍验收项:路径和变量是否都加了引号;失败时是否打印行号;临时文件是否一定被删;重跑是否安全;并发是否会打架;dry-run 是否可用;非零退出码是否真的表示失败。缺任何一项,脚本在半年内大概率会出一次事故。把骨架写进团队模板,比把规范写进文档有效得多。
评论(4)
我们线上量不大,用不上这么复杂,但长见识了。
看完感觉自己的技术债被点名了。
有个地方我觉得可以再展开讲讲,就是回滚那部分。
看完去把我们那条慢查询加了索引,从 3 秒降到 200ms,谢谢。
请 登录 后参与评论。