编程 Bash 脚本上生产前的六个坑:set -e 不生效、管道里变量丢了、文件名带空格

2026-09-20 21:04:01

Bash 脚本上生产前的六个坑:set -e 不生效、管道里变量丢了、文件名带空格

内容来源:tsight.io 的 Shell 脚本错误清单、yanxi123.com 的 Shell 变量坑记录,以及 gitcode bash errors 第 25 章。

1. for f in $(ls /data/logs/*.log) 会把文件名拆开

$(ls ...) 的输出先按 IFS(空格、制表符、换行)做词分割,再经过 glob 展开。文件名里带空格或换行时,一个路径会变成多个参数,后面的 rmmvcp 就会作用到错误的路径上。

生产写法用 NUL 分隔:

find /var/log/app -name "*.log" -print0 |
while IFS= read -r -d '' logfile; do
  echo "处理 ${logfile}"
done

-print0 用 NUL 而不是换行分隔结果,read -d '' 按 NUL 读,-r 禁止反斜杠转义。也可以直接交给 xargs:

find /var/log/app -name "*.log" -print0 | xargs -0 -n 1 gzip

2. 变量引用不加引号

$var 在含空格时会被分词,${var} 不会。所以除了明确需要分词的地方,一律写 "${var}"。数组同理,for f in "${arr[@]}" 是一个元素一个参数,for f in $arr 只取第一个元素,for f in "${arr[*]}" 会把所有元素拼成一个字符串。

files=("a b.txt" "c d.txt")
for f in "${files[@]}"; do echo "${f}"; done

3. set -euo pipefail 管不到的地方

三个开关的分工:-e 命令失败即退出,-u 引用未定义变量报错,-o pipefail 让管道中任一环失败整条管道就失败。比到处写 if [ $? -eq 0 ] 可靠得多,但它不是全能的。

set -e 在这些场景下不触发退出:

  • if / while 的条件判断部分
  • && / || 列表中非最后一条命令
  • ! 取反的表达式
  • 部分命令替换场景,例如 local x=$(false) 这条语句本身返回 0

最常见的中断来自 grep:没有匹配时返回 1,set -e 会让脚本直接退出。

grep "ERROR" app.log || true        # 明确允许无匹配
if grep -q "ERROR" app.log; then    # 或者放进条件里
  echo "有错误"
fi

pipefail 还有个副作用:cmd | head -n 1 这类写法里,head 读够一行就退出,上游进程收到 SIGPIPE 被杀,退出码 141,pipefail 会把整条管道判定为失败。要么在这一条上显式吞掉退出码(... || true),要么接受上游被提前掐断这件事。

4. 管道右侧是子 shell,循环里的变量会丢

count=0
cat app.log | while read -r line; do
  count=$((count + 1))
done
echo "${count}"   # 输出 0

管道两端的命令各自在子 shell 里执行,循环内对 count 的修改只存在于子 shell,循环结束就没了。两种改法:

# 重定向输入,循环留在主 shell
while read -r line; do
  count=$((count + 1))
done < app.log

# 让命令在子 shell 里跑,但循环留在主 shell
while read -r line; do
  count=$((count + 1))
done < <(cat app.log)

5. 用 trap 做退出清理

临时文件、锁文件、挂载点这类资源,靠正常流程走完来清理是不够的。trap 在脚本退出或收到信号时执行:

tmpfile=$(mktemp)
cleanup() { rm -f "${tmpfile}"; }
trap cleanup EXIT INT TERM

EXIT 覆盖正常结束和 exitINT / TERM 覆盖 Ctrl-C 和 kill。清理函数里尽量用 rm -f,避免文件已经不存在时清理本身又报错。

6. 定位问题的几个手段

  • bash -n script.sh:只做语法检查,不执行,改脚本后先跑一遍。
  • set -x:回显每条展开后的命令。在集群任务里慎用,日志量可能直接把 I/O 刷爆,通常只在局部打开再关掉。
  • $?:看上一条命令的退出码。
  • trap 'echo "Error on line $LINENO"' ERR:出错时打印行号,配合 set -e 用。
  • shellcheck:静态检查,引号、未定义变量、无用的 $? 判断这些问题它基本都能挑出来。

完整骨架

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

tmpfile=$(mktemp)
cleanup() { rm -f "${tmpfile}"; }
trap cleanup EXIT INT TERM

find /var/log/app -name "*.log" -print0 | while IFS= read -r -d '' logfile; do
  awk '/ERROR/ {print $0}' "${logfile}" >> ./error_report.log
done

这个骨架的循环体只做输出,不需要跨迭代保留变量,所以管道右侧的子 shell 不影响结果。一旦要在循环里累加计数或收集文件名,就得换成 done < <(find ...) 或输入重定向。

变量默认值、作用域、命令替换

  • ${var:-default}:变量为空时输出默认值,不改变变量本身。
  • ${var:=default}:变量为空时把默认值实际赋给变量。
: "${LOG_DIR:=/var/log/app}"   # 未设置时赋值
echo "${LOG_DIR}"

函数内变量用 local 限定作用域,避免污染全局:

process() {
  local count=0
  # ...
}

命令替换要加引号,否则换行和空格会被吞掉:

result="$(cmd)"

不加引号写成 result=$(cmd),多行输出会被压成一行、内部空白被折叠,后续按下标或行切分就全乱了。

复制全文 生成海报 Shell Bash 运维 Linux 脚本

推荐文章

程序员茄子在线接单