目录

Linux-17 Shell 脚本工程化:set -euo pipefail 的陷阱、引号规则与 trap 清理

这是上手部分的最后一篇。前面 16 篇讲的命令,最终都会被写进脚本 —— 而脚本的可靠性取决于你是否知道 shell 的那些坑

先看五个问题:

  1. set -euo pipefail 每一项在防什么?它在哪些情况下完全不生效
  2. [[ ]][ ] 有什么本质区别?
  3. 什么时候必须加引号?有没有一条能记住的规则?
  4. 怎么保证脚本无论怎么退出(正常、报错、被 Ctrl-C、被 kill)都能清理临时文件?
  5. 为什么 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/shdash(不是 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 是唯一可靠的清理方式 —— 它在任何退出路径上都会执行:正常结束、exitset -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 至少有六种不生效的情况,这是关键:

  1. 条件上下文里完全失效if cmdwhile cmdcmd && ...cmd || ...! cmd
  2. 函数被用在条件里时,函数内部的 -e 也失效(很隐蔽)
  3. local x=$(cmd) / export x=$(cmd) 掩盖退出码local 自身的退出码覆盖了命令的)—— 必须分两行写
  4. 管道中非最后一个命令(未开 pipefail 时)
  5. ((count++)) 结果为 0 时返回非零,会导致脚本退出
  6. 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 —— 它在任何退出路径上都会执行:正常结束、exitset -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 -Pfor 循环);④ 循环外提取不变的调用(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)、OPTARGOPTIND,用 while getopts ':e:v:dh' opt + shift $((OPTIND-1)) 就够了。开头那个冒号表示静默模式(自己处理错误提示)。

它的硬限制是只支持单字符选项,不认 --long-option。三个方案:

  1. 手写 while [[ $# -gt 0 ]] + case(最常用,无依赖,还能支持 --env=prod 这种等号形式,用 ${1#*=} 提取)
  2. getopt(1) 外部命令(GNU 版支持长选项,但 macOS 自带的 BSD 版不支持,可移植性差)
  3. 参数复杂了就换语言(Python 的 argparse、Go 的 flag/cobra 能自动生成帮助、做类型校验)

顺带两个易错点:"${10}" 超过 9 的位置参数必须加花括号-- 之后的参数应该一律当位置参数处理。

Q:写完脚本上生产前该做哪些检查?

四类:

安全:所有变量加双引号、数组用 "${arr[@]}"rm -rf 前用 ${VAR:?} 校验非空、临时文件用 mktemp、密码不放命令行(会进 ps 和 history)、文件名用 find -print0 | xargs -0

可靠:外部依赖用 command -v 检查、网络操作有超时(curl -mtimeoutssh -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 的形态