# 多机高可用方案 目标:单台主机挂了,任务队列、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:先决条件(不动架构,先把保命的东西立起来) 1. **定时备份 + 恢复演练跑通**:给 `pg_dump`(`POST /admin/backup` 或 cron)加每天一次定时任务,备份文件异地(另一台机器或对象 存储,只留本机等于没备);`deploy/restore-drill.sh` 在备用库上 真跑一次,演练通过才算备份有效。RPO = 备份间隔,写进运维手册。 2. **health 改读 DB**:`/health` 的 `worker_ok` 从文件心跳改为 `workers` 表聚合(任一 worker `last_seen` 在 180 秒内即 ok)。 改动小,但多机 health 的前提。 3. **监控聚合**:每机 `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 + 负载均衡) 1. 两台 server 进程(同代码、同 env、同 DB 连接串),前面一台 nginx(upstream + 健康检查,` /health` 要求 `ok` 且 `db_ok`)。 webhook、OAuth、API 任一 server 都能处理:去重、state、App 配置全在 DB 里,天然幂等。 2. **限流二选一**:改共享计数(Postgres 表存滑动窗口,量小够用; 量大再上 Redis)——推荐;或接受预算 ×N 并写进文档(不推荐, 等于限流失效一半)。 3. server 本机文件:server 进程不依赖 `host-state.json`(那是 worker 的),除限流与文件心跳(阶段 0/2 已处理)外无其它内存状态。 部署改为滚动:逐台传 tar、重启、health 通过再下一台,绝不同时 停两台。 4. 入口:nginx 本身仍是单点,见阶段 4。 阶段 2 验收:停一台 server,API、webhook、OAuth 回调、`/pr` (经阶段 1 转发)全部正常;限流在两台上的总和不超过设定值。 ### 阶段 3:数据面(最难、最贵,放最后) Postgres 单主是最后的单点。选项按成本排序: 1. **接受单 Postgres + 快速重建**(推荐起步):阶段 0 的定时备份 + 演练 + 一份重建手册(新容器 + 恢复备份 + 重启 server),RTO 约 30 分钟,RPO = 备份间隔。多数小团队到这里就够了。 2. **流复制 + 手动提升**:一主一从,主机挂了手动提从为主、改连接 串、重启 server。RTO 约 10 分钟,有脑裂与数据滞后风险,演练 必须定期做。 3. **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(现行纪律不变)。 ## 五、实施顺序与回滚 1. 阶段 0 → 验收:备份定时任务在跑、恢复演练通过、health 改 DB。 2. 阶段 1 → 验收:双 worker 主机 + `/pr` 转发;回滚:停第二台 worker 主机,`worktree_host` 列留着无害。 3. 阶段 2 → 验收:双 server + LB + 限流共享;回滚:LB 只留一台 upstream,另一台停服。 4. 阶段 3 → 按选项验收;回滚:切回单 Postgres + 备份。 5. 阶段 4 → 验收:nginx 切换演练;回滚:DNS/浮动 IP 指回原机。 每阶段结束都以一次“杀一台主机”的演练为准,不要以“服务都 active”为准——单机时代的 monitor 只能证明进程活着,证明不了 故障转移。