162 lines
11 KiB
Markdown
162 lines
11 KiB
Markdown
# 多机高可用方案
|
||
|
||
目标:单台主机挂了,任务队列、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 只能证明进程活着,证明不了
|
||
故障转移。
|