跳转至

后端服务部署

⚠️ 已退役(TFRM-254 / TFRM-243):本文档描述的 compose / SSH 部署方式已停用。app 部署已收敛为 Flux 拉模式 GitOps——CI 只造镜像,部署改由 gitops 仓 turingfocus/gitops apps/overlays/<env> 钉 tag → Flux reconcile。权威部署文档见 gitops 仓 docs/plans/gitops-cd-flux.md。以下内容仅作历史参考。

适用于所有环境。完成 Nginx 与网络配置 后执行。

部署架构

镜像构建/推送统一走 CNB registry,各环境部署方式不同:

beta:    CNB Pipeline → 构建镜像 → SSH Deploy → 服务器 docker compose up(tfrs-net)
staging: CNB Pipeline → 构建镜像 → SSH Deploy → kubectl set image + rollout status(k3s ns tfrs-mgmt)
  • beta:三个项目共享同一台服务器和 Docker 网络 tfrs-netscripts/deploy.sh + docker-compose.cloud.yml
  • staging:管理平面已于 2026-07-06 迁移到专属单节点 k3s(ns tfrs-mgmt,TFRM-251);tfrsmanager 后端三服务为 k8s Deployment,scripts/deploy-staging-k3s.sh 经 kubectl 零停机滚动更新镜像。

环境变量体系

环境变量分为两类,存放在 CNB 密钥仓库 turingfocus/build_env 中:

1. 部署凭证(按环境独立,每个环境一个文件)

文件 用途
tfrsmanager_deploy_ssh.yml Beta 服务器 SSH + Docker 凭证
tfrsmanager_deploy_ssh_staging.yml Staging 服务器 SSH + Docker 凭证

模板:

# 服务器 SSH 登录
PROD_SSH_HOST: "{服务器公网IP}"
PROD_SSH_PORT: "22"
PROD_SSH_USER: "deploy"
PROD_SSH_KEY: |
  -----BEGIN RSA PRIVATE KEY-----
  {deploy 用户的 SSH 私钥}
  -----END RSA PRIVATE KEY-----

# Docker 镜像仓库凭证(CNB Registry)
PROD_DOCKER_USER: "{CNB 用户名}"
PROD_DOCKER_PASSWORD: "{CNB Token}"

注意: SSH 私钥需要在目标服务器上生成并添加到 deploy 用户的 authorized_keys

2. 业务环境变量(按环境独立,区分沙箱/生产配置)

文件 用途
tfrsmanager_env.prod.yml Beta 后端业务变量
tfrsmanager_env.staging.yml Staging 后端业务变量
tfrs_user_portal.yaml Beta UserPortal 变量
tfrs_user_portal_staging.yaml Staging UserPortal 变量
tfrs_admin_portal.yaml Beta AdminPortal 变量
tfrs_admin_portal_staging.yaml Staging AdminPortal 变量

Staging 环境变量配置指南

TFRSManager 后端 (tfrsmanager_env.staging.yml)

以下变量需要用户手动配置。标记 ⚠️ 沙箱 的项目必须使用沙箱/测试环境配置,不得使用生产值。

# ===== 基础服务 =====
GIN_MODE: "release"
DB_HOST: "postgres"           # Docker 容器名
DB_PORT: "5432"
DB_USER: "tfrs"
DB_PASSWORD: "{自行设置}"
DB_NAME: "tfrs_staging"
DB_DRIVER: "postgres"
DB_SSL_MODE: "disable"

REDIS_ADDR: "redis:6379"     # Docker 容器名
REDIS_PASSWORD: "{自行设置}"
REDIS_DB: "0"
REDIS_ENABLED: "true"

# ===== 认证 =====
JWT_SECRET: "{自行生成,openssl rand -hex 32}"

# ===== 日志 =====
LOG_LEVEL: "info"
LOG_ENVIRONMENT: "staging"
LOG_DIR: "/app/logs"
LOG_MAX_SIZE: "100"
LOG_MAX_BACKUPS: "5"
LOG_MAX_AGE: "30"
LOG_COMPRESS: "true"

# ===== ⚠️ 沙箱:微信支付 =====
WECHAT_APPID: "{沙箱 AppID}"
WECHAT_MCHID: "{沙箱商户号}"
WECHAT_MCH_PRIVATE_KEY: "{沙箱私钥}"
WECHAT_MCH_SERIAL_NO: "{沙箱证书序列号}"
WECHAT_APIV3_KEY: "{沙箱 APIv3 密钥}"
WECHAT_NOTIFY_URL: "https://user-staging.turingfocus.cn/api/v1/payment/wechat/notify"
WECHAT_IS_SANDBOX: "true"

# ===== ⚠️ 沙箱:支付宝 =====
ALIPAY_APPID: "{沙箱 AppID}"
ALIPAY_APP_PRIVATE_KEY: "{沙箱应用私钥}"
ALIPAY_ALIPAY_PUBLIC_KEY: "{沙箱支付宝公钥}"
ALIPAY_SIGN_TYPE: "RSA2"
ALIPAY_NOTIFY_URL: "https://user-staging.turingfocus.cn/api/v1/payment/alipay/notify"
ALIPAY_IS_SANDBOX: "true"

# ===== 短信(可共用生产配置或使用测试模板) =====
SMS_PROVIDER: "tencent"
SMS_TENCENT_SECRET_ID: "{腾讯云 SecretId}"
SMS_TENCENT_SECRET_KEY: "{腾讯云 SecretKey}"
SMS_TENCENT_SDK_APP_ID: "{SDK AppID}"
SMS_TENCENT_SIGN_NAME: "{签名}"
SMS_CODE_TTL_MINUTES: "5"

# ===== 对象存储(可共用或独立 Bucket) =====
COS_BUCKET_NAME: "{Staging Bucket}"
COS_REGION: "ap-beijing"
COS_SECRET_ID: "{SecretId}"
COS_SECRET_KEY: "{SecretKey}"
COS_BASE_URL: "https://{bucket}.cos.{region}.myqcloud.com"
COS_MAX_AVATAR_SIZE: "2097152"

# ===== K8s 集群 =====
K8S_CONFIG_DIR: "/app/data/kubeconfigs"

# ===== Infisical 密钥管理 =====
# ⚠️ Infisical 按环境分独立实例:prod=secret.turingfocus.cn / staging=secret-staging.turingfocus.cn。
#   CLIENT_ID/SECRET 用 03 §5.3 的 cvm-staging-tfrs-manager(项目 Admin)。
#   PROJECT_ID 与 PROJECT_SLUG 必须是同一项目(TFRM-131 不变量 B;启动日志「Infisical Phase B 三元组」核对)。
INFISICAL_CLIENT_ID: "{Staging cvm-staging-tfrs-manager ID}"
INFISICAL_CLIENT_SECRET: "{Staging cvm-staging-tfrs-manager Secret}"
INFISICAL_PROJECT_ID: "{项目 Settings  Project ID}"
INFISICAL_PROJECT_SLUG: "{项目 Settings  Project slug}"   # TFRM-131:注入 spec.billing.infisical.projectSlug 用 slug 非 ID
INFISICAL_ENVIRONMENT: "staging"                            # 环境 slug(非显示名)
INFISICAL_SITE_URL: "https://secret-staging.turingfocus.cn"

# ===== 加密 =====
SECRET_ENCRYPTION_KEY: "{自行生成,openssl rand -hex 32}"
SECRET_CACHE_TTL: "3600"

# ===== 可观测性 =====
OTEL_SERVICE_NAME: "tfrsmanager-staging"
OTEL_SERVICE_VERSION: "1.0.0"
OTEL_ENVIRONMENT: "staging"
JAEGER_ENDPOINT: "http://jaeger:4318/v1/traces"
OTEL_SAMPLING_RATIO: "1.0"

# ===== 飞书应用 =====
FEISHU_CALLBACK_BASE_URL: "https://user-staging.turingfocus.cn"
FEISHU_OAUTH_REDIRECT_URL: "https://user-staging.turingfocus.cn/auth/feishu/callback"
FEISHU_ISV_ENABLED: "false"
FEISHU_ISV_APP_ID: ""
FEISHU_ISV_APP_SECRET: ""

# ===== 消费调度器 =====
CONSUMPTION_SCHEDULE_INTERVAL: "5m"
CONSUMPTION_PULL_WINDOW_MINUTES: "6"

# ===== 中央 TimescaleDB(TFRM-120 / TFRM-114 计费 of record)=====
# 注意:OTLP_TS_HOST / OTLP_TS_PORT 不放入 Infisical / CNB 密钥仓库。
#   容器互通的 DNS(timescaledb:5432)由 docker-compose.cloud.yml 的
#   user-service.environment 段硬编码注入,且会覆盖 env_file。
#   在 Infisical 里再配 host/port 不会生效,反而易让人误以为要填 127.0.0.1:5433。
OTLP_TS_USER: "tfrs"
OTLP_TS_PASSWORD: "{与 .env.infra 完全一致;不一致 user-service 启动 Fatal}"
OTLP_TS_DBNAME: "tfrs_billing"
OTLP_TS_SSLMODE: "disable"        # 同 VM docker network 内不需 TLS(详见 docs/specs/otlp-receiver-contract.md §9)
OTLP_TS_AUTO_MIGRATE: "true"      # otlp-receiver 启动时跑嵌入式 schema migration(幂等)
OTLP_SINK_TYPE: "timescale"       # 切 sink 到中央 TS;缺省 logging 仅适用本地开发

# ===== 中央 TS 远端冷归档(TFRM-123 / 财务审计 2 年 of-record)=====
# scripts/timescale-backup.sh::do_upload 用 aws s3 cp 推全量到远端 bucket。
# 留空 BACKUP_REMOTE_BUCKET → 跳过上传(向后兼容);prod 必填以兑现合规。
# 凭证与 COS_* 同源(同腾讯云账号,建议 bucket 级最小权限子账号)。
# 端点区域复用上方 COS_REGION,不新增区域变量。
# 宿主机依赖:deploy VM 需装 awscli(bootstrap-env / VM 初始化一次性)。
# 远端 ≥ 2 年保留由 bucket 侧 lifecycle rule(prefix=timescale/full/,≥740 天)兑现,
#   运营在腾讯云 COS 控制台一次性配置,脚本不删远端(详见 docs/timescaledb-ops.md §远端冷归档 SOP)。
BACKUP_REMOTE_BUCKET: "{远端 bucket 全名,含 appid,如 tfrs-ts-archive-1300000000}"
BACKUP_REMOTE_SECRET_ID: "{腾讯云 SecretId,与 COS 同源}"
BACKUP_REMOTE_SECRET_KEY: "{腾讯云 SecretKey,与 COS 同源}"

# ===== 集群初始化 =====
CLUSTER_INIT_OPERATOR_IMAGE: "{Operator 镜像地址}"
CLUSTER_INIT_OPERATOR_NAMESPACE: "tfrs-system"
CLUSTER_INIT_DEFAULT_CLUSTER_TYPE: "standard"
CLUSTER_INIT_INFISICAL_REGISTRY_PATH: "/docker-registry"

# ===== Phase B 计费注入(TFRM-131;billingEnabled 集群纳管时生效)=====
# OTLP_ENDPOINT 必填才能开 Phase B;其余有代码默认值,按需覆盖。
# ESO 专用 MI 凭据不在此 — 由 03 §5.6 写入 Infisical /otlp-eso-auth/,Manager 运行时读取。
CLUSTER_INIT_OTLP_ENDPOINT: "https://metrics-staging.turingfocus.cn"   # 该 env Manager OTLP receiver 公网 host(复用 metrics-* 域名)
CLUSTER_INIT_OTLP_ESO_AUTH_PATH: "/otlp-eso-auth/"                      # 专用 ESO MI 凭据固定路径(对应 03 §5.6)
CLUSTER_INIT_ESO_VERSION: "0.10.7"                                     # ESO chart 版本(默认值,一般不改)。注意无 v 前缀——ghcr.io OCI tag 格式(TFRM-133)
CLUSTER_INIT_ESO_NAMESPACE: "external-secrets-system"                  # ESO 自身安装 ns(产品自有,独立关注点)
CLUSTER_INIT_OTLP_AUTH_SECRET_NAME: "tfrs-otlp-infisical-auth-source"  # 物化到客户集群的 ESO authSecret 名(与 Operator ESO ExternalSecret 约定)

# ===== Helm/Operator =====
OPERATOR_HELM_REPO_URL: "{Helm Repo URL}"
OPERATOR_CHART_VERSION: "{Chart 版本}"
OPERATOR_IMAGE_TAG: "{Operator 镜像 Tag}"
HELM_STORAGE_DRIVER: "secret"

前端项目 (tfrs_user_portal_staging.yaml / tfrs_admin_portal_staging.yaml)

# UserPortal Staging
NEXT_PUBLIC_API_URL: "https://user-staging.turingfocus.cn"
NEXT_PUBLIC_ENV: "staging"
# 其他前端特定变量...

# AdminPortal Staging
NEXT_PUBLIC_API_URL: "https://admin-staging.turingfocus.cn"
NEXT_PUBLIC_ENV: "staging"
# 其他前端特定变量...

CNB 流水线配置

每个项目的 .cnb.yml 中添加 staging 部署事件(触发名不变):

# 手动触发 Staging 部署
$:
  web_trigger_deploy_staging:
    - <<: *deploy-staging-pipeline

  api_trigger_deploy_staging:
    - <<: *deploy-staging-pipeline

⚠️ TFRSManager 后端 staging = k3s(TFRM-251).deploy-staging-pipeline 不再走 docker-compose,而是经 SSH 在 staging 机器(k3s API 绑 localhost)执行 kubectl -n tfrs-mgmt set image deploy/<svc> <svc>=<新镜像> + kubectl rollout status (零停机,见 scripts/deploy-staging-k3s.sh)。env 由 cluster secret/configmap bootstrap,不再每次部署注入 .env——tfrsmanager_env.staging.yml 用于 bootstrap cluster secret(env 变更 → 重建 secret + kubectl rollout restart),故已从 staging pipeline 的 imports 移除。前置:staging 部署用户须能运行 kubectl 并读取 KUBECONFIG(默认 /etc/rancher/k3s/k3s.yaml:root / sudo / 已复制 kubeconfig; 脚本 preflight 会 fail-loud 兜底)。

下表 imports 密钥映射对 beta前端项目仍适用:

配置项 Beta (Prod) Staging
SSH 凭证 tfrsmanager_deploy_ssh.yml tfrsmanager_deploy_ssh_staging.yml
后端业务变量 tfrsmanager_env.prod.yml tfrsmanager_env.staging.yml(k3s:bootstrap cluster secret,非 per-deploy)
UserPortal 变量 tfrs_user_portal.yaml tfrs_user_portal_staging.yaml
AdminPortal 变量 tfrs_admin_portal.yaml tfrs_admin_portal_staging.yaml

部署步骤

前置准备(首次部署)

  1. 生成 deploy 用户 SSH 密钥(在 staging 服务器上):

    su - deploy
    ssh-keygen -t rsa -b 4096 -f ~/.ssh/cnb_deploy -N "" -m PEM
    cat ~/.ssh/cnb_deploy.pub >> ~/.ssh/authorized_keys
    cat ~/.ssh/cnb_deploy  # 复制私钥到 CNB 密钥仓库
    

  2. 在 CNB 密钥仓库创建配置文件(参照上述模板填充)

  3. 各项目 .cnb.yml 添加 staging 流水线

触发部署

# 通过 CNB MCP 触发(API 方式)
cnb_startBuild(repo: "turingfocus/k8s/tfrsmanager", branch: "main", event: "api_trigger_deploy_staging")
cnb_startBuild(repo: "turingfocus/ui/TFRobotFrontPortal", branch: "main", event: "api_trigger_deploy_staging")
cnb_startBuild(repo: "turingfocus/ui/TFRobotAdminPortal", branch: "main", event: "api_trigger_deploy_staging")

或通过 CNB Web 界面手动触发 web_trigger_deploy_staging

数据迁移脚本部署顺序约束

scripts/migrations/ 下脚本不会随容器自动执行(参见 scripts/migrations/README.md),DBA 需手动按序执行。涉及 GORM not null 标签变更的 DDL 脚本必须遵循以下窗口约束:

窗口 1:执行 backfill SQL → 人工验残留 = 0
窗口 2:执行 ALTER ... SET NOT NULL → 部署带新 GORM tag 的代码
  • 两个窗口不可合并:backfill 完成后人工 SELECT count(*) FROM <table> WHERE <col> IS NULL 确认残留为 0,才推 DDL 脚本
  • DDL 必须早于代码上线:避免「新代码已上线但旧 schema 仍允许 NULL」期间产生脏数据
  • 新环境从零部署不依赖本顺序:GORM AutoMigrate 会按代码侧的 tag 直接建出 NOT NULL 列

案例:TFRM-38(organizations.owner_user_id NOT NULL)

# 窗口 1
psql -h <staging-host> -U tfrs -d tfrs_manager -f scripts/migrations/002_backfill_enterprise_owner_user_id.sql
# 验证:SELECT COUNT(*) FROM organizations WHERE owner_user_id IS NULL AND deleted_at IS NULL;  -- 期望 0

# 窗口 2(与代码同窗口)
psql -h <staging-host> -U tfrs -d tfrs_manager -f scripts/migrations/003_alter_organizations_owner_user_id_not_null.sql
# 立即触发 staging 部署,让带 GORM not null tag 的代码上线

如发现窗口 1 输出 RAISE EXCEPTION(仍有残留),停止操作并人工审查空壳组织清单,不可强制推进