前言
写Shell脚本容易,写好Shell脚本难。随手写的脚本能跑,但换个环境就出问题;脚本越写越长,自己都看不懂;没有错误处理,跑到一半失败了也不知道。
本文整理Shell脚本编程的最佳实践,从代码规范到错误处理,让脚本更健壮、更易维护。
1. 脚本基础规范
1.1 标准模板
#!/bin/bash## 脚本名称: deploy.sh# 功能描述: 自动化部署脚本# 作者: your_name# 创建时间: 2025-01-08# 使用方式: ./deploy.sh [env] [version]#set-euo pipefail# 全局变量readonlySCRIPT_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-lecho$?# 返回0,因为wc成功了,但cat其实失败了1.3 变量使用规范
# 使用大括号包裹变量echo"${name}"# 推荐echo"$name"# 可以,但不够清晰# 给变量设置默认值name="${1:-default}"# 如果$1为空,使用defaultname="${1:=default}"# 如果$1为空,赋值并使用defaultname="${1:?错误信息}"# 如果$1为空,报错退出# 字符串操作file="/path/to/file.txt"echo"${file%.txt}"# /path/to/file 去掉后缀echo"${file##*/}"# file.txt 只取文件名echo"${file%/*}"# /path/to 只取目录# 只读变量readonlyCONFIG_FILE="/etc/app.conf"# 局部变量(在函数中)localtemp_var="value"2. 函数编写
2.1 函数定义规范
# 推荐写法function_name(){localarg1="$1"localarg2="${2:-default}"# 函数逻辑return0}# 带返回值的函数get_memory_usage(){localusageusage=$(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 显示帮助 EOFexit1}# 默认值ENV=""VERSION=""FORCE=false# 解析参数while[[$#-gt0]];docase"$1"in-e|--env)ENV="$2"shift2;;-v|--version)VERSION="$2"shift2;;-f|--force)FORCE=trueshift;;-h|--help)usage;;*)echo"未知参数:$1"usage;;esacdone# 参数检查if[[-z"$ENV"]];thenecho"错误: 必须指定环境"usagefi2.3 返回值与退出码
# 使用返回值check_service(){ifsystemctl is-active --quiet"$1";thenreturn0# 成功elsereturn1# 失败fi}ifcheck_service nginx;thenecho"nginx运行中"elseecho"nginx未运行"fi# 自定义退出码readonlyEXIT_SUCCESS=0readonlyEXIT_INVALID_ARGS=1readonlyEXIT_FILE_NOT_FOUND=2readonlyEXIT_PERMISSION_DENIED=3[[-f"$config_file"]]||exit$EXIT_FILE_NOT_FOUND3. 错误处理
3.1 trap捕获信号
#!/bin/bashset-euo pipefail# 临时文件TEMP_FILE=$(mktemp)# 清理函数cleanup(){localexit_code=$?rm-f"$TEMP_FILE"echo"清理完成,退出码:$exit_code"exit$exit_code}# 捕获退出信号trapcleanup EXITtrap'echo "收到中断信号"; exit 130'INTTERM# 主逻辑echo"正在处理..."# ... 脚本逻辑 ...3.2 错误处理函数
#!/bin/bash# 错误处理error_handler(){localline_no=$1localerror_code=$2echo"错误发生在第$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(){localmax_attempts=$1localdelay=$2shift2localcmd="$@"localattempt=1while[[$attempt-le$max_attempts]];doecho"尝试第$attempt次:$cmd"ifeval"$cmd";thenreturn0fiif[[$attempt-lt$max_attempts]];thenecho"失败,${delay}秒后重试..."sleep"$delay"fi((attempt++))doneecho"达到最大重试次数,失败"return1}# 使用retry35curl-f http://example.com/health4. 常用技巧
4.1 安全的文件操作
# 安全删除(先检查)safe_rm(){localtarget="$1"# 防止误删根目录if[["$target"=="/"]]||[[-z"$target"]];thenecho"危险操作,拒绝执行"return1fi# 检查是否存在if[[!-e"$target"]];thenecho"目标不存在:$target"return1firm-rf"$target"}# 安全的目录切换cd_safe(){cd"$1"||{echo"无法进入目录:$1"exit1}}# 创建目录(如果不存在)mkdir-p"$target_dir"4.2 并行执行
#!/bin/bash# 并行处理# 方法1:后台进程forserverinserver1 server2 server3;dossh"$server"'uptime'&donewait# 等待所有后台进程完成# 方法2:xargs并行echo"server1 server2 server3"|tr' ''\n'|\xargs-P3-I{}ssh{}'uptime'# 方法3:GNU parallel(需安装)parallel -j4ssh{}uptime::: server1 server2 server3 server44.3 锁机制防止重复执行
#!/bin/bash# 使用flock防止脚本重复执行LOCK_FILE="/tmp/$(basename"$0").lock"exec200>"$LOCK_FILE"if!flock -n200;thenecho"脚本已在运行中"exit1fi# 主逻辑echo"开始执行..."sleep60echo"执行完成"4.4 配置文件读取
#!/bin/bash# 读取配置文件CONFIG_FILE="${1:-/etc/app.conf}"# 检查文件存在[[-f"$CONFIG_FILE"]]||{echo"配置文件不存在:$CONFIG_FILE"exit1}# 方法1:source(注意安全风险)# source "$CONFIG_FILE"# 方法2:逐行解析(更安全)whileIFS='='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 Colorecho_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]];thenecho"[DEBUG]$*">&2fi}# 使用debug"变量值: name=$name"# 执行时开启调试# DEBUG=true ./script.sh5.2 详细执行跟踪
#!/bin/bash# 在需要调试的代码段前后开关set-x# 开启# ... 需要调试的代码 ...set+x# 关闭# 或者指定调试输出位置exec5>/tmp/debug.logBASH_XTRACEFD=5set-x5.3 shellcheck静态检查
# 安装# yum install shellcheck 或 apt install shellcheck# 检查脚本shellcheckscript.sh# 常见问题示例# SC2086: Double quote to prevent globbing and word splitting# SC2046: Quote this to prevent word splitting# SC2034: Variable appears unused6. 实战示例
6.1 服务部署脚本
#!/bin/bash## deploy.sh - 服务部署脚本#set-euo pipefailreadonlySCRIPT_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:$0v1.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"}main6.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" EOFexit1}COMMAND="${1:-}"[[-z"$COMMAND"]]&&usage# 执行forserverin"${SERVERS[@]}";doecho"======$server======"ssh$SSH_OPTS"$server""$COMMAND"2>&1||echo"执行失败"echo""done如果服务器分布在不同网络,可以先用组网工具(WireGuard、ZeroTier、星空组网等)把机器串起来,脚本里直接用虚拟IP,不用关心实际网络环境。
7. 常见问题
7.1 空格和特殊字符
# 错误:变量没加引号forfilein$files;do# 如果文件名有空格会出问题# 正确:加引号forfilein"$files";do# 处理文件名有空格的情况whileIFS=read-r -d''file;doecho"处理:$file"done<<(find.-name"*.txt"-print0)7.2 数组操作
# 定义数组arr=("item1""item2""item3")# 遍历foritemin"${arr[@]}";doecho"$item"done# 数组长度echo"长度:${#arr[@]}"# 追加元素arr+=("item4")# 索引访问echo"第一个:${arr[0]}"7.3 字符串比较
# 字符串比较用 [[ ]]if[["$str1"=="$str2"]];thenecho"相等"fi# 正则匹配if[["$str"=~^[0-9]+$]];thenecho"是数字"fi# 数值比较用 (( )) 或 -eqif((num1>num2));thenecho"num1更大"fiif[[$num1-gt$num2]];thenecho"num1更大"fi总结
| 类别 | 最佳实践 |
|---|---|
| 基础 | set -euo pipefail,变量加引号 |
| 变量 | 使用${}包裹,设置默认值 |
| 函数 | 使用local声明局部变量 |
| 错误处理 | trap捕获信号,清理临时文件 |
| 日志 | 带时间戳,区分级别 |
| 调试 | 使用shellcheck,set -x |
| 安全 | 防止误删,检查参数合法性 |
写Shell脚本的原则:
- 健壮性:考虑各种异常情况
- 可读性:清晰的命名和注释
- 可维护性:模块化,避免重复
- 安全性:防止误操作,谨慎使用
rm
更多运维技术文章,欢迎关注公众号:北平的秋葵