Files
OSGAccountServer/docs/DEPLOYMENT.md
T
Rocky 41e2145334 Publish verified Docker images to GHCR
Make deployments pull immutable CI-built images while keeping test failures diagnosable before any package is published.
2026-08-16 14:56:36 +08:00

9.2 KiB
Raw Blame History

OSG Account Server 部署指南

本方案使用一个非 root、只读文件系统兼容的 JDK 21 容器。OpenResty 终止 TLS 并反向代理, MySQL 复用已有实例。compose.yaml 不会创建数据库容器。

1. 前置条件

  • account.osglab.comosglab.com 已解析到 1Panel 主机。
  • Docker Engine、Docker Compose v2 和 1Panel OpenResty 已安装。
  • 已有 MySQL 8 实例可从应用容器通过 Docker 外部网络或私网访问。
  • 服务器时钟同步,Apple、DeepSeek、Volcengine 的出站 HTTPS/WSS 可用。
  • App 的 Associated Domains 包含 applinks:osglab.com

邀请使用一方 Universal Link,不依赖 Firebase Dynamic Links 或 Branch。

2. 注册邀请网页路由

网页实现在 features/inviteweb,资源在 src/main/resources/invite/index.html。组合根应只注册 一个 /i/{code} 路由;不要与旧 features/invite 路由同时挂载。ReferralLookupPort 是只读、 大小写敏感的验证边界,适配器应在 referrals 模块中用有界查询检查邀请码及活动状态:

先在 Koin 注册一个 ReferralLookupPort 实现;公共接线会从现有 AppConfig 读取 App Store URL、 邀请域名和 Apple Team ID/bundle ID

routing {
    configureInviteWebRoutes()
}

测试或不使用 Koin 的宿主可调用显式重载 configureInviteWebRoutes(referralLookup, InviteWebConfig(...))

邀请码规则与当前生成器一致:16 字节随机值编码为无填充 Base64URL,即固定 22 位、区分大小写, 字符白名单为 A-Za-z0-9_-。结构非法或查无记录均返回相同 404;查询超时或 失败返回不泄露内部信息的 503。页面和两个 AASA endpoint 均发送 no-store

3. 准备现有 MySQL

先创建数据库,字符集使用 utf8mb4。使用 docs/mysql-minimum-privileges.sql 创建相互独立的 迁移账号和运行账号,并将示例来源网段替换为实际应用容器网段。不要使用公网来源或 %

CREATE DATABASE osg_account
    CHARACTER SET utf8mb4
    COLLATE utf8mb4_0900_ai_ci;

应用启动时使用迁移账号执行 Flyway,随后 Hikari 连接池只使用运行账号。运行账号不能更新或删除 不可变 ledger/usage 历史,也不能访问 Flyway 元数据或未来未审查的表。禁止授予全局权限、 FILEPROCESSSUPERCREATE USERGRANT OPTION。不要复用 root 或其他应用用户。

若 MySQL 是容器,把它和应用接入同一个已存在的外部网络:

docker network create account-backend
docker network connect account-backend <existing-mysql-container>

已存在网络时不要重复创建。DATABASE_URL 中使用该 MySQL 容器在网络内可解析的名称。若 MySQL 位于私网主机,使用私有 DNS/IP,并在数据库防火墙中只允许应用主机或容器网段。

4. 配置环境与秘密

复制部署所需变量到项目根目录的未跟踪 .env,或使用 1Panel 的环境变量/秘密管理。不要把真实 值写入 YAML、镜像层或 Git。

必须设置:

  • DATABASE_URL=jdbc:mysql://<existing-mysql>:3306/osg_account?useUnicode=true&characterEncoding=utf8&connectionTimeZone=UTC&forceConnectionTimeZoneToSession=true
  • DATABASE_USERDATABASE_PASSWORD
  • DATABASE_MIGRATION_USERDATABASE_MIGRATION_PASSWORD:必须与运行账号及其密码不同
  • JWT_SECRET:至少 32 个随机字节
  • FIELD_ENCRYPTION_KEY:恰好 32 个随机字节的 Base64
  • IDENTITY_HMAC_KEY:至少 32 个随机字节的独立 Base64,不得复用字段加密密钥
  • APPLE_TEAM_IDAPPLE_KEY_IDAPPLE_PRIVATE_KEY_PEM
  • VOLCENGINE_API_KEYDEEPSEEK_API_KEY
  • APP_STORE_URL:正式 App Store HTTPS 地址;生产 Compose 不接受缺失值

生成新秘密的示例:

openssl rand -base64 48
openssl rand -base64 32
openssl rand -base64 32

三个结果分别用于 JWT、字段加密和身份 HMAC,不能相互复用。APPLE_PRIVATE_KEY_PEM 可使用包含 字面量 \n 的单行值;应用配置会在内存中还原换行。限制 .env 权限:

chmod 600 .env

生产 Compose 已固定 APP_ENV=production、Apple production 环境及两项完整性强制开关。 Apple 配置使用所属开发者账号的 Team ID、Key ID、bundle ID 和 .p8 私钥;火山引擎使用 SAUC v3 WSS endpoint、资源 ID 和 API KeyDeepSeek 使用 HTTPS endpoint、已开通模型名和 API Key。 三方凭据分别创建、分别轮换,不得复用。

5. 构建与启动

GitHub CI 在测试通过后发布私有镜像 ghcr.io/hkgood/osg-account-server。先使用仅有 read:packages 权限的部署令牌登录 GHCR; 令牌不要写入 .env、Compose、1Panel 截图或 shell 历史。然后检查变量插值并拉取镜像。 docker compose config 会展开秘密,不要把输出上传或粘贴到工单:

./gradlew test
echo "$GHCR_TOKEN" | docker login ghcr.io -u hkgood --password-stdin
docker compose config --quiet
docker compose pull
docker compose up -d
docker compose ps
curl --fail http://127.0.0.1:18080/health/ready

容器以 UID/GID 10001 运行,根文件系统只读,仅 /tmp 是带大小限制的 tmpfs。应用端口只映射到 宿主机回环地址,不能改成 0.0.0.0

查看日志时不得记录或复制 token、Apple subject、音频、prompt、转录或模型响应:

docker compose logs --since=10m account-server

6. 配置 1Panel / OpenResty

在 1Panel 创建 account.osglab.comosglab.com 两个 HTTPS 网站并签发证书。使用 deploy/openresty-account.conf

  1. maplimit_req_zonelimit_req_statusupstream 放入 OpenResty http 上下文。
  2. 将三个 server 块作为站点配置;按 1Panel 实际证书路径调整 ssl_certificate
  3. 示例 upstream 指向宿主机 127.0.0.1:18080。若 OpenResty 自身在容器中,则将其加入 account-backend,并改为 account-server:8080
  4. 配置明确对 /admin/internal/v1/admin 返回 404;不要新增绕过该规则的泛域名代理。
  5. API 示例按 IP 限制 20 请求/秒,邀请页限制 5 请求/秒,可基于真实流量谨慎调整。
  6. 代理统一支持 HTTP/1.1 Upgrade/Connection,因此当前 HTTP API 与后续 WebSocket 入口都可用。

两个 AASA 地址由 Ktor 根据 appleAppId 模板输出,不需要复制静态文件。配置检查成功后再通过 1Panel 重载 OpenResty

openresty -t

7. 验证

检查健康状态、反向代理、隐藏路径、限流和 AASA:

curl --fail https://account.osglab.com/health/ready
curl -i https://account.osglab.com/internal/
curl -i https://osglab.com/.well-known/apple-app-site-association
curl -i https://osglab.com/i/AbCdEf0123456789_-AbCd

预期结果:

  • 健康检查返回 200。
  • 内部路径返回 404。
  • AASA 返回 200、Content-Type: application/json,且不发生重定向。
  • 合法 22 位邀请码返回双语 HTML;非法、未知或失效邀请码统一返回 404。
  • 邀请页包含 nonce CSP、Cache-Control: no-storeX-Robots-Tag,无第三方请求。

使用真机验证 Universal Link。安装带 Associated Domains entitlement 的 App 后,从信息或邮件点击 https://osglab.com/i/<code> 应直接进入 App;未安装时应显示网页。AASA 更新受系统缓存影响, 首次验证应预留传播时间。

8. 防火墙

  • 公网入站仅允许 TCP 80/443。
  • SSH 仅允许管理员固定 IP 或 VPN;不公开 18080、8080、3306。
  • MySQL 3306 仅允许 Docker 私网或指定应用主机。
  • 出站允许 DNS、NTP、Apple HTTPS、DeepSeek HTTPS、Volcengine HTTPS/WSS。
  • 云安全组、主机防火墙和 1Panel 防火墙应采用相同边界,避免其中一层意外放行。

9. 更新与回滚

更新前备份 MySQL 并记录当前镜像标签。使用不可变标签构建:

IMAGE_TAG=sha-<commit> docker compose pull
IMAGE_TAG=sha-<commit> docker compose up -d

Flyway 迁移只向前执行。若新版本包含数据库迁移,应用镜像回滚不等于数据库回滚;应先按迁移影响 制定恢复方案。无数据库变更时,可把 IMAGE_TAG 切回上一版本并重新执行 docker compose up -d

备份、时间点恢复和每月恢复演练按 docs/BACKUP.md 执行。

10. 分阶段验收

  1. 本地阶段./gradlew test 通过;合法、未知、格式错误和 lookup 异常路径符合预期;HTML 不加载第三方资源。
  2. 容器阶段:镜像以 UID/GID 10001 运行;根文件系统只读;/health/live/health/ready 通过;容器无法取得额外 Linux capability。
  3. 预发布阶段:Flyway 使用迁移账号成功,应用使用运行账号成功;撤销运行账号 DDL 权限后服务 仍正常;OpenResty HTTP、HTTPS 和 WSS 代理验证通过。
  4. Apple 阶段:两个 AASA 地址返回无重定向 JSON;真机从信息或邮件点击 https://osglab.com/i/<code> 可打开 App;未安装 App 时显示双语落地页。
  5. 生产阶段:公网仅开放 80/4433306/8080/18080 不可达;邀请 URL 不出现在访问日志; 供应商凭据可用;完成加密备份并记录一次隔离恢复结果。