11 KiB
多机高可用方案
目标:单台主机挂了,任务队列、API、webhook、/pr 还能继续。
非目标:进行中的 turn 不迁移——中断后按现有语义由陈旧回收标
interrupted,再由人工或新建任务重跑(goal 任务本来就不支持
同任务 /retry,见 README)。零中断不在本方案范围内。
建议的可用性目标(建议值,实施前由用户定):控制面 RTO < 10 分钟, 数据 RPO < 1 小时(由备份间隔决定,不是 0)。
一、现状:单点在哪里(2026-10-26 实测为准)
| 层 | 现状 | 多机影响 |
|---|---|---|
| 控制面 | 单进程 ThreadingHTTPServer(cloud/server.py create_server),只绑 127.0.0.1:8765,nginx(宝塔,api.diante.me)反代;systemd 重启只防进程死,不防主机死 |
主机挂 = API 全挂 |
| 数据库 | Postgres 单容器 codex-postgres,卷 /var/lib/codex-postgres 在同一主机 |
主机挂 = 数据挂;磁盘坏 = 数据丢 |
| 备份 | cloud/backup.py 的 pg_dump + deploy/restore-drill.sh 恢复演练都已存在,但没有定时任务,且 README 明确写着“真实环境 pg_dump 仍需验证一次” |
有备份代码,无备份制度 |
| 队列认领 | 已多机安全:cloud/worker.py _claim 是 DB CAS(WHERE status='queued',rowcount == 1 才算拿到);陈旧回收 sweep_stale_tasks 也是 CAS 幂等,worker 每轮 + /admin/sweep 都可跑 |
多 worker 跨机抢任务今天就能用 |
| worker 注册 | workers 表(cloud/workers.py,worker_id/executor/capacity/last_seen)在 DB 里,可共享 |
好 |
| worker 心跳 | 两条:DB 注册表心跳 + 文件心跳 worker-heartbeat.json;/health 的 worker_ok 读的是本机文件(_worker_heartbeat_status) |
多 server 下 health 失真 |
| 限流 | RateLimiter 是进程内存滑动窗口(cloud/server.py,按 key sha 或 IP 计),只对 API 路由生效 |
多 server 下预算 ×N |
/pr |
在 server 进程内跑,worktree 从 server 本机文件系统解析(host_worktrees_dir;Docker 任务的 tenants 目录同样在 server 本机) |
server 与 worker 同主机是隐含前提,多机首先断在这里 |
| worker 状态 | host-state.json(host 任务登记)、autoscaler-state.json、codex-home 模板、test-repo、repos-dir、tenants/ 全部是本机路径 |
新主机要全套播种 |
| 镜像 | codex-app-server:latest 只在工作主机本地有(代码本身不在镜像里,运行时只读挂载 /opt/codex-app-server:/app) |
新主机要同步镜像 + 代码目录 |
| 出口 | tinyproxy(8888)+ 白名单 + iptables,每主机一份 | 新主机要复制白名单与 iptables 规则 |
| 入口 | nginx 单机;公网只经 api.diante.me 的 /oauth/ + /webhooks/ + /github-app/ 三组 location |
nginx 挂 = 公网全挂 |
| 密钥 | /etc/codex-app-server/env(0600)每机一份;GitHub App 的 webhook_secret 与 pem 在 Postgres github_app_config 表里(可共享) |
新主机要分发 env 文件 |
| 监控 | monitor.timer 每 5 分钟本机自查(systemctl、health、pg_isready、docker ps、磁盘),只写 journal;告警到 Muse 聊天的通道本身就是开放项(需 SSH key 常驻通道或 webhook URL) |
多机要聚合视图 + 告警通道 |
结论:队列、注册表、webhook 去重(webhook_deliveries DB claim)、
OAuth state、App 配置都已经在 DB 里,server 本身除限流与文件心跳
外是准无状态的。真正的硬耦合只有三处:/pr 的 worktree 本地性、
Postgres 单机、nginx 单机。方案按这个顺序拆阶段,每个阶段可独立
回滚到单机。
二、推荐拓扑(分阶段)
阶段 0:先决条件(不动架构,先把保命的东西立起来)
- 定时备份 + 恢复演练跑通:给
pg_dump(POST /admin/backup或 cron)加每天一次定时任务,备份文件异地(另一台机器或对象 存储,只留本机等于没备);deploy/restore-drill.sh在备用库上 真跑一次,演练通过才算备份有效。RPO = 备份间隔,写进运维手册。 - health 改读 DB:
/health的worker_ok从文件心跳改为workers表聚合(任一 workerlast_seen在 180 秒内即 ok)。 改动小,但多机 health 的前提。 - 监控聚合:每机
monitor.sh照常跑,结果汇总到一处(最简: 各机 journal + 一个汇总端点;不要为监控先引入新组件)。
阶段 1:worker 面多机(控制面仍单机,风险最低的一步)
加第二台 worker 主机:Docker worker + autoscaler,共享同一个
Postgres 队列。CAS 认领保证不重跑;一台 worker 主机挂了,另一台
继续认领(进行中的任务由 sweep 标 interrupted)。
但阶段 1 必须同时解决 /pr,否则 Docker 任务的 worktree 在 worker
主机上、server 在另一台主机上,/pr 直接 no_worktree:
- 方案 A(推荐):
/pr跟着 worktree 走。tasks表加worktree_host列,worker 认领时写自己的主机名;server 的/pr先查本机 worktree,没有则经 SSH/HTTP 转发到持有主机执行 (复用主机间已有的 SSH 通道与 codexsvc 身份),或至少返回 “worktree 在某主机”的明确错误而不是静默跳过。改动:tasks迁移一列 +_claim写主机名 +pr.py转发分支 + 单测。 - 方案 B(不推荐首选):
tenants/+host-worktrees/放共享存储 (NFS)。改动最小,但引入新单点、文件锁与跨机 git 竞态,性能 与一致性都不如 A,只在主机数很少且运维愿意管 NFS 时考虑。
阶段 1 验收:停一台 worker 主机,新任务仍被另一台认领完成;
/pr 在 worktree 所在主机与非所在主机上各跑一次,结果一致。
阶段 2:控制面多机(2 台 server + 负载均衡)
- 两台 server 进程(同代码、同 env、同 DB 连接串),前面一台
nginx(upstream + 健康检查,
/health要求ok且db_ok)。 webhook、OAuth、API 任一 server 都能处理:去重、state、App 配置全在 DB 里,天然幂等。 - 限流二选一:改共享计数(Postgres 表存滑动窗口,量小够用; 量大再上 Redis)——推荐;或接受预算 ×N 并写进文档(不推荐, 等于限流失效一半)。
- server 本机文件:server 进程不依赖
host-state.json(那是 worker 的),除限流与文件心跳(阶段 0/2 已处理)外无其它内存状态。 部署改为滚动:逐台传 tar、重启、health 通过再下一台,绝不同时 停两台。 - 入口:nginx 本身仍是单点,见阶段 4。
阶段 2 验收:停一台 server,API、webhook、OAuth 回调、/pr
(经阶段 1 转发)全部正常;限流在两台上的总和不超过设定值。
阶段 3:数据面(最难、最贵,放最后)
Postgres 单主是最后的单点。选项按成本排序:
- 接受单 Postgres + 快速重建(推荐起步):阶段 0 的定时备份 + 演练 + 一份重建手册(新容器 + 恢复备份 + 重启 server),RTO 约 30 分钟,RPO = 备份间隔。多数小团队到这里就够了。
- 流复制 + 手动提升:一主一从,主机挂了手动提从为主、改连接 串、重启 server。RTO 约 10 分钟,有脑裂与数据滞后风险,演练 必须定期做。
- Patroni 自动故障转移:RTO 最短,但要 etcd/consul、VIP、 fencing,运维复杂度上一个台阶;或直接用托管 Postgres(把 复杂度外包,按账单取舍)。
不要为“多机”先上 Patroni:阶段 1+2 解决的是最常见的进程/主机 故障,数据面按备份 + 演练先顶着,等阶段 2 稳定了再评估。
阶段 4:入口与出口(收尾)
- nginx 双机 + keepalived 浮动 IP(同机房),或 DNS 轮询 + 健康 检查(跨机房,生效慢)。宝塔重生成 vhost 会吃 location 的老 问题在多机下同样存在,备份恢复流程要同步到两台。
- tinyproxy 白名单与 iptables 规则做成配置同步(每机一份,改动 时同批下发,不要手工各改各的)。
- Docker 镜像进 registry(或每机同步构建产物),新主机入网不再 依赖某台机器的本地镜像。
- 新主机入网清单(每加一台必查):env 文件、镜像、AppArmor
profile(
docker-codex、codex-host)、tinyproxy 白名单、 iptables、codex-home 模板、test-repo、repos-dir、tenants 根、 systemd units、monitor.timer、NTP(心跳与超时都依赖时钟)。
三、故障场景表(各阶段下的表现)
| 故障 | 今天(单机) | 阶段 1 后 | 阶段 2 后 | 阶段 3 选项 1 后 |
|---|---|---|---|---|
| server 进程死 | systemd 重启,秒级 | 同左 | 另一台接管,无感 | 同左 |
| worker 主机挂 | 任务卡住到 sweep(300 秒)标 interrupted | 另一台继续认领 | 同左 | 同左 |
| server 主机挂 | 全挂 | API 全挂,worker 空转 | LB 切走,继续 | 同左 |
| Postgres 挂 | 全挂 | 全挂 | 全挂 | 备份重建(RTO 约 30 分钟) |
| nginx 挂 | 公网全挂 | 公网全挂 | 公网全挂 | 阶段 4 后切走 |
| 磁盘满 | monitor 报 fail,人工清 | 同左(每机自查) | 同左 | 同左 |
| Docker daemon 挂 | 该机任务全停 | 另一台继续 | 同左 | 同左 |
四、不做的事与风险
- 不迁移进行中的 turn:主机挂时正在跑的 turn 一定中断,
由 sweep 标
interrupted;重跑走新建任务(goal 任务的限制不变)。 试图热迁移 turn 状态的代价远大于收益。 - 不共享 SQLite:多机只走 Postgres 后端;SQLite 只留给单测与
本地开发(现有的后端抽象已支持,部署时
CLOUD_DB_BACKEND必须为postgres,并在启动时校验,缺驱动明确失败不回落)。 - 脑裂:写路径永远只认一个 Postgres 主;CAS 认领与 sweep 的
WHERE status守卫是最后一道防线,不要绕过它们做“强制认领”。 - 时钟:心跳、超时、sweep 全依赖时间戳,多机必须 NTP 同步, 否则会出现“活任务被误扫”或“死任务扫不掉”。
- 密钥分发:env 文件目前是手工放的;主机数上 3 台之前手工尚可, 再多就要 secrets 管理(Vault/托管密钥),不要把密钥写进仓库或 部署 tar(现行纪律不变)。
五、实施顺序与回滚
- 阶段 0 → 验收:备份定时任务在跑、恢复演练通过、health 改 DB。
- 阶段 1 → 验收:双 worker 主机 +
/pr转发;回滚:停第二台 worker 主机,worktree_host列留着无害。 - 阶段 2 → 验收:双 server + LB + 限流共享;回滚:LB 只留一台 upstream,另一台停服。
- 阶段 3 → 按选项验收;回滚:切回单 Postgres + 备份。
- 阶段 4 → 验收:nginx 切换演练;回滚:DNS/浮动 IP 指回原机。
每阶段结束都以一次“杀一台主机”的演练为准,不要以“服务都 active”为准——单机时代的 monitor 只能证明进程活着,证明不了 故障转移。