Unix 设计哲学教程 / 第 11 章:Shell 脚本与自动化
第 11 章:Shell 脚本与自动化
“The best way to automate is to first do it manually, then script it.”
Shell 脚本是 Unix 哲学的直接实践——将多个小工具通过管道和控制流组合,完成复杂的自动化任务。从系统管理到 DevOps,从数据处理到 CI/CD,Shell 脚本无处不在。
11.1 Shell 脚本基础
脚本结构
#!/bin/bash
# 脚本说明:简要描述脚本功能
# 作者:你的名字
# 日期:2026-05-10
set -euo pipefail # 严格模式
# -e: 命令失败立即退出
# -u: 使用未定义变量报错
# -o pipefail: 管道中任一命令失败即失败
# 常量定义
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly SCRIPT_NAME="$(basename "$0")"
readonly LOG_FILE="/var/log/${SCRIPT_NAME%.sh}.log"
# 函数定义
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"
}
error() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: $*" >&2
exit 1
}
cleanup() {
# 清理临时文件
rm -f "${TMP_FILE:-}"
}
# 设置清理陷阱
trap cleanup EXIT
# 主逻辑
main() {
log "脚本开始执行"
# ... 业务逻辑 ...
log "脚本执行完成"
}
main "$@"
变量与数据类型
# 变量赋值(等号两边不能有空格)
name="Alice"
age=30
readonly PI=3.14159 # 只读变量
# 使用变量
echo "$name"
echo "${name}_suffix" # 使用花括号避免歧义
# 字符串操作
str="Hello, World!"
echo "${#str}" # 长度: 13
echo "${str:0:5}" # 截取: Hello
echo "${str/World/Unix}" # 替换: Hello, Unix!
echo "${str,,}" # 转小写 (Bash 4+)
echo "${str^^}" # 转大写 (Bash 4+)
# 数组 (Bash)
arr=("apple" "banana" "cherry")
echo "${arr[0]}" # 第一个元素: apple
echo "${arr[@]}" # 所有元素
echo "${#arr[@]}" # 数组长度: 3
arr+=("date") # 追加元素
# 关联数组 (Bash 4+)
declare -A map
map[name]="Alice"
map[age]=30
echo "${map[name]}"
echo "${!map[@]}" # 所有键
# 命令替换
current_date=$(date +%Y-%m-%d)
file_count=$(find . -type f | wc -l)
# 算术运算
a=10; b=3
echo $((a + b)) # 加法: 13
echo $((a - b)) # 减法: 7
echo $((a * b)) # 乘法: 30
echo $((a / b)) # 除法: 3
echo $((a % b)) # 取余: 1
echo $((a ** b)) # 幂: 1000
# 浮点运算(需要 bc)
result=$(echo "scale=2; $a / $b" | bc)
echo "$result" # 3.33
条件判断
# 文件测试
[ -f file ] # 文件存在且是普通文件
[ -d dir ] # 目录存在
[ -r file ] # 文件可读
[ -w file ] # 文件可写
[ -x file ] # 文件可执行
[ -s file ] # 文件存在且大小 > 0
[ -L file ] # 文件是符号链接
[ file1 -nt file2 ] # file1 比 file2 新
[ file1 -ot file2 ] # file1 比 file2 旧
# 字符串测试
[ -z "$str" ] # 字符串为空
[ -n "$str" ] # 字符串不为空
[ "$a" = "$b" ] # 字符串相等
[ "$a" != "$b" ] # 字符串不等
[ "$a" \< "$b" ] # 字符串字典序小于
# 数值测试
[ "$a" -eq "$b" ] # 等于
[ "$a" -ne "$b" ] # 不等于
[ "$a" -gt "$b" ] # 大于
[ "$a" -ge "$b" ] # 大于等于
[ "$a" -lt "$b" ] # 小于
[ "$a" -le "$b" ] # 小于等于
# 逻辑运算
[ "$a" -gt 0 ] && [ "$a" -lt 100 ] # AND
[ "$a" -eq 0 ] || [ "$a" -eq 1 ] # OR
[ ! -f file ] # NOT
# [[ ]] 扩展测试(Bash)
[[ "$str" =~ ^[0-9]+$ ]] # 正则匹配
[[ "$str" == *.txt ]] # 通配符匹配
[[ -f file && -r file ]] # 组合条件(不用 [ ] 的话更简洁)
# if-elif-else
if [ "$count" -gt 100 ]; then
echo "大于100"
elif [ "$count" -gt 50 ]; then
echo "大于50"
else
echo "小于等于50"
fi
# case 语句
case "$action" in
start)
start_service
;;
stop)
stop_service
;;
restart)
stop_service
start_service
;;
*)
echo "Usage: $0 {start|stop|restart}"
exit 1
;;
esac
循环
# for 循环
for i in 1 2 3 4 5; do
echo "$i"
done
# 范围
for i in {1..10}; do
echo "$i"
done
# C 风格
for ((i=0; i<10; i++)); do
echo "$i"
done
# 遍历文件
for file in *.txt; do
echo "Processing: $file"
done
# 遍历命令输出
for user in $(cat /etc/passwd | cut -d: -f1); do
echo "User: $user"
done
# while 循环
while read -r line; do
echo "Line: $line"
done < file.txt
# 读取管道
cat file.txt | while IFS= read -r line; do
echo "$line"
done
# until 循环
until ping -c 1 server &>/dev/null; do
echo "等待 server 上线..."
sleep 5
done
# 循环控制
for i in {1..100}; do
[ "$i" -eq 50 ] && continue # 跳过本次
[ "$i" -eq 90 ] && break # 退出循环
echo "$i"
done
11.2 函数
函数定义与调用
# 函数定义方式 1
greet() {
local name="$1" # local 变量
echo "Hello, $name!"
}
# 函数定义方式 2
function greet {
echo "Hello, $1!"
}
# 调用函数
greet "Alice"
# 带返回值的函数
is_valid_email() {
local email="$1"
if [[ "$email" =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]; then
return 0 # 成功
else
return 1 # 失败
fi
}
if is_valid_email "user@example.com"; then
echo "有效的邮箱"
fi
# 返回字符串(通过 stdout)
get_hostname() {
hostname -f
}
my_host=$(get_hostname)
# 返回多个值(通过 stdout 和全局变量)
get_user_info() {
local username="$1"
USER_NAME=$(getent passwd "$username" | cut -d: -f5)
USER_HOME=$(getent passwd "$username" | cut -d: -f6)
USER_SHELL=$(getent passwd "$username" | cut -d: -f7)
}
get_user_info "root"
echo "Name: $USER_NAME, Home: $USER_HOME, Shell: $USER_SHELL"
11.3 错误处理
严格模式与陷阱
#!/bin/bash
set -euo pipefail
# trap 命令:捕获信号和退出
TMP_DIR=""
cleanup() {
if [ -n "$TMP_DIR" ] && [ -d "$TMP_DIR" ]; then
rm -rf "$TMP_DIR"
echo "清理临时目录: $TMP_DIR"
fi
}
# EXIT 陷阱:脚本退出时执行
trap cleanup EXIT
# ERR 陷阱:命令出错时执行
trap 'echo "错误发生在第 $LINENO 行,命令: $BASH_COMMAND" >&2' ERR
# 信号陷阱
trap 'echo "收到 SIGINT,正在退出..."; exit 130' INT
trap 'echo "收到 SIGTERM,正在退出..."; exit 143' TERM
# 创建临时目录
TMP_DIR=$(mktemp -d)
echo "临时目录: $TMP_DIR"
# 主逻辑
main() {
# 如果这里出错,cleanup 会自动执行
cp /some/file "$TMP_DIR/"
process_files "$TMP_DIR"
}
main
常见错误模式
# 1. 检查命令是否存在
command_exists() {
command -v "$1" &>/dev/null
}
if ! command_exists docker; then
echo "错误: docker 未安装" >&2
exit 1
fi
# 2. 检查文件是否存在
require_file() {
if [ ! -f "$1" ]; then
echo "错误: 文件不存在: $1" >&2
exit 1
fi
}
# 3. 重试机制
retry() {
local max_attempts=$1
shift
local attempt=1
local delay=5
until "$@"; do
if [ "$attempt" -ge "$max_attempts" ]; then
echo "错误: 命令失败 $max_attempts 次: $*" >&2
return 1
fi
echo "尝试 $attempt/$max_attempts 失败,${delay}秒后重试..."
sleep "$delay"
((attempt++))
done
}
retry 3 curl -s "https://api.example.com/data"
11.4 系统管理脚本
服务管理脚本
#!/bin/bash
# 简单的服务管理脚本
readonly SERVICE_NAME="myapp"
readonly PID_FILE="/var/run/${SERVICE_NAME}.pid"
readonly LOG_FILE="/var/log/${SERVICE_NAME}.log"
readonly APP_BIN="/opt/${SERVICE_NAME}/bin/${SERVICE_NAME}"
start() {
if [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
echo "${SERVICE_NAME} 已经在运行 (PID: $(cat "$PID_FILE"))"
return 1
fi
echo "启动 ${SERVICE_NAME}..."
nohup "$APP_BIN" >> "$LOG_FILE" 2>&1 &
echo $! > "$PID_FILE"
echo "${SERVICE_NAME} 已启动 (PID: $!)"
}
stop() {
if [ ! -f "$PID_FILE" ]; then
echo "${SERVICE_NAME} 未运行"
return 0
fi
local pid
pid=$(cat "$PID_FILE")
echo "停止 ${SERVICE_NAME} (PID: $pid)..."
kill "$pid" 2>/dev/null
# 等待进程退出
local timeout=30
while kill -0 "$pid" 2>/dev/null && [ "$timeout" -gt 0 ]; do
sleep 1
((timeout--))
done
if kill -0 "$pid" 2>/dev/null; then
echo "进程未退出,强制终止..."
kill -9 "$pid"
fi
rm -f "$PID_FILE"
echo "${SERVICE_NAME} 已停止"
}
status() {
if [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
echo "${SERVICE_NAME} 正在运行 (PID: $(cat "$PID_FILE"))"
else
echo "${SERVICE_NAME} 未运行"
fi
}
case "${1:-}" in
start) start ;;
stop) stop ;;
restart) stop; start ;;
status) status ;;
*) echo "用法: $0 {start|stop|restart|status}" ;;
esac
日志轮转脚本
#!/bin/bash
# 简单的日志轮转
readonly LOG_DIR="/var/log/myapp"
readonly MAX_SIZE=$((100 * 1024 * 1024)) # 100MB
readonly KEEP_COUNT=5
rotate_log() {
local logfile="$1"
if [ ! -f "$logfile" ]; then
return
fi
local size
size=$(stat -f%z "$logfile" 2>/dev/null || stat -c%s "$logfile" 2>/dev/null)
if [ "$size" -gt "$MAX_SIZE" ]; then
echo "轮转日志: $logfile ($size bytes)"
# 移动旧的轮转文件
for i in $(seq $((KEEP_COUNT - 1)) -1 1); do
[ -f "${logfile}.${i}.gz" ] && mv "${logfile}.${i}.gz" "${logfile}.$((i + 1)).gz"
done
# 压缩当前日志
cp "$logfile" "${logfile}.1"
gzip -f "${logfile}.1"
: > "$logfile" # 清空原文件
fi
}
# 轮转所有日志
find "$LOG_DIR" -name "*.log" -type f | while read -r logfile; do
rotate_log "$logfile"
done
# 清理过旧的日志
find "$LOG_DIR" -name "*.gz" -mtime +30 -delete
磁盘空间监控
#!/bin/bash
# 磁盘空间监控与告警
readonly THRESHOLD=90 # 告警阈值百分比
readonly ALERT_EMAIL="admin@example.com"
check_disk_space() {
local filesystem usage mount_point
df -h | tail -n +2 | while read -r filesystem _ _ _ usage mount_point; do
usage=${usage%\%} # 移除百分号
if [ "$usage" -ge "$THRESHOLD" ]; then
local message="磁盘空间告警: ${mount_point} 使用率 ${usage}%"
echo "$message"
echo "$message" | mail -s "磁盘空间告警" "$ALERT_EMAIL"
# 自动清理临时文件
if [ "$mount_point" = "/" ]; then
find /tmp -type f -mtime +7 -delete
journalctl --vacuum-time=7d
apt-get clean 2>/dev/null || yum clean all 2>/dev/null
fi
fi
done
}
check_disk_space
11.5 定时任务(Cron)
Crontab 语法
# 编辑 crontab
crontab -e
# 格式: 分 时 日 月 星期 命令
# ┌───── 分钟 (0-59)
# │ ┌───── 小时 (0-23)
# │ │ ┌───── 日 (1-31)
# │ │ │ ┌───── 月 (1-12)
# │ │ │ │ ┌───── 星期 (0-7, 0和7都是周日)
# │ │ │ │ │
# * * * * * command
# 常用示例
0 2 * * * /opt/scripts/backup.sh # 每天凌晨 2 点
*/5 * * * * /opt/scripts/check.sh # 每 5 分钟
0 0 * * 0 /opt/scripts/weekly-report.sh # 每周日午夜
0 9 1 * * /opt/scripts/monthly-cleanup.sh # 每月 1 号上午 9 点
30 18 * * 1-5 /opt/scripts/weekday.sh # 工作日下午 6:30
# 特殊字符串
@reboot command # 系统启动时
@yearly command # 每年 (0 0 1 1 *)
@monthly command # 每月 (0 0 1 * *)
@weekly command # 每周 (0 0 * * 0)
@daily command # 每天 (0 0 * * *)
@hourly command # 每小时 (0 * * * *)
# 查看 crontab
crontab -l
# 删除 crontab
crontab -r
# 其他用户的 crontab
sudo crontab -u alice -e
最佳实践
# 1. 使用绝对路径
0 2 * * * /usr/bin/python3 /opt/scripts/backup.py
# 2. 设置环境变量
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
SHELL=/bin/bash
MAILTO=admin@example.com
0 2 * * * /opt/scripts/backup.sh
# 3. 记录日志
0 2 * * * /opt/scripts/backup.sh >> /var/log/backup.log 2>&1
# 4. 防止并发(使用 flock)
0 2 * * * flock -n /tmp/backup.lock /opt/scripts/backup.sh
# 5. 使用 systemd timer(更现代的替代方案)
# /etc/systemd/system/backup.timer
# [Unit]
# Description=Daily Backup
#
# [Timer]
# OnCalendar=*-*-* 02:00:00
# Persistent=true
#
# [Install]
# WantedBy=timers.target
11.6 DevOps 实践
CI/CD 管道脚本
#!/bin/bash
# 部署脚本示例
set -euo pipefail
readonly APP_NAME="myapp"
readonly DEPLOY_DIR="/opt/${APP_NAME}"
readonly BACKUP_DIR="/opt/backups/${APP_NAME}"
readonly REPO_URL="git@github.com:user/${APP_NAME}.git"
readonly TIMESTAMP=$(date +%Y%m%d_%H%M%S)
log() { echo "[$(date '+%H:%M:%S')] $*"; }
# 1. 备份当前版本
backup() {
log "备份当前版本..."
mkdir -p "$BACKUP_DIR"
if [ -d "$DEPLOY_DIR" ]; then
tar czf "${BACKUP_DIR}/${APP_NAME}_${TIMESTAMP}.tar.gz" -C "$DEPLOY_DIR" .
# 只保留最近 5 个备份
ls -t "${BACKUP_DIR}"/*.tar.gz | tail -n +6 | xargs rm -f
fi
}
# 2. 拉取代码
pull_code() {
log "拉取最新代码..."
local tmp_dir
tmp_dir=$(mktemp -d)
git clone --depth 1 "$REPO_URL" "$tmp_dir"
echo "$tmp_dir"
}
# 3. 构建
build() {
local source_dir="$1"
log "构建应用..."
cd "$source_dir"
make build
# 或 npm build, go build, cargo build 等
}
# 4. 测试
test() {
local source_dir="$1"
log "运行测试..."
cd "$source_dir"
make test
}
# 5. 部署
deploy() {
local source_dir="$1"
log "部署应用..."
mkdir -p "$DEPLOY_DIR"
cp -r "${source_dir}/build/." "$DEPLOY_DIR/"
chown -R www-data:www-data "$DEPLOY_DIR"
}
# 6. 健康检查
health_check() {
log "执行健康检查..."
local retries=10
local url="http://localhost:8080/health"
for ((i=1; i<=retries; i++)); do
if curl -sf "$url" >/dev/null; then
log "健康检查通过 ✓"
return 0
fi
log "等待服务启动... ($i/$retries)"
sleep 3
done
log "健康检查失败 ✗"
return 1
}
# 7. 回滚
rollback() {
log "回滚到上一个版本..."
local latest_backup
latest_backup=$(ls -t "${BACKUP_DIR}"/*.tar.gz 2>/dev/null | head -1)
if [ -n "$latest_backup" ]; then
rm -rf "${DEPLOY_DIR:?}"/*
tar xzf "$latest_backup" -C "$DEPLOY_DIR"
log "回滚完成"
else
log "没有可用的备份"
return 1
fi
}
# 主流程
main() {
local source_dir
backup
source_dir=$(pull_code)
trap "rm -rf '$source_dir'" EXIT
test "$source_dir"
build "$source_dir"
deploy "$source_dir"
if ! health_check; then
log "部署失败,开始回滚..."
rollback
exit 1
fi
log "部署成功 ✓"
}
main "$@"
日志分析与监控
#!/bin/bash
# 实时监控脚本
readonly ALERT_THRESHOLD=100 # 每分钟错误数阈值
readonly CHECK_INTERVAL=60 # 检查间隔(秒)
monitor_errors() {
local logfile="$1"
local start_line=0
while true; do
local total_lines
total_lines=$(wc -l < "$logfile")
if [ "$total_lines" -gt "$start_line" ]; then
local error_count
error_count=$(tail -n +"$((start_line + 1))" "$logfile" | grep -c "ERROR" || true)
if [ "$error_count" -gt "$ALERT_THRESHOLD" ]; then
local sample
sample=$(tail -n +"$((start_line + 1))" "$logfile" | grep "ERROR" | head -5)
echo "告警: 最近 ${CHECK_INTERVAL} 秒内有 ${error_count} 个错误"
echo "示例:"
echo "$sample"
# 发送告警
# echo "$sample" | mail -s "错误频率告警" admin@example.com
fi
start_line=$total_lines
fi
sleep "$CHECK_INTERVAL"
done
}
monitor_errors "/var/log/app/error.log"
容器管理脚本
#!/bin/bash
# Docker 容器管理
set -euo pipefail
readonly CONTAINER_NAME="myapp"
readonly IMAGE_NAME="myregistry/myapp:latest"
readonly HEALTH_URL="http://localhost:8080/health"
deploy_container() {
echo "拉取最新镜像..."
docker pull "$IMAGE_NAME"
echo "停止旧容器..."
docker stop "$CONTAINER_NAME" 2>/dev/null || true
docker rm "$CONTAINER_NAME" 2>/dev/null || true
echo "启动新容器..."
docker run -d \
--name "$CONTAINER_NAME" \
--restart unless-stopped \
-p 8080:8080 \
-v /data/app:/app/data \
-e "DB_HOST=db.example.com" \
-e "DB_PORT=5432" \
--memory="512m" \
--cpus="1.0" \
"$IMAGE_NAME"
echo "等待容器启动..."
sleep 10
if curl -sf "$HEALTH_URL" >/dev/null; then
echo "容器部署成功 ✓"
else
echo "容器健康检查失败 ✗"
docker logs --tail 50 "$CONTAINER_NAME"
exit 1
fi
}
cleanup_images() {
echo "清理未使用的镜像..."
docker image prune -f
echo "清理未使用的卷..."
docker volume prune -f
}
case "${1:-deploy}" in
deploy) deploy_container ;;
cleanup) cleanup_images ;;
logs) docker logs -f "$CONTAINER_NAME" ;;
status) docker ps -f "name=$CONTAINER_NAME" ;;
*) echo "用法: $0 {deploy|cleanup|logs|status}" ;;
esac
11.7 脚本调试技巧
调试方法
# 1. 使用 set -x 打印执行的命令
set -x
command1
command2
set +x
# 2. 在 shebang 中启用调试
#!/bin/bash -x
# 3. 部分调试
debug_section() {
set -x
# 需要调试的代码
set +x
}
# 4. 使用 shellcheck 静态分析
shellcheck myscript.sh
# 5. 使用 bashdb 调试器
# apt install bashdb
bashdb myscript.sh
# 6. 打印变量值
echo "DEBUG: var=$var" >&2
# 7. 使用 PS4 自定义调试前缀
export PS4='+${BASH_SOURCE}:${LINENO}:${FUNCNAME[0]:+${FUNCNAME[0]}():} '
set -x
常见陷阱
# 陷阱 1: 变量未加引号
# ❌ 错误
files="file1.txt file2.txt"
rm $files # 如果文件名有空格会出错
# ✅ 正确
rm "$files" # 不对,这会把整个字符串当一个文件名
# 应该使用数组
files=("file1.txt" "file2.txt")
rm "${files[@]}"
# 陷阱 2: 命令替换中的换行
# ❌ 可能出错
files=$(ls *.txt)
for f in $files; do # 如果文件名有空格会出错
# ✅ 正确
while IFS= read -r f; do
echo "$f"
done < <(ls *.txt)
# 陷阱 3: [ ] 中的变量未加引号
# ❌ 错误
if [ $var = "yes" ]; then # 如果 var 为空,语法错误
# ✅ 正确
if [ "$var" = "yes" ]; then
# 陷阱 4: 管道中的子 Shell
# ❌ 变量不传递
count=0
echo "1 2 3" | while read -r n; do ((count++)); done
echo "$count" # 仍然是 0
# ✅ 使用进程替换
count=0
while read -r n; do ((count++)); done < <(echo "1 2 3")
echo "$count" # 3
注意事项
- ShellCheck 是你的朋友:在提交脚本之前,始终运行
shellcheck检查。 set -euo pipefail是标准开头:这能捕获大多数常见错误。- 用
$()替代反引号:$(command)比`command`更易读,且可以嵌套。 - 避免使用
eval:eval存在安全风险,几乎总有更好的替代方案。 - 脚本过长时考虑用 Python:当脚本超过 200 行或需要复杂数据结构时,Python 可能是更好的选择。
- 不要用 Shell 做数学计算:Shell 不擅长数学运算,复杂的计算应使用
bc、awk或 Python。