easyai-ai-gateway/docs/decisions/001-production-cicd.md
chengcheng 91ea0e6a2d
Some checks failed
ci / verify (pull_request) Failing after 4m18s
ci: harden production quality gates
2026-07-17 14:23:49 +08:00

69 lines
6.2 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.

# ADR-001: 隔离质量 CI 与 root-owned 生产发布
## 状态
Accepted
## 日期
2026-07-17
## 背景
AI Gateway 已通过独立部署仓库和 Docker Compose 运行在 `110.42.51.33`,公共入口是 `https://ai.51easyai.com`。同一服务器还运行认证中心、K3s 和其他 EasyAI 服务磁盘与信任边界都很紧张。Gitea 1.22 对依赖 Job、`environment`、`permissions` 和 `concurrency` 的支持也不能作为生产发布的安全边界。
源码仓需要为 Pull Request、`main` 和版本 Tag 提供真实质量门禁;生产发布还必须满足源码可追溯、镜像不可变、数据库迁移前有备份、失败可恢复上一应用版本,以及 Tag 控制的脚本不能直接取得宿主 root 权限。
## 决策
### 质量 CI
- `BCAI/easyai-ai-gateway` 使用仓库级 Runner 标签 `easyai-gateway-ci-unprivileged-v2`。PR 与 `main``ci.yml` 执行context 分别为 `ci / verify (pull_request)`、`ci / verify (push)``v*` Tag 必须由独立 `release-ci.yml` 执行context 为 `release-ci / verify-tag (push)`。两份 workflow 执行相同质量门禁,但 Tag 不复用 `main` 的旧 context。
- Runner 外层固定为 `gitea/runner:2.0.0-dind-rootless` 的指定 digestJob 固定为 `node:24.16.0-bookworm` 的指定 digest。外层按官方 rootless DinD 要求使用 `--privileged`,但内层 Docker daemon 和 Runner 以 UID 1000 运行。
- 不把宿主或内层 Docker socket 挂入 Job。Runner 配置使用 `docker_host: "-"`、`privileged: false`,工作流唯一允许的 bind source 是只读 `/opt/easyai-gateway-ci`,其中提供固定 Go、Node、pnpm、ShellCheck、Compose、Trivy 和 govulncheck 工具链。ShellCheck 与 Compose 使用官方静态发行物和固定 SHA-256不复制宿主的可变插件或在 Job 中临时安装。RootlessKit 强制 `--pidns`,即使 PR 传入 `--pid=host`Job 也不能进入外层 Runner 的 PID namespaceprovision 会实测 namespace inode。
- CI 只执行格式、静态检查、单元测试、依赖审计、Compose 校验、仓库扫描和 CI 自测;不构建发布镜像,不调用 `sudo`,不执行生产发布助手。
- 版本 Tag 必须是完整 SemVer且目标提交必须属于 `main` 历史。该检查是发布前置证据,但本身不授予生产权限。
- Runner 注册状态保存在专用 Docker named volume首次注册 Token 只传给短命注册容器;长期 systemd 服务不保存 Token也不加入宿主 `docker`/`sudo` 用户组。
### 生产 CD
- 生产发布由部署仓的 root-owned dispatcher 负责,和源码 CI Runner 分离。dispatcher 只接受受保护的 `main` 历史版本 Tag并在对应源码 SHA 上同时等待 `ci / verify (push)` 与本次 Tag 新产生的 `release-ci / verify-tag (push)` 成功。
- root 服务不执行 Tag 控制的 `pnpm`、测试脚本、源码仓 shell 脚本或应用程序。它只调用部署仓中由 root 固定和审核的命令,通过 BuildKit 构建目标镜像、用 Trivy 扫描镜像、发布 Registry digest再进行备份、迁移、健康检查和回滚。
- API/Web 镜像以完整 Git SHA 作为可追溯 Tag但生产 Compose 使用 Registry digest。服务器为每个 Git SHA 保存 root-only digest 清单,并拒绝把未知的既有 SHA Tag 当作可信发布物。
- 发布助手在迁移前生成并验证 PostgreSQL custom-format 备份,保留最近 5 份。应用健康检查失败时恢复上一组 digest-pinned 镜像;数据库恢复仍需人工确认。
### 残余风险与触发策略
rootless DinD 显著缩小了 Job 到宿主 Docker/root 的直接通路,但不是虚拟机级隔离。外层容器仍需 `--privileged`Runner、rootless Docker、内核或容器运行时漏洞以及 Gitea 对 `jobs.<job>.container.options` 的透传都可能造成容器边界突破。Job 还保留依赖安装所需的出站网络,并可能消耗共享主机的 CPU、内存和磁盘。因此
- 来自同仓受信任分支的 PR 可自动运行;来自 Fork 的 PR 必须先由维护者检查工作流变更并在 Gitea 中批准,不能自动调度。
- CI Job 不接收生产 Secret、宿主 Docker socket、部署目录或 Runner 状态目录。
- Runner 容量固定为 1外层容器设 4096 PID 上限;磁盘在安装前后硬门禁,但共享主机仍不具备完整的 CPU、内存和长期磁盘配额隔离。
- 中长期应把不受信任 PR CI 迁移到独立主机或虚拟机;当前 rootless Runner 不能被描述为强多租户沙箱。
- Ubuntu 22.04 没有受限 user namespace生产主机缺少 `rootlesskit` AppArmor profile 时不硬编码该 profile。provision 会先探测;在启用了 `kernel.apparmor_restrict_unprivileged_userns=1` 的新系统上,如果 profile 缺失则失败,不会降级为 `apparmor=unconfined` 或关闭内核限制。
## 备选方案
### 让源码 Runner 直接挂载宿主 Docker socket
拒绝。Docker socket 等价于宿主 root任何 PR 工作流都可能取得生产服务器控制权Fork 审批也不能把它变成可靠的技术隔离。
### 使用 host executor 但移除 Docker 组
拒绝。host Job 仍可读取宿主可见文件、消耗宿主资源并攻击同一用户进程Gitea 对容器选项的处理也无法为 host executor 提供隔离。
### 让 Tag 工作流通过 `sudo` 调用发布脚本
拒绝。Tag 可以控制 checkout 中的脚本和应用代码root 服务不得执行这些内容。固定部署仓 dispatcher 是独立的受保护信任根。
### `main` 通过后立即发布或只依赖 `latest`
拒绝。语义版本 Tag 是明确的发布授权点digest 才能证明运行中的容器对应不可变产物并可靠选择回滚版本。
## 影响
- PR、`main` 和版本 Tag 都有自动质量证据,但源码仓 CI 成功不会单独获得生产权限。
- 创建受保护版本 Tag 后,部署仓 dispatcher 必须同时取得同 SHA 的 `main` context 和独立 Tag context不能把旧 `main` 成功状态误当作本次 Tag 验证。
- 首次安装会清理本项目专属 Buildx 缓存并执行 8 GiB 前置、4 GiB 后置磁盘门禁,不会全局 prune 共享 Docker 状态。
- 首次启用 CD 仍需捕获当前运行镜像作为 bootstrap 回滚基线,并保证数据库迁移对上一应用版本向后兼容。