起因
线上发生过这个 bug:deploy 脚本第 3 步 cp 失败(路径变了),
但脚本继续跑第 4 步 restart,导致服务用了老配置 + 看着 deploy 成功。
半小时后才发现版本没真换。
bash 默认行为:命令失败继续往下跑。这是脚本灾难的根源。
下面是我每个生产脚本都加的 5 个前缀。
5 个安全选项
放每个 bash 脚本开头:
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
逐个解释:
1. set -e:任一命令 fail 立即退出
set -e
cp /tmp/foo /backup/foo # 失败
echo "this won't print" # 不再执行
唯一例外:被 if / while 测试的命令 / 用 || && 链接的命令 /
! 否定的命令。
set -e
if cp /tmp/foo /backup/; then
echo ok
fi # cp 失败时 if 走 else 分支,脚本继续
cp /tmp/foo /backup/ || echo "cp failed" # || 让 cp 失败不中断脚本
2. set -u:用未定义变量立即报错
set -u
echo "$undef"
# bash: undef: unbound variable
# 立即退出
防"变量名打错"导致 rm -rf $WRONG_NAME/file 跑成 rm -rf /file
(如果 WRONG_NAME 没定义就是空 → rm -rf /file 极危险)。
要允许"可能未定义":
echo "${MY_VAR:-default}" # 未定义时用 default
echo "${MY_VAR:?must be set}" # 未定义时报错且自定义消息
3. set -o pipefail:pipeline 任一段失败 → 整段失败
set -o pipefail
curl https://example.com/data.json | jq .users | head
# curl 失败时(如 404),jq 仍正常处理(处理 empty),head 正常
# 没 pipefail:echo $? = 0(最后一段 head 成功)
# 有 pipefail:echo $? = curl 的退出码
pipeline 中间失败默认被忽略。pipefail 让它显形。
4. IFS=$'\n\t':把"按空格分词"改成按换行 / tab
# 默认 IFS=" \t\n"
files="hello world.txt"
for f in $files; do
echo "$f"
done
# hello
# world.txt
# 一个文件名被当成两个
IFS=$'\n\t'
files=$(ls)
for f in $files; do
echo "$f" # 包含空格的文件名也作为一个
done
文件名 / 输入含空格时分词错误是经典 bug。IFS=$'\n\t' 减少踩坑。
(更彻底用数组 + for f in "${arr[@]}" 引号包裹。)
5. set -x:调试时显示每条命令(可选)
set -x
cp a b # 输出:+ cp a b
rm b # 输出:+ rm b
不要生产开(输出 noise + 可能 log 敏感数据)。临时 debug 加 / 移。
完整模板
#!/usr/bin/env bash
# 描述:部署 X 服务的脚本
# 用法:./deploy.sh [staging|prod]
set -euo pipefail
IFS=$'\n\t'
# 1. 校验参数
if [ $# -lt 1 ]; then
echo "Usage: $0 [staging|prod]" >&2
exit 64
fi
ENV=$1
case "$ENV" in
staging|prod) ;;
*) echo "unknown env: $ENV" >&2; exit 64 ;;
esac
# 2. 环境变量校验
: "${API_TOKEN:?API_TOKEN must be set}"
: "${TARGET_HOST:?TARGET_HOST must be set}"
# 3. 工作目录
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
# 4. 临时文件清理
TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT
# 5. 主逻辑
echo "=== building ==="
make build
echo "=== deploying to $ENV ==="
rsync -avz dist/ "$TARGET_HOST:/srv/myapp/"
echo "=== restarting ==="
ssh "$TARGET_HOST" 'systemctl restart myapp'
echo "=== verifying ==="
sleep 5
HTTP_CODE=$(curl -s -o /dev/null -w '%{http_code}' "https://$TARGET_HOST/")
if [ "$HTTP_CODE" != "200" ]; then
echo "post-deploy check failed: HTTP $HTTP_CODE" >&2
exit 1
fi
echo "=== done ==="
其它常用 pattern
trap:清理 + 异常处理
TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT
# 正常退出 / 错误退出 / Ctrl-C 都执行清理
trap 'echo "ERROR: line $LINENO"; cleanup' ERR
# set -e 触发的退出会执行 ERR trap
函数化 + log
log() { echo "[$(date -Iseconds)] $*" >&2; }
die() { log "FATAL: $*"; exit 1; }
main() {
log "start"
do_step_1 || die "step 1 failed"
do_step_2 || die "step 2 failed"
log "ok"
}
main "$@"
check command exists
require() {
command -v "$1" >/dev/null 2>&1 || die "missing dependency: $1"
}
require jq
require rsync
require ssh
安全的 mktemp
TMPFILE=$(mktemp)
# 比 TMPFILE=/tmp/foo$$ 安全(mktemp 用 random + safe perms)
trap 'rm -f "$TMPFILE"' EXIT
不要 cd 后忘记 cd 回来
# ❌
cd /tmp && do_stuff
# 之后的命令在 /tmp 跑
# ✅ 用 subshell 包
(cd /tmp && do_stuff)
# 退出 subshell 自动回原目录
# 或 pushd / popd
pushd /tmp >/dev/null
do_stuff
popd >/dev/null
静态检查:shellcheck
brew install shellcheck
shellcheck deploy.sh
shellcheck 几百条规则查"潜在 bug + 反模式"。比如:
- 没 quote 的变量(
$varvs"$var") - 用
[[ ]]还是[ ] - 多余的
cat(Useless Use of Cat) - 未定义的变量
pre-commit / CI 跑 shellcheck 强制无错。
bash vs sh vs zsh
#!/bin/sh:POSIX shell,最 portable,功能少#!/bin/bash:bash 特性可用([[ ]]/<()/ 数组等)#!/usr/bin/env bash:找 PATH 里的 bash(Mac brew bash 比系统 3.2 新)
生产脚本写 bash 而非 sh,能用现代特性。
极小 alpine container 没装 bash → 用 sh 或装 bash。
几个真实例子
A. rm -rf "${BUILD_DIR}/" 删错
BUILD_DIR 没定义 → rm -rf /。
set -u 阻止:
bash: BUILD_DIR: unbound variable
或者用 : "${BUILD_DIR:?}" 显式断言。
B. curl | bash 中间断
下载安装脚本时,pipefail + curl 校验:
URL=https://example.com/install.sh
curl -fsSL "$URL" -o /tmp/installer.sh
sha256sum -c expected.sha256
bash /tmp/installer.sh
# 比 curl | bash 安全:能验签 + 失败时不执行
C. ssh 远程脚本
# 远端的环境变量 / set -e 都要在 ssh 里设
ssh server <<'EOF'
set -euo pipefail
cd /srv/app
git pull
systemctl restart myapp
EOF
注意 <<'EOF'(单引号)防止 local 解释 $var;本地变量要传 << EOF
(无引号)。
效果
我们 ops 团队把所有 shell 脚本套用这套规范 + pre-commit shellcheck
强制后:
- "中途失败但脚本继续"类 bug 归零
- 调试时间下降明显(trap + log 一眼看到失败行)
- 新人脚本上线 review 时间 -50%(shellcheck 帮 review)
踩过的坑
-
set -e+ function 退出码:function 里return 1不立即退出
外层(只有命令)。要local var; var=$(cmd) || return 1显式判断。 -
[[ var = "x" ]]单=在 sh 里:bash 单=也行;纯 sh
用[ "$var" = "x" ]单括号 + 单=。 -
pipefail + 期望 pipeline 部分失败:
bash set -o pipefail grep foo huge.log | head -1 # head 提前关闭 stdin → grep 收 SIGPIPE → 失败
|| true容忍这种情况。 -
subshell 里 set -e 不继承:
bash set -e ( false; echo "still here" ) # subshell 里 set -e 仍有效 echo "main" # 这行根据 () 退出码决定
实际 () 子 shell 默认继承 -e,但有些版本 / 行为不一。验证。 -
echo不可靠:含-/\的字符串可能被 echo 解释。
生产用printf '%s\n' "$var"。
登录后参与评论。