5月28日 02:12

Shell 脚本中如何进行错误处理和调试?有哪些常用的技巧?

Shell 脚本没有编译器的类型检查和异常机制,错误一旦发生往往悄无声息地传播,导致脚本在错误的状态下继续运行,产出难以排查的脏数据。所以错误处理和调试能力是区分"能跑的脚本"和"可靠脚本"的分水岭,也是面试中的高频考点。

错误处理的核心手段

set 选项:让脚本在错误面前不再沉默

Shell 默认行为是"命令失败了就失败了,继续执行下一条",这在自动化场景中极其危险。set 选项可以从源头改变这个行为:

bash
set -e # 任何命令返回非零退出码时,脚本立即终止 set -u # 引用未定义变量时报错退出,而非当作空字符串 set -o pipefail # 管道中任意命令失败,整个管道返回失败状态

面试中常问的是为什么要组合使用 set -euo pipefail

  • 只用 set -e 不够:false | true 这条管道,false 失败了但管道整体返回 true 的退出码 0,set -e 不会触发退出。加上 pipefail 才能捕获管道内的失败。
  • 只用 set -e + pipefail 仍不够:echo $UNDEFINED_VARset -u 缺失时只输出空行,不会报错;如果这个变量是文件路径,后续操作会作用在错误的目标上。

需要注意 set -e 的一个坑:它在 ifwhile&&|| 等条件上下文中不会触发退出,因为 Shell 认为你已经在主动处理该命令的返回值了。

trap:在脚本退出时做最后一件事

trap 用于捕获信号或事件,最常见的用途是资源清理:

bash
cleanup() { rm -f "$TEMP_FILE" echo "Cleaned up temporary resources" >&2 } trap cleanup EXIT

面试追问:trap 能捕获哪些信号?EXIT 是信号吗?

  • EXIT 不是真正的信号,而是 Shell 内置的伪信号,在脚本以任何方式退出时都会触发(包括正常退出、set -e 触发的退出、未被捕获的信号导致的退出)。
  • INT(Ctrl+C)、TERM(kill 默认信号)是真正的信号,可以在脚本运行中被外部触发。
  • ERR 是另一个伪信号,仅在命令失败(返回非零)时触发,可以用来做错误日志记录:
bash
error_handler() { local line_no=$1 echo "Error at line $line_no" >&2 } trap 'error_handler $LINENO' ERR

自定义错误处理函数

对于需要携带上下文信息的错误,封装一个错误处理函数比直接 exit 1 更实用:

bash
die() { echo "[FATAL] $1 (line: ${BASH_LINENO[0]})" >&2 exit "${2:-1}" } # 使用示例 [ -f "$CONFIG" ] || die "Config file not found: $CONFIG"

BASH_LINENO 是 Bash 的内置数组,BASH_LINENO[0] 给出调用者所在行号,比手动标注更可靠。

调试的核心手段

set -x:最常用的调试开关

bash
# 对整个脚本开启 bash -x script.sh # 对脚本中某一段开启 set -x # 需要调试的代码 set +x

set -x 的输出以 + 开头,会显示变量展开后的实际值。set -v 则只显示原始输入行,不做变量展开。两者可以组合使用(set -xv),但实践中 set -x 单独使用频率远高于 -v

ShellCheck:静态分析比手动调试更高效

面试中提到调试,很多人只想到 set -x,但静态分析工具 ShellCheck 才是效率最高的手段。它能捕获类型错误、未引用变量、不安全的 rm 操作、废弃语法等问题:

bash
shellcheck myscript.sh

安装一次、受益全程。在 CI 流水线中加入 shellcheck 检查,可以在代码合并前拦截大量低级错误,比运行时调试成本低得多。

条件式调试输出

生产环境不能到处加 echo,一个受环境变量控制的调试函数更合适:

bash
debug() { [ "${DEBUG:-0}" = "1" ] && echo "[DEBUG] $*" >&2 return 0 }

关键细节:用 ${DEBUG:-0} 提供默认值,避免 set -u 下未定义变量报错;输出重定向到 >&2(标准错误),不污染标准输出的正常数据流。

面试中容易忽略的点

管道中的错误处理:这是面试高频追问。set -e + set -o pipefail 是基本答案,但更完整的做法是用 PIPESTATUS 数组获取管道中每个命令的退出码:

bash
grep "error" app.log | sort | uniq -c echo "Exit codes: ${PIPESTATUS[@]}"

子 Shell 中的 set 选项不向上传播:管道中的每个命令运行在子 Shell 中,set -e 在子 Shell 中触发退出只退出子 Shell,不会导致父脚本退出。这是 set -o pipefail 存在的原因之一。

临时文件的安全创建mktemp/tmp/myfile.$$ 更安全,后者存在符号链接攻击风险:

bash
TEMP_FILE=$(mktemp) || die "Failed to create temp file" trap 'rm -f "$TEMP_FILE"' EXIT

一个可靠脚本的骨架

把以上手段组合起来,得到一个可以直接复用的模板:

bash
#!/usr/bin/env bash set -euo pipefail # === 错误处理 === die() { echo "[FATAL] $*" >&2 exit 1 } # === 资源清理 === TEMP_FILE="" cleanup() { [ -n "$TEMP_FILE" ] && rm -f "$TEMP_FILE" } trap cleanup EXIT INT TERM # === 调试支持 === debug() { [ "${DEBUG:-0}" = "1" ] && echo "[DEBUG] $*" >&2 return 0 } # === 主逻辑 === main() { local input="${1:-}" [ -n "$input" ] || die "Usage: $0 <input>" [ -f "$input" ] || die "File not found: $input" TEMP_FILE=$(mktemp) || die "mktemp failed" debug "Created temp file: $TEMP_FILE" # 业务逻辑 ... debug "Done" } main "$@"

面试中给出这个骨架,再配合对每个 set 选项和 trap 信号的讲解,基本能覆盖错误处理和调试这两个考点的全部内容。

标签:Shell