Skip to content

Latest commit

 

History

History
119 lines (89 loc) · 6.3 KB

File metadata and controls

119 lines (89 loc) · 6.3 KB

部署与升级

本文说明怎么把本仓库产出的镜像上线,重点是「升级时服务中断多久」。 dev / prod 各自跑在哪台机器、用哪份 compose,属于部署仓库 / 服务器的范畴,本文只列 一份两边共用的清单,避免「手工部署」与「CI 部署」走成两套动作。

1. 停机窗口由什么决定

升级时服务不可用,成因分三层(下表是改前的形态,两条对应改动见 §2 与 §3 第 1 步):

层 改前 影响
停旧实例 → 起新实例之间没有缓冲 单副本,先停后起 窗口下限 = 拉镜像 + 起进程
应用没有优雅停机 收到 SIGTERM 直接退出 在途请求被直接掐断(连接重置)
应用启动本身 打开库 → 监听端口 极快(实测不足 1 秒),不是大头

因此本文只做两件事:把窗口压到最小(预拉镜像),让在途请求不再被掐断(优雅停机)。

⚠️ 不追求「新实例就绪后再停旧实例」的真零停机:单副本 + 共享 SQLite,新旧实例并存会抢写 同一个库文件。要做真零停机得先换掉 SQLite,那是一次架构变更,不在本文范围。

2. 应用侧做了什么(本仓库)

  • 优雅停机:收到 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)
在途请求已排水完成,正常退出

3. 上线清单(四步)

下面的命令用占位符:把 <service> / <stack> 换成本环境的服务名,<image> 换成 ghcr.io/<owner>/aitokenpool:<tag>。

谁执行哪几步:dev 的四步已由部署仓库的 CI 流水线自动执行(预拉 → 更新 → 健康校验 → 失败回滚),无需人工介入;下面的命令是 prod 手工部署时照做的,也是 dev 出问题时要 手动复现同一步时的参照。改完本节的判据,两边的做法应保持一致 —— 否则「手工部署」与 「CI 部署」会走成两套动作。

第 1 步 · 预拉(把拉取移出停机窗口)

镜像必须在服务实际运行的那个节点上先拉好 —— 只在 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。

第 2 步 · 更新

# Swarm
docker stack deploy -c docker-compose.yml <stack>
# 单机 compose(先 pull 再 up,且 image 用固定 tag)
docker compose pull && docker compose up -d

⚠️ image 用固定 tag,不要用 latest:本地已有同名镜像时 compose up -d 未必重新拉取, 会出现「流程跑了但版本没变」的静默失败;固定 tag 顺带让回滚变成一条明确的命令。

第 3 步 · 健康校验

curl -fsS http://<host>/healthz
  • 判据:HTTP 200,且响应里的 version 等于本次目标版本(/healthz 自报的是二进制里 编译进去的版本,不是文件名)。
  • 不要用「能成功调用模型」当判据:那取决于上游 key 的数据状态(没有任何 on 状态的 key 时网关会 503),与部署是否成功无关。

第 4 步 · 失败回滚

# Swarm
docker service update --rollback <service>
# 单机 compose
# 把 image 改回上一个 tag,再 pull + up -d
  • 触发条件:第 3 步超时,或 version 不是目标版本。
  • 判据:回滚后 /healthz 报的 version 回到上一个 tag。

4. 验收判据(每次改动部署方式时,改前 / 改后各测一次)

  1. 窗口长度:部署期间用 curl 连续探测 /healthz,量出失败窗口。
  2. 在途请求:部署期间发一个耗时 1–2 秒的请求 —— 改前应看到连接被重置,改后应正常 拿到完整响应。这是「优雅停机真的生效」的判据,不能只看容器退出码。
  3. 日志:停机时容器日志里应出现第 2 节那两行。

5. 硬约束(不要做的事)

  • 不要把编排改成「先起新、再停旧」(start-first):单副本 + 共享 SQLite,并存实例会 抢写同一个库。
  • 不要把数据库移到本地盘、或在共享盘上开 WAL —— 那是另一次架构变更,不在本文范围。
  • 不要改动 ATP_MASTER_KEY 的取值与注入方式:主密钥不对会让已加密的上游 key 全部 解不开(全量 503),且故障是静默的。
  • 升级前备份整个数据目录(不只是 aitokenpool.db):archive/ 子目录里的 JSONL 是明细表 在保留窗口之外那一段的唯一原件(归档自身按 [archive] 的 max_files × max_file_size 滚动,不是无限留存)—— 只备份库文件的话,恢复后早期明细整段消失,且不会报错。 恢复时注意文件属主/权限(容器内的运行用户要能读到)。