本文说明怎么把本仓库产出的镜像上线,重点是「升级时服务中断多久」。 dev / prod 各自跑在哪台机器、用哪份 compose,属于部署仓库 / 服务器的范畴,本文只列 一份两边共用的清单,避免「手工部署」与「CI 部署」走成两套动作。
升级时服务不可用,成因分三层(下表是改前的形态,两条对应改动见 §2 与 §3 第 1 步):
| 层 | 改前 | 影响 |
|---|---|---|
| 停旧实例 → 起新实例之间没有缓冲 | 单副本,先停后起 | 窗口下限 = 拉镜像 + 起进程 |
| 应用没有优雅停机 | 收到 SIGTERM 直接退出 | 在途请求被直接掐断(连接重置) |
| 应用启动本身 | 打开库 → 监听端口 | 极快(实测不足 1 秒),不是大头 |
因此本文只做两件事:把窗口压到最小(预拉镜像),让在途请求不再被掐断(优雅停机)。
- 优雅停机:收到 SIGTERM(
docker stop/ Swarm 更新 / 重建容器都会发)或 SIGINT 后,进程停止接受新连接、等待在途请求自然完成,然后正常退出。 - 有上限:等待的上限是
src/main.rs的DRAIN_LIMIT_SECS;超时仍未排完就强制退出, 并在日志里写一行排水超过上限 …。上限的存在意味着 SSE / 长连接请求可能被截断 —— 这是「有上限」的必然代价,不假装无损。 - 容器窗口必须比它长:
docker-compose.yml的stop_grace_period决定 docker 发完 SIGTERM 后多久发 SIGKILL。它必须大于DRAIN_LIMIT_SECS,否则应用还在排水就被打死, 优雅停机退化回原状且没有任何报错。这两个数的关系由src/shutdown_gate.rs守着 —— 改一个忘了另一个,cargo test会红。
日志里正常一次停机的两行:
收到 SIGTERM:停止接受新连接,等待在途请求完成(上限 Ns)
在途请求已排水完成,正常退出
下面的命令用占位符:把 <service> / <stack> 换成本环境的服务名,<image> 换成
ghcr.io/<owner>/aitokenpool:<tag>。
谁执行哪几步:dev 的四步已由部署仓库的 CI 流水线自动执行(预拉 → 更新 → 健康校验 → 失败回滚),无需人工介入;下面的命令是 prod 手工部署时照做的,也是 dev 出问题时要 手动复现同一步时的参照。改完本节的判据,两边的做法应保持一致 —— 否则「手工部署」与 「CI 部署」会走成两套动作。
镜像必须在服务实际运行的那个节点上先拉好 —— 只在 CI 所在机器上拉没有意义。
docker pull <image>- Swarm:在约束命中的 worker 节点上执行;之后
docker stack deploy仍带--with-registry-auth(预拉只是省掉任务启动时那一步,不替代鉴权)。 - 判据:
docker images | grep <image>能在该节点上看到目标 tag。 ⚠️ 能 ssh 到 worker 才能这么拉。dev 的 CI 用户只到得了 manager、ssh 不到 worker, 所以它借 manager 的 Docker API 拉:一个--mode global的一次性 service 会把镜像拉到 每个 worker 上(拉完即退,随后删除)。两者目的一样,只是「谁有权限做什么」不同 —— 别把 CI 那套改成 ssh 循环,也别反过来以为手工部署必须先开一个 service。
# Swarm
docker stack deploy -c docker-compose.yml <stack>
# 单机 compose(先 pull 再 up,且 image 用固定 tag)
docker compose pull && docker compose up -dlatest:本地已有同名镜像时 compose up -d 未必重新拉取,
会出现「流程跑了但版本没变」的静默失败;固定 tag 顺带让回滚变成一条明确的命令。
curl -fsS http://<host>/healthz- 判据:HTTP 200,且响应里的
version等于本次目标版本(/healthz自报的是二进制里 编译进去的版本,不是文件名)。 - 不要用「能成功调用模型」当判据:那取决于上游 key 的数据状态(没有任何
on状态的 key 时网关会 503),与部署是否成功无关。
# Swarm
docker service update --rollback <service>
# 单机 compose
# 把 image 改回上一个 tag,再 pull + up -d- 触发条件:第 3 步超时,或
version不是目标版本。 - 判据:回滚后
/healthz报的 version 回到上一个 tag。
- 窗口长度:部署期间用
curl连续探测/healthz,量出失败窗口。 - 在途请求:部署期间发一个耗时 1–2 秒的请求 —— 改前应看到连接被重置,改后应正常 拿到完整响应。这是「优雅停机真的生效」的判据,不能只看容器退出码。
- 日志:停机时容器日志里应出现第 2 节那两行。
- 不要把编排改成「先起新、再停旧」(
start-first):单副本 + 共享 SQLite,并存实例会 抢写同一个库。 - 不要把数据库移到本地盘、或在共享盘上开 WAL —— 那是另一次架构变更,不在本文范围。
- 不要改动
ATP_MASTER_KEY的取值与注入方式:主密钥不对会让已加密的上游 key 全部 解不开(全量 503),且故障是静默的。 - 升级前备份整个数据目录(不只是
aitokenpool.db):archive/子目录里的 JSONL 是明细表 在保留窗口之外那一段的唯一原件(归档自身按[archive]的max_files×max_file_size滚动,不是无限留存)—— 只备份库文件的话,恢复后早期明细整段消失,且不会报错。 恢复时注意文件属主/权限(容器内的运行用户要能读到)。