Files
codex-app-server/cloud/HA-DESIGN.md
T

162 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 多机高可用方案
目标:单台主机挂了,任务队列、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 只能证明进程活着,证明不了
故障转移。