# 生产 CI/CD 运行手册 ## 信任边界与固定资源 - 源码仓:`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` 源码仓只执行质量 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` 或部署目录权限,也不构建发布镜像。 生产 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 镜像内实际执行版本检查。 ## 首次安装或修复 CI Runner 前置条件:Linux x86_64、可用 Docker Engine、root 权限,以及 Docker 文件系统至少 8 GiB 可用空间。脚本只压缩本项目专属 `easyai-gateway-ci` Buildx 缓存,不运行全局 `docker system prune`,因为该服务器与其他服务共享 Docker 状态。 从 Gitea 仓库设置的 Actions Runner 页面取得一次性仓库注册 Token,在服务器可信的源码 checkout 中执行: ```bash RUNNER_REGISTRATION_TOKEN='<一次性 Token>' ./scripts/provision-ci-runner.sh ``` 已有有效 named volume 时可直接重跑,不再需要 Token: ```bash ./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;不存在时写入: ```text RUNNER_SECURITY_OPTIONS="" ``` 如果新系统的 `/proc/sys/kernel/apparmor_restrict_unprivileged_userns` 为 `1`,则必须先由宿主发行版或 Docker 安装包提供并加载 `rootlesskit` profile;provision 只探测和使用,不会自行复制一份 profile。缺失时 provision 会停止;不得手工关闭该 sysctl,也不得使用 `apparmor=unconfined` 绕过。 参考:[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/)。 ## 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}}' ``` 预期:外层 image user 为 `rootless`、外层因 DinD 为 `privileged=true`、内层安全选项含 `name=rootless`;RootlessKit 使用独立 PID namespace,内层 `--pid=host` 也不能看到外层 Runner;挂载仅包含专用 `/data`、只读配置和只读工具链,不能出现 `/var/run/docker.sock` 或 `/run/docker.sock`。 在 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/**` 和容器选项后再批准。 PR 与 `main` Push workflow 都校验事件携带的 base/before SHA 必须是触发 SHA 的祖先;分支落后或主干历史被改写时 CI 会 fail-closed。PR 先合并最新 `main` 再重跑,Gitea 分支保护同时启用 `block_on_outdated_branch`。 ## Fork PR 残余风险 rootless DinD 不是 VM 沙箱。外层仍按官方要求使用 `--privileged`,Gitea runner 也会解析 PR 控制的 `jobs..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 ``` dispatcher 以完整 Git SHA 发布 Registry Tag,并把 Registry 返回的 digest 写入服务器 root-only 发布清单。生产 Compose 使用 `repository@sha256:...`,不是 `latest`、版本号或可变 SHA Tag。 ## 发布后验证 ```bash curl -fsS https://ai.51easyai.com/gateway-api/healthz curl -fsS https://ai.51easyai.com/gateway-api/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/.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 处理磁盘压力。