有效排查 Bash 变量展开问题

Bash 脚本常因微妙的变量展开错误而失败。本指南全面剖析常见问题,如错误引用、未初始化值处理以及子 shell 和函数中的变量作用域管理。学习关键调试技术(`set -u`、`set -x`)并掌握强大的参数展开修饰符(如 `${VAR:-default}`),以编写健壮、可预测且无错的自动化脚本。停止调试神秘的空字符串,自信地编写脚本。

有效排查 Bash 变量展开问题

Bash 变量展开错误通常表现为随机行为:带空格的路径变成两个路径、文件名中的通配符展开为半个目录、循环内设置的变量消失、或缺失的环境变量静默变为空字符串。Shell 并非随机行事,它遵循的展开规则在你专注于脚本任务时容易被遗忘。

一个有用的思维模型是:Bash 并非简单地将 $name 替换为文本然后执行命令。它会展开变量,可能将结果拆分为单词,可能展开通配符,最后用生成的参数列表执行命令。大多数修复方法都源于控制这些步骤。

未设置变量默认变为空,除非你阻止它

默认情况下,以下脚本会打印空值并继续执行:

printf 'Deploying %s\n' "$APP_VERSION"

如果 APP_VERSION 是必需的,这就是一个错误。当变量为必填时,使用参数展开:

: "${APP_VERSION:?APP_VERSION must be set}"
printf 'Deploying %s\n' "$APP_VERSION"

开头的 : 是无操作命令。展开负责检查。如果变量未设置或为空,Bash 会打印消息并从非交互式 shell 退出。

对于可选值,明确设置默认值:

log_level=${LOG_LEVEL:-INFO}
retry_count=${RETRY_COUNT:-3}

冒号很重要。${VAR:-default}VAR 未设置或为空时使用默认值。${VAR-default} 仅在 VAR 未设置时使用默认值。如果空字符串是有效的配置值,这个区别就很重要。

set -u 也可以捕获未设置的变量:

set -u

它在许多脚本中很有用,但不能替代清晰的验证。在处理可选位置参数、数组或有意检查存在性的变量时,它也可能带来意外。当参数可能缺失时,使用 ${1:-}

mode=${1:-help}

引用变量,除非你需要拆分和通配

这是最常见的展开问题:

file="Quarterly Report *.txt"
rm $file

未加引号时,Bash 首先展开 $file,然后按空格拆分,再将 * 视为通配符。命令可能接收到多个你未预期的参数。加引号后,它只接收一个参数:

rm -- "$file"

-- 保护命令免受以破折号开头的值的影响。这对于像 -rf 这样的文件名很重要。

对变量、命令替换和大多数参数展开使用双引号:

cp "$source_file" "$destination_dir/"
printf 'User: %s\n' "$user_name"

单引号不同。它们完全阻止展开:

printf 'Home is $HOME\n'   # 打印字面文本
printf "Home is $HOME\n"   # 打印值

如果你看到脚本构建像 'prefix-$value' 这样的字符串,那很可能是一个错误。当值需要展开时,使用双引号。

数组解决许多参数构建问题

许多 Bash 错误源于将多个命令选项存储在一个字符串中:

opts="-a --delete --exclude *.tmp"
rsync $opts "$src/" "$dest/"

这依赖于单词拆分,当选项参数包含空格时可能会出错。使用数组:

opts=(-a --delete --exclude '*.tmp')
rsync "${opts[@]}" "$src/" "$dest/"

"${opts[@]}" 将每个数组元素展开为单独的参数。这正是大多数命令构建所需要的。

收集文件名时同样适用:

files=("$report_dir"/*.txt)
for file in "${files[@]}"; do
  [[ -e $file ]] || continue
  process_report "$file"
done

[[ -e $file ]] || continue 保护处理了没有文件匹配且通配符保持字面值的情况,具体取决于 shell 选项。

命令替换会移除尾随换行符

$(command) 捕获标准输出,但 Bash 会移除尾随换行符。这对于版本字符串通常没问题,但对于尾随换行符重要的数据则是错误的。

version=$(git describe --tags --always)
printf 'Version: %s\n' "$version"

对于面向行的输出,当你需要数组时,首选 mapfile

mapfile -t names < <(find "$base_dir" -maxdepth 1 -type f -name '*.log' -printf '%f\n')
for name in "${names[@]}"; do
  printf 'log=%s\n' "$name"
done

避免使用 for item in $(ls)。它会在空格、通配符和异常文件名上出错。循环遍历通配符或使用 find 并小心使用分隔符。

管道中的变量可能位于子 shell 中

这常常让人困惑,因为循环看起来运行正常:

count=0
printf '%s\n' a b c | while IFS= read -r line; do
  count=$((count + 1))
done
printf 'count=%s\n' "$count"

在许多 Bash 配置中,管道中的 while 循环在子 shell 中运行。递增发生了,但父 shell 的 count 保持不变。

改用进程替换:

count=0
while IFS= read -r line; do
  count=$((count + 1))
done < <(printf '%s\n' a b c)
printf 'count=%s\n' "$count"

或者让管道产生你需要的值,并直接捕获该值。

局部变量防止意外覆盖

Bash 函数中的变量是全局的,除非声明为 local。这可能会使辅助函数成为奇怪展开错误的来源:

env=prod

load_config() {
  env=dev
}

load_config
printf '%s\n' "$env"  # dev

对临时值使用 local

load_config() {
  local env=dev
  printf 'loaded defaults for %s\n' "$env"
}

local 是 Bash 特性。在 Bash 脚本中没问题,但这也是脚本不应使用 sh 运行的另一个原因。

当名称接触其他文本时使用花括号

$prefix_file 表示名为 prefix_file 的变量,而不是 $prefix 后跟 _file。使用花括号明确边界:

prefix=app
printf '%s\n' "${prefix}_file"

许多参数展开操作也需要花括号:

path=/var/log/nginx/access.log
printf 'dir=%s\n' "${path%/*}"
printf 'file=%s\n' "${path##*/}"

${path%/*} 移除最短的匹配后缀。${path##*/} 移除最长的匹配前缀。这些很有用,但当 dirnamebasename 能让脚本对团队更清晰时,不要过度使用。

通过打印实际参数来调试展开

set -x 显示展开后的命令。使用行号改进跟踪:

PS4='+ ${BASH_SOURCE}:${LINENO}: '
set -x
mv $file $target_dir
set +x

跟踪将显示命令是变成了 mv Quarterly Report *.txt /tmp/out 还是 mv 'Quarterly Report *.txt' /tmp/out。让 xtrace 远离秘密。

对于更安全的手动检查,使用 %q 打印值:

printf 'file=%q\n' "$file" >&2
printf 'target_dir=%q\n' "$target_dir" >&2

%q 使空格和特殊字符以比普通 echo 更易读的方式可见。

实用检查清单

当 Bash 变量展开错误时,按顺序检查以下内容:

  1. 脚本是否在 Bash 下运行,而不是 sh
  2. 变量是否实际设置?对必需值使用 ${VAR:?message}
  3. 除非有意拆分,否则每个展开是否都加了引号?
  4. 是否对多个参数使用了数组?
  5. 管道是否将循环置于子 shell 中?
  6. 函数是否因缺少 local 而覆盖了全局变量?
  7. 是否需要花括号将变量名与附近文本分开?

这些检查以最好的方式显得枯燥。它们将大多数展开错误从“Bash 很奇怪”转变为特定的、可修复的规则。

间接展开和名称引用需要格外小心

Bash 可以展开名称存储在另一个变量中的变量:

name=APP_ENV
printf '%s\n' "${!name}"

这会打印 APP_ENV 的值。它很强大,但会使脚本更难阅读,并且如果变量名来自用户输入,可能变得不安全。如果你只需要从名称到值的映射,关联数组更清晰:

declare -A endpoints=(
  [dev]='https://dev.example.test'
  [prod]='https://api.example.com'
)

printf '%s\n' "${endpoints[$env]:?unknown environment}"

Bash 还有带有 declare -n 的名称引用,常用于辅助函数。它们在库式脚本中很有用,但可能产生令人惊讶的副作用。仅当通过引用传递数组或变量确实简化了代码时才使用它们。

模式移除不是正则表达式匹配

参数展开运算符如 ${file%.log}${path##*/} 使用 shell 模式,而不是正则表达式。这个区别很重要。

file='access.log'
printf '%s\n' "${file%.log}"

这会移除 .log 后缀。它并不意味着“移除匹配正则表达式的任何内容”。对于正则表达式检查,使用 [[ ... =~ ... ]]

if [[ $port =~ ^[0-9]+$ ]]; then
  printf 'numeric\n'
fi

即使在那里,也要小心引用。=~ 的右侧通常不加引号,以便将其视为正则表达式。左侧变量在 [[ ]] 内部不需要引号,因为 [[ ]] 不像 [ ] 那样执行单词拆分。

仅导出子进程需要的内容

在 Bash 中设置变量不会自动使其对脚本启动的命令可用:

APP_ENV=prod
./run-app

run-app 不会看到 APP_ENV,除非它被导出或内联提供:

export APP_ENV=prod
./run-app

# 或
APP_ENV=prod ./run-app

当脚本打印正确的值但子进程行为像值缺失时,这通常是困惑的来源。变量存在于 shell 中;它从未被放入子进程的环境中。

反之亦然:子进程不能更改父 shell 的变量。如果辅助脚本打印 export TOKEN=...,正常运行它不会更新调用者。你必须 source 它,而 source 应保留给受信任的 shell 代码。

发布前的实际审查

在调用脚本或容器设置完成之前,以凌晨 2 点需要调试的下一个人的身份通读一遍。这会改变你注意到的内容。编写脚本时合理的提示在 CI 日志中可能变得模糊。感觉明显的 Docker 服务名称可能与应用程序中的变量名不匹配。对开发安全的 Bash 默认值可能对生产环境危险。

我喜欢用故意刁钻的值进行简短的试运行。使用带空格的路径。使用空的可选值。尝试以破折号开头的文件名。从不同的工作目录运行脚本。在没有一个预期环境变量的情况下启动容器。这些测试并不花哨,但它们能捕获通常首先崩溃的假设。

还要检查失败消息。如果唯一输出是 failed,那么文章的建议还没有落实到实现中。有用的失败消息会说明使用了什么值、什么检查失败以及操作员可以更改什么。这并不意味着转储每个环境变量或打印秘密。它意味着在具体帮助的地方具体说明:配置路径、缺失的命令名称、网络名称、服务主机名或进程尝试绑定的端口。

最后一个习惯是让示例接近系统实际运行的方式。如果生产环境使用 Compose,就用 Compose 测试。如果脚本由 systemd 启动,就用 systemd 或类似的最小环境测试。如果命令应该安全地复制粘贴,就在示例本身中包含引用、-- 分隔符和验证。读者复制工作模式的频率远高于复制警告。

那个审查不是官僚主义。它是让小型自动化保持枯燥的方式。枯燥正是你从 shell 提示、配置加载器、变量展开、容器诊断和 Docker 网络中所期望的。行为越不令人惊讶,下一个操作员就越容易信任它。

对于变量展开,在审查中再增加一个习惯:当命令行为异常时打印参数计数。一个小助手可以让不可见变得可见:

show_args() {
  local i=1
  for arg in "$@"; do
    printf 'arg[%d]=%q\n' "$i" "$arg" >&2
    i=$((i + 1))
  done
}

show_args mv $file $target_dir
show_args mv "$file" "$target_dir"

第一个调用显示错误命令将接收的内容;第二个显示修正后的版本。一旦你看到参数列表,引用错误就不再神秘。