Shell脚本编程
1. 概念与原理
前端写 Shell 脚本的高频场景:
- CI/CD 部署脚本(push 构建产物、备份、回滚)
- 本地开发自动化(启动多服务、批量重命名、清理缓存)
- 服务器维护脚本(日志清理、备份、健康检查)
- Docker 镜像 entrypoint / Dockerfile 里的多行命令
写 Shell 不是写一次性命令,而是写可重入、可调试、可在不同机器跑、出错能停的程序。这一篇教你按工程标准写 Shell。
2. Shebang 与解释器
#!/usr/bin/env bash # 推荐:用 env 找 bash,跨平台
#!/bin/bash # 写死路径,Alpine 容器里没 bash 会失败
#!/bin/sh # POSIX sh,最便携但功能少
Alpine 镜像默认只有 ash(busybox),写 #!/bin/bash 会报 not found。容器场景要么改用 #!/bin/sh + POSIX 语法,要么 apk add bash。
3. 严格模式(必备)
每个生产脚本开头加这一行,能避免 80% 隐藏 bug:
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
| 选项 | 作用 |
|---|---|
-e | 任何命令非 0 退出码就中止脚本 |
-u | 引用未定义变量就报错(避免 rm -rf $UNDEFINED/ 灾难) |
-o pipefail | 管道里任何一段失败整个管道失败(不然 false | true 算成功) |
IFS=$'\n\t' | 字段分隔符只用换行和 tab,避免空格切分文件名 |
调试时加 -x(打印每条执行的命令):
#!/usr/bin/env bash
set -euxo pipefail
或临时:bash -x script.sh。
局部禁用严格模式
某些命令允许失败:
set +e
some_command_that_might_fail
exit_code=$?
set -e
# 或者用 || true
some_command || true
# 或者显式判断
if ! some_command; then
echo "失败但继续"
fi
4. 变量与引用
4.1 永远加双引号
# 错(文件名含空格直接挂)
rm $file
cp $src $dst
# 对
rm "$file"
cp "$src" "$dst"
唯一不加引号的场景:需要 shell 做词分割(罕见)。
4.2 变量默认值
PORT="${PORT:-3000}" # 未设置或为空时用 3000
ENV="${ENV:-production}"
LOG_DIR="${LOG_DIR:-/var/log/app}"
# 必须设置,否则报错
TOKEN="${TOKEN:?需要设置 TOKEN 环境变量}"
4.3 字符串处理
str="hello-world.tar.gz"
echo "${#str}" # 长度 17
echo "${str:0:5}" # 子串 hello
echo "${str%.tar.gz}" # 从右移除最短匹配:hello-world
echo "${str%%.*}" # 从右移除最长匹配:hello-world
echo "${str#*.}" # 从左移除最短匹配:tar.gz
echo "${str##*.}" # 从左移除最长匹配:gz
echo "${str/world/foo}" # 替换第一个:hello-foo.tar.gz
echo "${str//-/_}" # 全部替换:hello_world.tar.gz
echo "${str^^}" # 大写
echo "${str,,}" # 小写
记忆口诀:# 在键盘左、% 在右;单字符短匹配,双字符长匹配。
4.4 数组
# 定义
files=("a.txt" "b.txt" "c.txt")
files+=("d.txt") # 追加
# 访问
echo "${files[0]}" # a.txt
echo "${files[@]}" # 所有元素,分别引用
echo "${#files[@]}" # 长度
# 遍历
for f in "${files[@]}"; do
echo "$f"
done
# 从命令输出建数组
mapfile -t files < <(find . -name "*.log")
# 或
readarray -t files < <(find . -name "*.log")
关键陷阱:"${arr[@]}" 加引号才能正确处理空格文件名,"${arr[*]}" 是把所有元素拼一个字符串(用 IFS 第一个字符分隔)。
4.5 关联数组(bash 4+)
declare -A config
config[host]="example.com"
config[port]="443"
echo "${config[host]}"
for key in "${!config[@]}"; do
echo "$key = ${config[$key]}"
done
macOS 默认 bash 3.2,不支持关联数组。需要 brew install bash 或用 zsh。
5. 控制流
5.1 if / 测试
# 文件测试
if [[ -f "$file" ]]; then echo "是普通文件"; fi
if [[ -d "$dir" ]]; then echo "是目录"; fi
if [[ -e "$path" ]]; then echo "存在"; fi
if [[ -r "$f" ]]; then echo "可读"; fi
if [[ -w "$f" ]]; then echo "可写"; fi
if [[ -x "$f" ]]; then echo "可执行"; fi
if [[ -s "$f" ]]; then echo "非空"; fi
if [[ -z "$str" ]]; then echo "空字符串"; fi
if [[ -n "$str" ]]; then echo "非空字符串"; fi
# 字符串
if [[ "$a" == "$b" ]]; then ...
if [[ "$a" != "$b" ]]; then ...
if [[ "$str" == prefix* ]]; then ... # 通配符匹配
if [[ "$str" =~ ^[0-9]+$ ]]; then ... # 正则
# 数字
if [[ "$n" -eq 0 ]]; then ...
if [[ "$n" -lt 100 ]]; then ...
# 或 (( )) 算术
if (( n < 100 )); then ...
# 组合
if [[ -f "$f" && -r "$f" ]]; then ...
if [[ "$a" == "x" || "$b" == "y" ]]; then ...
重要:[[ ]] 是 bash 增强(推荐),[ ] 是 POSIX。前者支持 ==、=~、&&、不需要给变量加引号防词分割。容器脚本里如果用 /bin/sh,必须用 [ ]。
5.2 case
case "$1" in
start)
echo "启动"
;;
stop|kill)
echo "停止"
;;
*.log)
echo "处理日志"
;;
*)
echo "未知命令"
exit 1
;;
esac
5.3 循环
# for
for i in 1 2 3; do echo $i; done
for i in {1..10}; do echo $i; done
for i in {0..100..10}; do echo $i; done # 步长 10
for f in *.log; do echo "$f"; done # 文件名展开
# C 风格
for ((i=0; i<10; i++)); do echo $i; done
# 遍历命令输出(避免坑)
# 错:for line in $(cat file); 会按空格切,含空格文件名挂
# 对:
while IFS= read -r line; do
echo "$line"
done < file.txt
# 同时遍历两个数组
for ((i=0; i<${#arr1[@]}; i++)); do
echo "${arr1[i]} - ${arr2[i]}"
done
# while
while [[ $n -lt 10 ]]; do
((n++))
done
# 无限重试 + 间隔
until curl -sf https://api/health; do
echo "等待中..."
sleep 2
done
6. 函数
# 定义
log() {
echo "[$(date '+%F %T')] $*"
}
# 参数
deploy() {
local env="$1" # 必须 local,否则全局污染
local version="$2"
log "部署 $env 版本 $version"
# 函数返回值通过 echo + 调用方捕获,return 只能 0-255 状态码
}
# 调用
deploy "production" "v1.2.3"
# 捕获 stdout
result=$(deploy "production" "v1.2.3")
# 返回状态码
is_running() {
systemctl is-active --quiet "$1"
return $? # 显式不写也行,最后一条命令的返回值就是函数返回值
}
if is_running nginx; then echo "运行中"; fi
6.1 函数库复用
# lib/log.sh
log_info() { echo "[INFO] $*"; }
log_warn() { echo "[WARN] $*" >&2; }
log_error() { echo "[ERROR] $*" >&2; }
# main.sh
source "$(dirname "$0")/lib/log.sh"
log_info "开始"
source 等同 .,在当前 shell 执行,函数和变量都生效。
7. 错误处理
7.1 trap 捕获信号
cleanup() {
echo "清理临时文件..."
rm -rf "$TMPDIR"
}
trap cleanup EXIT # 脚本退出时执行(无论正常异常)
trap 'echo "被中断"; exit 130' INT TERM
TMPDIR=$(mktemp -d)
# ... 业务代码
trap cleanup EXIT 是 Shell 脚本的 try ... finally。
7.2 退出码约定
| 码 | 含义 |
|---|---|
| 0 | 成功 |
| 1 | 通用错误 |
| 2 | 用法错误(参数问题) |
| 126 | 命令找到但不能执行(权限) |
| 127 | 命令找不到 |
| 128+N | 被信号 N 杀死(如 130 = Ctrl+C,137 = SIGKILL) |
| 自定义 | 1-125 任选 |
if [[ $# -lt 1 ]]; then
echo "用法: $0 <env>" >&2
exit 2
fi
deploy_app || exit 1
7.3 stderr 与 stdout 分离
echo "正常输出" # stdout (fd 1)
echo "错误信息" >&2 # stderr (fd 2)
# 重定向
cmd > out.log # stdout 到文件
cmd 2> err.log # stderr 到文件
cmd > out.log 2>&1 # 都到 out.log(顺序很重要!)
cmd &> all.log # bash 简写,stdout + stderr
cmd > /dev/null 2>&1 # 都丢弃
cmd > >(tee out.log) # stdout 同时显示和保存
2>&1 必须在 > file 之后:cmd 2>&1 > file 是把 stderr 复制到当前 stdout(终端),再把 stdout 改到文件,结果 stderr 没进文件。
8. 实战模板
8.1 通用部署脚本骨架
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'
# === 配置 ===
readonly APP_NAME="myapp"
readonly DEPLOY_DIR="/var/www/${APP_NAME}"
readonly BACKUP_DIR="/var/backups/${APP_NAME}"
readonly TIMESTAMP=$(date +%Y%m%d_%H%M%S)
# === 日志 ===
log() { echo "[$(date '+%F %T')] $*"; }
err() { echo "[$(date '+%F %T')] [ERROR] $*" >&2; }
die() { err "$*"; exit 1; }
# === 用法 ===
usage() {
cat <<EOF
用法: $0 <version>
示例: $0 v1.2.3
EOF
exit 2
}
# === 清理 ===
TMPDIR=""
cleanup() {
[[ -n "$TMPDIR" && -d "$TMPDIR" ]] && rm -rf "$TMPDIR"
}
trap cleanup EXIT
# === 主流程 ===
main() {
[[ $# -lt 1 ]] && usage
local version="$1"
log "部署版本 $version"
# 检查依赖
command -v rsync >/dev/null || die "需要 rsync"
command -v node >/dev/null || die "需要 node"
# 下载产物
TMPDIR=$(mktemp -d)
log "下载到 $TMPDIR"
curl -fsSL "https://cdn/builds/${version}.tar.gz" -o "$TMPDIR/build.tar.gz" \
|| die "下载失败"
# 备份现版本
if [[ -d "$DEPLOY_DIR" ]]; then
log "备份现版本"
mkdir -p "$BACKUP_DIR"
tar czf "${BACKUP_DIR}/${TIMESTAMP}.tar.gz" -C "$DEPLOY_DIR" .
fi
# 解压
tar xzf "$TMPDIR/build.tar.gz" -C "$TMPDIR"
# 原子切换(用临时目录 rename)
local new_dir="${DEPLOY_DIR}.new"
rm -rf "$new_dir"
mv "$TMPDIR/dist" "$new_dir"
rm -rf "${DEPLOY_DIR}.old"
[[ -d "$DEPLOY_DIR" ]] && mv "$DEPLOY_DIR" "${DEPLOY_DIR}.old"
mv "$new_dir" "$DEPLOY_DIR"
# 重载 Nginx
sudo nginx -t || die "Nginx 配置错误"
sudo nginx -s reload
log "部署完成"
}
main "$@"
8.2 健康检查 + 重试
#!/usr/bin/env bash
set -euo pipefail
URL="${1:-http://localhost:3000/health}"
MAX_RETRIES=30
INTERVAL=2
for ((i=1; i<=MAX_RETRIES; i++)); do
if curl -sf "$URL" >/dev/null; then
echo "健康检查通过(尝试 $i 次)"
exit 0
fi
echo "未就绪,$INTERVAL 秒后重试 ($i/$MAX_RETRIES)"
sleep "$INTERVAL"
done
echo "健康检查失败" >&2
exit 1
8.3 并发处理(用 xargs)
# 单线程
for url in $(cat urls.txt); do
curl -s "$url" -o "$(basename $url).html"
done
# 并发 10 个
cat urls.txt | xargs -P 10 -I {} sh -c 'curl -s "$1" -o "$(basename $1).html"' _ {}
# GNU parallel(更强大)
parallel -j 10 curl -s -O ::: $(cat urls.txt)
9. 跨平台兼容
9.1 macOS vs Linux 差异
| 命令 | macOS(BSD) | Linux(GNU) |
|---|---|---|
| sed -i | 需要后缀 sed -i '' 's/a/b/' f | sed -i 's/a/b/' f |
| date -d | 不支持 | date -d '1 day ago' |
| readlink -f | 不支持 | 支持 |
| stat | stat -f | stat -c |
| 排序 | sort 行为略不同 |
跨平台脚本两种方案:
- 检测系统分支:
[[ "$OSTYPE" == "darwin"* ]] - macOS 上
brew install coreutils gnu-sed,用gsed、gdate、greadlink
9.2 POSIX sh vs bash
容器场景常用 /bin/sh(Alpine 的 ash),不支持:
[[ ]],必须用[ ]- 数组 / 关联数组
${var//pattern/repl}全替换<<<here string((arithmetic)),要用$((...))或expr
要么 apk add bash 加上 bash,要么严格按 POSIX 写。用 shellcheck 校验。
10. shellcheck — 必装
# 安装
brew install shellcheck
apt install shellcheck
# 用
shellcheck script.sh
shellcheck 能查出 90% 的常见 bug(缺引号、未定义变量、错误的测试表达式、不可移植语法)。CI 里强制跑:
# .github/workflows/lint.yml
- name: Shellcheck
run: shellcheck scripts/*.sh
VSCode 装 shellcheck 插件实时提示。
11. 安全考量
11.1 命令注入
# 危险:用户输入直接拼到命令
read -p "输入文件名: " filename
rm $filename # 用户输入 "; rm -rf /"
# 安全:加引号 + 校验
read -r filename
[[ "$filename" =~ ^[a-zA-Z0-9._-]+$ ]] || die "非法文件名"
rm -- "$filename" # -- 防止 -rf 这种被当参数
11.2 敏感信息
# 危险:在命令行参数传 token
curl -H "Authorization: Bearer $TOKEN" url
# 其他用户 ps aux 能看到完整命令含 token
# 安全:用 stdin 或环境变量文件
curl -H "@-" url <<< "Authorization: Bearer $TOKEN"
# 或 curl --config 用配置文件,文件权限 600
11.3 临时文件
# 危险:可预测的临时文件名
echo "data" > /tmp/myfile # 攻击者可预先创建 symlink
# 安全:mktemp
tmp=$(mktemp)
trap "rm -f $tmp" EXIT
echo "data" > "$tmp"
12. 常见反模式
- 不加
set -euo pipefail:错误被吞,定位灾难 - 变量不加引号:含空格的路径分分钟挂
for line in $(cat file):换行被空格切,用while readif [ "$a" == "$b" ]:==不是 POSIX,用=或[[ ]]cd dir && rm -rf *:cd 失败时在当前目录爆炸。用cd dir || exit#!/bin/bash在 Alpine:没 bash 直接挂rm -rf $PATH/:变量为空时变成rm -rf /。用${PATH:?}防御- 大脚本不拆函数:500 行 main 没法维护
- 不用 shellcheck:CI 跑一下能省一半排障时间
13. 延伸阅读
- Google Shell Style Guide — 业内标杆
- Bash Pitfalls — 200+ 个常见坑,必读
- Bash Hackers Wiki — 内核级深入
- ShellCheck wiki — 每条规则都有详细解释
- 《The Linux Command Line》William Shotts — 系统学 Shell