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

香港站群服务器如何用Ansible统一部署站点配置并为失败任务设置回滚

发布人:Minchunlin 发布时间:1 天前 阅读量:22
香港站群服务器如何用Ansible统一部署站点配置并为失败任务设置回滚

香港站群服务器出现“部分站点已更新、部分站点仍是旧配置”、Nginx reload 失败或某个站点访问异常时,问题通常不在单条命令本身,而在于多台主机的配置来源、执行顺序和失败后的状态没有统一管理。正确做法是让 Ansible 从同一份清单和模板生成配置,先在控制节点完成连接与权限检查,再逐台执行配置变更、Nginx 语法检查和 reload;只要后续任务失败,就根据模板任务产生的备份文件恢复原配置。

排查顺序应保持由低风险到高风险:先确认控制节点能连接并提权,再确认目标机上的 Nginx、systemd 服务名和配置目录,随后使用 --check 观察变更,最后采用单台灰度、nginx -t、reload 和自动回滚。不要一开始就对整个站群服务器执行批量覆盖或重启。

先确认执行环境和故障范围

下面的示例以“Ansible 控制节点 + Linux/systemd 目标机 + Nginx”为基础。控制节点可以是 Linux 或其他能够运行 Ansible 的环境,目标机需要支持 SSH,并安装 Ansible 模块所需的 Python 环境。

先在控制节点确认 Ansible 的实际路径和版本,不要假设 ansible-playbook 一定位于 /usr/bin

ansible --version
command -v ansible
command -v ansible-playbook

如果目标机不是 systemd,或者 Nginx 服务名不是 nginx,应先调整变量和检查命令,而不是直接套用下面的 reload 任务。

建议把站群主机放在独立的 inventory 分组中:

# inventory/hosts.yml
all:
  children:
    hk_web:
      hosts:
        hk-web-01:
          ansible_host: 203.0.113.10
        hk-web-02:
          ansible_host: 203.0.113.11
      vars:
        ansible_user: deploy
        ansible_become: true
        ansible_become_method: sudo

示例中的 IP 使用文档保留地址,实际使用时替换为目标主机地址。主机名应保持稳定,不要在每次执行前手工修改清单,否则审计记录很难对应到具体服务器。

第一优先级:连接和提权

先执行无修改的连通性检查:

ansible hk_web -i inventory/hosts.yml -m ping

预期结果是每台主机返回 pong。如果出现 UNREACHABLE,优先检查 SSH 地址、端口、密钥、用户和安全策略;此时还没有进入站点配置阶段。

再确认 Ansible 能否通过 sudo 获得 root 权限:

ansible hk_web -i inventory/hosts.yml -b -m command -a 'id -u'

预期输出为 0。如果能连接但不是 0,说明 sudo 权限、sudo 密码或 ansible_become 配置存在问题。不要为了绕过权限错误而直接把配置文件改成普通用户可写,这会扩大 Nginx 配置被误改的范围。

第二优先级:确认服务、配置目录和现有状态

在所有目标机上执行只读检查:

ansible hk_web -i inventory/hosts.yml -b -m shell -a \
'command -v nginx && nginx -t && systemctl show -p LoadState --value nginx && systemctl is-active nginx'

正常情况下应同时满足:

  • command -v nginx 能返回可执行文件路径;
  • nginx -t 返回语法检查成功;
  • systemctl show 返回 loaded
  • systemctl is-active 返回 active

如果当前 nginx -t 已经失败,不要继续部署新配置。先根据输出定位已有配置错误,避免把“原有故障”和“本次变更”混在一起。若服务单元不是 nginx,可以使用以下命令查找实际名称:

systemctl list-unit-files --type=service | grep -i nginx

还应确认目标配置目录确实被主配置文件包含:

sudo nginx -T 2>/dev/null | grep -E 'include .*(conf.d|sites-enabled)'

nginx -T 会输出完整配置,可能包含内部域名或敏感信息,不要未经处理直接粘贴到公开工单或聊天窗口。下面示例使用 /etc/nginx/conf.d,只有在 nginx -T 证明该目录被 include 时才适用。

用清单和模板形成可重复部署

建议把站点变量、Nginx 模板和 Playbook 分开保存:

hk-sites/
├── inventory/
│   └── hosts.yml
├── group_vars/
│   └── hk_web.yml
├── templates/
│   └── managed-sites.conf.j2
├── audit/
└── site.yml

统一配置变量示例:

# group_vars/hk_web.yml
nginx_service_name: nginx
nginx_conf_dir: /etc/nginx/conf.d
managed_nginx_conf_path: "{{ nginx_conf_dir }}/managed-sites.conf"

sites:
  - name: site-a
    server_name: site-a.example
    aliases:
      - www.site-a.example
    listen: 80
    root: /srv/www/site-a/current

  - name: site-b
    server_name: site-b.example
    aliases:
      - www.site-b.example
    listen: 80
    root: /srv/www/site-b/current

这里把多个站点写进一个受 Ansible 管理的配置文件,优点是同一批站点的配置可以作为一个变更单元回滚。如果希望每个站点单独回滚,应改为每个站点一个模板文件,并为每个文件建立独立的 block/rescue

模板示例适用于静态站点:

# templates/managed-sites.conf.j2
# This file is managed by Ansible. Manual changes will be overwritten.

{% for site in sites %}
server {
    listen {{ site.listen | default(80) }};
    server_name {{ ([site.server_name] + (site.aliases | default([]))) | join(' ') }};

    root {{ site.root }};
    index index.html;

    location / {
        try_files $uri $uri/ =404;
    }
}

{% endfor %}

如果站点实际使用反向代理、HTTPS 或额外的安全策略,应在模板中明确写出对应配置,并先在测试环境验证。不要把静态站点模板直接套到需要 upstream 的站点上。

模板中的关键点是避免写入执行时间、随机数等每次都会变化的内容。这样同一份 Playbook 连续执行两次时,第二次应显示 changed=0,不会重复覆盖配置或 reload Nginx。

让失败任务具备明确的回滚边界

下面的 Playbook 做了几件事:

1. 检查变量、配置目录和站点根目录;

2. 使用 template 生成统一配置,并保留 Ansible 备份;

3. 在目标机上执行 nginx -t

4. 只有配置实际发生变化时才 reload;

5. 语法检查或 reload 失败时,恢复旧配置;

6. 恢复后再次执行语法检查和 reload;

7. 在控制节点追加一行 JSONL 审计记录。

# site.yml
- name: Deploy managed site configuration
  hosts: hk_web
  serial: 1
  any_errors_fatal: true
  become: true
  gather_facts: true

  vars:
    audit_file: "{{ playbook_dir }}/audit/deploy.jsonl"

  pre_tasks:
    - name: Create local audit directory
      ansible.builtin.file:
        path: "{{ playbook_dir }}/audit"
        state: directory
        mode: "0750"
      delegate_to: localhost
      become: false
      run_once: true

    - name: Validate site variables
      ansible.builtin.assert:
        that:
          - item.name is defined
          - item.server_name is defined
          - item.root is defined
          - (item.listen | default(80) | int) > 0
        fail_msg: "站点变量不完整,请检查 site.name、server_name、root 和 listen"
      loop: "{{ sites }}"
      loop_control:
        label: "{{ item.name }}"

    - name: Check nginx binary
      ansible.builtin.command: nginx -v
      register: nginx_version
      changed_when: false

    - name: Check nginx service unit
      ansible.builtin.command:
        cmd: "systemctl show -p LoadState --value {{ nginx_service_name }}"
      register: nginx_unit
      changed_when: false
      failed_when:
        - nginx_unit.rc != 0
        - nginx_unit.stdout | trim != "loaded"

    - name: Check nginx is active
      ansible.builtin.command:
        cmd: "systemctl is-active {{ nginx_service_name }}"
      register: nginx_active
      changed_when: false
      failed_when: false

    - name: Stop before deployment when nginx is not active
      ansible.builtin.assert:
        that:
          - nginx_active.stdout | trim == "active"
        fail_msg: "Nginx 当前不是 active,未自动启动服务,也未继续覆盖配置"

    - name: Check current nginx configuration
      ansible.builtin.command: nginx -t
      register: current_nginx_test
      changed_when: false

    - name: Check nginx configuration directory
      ansible.builtin.stat:
        path: "{{ nginx_conf_dir }}"
      register: nginx_conf_dir_stat

    - name: Require nginx configuration directory
      ansible.builtin.assert:
        that:
          - nginx_conf_dir_stat.stat.exists
          - nginx_conf_dir_stat.stat.isdir
        fail_msg: "配置目录不存在或不是目录:{{ nginx_conf_dir }}"

    - name: Check every site root
      ansible.builtin.stat:
        path: "{{ item.root }}"
      loop: "{{ sites }}"
      loop_control:
        label: "{{ item.name }}"
      register: site_root_checks

    - name: Require every site root
      ansible.builtin.assert:
        that:
          - item.stat.exists
          - item.stat.isdir | default(false)
        fail_msg: "站点根目录不存在或不是目录:{{ item.item.root }}"
      loop: "{{ site_root_checks.results }}"
      loop_control:
        label: "{{ item.item.name }}"

  tasks:
    - name: Initialize deployment status
      ansible.builtin.set_fact:
        deployment_status: running
        rollback_status: not_needed
        rollback_attempted: false

    - name: Render, validate and reload managed site configuration
      block:
        - name: Render managed nginx configuration
          ansible.builtin.template:
            src: managed-sites.conf.j2
            dest: "{{ managed_nginx_conf_path }}"
            owner: root
            group: root
            mode: "0644"
            backup: true
          register: rendered_config

        - name: Validate rendered nginx configuration
          ansible.builtin.command: nginx -t
          register: rendered_nginx_test
          changed_when: false

        - name: Reload nginx only when configuration changed
          ansible.builtin.service:
            name: "{{ nginx_service_name }}"
            state: reloaded
          when: rendered_config.changed

        - name: Mark deployment successful
          ansible.builtin.set_fact:
            deployment_status: success
            rollback_status: not_needed

      rescue:
        - name: Mark rollback as attempted
          ansible.builtin.set_fact:
            deployment_status: failed
            rollback_attempted: true
            rollback_status: pending

        - name: Restore previous managed configuration
          ansible.builtin.copy:
            src: "{{ rendered_config.backup_file }}"
            dest: "{{ managed_nginx_conf_path }}"
            remote_src: true
            owner: root
            group: root
            mode: "0644"
          when:
            - rendered_config is defined
            - rendered_config.changed | default(false)
            - rendered_config.backup_file is defined

        - name: Remove newly created managed configuration
          ansible.builtin.file:
            path: "{{ managed_nginx_conf_path }}"
            state: absent
          when:
            - rendered_config is defined
            - rendered_config.changed | default(false)
            - rendered_config.backup_file is not defined

        - name: Validate restored nginx configuration
          ansible.builtin.command: nginx -t
          register: rollback_nginx_test
          changed_when: false
          failed_when: false

        - name: Reload restored configuration
          ansible.builtin.service:
            name: "{{ nginx_service_name }}"
            state: reloaded
          register: rollback_reload
          failed_when: false
          when: rollback_nginx_test.rc == 0

        - name: Record rollback result
          ansible.builtin.set_fact:
            rollback_status: >-
              {% if rollback_nginx_test.rc | default(1) == 0
                    and not (rollback_reload.failed | default(false)) %}
              restored
              {% else %}
              rollback_failed
              {% endif %}
            deployment_status: failed

        - name: Fail the play after rollback handling
          ansible.builtin.fail:
            msg: >-
              配置部署失败,原配置回滚状态为
              {{ rollback_status }}。请检查 nginx -t、systemctl 和 Nginx 日志。

      always:
        - name: Write deployment audit record on controller
          ansible.builtin.lineinfile:
            path: "{{ audit_file }}"
            create: true
            mode: "0640"
            insertafter: EOF
            line: >-
              {{
                {
                  "time": ansible_date_time.iso8601,
                  "host": inventory_hostname,
                  "sites": sites | map(attribute="name") | list,
                  "status": deployment_status | default("unknown"),
                  "rollback": rollback_status | default("not_needed"),
                  "changed": rendered_config.changed | default(false)
                } | to_json
              }}
          delegate_to: localhost
          become: false
          register: audit_write
          failed_when: false

这个回滚逻辑实际保护什么

template 任务在目标文件已经存在且内容发生变化时,会生成一个带时间标记的备份文件。若后面的 nginx -t 或 reload 失败,rescue 会把该备份复制回原路径,再次检查并尝试 reload。

如果这是第一次创建 managed-sites.conf,没有旧文件可恢复,回滚动作会删除“本次刚创建且确实发生变化”的这个精确路径。它不会使用通配符删除整个配置目录。

这个回滚边界只覆盖 Ansible 在本 Playbook 中管理的 Nginx 配置文件,不覆盖站点根目录中的网页文件、数据库内容、DNS 记录或其他人工修改。如果同时发布站点文件,建议使用版本目录和固定的 current 软链接,保留上一版目录,在配置回滚之外再切回上一版文件。不要在回滚任务中直接清空站点根目录。

按风险顺序执行发布

先进行清单和语法预检查:

ansible-inventory -i inventory/hosts.yml --graph

ansible-playbook \
  -i inventory/hosts.yml \
  site.yml \
  --check

--check 可以提前发现变量、目录和部分模板变化,但它不能完全代替真实写入后的 nginx -t。模板仍需在实际目标机生成后,才能验证 include 关系、文件权限和最终配置内容。

为了降低影响范围,第一次正式执行只限制一台主机:

ansible-playbook \
  -i inventory/hosts.yml \
  site.yml \
  --limit hk-web-01

预期结果是:

  • 配置有变化时,模板任务显示 changed,随后 Nginx 完成 reload;
  • 配置没有变化时,模板任务显示 ok,不会重复 reload;
  • nginx -t 失败或 reload 失败时,Playbook 显示失败,并进入回滚流程;
  • serial: 1 让主机按顺序执行,不会同时改动所有香港站群服务器;
  • any_errors_fatal: true 使当前批次出现失败后停止继续扩散。

单台主机验证通过后,再执行整个分组:

ansible-playbook \
  -i inventory/hosts.yml \
  site.yml

如果某台主机失败,应先处理失败原因并确认该主机已恢复,再继续后续主机。不要通过重复执行来“碰运气”,因为重复执行并不能修复错误的模板、端口冲突或无效的 upstream 配置。

常见结果及对应处理方式

现象结果含义优先处理方式
UNREACHABLESSH、地址、端口或密钥问题,尚未执行站点变更检查 inventory、SSH 登录和网络访问
pong 正常但 id -u 不是 0能连接,但无法完成提权检查 sudo 权限、become 和密码配置
预检查中的 nginx -t 失败目标机原有配置已经有问题先修复原配置,不继续覆盖
模板任务为 ok目标文件与模板内容一致不需要 reload,说明部署具备幂等性
模板后 nginx -t 失败新模板存在语法错误、路径错误或配置冲突查看任务输出,确认是否已恢复旧文件
reload 失败但语法检查成功服务管理、运行权限或运行时依赖存在问题检查 systemd 状态和 Nginx 日志
回滚状态为 rollback_failed新旧配置都没有通过验证,不能认为业务已恢复暂停批量发布,保留现场并人工恢复
Nginx 正常但访问仍异常配置语法正确,不代表站点内容或上游应用正常根据访问日志、错误日志和站点实际响应继续判断

目标机上可以使用以下只读命令收集信息:

sudo nginx -t
sudo systemctl status nginx --no-pager
sudo journalctl -u nginx -n 100 --no-pager
sudo tail -n 100 /var/log/nginx/error.log

如果站点使用反向代理,出现 502 时不能直接认定 Nginx 配置语法错误。此时应确认 upstream 地址、端口和应用状态;若配置本身通过了 nginx -t,盲目回滚可能只会重新引入旧问题。

自动回滚未执行时的人工恢复

如果控制节点进程被强制终止、网络在模板写入后中断,rescue 不一定有机会运行。此时先查看 Playbook 输出中的 backup_file 路径,不要凭时间通配符猜测备份文件。

可以先只读查看备份文件:

sudo ls -lt /etc/nginx/conf.d/managed-sites.conf.*

确认备份文件确实对应本次变更后,再保留当前文件副本,并恢复指定备份:

sudo cp --preserve=mode,ownership \
  /etc/nginx/conf.d/managed-sites.conf \
  /etc/nginx/conf.d/managed-sites.conf.manual-before-rollback

sudo cp --preserve=mode,ownership \
  /etc/nginx/conf.d/managed-sites.conf.备份时间标记 \
  /etc/nginx/conf.d/managed-sites.conf

sudo nginx -t
sudo systemctl reload nginx

这里的“备份时间标记”必须替换成实际文件名。cp 会覆盖目标配置,因此执行前应确认路径精确无误,并且已经保留当前文件副本。不要使用 rm /etc/nginx/conf.d/*.conf 之类的宽范围删除命令。

加入定时检查和审计

定时任务应运行在 Ansible 控制节点,而不是让每台目标机各自执行一份不受控的配置脚本。最稳妥的方式是定时执行检查模式,发现模板漂移后再经过审核执行正式发布。

例如,先用 command -v ansible-playbook 确认路径,再由具备 SSH 和 sudo 权限的专用账号配置 cron:

15 2 * * * cd /opt/ansible/hk-sites && /usr/bin/ansible-playbook -i inventory/hosts.yml site.yml --check >> /var/log/ansible-hk-sites.log 2>&1

如果要定时自动应用配置,应确保控制节点目录中的模板来自经过审核的版本,不要在同一条定时任务中盲目执行 git pull 后立即发布。自动发布前还应使用 flock 或其他方式避免两个 Ansible 进程同时运行。

审计文件中的每一行记录至少应能回答以下问题:

  • 哪台目标主机执行了任务;
  • 哪些站点属于本次配置范围;
  • 模板是否发生变化;
  • 任务是成功、失败还是已回滚;
  • 回滚是否完成。

如果配置中包含密码、令牌或私钥,不要将其直接写入普通变量、审计文件或 --diff 输出。敏感变量应使用 Ansible Vault 管理,并对涉及敏感内容的任务使用 no_log: true。审计的目标是还原变更过程,而不是复制完整的秘密配置。

修复后的验证方法

完成正式发布或回滚后,至少执行以下验证。

首先在控制节点再次运行 Playbook:

ansible-playbook -i inventory/hosts.yml site.yml

如果配置已经正确同步,第二次执行通常应显示模板任务为 ok,且不会再次 reload。若每次都显示 changed,应检查模板中是否包含时间戳、随机值或不稳定排序。

然后在目标机上确认服务和配置状态:

sudo nginx -t
sudo systemctl is-active nginx
sudo nginx -T 2>/dev/null | grep -A8 -B2 'server_name site-a.example'

最后从目标机本地或受控的外部探测位置验证 Host 路由:

curl -sS -o /dev/null \
  -w 'HTTP status: %{http_code}\n' \
  -H 'Host: site-a.example' \
  http://127.0.0.1/

HTTP 状态码应结合站点设计判断,可能是成功响应或预期跳转;同时查看 Nginx error log,确认没有新增配置加载错误。若是刚完成回滚,除了检查 nginx -t 和服务状态,还要确认访问结果恢复到变更前的行为。

连续观察几轮定时审计结果后,再考虑扩大发布范围。对香港站群服务器而言,统一模板、单台灰度、即时语法检查、可追溯备份和失败回滚应当同时存在,缺少其中任何一环,批量自动化都可能把一个局部配置错误扩散成整组站点故障。

目录结构
全文