0.8 Bash 编程进阶与工程化
函数怎么把一段文本或一个数组交回调用方,而不靠全局变量?trap 在 exit、Ctrl-C、kill 三条路径上分别何时执行、执行几次?并发、限流、重试、加锁这些「工程件」,在 Bash 里的最小可用形态是什么?
写出一个带参数解析、文件锁、分级日志、失败重试、EXIT 清理和保留策略的备份脚本;用 shellcheck 与 bats 把它变成可回归的代码;判断一段逻辑该留在 Bash,还是应当换成 Python 或 Go。
0.7 的脚本能完成一次任务。运维的要求是完成一万次,且其中若干次会赶上磁盘满、网络抖、上一次还没跑完、有人在终端按了 Ctrl-C。这些情况下脚本是否留下半截文件、是否重复执行、是否把错误吞掉,决定了它能不能进 crontab。
Bash 的工程化不是把脚本写长,而是把失败路径写死:每个外部命令都会失败,每个临时文件都可能残留,每个信号都可能打断临界区。语言本身不提供异常、线程、连接池,你得用退出码、trap、文件锁和进程拼出等价物。这一章用一个备份脚本贯穿:前半段拆零件,后半段装成可直接运行的 backup.sh,再用 shellcheck 和 bats 固定行为。
0.8.1 函数的三种返回值
Bash 函数真正能返回的只有一个 0–255 的退出状态。文本、数组、结构体都得走别的通道。
Bash 函数有三种把结果交给调用方的方式:
- 退出状态:
return N或函数最后一条命令的状态,取值 0–255。只适合表达成功/失败或一个很小的枚举。 - 标准输出:函数里
printf,调用方用$(func)接。结果是一段文本,末尾换行会被剥掉;命令替换在子 shell 里执行。 - 写回调用方变量:用
local -n ref=$1(nameref,bash 4.3+)把数组或字符串写进调用方指定名字的变量。不靠全局变量时,这是 Bash 里不复制、不开子 shell 的「多返回值」手段。
#!/usr/bin/env bash
# 同一个函数库,三种返回方式各演示一次
set -euo pipefail
is_healthy() { [[ -r /etc/os-release ]]; } # 1) 退出状态
os_name() { . /etc/os-release; printf '%s\n' "$PRETTY_NAME"; } # 2) stdout
running_units() { # 3) nameref
local -n _out=$1
mapfile -t _out < <(
systemctl list-units --type=service --state=running --no-legend |
awk '{print $1}'
)
}
if is_healthy; then printf 'os=%s\n' "$(os_name)"; fi
running_units units
printf 'running=%d first=%s\n' "${#units[@]}" "${units[0]}"
关键行有三处。local -n _out=$1 绑定的是变量名,调用时传 units 而不是 $units;_out 这个名字要足够陌生,避免和调用方的变量撞名。mapfile 放在进程替换 < <(...) 里,它在当前 shell 执行,所以数组真的被填上了;写成 ... | mapfile -t _out 则管道右侧在子 shell 里,函数返回后数组仍是空的。$(os_name) 会剥掉末尾换行,并且 os_name 里的变量赋值不会影响调用方。
Bash 函数没有独立作用域:不加 local 的变量是全局的,cd、umask、trap、打开的文件描述符都会留给调用方。把函数当成「带名字的代码块」而不是「独立单元」,就不会奇怪为什么一个函数里的 cd 把后面的路径全带偏了。local 只隔离变量,不隔离 shell 状态。
| 通道 | 典型写法 | 能带多少数据 | 子 shell | 常见错误 |
|---|---|---|---|---|
| 退出状态 | return 1 | 0–255 整数 | 否 | 用 return "ok",报 numeric argument required |
| stdout | printf '%s\n' "$v" | 任意文本 | 是 | 不加引号接收,空格被拆开;末尾换行丢失 |
| nameref | local -n r=$1 | 任意变量 | 否 | 传了值而不是变量名,报 invalid variable name |
| 全局变量 | RESULT=... | 任意 | 否 | 并发调用互相覆盖,函数不再可重入 |
return 只接受整数。想返回「文件名」或「错误消息」,用 stdout;想返回多个字段,用 printf '%s\0' 加 mapfile -d '',或者 nameref 写回数组。不要用 echo 返回数据:echo -n、反斜杠转义、以 - 开头的字符串都会在不同 shell 上出偏差。
0.8.2 数组、关联数组与 mapfile
Bash 的索引数组是稀疏的,关联数组的遍历顺序没有保证。这两条决定了你不能把它当 Python list/dict 用。
declare -A hits=() # 关联数组必须显式声明
while IFS= read -r line; do
code=${line##* } # 取最后一个空格后的状态码
hits[$code]=$(( ${hits[$code]:-0} + 1 ))
done < access.log
for code in "${!hits[@]}"; do # ! 取键;顺序不保证
printf '%s %d\n' "$code" "${hits[$code]}"
done | sort -k1,1
${hits[$code]:-0} 里的 :-0 不是可有可无的:在 set -u 下,读取一个不存在的键会直接报错退出。${!hits[@]} 取键集合,${hits[@]} 取值集合,两者差一个 !。关联数组的键不能是空字符串。
索引数组的坑在「空位」:a=(alpha beta gamma); unset 'a[1]' 之后长度是 2,下标是 0 和 2;a+=(delta) 追加到下标 3,不是 1。"${a[@]}" 展开成独立单词并保留元素里的空格,"${a[*]}" 用 IFS 首字符拼成一个字符串。遍历一律用前者,不要写 for f in ${a[@]},也不要用 for f in $(find ...):前者会再按空格和通配符拆一次,后者在文件名含空格或换行时直接错位。
读命令输出用 mapfile:
mapfile -t logs < <(find /var/log -maxdepth 1 -type f -name '*.log' -print)
printf 'count=%d\n' "${#logs[@]}"
printf '%s\n' "${logs[@]}"
-t 去掉每行末尾的换行;不加会把换行留在元素里。文件名可能含换行时,用 find -print0 配 mapfile -d '',这是唯一在任意文件名上都正确的组合。readarray 是 mapfile 的同义词。
| 写法 | 展开结果 | 适用 |
|---|---|---|
"${a[@]}" | 每个元素一个词,保留空格 | 遍历、传参 |
"${a[*]}" | 一个词,元素用 IFS 首字符连接 | 打日志、拼显示 |
${a[@]} | 再按空格/通配符拆分 | 几乎总是错的 |
${!a[@]} | 下标或键的集合 | 关联数组遍历 |
0.8.3 trap:清理代码放在哪一行
脚本的清理逻辑只有两个要求:一定会跑,且只跑一次。trap 能同时满足,前提是写法正确。
trap 'handler' SIGNAL 把一段命令注册到信号或伪信号 EXIT 上。Bash 在「当前命令结束、准备执行下一条」的间隙执行处理器;如果正卡在 wait 上,会被可捕获信号打断并立刻执行。EXIT 在 shell 正常结束、执行 exit N、以及收到未被捕获的致命信号时触发;SIGKILL 下不触发,因为进程没有机会运行任何代码。处理器是全局的:在函数里注册的 trap 对之后整个 shell 有效。
最稳的骨架是「一个清理函数 + 两个信号转发」:
cleanup() {
local rc=$? # 第一行保存退出码,后面任何命令都会覆盖 $?
trap - EXIT INT TERM # 先摘掉,防止 exit 触发递归
[[ -n ${tmpdir:-} && -d $tmpdir ]] && rm -rf -- "$tmpdir"
log INFO "exit rc=$rc"
exit "$rc"
}
trap cleanup EXIT
trap 'exit 130' INT # 128 + SIGINT(2)
trap 'exit 143' TERM # 128 + SIGTERM(15)
四行各有理由。local rc=$? 必须在第一行,否则 trap - 已经把 $? 改掉。trap - ... 防止 cleanup 里的 exit 再触发一次 EXIT。信号处理器只把信号翻译成约定退出码再 exit,清理统一在一处执行。直接把 cleanup 同时注册给 INT 是常见错误:处理器返回后脚本会继续跑,清理过的临时目录又被使用。
trap cleanup EXIT INT TERM 在收到 SIGINT 时先以「信号处理器」身份调用 cleanup;cleanup 里如果没有 trap -,它末尾的 exit 又触发 EXIT,cleanup 第二次执行。第二次执行时 tmpdir 已经被删,rm -rf 无害,但日志会出现两条 exit rc=0,而真实的 rc 是 130。把信号转换成 exit 130、让 EXIT 只负责清理,才能保证「一次、且带正确退出码」。
信号处理有一个反直觉的边界:前台子进程会把 trap 推迟到它结束。脚本正在前台跑 tar 或 sleep 时,你 kill -TERM 脚本的 PID,bash 会等子进程退出后才执行处理器。Ctrl-C 看起来正常,是因为终端把 SIGINT 发给了整个前台进程组,子进程也死了;只杀脚本 PID 的 kill 就不会。把长命令放后台再 wait,信号才能立刻生效:"$@" & child_pid=$!; wait "$child_pid"。wait 被信号打断时返回大于 128,trap 随即执行;cleanup 里再对 child_pid 补一刀 kill -TERM,避免留下孤儿进程。这段逻辑会出现在 0.8.6 的完整脚本里。
| 信号 | 编号 | 默认动作 | 常见来源 | 脚本里的处理 |
|---|---|---|---|---|
SIGHUP | 1 | 终止 | 终端关闭、ssh 断线 | 重载配置,或忽略后 nohup |
SIGINT | 2 | 终止 | Ctrl-C | 转成 exit 130,走清理 |
SIGQUIT | 3 | 终止 + core | Ctrl-\ | 一般不捕获 |
SIGTERM | 15 | 终止 | kill、systemd、docker stop | 转成 exit 143,走清理 |
SIGKILL | 9 | 终止 | kill -9、OOM killer | 无法捕获,只能提前设计幂等 |
0.8.4 临时文件、锁与并发
临时文件的第一原则是名字不可预测。/tmp/backup.$$ 里的 PID 可以被猜到,攻击者可以先建一个同名符号链接,让你的 > 覆盖任意文件。mktemp -d "${TMPDIR:-/tmp}/backup.XXXXXX" 用 O_EXCL 创建,名字随机,目录权限默认 700(文件是 600);模板必须以至少三个 X 结尾。不要自己拼 /tmp/$RANDOM,也不要把临时目录建在会被其他用户写入的父目录下而不检查权限。需要跨重启保留的「临时」数据,应该放到 /var/tmp 或应用自己的状态目录。
锁解决的是「同一时刻只允许一个实例」。PID 文件有竞态:检查和写入之间可能被抢占,进程被 SIGKILL 后文件还会残留。flock 把互斥交给内核,进程无论怎么死,fd 关闭时锁自动释放:
exec {lock_fd}>"$LOCK_FILE" || die "cannot open $LOCK_FILE"
if ! flock -n "$lock_fd"; then
log WARN "another instance holds $LOCK_FILE; skip"
exit 0
fi
{lock_fd}> 是 bash 4.1+ 的「分配一个大于 10 的 fd」,避免和脚本自己用的 0/1/2 冲突。flock -n 不等待,拿不到就返回 1;flock -w 30 最多等 30 秒。锁文件放在本地文件系统上;NFS 上的 flock 行为依赖服务端和协议版本,跨主机互斥不要依赖它。锁的是文件描述符,不是文件名,所以 rm 掉锁文件不会释放锁,反而会让下一个进程锁住新文件,形成两个实例——锁文件不要删。
并发任务用「后台任务 + wait -n」限流。bash 5.1 起 wait -n -p var 能把「哪个 PID 结束了」写进变量:
declare -A running=()
for src in "${sources[@]}"; do
backup_one "$src" &
running[$!]=$src
if (( ${#running[@]} >= max_jobs )); then
finished=''
wait -n -p finished || log WARN "a worker failed"
unset 'running[$finished]'
fi
done
wait
running[$!]=$src 记录 PID 到源目录的映射,失败时能说清是哪一份。wait -n 返回第一个结束任务的状态;有失败要立即记下来,不能等到最后。没有任务时 wait -n 返回 127,循环条件保证不会走到那一步。
重试只对瞬时故障有意义。磁盘满、权限错、源目录不存在,重试一百次也不会成功,只会把故障拖长。给重试设次数上限、指数退避和总时限:
retry() {
local -i attempts=$1 delay=$2 n=1 rc=0
shift 2
until "$@"; do
rc=$?
if (( n >= attempts )); then
log ERROR "giving up after $n attempts (rc=$rc): $*"
return "$rc"
fi
log WARN "attempt $n/$attempts failed (rc=$rc); retry in ${delay}s"
sleep "$delay"
(( delay *= 2 ))
n+=1
done
}
until "$@" 把命令放在条件位置,set -e 不会因为失败而退出。退避从 5 秒翻倍到 10、20 秒,配合 timeout 使用时要算总预算:3 次重试加两次等待,最坏情况是「单次超时 × 3 + 15 秒」。对非幂等操作(POST、追加写、mv 到唯一名字)不要重试,否则一次成功会被执行两次。
0.8.5 参数、日志与 set -e 的裂缝
短选项用 getopts,它在当前 shell 解析,不重写 argv,也不需要外部 getopt:
while getopts ':s:d:k:nh' opt; do
case $opt in
s) src=$OPTARG ;;
d) dest=$OPTARG ;;
k) keep=$OPTARG ;;
n) dry_run=1 ;;
h) usage; exit 0 ;;
:) die "option -$OPTARG requires an argument" ;;
\?) die "unknown option -$OPTARG" ;;
esac
done
shift $((OPTIND - 1))
开头的 : 让 getopts 进入「静默模式」:缺参数时把 : 放进 opt 并把选项名放进 OPTARG,而不是自己往 stderr 打一句英文。\? 分支处理未知选项。长选项 --source 不在 getopts 能力范围内;要么手写 case 循环,要么接受短选项。解析完 shift $((OPTIND - 1)),剩下的才是位置参数。
日志要同时满足「人看」和「机器查」:时间、级别、消息,写 stderr,可选落盘:
log() {
local level=$1; shift
local line
printf -v line '%(%F %T)T %-5s %s' -1 "$level" "$*"
printf '%s\n' "$line" >&2
if [[ -n ${LOG_FILE:-} ]]; then
printf '%s\n' "$line" >>"$LOG_FILE" 2>/dev/null || true
fi
}
%(%F %T)T 是 bash 4.2+ 的时间格式,-1 表示「现在」,不 fork date。%-5s 让级别左对齐,日志更容易 awk。不要用 echo 拼日志:echo 对 -n 和反斜杠的处理在不同 shell 上不一致。日志里不要写密码、token、完整的连接串;需要记录时用 sed 打码。
set -e 不是「任何错误都退出」,它有明确的例外:
| 写法 | set -e 是否生效 | 说明 |
|---|---|---|
cmd || die | 不触发 | 命令在 || 左侧,失败由你处理 |
if cmd; then | 不触发 | 条件位置,失败是正常分支 |
! cmd | 不触发 | 取反后状态为 0 |
f 且 f 在条件里被调用 | 函数内部整段失效 | if f; then 会让 f 里的 false 不退出 |
local x=$(false) | 不触发 | local 的返回值掩盖了命令替换的状态(SC2155) |
((i++)) | 可能触发 | 表达式值为 0 时返回 1,i=0 时直接退出 |
grep no-match | 触发 | 退出码 1 是「没匹配」,不是错误 |
因此关键命令要显式检查,不能全指望 set -e:
mkdir -p -- "$dest" || die "cannot create $dest"
tar -czf "$archive" -C "$src" . || die "tar failed"
set -u 也有边界:bash 4.4 之前,空数组的 "${a[@]}" 在 set -u 下会报错;Ubuntu 24.04 的 5.2 已经修好。${var:-} 仍然是读可能未定义变量的标准写法。set -o pipefail 让管道返回最右侧非零状态,但 cmd | head 里 cmd 收到 SIGPIPE 退出 141,这是正常现象,不要当故障重试。
getopts 只认短选项,也不支持 --long。需要长选项时,用外部 getopt(util-linux)或手写 case 循环。两者真正的差别在「谁重排参数」:
| 维度 | getopts(bash 内建) | getopt(外部命令) |
|---|---|---|
| 标准 | POSIX,dash/ksh 也有 | GNU util-linux 扩展;BSD/macOS 参数不同 |
| 选项 | 短选项 -a、-a value | 短选项 + --source value |
| 参数顺序 | 遇到第一个非选项就停 | 默认重排,把选项挪到前面 |
| 解析方式 | 循环里直接读 $opt/$OPTARG | 输出规范化参数,需 eval set -- |
| 适用 | 可移植脚本、简单工具 | GNU 环境下的长选项 |
# GNU getopt:-o 短选项,--long 长选项,-- 结束
parsed=$(getopt -o s:d:nh --long source:,dest:,dry-run,help -- "$@") || exit 2
eval set -- "$parsed"
while true; do
case $1 in
-s|--source) src=$2; shift 2 ;;
-d|--dest) dest=$2; shift 2 ;;
-n|--dry-run) dry_run=1; shift ;;
-h|--help) usage; exit 0 ;;
--) shift; break ;;
*) die "unexpected: $1" ;;
esac
done
getopt 的输出经过 shell 引用,eval set -- 是标准用法;但 eval 只应处理 getopt 的受控输出,不能把用户输入直接拼进去。长选项在 BSD/macOS 的 getopt 上不可用,跨平台脚本仍应优先 getopts,或手写只认自己那几个选项的 case 循环。
0.8.6 完整脚本:backup.sh
把前面的零件装起来。脚本做四件事:参数解析、单实例锁、带重试的 tar 快照、按天保留。归档先写到目标目录下的 .incoming.*,成功后再 mv 到正式名字,保证别人永远看不到半截文件。
#!/usr/bin/env bash
# backup.sh — 目录快照备份:加锁、日志、重试、原子发布、保留
set -Eeuo pipefail
readonly PROG=${0##*/}
readonly STATE_DIR=${STATE_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/backup}
readonly LOG_FILE=${LOG_FILE:-$STATE_DIR/backup.log}
readonly LOCK_FILE=${LOCK_FILE:-$STATE_DIR/backup.lock}
src='' dest='' keep=7 dry_run=0 tmpdir='' child_pid='' lock_fd=''
log() {
local line
printf -v line '%(%F %T)T %-5s %s' -1 "$1" "${*:2}"
printf '%s\n' "$line" >&2
if [[ -n ${LOG_FILE:-} ]]; then
printf '%s\n' "$line" >>"$LOG_FILE" 2>/dev/null || true
fi
}
die() { log ERROR "$*"; exit 1; }
usage() {
cat <<EOF
用法: $PROG -s SRC -d DEST [-k DAYS] [-n]
-s SRC 要备份的目录(必填) -d DEST 归档存放目录(必填)
-k DAYS 保留天数,默认 $keep -n 试运行,只打印计划
EOF
}
cleanup() {
local rc=$?
trap - EXIT INT TERM
if [[ -n $child_pid ]]; then kill -TERM "$child_pid" 2>/dev/null || true; fi
if [[ -n $tmpdir && -d $tmpdir ]]; then rm -rf -- "$tmpdir"; fi
log INFO "exit rc=$rc"
exit "$rc"
}
on_signal() { log WARN "received SIG$1, aborting"; exit "$2"; }
retry() {
local -i attempts=$1 delay=$2 n=1 rc=0
shift 2
until "$@"; do
rc=$?
if (( n >= attempts )); then
log ERROR "giving up after $n attempts (rc=$rc): $*"
return "$rc"
fi
log WARN "attempt $n/$attempts failed (rc=$rc); retry in ${delay}s"
sleep "$delay"
(( delay *= 2 ))
n+=1
done
}
snapshot() {
local archive=$tmpdir/$1.tar.gz rc=0
tar --one-file-system --acls --xattrs -C "$src" -czf "$archive" . &
child_pid=$!
wait "$child_pid" || rc=$?
child_pid=''
if (( rc == 1 )); then
log WARN "tar: files changed while reading; archive kept"
return 0
fi
return "$rc"
}
main() {
local opt name
mkdir -p -- "$STATE_DIR" || die "cannot create $STATE_DIR"
while getopts ':s:d:k:nh' opt; do case $opt in
s) src=$OPTARG ;; d) dest=$OPTARG ;; k) keep=$OPTARG ;;
n) dry_run=1 ;; h) usage; exit 0 ;;
:) die "option -$OPTARG requires an argument" ;;
\?) die "unknown option -$OPTARG" ;;
esac; done
shift $((OPTIND - 1))
[[ -n $src && -n $dest ]] || { usage >&2; exit 2; }
[[ -d $src ]] || die "source is not a directory: $src"
[[ $keep =~ ^[0-9]+$ ]] || die "-k must be a non-negative integer"
mkdir -p -- "$dest" || die "cannot create $dest"
exec {lock_fd}>"$LOCK_FILE" || die "cannot open $LOCK_FILE"
if ! flock -n "$lock_fd"; then
log WARN "another run holds $LOCK_FILE; skip"
exit 0
fi
name="backup-$(date +%Y%m%dT%H%M%S)"
tmpdir=$(mktemp -d "$dest/.incoming.XXXXXX") || die "mktemp failed"
log INFO "start src=$src dest=$dest keep=${keep}d dry_run=$dry_run"
if (( dry_run )); then
log INFO "would publish $dest/$name.tar.gz"
return 0
fi
retry 3 5 snapshot "$name"
mv -f -- "$tmpdir/$name.tar.gz" "$dest/$name.tar.gz"
log INFO "published $dest/$name.tar.gz"
find "$dest" -maxdepth 1 -type f -name 'backup-*.tar.gz' \
-mtime +"$keep" -print -delete
}
trap cleanup EXIT
trap 'on_signal INT 130' INT
trap 'on_signal TERM 143' TERM
main "$@"
值得逐行看的设计点:
- 锁在
mkdir之后、干活之前。状态目录先建出来,锁文件才有地方放;锁拿到之后才建临时目录,避免「拿到锁之前就留下垃圾」。 - 临时目录建在
$dest里面。mv在同一文件系统内是rename,原子;建在/tmp时跨文件系统的mv退化成「复制 + 删除」,别人仍可能看到半截文件。 tar放后台再wait。这样SIGTERM能立刻进cleanup,由它杀掉 tar,而不是等 tar 自己跑完。tar退出码 1 表示「读的时候有文件变了」,对活动目录是常态,归档仍可用;2 才是致命错误。- 保留策略按天数。
-mtime +7删除修改时间超过 7×24 小时的归档。按个数保留要处理文件名里的空格,复杂度更高,脚本里不展开。
跑一遍,先看静态检查、试运行和真实备份:
$ shellcheck -x backup.sh && echo "shellcheck: clean" shellcheck: clean $ chmod +x backup.sh $ ./backup.sh -n -s /etc -d /tmp/bk 2025-03-02 10:12:01 INFO start src=/etc dest=/tmp/bk keep=7d dry_run=1 2025-03-02 10:12:01 INFO would publish /tmp/bk/backup-20250302T101201.tar.gz 2025-03-02 10:12:01 INFO exit rc=0 $ ./backup.sh -s /etc -d /tmp/bk 2025-03-02 10:12:03 INFO start src=/etc dest=/tmp/bk keep=7d dry_run=0 2025-03-02 10:12:04 INFO published /tmp/bk/backup-20250302T101203.tar.gz 2025-03-02 10:12:04 INFO exit rc=0 $ tar -tzf /tmp/bk/backup-20250302T101203.tar.gz | head -3 ./ ./hostname ./hosts $ ./backup.sh -s /etc -d /tmp/bk & [1] 41217 $ ./backup.sh -s /etc -d /tmp/bk 2025-03-02 10:12:09 WARN another run holds /home/me/.local/state/backup/backup.lock; skip 2025-03-02 10:12:09 INFO exit rc=0
shellcheck: clean 说明脚本没有命中常见缺陷;-x 让 shellcheck 跟进 source 的文件。试运行退出码 0、归档目录为空;真实运行生成以时间戳命名的文件;第三个进程拿不到锁,按设计跳过而不是排队。tar -tzf 能列出内容,说明 gzip 完整、归档没有截断。
中途 kill -TERM 时,.incoming.* 会被 cleanup 删掉,退出码是 143 而不是 0。这是「可被调度器管理」的底线:systemd 看到 143 会记为失败并按 Restart= 处理,cron 看到 0 才会认为这一轮正常。
脚本的行为需要测试固定下来。bats 是最小可用的选择,两个用例覆盖 dry-run 无副作用和真实产出:
#!/usr/bin/env bats
# test/backup.bats — 运行: bats test/backup.bats
setup() {
export STATE_DIR="$BATS_TEST_TMPDIR/state"
mkdir -p "$BATS_TEST_TMPDIR/src"
printf 'hello\n' >"$BATS_TEST_TMPDIR/src/a.txt"
}
@test "dry-run 不生成归档" {
run "$BATS_TEST_DIRNAME/../backup.sh" -n \
-s "$BATS_TEST_TMPDIR/src" -d "$BATS_TEST_TMPDIR/dest"
[ "$status" -eq 0 ]
run find "$BATS_TEST_TMPDIR/dest" -name '*.tar.gz'
[ -z "$output" ]
}
@test "真实运行发布一个可读归档" {
run "$BATS_TEST_DIRNAME/../backup.sh" \
-s "$BATS_TEST_TMPDIR/src" -d "$BATS_TEST_TMPDIR/dest"
[ "$status" -eq 0 ]
run tar -tzf "$BATS_TEST_TMPDIR"/dest/*.tar.gz
[ "$status" -eq 0 ]
[[ "$output" == *"./a.txt"* ]]
}
$ sudo apt install -y bats $ bats --tap test/backup.bats 1..2 ok 1 - dry-run 不生成归档 ok 2 - 真实运行发布一个可读归档
BATS_TEST_TMPDIR 是每个用例独立的临时目录,bats 负责清理,用例之间不会互相污染。run 把命令的 stdout、stderr、退出码收进 $output、$status,断言就写在这两个变量上。两个用例不追求覆盖率,它们锁定的是契约:dry-run 必须无副作用,成功必须产出可读归档。
- 「
tar退出 0 才说明备份成功」——GNU tar 的 1 表示「读取期间文件有变化」,对活动目录很常见,归档通常仍可用;2 才是致命错误。把 1 当失败会让告警失去意义。 - 「
mv一定是原子的」——只在同一文件系统内是rename。跨文件系统的mv是复制加删除,中途失败会留下半截文件。临时目录必须建在目标目录里。 - 「
flock可以锁 NFS」——Linux 的flock在 NFS 上依赖服务端实现,跨主机互斥不可靠。跨主机请用数据库锁、etcd/Redis 锁或专用锁服务。 - 「
trap ... EXIT在kill -9时也会执行」——SIGKILL不可捕获,进程没有任何机会运行清理。所有「必须完成」的步骤都要设计成幂等,靠下次运行补上。 - 「
set -e能抓住所有错误」——条件语句、||、!、local x=$(...)、((i++))都会绕过它。关键命令仍要显式|| die。
0.8.7 shellcheck、调试与可移植性
shellcheck 抓的是「语法合法但语义危险」的写法,最常命中的几类:
| 代码 | 含义 | 修法 |
|---|---|---|
| SC2086 | 未加引号的变量会分词和通配 | "$var",数组用 "${a[@]}" |
| SC2155 | local x=$(cmd) 掩盖返回码 | 拆成两行,先赋值再 local |
| SC2181 | 用 $? 判断而不是直接 if cmd | if cmd; then |
| SC2046 | $(cmd) 未加引号 | "$(cmd)" |
| SC2016 | 单引号里的 $ 不会展开 | 需要展开就改双引号 |
确实要保留某个写法时,用注释禁用并写理由,不要全局关规则:
# shellcheck disable=SC2086 # 这里需要分词:$flags 是多个独立选项
gcc $flags -o app app.c
格式统一交给 shfmt -i 4 -ci -sr,把缩进、case 分支缩进、重定向位置固定下来,代码评审就不用讨论空格。提交前跑 bash -n script.sh 做语法检查,shellcheck -x 做静态检查,bats 做行为检查,三条都过再合并。
调试用 set -x,但默认的 + cmd 不带行号和函数名。改 PS4 并把 trace 写到独立 fd,避免和业务输出混在一起:
exec {xtrace_fd}>"$tmpdir/trace.log"
PS4='+ ${BASH_SOURCE##*/}:${LINENO}:${FUNCNAME[0]:-main}: '
BASH_XTRACEFD=$xtrace_fd
set -x
BASH_SOURCE 在函数里是定义函数的文件,FUNCNAME[0] 是当前函数名。trace 写进文件后,tail -f 看执行路径,业务 stdout 仍然是干净的。生产脚本不要长期开着 set -x:它会打印变量值,可能把密码写进日志。
需要严格 POSIX 时,/bin/sh 在 Ubuntu 上是 dash,下面这些都不存在:[[ ]]、数组、local、$'...'、pipefail、{fd}>、mapfile、${var//a/b}。用 checkbashisms 或 dash -n 检查,把 shebang 改成 #!/bin/sh 之前先确认没有依赖 bash 扩展。
有一类任务不该用 Bash:解析 JSON/YAML、复杂数据结构、浮点计算、需要线程或连接池、需要细粒度错误类型。判断标准很简单:脚本超过 300 行,或者需要「返回值不只是 0/1」,就换 Python。Bash 的强项是编排已有命令、搬文件、拼管道;把这些强项之外的东西硬塞进来,最后会得到一个没有类型、没有异常、无法测试的怪物。
0.8.8 函数语义:返回码、nameref 与动态作用域
Bash 函数不是「独立单元」,而是「带名字的命令序列」。它只返回 0–255 的退出状态,其他结果必须走 stdout 或写回调用方变量。
return 的边界:
| 写法 | 结果 |
|---|---|
return 0 | 成功 |
return 1 | 一般失败 |
return 255 | 合法 |
return 256 | 取模 256,变成 0 |
return -1 | 取模 256,变成 255 |
return "ok" | 报 numeric argument required,函数退出码 2 |
函数最后一条命令的退出码就是默认返回值;没有 return 时不要假设它是 0。return 只能用于函数或 source 的文件,在脚本顶层用 exit。
is_root() { [[ $EUID -eq 0 ]]; }
if is_root; then printf 'root\n'; fi # 直接看退出码
要返回字符串,用 stdout;调用方必须用 $(...) 接,并记住它会去掉末尾换行、在子 Shell 执行:
os_name() { . /etc/os-release; printf '%s\n' "$PRETTY_NAME"; }
name=$(os_name)
要返回数组或避免子 Shell 复制,用 nameref(bash 4.3+):
collect_units() {
local -n out=$1
mapfile -t out < <(systemctl list-units --type=service --state=running --no-legend | awk '{print $1}')
}
collect_units units
printf '%d\n' "${#units[@]}"
local -n out=$1 绑定的是变量名,调用时传 units 而不是 $units;传值会报 invalid variable name。nameref 变量本身也是变量,递归或同名时会绕回自己;用足够陌生的名字(_out、__result),必要时 unset -n out 解除绑定。
Bash 的 local 是动态作用域:被调用函数能看到调用者的局部变量,而不是定义处的变量。这与 C/Java 的词法作用域不同:
inner() { printf 'seen=%s\n' "${tmp:-unset}"; }
outer() { local tmp=outer; inner; }
tmp=global; outer # seen=outer
如果 inner 也声明 local tmp,它会遮蔽外层的;local 只在当前函数调用栈有效,函数返回后恢复。调试「为什么函数里看到了不该看到的变量」时,先查调用链上的 local。
local 的属性:local -r 只读、local -i 整数、local -a 索引数组、local -A 关联数组、local -n nameref。local var 不带值会声明一个空变量,但它会遮蔽同名全局变量;需要「保存再恢复」时用 local -(bash 4.4+)保存 Shell 选项,或手动 local old=$var。
递归可以,但没有尾调用优化:
fact() {
local n=$1
if (( n <= 1 )); then printf '1\n'; return; fi
local sub
sub=$(fact $((n - 1))) || return
printf '%s\n' $((n * sub))
}
递归深度受栈限制,大数阶乘会溢出整数;Bash 算术是有符号 64 位(通常),超过就回绕。递归只适合目录遍历、树形结构这类天然分层的问题;数值计算用 awk/Python。
函数可以导出给子 Shell:export -f fact,子 bash 能直接调用;declare -f fact 打印定义,declare -F 列出所有函数名。函数名会覆盖同名命令(包括内建),定义前用 type -a name 检查;需要调用被覆盖的命令时用 command name 或 builtin name。
0.8.9 数组的下标、稀疏性与 mapfile 选项
索引数组是稀疏的:
a=(alpha beta gamma)
unset 'a[1]'
printf 'len=%d keys=%s\n' "${#a[@]}" "${!a[*]}" # len=2 keys=0 2
a+=(delta) # 追加到 3,不是 1
printf '%s\n' "${a[@]}"
"${a[@]}" 每个元素一个词并保留空格,"${a[*]}" 用一个词按 IFS 连接。遍历一律用前者;for x in ${a[@]} 会再分词与通配。元素个数用 #a[@] 取,${!a[@]} 是下标集合,${a[@]:1:2} 是切片。
关联数组必须先声明:
declare -A hits=()
hits["404"]=3
hits["500"]=$(( ${hits["500"]:-0} + 1 ))
for code in "${!hits[@]}"; do printf '%s %d\n' "$code" "${hits[$code]}"; done | sort
${hits[$code]:-0} 的 :-0 在 set -u 下是必需的:读取不存在的键会报 unbound variable。键不能是空字符串;键含空格时赋值写 h["$k"],读取也加引号。关联数组遍历顺序未定义,排序输出给 sort。
mapfile/readarray 把行读进数组:
| 选项 | 作用 | 注意 |
|---|---|---|
-t | 去掉行尾换行 | 不加则元素含 \n |
-d DELIM | 自定义分隔符 | -d '' 读 NUL,配 find -print0 |
-n N | 最多读 N 行 | 0 表示全部 |
-s N | 跳过前 N 行 | |
-O N | 从下标 N 开始写 | |
-C CALLBACK | 每读 quantum 行调用一次 | 配 -c |
-c QUANTUM | 回调间隔 | 默认 5000 |
mapfile -t lines < file.txt
mapfile -d '' -t files < <(find /srv -type f -print0)
printf 'lines=%d files=%d\n' "${#lines[@]}" "${#files[@]}"
mapfile -t 只去掉每行末尾的一个换行;文件末尾没有换行时最后一行仍会进数组。mapfile 在管道右侧会在子 Shell 执行,数组填不到父 Shell:
printf 'a\nb\n' | mapfile -t arr # arr 在父 Shell 是空的
mapfile -t arr < <(printf 'a\nb\n') # 正确
read -a 也能读数组,但它按 IFS 分词,不能保留整行:
IFS=, read -ra parts <<< "a,b,c"
printf '%d %s\n' "${#parts[@]}" "${parts[1]}"
printf '%s\0' "${a[@]}" 是数组的 NUL 序列化,配 mapfile -d '' 可往返;"${a[@]@Q}" 给每个元素加 Shell 引用,适合日志与重放。
0.8.10 trap 全解:信号、EXIT、ERR 与 DEBUG
trap 'handler' SIGNAL... 把一段命令注册到信号或伪信号上。处理器在「当前命令结束、准备执行下一条」的间隙运行;卡在 wait 上时会被可捕获信号打断并立即执行。
| 信号/伪信号 | 编号 | 触发 | 能否捕获 |
|---|---|---|---|
EXIT | 0 | Shell 正常退出、exit N、未捕获的致命信号 | 是 |
ERR | — | 命令失败(受 set -e 例外约束) | 是 |
DEBUG | — | 每条命令执行前 | 是(set -T 继承到函数) |
RETURN | — | 函数/source 返回 | 是(set -T) |
SIGHUP | 1 | 终端关闭、ssh 断线 | 是 |
SIGINT | 2 | Ctrl-C | 是 |
SIGQUIT | 3 | Ctrl-\ | 是 |
SIGTERM | 15 | kill、systemd、docker stop | 是 |
SIGKILL | 9 | kill -9、OOM | 否 |
SIGSTOP | 19 | 作业控制 | 否 |
trap -l # 列出信号名与编号
trap -p # 打印当前 trap
trap - SIGINT # 恢复默认
trap '' SIGINT # 忽略
trap -p 在交互式 Shell 里能确认「到底注册了什么」;trap - SIGINT 与 trap -- SIGINT 等价,单/双连字符都表示恢复默认。trap '' SIG 是忽略,不是恢复默认。
最稳的清理骨架:
cleanup() {
local rc=$?
trap - EXIT INT TERM
[[ -n ${tmpdir:-} && -d $tmpdir ]] && rm -rf -- "$tmpdir"
log INFO "exit rc=$rc"
exit "$rc"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
local rc=$? 必须在第一行,后面任何命令都会覆盖 $?;trap - 防止 cleanup 里的 exit 再次触发 EXIT;信号处理器只把信号翻译成 130/143,清理统一在一处。直接把 cleanup 同时注册给 INT 是常见错误:处理器返回后脚本会继续跑,清理过的临时目录又被使用。
两条反直觉的边界:
- 前台子进程会把 trap 推迟到它结束。脚本正在前台跑
tar/sleep时,kill -TERM脚本 PID,bash 会等子进程退出后才执行处理器。Ctrl-C 看起来正常,是因为终端把SIGINT发给了整个前台进程组;只杀脚本 PID 的kill就不会。把长命令放后台再wait,信号才能立刻生效。 SIGKILL不触发EXIT。进程没有机会运行任何代码;所有「必须完成」的步骤都要设计成幂等,靠下次运行补上。
ERR trap 受 set -e 例外约束:条件位置、&&/|| 左侧、!、local x=$(...)、((i++)) 都不会触发。set -E(errtrace)让 ERR 继承到函数/子 Shell;set -T(functrace)让 DEBUG/RETURN 继承。
set -Eeuo pipefail
trap 'printf "ERR %s:%s: %s\n" "${BASH_SOURCE[0]}" "$LINENO" "$BASH_COMMAND" >&2' ERR
DEBUG trap 可以打时间戳或记录命令,但每条命令都触发,开销大:
trap 'printf "+ %(%T)T %s\n" -1 "$BASH_COMMAND" >&2' DEBUG
set -T # 让函数内部也触发
生产脚本不要在 DEBUG trap 里打印变量值,可能泄漏密码;需要时用 set -x + BASH_XTRACEFD 写到受控文件。
0.8.11 mktemp、flock 与单实例边界
临时文件的第一原则是名字不可预测。/tmp/backup.$$ 的 PID 可以猜到,攻击者可以先建同名符号链接,让 > 覆盖任意文件。mktemp 用 O_EXCL 创建,名字随机,文件默认 600、目录默认 700:
tmpfile=$(mktemp "${TMPDIR:-/tmp}/app.XXXXXX") || exit 1
tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/app.XXXXXX") || exit 1
trap 'rm -rf -- "$tmpdir" "$tmpfile"' EXIT
模板必须以至少三个 X 结尾;-d 建目录,-p DIR 指定父目录,--suffix=.log 加后缀。-t 是旧写法(等价于在 TMPDIR 下建),新脚本直接用完整模板更清楚。TMPDIR 可能指向空间不足或 noexec 的分区,关键任务要检查可用空间。
锁解决「同一时刻只允许一个实例」。PID 文件有竞态:检查与写入之间可能被抢占,进程被 SIGKILL 后文件残留,PID 还会被复用。flock 把互斥交给内核,进程无论怎么死,fd 关闭时锁自动释放:
exec {lock_fd}>"$LOCK_FILE" || die "cannot open $LOCK_FILE"
if ! flock -n "$lock_fd"; then
log WARN "another instance holds $LOCK_FILE; skip"
exit 0
fi
{lock_fd}> 是 bash 4.1+ 的「分配一个大于 10 的 fd」;flock -n 不等待,-w 30 最多等 30 秒。锁的是打开文件描述,不是文件名:rm 掉锁文件不会释放锁,反而让下一个进程锁住新 inode,形成两个实例——锁文件不要删。
flock 的常用选项:
| 选项 | 作用 |
|---|---|
-n / --nonblock | 拿不到立即失败 |
-w N / --timeout N | 最多等 N 秒 |
-x / -s | 排他/共享锁 |
-u | 解锁 |
-o / --close | 执行命令前关闭 fd,子进程不继承锁 |
-c CMD | 在锁内执行命令 |
-E N | 拿不到锁时的退出码 |
# 包装器:拿不到锁就退出 75
flock -n -E 75 /var/lock/myapp.lock -c '/usr/local/bin/myapp --once'
NFS 上的 flock 依赖服务端实现,跨主机互斥不可靠;跨主机用数据库锁、etcd/Redis 锁或专用锁服务。本地容器共享同一个 /var/lock 时也要确认挂载与权限。
mkdir 也能做原子锁:mkdir /var/lock/myapp 成功即持锁,失败说明有人;释放用 rmdir。它的缺点是进程崩溃后目录残留,需要超时/心跳清理。O_EXCL 文件锁同理。选择顺序:本机单实例优先 flock;需要跨主机用外部锁服务;mkdir 只适合短临界区。
幂等是「锁 + 重试 + 原子发布」的底线:同一份输入跑两次,结果一致;失败后重跑,不会重复追加。备份脚本用「临时文件 + mv 原子替换」,数据库迁移用「版本表 + 事务」,文件处理用「先写 .tmp 再 rename」。
0.8.12 后台任务、wait -n 与限流
Bash 没有线程,并发就是「后台进程 + wait」。最小骨架:
pids=()
for src in "${sources[@]}"; do
backup_one "$src" &
pids+=($!)
done
rc=0
for pid in "${pids[@]}"; do
wait "$pid" || rc=1
done
exit "$rc"
$! 是最近一个后台进程的 PID;wait 收尸并返回该进程状态。没有 wait 的后台进程会变成孤儿,被 init/systemd 收养;脚本退出后它们仍在跑,可能写坏文件。
限流用 wait -n(bash 4.3+):
declare -A running=()
for src in "${sources[@]}"; do
backup_one "$src" &
running[$!]=$src
if (( ${#running[@]} >= max_jobs )); then
finished=''
wait -n -p finished || log WARN "a worker failed"
unset 'running[$finished]'
fi
done
wait
wait -n -p var 是 bash 5.1+,把「哪个 PID 结束」写进 var;没有 -p 时只知道「有一个结束了」。wait -n 在没有子进程时返回 127,循环条件要保证至少有一个。wait -f(5.1+)等待作业真正终止而不是状态变化,适合 kill 后确认。
jobs -l 看作业号与 PID,disown 把作业从 Shell 作业表移除(终端关闭不再收到 SIGHUP),nohup 忽略 SIGHUP 并把输出追加到 nohup.out。需要完全脱离终端用 setsid:
setsid ./long-task.sh </dev/null >/var/log/long-task.log 2>&1 &
timeout 给单个任务设上限,超时返回 124:
timeout --signal=TERM --kill-after=10 300 ./task || {
rc=$?; (( rc == 124 )) && log WARN "task timed out"; exit "$rc";
}
--kill-after=10 表示 TERM 后 10 秒还没退出就 KILL。timeout 只杀直接子进程,不杀它派生的进程组;需要杀整组时用 setsid + kill -- -PGID,或让任务自己处理 TERM。
coproc 是双向管道:
coproc WORKER { while IFS= read -r line; do printf 'got:%s\n' "$line"; done; }
printf 'a\nb\n' >&"${WORKER[1]}"
exec {WORKER[1]}>&-
while IFS= read -r out; do printf '%s\n' "$out"; done <&"${WORKER[0]}"
wait "$WORKER_PID"
必须关闭写端,否则读循环等不到 EOF;coproc 的 fd 在数组里,COPROC_PID 是 PID。它适合父子对话,不适合高并发;高并发用 xargs -P 或专门工具。
SIGCHLD trap 可以在子进程结束时收到通知,但 Bash 的 wait 与 trap 交互复杂,容易丢事件;优先用 wait -n 轮询。kill -0 PID 只检查「进程是否存在且有权发信号」,不能区分僵尸;ps -o stat= -p PID 看 Z 状态。
0.8.13 外部 getopt 与长选项
getopts 只认短选项,不支持 --source。需要长选项时,用 util-linux 的 getopt(注意不是 getopts):
parsed=$(getopt -o s:d:k:nh --long source:,dest:,keep:,dry-run,help -- "$@") || exit 2
eval set -- "$parsed"
while true; do
case $1 in
-s|--source) src=$2; shift 2 ;;
-d|--dest) dest=$2; shift 2 ;;
-k|--keep) keep=$2; shift 2 ;;
-n|--dry-run) dry_run=1; shift ;;
-h|--help) usage; exit 0 ;;
--) shift; break ;;
*) die "unexpected: $1" ;;
esac
done
getopt 的输出已经过 Shell 引用,eval set -- 是标准用法;但 eval 只应处理 getopt 的受控输出,绝不能把用户输入直接拼进去。-o 是短选项串,--long 是长选项列表,: 表示需要参数。
| 维度 | getopts(内建) | getopt(外部) |
|---|---|---|
| 标准 | POSIX,dash/ksh 也有 | GNU util-linux 扩展 |
| 长选项 | 不支持 | 支持 |
| 参数重排 | 遇到第一个非选项停止 | 默认重排,把选项挪到前面 |
| 输出 | 直接设置 opt/OPTARG | 规范化参数,需要 eval set -- |
| 可选参数 | 不支持 | :: |
| 跨平台 | 好 | BSD/macOS 参数不同,长选项不可用 |
getopt 默认会重排参数,POSIXLY_CORRECT=1 或 + 开头的 optstring 让它遇到第一个非选项就停。这个行为差异会让「cmd file -n」在 GNU 上解析 -n、在 BSD 上不解析;脚本要明确选项必须在操作数之前,或用 -- 分隔。
长选项也可以手写,避免 eval:
while (( $# )); do
case $1 in
--source=*) src=${1#*=}; shift ;;
--source) src=${2:?missing value}; shift 2 ;;
--dest=*) dest=${1#*=}; shift ;;
-n|--dry-run) dry_run=1; shift ;;
--) shift; break ;;
-*) die "unknown option: $1" ;;
*) break ;;
esac
done
operands=("$@")
手写解析可读、可控,适合选项少于十个的脚本;选项多、需要 --help 自动生成时再用 getopt。无论哪种,解析完都要校验必填项、拒绝未知选项、把剩余参数当操作数,并用 -- 防止文件名以 - 开头。
0.8.14 shellcheck 规则、trace 与 POSIX 边界
shellcheck 抓的是「语法合法但语义危险」的写法。高频规则:
| 规则 | 含义 | 修法 |
|---|---|---|
| SC2086 | 未加引号的变量会分词/通配 | "$var"、"${a[@]}" |
| SC2155 | local x=$(cmd) 掩盖返回码 | 先 local x 再赋值 |
| SC2181 | 用 $? 判断而不是直接 if cmd | if cmd; then |
| SC2046 | $(cmd) 未加引号 | "$(cmd)" |
| SC2016 | 单引号里的 $ 不会展开 | 需要展开就双引号 |
| SC2164 | cd 失败后继续 | `cd … |
| SC2068 | $@ 未加引号 | "$@" |
| SC2145 | $@ 混在字符串里 | "$*" 或分开打印 |
| SC2128 | 数组当标量用 | ${a[0]} 或 "${a[@]}" |
| SC2034 | 变量未使用 | 删除或说明 |
| SC1090/SC1091 | 无法跟随 source | # shellcheck source=lib.sh |
| SC3043 | POSIX sh 用了 local | 改 bash 或去掉 |
| SC3010 | POSIX sh 用了 [[ | 改 [ 或 bash |
确实要保留某个写法时,用注释禁用并写理由:
# shellcheck disable=SC2086 # 这里需要分词:$flags 是多个独立选项
gcc $flags -o app app.c
不要全局 disable=all;那等于关掉检查。CI 里用 shellcheck -x -S warning script.sh,-x 跟随 source,-S 设定最低级别;返回非零就阻断合并。
调试工具链:
| 工具 | 用途 |
|---|---|
bash -n | 只做语法检查,不执行 |
bash -v | 打印读入的原始行 |
bash -x | 打印展开后的命令 |
PS4 | 定制 trace 前缀,含文件/行号/函数 |
BASH_XTRACEFD | 把 trace 写到独立 fd |
set -T | 让 DEBUG/RETURN 继承到函数 |
trap ... DEBUG | 每条命令前执行 |
caller/BASH_LINENO | 在函数里取调用栈 |
shellcheck | 静态缺陷 |
shfmt | 统一格式 |
bats/shellspec | 行为测试 |
exec {xtrace_fd}>"$tmpdir/trace.log"
PS4='+ ${BASH_SOURCE##*/}:${LINENO}:${FUNCNAME[0]:-main}: '
BASH_XTRACEFD=$xtrace_fd
set -x
BASH_SOURCE 在函数里是定义函数的文件,FUNCNAME[0] 是当前函数名,BASH_LINENO 是调用行号。trace 写进文件后,tail -f 看执行路径,业务 stdout 仍然干净。生产脚本不要长期开 set -x:它会打印变量值,可能把密码写进日志。
可移植性:Ubuntu 的 /bin/sh 是 dash,以下都不存在:
| 特性 | bash | dash/POSIX sh |
|---|---|---|
[[ ]] | 有 | 无 |
| 数组 | 有 | 无 |
local | 有 | 非 POSIX(dash 支持但别依赖) |
$'...' | 有 | 无 |
pipefail | 有 | 无 |
{fd}> | 有 | 无 |
mapfile | 有 | 无 |
${var//a/b} | 有 | 无 |
source | 有 | 用 . |
function 关键字 | 有 | 无 |
<<< | 有 | 无 |
<(...) | 有 | 无 |
用 checkbashisms 或 dash -n 检查,把 shebang 改成 #!/bin/sh 之前先确认没有依赖 bash 扩展。反过来,脚本用了 [[ ]] 就明确写 #!/usr/bin/env bash,不要既写 sh 又用 bash 语法。
判断「该不该换语言」:脚本超过 300 行,或需要「返回值不只是 0/1」、JSON/YAML 解析、浮点计算、线程/连接池、细粒度错误类型,就换 Python。Bash 的强项是编排已有命令、搬文件、拼管道;把这些之外的东西硬塞进来,最后会得到一个没有类型、没有异常、无法测试的怪物。
0.8.15 函数库与 source
库文件是一组函数,用 source/. 在当前 Shell 加载:
# lib/log.sh
log() {
local level=$1; shift
printf '%(%F %T)T %-5s %s\n' -1 "$level" "$*" >&2
}
die() { log ERROR "$*"; exit 1; }
# main.sh
#!/usr/bin/env bash
set -euo pipefail
lib_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
# shellcheck source=lib/log.sh
. "$lib_dir/lib/log.sh"
BASH_SOURCE[0] 是当前文件路径,dirname 取目录,cd ... && pwd 转绝对路径。不用 $0:source 时 $0 是调用者的名字,不是库文件。realpath 也能做,但依赖 coreutils;cd + pwd 更可移植。
库的设计约束:
- 库不要
exit。加载失败用return 1,让调用方决定;库里的die只应在明确的「不可恢复」场景用,且文档写清。 - 库不要全局
set -e。它会把调用方的错误处理策略改掉;库函数内部需要严格模式时,用子 Shell 或local -保存/恢复选项。 - 函数名加前缀。
log()、die()太通用,容易和调用方或其他库冲突;用mylib_log、mylib_die,或mylib::log(bash 允许::在函数名里)。 - 变量用
local。库函数里的循环变量、临时变量不加local会污染调用方;需要返回值的 nameref 用陌生名字。 - 不要依赖调用方的 cwd/IFS/umask。函数内显式设置并在返回前恢复;
local IFS=:只影响函数。 - trap 的所有权。库不要覆盖调用方的
EXITtrap;需要清理时提供mylib_cleanup让调用方注册,或保存旧 trap 并在结束时恢复。
防止库被直接执行:
if [[ ${BASH_SOURCE[0]} == "$0" ]]; then
printf 'this file is a library; source it\n' >&2
exit 2
fi
source 与 bash lib.sh 的差别:前者在当前 Shell 定义函数、改变变量;后者在子 Shell 执行,函数定义随子 Shell 退出消失。source 失败(文件不存在、语法错误)默认不会让调用方退出(除非 set -e),要显式检查:
. "$lib" || { printf 'cannot load %s\n' "$lib" >&2; exit 1; }
库的测试:用 bats 加载库后直接调用函数,断言 stdout/退出码;库函数尽量纯(输入参数、输出 stdout、不改全局),可测试性会好很多。
0.8.16 backup.sh 逐行复盘
0.8.6 的脚本能跑,但「能跑」和「知道每一行在防什么」是两件事。下表按执行顺序拆开,列出去掉这一行会出什么事。
| 代码段 | 作用 | 去掉/写错的后果 |
|---|---|---|
#!/usr/bin/env bash | 用 PATH 里的 bash | 写成 sh 会在 [[ ]]、数组处炸 |
set -Eeuo pipefail | 严格模式 + ERR 继承 | 少了 -E,函数里的失败不触发外层 trap;少了 -u,空变量静默通过 |
readonly PROG/STATE_DIR/... | 常量与默认值 | 变量被后续覆盖,日志/锁跑到别处 |
log() | 时间、级别、stderr、可选落盘 | 用 echo 拼日志,-n/反斜杠行为随 Shell 变 |
die() | 记录后退出 1 | 直接 exit 没有上下文,排障只能猜 |
usage() + here-doc | 用法写到 stderr | 写到 stdout 会污染管道数据 |
cleanup() 首行 local rc=$? | 保存真实退出码 | 后面任何命令都会覆盖 $?,日志永远 rc=0 |
trap - EXIT INT TERM | 防止递归 | cleanup 里的 exit 再次触发 EXIT,清理跑两次 |
kill -TERM "$child_pid" | 中断时杀掉 tar | 留下孤儿 tar,继续写临时目录 |
retry() 的 until "$@" | 失败重试,条件位置不触发 set -e | 写成 "$@" 裸命令,第一次失败就退出,重试没机会 |
snapshot() 后台 tar + wait | 让信号能打断 | 前台 tar 会把 trap 推迟到它结束 |
tar 退出码 1 视为成功 | 读取期间文件变化是常态 | 把 1 当失败,活动目录永远告警 |
getopts ':s:d:k:nh' | 静默模式短选项 | 少冒号,缺参数时 getopts 自己打英文错误 |
shift $((OPTIND - 1)) | 去掉已解析选项 | 位置参数里混进 -s,后续校验错位 |
mkdir -p "$STATE_DIR" 在锁之前 | 锁文件有地方放 | 锁文件打不开,脚本在拿到锁前就死 |
exec {lock_fd}>"$LOCK_FILE" | 分配 >10 的 fd | 用固定 fd 3 可能和脚本其他 fd 冲突 |
flock -n "$lock_fd" | 非阻塞单实例 | 用 PID 文件检查+写入有竞态,崩溃留残留 |
mktemp -d "$dest/.incoming.XXXXXX" | 临时目录在目标文件系统 | 建在 /tmp,mv 跨文件系统退化成复制+删除,非原子 |
retry 3 5 snapshot "$name" | 3 次、5 秒起指数退避 | 无上限重试会把故障拖长;非幂等操作不能重试 |
mv -f "$tmpdir/$name.tar.gz" "$dest/$name.tar.gz" | 同文件系统原子发布 | 直接写正式名,别人能看到半截归档 |
find "$dest" -mtime +"$keep" -print -delete | 按天保留 | 先 -delete 后条件会删错;-mtime 按 24h 取整 |
trap cleanup EXIT + trap 'on_signal ...' | 清理与信号分离 | 把 cleanup 同时挂 INT/TERM,处理器返回后脚本继续跑 |
这张表的用法是:改脚本时先找到对应行,问「删掉它,最坏会发生什么」。答不上来,说明这行是抄来的,不是设计出来的。
tar 的退出码值得单独记:0 正常,1 读取期间文件有变化(活动目录常见,归档通常仍可用),2 致命错误。把 1 当失败会让备份告警天天响,最后没人看;把 2 当成功会发布一个坏归档。脚本里的 if (( rc == 1 )) 是明确的选择,不是疏忽。
0.8.17 实验:shellcheck、trace 与锁的系统调用
实验一:shellcheck 抓什么
cat > /tmp/sc.sh <<'EOF'
#!/bin/sh
for f in $(ls); do
if [ $f = "a b" ]; then
echo $f
fi
done
EOF
bash -n /tmp/sc.sh && echo "bash -n: syntax ok"
shellcheck /tmp/sc.sh || true
预期 bash -n 通过(语法合法),shellcheck 报 SC2045(迭代 ls)、SC2086($f 未引用)、SC2039/SC3043(/bin/sh 下的 bash 假设)等。shellcheck -f gcc 输出可被编辑器解析,-S warning 只显示 warning 以上。CI 里用 shellcheck -x -S warning script.sh 作为门禁;确实要保留的写法用 # shellcheck disable=SC2086 并写理由。
实验二:把 trace 写到独立 fd
cat > /tmp/trace-demo.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
exec {fd}>/tmp/trace.log
BASH_XTRACEFD=$fd
PS4='+${BASH_SOURCE##*/}:${LINENO}:${FUNCNAME[0]:-main}: '
set -x
f() { local x=$1; printf 'x=%s\n' "$x"; }
f hello
set +x
exec {fd}>&-
EOF
bash /tmp/trace-demo.sh
cat /tmp/trace.log
预期 stdout 只有 x=hello,trace 文件里是 +trace-demo.sh:8:f: ... 形式。BASH_XTRACEFD 必须在 set -x 之前设置;关闭 fd 前先 set +x,否则 bash 会往已关闭的 fd 写 trace 并报错。
实验三:flock 与子进程
cat > /tmp/lock-demo.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
exec {fd}>/tmp/lock-demo.lock
flock -n "$fd" || { echo "busy"; exit 75; }
echo "locked pid=$$"
sleep 30
EOF
bash /tmp/lock-demo.sh &
sleep 1
strace -f -e trace=flock,clone,wait4 -o /tmp/lock.trace bash /tmp/lock-demo.sh || true
grep -E 'flock|clone|wait4' /tmp/lock.trace | head
ls -l /proc/$$/fd | tail -5
预期第二个实例的 flock 返回 EWOULDBLOCK,脚本打印 busy 并以 75 退出;strace 里能看到 flock(fd, LOCK_EX|LOCK_NB)。/proc/PID/fd 能看到锁文件 fd;flock 锁的是打开文件描述,rm 锁文件不会释放锁。
0.8.18 三个可复用脚本
脚本一:lib/log.sh——可 source 的函数库
#!/usr/bin/env bash
# lib/log.sh — 日志、die、retry、require_cmd;只定义函数,不设置全局 shell 选项
# 用法: . lib/log.sh
if [[ ${BASH_SOURCE[0]} == "$0" ]]; then
printf 'this file is a library; source it\n' >&2
exit 2
fi
mylog() {
local level=$1; shift
printf '%(%F %T)T %-5s %s\n' -1 "$level" "$*" >&2
}
mydie() { mylog ERROR "$*"; exit 1; }
myrequire_cmd() {
local cmd
for cmd in "$@"; do
command -v "$cmd" >/dev/null 2>&1 || mydie "missing command: $cmd"
done
}
myretry() {
local -i attempts=$1 delay=$2 n=1 rc=0
shift 2
until "$@"; do
rc=$?
if (( n >= attempts )); then
mylog ERROR "giving up after $n attempts (rc=$rc): $*"
return "$rc"
fi
mylog WARN "attempt $n/$attempts failed (rc=$rc); retry in ${delay}s"
sleep "$delay"
(( delay *= 2 ))
n+=1
done
}
这个库不 set -e、不注册 trap、不 exit(除了 mydie 的显式调用),函数名带 my 前缀,变量全是 local。调用方负责严格模式和清理;库只提供工具。BASH_SOURCE[0] == "$0" 阻止 bash lib/log.sh 直接执行。
脚本二:parallel-jobs.sh——NUL 输入 + 限流
#!/usr/bin/env bash
# parallel-jobs.sh — 从 stdin 读 NUL 分隔的项,最多 J 个并发执行 CMD ITEM
# 用法: find ... -print0 | parallel-jobs.sh -j 4 -- gzip --
set -euo pipefail
usage() { printf 'usage: %s -j N -- CMD [ARG...]\n' "${0##*/}" >&2; }
jobs=0
while getopts ':j:h' opt; do
case $opt in
j) jobs=$OPTARG ;;
h) usage; exit 0 ;;
:) printf 'option -%s needs an argument\n' "$OPTARG" >&2; exit 2 ;;
\?) printf 'unknown option -%s\n' "$OPTARG" >&2; exit 2 ;;
esac
done
shift $((OPTIND - 1))
[[ $jobs =~ ^[1-9][0-9]?$ ]] || { usage; exit 2; }
(( $# > 0 )) || { usage; exit 2; }
mapfile -d '' -t items
(( ${#items[@]} > 0 )) || exit 0
failed=0
declare -A running=()
cleanup() {
local pid
for pid in "${!running[@]}"; do kill -TERM "$pid" 2>/dev/null || true; done
wait 2>/dev/null || true
}
trap cleanup INT TERM
for item in "${items[@]}"; do
"$@" "$item" &
running[$!]=$item
if (( ${#running[@]} >= jobs )); then
done_pid=''
if ! wait -n -p done_pid; then
failed=1
printf 'worker failed: %s\n' "${running[$done_pid]:-unknown}" >&2
fi
unset 'running[$done_pid]'
fi
done
while (( ${#running[@]} )); do
done_pid=''
if ! wait -n -p done_pid; then
failed=1
printf 'worker failed: %s\n' "${running[$done_pid]:-unknown}" >&2
fi
unset 'running[$done_pid]'
done
exit "$failed"
mapfile -d '' 按 NUL 读,文件名含空格/换行都安全;running[$!]=$item 记录 PID 到项,失败时能说清是哪一项;wait -n -p 需要 bash 5.1+,没有子进程时返回 127,循环条件保证不会走到。cleanup 只杀仍在运行的子进程,wait 收尸。"$@" "$item" 把项追加到命令末尾,命令本身用 -- 分隔,避免项被当成选项。
脚本三:lock-run.sh——flock 包装器
#!/usr/bin/env bash
# lock-run.sh — 单实例执行命令;拿不到锁退出 75
# 用法: lock-run.sh LOCKFILE COMMAND [ARG...]
set -euo pipefail
lock=${1:?usage: lock-run.sh LOCKFILE COMMAND [ARG...]}
shift
(( $# > 0 )) || { printf 'missing command\n' >&2; exit 2; }
[[ $lock == /* ]] || { printf 'LOCKFILE must be absolute\n' >&2; exit 2; }
exec {fd}>"$lock" || { printf 'cannot open %s\n' "$lock" >&2; exit 1; }
if ! flock -w "${LOCK_TIMEOUT:-30}" "$fd"; then
printf 'another instance holds %s\n' "$lock" >&2
exit 75
fi
# 锁随 fd 关闭释放;子进程会继承 fd,长驻子进程会一直持锁
"$@"
flock -w 最多等 30 秒,拿不到退出 75(EX_TEMPFAIL,调用方可重试);exec {fd}> 分配 >10 的 fd,避免与 0/1/2 冲突。锁文件必须长期保留,不要 rm;子进程继承 fd 是双刃剑:短命令没问题,daemon 化的子进程会永久持锁,需要 flock -o(执行前关闭 fd)或让子进程自己管理锁。
0.8.19 陷阱清单
local -n out=$1` 传的是变量名;调用写 `collect "$items"会报invalid variable name。mapfile放在管道右侧会在子 Shell 执行,数组填不到父 Shell;用< <(...)。trap cleanup EXIT INT TERM会让清理跑两次;信号处理器只exit 130/143。SIGKILL不触发EXIT;所有「必须完成」的步骤都要幂等。rm锁文件不会释放锁,反而让下一个进程锁新 inode;锁文件不要删。flock在 NFS 上跨主机不可靠;跨主机用外部锁服务。wait -n没有子进程时返回 127;循环前确认至少启动了一个。- 库文件里
set -e会改掉调用方的错误策略;库只定义函数。 getopt的eval set --只能处理 getopt 的受控输出,不能拼用户输入。shellcheck disable=all等于关掉检查;只禁用具体规则并写理由。
0.8.20 本章出口检查
| 检查项 | 达标线 | 没达标去哪 |
|---|---|---|
| 用 nameref 返回数组 | 调用方不加 $,函数内 local -n | 0.8.1 |
解释 "${a[@]}" 与 "${a[*]}" 的差别 | 说出分词与 IFS | 0.8.2 |
写出只执行一次的 EXIT 清理 | 首行保存 $?,先 trap - | 0.8.3 |
用 flock 实现单实例 | 锁 fd,不删锁文件 | 0.8.4 |
| 给重试设上限与退避 | 非幂等操作不重试 | 0.8.4 |
跑通 backup.sh 的 dry-run 与真实备份 | 退出码、归档、无残留 | 0.8.6 |
逐行解释 backup.sh | 能说出每行去掉后的故障 | 0.8.16 |
| 用 nameref 返回数组 | 调用方不加 $,函数内 local -n | 0.8.8 |
用 mapfile 读 NUL 列表 | 会 -d '' 且不在管道右侧 | 0.8.9 |
写只执行一次的 EXIT 清理 | 首行保存 $?,先 trap - | 0.8.10 |
用 flock 做单实例 | 锁 fd、不删锁文件、知道 NFS 边界 | 0.8.11 |
用 wait -n 限流 | 会记录 PID 到任务、处理 127 | 0.8.12 |
| 解析长选项 | 知道 getopt 的 eval set -- 边界 | 0.8.13 |
用 shellcheck 与 trace | 会 -x、BASH_XTRACEFD、PS4 | 0.8.14 |
| 写可 source 的函数库 | 不设全局 set -e、不覆盖 trap | 0.8.15 |
Q1 一个函数用 local -n out=$1` 写回数组,调用方写 `collect $items,报 invalid variable name。为什么?改成什么?
Q2 脚本里写了 trap 'cleanup' EXIT INT TERM。按 Ctrl-C 后日志出现两条 exit rc=0,且临时目录被删了两次。解释原因并给出正确写法。
Q3 set -euo pipefail 下,if grep -q foo file; then ...; fi 在 foo 不存在时会不会退出脚本?local n=$(grep -c foo file) 呢?
Q4 两个备份进程同时启动,都用了 flock -n /var/lock/backup.lock,但其中一个在拿到锁后 rm 了锁文件。会出现什么后果?
答案
A1 local -n 绑定的是变量名,不是值。$items` 会先展开成数组内容,`local -n` 拿到的是「alpha」这样的字符串,不是合法变量名。调用应写 `collect items`,函数内部再用 `"${out[@]}" 读取。
A2 cleanup 同时被注册为 INT 处理器和 EXIT 处理器。Ctrl-C 先以信号处理器身份调用一次,函数末尾的 exit 又触发 EXIT,于是第二次执行;第二次的 $? 已经是 0,所以日志是 rc=0 而不是 130。正确写法是信号处理器只 exit 130/exit 143,cleanup 只挂 EXIT,并在 cleanup 首行 trap - EXIT INT TERM。
A3 grep 在 if 条件里,失败不会触发 set -e,脚本继续。local n=$(...)` 的返回状态是 `local` 自己的 0,命令替换的失败被掩盖(SC2155),所以也不会退出,但 `n` 可能是空串。需要区分「没匹配」和「读文件失败」时,显式写 `if ! n=$(grep -c ...); then ...; fi 并判断 $?。
A4 锁绑定的是 inode。进程 A 锁住旧文件后删掉它,进程 B 创建同名新文件并锁住新 inode,两个进程都认为自己持有锁,互斥失效。锁文件应长期保留,只锁不删;需要清理时用 flock 的等待超时和日志,而不是 rm。
母本对应
| 母本 | 章节 | 本书落点 | 程度 |
|---|---|---|---|
| shell-book | 第 14 章 处理用户输入 | 0.8.5 | 完整(getopts、参数校验) |
| shell-book | 第 15 章 呈现数据 | 0.8.4–0.8.5 | 完整(临时文件、日志、fd) |
| shell-book | 第 16 章 脚本控制 | 0.8.3–0.8.4 | 完整(信号、后台、作业控制) |
| shell-book | 第 17 章 创建函数 | 0.8.1–0.8.2 | 完整(返回值、数组、库) |
| shell-book | 第 24 章 编写简单的脚本实用工具 | 0.8.6 | 精选(备份脚本结构) |
| Shotts | 第 26、30、36 章 自顶向下、故障诊断、其他命令 | 0.8.1、0.8.7 | 精选(设计顺序、调试) |
| 鸟哥 | 第 12 章 学习 Shell Scripts | 0.8.1–0.8.5 | 背景(语法与流程控制) |
| Kerrisk | 第 20–28 章 信号与进程 | 0.8.3 | 一句带过(用户态信号语义) |
| shell-book | 第 15–17 章 呈现数据、脚本控制、创建函数 | 0.8.8–0.8.12、0.8.15 | 完整(fd、trap、函数、库) |
| Shotts | 第 26、30、36 章 自顶向下、故障诊断、其他命令 | 0.8.14 | 精选(调试、防御式编程) |
| 鸟哥 | 第 12.6 节 shell script 的追踪与 debug | 0.8.14 | 完整(sh -n/-v/-x) |
下一站:0.9 进程、作业控制与信号从「脚本怎么处理信号」走到「系统怎么组织进程」:进程组、会话、控制终端,以及为什么
kill -9不是万能药。