跳到主要内容

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/' fsed -i 's/a/b/' f
date -d不支持date -d '1 day ago'
readlink -f不支持支持
statstat -fstat -c
排序sort 行为略不同

跨平台脚本两种方案:

  1. 检测系统分支:[[ "$OSTYPE" == "darwin"* ]]
  2. macOS 上 brew install coreutils gnu-sed,用 gsedgdategreadlink

9.2 POSIX sh vs bash

容器场景常用 /bin/sh(Alpine 的 ash),不支持:

  • [[ ]],必须用 [ ]
  • 数组 / 关联数组
  • ${var//pattern/repl} 全替换
  • &lt;&lt;&lt; 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 read
  • if [ "$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. 延伸阅读