easyai-ai-gateway/docs/decisions/001-production-cicd.md
chengcheng f226f9c953
All checks were successful
ci / verify (pull_request) Successful in 8m50s
ci: enforce production-safe database migrations
2026-07-17 16:59:50 +08:00

7.0 KiB
Raw Blame History

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、environmentpermissionsconcurrency 的支持也不能作为生产发布的安全边界。

源码仓需要为 Pull Request、main 和版本 Tag 提供真实质量门禁;生产发布还必须满足源码可追溯、镜像不可变、数据库迁移前有备份、失败可恢复上一应用版本,以及 Tag 控制的脚本不能直接取得宿主 root 权限。

决策

质量 CI

  • BCAI/easyai-ai-gateway 使用仓库级 Runner 标签 easyai-gateway-ci-unprivileged-v2。PR 与 mainci.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=hostJob 也不能进入外层 Runner 的 PID namespaceprovision 会实测 namespace inode。
  • CI 只执行格式、静态检查、单元测试、依赖审计、Compose 校验、仓库扫描和 CI 自测;不构建发布镜像,不调用 sudo,不执行生产发布助手。
  • PR、main 与 Tag CI 都把 deploy/ci/production-migration-base 中的当前生产提交作为数据库兼容基线:已经存在于生产基线的迁移不得修改或删除,新迁移静态拒绝 DROP、TRUNCATE、DELETE、MERGE、CREATE OR REPLACE、RENAME、列类型/非空收紧等 contract 操作。PR/main 还使用事件中不可变的 base/before SHA 比对,确保基线更新稍有滞后时,已经进入 main 的新迁移仍不可被改写。
  • 版本 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 的直接通路,但不是虚拟机级隔离。外层容器仍需 --privilegedRunner、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 回滚基线,并保证数据库迁移对上一应用版本向后兼容。
  • 每次生产发布和恢复演练成功后,必须用单独受审 PR 把 deploy/ci/production-migration-base 前移到刚刚部署的完整源码 SHA不得在发布成功前前移。静态门禁不能证明任意数据变换可逆生产发布仍必须先做可恢复备份并定期执行真实 pg_restore 演练。