diff --git a/.gitea/workflows/deploy-production.yaml b/.gitea/workflows/deploy-production.yaml index 54c775f..d53f81e 100644 --- a/.gitea/workflows/deploy-production.yaml +++ b/.gitea/workflows/deploy-production.yaml @@ -6,23 +6,60 @@ on: jobs: deploy: - # Reuse the runner that deploys zhixing-system to the same Docker host. + # Build on the runner; the production Docker daemon is reached only over SSH. runs-on: ubuntu-latest + env: + IMAGE_PREFIX: ${{ vars.IMAGE_PREFIX }} + DEPLOY_TAG: ${{ gitea.sha }} steps: - name: Checkout uses: https://github.com/actions/checkout@v4 with: persist-credentials: false - - name: Build, migrate and deploy + - name: Build and push to Gitea Packages shell: bash env: + REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }} + REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} + BUILD_PLATFORM: ${{ vars.BUILD_PLATFORM }} + run: | + set -Eeuo pipefail + umask 077 + : "${IMAGE_PREFIX:?Set IMAGE_PREFIX to registry/owner/image (without a scheme or tag)}" + : "${DEPLOY_TAG:?Missing commit SHA}" + : "${REGISTRY_USERNAME:?Set REGISTRY_USERNAME}" + : "${REGISTRY_PASSWORD:?Set REGISTRY_PASSWORD}" + registry="${IMAGE_PREFIX%%/*}" + auth_dir=$(mktemp -d) + trap 'rm -rf -- "$auth_dir"' EXIT + export DOCKER_CONFIG="$auth_dir" + printf '%s' "$REGISTRY_PASSWORD" | docker login "$registry" --username "$REGISTRY_USERNAME" --password-stdin + unset REGISTRY_PASSWORD + docker build --pull --load --platform "${BUILD_PLATFORM:-linux/amd64}" \ + --label "org.opencontainers.image.revision=$DEPLOY_TAG" \ + -f Dockerfile.backend -t "$IMAGE_PREFIX-backend:$DEPLOY_TAG" . + docker build --pull --load --platform "${BUILD_PLATFORM:-linux/amd64}" \ + --label "org.opencontainers.image.revision=$DEPLOY_TAG" \ + -f Dockerfile.frontend -t "$IMAGE_PREFIX-web:$DEPLOY_TAG" . + docker push "$IMAGE_PREFIX-backend:$DEPLOY_TAG" + docker push "$IMAGE_PREFIX-web:$DEPLOY_TAG" + - name: Pull, migrate and deploy on server B + shell: bash + env: + PROD_HOST: ${{ secrets.PROD_HOST }} + PROD_PORT: ${{ secrets.PROD_PORT }} + PROD_USER: ${{ secrets.PROD_USER }} + DEPLOY_PATH: ${{ vars.DEPLOY_PATH }} + PROD_SSH_KEY: ${{ secrets.PROD_SSH_KEY }} + PROD_KNOWN_HOSTS: ${{ secrets.PROD_KNOWN_HOSTS }} + REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }} + REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} DATABASE_URL: ${{ secrets.DATABASE_URL }} ADMIN_PASSWORD: ${{ secrets.ADMIN_PASSWORD }} WQ_EMAIL: ${{ secrets.WQ_EMAIL }} WQ_PASSWORD: ${{ secrets.WQ_PASSWORD }} ENCRYPTION_KEY: ${{ secrets.ENCRYPTION_KEY }} ADMIN_USERNAME: ${{ vars.ADMIN_USERNAME }} - DATABASE_NETWORK: ${{ vars.DATABASE_NETWORK }} PUBLIC_ORIGIN: ${{ vars.PUBLIC_ORIGIN }} MCP_ENABLED: ${{ vars.MCP_ENABLED }} - run: bash scripts/deploy-production.sh + run: bash scripts/deploy-remote.sh diff --git a/.scratch/gitea-deployment/issues/01-remote-deployment.md b/.scratch/gitea-deployment/issues/01-remote-deployment.md new file mode 100644 index 0000000..5f70915 --- /dev/null +++ b/.scratch/gitea-deployment/issues/01-remote-deployment.md @@ -0,0 +1,30 @@ +# 两机镜像部署 + +Type: task +Status: ready-for-agent + +## 要求 + +- Runner 构建 backend/web 并推送 A 的 Gitea Packages,两个镜像成功后通过 SSH 部署 B。 +- B 仅接收 Compose、部署脚本和环境,拉取镜像后预检、互斥迁移及健康启动。 +- 数据库位于 C,B 通过 `DATABASE_URL` 连接 C 的内网地址,不再依赖 `DATABASE_NETWORK`。 +- 沿用 Gitea 应用配置及用户指定的 `PROD_SSH_KEY`、`PROD_HOST`、`PROD_USER` Secrets。 +- 保留原有本地/公网 Compose、数据库及单 worker 限制;更新部署说明。 + +## 验收 + +校验工作流与 Compose 配置、B 部署脚本的失败分支、SSH 环境特殊字符传输、临时认证文件清理。真实 A 推送及 B 部署不在本地验收中执行。 + +## Comments + +- 2026-09-24:已开始实施,真实服务器连接参数由用户在 Gitea 配置。 +- 2026-09-24:本地实现完成。生产 Compose 改为纯镜像引用;工作流构建推送后通过 SSH 传递部署文件和环境,B 拉取、预检、加锁、迁移并等待健康状态。说明已同步。 +- 2026-09-24:按用户确认适配 B 应用/C 数据库分离,移除外部数据库网络及 `DATABASE_NETWORK` 的传递、必填校验;说明和测试同步更新。未设置该变量时,Compose 5.3.1 默认/jobs profile 校验通过,backend/migrate/web 均使用默认网络,远程数据库 URL 保持一致;8 个必填配置拒绝、Bash/工作流检查和 8 项模拟部署测试通过。未连接真实 B/C,实际容器到 C 的连通性仍需部署验收。 + +## 初次镜像部署验证结果(数据库拆分前) + +- 本机 Docker Compose 5.3.1 默认/jobs profile 展开通过;确认所有服务无 build、backend 与 migrate 共用提交镜像、外部数据库网络及回环端口保留,9 个必填 Compose 配置缺失均拒绝。 +- Workflow YAML、SSH Secrets 映射及 Bash 语法检查通过;模拟构建步骤验证成功顺序与 login/build/push 失败传播、认证目录清理。 +- `python3 -m unittest discover -s scripts/tests -v`:8 项通过,覆盖前置失败保护旧服务、锁归属、迁移失败不启动、健康失败返回非零、缺失配置、SSH 特殊字符传递与环境隔离、远端失败传播和临时文件清理。 +- 独立只读核验完成;停服后失败仍需人工处理,沿用既有迁移策略,避免未知 schema 状态下自动回退。 +- `git diff --check` 通过。Dockerfile/业务代码未改变,未重建真实镜像;真实 Gitea 推送、B SSH 身份与权限、数据库迁移和公网健康仍需首次运行工作流验收。 diff --git a/.scratch/gitea-deployment/spec.md b/.scratch/gitea-deployment/spec.md index 9f94cd9..2a1ccc1 100644 --- a/.scratch/gitea-deployment/spec.md +++ b/.scratch/gitea-deployment/spec.md @@ -1,5 +1,17 @@ # Gitea 生产部署 +## 当前部署方式(2026-09-24) + +本节替代下方历史记录中的同机构建部署方案。A 运行 Gitea/Packages,Runner 构建并推送两个提交标签镜像,通过 SSH 在 B 的独立发布目录执行 pull、预检、互斥迁移及健康检查;生产 Compose 不含 build。现有数据库、项目名、端口和 Gitea 应用配置沿用。 + +数据库独立部署在 C,B 的 backend 和 migrate 通过 `DATABASE_URL` 使用 C 的内网地址。生产 Compose 使用默认网络,移除 `DATABASE_NETWORK` 外部网络声明及工作流、部署脚本的传递和必填校验。 + +SSH 采用用户指定的 Gitea Secrets:`PROD_SSH_KEY`、`PROD_HOST`、`PROD_USER`,补充主机公钥校验 `PROD_KNOWN_HOSTS` 与可选端口 `PROD_PORT`。镜像前缀、B 部署目录和构建平台可配置。应用环境经 SSH stdin 传递,不生成应用凭据文件;SSH 和 Docker 认证临时目录结束时清理。 + +任务与验证见 [两机镜像部署](issues/01-remote-deployment.md)。本任务只修改本地文件及进行隔离验证,不推送镜像或操作真实 B。 + +## 历史方案 + 采用已选择的 compound 经验 1,经当前项目核验:复用锁定依赖的 Dockerfile、单 worker 和数据库健康接口;新增独立生产 Compose、外部 PostgreSQL 配置、同机 host Runner 工作流。生产凭据通过 Gitea Secrets 注入,非敏感配置通过 Variables 注入,不生成凭据文件。数据库网络为可配置参数,不沿用旧项目常量作为强制约定。 部署在构建完成后停止写入、执行一次性迁移,再启动健康检查。固定项目名与按提交标记镜像;使用宿主机文件锁串行化。保留本地和独立公网部署的现有行为。不执行远程部署、不修改知识库。 diff --git a/README.md b/README.md index 107fa75..1474027 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ Chatbox 来源使用 `kind=chatbox`,会话 ID 为 `reference`,生成轮次 I ## Gitea 自动部署(复用已有 PostgreSQL) -使用独立的 `compose.production.yaml` 和 `.gitea/workflows/deploy-production.yaml`。配置步骤、数据库账号密码位置及升级处理见 [Gitea 部署说明](docs/deployment-gitea.md)。 +Gitea Runner 构建镜像并推送到服务器 A 的 Gitea Packages,再通过 SSH 到服务器 B 拉取镜像、迁移和部署;B 上的应用通过 `DATABASE_URL` 连接服务器 C 的内网 PostgreSQL。使用独立的 `compose.production.yaml` 和 `.gitea/workflows/deploy-production.yaml`。镜像仓库、SSH、数据库配置及升级处理见 [Gitea 部署说明](docs/deployment-gitea.md)。 ## 公网 HTTPS 部署 diff --git a/compose.production.yaml b/compose.production.yaml index a1df5aa..88535aa 100644 --- a/compose.production.yaml +++ b/compose.production.yaml @@ -13,10 +13,7 @@ x-container-logging: &container-logging max-file: "5" x-backend: &backend - image: wq-alpha-production-backend:${DEPLOY_TAG:-local} - build: - context: . - dockerfile: Dockerfile.backend + image: ${IMAGE_PREFIX:?Set the Gitea registry/owner/image prefix}-backend:${DEPLOY_TAG:?Set the published release tag} environment: DATABASE_URL: ${DATABASE_URL:?Set the Gitea DATABASE_URL secret} ADMIN_USERNAME: ${ADMIN_USERNAME:-admin} @@ -32,9 +29,6 @@ x-backend: &backend AI_OUTPUT_TOKENS: ${AI_OUTPUT_TOKENS:-4096} AI_TIMEOUT: ${AI_TIMEOUT:-180} WQ_BASE_URL: ${WQ_BASE_URL:-https://api.worldquantbrain.com} - networks: - - default - - database services: backend: @@ -67,10 +61,7 @@ services: <<: *observability-labels observability.service: "web" logging: *container-logging - image: wq-alpha-production-web:${DEPLOY_TAG:-local} - build: - context: . - dockerfile: Dockerfile.frontend + image: ${IMAGE_PREFIX:?Set the Gitea registry/owner/image prefix}-web:${DEPLOY_TAG:?Set the published release tag} environment: SITE_ADDRESS: http://:80 ports: @@ -89,10 +80,6 @@ services: start_period: 10s restart: unless-stopped -networks: - database: - external: true - name: ${DATABASE_NETWORK:?Set the existing PostgreSQL Docker network} volumes: caddy_data: caddy_config: diff --git a/docs/deployment-gitea.md b/docs/deployment-gitea.md index 2727759..3218489 100644 --- a/docs/deployment-gitea.md +++ b/docs/deployment-gitea.md @@ -1,78 +1,141 @@ # Gitea 生产部署 -本方案在目标 Linux 服务器上由已有 Gitea Runner 构建并启动 Docker Compose,复用已有 PostgreSQL。入口绑定 `127.0.0.1:8112`,由宿主机反向代理提供公网 HTTPS。现有 `compose.yaml`、`compose.public.yaml` 保持独立,不与生产文件叠加。 +服务器分工:**A 提供 Gitea 和 Packages 镜像仓库,B 运行应用,C 运行 PostgreSQL**,B 与 C 通过内网连接。Gitea Runner 构建前后端镜像并推送到 A,然后通过 SSH 在 B 拉取镜像、检查配置与数据库、执行迁移并启动服务。Runner 可以在 A 或其他构建机上,无需访问 B 的 Docker socket。 -## 1. 填写配置和数据库账号密码 +生产使用独立的 `compose.production.yaml`,只引用镜像,不在 B 构建。B 上的 backend 和 migrate 通过 `DATABASE_URL` 连接 C,使用 Compose 默认网络,不依赖外部数据库 Docker 网络。Web 绑定 `127.0.0.1:8112`,由 B 的宿主机反向代理提供 HTTPS。`compose.yaml`、`compose.public.yaml` 的本地构建方式不变,不与生产文件叠加。 -进入 **Gitea 仓库 → 设置 → Actions → Secrets / Variables**,按下表创建同名配置。敏感值填写在 Secrets,工作流仅在部署步骤通过环境变量注入,不生成服务器凭据文件。 +## 1. 配置 Gitea Secrets 和 Variables -| 位置 | 名称 | 填写内容 | +进入 **仓库 → 设置 → Actions → Secrets / Variables**。现有应用 Secrets 继续使用,新增镜像仓库和 SSH 配置。 + +### Secrets + +| 名称 | 必填 | 填写内容 | | --- | --- | --- | -| Secrets(必填) | `DATABASE_URL` | `postgresql+asyncpg://数据库账号:数据库密码@数据库网络别名:5432/数据库名` | -| Secrets(必填) | `ADMIN_PASSWORD` | 系统初始管理员密码,至少 12 字符,与数据库密码独立 | -| Secrets(必填) | `ENCRYPTION_KEY` | 下面命令生成的 Fernet 密钥,升级保持不变 | -| Secrets(必填) | `WQ_EMAIL` | WorldQuant 登录邮箱 | -| Secrets(必填) | `WQ_PASSWORD` | WorldQuant 登录密码,按原样填写,不加引号 | -| Variables(必填) | `DATABASE_NETWORK` | PostgreSQL 所在的现有 Docker 网络,例如 `1panel-network` | -| Variables(必填) | `PUBLIC_ORIGIN` | 实际 HTTPS 来源,例如 `https://alpha.your-domain.com`,不带路径或末尾 `/` | -| Variables(可选) | `ADMIN_USERNAME` | 初始管理员账号,默认 `admin` | -| Variables(可选) | `MCP_ENABLED` | 设置 `true` 启用 MCP;未配置时默认关闭 | +| `PROD_HOST` | 是 | B 的 SSH 主机名或 IP,不带协议、用户名和端口 | +| `PROD_USER` | 是 | B 的 SSH 登录用户,能直接执行 Docker 命令,无需交互式 sudo | +| `PROD_SSH_KEY` | 是 | 专用 SSH 私钥完整内容,保留换行;对应公钥放入 B 用户的 `~/.ssh/authorized_keys`;使用无需交互输入口令的部署密钥 | +| `PROD_KNOWN_HOSTS` | 是 | 经核验的 B 主机公钥,采用 OpenSSH `known_hosts` 格式,见下文 | +| `PROD_PORT` | 否 | B 的 SSH 端口,默认 `22` | +| `REGISTRY_USERNAME` | 是 | 有权访问镜像 owner 的 Gitea 用户名 | +| `REGISTRY_PASSWORD` | 是 | 该用户的 Gitea Personal Access Token,至少具备 `write:package` 权限(包含拉取权限),并有目标 owner 的包访问权限 | +| `DATABASE_URL` | 是 | `postgresql+asyncpg://数据库账号:数据库密码@C的内网IP或域名:数据库端口/数据库名`,须从 B 的容器内可连接 | +| `ADMIN_PASSWORD` | 是 | 初始管理员密码,至少 12 字符,与数据库密码独立 | +| `ENCRYPTION_KEY` | 是 | Fernet 密钥,升级必须保持不变 | +| `WQ_EMAIL` | 是 | WorldQuant 登录邮箱 | +| `WQ_PASSWORD` | 是 | WorldQuant 登录密码,按原样填写,不加引号 | -端口与 AI 限制使用 `compose.production.yaml` 的默认值,不需要在 Gitea 配置:`WEB_PORT=8112`、`AI_REQUEST_LIMIT=12`、`AI_TOOL_LIMIT=12`、`AI_OUTPUT_TOKENS=4096`、`AI_TIMEOUT=180`。需要调整时修改 Compose 中对应默认值;工作流不再读取这些同名 Gitea Variables。 +### Variables -数据库连接串示例(实际填写时替换示例值): +| 名称 | 必填 | 填写内容 | +| --- | --- | --- | +| `IMAGE_PREFIX` | 是 | `A的仓库地址/owner/镜像名前缀`,全小写,不带 `https://` 或标签;当前仓库可填 `tea.bcc-life.cn/sakibcc/worldquant-alpha-system` | +| `DEPLOY_PATH` | 是 | B 上的绝对部署目录,例如 `/opt/wq-alpha`;SSH 用户须可写 | +| `BUILD_PLATFORM` | 否 | B 的 CPU 平台,默认 `linux/amd64`;ARM64 服务器填 `linux/arm64` | +| `PUBLIC_ORIGIN` | 是 | 实际 HTTPS 来源,例如 `https://alpha.your-domain.com`,不带路径或末尾 `/` | +| `ADMIN_USERNAME` | 否 | 初始管理员账号,默认 `admin` | +| `MCP_ENABLED` | 否 | `true` 启用 MCP,默认关闭 | + +镜像命名遵循 Gitea 的 `registry/owner/image:tag` 规则。以上示例产生两个包: ```text -postgresql+asyncpg://wq_user:YOUR_PASSWORD@postgresql:5432/wq_alpha +tea.bcc-life.cn/sakibcc/worldquant-alpha-system-backend:<完整提交 SHA> +tea.bcc-life.cn/sakibcc/worldquant-alpha-system-web:<完整提交 SHA> ``` -生成加密密钥: +`REGISTRY_USERNAME` 是登录用户,`IMAGE_PREFIX` 中的 owner 是包所有者,两者在组织仓库中可能不同。Packages 属于该用户或组织,可在 Gitea 中关联到当前仓库。默认使用同一个 Token 推送和远程拉取,B 的部署进程因此也会接收该 Token。 + +主机公钥应从可信的 B 控制台取得,并与 SSH 扫描结果核对。例如在可信管理终端运行 `ssh-keyscan -p 22 B_HOST`,通过 B 的控制台核对其指纹后,将核验过的完整行保存为 `PROD_KNOWN_HOSTS`。非默认端口的主机字段应为 `[B_HOST]:PORT`;主机名必须与 `PROD_HOST` 一致。工作流严格校验主机身份,不会在每次部署时自动信任扫描结果。 + +生成新安装的加密密钥: ```bash python3 -c 'import base64,secrets; print(base64.urlsafe_b64encode(secrets.token_bytes(32)).decode())' ``` -`DATABASE_URL` 的用户名和密码中的特殊字符须做 URL 百分号编码,例如 `@` → `%40`、`#` → `%23`、`/` → `%2F`、`%` → `%25`。在 Gitea 输入框填写值本身,不添加包裹引号,不填写 `DATABASE_URL=` 前缀。管理员密码中的 `$`、引号等按原样填写,由环境变量传递,无需 shell 转义。不要在日志打印变量,或开启 `set -x`。 +`DATABASE_URL` 中账号和密码的特殊字符须 URL 编码,例如 `@` → `%40`、`#` → `%23`、`/` → `%2F`。在 Gitea 输入框填写值本身,不加包裹引号或 `KEY=` 前缀;其他密码中的 `$`、引号和换行原样填写,SSH 传递会进行 Bash 转义。不要开启 `set -x` 或打印凭据。 -在现有 PostgreSQL 管理界面先创建专用数据库与账号,账号须可连接并拥有该库中应用 schema 的建表和迁移权限。确认网络存在、数据库别名可解析、PostgreSQL 允许该 Docker 网络访问。应用使用异步驱动 `asyncpg`;远程数据库或强制 TLS 的实例需另外按该实例的 TLS 配置调整连接参数,此模板默认同机 Docker 网络。 +端口与 AI 限制继续使用 Compose 默认值:`WEB_PORT=8112`、`AI_REQUEST_LIMIT=12`、`AI_TOOL_LIMIT=12`、`AI_OUTPUT_TOKENS=4096`、`AI_TIMEOUT=180`。需要调整时修改 Compose 默认值;工作流不读取这些同名 Gitea Variables。 -如果从已有安装迁移数据,需恢复完整数据库并沿用原 `ENCRYPTION_KEY`;修改管理员环境变量不会重置已有管理员密码。 +`DATABASE_URL` 示例(假设 C 的内网 IP 为 `10.0.0.30`,按实际账号、密码、端口和库名替换): -WorldQuant 凭据由环境变量管理,启动时加密写入数据库;页面只保留连接操作。修改 `WQ_PASSWORD` 后重新运行部署即可更新。`WQ_EMAIL` 必须与已有绑定账户一致,避免混入其他账户数据;生产不读取 `account.json` 或本地 `.env`。 +```text +postgresql+asyncpg://wq_user:YOUR_PASSWORD@10.0.0.30:5432/wq_alpha +``` -## 2. 配置 Gitea Runner +本版本不再读取或校验 `DATABASE_NETWORK`,Gitea 中的旧变量可以删除。不要用 C 上的容器名或 Docker 网络名作为跨主机连接地址。 -沿用已成功部署 `zhixing-system` 的运行器标签 **`ubuntu-latest`**,无需注册新的 `wq-production` 运行器。该运行器的执行环境须能通过 Docker CLI/Compose 操作同一台 1Panel 服务器的 Docker daemon;可以复用现有 Docker 连接方式,不强制 host 模式。 +已有安装需沿用数据库和 `ENCRYPTION_KEY`;修改管理员环境变量不会重置已有密码。WorldQuant 凭据启动时加密写入数据库,`WQ_EMAIL` 必须与已有绑定账户一致;修改 `WQ_PASSWORD` 后重新部署即可更新。生产不读取 `account.json` 或 `.env`。 -执行环境需要 Git、Bash、Node.js 20(checkout v4)以及支持 `up --wait --wait-timeout` 的 Docker Compose。本流程不需要 `flock` 或预建 `/opt/wq-alpha`。服务器需能访问 checkout action、基础镜像仓库和依赖源。 +## 2. 准备 A、Runner、B 和 C -在仓库启用 Actions,`main` push 或手动运行会部署。Secrets 通过部署步骤的环境变量注入,`--env-file /dev/null` 防止误读开发 `.env`。构建可并行;构建完成后,以 Docker 唯一容器名 `wq-alpha-production-deploy-lock` 互斥保护预检、迁移和服务切换。锁容器不启动、不携带凭据,正常结束或失败时删除;竞争失败的任务不会删除其他任务的锁。 +### A:Gitea Packages -如果 Runner 被强制终止或 Docker 断连,可能留下锁容器。先确认没有本项目部署正在执行,再手动运行 `docker rm wq-alpha-production-deploy-lock` 后重试。不要在部署进行时删除锁。 +启用仓库 Actions 和 Gitea Packages。Runner 与 B 都须能解析并访问 `IMAGE_PREFIX` 中的仓库地址;HTTPS 证书须被两端 Docker 信任。Gitea 前的反向代理须正确转发 `/v2/` 并允许镜像层上传。本流程不自动修改 Docker 的 insecure registry 或 TLS 设置。 -## 3. 配置反向代理并首次运行 +### Runner:构建与 SSH -为 `PUBLIC_ORIGIN` 对应域名配置 HTTPS,转发至 **`http://127.0.0.1:8112`**(或你的 `WEB_PORT`)。代理须运行在宿主机网络中;独立 bridge 容器中的 `127.0.0.1` 不指向宿主机。若使用容器化 1Panel/OpenResty,先确认其网络模式。AI 流式响应需要关闭代理缓冲,并允许长连接/足够长的读取超时。 +继续使用 `ubuntu-latest` 标签,该标签对应的**实际执行环境**需要 Git、Bash、Node.js 20(checkout v4)、Docker CLI/Buildx、OpenSSH 客户端、tar、base64,并能连接构建机的 Docker daemon。容器模式 Runner 也需要具备这些工具和 Docker 连接能力。 -首次部署建议在 Gitea Actions 页面手动运行工作流。需要在服务器排障运行时,须先从安全渠道将上表必填配置注入当前进程环境,再执行: +Runner 需要访问 checkout action、基础镜像仓库、依赖源、A 的镜像仓库和 B 的 SSH 端口。`BUILD_PLATFORM` 必须匹配 B;若与构建机架构不同,需事先在构建机配置对应的跨架构构建能力。 + +### B:运行环境 + +安装 Bash、tar、base64、Docker Engine 和支持 `pull --policy`、`run --pull`、`up --wait --wait-timeout` 的 Docker Compose 插件。SSH 用户需要直接操作 B 的本机 Docker daemon;不需要 Git、Node.js、Python 或源码 checkout。脚本使用临时 `DOCKER_CONFIG`,不要依赖用户配置目录中的命名 Docker context;使用本机默认 socket。 + +在 B 创建部署目录并交给部署用户,例如将示例用户 `deploy` 换成实际的 `PROD_USER`: + +```bash +sudo install -d -m 700 -o deploy -g deploy /opt/wq-alpha +``` + +将部署公钥加入该用户的 `authorized_keys`,核对 B 的反向代理及应用容器到 C 数据库端口的连通性。B、C 主机内网互通不能替代容器内的连接验证。 + +### C:数据库 + +在 C 创建专用数据库和账号,赋予该库应用 schema 的建表与迁移权限。PostgreSQL 须监听内网可访问的接口,防火墙和 `pg_hba.conf` 须允许 B 的连接来源。默认 Docker bridge 出站通常使用 B 的主机地址,若配置了额外路由或 NAT,以 C 实际收到的来源地址为准。 + +若 PostgreSQL 运行在 C 的 Docker 容器中,须发布到 C 的内网地址,例如 `10.0.0.30:5432:5432`;`DATABASE_URL` 使用 C 的内网 IP 和发布端口。强制 TLS 的实例还需按数据库证书及连接要求配置客户端参数。部署流程只在 B 执行迁移,通过数据库连接访问 C,无需 C 的 SSH 凭据。 + +## 3. 部署流程与首次验收 + +`main` push 或手动运行工作流会依次执行: + +1. checkout 当前提交,在 Runner 构建 backend/web,分别推送到 A 的 Gitea Packages。两个镜像均推送成功才执行 SSH 部署。 +2. `scripts/deploy-remote.sh` 严格验证 B 主机公钥,通过同一 SSH 连接传入应用环境变量、生产 Compose 和部署脚本。 +3. B 为每次尝试创建 `DEPLOY_PATH/releases/<提交 SHA>.<随机后缀>/`,只保存 `compose.production.yaml` 和 `scripts/deploy-production.sh`,无需更新项目源码。 +4. B 登录 A 的仓库并拉取两个镜像。随后获得 Docker 部署锁,运行新 backend 镜像校验配置和数据库连接。 +5. 记录旧镜像 ID/标签,停止 web/backend,执行一次性迁移,再使用已拉取的镜像启动并等待健康检查。后端保持一个实例、一个 worker。 + +Compose 项目名固定为 `wq-alpha-production`,所以独立发布目录不会创建第二套应用或卷。迁移使用同一 backend 镜像;停止服务后不再访问仓库或构建。迁移或健康检查失败会让 SSH 和 Actions 返回失败。 + +应用凭据通过 SSH 标准输入传递,不写入发布目录或 `.env`;Compose 显式使用 `--env-file /dev/null`。SSH 私钥与 Docker 登录信息使用权限受限的临时目录,正常结束或失败时清理,不覆盖用户原有 Docker 登录配置。进程被强制杀死时可能残留临时文件;拥有 Runner、B 或 Docker 管理权限的人仍可读取进程和容器运行时配置。 + +Docker 唯一容器名 `wq-alpha-production-deploy-lock` 保护预检、迁移及服务切换;锁容器不运行、不携带凭据。并发部署竞争失败会退出,且只清理自己取得的锁。Runner 被强制终止或 Docker 断连时可能留下锁;确认没有部署正在执行后,可在 B 手动删除: + +```bash +docker rm wq-alpha-production-deploy-lock +``` + +B 的宿主机 HTTPS 反向代理应转发到 `http://127.0.0.1:8112`。独立 bridge 容器中的 `127.0.0.1` 不指向宿主机;使用 1Panel/OpenResty 时需确认其网络模式。AI 流式响应需要关闭代理缓冲,并设置足够的读取超时。 + +首次部署建议手动运行工作流,确认 Packages 中有两个提交标签、B 上容器健康,再通过公网检查 `/api/v1/health`、登录、写请求和 Cookie。容器健康检查不替代 DNS、HTTPS 和真实 Runner 验收。 + +## 4. 升级、备份和失败处理 + +每次升级包含停机窗口;提前暂停或结束长时间任务。发布前通过数据库管理工具备份专用库,并在独立安全位置备份加密密钥及必要配置。此工作流不会自动备份或自动恢复数据库。 + +构建、推送、建立 SSH 连接、拉取或预检阶段失败时不会停止旧服务。停止服务后的迁移、健康检查失败或 SSH 中断需要在 B 核对实际状态并人工处理;不要对可能已变更的 schema 自动降级。Actions 日志记录切换前的镜像 ID/标签,成功后输出当前提交。两端均不执行 image prune;保留需要回退的 Packages 标签及 B 上的发布目录。 + +提交标签用于定位版本;同一提交重跑会重新构建并覆盖同名标签,严格核对历史产物时使用日志中的镜像 ID/digest。回退须先确认旧代码与当前 schema 兼容,然后从安全渠道注入对应配置、`IMAGE_PREFIX`、旧 `DEPLOY_TAG` 和仓库凭据,在匹配旧版本的发布目录运行: ```bash bash scripts/deploy-production.sh ``` -固定 Compose 项目名为 `wq-alpha-production`,后端保持一个实例、一个 worker。脚本先验证 Compose,构建按 Git 提交标记的镜像,再检查密钥格式和数据库连通性;随后停止 web/backend、执行一次性迁移、启动服务并等待健康检查。Web 健康检查同时覆盖页面和经 Caddy 转发的数据库健康接口。迁移或启动失败时非零退出并显示容器状态,不继续标记成功。 +新流程产生的发布目录只含部署文件,手动执行仍需注入完整环境。若旧 schema 不兼容,先停止应用、人工确认后从经过验证的备份恢复数据库,再使用原加密密钥和对应旧版本启动;恢复会丢失备份后的写入。不要仅为回退重跑旧的“同机构建”工作流。 -首次部署完成后,检查公网 `/api/v1/health`,再通过真实域名登录并确认页面、写请求和 Cookie 正常。容器健康通过不能替代 HTTPS、DNS 和真实 Gitea Runner 验收。 - -## 4. 升级、备份和失败处理 - -升级包含停机窗口;提前结束或暂停长时间任务。每次发布前通过现有数据库管理工具备份专用库,并在独立安全位置备份加密密钥及必要配置,先在独立库验证恢复。此工作流不会自动备份或自动恢复数据库。 - -构建及预检失败时旧服务继续运行。停止服务后的迁移或健康检查失败需要人工处理;不要对可能已变更的 schema 直接自动降级。脚本在 Actions 日志输出切换前的镜像 ID/标签,成功后输出当前提交标识;保留旧镜像且不执行 prune。请保留部署日志作为回退参考,不再依赖宿主机版本记录文件。 - -如旧代码与当前 schema 兼容,可检出旧提交并指定其镜像标签启动;否则先停止应用,使用经过验证的备份恢复数据库,再用原加密密钥和对应旧版本启动。数据库恢复会丢失备份后的写入,必须人工确认后执行。本配置没有自动数据库降级,也不承诺无停机升级。 - -以下排查命令不依赖凭据环境变量(可能含业务信息的日志请勿公开): +以下命令在 B 执行,不依赖凭据环境变量: ```bash docker ps -a --filter label=com.docker.compose.project=wq-alpha-production @@ -80,13 +143,9 @@ docker logs --tail=100 wq-alpha-production-backend-1 docker logs --tail=100 wq-alpha-production-web-1 ``` -普通维护不要使用 `down -v`。这里的数据库由外部管理,备份与恢复应在数据库管理端进行。 +不要公开含业务信息的日志,普通维护不要使用 `down -v`。发布目录可在确认无需回退后人工清理,不影响 named volumes;数据库备份和恢复在数据库管理端进行。 -## 配置依据 - -采用 compound 经验 1 中的生产/开发隔离、显式外部网络、必填凭据、固定项目名、独立迁移和部署健康检查。未照搬旧项目端口、业务 Job 或卷权限初始化:本应用后端不写 named volume,已有镜像使用 UID 10001。 - -凭据存放在 Gitea Secrets,通过步骤级环境变量交给 Compose,不落地到配置文件。Docker 容器仍需持有运行时配置,因此拥有 Runner 或 Docker 管理权限的人仍可能读取它们。若此前手动创建了旧 `.env.production`,新流程不再读取它;确认配置已迁移到 Gitea 并妥善备份密钥后可自行移除旧文件。相关官方资料:[Compose 外部网络](https://docs.docker.com/reference/compose-file/networks/)、[环境变量插值](https://docs.docker.com/compose/how-tos/environment-variables/variable-interpolation/)、[Gitea Runner 标签](https://gitea.com/gitea/runner/src/branch/main/README.md)。 +配置依据:[Gitea Container Registry](https://docs.gitea.com/usage/packages/container/)、[Docker 登录](https://docs.docker.com/reference/cli/docker/login/)、[Compose pull](https://docs.docker.com/reference/cli/docker/compose/pull/)、[Compose run](https://docs.docker.com/reference/cli/docker/compose/run/)、[Compose up](https://docs.docker.com/reference/cli/docker/compose/up/)。 ## 5. 1Panel 夜间全量目录同步 diff --git a/scripts/deploy-production.sh b/scripts/deploy-production.sh index 7f7efa7..deca453 100644 --- a/scripts/deploy-production.sh +++ b/scripts/deploy-production.sh @@ -1,20 +1,20 @@ #!/usr/bin/env bash -# Deploy on the target Linux Docker host with configuration injected by Gitea. -# Returns nonzero on configuration/build/migration/health failure. Never removes data. +# Pull and deploy on server B with configuration injected over SSH by Gitea. +# Returns nonzero on configuration/pull/migration/health failure. Never removes data. set -Eeuo pipefail umask 077 cd "$(dirname "${BASH_SOURCE[0]}")/.." # Fail before touching the host; never read a checkout's local .env file. -for key in WQ_EMAIL WQ_PASSWORD DATABASE_URL ADMIN_PASSWORD ENCRYPTION_KEY DATABASE_NETWORK PUBLIC_ORIGIN; do +for key in IMAGE_PREFIX DEPLOY_TAG REGISTRY_USERNAME REGISTRY_PASSWORD WQ_EMAIL WQ_PASSWORD DATABASE_URL ADMIN_PASSWORD ENCRYPTION_KEY PUBLIC_ORIGIN; do if [[ -z "${!key:-}" ]]; then echo "Missing required Gitea configuration: $key" >&2 exit 1 fi done -export DEPLOY_TAG="${DEPLOY_TAG:-$(git rev-parse HEAD)}" compose=(docker compose --env-file /dev/null -p wq-alpha-production -f compose.production.yaml) lock_id="" +auth_dir="" cleanup() { local rc=$? "${compose[@]}" --profile jobs ps -a || true @@ -22,25 +22,34 @@ cleanup() { if [[ -n "$lock_id" ]]; then docker rm "$lock_id" >/dev/null || true fi + if [[ -n "$auth_dir" ]]; then + rm -rf -- "$auth_dir" + fi exit "$rc" } trap cleanup EXIT trap 'exit 130' INT trap 'exit 143' TERM +trap 'exit 129' HUP "${compose[@]}" config --quiet "${compose[@]}" --profile jobs config --quiet -"${compose[@]}" build backend web +auth_dir=$(mktemp -d) +export DOCKER_CONFIG="$auth_dir" +printf '%s' "$REGISTRY_PASSWORD" | docker login "${IMAGE_PREFIX%%/*}" --username "$REGISTRY_USERNAME" --password-stdin +unset REGISTRY_PASSWORD +# Download both images before stopping anything; migration uses the backend image. +"${compose[@]}" pull --policy always backend web # Docker enforces unique container names across runner jobs and checkout paths. # The lock container is never started and receives no deployment credentials. if ! lock_id=$(docker create --name wq-alpha-production-deploy-lock \ --label "wq.deploy.commit=$DEPLOY_TAG" \ - --network none --entrypoint /bin/true "wq-alpha-production-backend:$DEPLOY_TAG"); then + --network none --entrypoint /bin/true "$IMAGE_PREFIX-backend:$DEPLOY_TAG"); then echo 'Cannot acquire deployment lock; check Docker and other active deployments.' >&2 exit 1 fi # Validate secrets and DB connectivity before interrupting the running version. -"${compose[@]}" run --rm --no-deps backend python -c ' +"${compose[@]}" run --rm --no-deps --pull never -T backend python -c ' import asyncio from app.config import Settings from sqlalchemy import text @@ -66,6 +75,6 @@ if [[ -n "$previous_images" ]]; then docker image inspect --format '{{.Id}} {{json .RepoTags}}' $previous_images fi "${compose[@]}" stop web backend -"${compose[@]}" --profile jobs run --rm --no-deps migrate -"${compose[@]}" up -d --no-build --remove-orphans --wait --wait-timeout 180 backend web +"${compose[@]}" --profile jobs run --rm --no-deps --pull never -T migrate +"${compose[@]}" up -d --no-build --pull never --remove-orphans --wait --wait-timeout 180 backend web echo "Production is healthy; release $DEPLOY_TAG" diff --git a/scripts/deploy-remote.sh b/scripts/deploy-remote.sh new file mode 100644 index 0000000..d8e5831 --- /dev/null +++ b/scripts/deploy-remote.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# Send a release bundle and environment to server B. No application .env is written. +# Requires Bash/OpenSSH locally and Bash/tar/base64/Docker Compose on server B. +# Returns the remote deployment status; credentials never appear in SSH arguments. +set -Eeuo pipefail +umask 077 + +cd "$(dirname "${BASH_SOURCE[0]}")/.." +for key in PROD_HOST PROD_USER DEPLOY_PATH PROD_SSH_KEY PROD_KNOWN_HOSTS IMAGE_PREFIX DEPLOY_TAG REGISTRY_USERNAME REGISTRY_PASSWORD WQ_EMAIL WQ_PASSWORD DATABASE_URL ADMIN_PASSWORD ENCRYPTION_KEY PUBLIC_ORIGIN; do + if [[ -z "${!key:-}" ]]; then + echo "Missing required Gitea configuration: $key" >&2 + exit 1 + fi +done +if [[ "$DEPLOY_PATH" != /* || ! "$DEPLOY_TAG" =~ ^[a-zA-Z0-9_][a-zA-Z0-9_.-]{0,127}$ ]]; then + echo 'DEPLOY_PATH must be absolute and DEPLOY_TAG must be a valid Docker tag.' >&2 + exit 1 +fi + +ssh_dir=$(mktemp -d) +trap 'rm -rf -- "$ssh_dir"' EXIT +trap 'exit 130' INT +trap 'exit 143' TERM +trap 'exit 129' HUP +printf '%s\n' "$PROD_SSH_KEY" > "$ssh_dir/id" +printf '%s\n' "$PROD_KNOWN_HOSTS" > "$ssh_dir/known_hosts" +unset PROD_SSH_KEY PROD_KNOWN_HOSTS +ssh_options=( + -F /dev/null -T + -i "$ssh_dir/id" + -p "${PROD_PORT:-22}" + -l "$PROD_USER" + -o BatchMode=yes + -o IdentitiesOnly=yes + -o StrictHostKeyChecking=yes + -o "UserKnownHostsFile=$ssh_dir/known_hosts" + -o GlobalKnownHostsFile=/dev/null + -o ConnectTimeout=15 + -o ServerAliveInterval=15 + -o ServerAliveCountMax=4 +) + +# Bash %q preserves quotes, dollar signs and newlines without evaluating values. +# Only this allowlist crosses SSH; private keys and the runner's environment stay local. +{ + printf 'set -Eeuo pipefail\numask 077\n' + for key in DEPLOY_PATH IMAGE_PREFIX DEPLOY_TAG REGISTRY_USERNAME REGISTRY_PASSWORD WQ_EMAIL WQ_PASSWORD DATABASE_URL ADMIN_PASSWORD ENCRYPTION_KEY PUBLIC_ORIGIN ADMIN_USERNAME MCP_ENABLED; do + printf 'export %s=%q\n' "$key" "${!key:-}" + done + cat <<'REMOTE' +mkdir -p -- "$DEPLOY_PATH/releases" +release_dir=$(mktemp -d "$DEPLOY_PATH/releases/$DEPLOY_TAG.XXXXXX") +cd "$release_dir" +base64 -d <<'WQ_RELEASE_BUNDLE' | tar -xzf - +REMOTE + # A small base64 archive lets one SSH connection carry files and shell-quoted env. + # Each attempt gets its own directory, so competing jobs cannot overwrite files. + COPYFILE_DISABLE=1 tar -czf - compose.production.yaml scripts/deploy-production.sh | base64 + printf '\nWQ_RELEASE_BUNDLE\n' + # Docker must not consume the SSH script input. B does not need a Git checkout. + printf 'exec bash scripts/deploy-production.sh