在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机制,把这套规范落地到每一个人的日常工作中。