Shell 运维脚本基础:严格模式与安全参数处理

用 set -Eeuo pipefail、参数校验、trap 清理与预览确认,写出半夜出事也不会闯祸的运维脚本。

技术文章约 20 分钟248 次阅读

运维脚本大多在凌晨跑、在故障时跑、在没人盯着的时候跑。本文要解决的是"脚本半路出错却继续执行"这个问题——备份只做了一半、文件被挪了一半、服务重启了一半——它背后最基础的事实是:bash 在真正执行前先把命令行拆成词、展开变量,再靠进程的退出码决定流程;不理解"展开"与"退出码"这两件事,任何安全写法都只是背下来的咒语。文章从这两个机制出发,推导出严格模式(set -Eeuo pipefail)、引号规范、临时文件清理、预览确认等一整套安全写法,并给出可复现的完整脚本与验证方法。

#原理层:bash 执行一条命令前发生了什么

#一条命令的生命周期:解析、展开、执行

bash 拿到一行输入后,并不像人眼看到的那样"直接运行"。它按固定顺序做几件事:词法解析把行切成单词与操作符;然后进行展开(expansion);最后才执行并等待退出码。与脚本安全最相关的展开顺序是(bash 手册顺序的一个子集):

  1. 花括号展开(brace expansion):{a,b}、{1..3};
  2. 波浪号展开(tilde expansion):~、~user;
  3. 参数展开(parameter expansion):$var、${var:-x};
  4. 命令替换(command substitution):$(...);
  5. 算术展开(arithmetic expansion):$((...));
  6. 单词拆分(word splitting):按 IFS(默认为空格、制表符、换行)把上一步的结果切段;
  7. 路径名展开(pathname expansion):把 *、?、[...] 匹配成真实文件名;
  8. 引号移除(quote removal):把语法层面的引号删掉。

顺序决定一切:参数展开发生在单词拆分之前,所以"变量的值"是在拆分时被切开的;路径名展开在拆分之后,所以通配符会再次作用于拆出来的每个词;引号移除在最后,所以引号是语法标记而不是数据。用 set -x 可以亲眼看这个过程——它会把"展开后的最终命令"打印出来:

#!/usr/bin/env bash
set -x
v="a b c"
printf '<%s>\n' $v
set +x

在本机 bash 3.2.57 上,printf 实际收到的参数是三个:a、b、c(而不是一个 a b c)。这就是"变量展开后再被拆词"的直接证据。

#单词拆分与路径名展开:未加引号的变量为什么危险

未加引号的 $v 在展开之后,会继续经历单词拆分与路径名展开这两道工序,危险各有来历。

首先,单词拆分按 IFS 切段。实测:

f="my file.txt"
printf 'unquoted: <%s>\n' $f
printf 'quoted:   <%s>\n' "$f"

输出是 unquoted: <my>、unquoted: <file.txt>、quoted: <my file.txt>。也就是说,rm $f 实际执行的是"删除两个文件":my 和 file.txt。原文那句判断完全成立:不加引号,文件名带空格时会被拆成多个参数,rm -rf $work_dir/ 直接拆成两条命令,这是脚本误删的头号原因。而 $DIR/ 在 DIR 为空时展开成 /——实测 printf '<%s>\n' $DIR/ 的输出就是 /,即 rm -rf $DIR/ 在 DIR 未定义时相当于 rm -rf /。set -u 正是为了拦住这类展开。

其次,路径名展开会把 *、?、[ 匹配成文件名。实测在一个含 aa、ab、ac 三个文件的临时目录里:ls $d/a* 会列出 aa、ab、ac;而 ls "$d/a*" 因为引号把星号变成字面量,报 No such file。更隐蔽的情况是匹配不到任何文件:bash 默认(未开 nullglob)会把字面量原样传给命令,printf '<%s>\n' *.nodotfile 在空目录里输出 <*.nodotfile>;开了 nullglob 才展开为空。这意味着 grep -l pattern *.log 在没有 .log 文件时会老老实实去 grep 名为 *.log 的文件——不是报错,而是语义悄悄改变。

结论只有一条:一切带引用的展开都加双引号。"$var"、"$(cmd)"、"$@" 是默认姿势;不加引号是刻意为之的例外(例如明确要按 IFS 拆词——而这种需求应该用 read -r -a arr 或数组显式表达)。

#退出码:进程之间唯一的对话语言

Unix 进程之间没有"抛异常"这回事,唯一的状态通道是退出码(exit status):0 表示成功,1–255 表示失败(128+信号编号表示被信号杀死)。前一条命令的退出码存在 $? 里,if、while、&&、|| 全部以退出码为判断依据:

  • cmd1 && cmd2:cmd1 成功(0)才执行 cmd2;
  • cmd1 || cmd2:cmd1 失败(非 0)才执行 cmd2;
  • if cmd; then ... ; fi:以 cmd 的退出码决定分支。

明白这一点,就能看清"半路出错却继续执行"的机制根源:bash 默认对每一条命令的失败不闻不问,执行完上一条就接着执行下一条,除非你显式地检查退出码、用 && / || / if 表达依赖,或者打开 set -e。脚本里没有自动的"上一步失败就别往下走",这既是最基本的机制事实,也是整篇文章所有安全写法的出发点。

#set -e / set -u / pipefail / -E:各自改变了哪个默认行为

严格模式的四个开关,本质是四个"默认行为的修改",改的不是错误处理本身:

  • set -e(errexit):在每个简单命令、管道、命令列表执行完后检查退出码,非零就立刻退出整个 shell。但 bash 对"条件上下文"网开一面:if / elif / while / until 的条件、&& / || 列表(除最后一个命令外)、! 前置的命令、以及在这些上下文里被调用的函数,失败时都不会触发退出。这是设计使然——条件判断天然要"允许失败"。它的边界正是本文后面要重点排查的部分。
  • set -u(nounset):参数展开遇到未定义变量时报 unbound variable 并退出,而不是把变量当作空字符串。实测 bash -c 'set -u; x=$NOT_SET; echo after' 打印 bash: NOT_SET: unbound variable 后退出(本机 bash 3.2.57 退出码为 127,关键是"立即非零退出")。没有它,rm -rf $DIR/ 就会变成 rm -rf /。
  • set -o pipefail:把管道(pipeline)的退出码从"最后一个命令的退出码"改为"所有命令中最后一个非零的退出码"。没有它,mysqldump xxx | gzip > backup.sql.gz 里 mysqldump 失败时,gzip 可能照常成功,你得到的是一个空备份文件,还显示"成功"——这正是原文强调的经典事故。
  • set -E(errtrace):让 ERR trap 能被 shell 函数、命令替换、子 shell 继承。配合 trap 'echo "line $LINENO failed"' ERR,子函数里任何一个命令失败都能定位到行号。

它们合起来的语义是:把所有"你没明说的失败"从"静默继续"改为"立刻暴露"。注意"暴露"不等于"处理"——退出只是把现场交还给你,接下来怎么办(重试、清理、报警)仍然要靠显式错误处理。

#管道与 SIGPIPE:读端关掉时写端会怎样

管道是内核里的一个缓冲区,两侧各是一个独立进程。缓冲区很小(几十 KB 量级),写端写满后会被阻塞,直到读端取走数据;一旦读端进程先退出(比如 head -n 1 读完一行就结束),写端下一次 write 就会收到 SIGPIPE 信号。SIGPIPE 的默认动作是终止进程,被它杀死的进程退出码是 128+13=141。

实测:

set -o pipefail
yes | head -n 1 >/dev/null
echo "status=$? pipestatus=${PIPESTATUS[*]}"

输出 status=141 pipestatus=141 0:yes 写满缓冲区后读端已退出,被 SIGPIPE 终止为 141,head 正常为 0。这一条因果链解释三个现象:为什么 set -o pipefail 会把"正常的截断"也当成失败(141 是非零);为什么排错时看到 141,第一反应应是"上游被管道下游提前关闭杀掉了",而不是"上游崩溃了";以及为什么在 mysqldump | gzip > file 里必须让 mysqldump 的失败显式可见——本质上都是"退出码 + 信号"这两个机制的组合。

#小结:为什么严格模式不足以替代显式错误处理

把上面几节合起来看:严格模式改的是"失败时的默认动作"(从继续变退出),但 bash 的例外汇总起来是一个不小的集合,而且退出码本身不携带"是哪个环节、为什么"的信息。参数错、文件缺、管道被截断、命令替换内部失败,表现各不相同。所以正确的心智模型是:set -Eeuo pipefail 是地基,地基本身不盖楼——参数校验负责"先拒后行",trap 负责"退场清理",显式检查负责"结果复核"。

#操作层:一份可复现的"要么全成、要么明确失败"脚本

#前置条件与版本

以下示例全部在本机验证可复现,环境细节如下:

  • bash:macOS 自带 /bin/bash 3.2.57(GNU bash),Linux 常见发行版为 bash 4.x/5.x;文中用到的 -E、-u、-o pipefail、${VAR:-default}、PIPESTATUS、LINENO 各版本行为一致。
  • shebang 用 #!/usr/bin/env bash,不要写 #!/bin/sh:Debian/Ubuntu 等系统的 /bin/sh 指向 dash,dash 不支持 pipefail——本机实测 dash -c 'set -o pipefail; echo hi' 直接报 Illegal option -o pipefail 并以 2 退出,脚本会卡死在第一行。
  • 计时用 date '+%F %T'(%F 即 %Y-%m-%d,%T 即 %H:%M:%S),macOS 与 Linux 都支持。

#骨架:shebang 与 set 行

#!/usr/bin/env bash
set -Eeuo pipefail

四个开关各管一件事,缺一个都有漏洞:没有 -e,前面的失败会被后面的成功掩盖;没有 -u,空变量可能变成 rm -rf /;没有 -o pipefail,备份管道里的 mysqldump 失败会被 gzip 的成功盖住;没有 -E,函数里的错误定位不到行号。

#参数校验:先拒后行

严格模式只解决"中途出错",参数错了要在一开始就拦住。完整写法(本机实测:无参数时打印用法并以 2 退出,目录不存在时打印错误并以 2 退出):

#!/usr/bin/env bash
set -Eeuo pipefail

usage() {
    echo "用法: $0 <目录>" >&2
    exit 2
}

[[ $# -eq 1 ]] || usage
work_dir=$1

# 目录必须存在、必须是目录、必须可读
[[ -d "$work_dir" ]] || { echo "错误: 不是目录: $work_dir" >&2; exit 2; }
[[ -r "$work_dir" ]] || { echo "错误: 目录不可读: $work_dir" >&2; exit 2; }

注意两个细节:[[ $# -eq 1 ]] || usage 利用 || 在左侧成功时不执行右侧;$1 在 $# 已确认大于等于 1 之后才读取,这样在 set -u 下不会触发 unbound 错误。所有对 $work_dir 的引用都带双引号。

#引号规范

  • 所有参数展开、命令替换一律加双引号:"$work_dir"、"$(date)"。
  • 遍历命令行参数用 "$@",它逐个保留每个参数(含空格),不被拆词。
  • 需要保存一组路径时用数组:files=(a.txt "b c.txt"); rm -f -- "${files[@]}";-- 用来防止文件名以 - 开头被当成选项。
  • 刻意拆词的场合极少,真需要时用 read -r -a arr <<< "$line" 显式表达,而不是裸写 $var。

#临时文件与 trap EXIT

脚本创建临时文件,失败退出时会留下垃圾;更糟的是下次运行读到残留文件。用 trap ... EXIT 保证无论正常还是异常退出都清理:

tmp_dir=$(mktemp -d)
trap 'rm -rf "$tmp_dir"' EXIT

# 之后脚本任何位置失败退出,tmp_dir 都会被清掉

这条写法的机制要点有两个。第一,mktemp -d 生成的目录名带随机后缀,权限实测为 0700,既避免符号链接攻击(固定的 /tmp/xxx 可能被预置软链指向要害目录),也避免路径冲突;绝对不要用固定的 /tmp/xxx 路径,多实例并发跑同一个脚本时会互相踩。第二,trap 的字符串分两种展开时机——单引号是"触发时才展开",双引号是"注册时就展开"。实测:

x=before
trap "echo captured-at-setup: $x" EXIT   # 注册时展开 -> 存的是 before
x=changed
trap 'echo evaluated-at-exit: $x' EXIT   # 触发时展开 -> 读到 changed
exit 0

输出是 captured-at-setup: before 与 evaluated-at-exit: changed。对 rm -rf "$tmp_dir" 这种清理来说,用单引号更稳——它总是指向注册那一刻的目录。EXIT trap 的实际清理效果同样实测过:脚本建立临时目录后以 1 退出,目录被删掉。

#危险操作:预览 + 确认开关

涉及删除、重启、发布的操作,脚本默认只打印动作,加 --apply(或 -y)才真执行。原文的生产写法是:

apply=false
[[ "${1:-}" == "--apply" ]] && apply=true

do_backup() {
    echo "[action] 备份 $src -> $dst"
    if $apply; then
        cp -a "$src" "$dst"
    fi
}

if $apply 依赖一个机制事实:true 和 false 是两条真实存在的命令,$apply 展开后就是把 true 或 false 当作命令执行。这种写法没问题,但可读性弱;更直白的等价写法是 if [[ "$apply" == true ]]; then。输出里用 [action] 前缀标记将要发生的操作,人类看日志一眼就能核对。删除类操作再额外加一道确认:

if [[ "$apply" == true ]]; then
    read -r -p "确认删除 $target 下的过期备份? [y/N] " ans
    [[ "$ans" == "y" ]] || { echo "已取消"; exit 0; }
    rm -rf "$target"
fi

read -r -p 的 -r 防止反斜杠转义,-p 打印提示;默认值 [y/N] 表示只接受显式的 y。

#日志:可复盘、不泄露

脚本要能在事后复盘,日志至少要包含:时间、操作对象、结果。原文的 log 函数(%F %T 输出完整时间戳):

log() { printf '%s %s\n' "$(date '+%F %T')" "$*"; }
log "开始同步 $work_dir"
log "同步完成,共 ${count} 个文件"

用 set -x 调试时尤其小心:它会把"展开后的命令行"原样打出来,export PASSWORD=xxx 这类内容会直接进日志。这不是理论风险——原文记录的真实事故是:脚本用 set -x 跑,把带数据库密码的连接串打进了 CI 日志,日志又同步到第三方平台。所以有两层铁律:含密码的命令永远不要出现在 set -x 的输出里;敏感参数从环境变量读,且只在需要时导出。附带一个机制事实:进程命令行参数对同一主机上的所有用户可见(Linux 下是 /proc/<pid>/cmdline,macOS 下 ps -ef 能看到),所以密码也不应该出现在命令行里。

#完整示例脚本

把以上要点合并成可直接运行的脚本 demo_backup.sh(已在 bash 3.2.57 实测全部路径):

#!/usr/bin/env bash
# demo_backup.sh —— "严格模式 + 显式错误处理"的完整骨架
# 用法: ./demo_backup.sh <源目录> [--apply]
set -Eeuo pipefail

usage() {
    echo "用法: $0 <源目录> [--apply]" >&2
    exit 2
}

[[ $# -ge 1 ]] || usage
src_dir=$1
apply=false
[[ "${2:-}" == "--apply" ]] && apply=true

# 先拒后行: 参数不对, 一行都不跑
[[ -d "$src_dir" ]] || { echo "错误: 不是目录: $src_dir" >&2; exit 2; }
[[ -r "$src_dir" ]] || { echo "错误: 目录不可读: $src_dir" >&2; exit 2; }

dst_root="${BACKUP_ROOT:-$HOME/backups}"
mkdir -p "$dst_root"

# 临时目录: mktemp 生成随机后缀; 无论正常还是异常退出都清理
tmp_dir=$(mktemp -d)
trap 'rm -rf "$tmp_dir"' EXIT

log() { printf '%s %s\n' "$(date '+%F %T')" "$*"; }

do_backup() {
    local stamp dst size
    stamp=$(date '+%Y%m%d-%H%M%S')
    dst="$dst_root/$(basename "$src_dir")-$stamp.tar.gz"
    echo "[action] 备份 $src_dir -> $dst"
    if [[ "$apply" == true ]]; then
        # 先在临时目录里打包, 成功后 mv 原子落地; tar 失败时 pipefail 让管道非零, set -e 立即退出
        tar -C "$src_dir" -cf - . | gzip -9 > "$tmp_dir/backup.tar.gz"
        mv "$tmp_dir/backup.tar.gz" "$dst"
        size=$(wc -c < "$dst" | tr -d ' ')
        log "备份成功: $dst ($size 字节)"
    else
        log "预览模式: 未实际执行, 加 --apply 才会真正备份"
    fi
}

do_backup

它的设计逻辑逐行可解释:set -Eeuo pipefail 打底;缺参数/坏目录在第一步用退出码 2 拒绝;$1、${2:-} 在 set -u 下安全读取;mkdir -p "$dst_root" 直接把目标目录建出来,不假设它已存在;打包管道里 tar | gzip 任何一个失败都会让整条管道非零,set -e 随即退出,此时 EXIT trap 清理临时目录,目标位置不会留下半成品——这正是"备份只做了一半"事故的结构性解法;成功后用 wc -c 复核文件大小并记日志,是"显式错误处理"的一个最小样本。

#运行报错时怎么排查

脚本严格模式打开后报错退出,按这个顺序定位:

  1. 看退出位置与行号:trap 'echo "line $LINENO failed"' ERR 配合 set -E,函数与子 shell 里的失败也能报出源头行。
  2. 看实际执行的命令:局部 set -x 或 bash -x 脚本 只打印展开后的命令行,不泄露变量内容。
  3. 看退出码归属:$? 是上一条前台命令的退出码;管道各段用 PIPESTATUS 数组分别取,必须在管道结束后立刻读取。

#验证层:怎么确认自己写对了

#静态检查:bash -n 与 shellcheck

bash -n 只做语法检查,不执行;shellcheck 能查出一堆真问题。原文的检查命令照用:

bash -n demo_backup.sh
shellcheck demo_backup.sh

shellcheck 未安装时先装:macOS 用 brew install shellcheck,Debian/Ubuntu 用 apt install shellcheck。它最常抓到的三类警告都是真实事故的高发项:SC2086(变量没加引号)、SC2164(cd 失败没处理)、SC2012(解析 ls 输出)。经验法则保留原文:任何超过 50 行的运维脚本都值得过一遍 shellcheck。

#行为级验证:五个最小实验

静态检查通过不等于行为正确。机制层面的结论各自配一个最小实验,全部在本机 bash 3.2.57 上验证过。

实验一,分词。无引号时 printf 收到三个参数,加引号收到一个:

v="a b c"; printf '<%s>\n' $v      # 输出三行: <a> <b> <c>
v="a b c"; printf '<%s>\n' "$v"    # 输出一行: <a b c>

实验二,set -u。未定义变量报错退出;${VAR:-default} 正常给默认值:

bash -c 'set -u; x=$NOT_SET; echo after'        # 报 unbound variable, 退出码非零
bash -c 'set -u; x=${NOT_SET:-d}; echo "$x"'    # 输出 x=d, 继续执行

实验三,pipefail。同一句话的两种结局:

bash -c 'false | true; echo $?'                  # 0: 失败被吞
bash -c 'set -o pipefail; false | true; echo $?' # 1: 如实暴露

实验四,SIGPIPE。读端提前关闭时,上游以 141 结束:

bash -c 'set -o pipefail; yes | head -n 1 >/dev/null; echo "${PIPESTATUS[*]}"'
# 期望输出: 141 0

实验五,set -e 的条件上下文豁免。if 与 ! 中的失败不退出:

bash -c 'set -e; if false; then :; fi; echo alive'   # 输出 alive, 退出码 0
bash -c 'set -e; ! false; echo alive'                # 输出 alive

#判定标准

  • bash -n 无输出、shellcheck 0 error:静态检查通过。
  • 五个实验的期望输出与实测一致。
  • 示例脚本三路径复测:无参数与坏目录都是退出码 2 且打印到 stderr;预览模式(不加 --apply)不产生任何文件、退出码 0;apply 模式生成非空压缩包、日志含时间与字节数、退出码 0。任何一条不符都说明脚本被改坏或环境不符约定。

#边界与坑:严格模式的失效场景

#set -e 在条件表达式与 && / || 中"失效"

这是最常被误解的一条。set -e 不是"任何非零都退出",而是"非零且不在条件上下文才退出"。以下场景里失败是允许的、不会退出(全部实测):

  • if、elif、while、until 的条件;
  • && 或 || 列表的左侧(除最后一个命令外);
  • ! 取反的命令;
  • 在上述上下文里被调用的函数。

实测汇总:

bash -c 'set -e; false || true; echo 1'          # 输出 1
bash -c 'set -e; false && echo no; echo 2'       # 输出 2: 左侧失败不退出
bash -c 'set -e; true && false; echo 3'          # 不输出 3: 右侧是最后命令, 触发退出

所以"开了 set -e,grep 没匹配到却继续跑"这类问题,先检查目标命令是否落在条件上下文里。这不是缺陷,是设计:条件判断必须允许失败。真正的问题是把"必须成功"的命令写进了条件上下文而不自知。

#管道:没有 pipefail 时 set -e 也看不见失败

set -e 判断的是整条管道的退出码。没有 pipefail 时,false | true 的退出码是最后命令 true 的 0,set -e 无从发现失败——实测 bash -c 'set -e; false | true; echo alive' 输出 alive。开 pipefail 后,管道退出码变成"最后一个非零",false | true 立即暴露。但 pipefail 也带来新陷阱:grep 无匹配时退出码是 1。实测 set -Eeuo pipefail 下 printf 'a\nb\n' | grep '^z' 整条管道非零,set -e 直接退出;同样的 grep 放进 if 条件则安然无恙。所以"管道里有没有内容"这种断言,应该写成 if printf ... | grep -q '^z'; then,而不是裸管道。

#${VAR:-default} 与 set -u 的配合

set -u 下引用未定义变量会退出,但两种 :- 写法是显式的逃生门:

x=${VAR:-default}   # VAR 未设置或为空 -> default
x=${VAR-default}    # 仅 VAR 未设置 -> default; VAR 为空字符串时保留空

它们和 set -u 兼容(实测未定义变量不报错)。对"可选参数"的正确姿势是 "${2:-}",对"可选环境变量"是 "${BACKUP_ROOT:-$HOME/backups}"——示例脚本里两条都用到了。注意不要裸写 $2,那在 set -u 下会 unbound。

#命令替换与 local 的掩蔽

命令替换内部,errexit 默认被关闭(POSIX 规定如此),实测:

bash -c 'set -e; y=$(false; echo inner); echo "y=$y"'
# 输出 y=inner, 而不是退出: 命令替换内部的 false 没有触发 -e

bash 4.4+ 可以 shopt -s inherit_errexit 让命令替换内部也遵守 -e,但这是版本特性,不能假设所有服务器都生效。另一个常见的掩蔽是 local:实测 bash 3.2 下 set -e; f(){ local x=$(false); echo in-f; }; f 会继续执行并整体成功——local 吞掉了命令替换的失败状态。稳妥做法是分开写:

local x
x=$(可能失败的命令)

#SIGPIPE 141:是被截断,还是真失败

在 pipefail 下看到 141 或上游非零,第一反应是排查 SIGPIPE,而不是直接判定脚本坏了。yes | head -n 5 的"失败"其实是正常的截断。需要区分时,用 if ! 配合 PIPESTATUS 逐段检查(实测可复现):

set -Eeuo pipefail
if ! yes | head -n 5 >/dev/null; then
    echo "上游=${PIPESTATUS[0]}, head=${PIPESTATUS[1]}"
fi

输出 上游=141, head=0。同理可以识别 grep 无匹配(PIPESTATUS[1]=1)。判断标准是业务语义:如果这条管道本来就"只需要前面几行",141 是可接受的;如果期待全量处理完毕,141 说明数据被截断了,要按失败对待。

#cd 失败与 || true 的滥用

cd 是另一个高频失联点。严格模式下,裸 cd /no/such/dir 会失败并退出(实测退出码 1);但一旦把它包进 || true、放进条件上下文、或放在没有 pipefail 保护的管道里,后续 rm、cp、tar 就会在不是预期的目录里继续执行,而且往往"成功"——因为那个目录恰好存在。ShellCheck 的 SC2164 就是抓这个。"管道里 cd 失败被吞"的典型是 cd "$dir" | tee log 这种写法,cd 的退出码被 tee 的 0 盖住。对待 || true 的正确态度只有一条:要么明确接受失败并处理,要么就让它炸出来——|| true 是给"失败也无所谓"的命令用的,不是给错误处理偷懒用的,这就是原文强调的反模式。

#什么时候不该用 set -e

set -e 适合"顺序执行、失败即中止"的脚本主体。不适合它的场景:需要逐条容忍部分失败再统一统计的批处理(如批量上传,失败的要记录后继续下一批);与第三方程序交互、依赖其退出码语义不稳定时;以及脚本本身被更上层编排、需要把决策留给调用方时。这些场景里,显式的 if cmd; then ... else ... fi、rc=$? 与 PIPESTATUS 检查比 set -e 更诚实——严格模式的目的是暴露失败,不是假装失败不存在。

#收尾

把机制和写法串起来,就是原文最后那句话的展开:set -Eeuo pipefail + 双引号 + trap 清理 + 预览确认,这四样配齐,脚本的"半夜出事率"能降一个量级。它们分别对应:让失败立即暴露(退出码机制)、不让词法展开改变语义(展开机制)、保证退场干净(trap 机制)、把不可逆动作留给人确认(人的决策机制)。下一份运维脚本,从 #!/usr/bin/env bash 和 set -Eeuo pipefail 开始,把"要么全成、要么明确失败"写成默认姿势。

相关阅读

Related

留言讨论

0 条

还没有留言,来说点什么吧。

Shell 运维脚本基础:严格模式与安全参数处理 · LXH·BLOG