Linux-17 Shell 脚本工程化:set -euo pipefail 的陷阱、引号规则与 trap 清理
这是上手部分的最后一篇。前面 16 篇讲的命令,最终都会被写进脚本 —— 而脚本的可靠性取决于你是否知道 shell 的那些坑。
先看五个问题:
set -euo pipefail每一项在防什么?它在哪些情况下完全不生效?[[ ]]和[ ]有什么本质区别?- 什么时候必须加引号?有没有一条能记住的规则?
- 怎么保证脚本无论怎么退出(正常、报错、被 Ctrl-C、被 kill)都能清理临时文件?
- 为什么 shell 脚本慢?什么时候该换 Python/Go?
1. 脚本的骨架
1.1 shebang
#!/bin/bash # 大多数 Linux 可用
#!/usr/bin/env bash # ✅ 最可移植:去 PATH 里找 bash
#!/bin/sh # ⚠️ POSIX shell,【不能用 bash 特性】
为什么推荐 #!/usr/bin/env bash:macOS 的新版 bash 装在 /opt/homebrew/bin/bash(系统自带的 /bin/bash 是 2007 年的 3.2 版),FreeBSD 在 /usr/local/bin/bash。写死 /bin/bash 在这些系统上会用到老版本或根本找不到。
#!/bin/sh 的坑:Debian/Ubuntu 上 /bin/sh 是 dash(不是 bash),以下全部不可用:
[[ ]] # 双方括号
arr=(a b c) # 数组
${var,,} ${var^^} # 大小写转换
<(cmd) # 进程替换
$'\t' # C 风格转义
{1..10} # 花括号序列展开
echo -e # dash 的 echo 不支持 -e
source # 只能用 .
# 检查是否真的兼容 POSIX
checkbashisms script.sh # Debian 的 devscripts 包
shellcheck -s sh script.sh # 用 sh 方言检查
dash -n script.sh # 用 dash 做语法检查
1.2 开篇第一问:set -euo pipefail
set -euo pipefail
# ││││
# │││└─ o pipefail : 管道中任意一段失败,整个管道就算失败
# ││└── u : 使用未定义变量时报错退出
# │└─── e : 任何命令返回非 0 就立即退出
# └──── set : shell 选项
逐项说明它在防什么:
# ── -e (errexit):防止「错误被忽略后继续执行造成更大破坏」──
cd /nonexistent # 失败了,但脚本继续跑!
rm -rf ./* # 💀 在【当前目录】执行了删除
# 加了 set -e:cd 失败就退出,不会执行到 rm
# ── -u (nounset):防止变量名拼错导致的灾难 ──
rm -rf "$TAGET_DIR/" # 变量名拼错了(TARGET -> TAGET)
# 没有 -u 时展开成 rm -rf / 💀
# 加了 -u:报 "TAGET_DIR: unbound variable" 并退出
# ── -o pipefail:防止管道中间的失败被最后一个命令的成功掩盖 ──
curl -s https://api/data | jq '.items' # curl 失败了,但 jq 处理空输入成功
echo $? # 0 <- 看起来成功了!
# 加了 pipefail:$? 是 curl 的非 0 退出码
1.3 set -e 的七个陷阱(重点)
set -e 有一长串不生效的情况,这是很多人以为「加了就安全」却仍然踩坑的原因:
set -e
# ❌ 陷阱 1:条件判断的上下文里【完全不生效】(这是设计意图)
if failing_cmd; then ...; fi # 不退出
while failing_cmd; do ...; done # 不退出
failing_cmd && echo ok # 不退出
failing_cmd || echo failed # 不退出
! failing_cmd # 不退出
# ❌ 陷阱 2:函数被用在条件里时,函数【内部】的 -e 也失效
check() {
failing_cmd # 这里失败了
echo "还是执行到了这里" # 竟然会执行!
}
if check; then ...; fi # 因为 check 处于条件上下文,它内部的 -e 被禁用
# ❌ 陷阱 3:local / export / declare 会掩盖退出码
result=$(failing_cmd) # ✅ 这个会退出(赋值语句传递退出码)
local r=$(failing_cmd) # ❌ 【不会】退出!local 的退出码是它自己的
export r=$(failing_cmd) # ❌ 同上(这个坑非常隐蔽)
# ✅ 修法:分两行
local r
r=$(failing_cmd)
# ❌ 陷阱 4:管道中非最后一个命令(没开 pipefail 时)
failing_cmd | cat # 不退出($? 是 cat 的)
# ❌ 陷阱 5:算术运算结果为 0
count=0
((count++)) # 返回 0(因为 count++ 的【值】是 0)-> 脚本退出!
((count++)) || true # ✅
count=$((count+1)) # ✅ 这个不会
# ❌ 陷阱 6:grep 没匹配到返回 1(08 篇讲过)
grep pattern file # 没匹配 -> 退出码 1 -> 脚本退出
grep pattern file || true # ✅
if grep -q pattern file; then ... # ✅
# ❌ 陷阱 7:子 shell 与 trap 的交互在不同 bash 版本行为不完全一致
-u 的陷阱:
set -u
echo "$1" # 没传参数时报错退出
echo "${1:-默认值}" # ✅ 用默认值
echo "${1-}" # ✅ 允许为空
arr=()
echo "${arr[@]}" # bash ≤4.3 上空数组会触发 unbound variable
echo "${arr[@]:-}" # ✅ 兼容写法
结论:set -euo pipefail 是有用的基线,但不能替代显式的错误处理。
# ✅ 关键路径显式检查
if ! cd "$WORKDIR"; then
echo "无法进入目录 $WORKDIR" >&2
exit 1
fi
cd "$WORKDIR" || { echo "无法进入 $WORKDIR" >&2; exit 1; }
mkdir -p "$OUT" || die "创建目录失败: $OUT"
1.4 完整模板
#!/usr/bin/env bash
#
# 用途:部署 myapp 到指定环境
# 用法:./deploy.sh -e prod -v 1.2.3 [-d]
#
set -euo pipefail
IFS=$'\n\t' # ✅ 去掉空格作为分隔符,减少「未加引号变量」的伤害
# ── 脚本自身的位置(不受调用方 cwd 影响)──
SCRIPT_DIR="$(cd -P -- "$(dirname -- "$(readlink -f "${BASH_SOURCE[0]}")")" && pwd)"
# ^^ 物理路径(03 篇 6.1)
# ^^^^^^^^^^^^^^ 解析软链接(脚本被软链接调用时)
# ^^^^^^^^^^^^^^^^^ 比 $0 可靠(source 时也对)
SCRIPT_NAME="$(basename -- "${BASH_SOURCE[0]}")"
readonly SCRIPT_DIR SCRIPT_NAME
ENVIRONMENT=""
VERSION=""
DRY_RUN=false
LOG_LEVEL="${LOG_LEVEL:-info}"
# ── 日志(一律输出到 stderr,不污染 stdout)──
_log() { printf '%s [%-5s] %s\n' "$(date +'%F %T')" "$1" "${*:2}" >&2; }
debug() { [[ "$LOG_LEVEL" == debug ]] && _log DEBUG "$@" || true; }
info() { _log INFO "$@"; }
warn() { _log WARN "$@"; }
error() { _log ERROR "$@"; }
die() { error "$@"; exit 1; }
# ── 清理(无论怎么退出都执行,见第 6 章)──
TMPDIR_SELF=""
cleanup() {
local code=$?
[[ -n "$TMPDIR_SELF" && -d "$TMPDIR_SELF" ]] && rm -rf -- "$TMPDIR_SELF"
(( code != 0 )) && error "脚本以退出码 $code 结束"
return $code
}
trap cleanup EXIT
trap 'die "被信号中断"' INT TERM
usage() {
cat <<EOF
用法: $SCRIPT_NAME -e <env> -v <version> [选项]
必需:
-e ENV 环境(prod|staging|dev)
-v VERSION 版本号
可选:
-d 演练模式
-h 帮助
示例:
$SCRIPT_NAME -e staging -v 1.2.3
EOF
}
parse_args() {
while getopts ':e:v:dh' opt; do
case "$opt" in
e) ENVIRONMENT="$OPTARG" ;;
v) VERSION="$OPTARG" ;;
d) DRY_RUN=true ;;
h) usage; exit 0 ;;
:) die "选项 -$OPTARG 缺少参数" ;;
\?) usage >&2; die "未知选项 -$OPTARG" ;;
esac
done
shift $((OPTIND - 1))
[[ -n "$ENVIRONMENT" ]] || { usage >&2; die "必须指定 -e"; }
[[ -n "$VERSION" ]] || { usage >&2; die "必须指定 -v"; }
[[ "$ENVIRONMENT" =~ ^(prod|staging|dev)$ ]] || die "无效环境: $ENVIRONMENT"
}
check_deps() {
local missing=() cmd
for cmd in curl jq rsync; do
command -v "$cmd" >/dev/null 2>&1 || missing+=("$cmd")
done
(( ${#missing[@]} == 0 )) || die "缺少依赖: ${missing[*]}"
}
main() {
parse_args "$@"
check_deps
TMPDIR_SELF="$(mktemp -d)" || die "无法创建临时目录"
info "开始部署 $VERSION 到 $ENVIRONMENT"
[[ "$DRY_RUN" == true ]] && warn "演练模式,不会实际执行"
# ... 实际逻辑 ...
info "部署完成"
}
main "$@"
2. 变量与引号
2.1 开篇第三问:引号决策树
要引用一个变量?
├─ 是 -> 【永远加双引号】"$var"
│ 唯一例外:确实需要分词或 glob(这时应该改用数组)
└─ 是字面字符串?
├─ 需要展开变量/命令替换 -> 双引号 "text $var"
└─ 完全字面 -> 单引号 'text $literal'
一条铁律:"$var"、"$@"、"${arr[@]}"、"$(cmd)" 这四个形式永远加引号。
f="my file.txt"
rm $f # ❌ 变成 rm my file.txt(两个参数)
rm "$f" # ✅
p="*.txt"
echo $p # ❌ 被 glob 展开成实际文件名
echo "$p" # ✅ 输出字面的 *.txt
v=""
[ $v = "x" ] # ❌ 变成 [ = "x" ],语法错误
[ "$v" = "x" ] # ✅
files=$(ls) # ❌ 文件名带空格就散了
mapfile -t files < <(ls) # ✅ 读进数组
2.2 参数展开:默认值与校验
${var:-default} # 为空或未设 -> 用 default(不改变 var)
${var-default} # 未设 -> 用 default(空字符串算已设置)
${var:=default} # 为空 -> 用 default 并【赋值】
${var:?错误信息} # ✅ 为空 -> 打印错误并退出(参数校验神器)
${var:+替代值} # 【非空】时才用替代值
PORT="${PORT:-8080}"
: "${DEPLOY_DIR:?必须设置 DEPLOY_DIR}"
rm -rf "${TARGET:?TARGET 未设置}"/* # ✅ 变量为空时直接报错,不会变成 rm -rf /*
OPTS="${DEBUG:+--verbose --log-level=debug}"
# 字符串操作(不起进程,比 sed/cut 快得多)
p=/var/log/app.log
echo "${#p}" # 16 长度
echo "${p##*/}" # app.log = basename
echo "${p%/*}" # /var/log = dirname
echo "${p%.log}.txt" # /var/log/app.txt
echo "${p##*.}" # log 扩展名
echo "${p//log/LOG}" # 全部替换(单个 / 是替换第一个)
echo "${p:5:3}" # 子串
echo "${p^^}" "${p,,}" # 转大写 / 小写(bash 4+)
# 记忆:# 在键盘上 $ 左边 -> 删前缀;% 在右边 -> 删后缀
# 单个 = 最短匹配,双个 = 最长匹配
2.3 数组:必须掌握
数组是「安全地处理多个值」的唯一正确方式。 很多人用字符串拼接代替,然后被空格坑到。
arr=(a b "c d" e) # 定义
arr+=(f g) # 追加
echo "${arr[0]}" # 第一个
echo "${arr[-1]}" # 最后一个(bash 4.3+)
echo "${#arr[@]}" # ✅ 元素个数
echo "${!arr[@]}" # 所有索引
echo "${arr[@]:1:2}" # 切片
# ── 遍历(唯一正确的写法)──
for x in "${arr[@]}"; do echo "[$x]"; done # ✅ 双引号 + [@]
# [a] [b] [c d] [e] [f] [g] <- "c d" 保持完整
for x in ${arr[@]}; do ...; done # ❌ "c d" 被拆成两个
for x in "${arr[*]}"; do ...; done # ❌ 拼成一个字符串,只循环一次
[@] 与 [*] 的区别(与 $@/$* 完全一样,09 篇 4.4):
arr=(a "b c" d)
printf '%s\n' "${arr[@]}" # 3 行:a / b c / d ✅ 每个元素独立
printf '%s\n' "${arr[*]}" # 1 行:a b c d 拼成一个字符串
IFS=,; echo "${arr[*]}" # a,b c,d 用 IFS 首字符连接
# ── 实战 ──
# ① 构建命令参数(避免字符串拼接的空格问题)
args=(--verbose --output "/path with space/out.txt")
[[ "$DEBUG" == true ]] && args+=(--debug)
[[ -n "${CONFIG:-}" ]] && args+=(--config "$CONFIG")
mycommand "${args[@]}" # ✅ 每个元素作为一个独立参数
opts="--output '/path with space/out.txt'"
mycommand $opts # ❌ 引号不会被再次解析,路径被拆开
# ② 读命令输出到数组
mapfile -t lines < <(grep ERROR app.log) # ✅ bash 4+
readarray -t lines < file # 同 mapfile
echo "共 ${#lines[@]} 行"
# ③ 处理任意文件名(07 篇)
files=()
while IFS= read -r -d '' f; do files+=("$f"); done < <(find . -name '*.log' -print0)
# ④ 切分字符串
IFS=',' read -ra parts <<< "a,b,c"
echo "${parts[1]}" # b
# ⑤ 传给函数(要展开)
process() { local -a items=("$@"); for i in "${items[@]}"; do echo "$i"; done; }
process "${arr[@]}"
# 或者用 nameref 传引用(bash 4.3+,避免拷贝)
process_ref() { local -n ref=$1; ref+=("new"); echo "${#ref[@]}"; }
process_ref arr
关联数组(哈希表):
declare -A config # ✅ 必须先声明
config[host]="10.0.0.5"
config["db name"]="mydb" # key 可以有空格
echo "${config[host]}"
echo "${!config[@]}" # 所有 key
echo "${#config[@]}" # 元素个数
for k in "${!config[@]}"; do echo "$k = ${config[$k]}"; done
[[ -v config[host] ]] && echo "存在" # ✅ 判断 key 存在(bash 4.3+)
unset 'config[host]' # 删除(注意引号)
# 实战:计数
declare -A count
while read -r ip; do ((count[$ip]++)) || true; done < ips.txt
for ip in "${!count[@]}"; do echo "${count[$ip]} $ip"; done | sort -rn
2.4 声明与作用域
readonly VERSION="1.2.3" # 常量
declare -i count=0 # 整型
declare -a arr # 数组
declare -A map # 关联数组
declare -n ref=target # 引用(nameref)
declare -p VAR # ✅ 打印变量的类型与值(调试用)
# ── 函数里必须用 local ──
process() {
local file="$1"
local -a items=()
local -i n=0
local IFS=$'\n' # ✅ 局部修改 IFS(返回后自动恢复)
# 不加 local 就是全局变量,会污染调用者 —— shell 脚本 bug 的常见来源
}
3. 条件测试
3.1 开篇第二问:[[ ]] vs [ ] vs (( ))
[ ] / test |
[[ ]] |
(( )) |
|
|---|---|---|---|
| 本质 | 命令(语义上是外部命令) | bash 关键字(语法层面) | 算术求值 |
| POSIX | ✅ | ❌ bash/zsh/ksh 专有 | ❌ |
| 变量未加引号 | ❌ 会出错(分词 + glob) | ✅ 安全 | ✅ |
| 逻辑运算 | -a / -o(已废弃) |
&& / || |
&& / || |
| 正则匹配 | ❌ | ✅ =~ |
❌ |
| 通配匹配 | ❌ | ✅ == 右侧支持 glob |
❌ |
| 数值比较 | -eq -lt -gt |
同 | == < > 直接用 |
| 空变量 | ❌ 语法错误 | ✅ 安全 | ✅ |
v=""
[ $v = "x" ] # ❌ bash: [: =: unary operator expected
[[ $v == "x" ]] # ✅ 正常返回 false(甚至不需要引号)
f="a b.txt"
[ -f $f ] # ❌ 参数太多
[[ -f $f ]] # ✅ 安全
# [[ ]] 独有的能力
[[ "$name" == a* ]] # glob 匹配
[[ "$name" =~ ^[a-z]+[0-9]{2}$ ]] # 正则匹配
[[ "$a" == "$b" && "$c" != "$d" ]] # && 直接用
# 正则的捕获组
if [[ "v1.2.3" =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
echo "主版本 ${BASH_REMATCH[1]}, 次版本 ${BASH_REMATCH[2]}"
# ^^^^^^^^^^^^^^ [0] 是整个匹配
fi
# ⚠️ 正则不要加引号(加了就变成字面字符串匹配)
[[ "$x" =~ ^[0-9]+$ ]] # ✅
[[ "$x" =~ "^[0-9]+$" ]] # ❌
# (( )) 做数值最自然
(( i > 3 )) && echo "大于 3"
(( sum = a + b ))
if (( count > 100 )); then ...; fi # ✅ 比 [ "$count" -gt 100 ] 清晰
echo $(( 2 ** 10 )) # 1024
结论:bash 脚本一律用 [[ ]](字符串/文件)和 (( ))(数值);只有写 #!/bin/sh 时才用 [ ]。
3.2 测试表达式速查
# ── 文件 ──
[[ -e p ]] 存在 [[ -f p ]] 普通文件 [[ -d p ]] 目录
[[ -L p ]] 软链接 [[ -s p ]] 非空 [[ -r/-w/-x p ]] 可读/写/执行
[[ -O p ]] 属主是我 [[ -u/-g/-k p ]] SUID/SGID/Sticky
[[ -p p ]] 管道 [[ -S p ]] socket [[ -b/-c p ]] 块/字符设备
[[ -t 1 ]] ✅ fd 1 是终端(判断是否输出颜色)
[[ a -nt b ]] a 比 b 新 [[ a -ot b ]] 更旧 [[ a -ef b ]] ✅ 同一个 inode
# ── 字符串 ──
[[ -z "$s" ]] 为空 [[ -n "$s" ]] 非空
[[ "$a" == "$b" ]] [[ "$a" != "$b" ]] [[ "$a" < "$b" ]] 字典序
[[ "$s" == pre* ]] glob [[ "$s" =~ re ]] 正则
# ── 数值([[ ]] 里用这些,(( )) 里用 > < ==)──
-eq -ne -lt -le -gt -ge
# ── 变量 ──
[[ -v name ]] ✅ 变量已定义(bash 4.2+)
[[ -o errexit ]] shell 选项已开启
4. 流程控制
# ── case(比一串 elif 清晰)──
case "$ENVIRONMENT" in
prod|production) deploy_prod ;;
staging|stg) deploy_staging ;;
test-*) deploy_test "${ENVIRONMENT#test-}" ;; # glob 匹配
*) die "未知环境: $ENVIRONMENT" ;;
esac
# ── for 的四种形式 ──
for f in *.log; do ... done # glob
for f in "${arr[@]}"; do ... done # ✅ 数组
for i in {1..10}; do ... done # 序列(不起进程,优于 seq)
for ((i=0; i<10; i++)); do ... done # ✅ C 风格
for arg in "$@"; do ... done # ✅ 参数
# ⚠️ glob 无匹配的坑
for f in *.log; do echo "$f"; done # 没有 .log 时输出字面的 "*.log"
shopt -s nullglob # ✅ 无匹配时展开成空
for f in *.log; do [[ -e "$f" ]] || continue; ... done # 或显式检查
# ── while read 的正确写法(04/06/09 篇讲过)──
while IFS= read -r line; do
echo "[$line]"
done < input.txt
# ^^^^^^^^^^^^^^ 用重定向而不是管道(否则变量修改会丢,09 篇 3.2)
while IFS= read -r -d '' f; do ...; done < <(find . -print0) # \0 分隔
while IFS=: read -r user _ uid _ _ home shell; do ...; done < /etc/passwd
# ⚠️ 循环里的 ssh/mysql 会吃掉 stdin
while read -r host; do ssh "$host" uptime; done < hosts.txt # ❌ ssh 读走剩余输入
while read -r host; do ssh -n "$host" uptime; done < hosts.txt # ✅ -n
# ── 带超时的等待(生产脚本常用)──
wait_for() {
local url=$1 timeout=${2:-60} elapsed=0
until curl -sf --max-time 2 "$url" >/dev/null 2>&1; do
(( elapsed += 2 ))
(( elapsed >= timeout )) && { error "等待 $url 超时"; return 1; }
sleep 2
done
info "$url 已就绪(${elapsed}s)"
}
5. 函数
# ── 参数 ──
greet() {
local name="${1:?需要名字}" # ✅ 必需参数校验
local greeting="${2:-你好}" # 可选参数带默认值
echo "$greeting, $name!"
}
# ── 返回值:return 只能是 0-255 的退出码,不能返回数据 ──
get_count() { return 42; } # ❌ 只表达"退出码 42",语义是失败
# ✅ 用 stdout 返回数据,用 return 表达成败
get_count() {
local n
n=$(wc -l < "$1") || return 1
echo "$n" # 数据写 stdout
}
count=$(get_count file.txt) || die "统计失败"
# ✅ 或用 nameref 输出(避免起子 shell)
get_count_ref() { local -n out=$2; out=$(wc -l < "$1"); }
get_count_ref file.txt result; echo "$result"
# ⚠️ 因为 stdout 用来返回数据,所有日志必须走 stderr(否则混进返回值)
# ── 错误处理模式 ──
process_file() {
local f=$1
[[ -f "$f" ]] || { error "文件不存在: $f"; return 1; }
[[ -r "$f" ]] || { error "文件不可读: $f"; return 2; }
return 0
}
process_file "$input" || case $? in
1) die "输入文件缺失" ;;
2) die "权限不足" ;;
esac
declare -F # 列出所有函数名
declare -f myfunc # 打印函数定义
6. trap 与清理
6.1 开篇第四问:保证清理一定执行
trap ... EXIT 是唯一可靠的清理方式 —— 它在任何退出路径上都会执行:正常结束、exit、set -e 触发、被信号杀死(除 SIGKILL)。
TMPDIR_SELF=""
cleanup() {
local exit_code=$? # ✅ 第一行就保存退出码(后面的命令会覆盖 $?)
[[ -n "$TMPDIR_SELF" && -d "$TMPDIR_SELF" ]] && rm -rf -- "$TMPDIR_SELF"
[[ -n "${LOCKFILE:-}" ]] && rm -f -- "$LOCKFILE"
kill "${BG_PID:-}" 2>/dev/null || true # 清理后台进程
(( exit_code != 0 )) && error "脚本失败(退出码 $exit_code)"
return $exit_code # ✅ 保持原退出码
}
trap cleanup EXIT
TMPDIR_SELF=$(mktemp -d) # 之后才创建资源
trap cleanup EXIT # ✅ 任何退出(最重要)
trap 'echo 被中断; exit 130' INT # Ctrl-C(130 = 128+2)
trap 'echo 被终止; exit 143' TERM # kill(143 = 128+15)
trap '' INT # 忽略 Ctrl-C(关键区段保护)
trap - INT # 恢复默认
trap -p # ✅ 列出当前所有 trap
# ⚠️ SIGKILL(9)无法捕获 —— 清理逻辑不能只依赖 trap,
# 临时文件应放在 /tmp(重启清空)或用可预测路径便于后续清理
# ── ERR trap:出错时打印上下文(比单纯 set -e 有用得多)──
on_error() {
local exit_code=$? line=$1 cmd=$2
error "第 $line 行失败(退出码 $exit_code): $cmd"
error "调用栈: ${FUNCNAME[*]:1}"
}
trap 'on_error $LINENO "$BASH_COMMAND"' ERR
set -E # ✅ 让 ERR trap 在函数里也生效(errtrace)
6.2 临时文件与原子写
tmpfile=$(mktemp) || die "无法创建临时文件"
tmpdir=$(mktemp -d) || die
trap 'rm -rf -- "$tmpdir"' EXIT
# 为什么不用 $$ 拼名字:可预测 -> 符号链接攻击(14 篇 10.13)
# ✅ 原子写文件(04 篇 5.1)
write_config() {
local target=$1 content=$2 tmp
tmp=$(mktemp "$(dirname "$target")/.$(basename "$target").XXXXXX") || return 1
# ^^^^^^^^^^^^^^^^^^^^^ 必须与目标同目录(同一文件系统才能 rename)
printf '%s' "$content" > "$tmp" || { rm -f "$tmp"; return 1; }
chmod 644 "$tmp"
sync -f "$tmp" 2>/dev/null || true
mv -f -- "$tmp" "$target" # ✅ 原子替换
}
6.3 防止并发执行
# ✅ flock:最可靠(14 篇 8.4 提过)
LOCKFILE=/var/lock/myjob.lock
exec 9>"$LOCKFILE" || die "无法打开锁文件"
flock -n 9 || die "另一个实例正在运行"
# ^^ -n 拿不到锁立即失败(-w 10 表示等 10 秒)
# 锁随 fd 9 关闭自动释放(进程退出即释放,不留陈旧锁)
# crontab 里的一行版
# */5 * * * * flock -n /var/lock/myjob.lock /path/script.sh
# ❌ 不要自己用 pid 文件实现锁 —— 有 TOCTOU 竞态且会留下陈旧锁
7. 参数解析
"$0" # 脚本名(${BASH_SOURCE[0]} 更可靠)
"${10}" # 第 10 个参数(超过 9 必须加花括号!)
"$#" # 参数个数
"$@" # ✅ 所有参数(每个独立成词)
"$?" "$$" "$!" # 上条退出码 / 当前 PID / 最后一个后台 PID
shift [n] # 丢掉前 n 个参数
# ── getopts:短选项 ──
while getopts ':e:v:dh' opt; do
# ^ 开头冒号 = 静默模式(自己处理错误,推荐)
# ^^ e 后的冒号 = 该选项需要参数
case "$opt" in
e) ENVIRONMENT="$OPTARG" ;;
d) DRY_RUN=true ;;
h) usage; exit 0 ;;
:) die "选项 -$OPTARG 需要参数" ;;
\?) die "未知选项 -$OPTARG" ;;
esac
done
shift $((OPTIND - 1)) # ✅ 移除已解析的选项
getopts 只支持单字符短选项,需要 --long-option 有三个方案:
# 方案一:手写循环(最常用,无依赖)
while [[ $# -gt 0 ]]; do
case "$1" in
-e|--env) ENVIRONMENT="$2"; shift 2 ;;
--env=*) ENVIRONMENT="${1#*=}"; shift ;; # 支持 --env=prod
-d|--dry-run) DRY_RUN=true; shift ;;
-h|--help) usage; exit 0 ;;
--) shift; break ;; # -- 之后都是位置参数
-*) die "未知选项: $1" ;;
*) POSITIONAL+=("$1"); shift ;;
esac
done
# 方案二:getopt(1)(GNU 版支持长选项,macOS 自带的不支持)
parsed=$(getopt -o e:v:dh -l env:,version:,dry-run,help -n "$0" -- "$@") || { usage; exit 1; }
eval set -- "$parsed"
# 方案三:参数复杂了就该换语言(Python argparse / Go flag、cobra)
8. 日志与输出
核心原则:stdout 只放「数据」,所有日志/提示/错误一律走 stderr。
# 理由:这样脚本才能被安全地放进管道
get_ips() {
info "正在查询…" # 走 stderr,不污染
echo "10.0.0.1" # 数据走 stdout
echo "10.0.0.2"
}
get_ips | wc -l # 2 ✅ 如果 info 走 stdout,这里就是 3
_log() { printf '%s [%-5s] %s\n' "$(date +'%F %T')" "$1" "${*:2}" >&2; }
info() { _log INFO "$@"; }
die() { _log ERROR "$@"; exit 1; }
# ── 颜色:只在终端时输出 ──
if [[ -t 2 ]]; then # ✅ 判断 stderr 是否连着终端(重定向到文件时不加颜色)
readonly C_RED=$'\033[0;31m' C_RESET=$'\033[0m'
else
readonly C_RED='' C_RESET=''
fi
error() { printf '%s[ERROR]%s %s\n' "$C_RED" "$C_RESET" "$*" >&2; }
# ── 让整个脚本的输出落盘 ──
exec > >(tee -a "$LOGFILE") 2>&1 # 同时输出到终端和文件
exec >> "$LOGFILE" 2>&1 # 只写文件
exec > >(logger -t myjob -p user.info) 2> >(logger -t myjob -p user.err) # 进 journal
# 或者交给 systemd(14 篇的 StandardOutput=journal)
9. 调试与检查
bash -n script.sh # ✅ 只检查语法,不执行
bash -x script.sh # ✅ 追踪每条命令(展开后的样子)
set -x; ...; set +x # 只追踪一段
export PS4='+ ${BASH_SOURCE##*/}:${LINENO}:${FUNCNAME[0]:-main}(): ' # ✅ 带位置的追踪
exec 5> /tmp/trace.log; BASH_XTRACEFD=5; set -x # 追踪输出单独存文件
declare -p myvar myarr # 打印变量的完整状态
9.1 ShellCheck:必须用
shellcheck script.sh
shellcheck -s bash -S warning script.sh
shellcheck -x script.sh # 跟进 source 的文件
find . -name '*.sh' -print0 | xargs -0 shellcheck -S warning # CI 里跑
它能查出的典型问题(每条都对应真实的 bug):
| 编号 | 问题 | 后果 |
|---|---|---|
| SC2086 | 变量未加引号 | 空格/glob 导致参数错乱 |
| SC2046 | $(cmd) 未加引号 |
同上 |
| SC2068 | $@ 未加引号 |
参数边界丢失 |
| SC2164 | cd 没检查返回值 |
目录不存在仍执行后续危险操作 |
| SC2155 | local x=$(cmd) 掩盖退出码 |
set -e 不生效(1.3 陷阱 3) |
| SC2015 | A && B || C 的逻辑陷阱 |
B 失败也会执行 C |
| SC2094 | 同一文件同时读写 | 文件被清空(sort f > f) |
| SC2115 | rm -rf "$VAR/" 未校验 |
变量为空时删根目录 |
| SC2181 | 用 $? 而不是直接判断命令 |
易错 |
# 需要忽略某条时,写明理由
# shellcheck disable=SC2086 # 这里确实需要分词展开 opts
mycommand $opts
9.2 测试
# bats:shell 的单元测试框架
cat > test/utils.bats <<'EOF'
setup() { source "${BATS_TEST_DIRNAME}/../lib/utils.sh"; }
@test "version_gt 能正确比较版本" {
run version_gt "1.2.3" "1.2.2"
[ "$status" -eq 0 ]
}
@test "缺少参数时应该报错" {
run ./deploy.sh
[ "$status" -ne 0 ]
[[ "$output" == *"必须指定"* ]]
}
EOF
bats test/
10. 性能:开篇第五问
10.1 为什么慢
shell 慢的唯一原因是「进程创建」(fork + exec 的开销),不是「解释执行」。
time for i in {1..10000}; do echo x >/dev/null; done
# real 0m0.09s <- echo 是 builtin,不起进程
time for i in {1..10000}; do /bin/echo x >/dev/null; done
# real 0m5.20s <- 每次 fork+exec,慢 50 倍
10.2 优化手段
# ── ① 用 builtin 和参数展开替代外部命令 ──
basename "$p" -> "${p##*/}" # 快 100 倍
dirname "$p" -> "${p%/*}"
echo "$s" | tr a-z A-Z -> "${s^^}"
expr $a + $b -> $((a + b))
echo "$s" | wc -c -> ${#s}
$(cat file) -> $(<file) # ✅ bash 内建读文件,不起 cat
# ── ② 不要在循环里起进程 ──
# ❌ 10000 行 = 20000 个进程
while read -r line; do
field=$(echo "$line" | cut -d, -f2)
echo "$field" >> out.txt
done < input.csv
# ✅ 一个进程搞定
awk -F, '{print $2}' input.csv > out.txt
# ✅ 或纯 shell(不起进程)
while IFS=, read -r _ field _; do printf '%s\n' "$field"; done < input.csv > out.txt
# ── ③ 批量而不是逐个 ──
for f in *.log; do gzip "$f"; done # ❌
printf '%s\0' *.log | xargs -0 -P "$(nproc)" gzip # ✅
# ── ④ 减少重复调用 ──
for i in {1..1000}; do echo "$(date) $i"; done # ❌ 调 1000 次 date
now=$(date); for i in {1..1000}; do echo "$now $i"; done # ✅
# ── ⑤ 并行 ──
printf '%s\n' "${hosts[@]}" | xargs -P 10 -I{} ssh -n {} uptime # xargs -P
for h in "${hosts[@]}"; do ssh -n "$h" uptime > "/tmp/out.$h" & done
wait # 后台任务 + wait
max_jobs=10 # 带并发上限
for h in "${hosts[@]}"; do
while (( $(jobs -rp | wc -l) >= max_jobs )); do sleep 0.1; done
ssh -n "$h" uptime > "/tmp/out.$h" &
done
wait
parallel -j 10 ssh {} uptime ::: "${hosts[@]}" # GNU parallel(输出不交错)
10.3 什么时候该换语言
继续用 shell:
✓ 主要在【调用其他命令】、串联管道
✓ 逻辑简单(顺序执行 + 少量条件)
✓ 200 行以内
✓ 系统管理、部署、CI 胶水脚本
换 Python / Go:
✗ 需要【数据结构】(嵌套字典、JSON、结构体)
✗ 需要【复杂错误处理】(重试、回滚、事务)
✗ 需要【并发控制】(超过「起几个后台任务」的程度)
✗ 需要【单元测试】(bats 能用但很受限)
✗ 大量字符串/数值计算(shell 没有浮点,要靠 bc/awk)
✗ 超过 300~500 行,或要被多人长期维护
判断标准:「如果你在 shell 里实现数据结构、或在 grep/sed/awk 里嵌套三层以上,
就该换语言了。」
11. 生产脚本检查清单
── 骨架 ──
[ ] #!/usr/bin/env bash
[ ] set -euo pipefail
[ ] 关键路径显式检查错误(不只依赖 -e)
[ ] trap cleanup EXIT 清理临时资源
[ ] 开头有用途、用法、示例注释
── 安全 ──
[ ] 所有变量引用加双引号 "$var"
[ ] 数组用 "${arr[@]}",参数用 "$@"
[ ] rm -rf 前校验变量非空:${VAR:?} 或显式 if
[ ] 临时文件用 mktemp(不用 $$ 拼名字)
[ ] 密码不写在命令行(会进 ps 和 history)
[ ] 用 find -print0 | xargs -0 处理文件名
[ ] 幂等(重复执行结果一致)
[ ] 破坏性操作有 --dry-run
── 可靠性 ──
[ ] 外部命令依赖检查(command -v)
[ ] 网络/远程操作有超时(curl -m、timeout、ssh -o ConnectTimeout)
[ ] 长任务防并发(flock)
[ ] 关键操作前备份
[ ] 有失败通知(邮件/webhook/healthchecks)
── 可观测 ──
[ ] 日志走 stderr,数据走 stdout
[ ] 日志带时间戳与级别
[ ] 有 DEBUG 开关
[ ] 退出码有意义
── 质量 ──
[ ] shellcheck 无 warning
[ ] bash -n 通过
[ ] 在目标环境实际跑过
[ ] cron 里跑的用 env -i 模拟过最小环境(14 篇 8.3)
12. 面试题
Q:set -euo pipefail 每一项在防什么?set -e 有哪些不生效的情况?
-e:命令失败立即退出,防止「错误被忽略后继续造成更大破坏」(cd /nonexistent失败后继续rm -rf ./*)-u:用未定义变量报错,防止变量名拼错导致rm -rf "$TAGET/"变成rm -rf /-o pipefail:管道任意段失败即算失败,防止curl ... | jq里 curl 的失败被 jq 的成功掩盖
set -e 至少有六种不生效的情况,这是关键:
- 条件上下文里完全失效:
if cmd、while cmd、cmd && ...、cmd || ...、! cmd - 函数被用在条件里时,函数内部的
-e也失效(很隐蔽) local x=$(cmd)/export x=$(cmd)掩盖退出码(local自身的退出码覆盖了命令的)—— 必须分两行写- 管道中非最后一个命令(未开 pipefail 时)
((count++))结果为 0 时返回非零,会导致脚本退出grep没匹配返回 1
结论:set -euo pipefail 是基线而非保障,关键路径必须显式检查 —— cmd || die "..." 或 if ! cmd; then ...。
Q:[[ ]] 和 [ ] 有什么本质区别?
[ ] 是命令,[[ ]] 是 bash 关键字 —— 这一条差异决定了其余所有区别。
因为 [ ] 是命令,它的参数要经过正常的分词和 glob 展开:v=""; [ $v = "x" ] 会展开成 [ = "x" ](参数缺失,语法错误),f="a b"; [ -f $f ] 会变成三个参数。
而 [[ ]] 在语法层面被解析,内部不做分词也不做 glob,所以变量不加引号也安全。它还独有三个能力:=~ 正则匹配(捕获组存在 BASH_REMATCH 数组里)、== 右侧的 glob 匹配、以及直接用 &&/||([ ] 只能用已废弃的 -a/-o)。
代价是 [[ ]] 不是 POSIX(bash/zsh/ksh 专有)。所以规则是:bash 脚本一律用 [[ ]](字符串/文件)和 (( ))(数值),只有写 #!/bin/sh 时才用 [ ]。
Q:为什么数组必须写 "${arr[@]}"?[@] 和 [*] 有什么区别?
[@] 让每个元素独立成词,[*] 把所有元素拼成一个字符串(用 IFS 首字符连接)。加引号才能保住这个区别:
arr=(a "b c" d)
printf '%s\n' "${arr[@]}" # 3 行:a / b c / d ✅ "b c" 完整
printf '%s\n' "${arr[*]}" # 1 行:a b c d
for x in ${arr[@]} # ❌ 不加引号,"b c" 被拆成两个
这与 "$@" / "$*" 的区别完全一致。实际价值最大的场景是构建命令参数:
args=(--output "/path with space/x.txt")
[[ "$DEBUG" == true ]] && args+=(--debug)
mycommand "${args[@]}" # ✅ 每个元素一个独立参数
如果用字符串拼接 opts="--output '/path with space/x.txt'",那些引号不会被再次解析,路径仍然会被空格拆开 —— 这是「用字符串代替数组」的经典 bug。
Q:怎么保证脚本无论怎么退出都能清理临时文件?
用 trap cleanup EXIT —— 它在任何退出路径上都会执行:正常结束、exit、set -e 触发、被 SIGINT/SIGTERM 杀死。
两个实现细节很关键:
cleanup() {
local exit_code=$? # ✅ 第一行就保存(后面的命令会覆盖 $?)
[[ -n "$TMPDIR_SELF" && -d "$TMPDIR_SELF" ]] && rm -rf -- "$TMPDIR_SELF"
return $exit_code # ✅ 保持原退出码
}
trap cleanup EXIT
TMPDIR_SELF=$(mktemp -d) # trap 设好【之后】才创建资源
唯一无法捕获的是 SIGKILL(9),所以清理不能只依赖 trap —— 临时文件应放在 /tmp(重启清空)或用可预测路径便于后续清理。
另外推荐配 trap 'on_error $LINENO "$BASH_COMMAND"' ERR + set -E,出错时打印行号、失败的命令和调用栈(FUNCNAME),比单纯的 set -e 有用得多。
Q:shell 脚本为什么慢?什么时候该换语言?
慢的唯一原因是进程创建(fork + exec),不是「解释执行」。实测循环一万次:用 builtin 的 echo 是 0.09 秒,用 /bin/echo 是 5.2 秒 —— 差 50 倍。
四条优化:① 用参数展开替代外部命令(${p##*/} 替 basename 快百倍,$(<file) 替 $(cat file));② 绝不在循环里起进程(把 while read + cut 换成一次 awk);③ 批量而非逐个(xargs -P 替 for 循环);④ 循环外提取不变的调用(date 只调一次)。
该换语言的信号:需要数据结构(嵌套 JSON、结构体)、需要复杂错误处理(重试/回滚/事务)、需要真正的并发控制、需要单元测试、有大量数值计算(shell 没有浮点)、或者超过 300~500 行。
一个很实用的判断标准:如果你在 shell 里实现数据结构,或者在 grep/sed/awk 里嵌套三层以上,就该换语言了。
Q:为什么脚本的日志必须输出到 stderr?
因为stdout 是「数据通道」,要留给函数返回值和管道下游。
shell 函数无法用 return 返回数据(只能返回 0-255 的退出码),所以约定是用 stdout 返回数据。如果日志也走 stdout,就会混进返回值:
get_ips() {
info "正在查询…" # 走 stderr,不污染
echo "10.0.0.1"; echo "10.0.0.2"
}
get_ips | wc -l # 2 ✅ 如果 info 走 stdout,这里会是 3
count=$(get_ips) # 也会被污染
配套的两个实践:颜色输出要用 [[ -t 2 ]] 判断 stderr 是否连着终端(重定向到文件时不输出 ANSI 转义码,否则日志文件里全是乱码);整个脚本的输出落盘用 exec > >(tee -a "$LOG") 2>&1,或者干脆交给 systemd 的 StandardOutput=journal。
Q:getopts 和手写参数解析各有什么适用场景?
getopts 是 shell 内建,处理短选项(-e prod -d)很干净,自动处理选项聚合(-abc)、OPTARG、OPTIND,用 while getopts ':e:v:dh' opt + shift $((OPTIND-1)) 就够了。开头那个冒号表示静默模式(自己处理错误提示)。
它的硬限制是只支持单字符选项,不认 --long-option。三个方案:
- 手写
while [[ $# -gt 0 ]]+case(最常用,无依赖,还能支持--env=prod这种等号形式,用${1#*=}提取) getopt(1)外部命令(GNU 版支持长选项,但 macOS 自带的 BSD 版不支持,可移植性差)- 参数复杂了就换语言(Python 的
argparse、Go 的flag/cobra能自动生成帮助、做类型校验)
顺带两个易错点:"${10}" 超过 9 的位置参数必须加花括号;-- 之后的参数应该一律当位置参数处理。
Q:写完脚本上生产前该做哪些检查?
四类:
安全:所有变量加双引号、数组用 "${arr[@]}"、rm -rf 前用 ${VAR:?} 校验非空、临时文件用 mktemp、密码不放命令行(会进 ps 和 history)、文件名用 find -print0 | xargs -0。
可靠:外部依赖用 command -v 检查、网络操作有超时(curl -m、timeout、ssh -o ConnectTimeout)、长任务用 flock 防并发、破坏性操作提供 --dry-run、脚本幂等(重复执行结果一致)。
可观测:日志走 stderr 带时间戳和级别、有 DEBUG 开关、退出码有意义、失败要有通知(否则「静默失败三个月」是最常见的事故,12 篇 6.4 提过)。
质量:shellcheck 无 warning(它能查出 SC2086 未加引号、SC2155 local x=$(cmd) 掩盖退出码、SC2115 rm -rf "$VAR/" 未校验这些真实 bug)、bash -n 语法检查、在目标环境实际跑过、cron 里的脚本用 env -i 模拟过最小环境。
小结
#!/usr/bin/env bash最可移植;#!/bin/sh在 Debian 上是 dash,[[ ]]、数组、<()全部不可用set -euo pipefail是基线不是保障 —— 它在条件上下文、函数被当条件用、local x=$(cmd)、((i++))等场景下不生效,关键路径要显式检查- 引号铁律:
"$var"、"$@"、"${arr[@]}"、"$(cmd)"永远加引号 [[ ]]是关键字所以不分词不 glob,还独有=~正则和 glob 匹配;数值用(( ))- 数组是安全处理多值的唯一方式,
[@]每个元素独立、[*]拼成一个字符串 trap cleanup EXIT是唯一可靠的清理方式(第一行存$?,SIGKILL无法捕获)flock防并发,不要自己用 pid 文件(有竞态)- stdout 是数据通道,日志一律走 stderr;颜色用
[[ -t 2 ]]判断 - shell 慢在进程创建:用参数展开替代
basename/cut,别在循环里起进程,用xargs -P并行 - 超过 300 行 / 需要数据结构 / 需要测试,就该换 Python 或 Go
shellcheck是必须的,它查出的每条警告都对应真实 bug
上手部分(01~17)到这里结束。 从下一篇开始进入第二部分:深入层(18~40 篇),主线从「怎么用」转向「为什么被设计成这样」:
- 17 Unix 设计哲学与 Linux 的形态 —— 一切皆文件的代价、宏内核之争、POSIX 是谁的妥协
- 18 从按下电源到 shell 提示符 —— 每一环为什么必须存在、initramfs 为什么被发明
- 19 VFS:一切皆文件的实现代价 ——
file_operations如何统一磁盘/管道/socket - 20 进程的诞生:fork 为什么是 fork —— 对比
CreateProcess/posix_spawn,COW 是设计还是补丁 - 21~22 凭证模型与权限演进史 —— 你在 10 篇学的操作,在这里能看到它们为什么长成这样
下一篇讲 Unix 设计哲学与 Linux 的形态。
xingliuhua