Skip to content

09 — Deployment Pattern (Vultr Japan + cloudflared + Docker)

Status: 📐 Architecture proposal · 2026-05-07 Source: Direct port of PRD's deployment (~/PRD/.claude/rules/deployment.md) with Docker isolation added on top Goal: Co-locate twilight-drive backend on the existing PRD Vultr Japan VPS without coupling failure modes, secrets, or resource budgets between the two projects

复用 PRD 已经验证过的 cloudflared tunnel + CF Access 模式;Docker 容器是两个项目之间的隔离边界

目标 (Goal)

把 twilight-drive 的 P1.0 backend 部署到 PRD 同一台 Vultr Japan VPS(避免新开机器的成本与运维复杂度),同时通过 Docker 容器把两个项目的故障域、文件系统、网络端口、systemd 命名空间彻底切开。

域名分配(已确认):

用途域名服务
Backend APIapi.fsagent.cccloudflared tunnel → Docker 容器 :8080
Docs sitedev.fsagent.ccCloudflare Pages 自定义域 (existing)
(未来) 用户 dashboardTBD(P1.1+)

PRD 已经在跑什么(不动)

Vultr Japan (Ubuntu 22.04, user openclaw, IP 139.180.196.53)

├── systemd user 服务(loginctl enable-linger 持久化)
│   ├── poly-trade-dashboard.service       FastAPI on 127.0.0.1:8080
│   ├── poly-trade-cloudflared.service     tunnel "prd-dashboard"
│   ├── poly-trade-{scan,copy,rebalance,pnl,qa}.timer
│   └── Docker: Fordefi MPC signer (--restart=always)

├── ~/.cloudflared/<UUID>.json   (PRD tunnel creds, mode 600)
├── ~/.cloudflare-token          (API token, IP-locked)
├── ~/prd/{.env, secrets, data, logs}/

└── Cloudflare zone fsagent.cc → tunnel "prd-dashboard" → dashboard.fsagent.cc

提议加什么(隔离边界用 ▲▲▲ 标出)

Vultr Japan (same host)

├── (existing) PRD 的所有服务

├──▲▲▲ 隔离边界(Docker 容器 + 独立 home 子目录) ▲▲▲

├── Docker container "twilight-backend"
│   │  image: ghcr.io/lacatfly/twilight-drive:0.2.0 (固定 SHA)
│   │  binds: 127.0.0.1:8081 → 容器 :8080      (port 8080 已被 PRD 占用)
│   │  caps: drop ALL;no-new-privileges
│   │  filesystem: read-only + /tmp tmpfs
│   │  resources: cpus=1.5, memory=1500m
│   ├── volume: ~/twilight/data → /data (DuckDB cache)
│   ├── env_file: ~/twilight/.env (TUSHARE_TOKEN, BEARER_DB_PATH, ...)
│   └── healthcheck: GET /healthz

├── systemd user units(前缀 twilight-* 避免命名冲突)
│   ├── twilight-backend.service       wraps `docker compose up`
│   └── twilight-cloudflared.service   独立 tunnel "twilight-backend"

├── ~/.cloudflared/twilight-backend-<UUID>.json   (新 tunnel creds)
├── ~/.cloudflared/twilight-config.yml            (独立配置文件)
├── ~/twilight/{.env, data, logs, compose.yml}/

└── Cloudflare zone fsagent.cc
    ├── (existing) tunnel "prd-dashboard" → dashboard.fsagent.cc
    ├── (new)      tunnel "twilight-backend" → api.fsagent.cc
    ├── (new)      Pages custom domain dev.fsagent.cc → twilight-drive.pages.dev
    └── Access policies:
        ├── (existing) prd-dashboard: default-email-otp
        └── (new)      twilight-backend:
                      - early access: Email OTP for team
                      - P1.1 production: service tokens (one per profile, revocable)

高风险服务问题与缓解(Risk Register)

1. 端口冲突 — PRD 占用 :8080

风险影响缓解
二者绑同一端口 → 后启动者崩backend 启动失败容器对外 binds 127.0.0.1:8081;容器内部仍是 :8080,FastAPI 代码不需要知道宿主端口
端口范围耗尽(多 user profile 起来后)P1.1 阶段问题Hermes 容器用 Docker 内部网络,不需要映射主机端口;iLink 是出站连接,不依赖入站端口

2. Secret 泄漏

风险影响缓解
PRD 的 FORDEFI_API_TOKEN 被 twilight 容器读到监管 + 资金风险Docker env_file: ~/twilight/.env这一个),不挂 ~/prd/;容器进程命名空间隔离
twilight 的 TUSHARE_TOKEN 被 PRD 进程读到Tushare 配额耗尽 + token 失窃~/twilight/.env mode 600 + 同一用户 (openclaw) 但容器 mount 仅 twilight 目录
镜像里烤进 secret镜像泄漏=secret 泄漏永远不在 Dockerfile 里 COPY .env 或 secret;CI 检查(grep -r 'TUSHARE_TOKEN' Dockerfile → fail)
容器逃逸读宿主文件整个 home 暴露drop ALL caps + no-new-privileges + read_only: true + 不挂 ~ 整体,只挂 ~/twilight/data

3. Docker 容器命名冲突

风险缓解
容器名与 PRD 冲突强制前缀:twilight-*(PRD 用 poly-trade-*);每用户容器名 hermes-user-<id>
Docker network 重叠所有 twilight 容器在同一个自定义 bridge network twilight-net 中,与 PRD network 隔离

4. cloudflared tunnel 耦合

风险缓解
复用 PRD tunnel 加 ingress rule不复用:独立 tunnel twilight-backend 独立 UUID 独立 creds 文件,独立 systemd service。一个 tunnel 挂掉不影响另一个
同一 IP 两个 tunnel 流量混杂cloudflared 协议层路由按 hostname;流量上 CF 边缘已分开
API token scope 过宽(PRD 的 token 能动 twilight 的 tunnel 配置)给 twilight 单独申一个 IP-locked CF API token (scope: Tunnel:Edit + Access:Apps + Zone:DNS:Edit + Zone:Read);不复用,单独存 ~/.cloudflare-token-twilight

5. 资源争用

风险缓解
Twilight 把 Vultr CPU/RAM 吃满 → PRD 交易暂停Docker --cpus 1.5 --memory 1500m(VPS 规格 4GB → twilight 上限 ~37%;PRD 平时 ~1GB)
DuckDB 写并发把磁盘 IO 压满DuckDB cache 是单写者(FastAPI 进程);P2-A 仓库写在 Mac Mini,不在 Vultr
日志膨胀填满磁盘systemd journald rate-limit 默认;docker 用 --log-opt max-size=10m --log-opt max-file=3

6. 镜像供应链

风险缓解
第三方 base image 投毒base 镜像 pin SHA:FROM python:3.11-slim@sha256:<digest>;用 GitHub Dependabot 跟进新 SHA
pip install 拉到中毒包pip-audit 进 CI;requirements.txt 用 hash pinning (pip install --require-hashes)
镜像 push 到 ghcr.io 时 token 泄漏GitHub Actions OIDC,不存长期 PAT;workflow 只在 v* tag push 时跑
攻击者篡改 ghcr.io 镜像部署时 pull by SHA digest(不是 tag),systemd unit 里写死 image: ghcr.io/lacatfly/twilight-drive@sha256:<digest>

7. Docker daemon 共享

风险缓解
PRD Fordefi signer + twilight backend 共用同一个 docker daemon容器之间默认 bridge 网络隔离;--network=hostprovisioner 例外:挂载 docker.sock 仅用于创建 Hermes 容器,其他容器一律不挂
一个容器漏洞 → docker daemon → 整机 root这是 docker 的固有风险;rootless docker 是更彻底方案,但 PRD 现在不是 rootless,先不动;中期 (P2) 评估迁移

8. 网络出口

风险缓解
容器被攻击后向外探测/扫Docker 默认无出口限制;v1 接受。P2 评估 --network 自定义 + iptables egress allowlist (api.tushare.pro, *.eastmoney.com, openrouter, siliconflow)
Tushare 配额被异常请求耗尽backend 进程内 token-bucket rate-limiter(属 P1.0 实现)+ DuckDB cache 摊销重复请求

9. 故障恢复

风险缓解
docker daemon 重启把所有容器搞掉systemd unit Restart=always;docker --restart=unless-stopped
进程 OOM killsystemd Restart=on-failure;docker --memory 设了 cgroup 限制;超出会被 kernel OOM killer 干掉而不是拖累整机
cloudflared tunnel 边缘连接断cloudflared 自带自动重连;systemd Restart=always;CF Access 仍然挡未授权流量,所以"暂时无服务"远好过"裸暴露"
DuckDB cache 损坏cache 是可重建的 (24h TTL);rm 文件 + 重启即可

10. 监控盲区

风险缓解
容器 silently 卡在某个 import 上healthcheck: GET /healthz + Docker unhealthy 后 systemd Restart=always
Tushare 配额异常增长backend 实现日 quota 计数(P1.0 任务)
用户量爬升后忘记升 Vultr 套餐docker stats + docker ps --format 监控总内存;超过 3.2 GB 时告警;Mac Mini overflow 预案

11. 升级 / 回滚

风险缓解
新版本 break prod部署都走 local edit → GitHub PR → merge → CI 推 ghcr.io → VPS docker pull —— 跟 PRD 同样纪律
回滚困难docker compose 写明 image: 含 SHA digest;docker compose pull 拉新版前先记录旧版 SHA;回滚 = 改 SHA + docker compose up -d
数据库迁移半成P1.0 cache 无 schema 迁移;P1.1 Postgres 用 alembic(migrations 与 backend image 一起发)

部署流程(要落到 deploy/ 目录)

One-shot bootstrap (人工,VPS 上跑一次)

bash
# On Vultr (ssh tvps)
mkdir -p ~/twilight/{data,logs,secrets}
cd ~/twilight
git clone https://github.com/LaCatFly/twilight-drive.git source

# Apply env (manual — copy .env.example, fill in values)
cp source/profile/template-stock-research-pro/.env.example .env
chmod 600 .env
$EDITOR .env

# Tunnel: create + DNS + creds
cloudflared tunnel create twilight-backend
cloudflared tunnel route dns twilight-backend api.fsagent.cc
mv ~/.cloudflared/<UUID>.json ~/.cloudflared/twilight-backend-<UUID>.json
# Stash UUID for systemd unit
echo "<UUID>" > ~/.cloudflared/TWILIGHT_TUNNEL_UUID

# Cloudflare API token (separate from PRD's, IP-locked, scoped down)
echo "<NEW_TOKEN>" > ~/.cloudflare-token-twilight
chmod 600 ~/.cloudflare-token-twilight

# Compose config (substitute UUID into the template)
cd source && bash deploy/install.sh

# CF Access policy
# Manual via Zero Trust dashboard:
#   Access > Applications > Add an application > Self-hosted
#   Domain: api.fsagent.cc
#   Policy: default-email-otp (allowlist team emails)

Recurring deploy (CI-triggered on v* tag)

GitHub Actions release.yml (extended):
  ├── Build wheel (existing)
  ├── Pack skill tarball (existing)
  └── Build + push Docker image
      tag: v0.2.0 → ghcr.io/lacatfly/twilight-drive:0.2.0
      also: ghcr.io/lacatfly/twilight-drive:latest
      digest pinned in compose.yml on next git pull on VPS

VPS:
  cd ~/twilight/source && git pull
  cd ~/twilight && docker compose pull && docker compose up -d
  systemctl --user restart twilight-backend

與其他 spec 的关系

  • 00-multi-tenancy:profile 隔离的另一半;本文是 backend 与 PRD 之间的隔离
  • 08-hermes-aligned-structure:profile 模板的 config.yaml.templateTWILIGHT_SERVICE_URL 指向 https://api.fsagent.cc
  • superpowers/specs/phase1:P1.0 spec 之前写"Vultr + Caddy + systemd"——本文取代 Caddy 部分(cloudflared 同时给我们 TLS + 公网入口 + Access auth,Caddy 是冗余的);其余 systemd + DuckDB cache + bearer auth 不变
  • superpowers/specs/phase2 Initiative C:未来 New-API gateway 跑在另一个 Docker 容器,复用本文的隔离/cloudflared 模式

關鍵決策點 (Open Questions)

Q1. 数据库选型 — DuckDB on host volume vs Postgres in second container?

  • A) DuckDB 文件 + host bind volume(P1.0 cache 简单,规划里就是这个)
  • B) Postgres 容器(P1.1 用户/计费表的位置)
  • C) 两者并存:DuckDB 给 cache(regenerable,无需备份),Postgres 给 user state(要备份)

建議 C:DuckDB cache 容器内 + bind mount,丢了重建;Postgres 起独立容器(twilight-postgres),独立 volume + cron 备份。两者职责不一样不能混。

Q2. base 镜像选择?

  • A) python:3.11-slim (官方,~120MB)
  • B) python:3.11-alpine (~50MB 但 musl libc 跟一些包不兼容)
  • C) gcr.io/distroless/python3 (Google 维护,~50MB,无 shell 增加防御)

建議 A:slim 是最稳的选择;alpine musl 跟 pandas/akshare 经常出 wheel 兼容问题;distroless 调试不方便(没 shell)等成熟后再换。

Q3. cloudflared tunnel — 复用 PRD tunnel 还是独立?

  • A) 复用:在 PRD ~/.cloudflared/config.yml 加 ingress rule
  • B) 独立:新 tunnel UUID + 新 systemd unit

已决 B(独立):见 §"高风险" §4,独立 tunnel 给独立故障域 + 独立 API token scope。

Q4. CF Access — 谁能访问 backend?

  • A) Email OTP(团队 allowlist)
  • B) Service token(每 profile 一个,机器对机器)
  • C) 阶段切换:v1.x Email OTP(人手测);P1.1 起 service token(profile 自动调用)

已决 C:service token 是正确的机器鉴权方式,但 v1 阶段还没 provisioner,先用 OTP 给团队人手验证;P1.1 起切。

Q5. 镜像 SHA pinning 在哪做?

  • A) compose.ymlimage: ghcr.io/lacatfly/twilight-drive:0.2.0(tag)
  • B) compose.ymlimage: ghcr.io/lacatfly/twilight-drive@sha256:<digest>(digest pin)
  • C) 两层:CI 用 tag 推,部署用 digest pin

建議 C:tag 易读但可被推覆盖;digest pin 不可篡改。CI workflow 在 release 后把 digest 写回 compose.yml 并自动开 PR(Dependabot-style)。

下一步 (Next Steps)

详见 plans/2026-05-07-deploy-pattern.md。Highlights:

  1. deploy/ 目录到仓库(Dockerfile, compose.yml, systemd units, install.sh, cloudflared config template, env.example) — 这一 PR
  2. CI release.yml 扩展支持 push Docker image(同一 PR 内最好;或分一 PR 跟 image 实测一起)
  3. 实施 P1.0 backend FastAPI(Week 2)—— Dockerfile 等到这一步才能真 build
  4. 在 PRD VPS 上手动跑一次 install.sh(你来)—— 一次性建 tunnel + DNS + Access 应用
  5. CF Pages 给 docs site 加自定义域 dev.fsagent.cc(你在 CF 控制台点一下)

进一步阅读

团队内部文档