停止有缺陷的更新

AnsibleBeginner
立即练习

介绍

Courtyard Sessions 的一个新包有看起来正常的首页,却缺少访客下载。这个独立环境从两个后端都正常提供原网站开始。你将改进准备好的 Ansible 发布流程,让候选版本失败后恢复此前可用的版本,并在更新其他节点前停止。

请先完成《逐台更新主机》,或具备其中检查工具、委派、串行批次、copy/file 操作和完整 HTTP 内容检查的同等实践经验。本实验讲解实际链接目标的记录及 block/rescue 恢复。时长为作者估计,并非实测的初学者完成时间。

恢复失败版本并停止发布

在这一步,你将记录实际的上一版本,在内容检查失败后恢复当前节点,并显式停止下一批。

打开 Website Tab,原始标题为 **Good things, around the corner.**。通过 The program 和 Download the program (CSV) 核对包含两个节目的原始节目单。这里的网关和两个 SSH 后端独立于上一实验,不依赖此前的文件或运行进程。

切换到 Controller Tab,起始目录为 /home/labex/project。已准备的 inspect.yml 和 rollout.yml 是上一实验中的工具。读取选定的包和当前状态:

cat group_vars/all.yml
ls packages/courtyard-incomplete
ansible-playbook inspect.yml

release_id 选择了 courtyard-incomplete。它有首页和标识信息,却没有 schedule.csv。两个实际后端仍报告 courtyard-preview。请保持所有提供的包字节不变:向候选包补入 CSV 会掩盖发布缺陷,无法练习恢复。暂时不要运行没有保护的发布流程。

block 将可能失败的任务组成一组;当其中普通任务失败时,会执行它的 rescue 任务。用读取文件系统元数据的 stat,在 block 前记录 current。follow: false 读取链接本身,而非它指向的目录。保存的 previous.stat.lnk_source 就是实际的上一目标,因此恢复时会回到此前正在运行的版本,而不是假定某个固定目录。

恢复流程必须还原链接,请求恢复后的首页和完整 CSV,再将两者与上一版本的包比较。basename 过滤器从保存的目标中提取版本目录名,让现有的 lookup('file', ...) 比较能够读取匹配的本地包。只有恢复健康的节点才能加回流量池。如果恢复后的任一资源缺失或有误,该 rescue 任务会失败,节点继续留在池外,另一后端则继续服务访客。

成功的 rescue 会清除原任务失败对 Ansible 失败处理决策的影响。因此,仅有 any_errors_fatal 并不能在恢复成功后停止后续批次。在 rescue 末尾显式执行 fail,会报告候选版本被拒绝并停止下一批。即使原网站已恢复健康,非零退出仍是本练习的预期结果。无效 YAML 和无法连接的 SSH 主机不会触发这个 rescue 机制;它不是通用回滚保证。

用以下完整版本替换 rollout.yml。cat > 只替换这个学习者 playbook,带引号的 here-document 分隔符保留 Ansible 表达式。请按示例保留空格:

cat > rollout.yml <<'YAML'
- name: Release one website backend at a time
  hosts: website
  order: sorted
  gather_facts: false
  serial: 1
  any_errors_fatal: true
  tasks:
    - name: Remember the actual previous release link
      ansible.builtin.stat:
        path: "{{ release_root }}/current"
        follow: false
      register: previous
    - name: Update this backend or recover it
      block:
        - name: Drain this backend from the pool
          ansible.builtin.uri:
            url: "{{ pool_api }}/pool"
            method: POST
            body_format: json
            body:
              node: "{{ inventory_hostname }}"
              enabled: false
          delegate_to: controller
        - name: Create this release directory
          ansible.builtin.file:
            path: "{{ release_root }}/releases/{{ release_id }}"
            state: directory
            mode: '0755'
        - name: Copy the selected package over SSH
          ansible.builtin.copy:
            src: "{{ package_root }}/{{ release_id }}/"
            dest: "{{ release_root }}/releases/{{ release_id }}/"
            mode: '0644'
        - name: Activate the selected release
          ansible.builtin.file:
            src: "{{ release_root }}/releases/{{ release_id }}"
            dest: "{{ release_root }}/current"
            state: link
        - name: Read the real candidate homepage
          ansible.builtin.uri:
            url: "http://{{ hostvars[inventory_hostname].ansible_host }}:{{ backend_port }}/"
            return_content: true
          delegate_to: controller
          register: homepage
        - name: Require the complete candidate homepage
          ansible.builtin.assert:
            that: homepage.content == lookup('file', package_root ~ '/' ~ release_id ~ '/index.html', rstrip=False)
        - name: Read the real candidate download
          ansible.builtin.uri:
            url: "http://{{ hostvars[inventory_hostname].ansible_host }}:{{ backend_port }}/schedule.csv"
            return_content: true
          delegate_to: controller
          register: download
        - name: Require the complete approved program
          ansible.builtin.assert:
            that: download.content == lookup('file', package_root ~ '/' ~ approved_release ~ '/schedule.csv', rstrip=False)
        - name: Rejoin only after both business checks
          ansible.builtin.uri:
            url: "{{ pool_api }}/pool"
            method: POST
            body_format: json
            body:
              node: "{{ inventory_hostname }}"
              enabled: true
          delegate_to: controller
      rescue:
        - name: Restore the previous release link
          ansible.builtin.file:
            src: "{{ previous.stat.lnk_source }}"
            dest: "{{ release_root }}/current"
            state: link
        - name: Read the restored homepage
          ansible.builtin.uri:
            url: "http://{{ hostvars[inventory_hostname].ansible_host }}:{{ backend_port }}/"
            return_content: true
          delegate_to: controller
          register: restored_home
        - name: Require the complete previous homepage
          ansible.builtin.assert:
            that: restored_home.content == lookup('file', package_root ~ '/' ~ (previous.stat.lnk_source | basename) ~ '/index.html', rstrip=False)
        - name: Read the restored download
          ansible.builtin.uri:
            url: "http://{{ hostvars[inventory_hostname].ansible_host }}:{{ backend_port }}/schedule.csv"
            return_content: true
          delegate_to: controller
          register: restored_download
        - name: Require the complete previous program
          ansible.builtin.assert:
            that: restored_download.content == lookup('file', package_root ~ '/' ~ (previous.stat.lnk_source | basename) ~ '/schedule.csv', rstrip=False)
        - name: Rejoin only after both restoration checks
          ansible.builtin.uri:
            url: "{{ pool_api }}/pool"
            method: POST
            body_format: json
            body:
              node: "{{ inventory_hostname }}"
              enabled: true
          delegate_to: controller
        - name: Stop subsequent batches after recovery
          ansible.builtin.fail:
            msg: Candidate failed; the previous release is healthy again. Stop this rollout.
YAML

在修改任何后端前先验证语法:

ansible-playbook rollout.yml --syntax-check

随后尝试不完整版本。这个命令预期会在恢复后失败:

ansible-playbook rollout.yml

立即读取最终退出状态。$? 是上一命令的退出状态,echo 将它打印出来。执行其他命令会替换 shell 保存的这个值:

echo $?

预期为非零状态,通常是 2。输出中,新首页检查成功,候选下载返回 HTTP 404,恢复后的首页和 CSV 检查通过,随后 Stop subsequent batches after recovery 有意失败。HTTP 404 标明下载资源缺失。SSH 连接仍可用于恢复。web2 不得进入发布任务。

再次运行观察工具:

ansible-playbook inspect.yml

两个后端都必须报告 courtyard-preview,且都处于活动状态。切换到 Website Tab,重新加载页面,进入 The program,再次下载 CSV,确认原有两个节目仍可用。Plan a visit 仍必须显示无障碍访问信息。

恢复后的网站仍提供两个原始节目和完整下载。

恢复后的 Courtyard Sessions 网站及其原始节目单

如果只是普通 YAML 或文件名错误,用 ls 检查,修正完整 playbook 后重新执行语法检查。如果此前的编辑已切换链接,而恢复逻辑当时还不能工作,请先保存上面的正确 playbook。然后可以用同一种已学过的健康发布流程,明确将发布版本和预期节目单都选为已知原始包,以恢复原版。引号将这两个赋值合并为一个 --extra-vars 参数:

ansible-playbook rollout.yml --extra-vars "release_id=courtyard-preview approved_release=courtyard-preview"

这条恢复命令应成功。用 inspect.yml 确认两个节点,再用原来的 ansible-playbook rollout.yml 命令重试不完整候选包,以展示要求的失败和恢复。不要重置 VM 或重写提供的候选包。请保持 playbook 可复用:流量池请求必须使用提供的 pool_api,文件路径使用提供的 release_root 和 package_root,发布任务使用这里展示的直接模块。

总结

你在真实的下载缺失故障后恢复了实际的上一版网站。两项恢复检查决定节点能否重新加入流量池,而显式失败确保即使 rescue 成功,后续 Ansible 批次也会停止。