上一篇 分享链接 返回 返回顶部

香港服务器如何用Ansible实现重复部署与失败回滚

发布人:Minchunlin 发布时间:8小时前 阅读量:15
香港服务器如何用Ansible实现重复部署与失败回滚

同一份发布任务,第一次成功、第二次却改坏配置,或者服务重启正常但业务仍然报错,这类问题通常不是香港服务器本身不稳定,而是部署过程没有明确区分“准备版本、切换版本、验证业务”三个阶段。先判断故障发生在哪个阶段,再决定重试还是回滚,才能避免越修越乱。

建议按“连接与权限 → 发布物与配置 → 当前版本指针 → 服务状态 → 业务健康”的顺序排查。实现上,用 Ansible 管理确定的目标状态,把代码和配置放入独立版本目录;切换后执行健康检查,失败则恢复旧版本并再次验证。可重复部署依靠幂等任务与不可变发布物,失败回滚依靠可恢复的旧状态,而不是简单重跑 Playbook。

高频现象:任务执行成功,不代表部署成功

先区分下面几种现象,通常就能缩小排查范围。

现象优先判断核验方法
第二次执行仍大量显示 changed使用了无条件执行的命令,或模板包含动态时间等内容查看具体任务,比较配置内容与文件摘要
服务显示运行中,但请求失败进程启动不等于应用就绪检查本机健康接口、应用日志和依赖状态
回滚代码后仍然异常配置、数据库或外部状态没有同步恢复比较旧版本配置及此次变更记录
单台成功,多台出现版本混杂并发部署、批次中断或部分主机不可达分别读取每台服务器的版本指针和健康接口
定时任务触发后发布状态反复变化两次部署重叠执行核对调度日志、运行进程和发布锁

Ansible 的 ok 只表示任务无需修改,changed 表示模块报告发生了修改;两者都不能代替业务验收。尤其不要为了让输出“好看”,给真实修改状态的命令随意加上 changed_when: false

按优先级完成低风险核验

以下示例适用于使用 systemd 的 Linux 香港服务器,控制端安装 Ansible,目标端具备相应 Python 环境。应用服务名以 webapp 为例,需替换为已存在的实际服务。

1. 先确认主机范围、连接和权限

ansible-playbook -i inventory.ini deploy.yml --syntax-check
ansible-playbook -i inventory.ini deploy.yml --list-hosts
ansible -i inventory.ini hk_app -m ansible.builtin.ping
ansible -i inventory.ini hk_app -b -m ansible.builtin.command -a "id"

ping 模块成功说明 Ansible 通信与模块执行基本正常,并不是 ICMP 测试。如果报 UNREACHABLE,先排查 SSH、认证和网络可达性;连接正常但提权失败,应修复 become 授权,而不是扩大目录权限。

确认主机清单确实指向预期服务器,不要通过关闭 SSH 主机密钥校验来绕过身份异常。

2. 再确认版本、磁盘与运行状态

在目标服务器上执行这些只读检查:

readlink -f /srv/webapp/current
df -h /srv/webapp
df -i /srv/webapp
systemctl status webapp --no-pager
journalctl -u webapp -n 100 --no-pager

版本路径不存在,说明发布目录或链接有问题;空间或 inode 耗尽,可能导致解包、日志写入失败;服务启动后立即退出,应优先核验配置和运行权限。不要在原因未明时连续重启。

3. 最后核验健康接口

curl --fail --silent --show-error \
  --max-time 5 http://127.0.0.1:8080/health

地址和超时为示例参数,应按应用调整。健康接口最好同时返回就绪状态和发布版本,例如:

{"ready": true, "release": "release_20260912_01"}

仅返回 HTTP 200 无法排除“旧进程仍在提供服务”的情况。本机通过而用户入口失败时,还要检查实际访问路径,不能继续把问题归因于发布包。

把代码、配置和运行数据分开管理

建议使用下面的目录约定:

/srv/webapp/
├── releases/
│   ├── release_20260911_01/
│   └── release_20260912_01/
├── shared/
└── current -> releases/release_20260911_01

releases 保存不可变的代码和版本配置,shared 保存上传文件等运行数据,current 指向当前版本。服务启动路径必须通过 current 读取应用及配置,不能仍然引用某个写死的版本目录。

准备发布物时,按以下顺序执行:

  1. 在控制端确定发布编号、代码版本和包摘要;同一个发布编号不能对应不同内容。
  2. 使用 ansible.builtin.file 创建版本目录,用 copy 传包并核验摘要,再用 unarchive 解包。
  3. template 渲染该版本自己的配置;应用支持配置校验命令时,通过模板的 validate 参数或独立任务先行校验。
  4. 所有检查通过后才写入 .ready 标记。重复部署时,应核对发布清单与摘要,再决定是否跳过准备阶段,不能只凭目录存在就认定准备完成。

只有代码和配置都能随版本指针一起恢复,目录切换才具备完整的应用回滚意义。 如果模板直接覆盖 /etc 下的共享配置,还需要单独备份、记录并恢复这些文件。

敏感配置使用 Ansible Vault 等方式保护,相关任务设置 no_log: true,必要时关闭 diff。普通配置可以审查差异,但不要把密码和令牌输出到发布日志。

用 block/rescue 实现切换失败后的恢复

下面的 deploy.yml 聚焦已经完成准备的版本切换,适用于已有健康旧版本的升级,不包含首次安装。运行前需要满足:

  • 新旧目录都有可信的 .ready 标记,发布内容已核验。
  • webapp 已配置为读取 current 下的代码与配置。
  • 健康接口返回前述 JSON,release 与版本目录名一致。
  • 使用 GNU lnmv,临时链接与 current 位于同一文件系统。
  • 发布期间禁止其他任务同时修改 current

此操作会修改版本链接并重启服务,可能造成短暂中断。执行前保留旧版本、配置备份,并确认旧版本仍兼容当前数据。

---
- name: 发布已准备的应用版本
  hosts: hk_app
  become: true
  serial: 1
  any_errors_fatal: true
  vars:
    app_root: /srv/webapp
    app_service: webapp
    health_url: http://127.0.0.1:8080/health

  pre_tasks:
    - name: 校验发布编号
      ansible.builtin.assert:
        that:
          - release_id is defined
          - (release_id | default('')) is match('^[A-Za-z0-9][A-Za-z0-9_-]*$')

    - name: 读取原版本
      ansible.builtin.command:
        argv: [readlink, -f, "{{ app_root }}/current"]
      register: old_release
      changed_when: false

    - name: 确认原版本位于发布目录
      ansible.builtin.assert:
        that:
          - old_release.stdout.startswith(app_root + '/releases/')

    - name: 检查新旧版本准备标记
      ansible.builtin.stat:
        path: "{{ item }}/.ready"
      loop:
        - "{{ old_release.stdout }}"
        - "{{ app_root }}/releases/{{ release_id }}"
      register: ready_checks

    - name: 拒绝未准备完成的版本
      ansible.builtin.assert:
        that:
          - item.stat.exists
          - item.stat.isreg | default(false)
      loop: "{{ ready_checks.results }}"

  tasks:
    - name: 切换并验证
      block:
        - name: 建立新版本临时链接
          ansible.builtin.command:
            argv:
              - ln
              - -sfnT
              - "{{ app_root }}/releases/{{ release_id }}"
              - "{{ app_root }}/.current-next"
          when: old_release.stdout != app_root + '/releases/' + release_id

        - name: 原子替换当前链接
          ansible.builtin.command:
            argv:
              - mv
              - -Tf
              - "{{ app_root }}/.current-next"
              - "{{ app_root }}/current"
          when: old_release.stdout != app_root + '/releases/' + release_id

        - name: 新版本切换后重启服务
          ansible.builtin.systemd_service:
            name: "{{ app_service }}"
            state: restarted
          when: old_release.stdout != app_root + '/releases/' + release_id

        - name: 检查新版本就绪状态
          ansible.builtin.uri:
            url: "{{ health_url }}"
            return_content: true
            timeout: 3
          register: new_health
          until:
            - new_health.status | default(0) == 200
            - (new_health.json | default({})).get('ready', false) == true
            - (new_health.json | default({})).get('release', '') == release_id
          retries: 10
          delay: 3

      rescue:
        - name: 建立原版本恢复链接
          ansible.builtin.command:
            argv:
              - ln
              - -sfnT
              - "{{ old_release.stdout }}"
              - "{{ app_root }}/.current-next"

        - name: 恢复原版本指针
          ansible.builtin.command:
            argv:
              - mv
              - -Tf
              - "{{ app_root }}/.current-next"
              - "{{ app_root }}/current"

        - name: 重启原版本服务
          ansible.builtin.systemd_service:
            name: "{{ app_service }}"
            state: restarted

        - name: 验证原版本恢复
          ansible.builtin.uri:
            url: "{{ health_url }}"
            return_content: true
            timeout: 3
          register: old_health
          until:
            - old_health.status | default(0) == 200
            - (old_health.json | default({})).get('ready', false) == true
            - (old_health.json | default({})).get('release', '') == (old_release.stdout | basename)
          retries: 10
          delay: 3

        - name: 回滚后仍将本次发布标记为失败
          ansible.builtin.fail:
            msg: "发布验证失败,原版本已恢复并通过健康检查。"

重试次数和超时只是示例,应覆盖应用正常启动所需时间,而不是无限等待。

这里用同目录的重命名替换链接,避免先删除 current 再创建链接产生的路径空窗;但链接替换原子性不等于服务无中断serial: 1 限制逐台处理,最终的 fail 配合 any_errors_fatal 阻止失败后继续发布后续批次。已经成功升级的前序主机不会自动全部回滚,需要按发布记录决定是否统一恢复。

同一版本再次执行时,切换与重启任务会跳过,只重新验证健康。如果当前版本本来就是目标版本且检查失败,恢复分支只是重启原版本,不会自动寻找更早版本。

定时执行、审计与故障边界

发布前先用 --limit 选择一台主机验证,再扩大范围。手工执行和定时执行必须走同一个互斥入口,例如在单一控制端使用:

flock -n /var/lock/webapp-deploy.lock \
  ansible-playbook -i inventory.ini deploy.yml \
  -e release_id=release_20260912_01

运行账号必须有权创建锁文件;未获得锁时应报告本次未执行。该锁只对同一控制端有效,多控制端部署需要统一调度互斥。

定时任务可用 ansible.builtin.cron 管理,并固定任务名称,避免重复添加。调度脚本应使用绝对路径、固定工作目录和明确的发布编号,不要每次追踪含义变化的“最新版”。任务日志与交互终端不同,需单独设置保存位置、访问权限和保留策略。

每次发布至少记录:执行人或调度身份、目标主机、原版本、新版本、包摘要、配置版本、健康检查结果和回滚结果。配置仓库有提交记录,不等于服务器最终状态已经被审计。

还要明确三个例外:

  • 目标主机不可达rescue 不会自动处理不可达错误。SSH 中断后,应重新连接读取实际指针,确认切换是否发生,再决定恢复操作。
  • 数据变更不兼容:链接回滚不能撤销数据库迁移或外部写入。涉及这类变更时,应先验证新旧程序兼容性,并单独准备数据备份与恢复方案。
  • 回滚本身失败:旧版本健康检查不通过时,立即停止后续发布,保留现场日志,不要继续循环切换。

修复后别遗漏二次部署验证

先确认版本指针、健康接口版本、服务日志和实际用户入口一致,再用同一个发布编号执行第二次。预期应是发布物不变、配置无意外差异、服务不重复重启,健康检查仍然通过。

随后在测试环境主动触发一次新版本健康失败,确认旧版本能够恢复,而且发布任务最终仍报告失败。最容易遗漏的复核点是:旧版本不仅要保留在磁盘上,还必须能读取恢复后的配置、兼容当前数据,并真正对外提供服务。

目录结构
全文