refactor(release): 改为 Agent 双阶段人工发布
删除 Gitea Actions、Tag/Main 自动流水线和旧 Runner 配置,取消 Git 操作与发布授权的绑定。\n\n新增本地镜像发布、固定生产部署助手、digest manifest、迁移安全检查、simulation 冒烟及显式回滚流程。\n\n验证:pnpm lint、pnpm test、pnpm build、Go 全量测试、ShellCheck、Compose 配置、人工发布测试和 linux/amd64 完整栈冒烟。
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
## 状态
|
||||
|
||||
Accepted
|
||||
Superseded by [ADR-003](003-manual-agent-release.md)
|
||||
|
||||
## 日期
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# ADR-003: 本地 Agent 双阶段人工发布
|
||||
|
||||
## 状态
|
||||
|
||||
Accepted;取代 ADR-001 的自动 CI/CD 决策。
|
||||
|
||||
## 日期
|
||||
|
||||
2026-07-22
|
||||
|
||||
## 背景
|
||||
|
||||
AI Gateway 的交付重点是 API 制品能够启动、迁移数据库并通过核心兼容接口。原有 PR、`main` 和 Tag 三套重复质量门禁占用单并发 Runner,发布还要等待外部 dispatcher,反馈与上线时间不符合当前小团队由 Agent 主导运维的模式。
|
||||
|
||||
## 决策
|
||||
|
||||
- 删除所有 Gitea Actions,不监听 Push、PR、Tag、Webhook、轮询或定时事件。`main` 不设置保护规则或 required status,允许经用户授权的 Agent 直接提交和推送。
|
||||
- Git 操作与生产授权彻底分离。commit、push、merge 和 Tag 都不会构建或部署。
|
||||
- 发布分成两次独立的人工作业:本地 `publish` 负责验证、构建、simulation 冒烟、推送不可变镜像并生成带 SHA-256 内容完整性校验的 JSON manifest;用户再次确认后,`deploy` 才把该 manifest 应用到生产。
|
||||
- 只允许发布工作区干净、已提交且属于 `origin/main` 历史的完整 SHA。Registry Tag 使用完整 SHA,生产 Compose 只接受 `repository@sha256:...`。
|
||||
- publish 相对当前线上 manifest 选择 API/Web 变更;未变化的组件沿用现有 digest。两阶段之间如果线上基线变化,客户端和服务器都拒绝过期 manifest。
|
||||
- 待发布镜像在本地临时 PostgreSQL 中执行全部迁移,并逐项记录 health、readiness、OpenAPI、鉴权、模型列表、Chat Completions、Responses、Images 和 Web 反代冒烟。生成接口使用 5ms simulation,不调用真实上游。
|
||||
- 只有迁移变化时生产才创建并验证 PostgreSQL custom-format 备份。生产探活失败自动恢复上一应用 digest;数据库恢复仍需人工决定。
|
||||
- 完整测试、依赖审计和漏洞扫描保留为按需人工命令,不再影响镜像发布关键路径。
|
||||
|
||||
## 影响
|
||||
|
||||
- 不再有自动质量证据或自动发布;用户和 Agent 必须严格遵守两次显式授权边界。
|
||||
- 允许直接推送 `main` 提高速度,也取消了服务端代码审查强制约束。可追溯性改由干净提交、main 历史、OCI revision、Registry digest 和服务器 release manifest 保证。
|
||||
- 本机是 `Darwin arm64`,发布脚本必须使用 Buildx 构建并实际运行 `linux/amd64` 镜像。
|
||||
- Registry、SSH 或生产 helper 不可用时发布失败关闭,不允许退回 `latest`、重建制品或直接修改 Compose。
|
||||
+100
-133
@@ -1,167 +1,134 @@
|
||||
# 生产 CI/CD 运行手册
|
||||
# 人工生产发布运行手册
|
||||
|
||||
## 信任边界与固定资源
|
||||
## 原则
|
||||
|
||||
生产发布没有自动触发器。允许 Agent 直接提交和推送 `main`,但 Git 操作不会授权 publish 或 deploy:
|
||||
|
||||
1. 用户明确要求发布镜像后,Agent 执行本地 publish。
|
||||
2. Agent 报告 manifest、digest、组件和冒烟结果并停止。
|
||||
3. 用户再次明确确认上线后,Agent 执行 deploy。
|
||||
|
||||
不得把两步放进同一个命令、Git hook、Gitea Action、Webhook、服务、Timer 或轮询脚本。
|
||||
|
||||
## 一次性停用旧自动化
|
||||
|
||||
源码仓已经删除 `.gitea/workflows` 中的全部工作流,`main` 分支保护和 required status 也必须保持关闭。可用下列只读命令验收:
|
||||
|
||||
```bash
|
||||
tea branches list --repo BCAI/easyai-ai-gateway --output yaml | sed -n '/^- name: main$/,+3p'
|
||||
```
|
||||
|
||||
预期 `protected: "false"` 且 `user-can-push: "true"`。
|
||||
|
||||
确认没有运行中任务后,在旧 Runner 主机执行:
|
||||
|
||||
```bash
|
||||
systemctl disable --now easyai-gateway-ci-v2-runner.service
|
||||
systemctl is-active easyai-gateway-ci-v2-runner.service
|
||||
```
|
||||
|
||||
预期第二条命令返回 `inactive`。保留 `easyai-gateway-ci-v2-data` volume 观察一段时间,不立即删除。
|
||||
|
||||
生产部署仓不得保留 Tag/Main dispatcher、Webhook receiver 或 Timer。先只读列出相关单元,确认精确名称后逐个 `disable --now`,不得用模糊匹配批量停止其他 EasyAI 服务:
|
||||
|
||||
```bash
|
||||
systemctl list-unit-files --type=service --type=timer | grep -Ei 'easyai.*(release|dispatch|deploy)'
|
||||
```
|
||||
|
||||
## 安装固定生产助手
|
||||
|
||||
生产助手只解析 manifest、拉取 digest、备份/迁移、更新 Compose、探活和回滚;它不是常驻服务,也不读取源码 checkout。
|
||||
|
||||
把以下三个已审查文件复制到生产服务器:
|
||||
|
||||
- `deploy/manual/easyai-ai-gateway-release`
|
||||
- `deploy/manual/easyai-ai-gateway-release.conf.example`
|
||||
- `scripts/release-manifest.mjs`
|
||||
|
||||
在可信 checkout 中可运行一次安装器:
|
||||
|
||||
```bash
|
||||
sudo ./deploy/manual/install-release-helper.sh
|
||||
sudoedit /etc/easyai-ai-gateway-release.conf
|
||||
sudo /usr/local/sbin/easyai-ai-gateway-release status
|
||||
```
|
||||
|
||||
配置文件必须为 root 所有、`0600`。助手安装后不会创建或启用 service、timer、webhook 或 watcher。
|
||||
|
||||
默认生产资源:
|
||||
|
||||
- 源码仓:`https://git.51easyai.com/BCAI/easyai-ai-gateway`
|
||||
- CI Runner:`easyai-gateway-ci-v2-runner.service`
|
||||
- CI 标签:`easyai-gateway-ci-unprivileged-v2`
|
||||
- Runner 状态:Docker named volume `easyai-gateway-ci-v2-data` 内的 `/data/.runner`
|
||||
- 只读工具链:`/opt/easyai-gateway-ci`
|
||||
- Runner 配置:`/etc/easyai-gateway-ci-v2/config.yaml`
|
||||
- Runner 运行参数:`/etc/easyai-gateway-ci-v2/runner.env`
|
||||
- 生产入口:`https://ai.51easyai.com`
|
||||
- 部署目录:`/root/easyai-ai-gateway-deploy`
|
||||
- 发布清单:`/root/easyai-ai-gateway-deploy/.releases`
|
||||
- 数据库备份:`/var/backups/easyai-ai-gateway`
|
||||
- 公网入口:`https://ai.51easyai.com`
|
||||
|
||||
源码仓只执行质量 CI:PR context 是 `ci / verify (pull_request)`,`main` context 是 `ci / verify (push)`,版本 Tag 使用独立 workflow/context `release-ci / verify-tag (push)`。三者都执行生产迁移兼容门禁、Go 格式/vet/test/govulncheck、前端 lint/test/build、依赖审计、Compose 校验、Trivy 仓库扫描和 CI 脚本自测。Job 没有 Docker socket、`sudo` 或部署目录权限,也不构建发布镜像。
|
||||
## 阶段一:本地 publish
|
||||
|
||||
生产 CD 由部署仓的 root-owned dispatcher 执行。它在相同源码 SHA 上同时等待 `ci / verify (push)` 与本次新产生的 `release-ci / verify-tag (push)` 成功,然后只运行部署仓中固定的 BuildKit 构建、Trivy 镜像扫描、digest 发布、备份、迁移、健康检查和回滚命令;不得执行 Tag checkout 中的 `pnpm`、测试、shell 脚本或应用程序。
|
||||
|
||||
provision 安装 checksum-pinned 的官方静态 ShellCheck `0.11.0` 和 Docker Compose `5.3.1`,不会依赖宿主是否预装这些命令。所有下载都在使用前校验固定 SHA-256,并在精确 Node Job 镜像内实际执行版本检查。PostgreSQL 16 集成库作为固定 digest 的 Actions service container 运行,PR Job 不接收宿主或内层 Docker API。
|
||||
|
||||
## 首次安装或修复 CI Runner
|
||||
|
||||
前置条件:Linux x86_64、可用 Docker Engine、root 权限,以及 Docker 文件系统至少 8 GiB 可用空间。脚本只压缩本项目专属 `easyai-gateway-ci` Buildx 缓存,不运行全局 `docker system prune`,因为该服务器与其他服务共享 Docker 状态。
|
||||
|
||||
从 Gitea 仓库设置的 Actions Runner 页面取得一次性仓库注册 Token,在服务器可信的源码 checkout 中执行:
|
||||
前置条件:Docker Desktop/Engine、Buildx、Compose v2、Node、Go、可用 Registry 登录和生产只读 SSH。源码必须无 tracked/untracked 改动,HEAD 必须属于最新获取的 `origin/main` 历史。
|
||||
|
||||
```bash
|
||||
RUNNER_REGISTRATION_TOKEN='<一次性 Token>' ./scripts/provision-ci-runner.sh
|
||||
docker login --username=<your-aliyun-account> registry.cn-shanghai.aliyuncs.com
|
||||
./scripts/publish-release-images.sh --components auto
|
||||
```
|
||||
|
||||
已有有效 named volume 时可直接重跑,不再需要 Token:
|
||||
`auto` 读取线上当前 manifest,与 HEAD 比较后选择:
|
||||
|
||||
- `api`:API、Go workspace 或迁移变化;
|
||||
- `web`:Web、contracts、pnpm 或 Nginx 容器配置变化;
|
||||
- `all`:Dockerfile/Compose 等共享运行时变化;
|
||||
- `none`:无运行时变化,不产生发布物。
|
||||
|
||||
首次发布或线上状态不可验证时只能显式使用:
|
||||
|
||||
```bash
|
||||
./scripts/provision-ci-runner.sh
|
||||
./scripts/publish-release-images.sh --components all
|
||||
```
|
||||
|
||||
宿主无法直连 Docker Hub 时,可使用可信 HTTPS 镜像代理;Runner 来源仍必须携带脚本中完全相同的 manifest digest,内层 daemon 拉取的 Job 与 service 镜像也会继续校验工作流固定的 digest:
|
||||
此路径保守地把迁移标记为变化:生产助手会先备份数据库,再运行新 API 镜像内的幂等 migrator。
|
||||
|
||||
```bash
|
||||
CI_RUNNER_IMAGE_SOURCE='docker.m.daocloud.io/gitea/runner:2.0.0-dind-rootless@sha256:5b7b625ff773d0ee761788c47582503ec1b241fa5b81edebad48a57e663f4f3a' \
|
||||
CI_NESTED_DOCKER_REGISTRY_MIRROR='https://docker.m.daocloud.io' \
|
||||
./scripts/provision-ci-runner.sh
|
||||
```
|
||||
|
||||
注册 Token 通过交互 stdin 送给短命注册容器,不出现在宿主或容器的命令行参数中;provision 随后立即从环境中清除它,长期 systemd unit 也不包含 Token。不要输出或复制 `/data/.runner` 内容。脚本会停用旧 host Runner,并确保旧 Runner 用户和 v2 遗留用户不属于 `docker`/`sudo` 组。
|
||||
|
||||
默认磁盘门禁可用环境变量提高,但生产环境不应降低:
|
||||
|
||||
```bash
|
||||
CI_RUNNER_MIN_FREE_GIB=8 \
|
||||
CI_RUNNER_MIN_POST_INSTALL_FREE_GIB=4 \
|
||||
RUNNER_REGISTRATION_TOKEN='<一次性 Token>' \
|
||||
./scripts/provision-ci-runner.sh
|
||||
```
|
||||
|
||||
## AppArmor 分支
|
||||
|
||||
Ubuntu 22.04 默认没有 `kernel.apparmor_restrict_unprivileged_userns` 限制,当前服务器没有 `rootlesskit` profile 是受支持路径。provision 会实测 Docker 是否接受该 profile;不存在时写入:
|
||||
publish 会执行快速 Go 测试、迁移安全校验、`linux/amd64` 构建、临时完整栈迁移和 API simulation 冒烟。全部通过后才推送完整 SHA Tag,并生成带 SHA-256 内容完整性字段的固定 schema manifest:
|
||||
|
||||
```text
|
||||
RUNNER_SECURITY_OPTIONS=""
|
||||
dist/releases/<40 位 Git SHA>.json
|
||||
```
|
||||
|
||||
如果新系统的 `/proc/sys/kernel/apparmor_restrict_unprivileged_userns` 为 `1`,则必须先由宿主发行版或 Docker 安装包提供并加载 `rootlesskit` profile;provision 只探测和使用,不会自行复制一份 profile。缺失时 provision 会停止;不得手工关闭该 sysctl,也不得使用 `apparmor=unconfined` 绕过。
|
||||
成功输出必须包含 `production_changed=false`。此时 Agent 应报告结果并停止,不得继续上线。
|
||||
|
||||
参考:[Docker Rootless Docker-in-Docker](https://docs.docker.com/engine/security/rootless/tips/#rootless-docker-in-docker)、[Ubuntu 安全特性版本矩阵](https://documentation.ubuntu.com/security/security-features/security-features-overview/)。
|
||||
## 阶段二:人工 deploy
|
||||
|
||||
## Runner 真实验证
|
||||
|
||||
安装脚本会先启动一个无 Token、无持久 Runner 状态挂载、无宿主 bind mount 的短命 rootless DinD probe;随后启动长期服务,确认 Runner 已向 Gitea declare、通过内层 `docker info` 验证 `name=rootless`,再从内层 daemon 拉取固定 digest 的 Job 镜像,并在该镜像中实际运行全部工具。人工复核:
|
||||
收到用户再次确认后执行:
|
||||
|
||||
```bash
|
||||
systemctl is-active easyai-gateway-ci-v2-runner.service
|
||||
systemctl is-enabled easyai-gateway-ci-v2-runner.service
|
||||
systemctl cat easyai-gateway-ci-v2-runner.service
|
||||
journalctl -u easyai-gateway-ci-v2-runner.service --since '15 minutes ago' --no-pager
|
||||
|
||||
docker inspect -f 'user={{.Config.User}} privileged={{.HostConfig.Privileged}} apparmor={{.AppArmorProfile}}' \
|
||||
easyai-gateway-ci-v2-runner
|
||||
docker inspect -f '{{range .Mounts}}{{println .Source " -> " .Destination .Mode}}{{end}}' \
|
||||
easyai-gateway-ci-v2-runner
|
||||
docker exec easyai-gateway-ci-v2-runner docker info \
|
||||
--format '{{range .SecurityOptions}}{{println .}}{{end}}'
|
||||
docker exec easyai-gateway-ci-v2-runner docker image inspect \
|
||||
'docker.io/library/node:24.16.0-bookworm@sha256:40ad9f3064e67d6860b4bc3fe1880b2953934fd6320ada990e45fe0efa6badd7' \
|
||||
--format '{{.Id}}'
|
||||
./scripts/deploy-production-release.sh dist/releases/<40 位 Git SHA>.json
|
||||
```
|
||||
|
||||
预期:外层 image user 为 `rootless`、外层因 DinD 为 `privileged=true`、内层安全选项含 `name=rootless`;RootlessKit 使用独立 PID namespace,内层 `--pid=host` 也不能看到外层 Runner;挂载仅包含专用 `/data`、只读配置和只读工具链,不能出现 `/var/run/docker.sock` 或 `/run/docker.sock`。
|
||||
客户端和生产助手都会确认当前生产 SHA 仍等于 manifest 的 `baseReleaseSha`。不相等说明 publish 之后生产发生变化,必须重新 publish,不能覆盖或使用强制参数。
|
||||
|
||||
在 Gitea 中分别验证:
|
||||
助手随后验证:
|
||||
|
||||
1. 同仓 PR 的 `ci / verify (pull_request)` 成功。
|
||||
2. `main` Push 的 `ci / verify (push)` 成功。
|
||||
3. 合法 SemVer Tag 新产生独立 `release-ci / verify-tag (push)`;非法或不属于 `main` 历史的 Tag 失败,且 Tag context 不能被旧 `main` context 替代。
|
||||
4. Fork PR 默认不自动调度,维护者先检查 `.gitea/workflows/**` 和容器选项后再批准。
|
||||
- API/Web 均为完整 Registry digest;
|
||||
- 镜像为 `linux/amd64`;
|
||||
- 本次变化组件的 OCI revision 等于 release SHA;
|
||||
- 迁移变化时备份可被 `pg_restore -l` 读取;
|
||||
- 内部 API health/readiness/OpenAPI;
|
||||
- Web 页面和 Web API 反代;
|
||||
- 公网 health/readiness/OpenAPI。
|
||||
|
||||
PR 与 `main` Push workflow 都校验事件携带的 base/before SHA 必须是触发 SHA 的祖先;分支落后或主干历史被改写时 CI 会 fail-closed。PR 先合并最新 `main` 再重跑,Gitea 分支保护同时启用 `block_on_outdated_branch`。
|
||||
失败时自动恢复上一组应用 digest 并重复探活。已经执行的 expand-only 数据库迁移不会自动恢复。
|
||||
|
||||
## Fork PR 残余风险
|
||||
|
||||
rootless DinD 不是 VM 沙箱。外层仍按官方要求使用 `--privileged`,Gitea runner 也会解析 PR 控制的 `jobs.<job>.container.options`。配置已禁止 Job privileged、宿主/内层 Docker socket 和任意 bind source,但 Runner、rootless Docker、容器运行时或内核漏洞仍可能突破边界;Job 的出站网络和资源消耗也不是强租户隔离。因此 Fork PR 必须维护者批准,CI 不得注入生产 Secret;未来应迁移到独立 CI 主机或虚拟机。
|
||||
|
||||
## 数据库迁移兼容门禁
|
||||
|
||||
`deploy/ci/production-migration-base` 必须等于当前生产 API 对应的完整源码 SHA。`scripts/ci-validate-migrations.mjs` 只允许新增、按文件名严格追加且为普通文件的迁移;生产已知迁移以及已经进入 `main` 的迁移不可修改或删除。DROP、TRUNCATE、DELETE、MERGE、CREATE OR REPLACE、动态 EXECUTE、事务控制、DO/函数/过程定义、RENAME、列类型变更、增加/设置 `NOT NULL`、删除默认值和 REVOKE 会直接使 CI 失败。需要过程式迁移时先拆成可审计的声明式 expand/contract SQL;不能把过程体藏在普通或 dollar-quoted 字符串中。
|
||||
|
||||
该规则允许 INSERT/UPDATE 等数据迁移,因此不能替代备份和恢复验证。每次生产发布成功并完成 `pg_restore` 演练后,创建一个只前移 `deploy/ci/production-migration-base` 的受审 PR;不要把基线指向尚未成功部署的提交。
|
||||
|
||||
## 发布生产版本
|
||||
|
||||
1. 合并到受保护 `main`,确认源码 CI 成功。
|
||||
2. 在已验证提交上创建完整 SemVer Tag 并推送。
|
||||
3. 确认同一 SHA 的 `ci / verify (push)` 和本次 `release-ci / verify-tag (push)` 都成功。
|
||||
4. 由部署仓 root-owned dispatcher 构建、扫描并发布 digest;源码仓工作流不会直接调用生产 helper。
|
||||
5. 发布、健康检查和恢复演练完成后,用单独 PR 把 `deploy/ci/production-migration-base` 更新为该生产 Tag 的完整源码 SHA。
|
||||
## 状态与回滚
|
||||
|
||||
```bash
|
||||
git switch main
|
||||
git pull --ff-only
|
||||
git tag -a v0.1.1 -m 'release: v0.1.1'
|
||||
git push origin v0.1.1
|
||||
./scripts/deploy-production-release.sh --status
|
||||
./scripts/deploy-production-release.sh --rollback <服务器已有的历史 SHA>
|
||||
```
|
||||
|
||||
dispatcher 以完整 Git SHA 发布 Registry Tag,并把 Registry 返回的 digest 写入服务器 root-only 发布清单。生产 Compose 使用 `repository@sha256:...`,不是 `latest`、版本号或可变 SHA Tag。
|
||||
回滚也需要用户明确指令。服务器只接受 `.releases/<SHA>.json` 中已有版本,拒绝远端任意 Tag、`latest` 或临时镜像。
|
||||
|
||||
## 发布后验证
|
||||
## 故障排查
|
||||
|
||||
宿主 Nginx 的 `ai.51easyai.com` TLS server 必须包含仓库中的 `deploy/nginx/ai.51easyai.com-api-v1.inc` 等价规则,保留完整 URI 转发到 `127.0.0.1:8088`。修改前先备份现有配置,执行 `nginx -t` 成功后才能 reload。
|
||||
|
||||
```bash
|
||||
curl -fsS https://ai.51easyai.com/api/v1/healthz
|
||||
curl -fsS https://ai.51easyai.com/api/v1/readyz
|
||||
ssh root@110.42.51.33 'cd /root/easyai-ai-gateway-deploy && ./gateway-ops.sh ps'
|
||||
```
|
||||
|
||||
同时检查服务器磁盘、API 日志和数据库容器健康状态。发布后的首小时如出现错误率翻倍、P95 延迟增加 50%、数据完整性或安全问题,应立即回滚。
|
||||
|
||||
## 应用与数据库回滚
|
||||
|
||||
发布失败时固定部署脚本会恢复上一组 API/Web digest 并重复健康检查。人工回滚只选择服务器已有 root-only 发布清单的历史 Git SHA:
|
||||
|
||||
```bash
|
||||
sudo /usr/local/sbin/easyai-ai-gateway-release <40 位历史 Git SHA>
|
||||
```
|
||||
|
||||
没有 `/root/easyai-ai-gateway-deploy/.releases/<SHA>.env` 的远端 SHA Tag 不应被盲目信任。数据库备份位于 `/var/backups/easyai-ai-gateway`,只保留最近 5 份。应用回滚不会自动恢复数据库;只有停止写入并再次保留当前副本后,操作员才能选择备份执行 `pg_restore`。
|
||||
|
||||
## Runner 故障恢复
|
||||
|
||||
先在 Gitea 确认没有运行中的 Job,再检查服务与两个 daemon:
|
||||
|
||||
```bash
|
||||
systemctl status easyai-gateway-ci-v2-runner.service --no-pager
|
||||
docker logs --tail 100 easyai-gateway-ci-v2-runner
|
||||
docker exec easyai-gateway-ci-v2-runner docker info
|
||||
df -h /var/lib/docker
|
||||
```
|
||||
|
||||
确认空闲后才执行:
|
||||
|
||||
```bash
|
||||
systemctl restart easyai-gateway-ci-v2-runner.service
|
||||
journalctl -u easyai-gateway-ci-v2-runner.service -n 100 --no-pager
|
||||
```
|
||||
|
||||
不要删除 `easyai-gateway-ci-v2-data` volume;它包含 Runner 注册状态和内层 daemon 缓存。不得用全局 prune 处理磁盘压力。
|
||||
- publish 前失败:没有推送镜像,也没有可部署 manifest;修复本地环境后重试。
|
||||
- 镜像已经部分推送但 manifest 未生成:SHA Tag 被视为占用,先审查 Registry 中的 digest,不得覆盖重推。
|
||||
- deploy 报基线过期:重新读取线上状态并重新 publish。
|
||||
- 迁移备份失败:生产不切换。
|
||||
- 生产探活和自动应用回滚都失败:停止发布,保留日志、当前数据库和 release manifest,人工恢复服务;不得自动执行 `pg_restore`。
|
||||
|
||||
Reference in New Issue
Block a user