在Ubuntu服务器运维中,Shell脚本是最基础也是最高频使用的自动化工具。但很多运维人员写出来的脚本要么跑不通,要么报错后找不到原因,要么在生产环境中因为一个小错误导致服务中断。核心问题就三个:没有规范的编写习惯、缺乏系统的错误处理机制、以及对Shell本身的特性理解不够深入。下面我会从脚本头部声明、变量命名、错误捕获、日志记录、退出码管理、函数封装等维度,逐一给出具体可执行的规范和代码示例。
一、脚本头部声明与基础设置
任何一个规范的Shell脚本,第一行必须是Shebang声明,指定解释器路径。在Ubuntu环境下,推荐使用/bin/bash而不是/bin/sh,因为sh在不同系统中可能指向dash,功能不完整。紧接着要加上set命令开启严格模式,这是很多人忽略但极其重要的一步。
#!/bin/bash set -euo pipefail # 脚本名称、版本、作者、用途说明 # 脚本名称: deploy_service.sh # 版本: v2.1.0 # 作者: ops@example.com # 用途: 自动化部署Web服务 # 创建时间: 2024-06-15
set -e表示任何命令返回非零退出码时立即终止脚本;set -u表示使用未定义变量时报错;set -o pipefail表示管道中任何一个命令失败,整个管道返回失败。这三个选项组合使用,能在脚本运行初期就把大部分低级错误拦截掉。
二、变量命名与类型管理规范
Shell是弱类型语言,变量不需要声明类型,但这恰恰是混乱的根源。规范做法是:所有变量名使用大写字母加下划线,局部变量使用小写,常量使用全大写。涉及路径的变量必须以斜杠结尾或明确标注,避免路径拼接出错。
# 常量定义
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly LOG_FILE="/var/log/deploy_service.log"
readonly MAX_RETRIES=3
# 普通变量
service_name="nginx"
deploy_path="/opt/${service_name}"
特别注意,BASH_SOURCE[0]是获取脚本自身路径的可靠方式,比$0更准确,尤其是在脚本被source调用或者通过符号链接执行时。路径变量一律用双引号包裹,防止路径中包含空格时被拆分成多个参数。
三、函数封装与模块化设计
超过50行的脚本必须拆分成函数。每个函数只做一件事,函数名用动词开头,清晰表达意图。函数之间通过返回值和参数传递数据,不要依赖全局变量。这不仅让代码可读,更重要的是方便单元测试和错误定位。
check_root() {
if [[ $EUID -ne 0 ]]; then
log_error "此脚本必须以root用户运行"
exit 1
fi
}
check_dependencies() {
local deps=("curl" "wget" "systemctl")
for dep in "${deps[@]}"; do
if ! command -v "$dep" &> /dev/null; then
log_error "缺少依赖: $dep"
exit 1
fi
done
}
install_package() {
local pkg_name=$1
log_info "正在安装: $pkg_name"
apt-get install -y "$pkg_name" &>&1
if [[ $? -ne 0 ]]; then
log_error "安装失败: $pkg_name"
return 1
fi
return 0
}
函数内部使用local声明局部变量,避免污染全局命名空间。返回值用return,不要用echo输出结果再用$()捕获,那样既慢又容易出错。
四、系统化的错误处理机制
这是整篇文章最核心的部分。Shell脚本的错误处理不是简单地在命令后面加|| exit,而是要建立一套完整的体系:错误捕获、错误分级、错误恢复、错误上报。
第一层:trap信号捕获。在脚本开头设置trap,捕获EXIT、ERR、INT、TERM等信号,确保脚本无论正常退出还是异常中断,都能执行清理操作。
cleanup() {
local exit_code=$?
log_info "脚本退出,退出码: $exit_code"
# 清理临时文件
[[ -d "$TMP_DIR" ]] && rm -rf "$TMP_DIR"
# 恢复原始状态(如有)
[[ -f "$BACKUP_FILE" ]] && mv "$BACKUP_FILE" "$ORIGINAL_FILE"
}
trap cleanup EXIT
trap 'log_error "脚本被中断"; exit 130' INT TERM
第二层:命令级错误判断。对于关键操作,不能只看退出码,还要验证操作结果。比如创建目录后要确认目录确实存在,文件下载后要校验文件大小或哈希值。
download_file() {
local url=$1
local dest=$2
curl -fsSL "$url" -o "$dest"
if [[ ! -s "$dest" ]]; then
log_error "下载失败或文件为空: $url"
return 1
fi
log_info "文件下载成功: $dest"
return 0
}
第三层:重试机制。网络操作、包安装等容易因瞬时故障失败的操作,必须加重试逻辑,但要设置最大重试次数和退避时间,避免死循环。
retry_command() {
local max_attempts=$1
local delay=$2
shift 2
local attempt=1
while [[ $attempt -le $max_attempts ]]; do
log_info "第 $attempt 次尝试执行: $*"
if "$@"; then
return 0
fi
attempt=$((attempt + 1))
[[ $attempt -le $max_attempts ]] && sleep "$delay"
done
log_error "已达最大重试次数($max_attempts),操作失败"
return 1
}
# 使用示例
retry_command 3 5 apt-get update
五、日志记录规范
没有日志的运维脚本等于在黑暗中操作。规范的做法是定义统一的日志函数,支持不同级别(INFO、WARN、ERROR、DEBUG),日志同时输出到控制台和文件,带时间戳和脚本名称。
LOG_LEVEL="${LOG_LEVEL:-INFO}"
log_msg() {
local level=$1
shift
local timestamp
timestamp=$(date '+%Y-%m-%d %H:%M:%S')
local msg="[$timestamp] [$level] [$(basename "$0")] $*"
echo "$msg" &>&2
echo "$msg" &>> "$LOG_FILE"
}
log_info() { [[ "$LOG_LEVEL" == "DEBUG" || "$LOG_LEVEL" == "INFO" ]] && log_msg "INFO" "$@"; }
log_warn() { [[ "$LOG_LEVEL" == "DEBUG" || "$LOG_LEVEL" == "INFO" || "$LOG_LEVEL" == "WARN" ]] && log_msg "WARN" "$@"; }
log_error() { log_msg "ERROR" "$@"; }
log_debug() { [[ "$LOG_LEVEL" == "DEBUG" ]] && log_msg "DEBUG" "$@"; }
日志级别通过环境变量LOG_LEVEL控制,默认INFO。生产环境用INFO,排查问题时临时改成DEBUG,不需要改代码。日志输出到stderr而不是stdout,这样脚本的正常输出和日志不会混在一起,方便管道处理。
六、退出码管理规范
Shell脚本的退出码是0表示成功,非0表示失败,但具体用什么数字是有讲究的。建议建立统一的退出码对照表,方便调用方判断失败原因。
# 退出码定义 # 0 - 成功 # 1 - 通用错误 # 2 - 参数错误 # 3 - 权限不足 # 4 - 依赖缺失 # 5 - 网络错误 # 6 - 磁盘空间不足 # 126 - 命令不可执行 # 127 - 命令未找到 # 130 - 被SIGINT中断 # 137 - 被SIGKILL杀死
在脚本中不要随意使用exit 1,要根据具体错误类型选择对应的退出码。同时在trap的cleanup函数中记录最终退出码,方便事后排查。
七、Ubuntu特有的注意事项
Ubuntu基于Debian,使用apt包管理器。在脚本中涉及包操作时,要注意几点:apt-get update必须在install之前执行;不要混用apt和apt-get,推荐统一用apt;操作前检查/etc/apt/sources.list是否可用;大版本升级时要处理交互式确认(用DEBIAN_FRONTEND=noninteractive)。
export DEBIAN_FRONTEND=noninteractive apt-get update -qq apt-get install -y --no-install-recommends "$package_name" apt-get clean
另外Ubuntu的systemd服务管理要用systemctl,不要直接操作/etc/init.d下的脚本。服务重启后要用systemctl is-active验证状态,而不是假设它一定成功。
systemctl restart "$service_name"
sleep 2
if ! systemctl is-active --quiet "$service_name"; then
log_error "服务 $service_name 启动失败"
journalctl -u "$service_name" --no-pager -n 20 &>&2
exit 5
fi
八、脚本安全与权限控制
运维脚本往往需要高权限,但不意味着脚本本身要用777权限。脚本文件设为750,属主root,属组设为专门的运维组。脚本中涉及密码、密钥的内容不要硬编码,使用环境变量或加密的配置文件。对用户输入要做过滤,防止命令注入。
# 危险操作示例 - 永远不要这样写
# rm -rf /$user_input
# 安全写法
if [[ "$user_input" =~ ^[a-zA-Z0-9_.-]+$ ]]; then
rm -rf "/opt/${user_input}"
else
log_error "非法输入: $user_input"
exit 2
fi
九、完整脚本模板示例
把以上所有规范整合起来,一个生产可用的Ubuntu运维脚本骨架如下:
#!/bin/bash
set -euo pipefail
readonly SCRIPT_NAME="$(basename "$0")"
readonly SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly LOG_FILE="/var/log/${SCRIPT_NAME%.sh}.log"
readonly MAX_RETRIES=3
LOG_LEVEL="${LOG_LEVEL:-INFO}"
log_msg() {
local level=$1; shift
local ts; ts=$(date '+%Y-%m-%d %H:%M:%S')
echo "[$ts] [$level] [$SCRIPT_NAME] $*" &>&2
echo "[$ts] [$level] [$SCRIPT_NAME] $*" &>> "$LOG_FILE"
}
log_info() { [[ "$LOG_LEVEL" =~ ^(DEBUG|INFO)$ ]] && log_msg "INFO" "$@"; }
log_error() { log_msg "ERROR" "$@"; }
cleanup() {
local code=$?
log_info "退出码: $code"
}
trap cleanup EXIT
main() {
check_root
check_dependencies
# 业务逻辑...
log_info "执行完成"
}
main "$@"
十、总结与实操建议
写好Ubuntu运维Shell脚本不是靠天赋,而是靠规范。核心就几条:开头开严格模式,变量命名有章法,函数单一职责,错误处理分三层,日志带级别和时间戳,退出码有定义,安全不硬编码。把这些习惯养成之后,你的脚本在生产环境中会稳定得多,出了问题也能快速定位。建议团队内部建立脚本Code Review机制,把这套规范落地到每一个人的日常工作中。
