前言
写Shell脚本容易,写好Shell脚本难。随手写的脚本能跑,但换个环境就出问题;脚本越写越长,自己都看不懂;没有错误处理,跑到一半失败了也不知道。
本文整理Shell脚本编程的最佳实践,从代码规范到错误处理,让脚本更健壮、更易维护。
1. 脚本基础规范
1.1 标准模板
#!/bin/bash
#
# 脚本名称: deploy.sh
# 功能描述: 自动化部署脚本
# 作者: your_name
# 创建时间: 2025-01-08
# 使用方式: ./deploy.sh [env] [version]
#
set -euo pipefail
# 全局变量
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly SCRIPT_NAME="$(basename "$0")"
readonly LOG_FILE="/var/log/${SCRIPT_NAME%.sh}.log"
# 默认值
ENV="${1:-prod}"
VERSION="${2:-latest}"
# 主函数
main() {
log "开始执行..."
# 主逻辑
log "执行完成"
}
# 日志函数
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"
}
# 执行
main "$@"
1.2 set命令详解
set -e # 遇到错误立即退出
set -u # 使用未定义变量报错
set -o pipefail # 管道中任一命令失败则整体失败
set -x # 调试模式,打印每条命令
# 组合使用
set -euo pipefail
为什么需要这些设置:
# 没有 set -e 的问题
rm -rf /important/data # 假设这里写错了路径
echo "删除成功" # 即使上面失败,这行还会执行
# 没有 set -u 的问题
echo $UNDEFIND_VAR # 不报错,只是空值
rm -rf $UNDEFIND_VAR/* # 危险!可能变成 rm -rf /*
# 没有 set -o pipefail 的问题
cat /nonexistent | grep "test" | wc -l
echo $? # 返回0,因为wc成功了,但cat其实失败了
1.3 变量使用规范
# 使用大括号包裹变量
echo "${name}" # 推荐
echo "$name" # 可以,但不够清晰
# 给变量设置默认值
name="${1:-default}" # 如果$1为空,使用default
name="${1:=default}" # 如果$1为空,赋值并使用default
name="${1:?错误信息}" # 如果$1为空,报错退出
# 字符串操作
file="/path/to/file.txt"
echo "${file%.txt}" # /path/to/file 去掉后缀
echo "${file##*/}" # file.txt 只取文件名
echo "${file%/*}" # /path/to 只取目录
# 只读变量
readonly CONFIG_FILE="/etc/app.conf"
# 局部变量(在函数中)
local temp_var="value"
2. 函数编写
2.1 函数定义规范
# 推荐写法
function_name() {
local arg1="$1"
local arg2="${2:-default}"
# 函数逻辑
return 0
}
# 带返回值的函数
get_memory_usage() {
local usage
usage=$(free | awk '/Mem:/ {printf "%.1f", ($2-$7)/$2*100}')
echo "$usage"
}
# 调用
mem=$(get_memory_usage)
echo "内存使用率: ${mem}%"
2.2 参数处理
#!/bin/bash
# 处理命令行参数
usage() {
cat <<EOF
Usage: $0 [OPTIONS]
Options:
-e, --env ENV 环境 (dev|test|prod)
-v, --version VER 版本号
-f, --force 强制执行
-h, --help 显示帮助
EOF
exit 1
}
# 默认值
ENV=""
VERSION=""
FORCE=false
# 解析参数
while [[ $# -gt 0 ]]; do
case "$1" in
-e|--env)
ENV="$2"
shift 2
;;
-v|--version)
VERSION="$2"
shift 2
;;
-f|--force)
FORCE=true
shift
;;
-h|--help)
usage
;;
*)
echo "未知参数: $1"
usage
;;
esac
done
# 参数检查
if [[ -z "$ENV" ]]; then
echo "错误: 必须指定环境"
usage
fi
2.3 返回值与退出码
# 使用返回值
check_service() {
if systemctl is-active --quiet "$1"; then
return 0 # 成功
else
return 1 # 失败
fi
}
if check_service nginx; then
echo "nginx运行中"
else
echo "nginx未运行"
fi
# 自定义退出码
readonly EXIT_SUCCESS=0
readonly EXIT_INVALID_ARGS=1
readonly EXIT_FILE_NOT_FOUND=2
readonly EXIT_PERMISSION_DENIED=3
[[ -f "$config_file" ]] || exit $EXIT_FILE_NOT_FOUND
3. 错误处理
3.1 trap捕获信号
#!/bin/bash
set -euo pipefail
# 临时文件
TEMP_FILE=$(mktemp)
# 清理函数
cleanup() {
local exit_code=$?
rm -f "$TEMP_FILE"
echo "清理完成,退出码: $exit_code"
exit $exit_code
}
# 捕获退出信号
trap cleanup EXIT
trap 'echo "收到中断信号"; exit 130' INT TERM
# 主逻辑
echo "正在处理..."
# ... 脚本逻辑 ...
3.2 错误处理函数
#!/bin/bash
# 错误处理
error_handler() {
local line_no=$1
local error_code=$2
echo "错误发生在第 $line_no 行,退出码: $error_code"
# 可以在这里发送告警
}
trap 'error_handler ${LINENO} $?' ERR
# 日志函数
log_error() {
echo "[ERROR] $(date '+%Y-%m-%d %H:%M:%S') $*" >&2
}
log_info() {
echo "[INFO] $(date '+%Y-%m-%d %H:%M:%S') $*"
}
log_warn() {
echo "[WARN] $(date '+%Y-%m-%d %H:%M:%S') $*"
}
3.3 重试机制
# 带重试的函数
retry() {
local max_attempts=$1
local delay=$2
shift 2
local cmd="$@"
local attempt=1
while [[ $attempt -le $max_attempts ]]; do
echo "尝试第 $attempt 次: $cmd"
if eval "$cmd"; then
return 0
fi
if [[ $attempt -lt $max_attempts ]]; then
echo "失败,${delay}秒后重试..."
sleep "$delay"
fi
((attempt++))
done
echo "达到最大重试次数,失败"
return 1
}
# 使用
retry 3 5 curl -f http://example.com/health
4. 常用技巧
4.1 安全的文件操作
# 安全删除(先检查)
safe_rm() {
local target="$1"
# 防止误删根目录
if [[ "$target" == "/" ]] || [[ -z "$target" ]]; then
echo "危险操作,拒绝执行"
return 1
fi
# 检查是否存在
if [[ ! -e "$target" ]]; then
echo "目标不存在: $target"
return 1
fi
rm -rf "$target"
}
# 安全的目录切换
cd_safe() {
cd "$1" || {
echo "无法进入目录: $1"
exit 1
}
}
# 创建目录(如果不存在)
mkdir -p "$target_dir"
4.2 并行执行
#!/bin/bash
# 并行处理
# 方法1:后台进程
for server in server1 server2 server3; do
ssh "$server" 'uptime' &
done
wait # 等待所有后台进程完成
# 方法2:xargs并行
echo "server1 server2 server3" | tr ' ' '\n' | \
xargs -P 3 -I {} ssh {} 'uptime'
# 方法3:GNU parallel(需安装)
parallel -j 4 ssh {} uptime ::: server1 server2 server3 server4
4.3 锁机制防止重复执行
#!/bin/bash
# 使用flock防止脚本重复执行
LOCK_FILE="/tmp/$(basename "$0").lock"
exec 200>"$LOCK_FILE"
if ! flock -n 200; then
echo "脚本已在运行中"
exit 1
fi
# 主逻辑
echo "开始执行..."
sleep 60
echo "执行完成"
4.4 配置文件读取
#!/bin/bash
# 读取配置文件
CONFIG_FILE="${1:-/etc/app.conf}"
# 检查文件存在
[[ -f "$CONFIG_FILE" ]] || {
echo "配置文件不存在: $CONFIG_FILE"
exit 1
}
# 方法1:source(注意安全风险)
# source "$CONFIG_FILE"
# 方法2:逐行解析(更安全)
while IFS='=' read -r key value; do
# 跳过注释和空行
[[ "$key" =~ ^#.*$ ]] && continue
[[ -z "$key" ]] && continue
# 去掉空格
key=$(echo "$key" | xargs)
value=$(echo "$value" | xargs)
# 赋值
declare "$key=$value"
done < "$CONFIG_FILE"
echo "DB_HOST: $DB_HOST"
echo "DB_PORT: $DB_PORT"
4.5 颜色输出
#!/bin/bash
# 颜色定义
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
echo_red() { echo -e "${RED}$*${NC}"; }
echo_green() { echo -e "${GREEN}$*${NC}"; }
echo_yellow() { echo -e "${YELLOW}$*${NC}"; }
echo_blue() { echo -e "${BLUE}$*${NC}"; }
# 使用
echo_green "[OK] 服务正常"
echo_red "[FAIL] 服务异常"
echo_yellow "[WARN] 磁盘空间不足"
5. 调试技巧
5.1 调试模式
#!/bin/bash
# 调试开关
DEBUG=${DEBUG:-false}
debug() {
if [[ "$DEBUG" = true ]]; then
echo "[DEBUG] $*" >&2
fi
}
# 使用
debug "变量值: name=$name"
# 执行时开启调试
# DEBUG=true ./script.sh
5.2 详细执行跟踪
#!/bin/bash
# 在需要调试的代码段前后开关
set -x # 开启
# ... 需要调试的代码 ...
set +x # 关闭
# 或者指定调试输出位置
exec 5>/tmp/debug.log
BASH_XTRACEFD=5
set -x
5.3 shellcheck静态检查
# 安装
# yum install shellcheck 或 apt install shellcheck
# 检查脚本
shellcheck script.sh
# 常见问题示例
# SC2086: Double quote to prevent globbing and word splitting
# SC2046: Quote this to prevent word splitting
# SC2034: Variable appears unused
6. 实战示例
6.1 服务部署脚本
#!/bin/bash
#
# deploy.sh - 服务部署脚本
#
set -euo pipefail
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly APP_NAME="myapp"
readonly DEPLOY_DIR="/opt/${APP_NAME}"
readonly BACKUP_DIR="/opt/backup/${APP_NAME}"
# 颜色
RED='\033[0;31m'
GREEN='\033[0;32m'
NC='\033[0m'
log_info() { echo -e "${GREEN}[INFO]${NC} $*"; }
log_error() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
usage() {
cat <<EOF
Usage: $0 <version>
Example:
$0 v1.2.3
EOF
exit 1
}
# 参数检查
VERSION="${1:-}"
[[ -z "$VERSION" ]] && usage
# 检查环境
check_env() {
log_info "检查环境..."
# 检查权限
[[ $EUID -eq 0 ]] || {
log_error "需要root权限"
exit 1
}
# 检查目录
mkdir -p "$DEPLOY_DIR" "$BACKUP_DIR"
}
# 备份
backup() {
log_info "备份当前版本..."
if [[ -d "$DEPLOY_DIR/current" ]]; then
local backup_name="${APP_NAME}_$(date +%Y%m%d_%H%M%S)"
cp -r "$DEPLOY_DIR/current" "$BACKUP_DIR/$backup_name"
log_info "备份到: $BACKUP_DIR/$backup_name"
fi
}
# 下载
download() {
log_info "下载版本: $VERSION"
local download_url="https://releases.example.com/${APP_NAME}/${VERSION}.tar.gz"
local temp_file=$(mktemp)
curl -fsSL "$download_url" -o "$temp_file" || {
log_error "下载失败"
rm -f "$temp_file"
exit 1
}
# 解压
mkdir -p "$DEPLOY_DIR/$VERSION"
tar -xzf "$temp_file" -C "$DEPLOY_DIR/$VERSION"
rm -f "$temp_file"
}
# 切换版本
switch_version() {
log_info "切换到新版本..."
# 更新软链接
ln -sfn "$DEPLOY_DIR/$VERSION" "$DEPLOY_DIR/current"
}
# 重启服务
restart_service() {
log_info "重启服务..."
systemctl restart "$APP_NAME" || {
log_error "重启失败,尝试回滚"
rollback
exit 1
}
# 健康检查
sleep 5
if ! curl -sf http://localhost:8080/health > /dev/null; then
log_error "健康检查失败,尝试回滚"
rollback
exit 1
fi
}
# 回滚
rollback() {
log_info "回滚到上一版本..."
local latest_backup=$(ls -t "$BACKUP_DIR" | head -1)
if [[ -n "$latest_backup" ]]; then
ln -sfn "$BACKUP_DIR/$latest_backup" "$DEPLOY_DIR/current"
systemctl restart "$APP_NAME"
fi
}
# 清理旧版本
cleanup() {
log_info "清理旧版本..."
# 保留最近5个备份
cd "$BACKUP_DIR" && ls -t | tail -n +6 | xargs -r rm -rf
}
# 主函数
main() {
check_env
backup
download
switch_version
restart_service
cleanup
log_info "部署完成: $VERSION"
}
main
6.2 批量服务器执行脚本
#!/bin/bash
#
# batch_exec.sh - 批量服务器执行命令
#
set -euo pipefail
# 服务器列表(可以从文件读取)
SERVERS=(
"10.10.0.1"
"10.10.0.2"
"10.10.0.3"
)
# SSH选项
SSH_OPTS="-o ConnectTimeout=5 -o StrictHostKeyChecking=no"
usage() {
cat <<EOF
Usage: $0 <command>
Example:
$0 "uptime"
$0 "df -h"
$0 "systemctl status nginx"
EOF
exit 1
}
COMMAND="${1:-}"
[[ -z "$COMMAND" ]] && usage
# 执行
for server in "${SERVERS[@]}"; do
echo "====== $server ======"
ssh $SSH_OPTS "$server" "$COMMAND" 2>&1 || echo "执行失败"
echo ""
done
如果服务器分布在不同网络,可以先用组网工具(WireGuard、ZeroTier、星空组网等)把机器串起来,脚本里直接用虚拟IP,不用关心实际网络环境。
7. 常见问题
7.1 空格和特殊字符
# 错误:变量没加引号
for file in $files; do # 如果文件名有空格会出问题
# 正确:加引号
for file in "$files"; do
# 处理文件名有空格的情况
while IFS= read -r -d '' file; do
echo "处理: $file"
done < <(find . -name "*.txt" -print0)
7.2 数组操作
# 定义数组
arr=("item1" "item2" "item3")
# 遍历
for item in "${arr[@]}"; do
echo "$item"
done
# 数组长度
echo "长度: ${#arr[@]}"
# 追加元素
arr+=("item4")
# 索引访问
echo "第一个: ${arr[0]}"
7.3 字符串比较
# 字符串比较用 [[ ]]
if [[ "$str1" == "$str2" ]]; then
echo "相等"
fi
# 正则匹配
if [[ "$str" =~ ^[0-9]+$ ]]; then
echo "是数字"
fi
# 数值比较用 (( )) 或 -eq
if (( num1 > num2 )); then
echo "num1更大"
fi
if [[ $num1 -gt $num2 ]]; then
echo "num1更大"
fi
总结
| 类别 | 最佳实践 |
|---|---|
| 基础 | set -euo pipefail,变量加引号 |
| 变量 | 使用 ${} 包裹,设置默认值 |
| 函数 | 使用 local 声明局部变量 |
| 错误处理 | trap 捕获信号,清理临时文件 |
| 日志 | 带时间戳,区分级别 |
| 调试 | 使用 shellcheck,set -x |
| 安全 | 防止误删,检查参数合法性 |
写Shell脚本的原则:
- 健壮性:考虑各种异常情况
- 可读性:清晰的命名和注释
- 可维护性:模块化,避免重复
- 安全性:防止误操作,谨慎使用
rm
更多运维技术文章,欢迎关注公众号:北平的秋葵