bash 脚本 5 个安全前缀:让 silent failure 显形

起因

线上发生过这个 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 的变量($var vs "$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)

踩过的坑

  1. set -e + function 退出码:function 里 return 1 不立即退出
    外层(只有命令)。要 local var; var=$(cmd) || return 1 显式判断。

  2. [[ var = "x" ]]= 在 sh 里:bash 单 = 也行;纯 sh
    [ "$var" = "x" ] 单括号 + 单 =

  3. pipefail + 期望 pipeline 部分失败
    bash set -o pipefail grep foo huge.log | head -1 # head 提前关闭 stdin → grep 收 SIGPIPE → 失败
    || true 容忍这种情况。

  4. subshell 里 set -e 不继承
    bash set -e ( false; echo "still here" ) # subshell 里 set -e 仍有效 echo "main" # 这行根据 () 退出码决定
    实际 () 子 shell 默认继承 -e,但有些版本 / 行为不一。验证。

  5. echo 不可靠:含 - / \ 的字符串可能被 echo 解释。
    生产用 printf '%s\n' "$var"

精确评价 共 0 人评价
可复现性
可复现 · 0 不可复现 · 0
文风
文风流畅 · 0 文风晦涩 · 0
立场
支持 · 0 反对 · 0

登录后即可对本帖作出评价。

评论区 0 条 · 所有人可在此交流

登录后参与评论。

还没有评论,来说两句。