跳转至

27. 自动化与脚本编写

本章介绍 RHEL 9 运维自动化的核心技能:规范化 Shell 脚本编写、Ansible 进阶应用,以及 systemd timer 替代传统 cron 的最佳实践。自动化是消除重复性手工操作、降低人为失误风险的根本途径,也是第 28 章自动化部署的直接基础。


27.1 前提条件

  • 已完成第四阶段全部配置(第 20–26 章)。
  • 通过 SecureCRT(SSH 端口 2222)以 ops 用户、密钥认证方式登录 192.168.1.100,执行 sudo -i 切换至 root 环境。
  • Ansible 已安装(第 26 章);Git 已安装(第 30 章)。

本章全局参数

参数
主机名 / IP linuxdc / 192.168.1.100
脚本部署目录 /usr/local/bin/
巡检日志路径 /var/log/linuxdc-health.log
Timer 执行时间 每日 06:00

27.2 Shell 脚本规范化编写

27.2.1 生产级脚本基础结构

规范的生产级 Shell 脚本应包含严格模式声明、结构化日志函数、前置检查和清晰的主逻辑分层:

tee /usr/local/bin/linuxdc-health.sh << 'SCRIPT'
#!/usr/bin/env bash
# ============================================================
# 脚本名称: linuxdc-health.sh
# 功能描述: 系统健康状态巡检
# 运行环境: RHEL 9 | 主机: linuxdc | IP: 192.168.1.100
# ============================================================

# 严格模式(生产脚本必须开启)
# -e: 遇到错误立即退出;-u: 引用未定义变量时报错;
# -o pipefail: 管道中任一命令失败则整体失败
set -euo pipefail

# --- 常量定义 ---
readonly SCRIPT_NAME=$(basename "$0")
readonly LOG_FILE="/var/log/linuxdc-health.log"
readonly TIMESTAMP=$(date '+%F %T')

# --- 结构化日志函数 ---
log()  { echo "[${TIMESTAMP}] [INFO]  $*" | tee -a "${LOG_FILE}"; }
warn() { echo "[${TIMESTAMP}] [WARN]  $*" | tee -a "${LOG_FILE}" >&2; }
die()  { echo "[${TIMESTAMP}] [ERROR] $*" | tee -a "${LOG_FILE}" >&2; exit 1; }

# --- 前置检查 ---
[[ $EUID -ne 0 ]] && die "此脚本需要 root 权限运行,请使用 sudo 执行"

# --- 主逻辑 ---
main() {
    log "开始系统健康巡检 | 主机: $(hostname) | IP: 192.168.1.100"

    # 磁盘空间检查(超过 85% 告警)
    local disk_usage
    disk_usage=$(df -h / | awk 'NR==2 {print $5}' | tr -d '%')
    if [[ ${disk_usage} -ge 85 ]]; then
        warn "根分区使用率 ${disk_usage}%,超过 85% 告警阈值"
    else
        log "根分区使用率 ${disk_usage}% - 正常"
    fi

    # 可用内存检查
    local mem_free
    mem_free=$(free -m | awk '/^Mem:/ {print $4}')
    log "可用内存: ${mem_free} MB"

    # 关键服务检查
    local services=("sshd" "chronyd" "firewalld")
    for svc in "${services[@]}"; do
        if systemctl is-active --quiet "${svc}"; then
            log "服务 ${svc}: 运行中"
        else
            warn "服务 ${svc}: 未运行,请立即检查"
        fi
    done

    log "巡检完成"
}

main "$@"
SCRIPT

chmod +x /usr/local/bin/linuxdc-health.sh

# 验证脚本语法无误
bash -n /usr/local/bin/linuxdc-health.sh

# 执行巡检
/usr/local/bin/linuxdc-health.sh
cat /var/log/linuxdc-health.log

27.2.2 参数处理与帮助信息

mkdir -p ~/scripts

tee ~/scripts/backup-selective.sh << 'SCRIPT'
#!/usr/bin/env bash
set -euo pipefail

usage() {
    cat << EOF
用法: $0 [选项] <目标目录>

选项:
  -d, --dest   DIR    备份存储目录(默认: /backup)
  -r, --retain DAYS   保留天数(默认: 7)
  -h, --help          显示此帮助信息

示例:
  $0 /etc
  $0 -d /nas/backup -r 30 /opt/app
EOF
    exit 0
}

# 默认值
DEST="/backup"
RETAIN=7
TARGET=""

# 参数解析
while [[ $# -gt 0 ]]; do
    case $1 in
        -d|--dest)    DEST="$2";    shift 2 ;;
        -r|--retain)  RETAIN="$2";  shift 2 ;;
        -h|--help)    usage ;;
        -*)           echo "错误: 未知选项 $1"; usage ;;
        *)            TARGET="$1";  shift ;;
    esac
done

[[ -z "${TARGET}" ]]  && { echo "错误: 未指定目标目录"; usage; }
[[ -d "${TARGET}" ]]  || { echo "错误: ${TARGET} 不存在或不是目录"; exit 1; }

DATE=$(date +%F)
mkdir -p "${DEST}"
tar -czpf "${DEST}/$(basename "${TARGET}")-${DATE}.tar.gz" "${TARGET}"
find "${DEST}" -name "*.tar.gz" -mtime +"${RETAIN}" -delete

echo "备份完成: ${DEST}/$(basename "${TARGET}")-${DATE}.tar.gz"
SCRIPT

chmod +x ~/scripts/backup-selective.sh

# 验证帮助信息
~/scripts/backup-selective.sh --help

27.2.3 错误处理与信号捕获

tee ~/scripts/safe-deploy.sh << 'SCRIPT'
#!/usr/bin/env bash
set -euo pipefail

TMPDIR=$(mktemp -d)
LOCKFILE="/tmp/deploy.lock"

# 清理函数(脚本退出、被 Ctrl+C 中断或收到 TERM 信号时均触发)
cleanup() {
    local exit_code=$?
    rm -rf "${TMPDIR}"
    rm -f  "${LOCKFILE}"
    [[ ${exit_code} -ne 0 ]] && echo "部署因错误退出(代码 ${exit_code}),临时文件已清理"
}
trap cleanup EXIT INT TERM

# 防止并发执行(文件锁机制)
if [[ -f "${LOCKFILE}" ]]; then
    echo "另一个部署进程正在运行(PID: $(cat "${LOCKFILE}")),退出"
    exit 1
fi
echo $$ > "${LOCKFILE}"

log() { echo "[$(date '+%F %T')] $*"; }
log "部署开始,临时目录: ${TMPDIR}"

# 此处放置实际部署逻辑
# cp /opt/release/app.tar.gz "${TMPDIR}/"
# tar -xzf "${TMPDIR}/app.tar.gz" -C /opt/app/

log "部署完成"
SCRIPT

chmod +x ~/scripts/safe-deploy.sh
bash -n ~/scripts/safe-deploy.sh

27.3 Ansible 进阶应用

27.3.1 变量、Facts 与条件判断

tee ~/ansible-playbooks/conditional-deploy.yml << 'EOF'
---
- name: 条件化部署示例
  hosts: webservers
  become: yes

  vars:
    app_version: "2.1.0"
    deploy_env: "production"
    backup_before_deploy: true

  tasks:
    - name: 部署前备份(仅生产环境执行)
      archive:
        path: /opt/app
        dest: "/backup/app-{{ ansible_date_time.date }}.tar.gz"
      when:
        - deploy_env == "production"
        - backup_before_deploy | bool

    - name: 安装 Python3 依赖
      dnf:
        name:
          - python3
          - python3-pip
        state: present

    - name: 检查当前应用版本
      command: /opt/app/bin/app --version
      register: current_version
      ignore_errors: yes
      changed_when: false

    - name: 输出版本对比信息
      debug:
        msg: >
          当前版本: {{ current_version.stdout | default('未安装') }},
          目标版本: {{ app_version }}
      when: current_version.stdout | default('') != app_version
EOF

27.3.2 Handlers 与通知机制

Handlers 只在被 notify 触发且对应任务实际产生变更时才执行,且无论被触发多少次,每个 handler 在一次 play 中只执行一次(在所有 task 完成后统一运行),避免配置多次变更导致服务被多次重启。

tee ~/ansible-playbooks/nginx-config.yml << 'EOF'
---
- name: Nginx 配置管理
  hosts: webservers
  become: yes

  handlers:
    - name: reload nginx
      systemd:
        name: nginx
        state: reloaded

    - name: restart nginx
      systemd:
        name: nginx
        state: restarted

  tasks:
    - name: 安装 Nginx
      dnf:
        name: nginx
        state: present
      notify: restart nginx

    - name: 部署 Nginx 配置文件
      template:
        src: nginx.conf.j2
        dest: /etc/nginx/nginx.conf
        validate: 'nginx -t -c %s'
      notify: reload nginx

    - name: 确保 Nginx 运行并开机自启
      systemd:
        name: nginx
        state: started
        enabled: yes
EOF

27.3.3 使用 ansible-vault 加密敏感数据

mkdir -p ~/ansible-playbooks/vault

# 创建加密变量文件(存储数据库密码、API 密钥等敏感信息)
ansible-vault create ~/ansible-playbooks/vault/secrets.yml
# 输入 Vault 密码后,在编辑器中添加:
# db_password: "Db@Secure2025!"
# api_key: "sk-xxxxxxxxxxxxxxxx"

# 在 Playbook 中通过 vars_files 引用加密文件:
# vars_files:
#   - vault/secrets.yml

# 执行时输入 Vault 密码解密
ansible-playbook site.yml --ask-vault-pass

# CI/CD 场景使用密码文件(不交互输入)
echo "vault_password_here" > ~/.vault_pass
chmod 600 ~/.vault_pass
ansible-playbook site.yml --vault-password-file ~/.vault_pass

# 查看加密文件内容(需 Vault 密码)
ansible-vault view ~/ansible-playbooks/vault/secrets.yml

# 修改加密文件
ansible-vault edit ~/ansible-playbooks/vault/secrets.yml

27.4 systemd Timer(替代 cron)

systemd timer 是 RHEL 9 推荐的定时任务实现方式,相比 cron 具备完整的日志集成、依赖管理和错误处理能力。

27.4.1 创建 systemd Timer

以每日健康巡检任务为例:

# 创建 service unit(定义实际执行的工作)
tee /etc/systemd/system/health-check.service << 'EOF'
[Unit]
Description=System Health Check
After=network.target

[Service]
Type=oneshot
ExecStart=/usr/local/bin/linuxdc-health.sh
StandardOutput=journal
StandardError=journal
EOF

# 创建 timer unit(定义触发时间和策略)
tee /etc/systemd/system/health-check.timer << 'EOF'
[Unit]
Description=Daily System Health Check Timer
Requires=health-check.service

[Timer]
# 每天 06:00 执行
OnCalendar=*-*-* 06:00:00
# 系统启动 5 分钟后补执行一次(处理开机时错过的触发)
OnBootSec=5min
# 错过的触发点在下次系统启动时补执行
Persistent=true
# 随机延迟最多 5 分钟(分散多节点同时触发的压力)
RandomizedDelaySec=300

[Install]
WantedBy=timers.target
EOF

systemctl daemon-reload
systemctl enable --now health-check.timer

# 验证 timer 状态和下次触发时间
systemctl list-timers health-check.timer

# 手动触发测试
systemctl start health-check.service
journalctl -u health-check.service -n 20

27.4.2 验证 OnCalendar 语法

# 验证 timer 时间表达式是否正确
systemd-analyze calendar "*-*-* 06:00:00"
systemd-analyze calendar "Mon,Wed,Fri *-*-* 02:30:00"
systemd-analyze calendar "monthly"

27.4.3 cron 与 systemd timer 对比

特性 cron systemd timer
日志管理 需手动重定向 自动写入 journal,journalctl 直接查询
依赖管理 不支持 完整 systemd 依赖链(After=Requires=
错过执行处理 直接跳过 Persistent=true 在下次启动时补执行
随机延迟 需手动 sleep RandomizedDelaySec 原生支持
监控集成 困难 原生 systemctl status 查看状态
错误通知 需额外配置 结合 OnFailure= 单元自动通知

27.5 Git 自动化部署钩子

# 在服务端裸仓库中创建 post-receive 钩子
mkdir -p /opt/repos/myapp.git/hooks

tee /opt/repos/myapp.git/hooks/post-receive << 'SCRIPT'
#!/usr/bin/env bash
set -euo pipefail

DEPLOY_DIR="/opt/app/myapp"
BRANCH="main"
LOG="/var/log/git-deploy.log"

while read -r old_rev new_rev ref_name; do
    branch="${ref_name##refs/heads/}"
    [[ "${branch}" != "${BRANCH}" ]] && continue

    echo "$(date '+%F %T') 收到推送,分支: ${branch},版本: ${new_rev:0:8}" >> "${LOG}"
    git --work-tree="${DEPLOY_DIR}" --git-dir="$(pwd)" checkout -f "${BRANCH}"

    # 重载应用服务(优先 reload,避免中断连接)
    systemctl reload nginx 2>/dev/null || systemctl restart nginx

    echo "$(date '+%F %T') 部署完成" >> "${LOG}"
done
SCRIPT

chmod +x /opt/repos/myapp.git/hooks/post-receive

27.6 常见问题与排查

问题现象 排查步骤
脚本 set -e 下意外退出 对允许失败的命令追加 \|\| true;或局部 set +e … set -e 包裹
Ansible Playbook 不幂等 使用 stat/command 模块检查状态再决定执行;避免裸 command/shell
systemd timer 未触发 systemctl list-timerssystemd-analyze calendar 验证语法
ansible-vault 密码遗忘 无法恢复,需重新创建加密文件;务必将密码存入密码管理器
# 验证 OnCalendar 语法
systemd-analyze calendar "*-*-* 06:00:00"

# 脚本语法检查
bash -n /usr/local/bin/linuxdc-health.sh

# Ansible 连通性测试
ansible webservers -m ping -v
ansible-playbook site.yml --check --diff

27.7 实践任务

  1. 登录 linuxdc(SecureCRT 端口 2222,ops 用户,密钥认证,sudo -i 切换 root)。
  2. 按照 27.2.1 创建 /usr/local/bin/linuxdc-health.sh,执行后查看 /var/log/linuxdc-health.log
  3. 创建参数化备份脚本 backup-selective.sh,测试 -h 帮助输出和实际备份功能。
  4. 编写含 handlers 的 Ansible Playbook,确保 Nginx 配置变更时自动触发 reload。
  5. 配置 health-check.timer,验证下次触发时间,手动触发并查看日志:

    systemctl list-timers health-check.timer
    systemctl start health-check.service
    journalctl -u health-check.service -n 10
    

27.8 自测问题

Q1:Shell 脚本中 set -euo pipefail 三个选项各有什么作用?

-e:任意命令返回非零退出码时立即终止脚本,防止错误被忽略后继续执行导致状态异常;-u:引用未定义变量时报错退出,防止拼写错误的变量名被当作空字符串静默处理;-o pipefail:管道 cmd1 | cmd2 中若 cmd1 失败,整个管道返回非零(默认只检查最后一条命令),防止管道前段的错误被掩盖。三者组合是生产脚本严格模式的安全基线。

Q2:Ansible handlers 与普通 task 的核心区别是什么?

普通 task 按声明顺序逐条执行;handlers 只在被 notify 通知且对应任务实际产生变更(changed)时才执行,且无论在一次 play 中被触发多少次,每个 handler 只执行一次(在所有 task 完成后统一运行)。这一机制避免了因配置文件多次变更导致服务被多次不必要地重启,是 Playbook 幂等性设计的关键支撑。

Q3:systemd timer 的 Persistent=true 解决了什么问题?

若 timer 应触发时系统处于关机状态(如每日 06:00 执行,但服务器在此期间停机),Persistent=true 会在系统下次启动时立即补执行一次错过的任务,确保定期巡检、备份等关键任务不被遗漏。默认(Persistent=false)则直接跳过错过的触发点,等待下一次计划时间,可能导致连续多天未执行而不自知。


27.9 章节总结

本章构建了 RHEL 9 自动化运维的完整技能体系:规范化 Shell 脚本(严格模式 set -euo pipefail、结构化日志函数、参数处理、错误捕获与信号处理)、Ansible 进阶应用(变量与条件判断、handlers 机制、vault 敏感数据加密),以及 systemd timer 替代 cron 的最佳实践(含 PersistentRandomizedDelaySec 等生产要素)。这些技能是第 28 章自动化部署与镜像管理的直接基础。