Docker Swarm 脑裂灾难恢复:利用 Ansible 与 Restic 快速重建 Raft 集群
在生产环境中,Docker Swarm 凭借其轻量化、易维护的特点被广泛部署。然而,由于 Swarm Manager 节点之间强依赖 Raft 共识协议,当遭遇网络分区、磁盘 I/O 严重抖动或节点异常宕机时,Manager 节点数量极易降到法定人数(Quorum)以下,进而引发集群脑裂(Split-Brain)或共识丧失。此时,整个 Swarm 管理端将处于不可用状态(运行中的容器通常能继续运行,但无法进行任何调度或更新操作)。
本文将介绍一种高效、自动化的灾难恢复方案:通过 Ansible 编排调度,利用 Restic 安全恢复备份的 Raft 状态数据,并强行重建 Swarm 领导者,最终恢复集群活力。
1. 恢复策略的核心逻辑:避免“二次脑裂”
在动手编写自动化脚本前,必须理清 Swarm Raft 恢复的底层逻辑。
许多运维人员会犯一个致命错误:在所有旧的 Manager 节点上同时恢复 /var/lib/docker/swarm 的备份,然后直接启动 Docker。 这样做会导致每个节点都认为自己拥有最新的状态,并在无法建立共识的情况下各自尝试选举,从而引发二次脑裂。
正确的恢复路径如下:
[ 灾难发生:Swarm 共识崩溃 ]
│
┌─────────────┴─────────────┐
▼ ▼
【Seed 种子节点】 【其他旧 Manager 节点】
│ │
1. 停止 Docker 服务 1. 停止 Docker 服务
│ │
2. 清理旧 Swarm 目录 2. 彻底清理 Swarm 目录
│ │
3. Restic 恢复健康的 Raft 备份 │
│ │
4. 强制初始化: --force-new-cluster │
│ │
5. 获取新 Join Token │
│ │
└─────────────┬─────────────┘
▼
【其他旧 Manager 节点】
3. 以 Manager 身份加入新集群
- 确立种子节点(Seed Node):选择一台性能最好、网络最稳定的原 Manager 节点作为恢复的种子。
- 状态清理:在所有 Manager 节点上停止 Docker 进程。
- 单点恢复:仅在种子节点上通过 Restic 恢复
/var/lib/docker/swarm目录的最后一次健康备份。 - 强制重建(--force-new-cluster):在种子节点上启动 Docker,并执行强制单节点集群初始化。这会强制废除旧的 Raft 成员列表,生成新的 Raft 元数据,并将当前节点设为唯一的 Leader。
- 重构群落:获取新的 Manager Join Token,清理其他 Manager 节点的 Swarm 目录,并让它们作为干净的节点重新加入新生成的 Swarm。
2. 环境与准备工作
本方案假设你的环境已满足以下条件:
- Ansible 控制端:可以无密码 SSH 登录所有 Swarm 节点。
- Restic 备份仓库:Swarm 的
/var/lib/docker/swarm目录已经定期通过 Restic 备份至远端存储(如 MinIO、AWS S3 或自建的 SFTP 服务器)。 - 免密认证环境:Restic 密码和仓库凭证已通过环境变量或 Ansible Vault 保护。
变量定义样例 (group_vars/all.yml)
# Restic 仓库配置
restic_repo: "s3:https://s3.example.com/swarm-backups"
restic_password: "SuperSecretResticPassword"
aws_access_key_id: "my-access-key"
aws_secret_access_key: "my-secret-key"
# Swarm 配置
swarm_seed_host: "manager-01" # 选定的种子节点(必须在 Ansible Inventory 中定义)
swarm_interface: "eth0" # 用于 Swarm 内部通信的网卡
3. Ansible 灾难恢复 Playbook 编写
我们将恢复流程封装进一个名为 swarm_recovery.yml 的 Playbook 中。它将自动化执行停止服务、单点恢复、强制起群、获取 Token、动态重组的所有步骤。
---
- name: Docker Swarm Raft Cluster Disaster Recovery
hosts: swarm_managers
become: yes
vars_files:
- group_vars/all.yml
tasks:
# -----------------------------------------------------------------
# 步骤 1:全网停服,规避状态冲突
# -----------------------------------------------------------------
- name: Stop Docker daemon on all managers
systemd:
name: docker
state: stopped
- name: Ensure Docker socket is stopped
systemd:
name: docker.socket
state: stopped
# -----------------------------------------------------------------
# 步骤 2:彻底清理非种子节点的 Swarm 残留,防止后续干扰
# -----------------------------------------------------------------
- name: Clear Swarm state directory on non-seed managers
file:
path: /var/lib/docker/swarm
state: absent
when: inventory_hostname != swarm_seed_host
# -----------------------------------------------------------------
# 步骤 3:在种子节点上执行 Restic 恢复
# -----------------------------------------------------------------
- name: Recover Raft backup on Seed Node
block:
- name: Clean existing Swarm directory on seed node
file:
path: /var/lib/docker/swarm
state: absent
- name: Recreate Swarm directory container
file:
path: /var/lib/docker/swarm
state: directory
mode: '0700'
- name: Pull latest healthy snapshot from Restic
shell: |
restic restore latest \
--target / \
--include /var/lib/docker/swarm
environment:
RESTIC_REPOSITORY: "{{ restic_repo }}"
RESTIC_PASSWORD: "{{ restic_password }}"
AWS_ACCESS_KEY_ID: "{{ aws_access_key_id }}"
AWS_SECRET_ACCESS_KEY: "{{ aws_secret_access_key }}"
when: inventory_hostname == swarm_seed_host
# -----------------------------------------------------------------
# 步骤 4:激活种子节点,强行生成单节点 Raft 领导权
# -----------------------------------------------------------------
- name: Start Docker on Seed Node
systemd:
name: docker
state: started
when: inventory_hostname == swarm_seed_host
- name: Force new Swarm cluster initialization on Seed Node
shell: |
docker swarm init \
--force-new-cluster \
--advertise-addr {{ hostvars[inventory_hostname]['ansible_' + swarm_interface]['ipv4']['address'] }}
when: inventory_hostname == swarm_seed_host
register: swarm_init_result
- name: Retrieve new Manager Join Token from Seed Node
shell: "docker swarm join-token manager -q"
when: inventory_hostname == swarm_seed_host
register: manager_token_raw
- name: Set Join Token and Seed IP as global fact
set_fact:
new_manager_token: "{{ hostvars[swarm_seed_host]['manager_token_raw']['stdout'] }}"
seed_private_ip: "{{ hostvars[swarm_seed_host]['ansible_' + swarm_interface]['ipv4']['address'] }}"
run_once: true
# -----------------------------------------------------------------
# 步骤 5:启动其他 Manager 的 Docker,并重组集群
# -----------------------------------------------------------------
- name: Start Docker on remaining managers
systemd:
name: docker
state: started
when: inventory_hostname != swarm_seed_host
- name: Join remaining managers to the new Swarm cluster
shell: |
docker swarm join \
--token {{ new_manager_token }} \
{{ seed_private_ip }}:2377
when: inventory_hostname != swarm_seed_host
retries: 3
delay: 5
register: join_result
until: join_result is succeeded
# -----------------------------------------------------------------
# 步骤 6:健康状态验证
# -----------------------------------------------------------------
- name: Check Swarm node status from Seed
shell: "docker node ls"
when: inventory_hostname == swarm_seed_host
register: swarm_nodes_status
- name: Show Swarm Cluster Status
debug:
var: swarm_nodes_status.stdout_lines
when: inventory_hostname == swarm_seed_host
4. 关键步骤原理解析与高可用避坑
① 为什么要执行 docker swarm init --force-new-cluster?
当 Raft 数据库中的旧成员无法通信时,直接启动 Docker,Swarm 将永远卡在“寻找共识成员”的自锁阶段。--force-new-cluster 告诉守护进程:“丢弃当前 Raft 记录中的所有其他 Manager 成员身份,以我自己恢复出的数据为准,重新生成单成员共识组,并转为 Active 状态。”
这是灾难恢复中至关重要的一步,切不可直接在多台机器上并发执行此命令。
② Swarm Autolock(自动锁定)的处理
如果在灾难发生前,你的 Swarm 集群启用了 Autolock(即在重启 Docker 时需要手动输入解锁密钥以解密 Raft 密钥对),那么在种子节点启动 Docker 并执行恢复后,你需要先运行 docker swarm unlock 并输入原解锁密钥,然后再进行后续的操作。
如果你的集群开启了该功能,建议在 Playbook 的启动任务后增加交互式提示或利用 Ansible 变量自动填入 unlock_key:
- name: Unlock Swarm if locked
shell: "docker swarm unlock <<EOF\n{{ swarm_unlock_key }}\nEOF"
when: inventory_hostname == swarm_seed_host and swarm_is_locked
③ Restic 恢复时的增量更新
由于 /var/lib/docker/swarm 中可能包含数万个小型的 Raft 状态日志段文件(wal 和 snap),Restic 的快速检索和解密传输机制能够将恢复时间控制在数秒内。
在执行 restic restore 前,执行 file: path=/var/lib/docker/swarm state=absent 是推荐的做法,这能确保不会有残留的脏数据干扰恢复后的 Raft 状态,防止出现数据合并冲突。
5. 灾后健康度验证
Playbook 执行完成后,你可以在 swarm_seed_host 上运行以下命令,验证集群是否已恢复元气:
# 查看节点状态,确认所有 Manager 节点均显示 Ready 且 Status 为 Active
docker node ls
# 验证服务(Services)是否自动重新拉起
docker service ls
注意: 重新组建集群后,由于原有的 Overlay 网络配置是从 Raft 库中一同恢复出来的,原运行在 Worker 节点上的容器通常可以无缝重连至新的 Manager,无需重启所有业务容器。
6. 总结与日常预案建议
利用 Ansible + Restic 构建的 Swarm 恢复方案,可以在集群遭遇不可逆脑裂后的 2~5 分钟内一键重建控制平面。为了确保该机制在真实灾难场景中切实有效,建议在日常运维中坚持以下规范:
- 高频备份计划:建议利用 Systemd Timer 或 Cron,每隔 1 小时或至少每天一次执行
restic backup /var/lib/docker/swarm。 - 异地灾备:务必将 Restic 仓库托管在外部高可用对象存储或异地机房,防止整个宿主机集群物理损坏。
- 定期进行脑裂演练:在非生产环境,可以通过人为对 Manager 节点注入
iptables -A INPUT -p tcp --dport 2377 -j DROP制造脑裂,以此演练上述 Playbook,确保在突发故障时运维团队能够临危不乱。