# Sub2API VPS 部署 + CI/CD 记录

> 2026-04-19 @ `ai.puro.im` · 217.216.32.230 · Ubuntu 22.04 x86_64

本文档对应 `LOCAL_SETUP_NOTES.md`（Mac 本地开发环境）的线上版本，覆盖：从零到站点可访问、接入 Codex CLI、建立 Drone CI 自动部署的完整流程。

---

## 概览

```
           外网
            │ HTTPS (Let's Encrypt, Caddy auto-TLS)
            ▼
     ai.puro.im :443
            │
     ┌──────┴──────┐
     │   Caddy     │  host, /etc/caddy/conf.d/sub2api.conf
     │ reverse_    │  → 127.0.0.1:8081
     │ proxy       │
     └──────┬──────┘
            │
     docker port map 127.0.0.1:8081 → 容器 8080
            │
   ┌────────▼──────────────┐
   │ sub2api-net (compose) │
   │   sub2api (Go)        │  /opt/sub2api/ (持久化)
   │   sub2api-pg (PG15)   │  ├─ app-data/ (config, .installed, pricing, logs)
   │   sub2api-redis       │  ├─ pg-data/
   │   (redis:7-alpine)    │  └─ redis-data/
   └───────────────────────┘
            │  VPS 出口
            ▼
  api.anthropic.com / chatgpt.com / api.openai.com
```

### CI 流程

```
Mac (本地仓库) ─ git push gitea main ─▶ Gitea (git.puro.im) ──webhook──▶ Drone (devops.puro.im)
                                                                              │
                                                                  ┌───────────┴────────────┐
                                                                  │  build-frontend (pnpm) │
                                                                  │  build-backend (go)    │
                                                                  │  deploy                │
                                                                  │   - cp bin + Dockerfile│
                                                                  │   - docker compose up  │
                                                                  │     --build sub2api    │
                                                                  └────────────────────────┘
```

---

## 一、VPS 环境

| 项 | 值 | 备注 |
|---|---|---|
| 主机 | 217.216.32.230（新加坡） | `ssh vps` |
| OS | Ubuntu 22.04 LTS x86_64 | |
| Caddy | host 安装，`/etc/caddy/conf.d/*.conf` auto-include | 已跑着 puro.im / git.puro.im / devops.puro.im / erp.puro.im / ... |
| Docker | host 装 + `devops-net` 网络 | 各应用容器都在 devops-net 里 |
| Gitea | `git.puro.im:2222` (SSH), 3000 (HTTP) | purovps/sub2api 仓库 |
| Drone | `devops.puro.im`（OAuth via Gitea） | drone-server + drone-runner-docker |
| Sub2API 专用 docker-compose | `sub2api-net` 独立 bridge 网络（**不加 devops-net**） | 隔离 PG/Redis 生命周期 |
| DNS | Cloudflare **DNS only**（非代理模式） | `ai.puro.im` A 记录 → VPS IP |
| 端口冲突 | 宿主 `:8080` 被 drone-server 占 | Sub2API 映射到宿主 `127.0.0.1:8081` |

---

## 二、初次部署流程（手工路径，首次跑通）

> CI 跑通后这段主要作为"首次引导 / 灾难恢复参考"。日常改代码走 CI（下一节）。

### 1. 本机交叉编译 linux/amd64 二进制

```bash
cd /Users/mini/Work/dev/sub2api/backend
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
  go build -tags embed -ldflags='-s -w' -o sub2api-linux ./cmd/server
# 产物 ~75MB, static ELF x86-64
```

注意：本机 macOS arm64，VPS 是 x86_64，**必须交叉编译**。`-tags embed` 把前端 dist 打进二进制（配合先跑 `pnpm run build`）。

### 2. VPS 建目录 + scp 文件

```bash
ssh vps "mkdir -p /opt/sub2api/{app-data,pg-data,redis-data}"

# 准备 deploy 文件（本地 staging 目录）
cp backend/sub2api-linux /Users/mini/Work/dev/sub2api-deploy/
# Dockerfile + docker-compose.yml 见下节内容

scp /Users/mini/Work/dev/sub2api-deploy/{sub2api-linux,Dockerfile,docker-compose.yml} \
    vps:/opt/sub2api/

# distroless:nonroot 需要 UID 65532 可写
ssh vps "chown -R 65532:65532 /opt/sub2api/app-data"
```

### 3. Dockerfile（`.ci/Dockerfile` 也是同一份）

```dockerfile
FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR /app
COPY sub2api-linux /app/sub2api
EXPOSE 8080
ENTRYPOINT ["/app/sub2api"]
```

### 4. docker-compose.yml

关键点：
- `sub2api` 监听 `127.0.0.1:8081:8080`（避开宿主 8080 被 drone 占）
- `/app/data` 整个挂到 `./app-data`（含 config.yaml / install.lock / pricing / logs）
- PG 密码通过 `POSTGRES_PASSWORD` env（不进仓库）
- 独立 `sub2api-net` bridge，不混 devops-net

生产文件位置：`/opt/sub2api/docker-compose.yml`（**不签入 git**，含 PG 密码）。模板结构：

```yaml
services:
  postgres:
    image: postgres:15
    container_name: sub2api-pg
    environment:
      POSTGRES_DB: sub2api
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: "<32-hex 强口令>"
    volumes: [./pg-data:/var/lib/postgresql/data]
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres -d sub2api"]
    restart: unless-stopped
    networks: [sub2api-net]

  redis:
    image: redis:7-alpine
    container_name: sub2api-redis
    command: ["redis-server", "--appendonly", "yes"]
    volumes: [./redis-data:/data]
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
    restart: unless-stopped
    networks: [sub2api-net]

  sub2api:
    build: .
    image: sub2api:local
    container_name: sub2api
    depends_on:
      postgres: { condition: service_healthy }
      redis:    { condition: service_healthy }
    volumes:
      - ./app-data:/app/data
    ports: ["127.0.0.1:8081:8080"]
    restart: unless-stopped
    networks: [sub2api-net]

networks:
  sub2api-net:
    driver: bridge
```

### 5. 起 stack

```bash
ssh vps "cd /opt/sub2api && docker compose up -d --build"
# 等 PG/Redis healthy 后 sub2api 才会起（depends_on + healthcheck 保证）
```

### 6. Caddy 反代

`/etc/caddy/conf.d/sub2api.conf`：

```caddy
ai.puro.im {
    reverse_proxy 127.0.0.1:8081 {
        header_up X-Real-IP {remote_host}
        header_up X-Forwarded-For {remote_host}
        header_up X-Forwarded-Proto {scheme}
    }
    encode gzip zstd
    log {
        output file /var/log/caddy/ai.puro.im.log
        format json
    }
}
```

```bash
ssh vps "systemctl reload caddy"
```

### 7. 首次访问 → Setup Wizard

**不预先写 `/opt/sub2api/app-data/config.yaml`**——让 sub2api 以 wizard 模式启动，自己生成 config。走：

1. 浏览器开 `https://ai.puro.im` → 自动进 Setup Wizard
2. Step 1 数据库：host=`postgres` / port=`5432` / user=`postgres` / password=<compose 里那个> / dbname=`sub2api` / sslmode=`disable`
3. Step 2 Redis：host=`redis` / port=`6379` / password 空 / db=`0`
4. Step 3 Admin：邮箱 + ≥8 位密码（自己选好记下来）
5. Step 4 提交 → wizard 写 `app-data/config.yaml` + `app-data/.installed` + DB 创建 admin → 进程自杀、compose 重启后以正常模式跑

### 8. 加 OpenAI OAuth 账号（账号池）

从本机 `~/.codex/auth.json` 抽 `tokens.refresh_token`，粘到：
- 后台 → 账号管理 → 添加账号 → Platform: OpenAI / Type: OAuth → "粘贴 Refresh Token" → 验证
- 弹出 ChatGPT 邮箱+Plan 即成功

### 9. 三件必调（否则 503 "no available OpenAI accounts"）

见 [§四 · 坑 3](#坑-3-新建-oauth-账号一上来无法调度)，后台 UI 里：
- **开启调度开关**
- **去掉 auto_pause_on_expired**
- **清掉 expires_at**（或设远期）

---

## 三、CI/CD 流水线（Drone）

### 仓库

| 位置 | 用途 |
|---|---|
| GitHub: `Wei-Shaw/sub2api` | 上游，origin remote |
| Gitea: `git.puro.im/purovps/sub2api` | 我们的 fork，**Drone trigger source**，remote 名 `gitea` |

本地开发流程：

```bash
# 日常
git checkout main
# 改代码...
git commit
git push gitea main    # 触发 CI
# 可选同步上游
git fetch origin
git merge origin/main  # 手动决定是否合上游
```

### Drone 激活

首次激活：https://devops.puro.im → Sync → 点亮 `purovps/sub2api` → Settings → 开 **Trusted**（pipeline 要挂 host socket 和 `/opt/sub2api`）。

激活后 Gitea 会自动配 webhook（`POST https://devops.puro.im/hook`，事件 `push, pull_request, ...`）。

### `.drone.yml` 三段 pipeline

```yaml
kind: pipeline
type: docker
name: default
trigger: { branch: [main], event: [push] }

steps:
  - name: build-frontend       # node:18-alpine + pnpm 10.33
    # pnpm install --frozen-lockfile && pnpm run build
    # 产物 → backend/internal/web/dist/

  - name: build-backend        # golang:1.23-alpine + GOTOOLCHAIN=auto
    # 自动拉 Go 1.26.2 toolchain
    # CGO_ENABLED=0 GOOS=linux GOARCH=amd64 → sub2api-linux

  - name: deploy               # docker:cli
    # cp backend/sub2api-linux /opt/sub2api/sub2api-linux
    # cp .ci/Dockerfile       /opt/sub2api/Dockerfile
    # cd /opt/sub2api && docker compose up -d --build sub2api
    # 验证 sub2api 容器 running
```

host 挂载 3 类卷：
- `/var/run/docker.sock` — 用宿主 docker daemon 做构建+重启
- `/opt/sub2api` — 更新 binary/Dockerfile
- `/opt/drone/cache/{pnpm-store,go-build,go-mod}` — 加速后续构建

### 首次构建时长

| Step | 首次 | 缓存命中后 |
|---|---|---|
| build-frontend | ~90s | ~30s |
| build-backend | ~60s（+首次下 Go 1.26.2 toolchain） | ~40s |
| deploy | ~20s | ~20s |
| **合计** | **~3 min** | **~90s** |

### CI 不碰的内容（secrets / 数据）

| 文件/目录 | 位置 | 为什么 |
|---|---|---|
| `docker-compose.yml` | `/opt/sub2api/` | 含 PG 密码 |
| `app-data/config.yaml` | `/opt/sub2api/app-data/` | JWT secret / 运行态 |
| `pg-data/` | `/opt/sub2api/` | PG 数据 |
| `redis-data/` | `/opt/sub2api/` | Redis 数据 |

CI 只：
- 覆写 `/opt/sub2api/sub2api-linux`（二进制）
- 覆写 `/opt/sub2api/Dockerfile`
- `docker compose up -d --build sub2api` — 只重建 `sub2api` service（PG/Redis 不动）

### Skip CI

文档/无代码变动想跳过构建：commit 消息加 `[CI SKIP]`（Drone 官方约定）。

---

## 四、踩坑与解法

### 坑 1：Setup Wizard 默认写 `server.port: 443`

**现象**：Wizard 跑完 → 容器重启 → `127.0.0.1:8081` 无响应（app 启动了但监听在 container 内的 :443）。

**根因**：Wizard 默认把 `server.port` 写成 443（面向"容器直接暴露 HTTPS"的用法，不适合我们"容器内 8080 + Caddy 外 443"的模式）。

**解法**：
```bash
ssh vps "sed -i 's/port: 443/port: 8080/' /opt/sub2api/app-data/config.yaml && docker restart sub2api"
```

后续 CI 不重写 config.yaml，这个修复是一次性的。

---

### 坑 2：distroless:nonroot 日志目录权限

**现象**：容器启动日志刷 `write error: can't open new logfile: open /app/data/logs/sub2api.log: permission denied`。

**根因**：`gcr.io/distroless/static-debian12:nonroot` 进程 UID=65532，但 `/opt/sub2api/app-data/logs` 宿主目录 owner=root。

**解法**：
```bash
ssh vps "chown -R 65532:65532 /opt/sub2api/app-data"
```

一次性。CI 不碰 app-data，所以不复发。

---

### 坑 3：新建 OAuth 账号一上来"无法调度"

**现象**：refresh_token 粘好、验证通过、UI 显示"正常"，curl `/responses` 报 `503 "Service temporarily unavailable"`，日志 `openai.account_select_failed: no available OpenAI accounts`。

**根因（三个叠加）**：
1. UI 默认 `schedulable=false`
2. Wizard/后端给 `expires_at` 写了 **access_token 的短期过期**（~7 分钟后），而 `auto_pause_on_expired=true` 默认开
3. 每次 sub2api 重启 → `[AccountExpiry] Auto paused 1 expired accounts` → schedulable 被打回 false

**解法**（SQL 或 UI 都可）：
```sql
UPDATE accounts
SET schedulable=true, expires_at=NULL, auto_pause_on_expired=false
WHERE id=<n>;
```

**已存 memory**：`feedback_sub2api_account_pitfalls.md`。长期方案：修 fork 里的账号创建逻辑，OAuth 账号的 `expires_at` 应指 refresh_token 过期或订阅到期，不是 access_token。

---

### 坑 4：`run_mode: simple` 隐藏 SaaS 菜单

**现象**：为跳过 `INSUFFICIENT_BALANCE` 临时切到 `run_mode: simple`，重登后台发现**用户管理 / 分组管理 / 渠道管理 / 订阅管理 / 兑换码 / 优惠码**全消失。

**根因**：**设计行为**，不是 bug。`simple` 模式是给 "单用户/团队内部工具" 用，刻意隐藏 SaaS 管理面板；前端 `stores/auth.ts` 里 `isSimpleMode = computed(() => runMode === 'simple')` 控制路由可见性。

**解法**：
- 如果需要管理 group/订阅/计费/兑换码 → 保持 `run_mode: standard`
- `INSUFFICIENT_BALANCE` 的替代方案：给 admin 塞大额 balance 即可（见坑 5）

---

### 坑 5：`standard` 模式 + admin balance=0 → 403

**现象**：切回 `standard` 后 curl `/responses` 报 `INSUFFICIENT_BALANCE`。

**根因**：中间件 `api_key_auth.go:198` 检查 `apiKey.User.Balance <= 0` → 403。admin 默认余额为 0。

**解法**：
```sql
UPDATE users SET balance = 1000000000 WHERE id=1;
```

10 亿够跑很久。接 iShare 后改用 iShare 订阅模式（sub2api 标记为 "subscription mode"，走订阅限额而不查 balance）。

---

### 坑 6：Redis L2 cache stale

**现象**：跑 [坑 5] 的 SQL 之后、docker restart sub2api 之后，curl 仍报 `INSUFFICIENT_BALANCE`。

**根因**：Sub2API 的 API key 鉴权有两级缓存：
- L1 进程内 LRU（TTL 15s）
- L2 Redis（key `sub2api:apikey:*`，**TTL 300s**）

docker restart 只清 L1，L2 是独立 Redis 容器，保留着旧的 `balance=0` 缓存条目。

**解法**：
```bash
ssh vps "docker exec sub2api-redis redis-cli FLUSHDB && docker restart sub2api"
```

**一般经验**：改 DB 里的 user / api_key / account 后需重启 sub2api **+** 清 Redis。只重启 sub2api 不够。

---

### 坑 7：`/setup/*` 路由按需注册

**现象**：首次手工写了 config.yaml 让容器跑起来，后来想走 Wizard 重新配置；前端进了 `/setup` 页，填完点"测试连接" → `Request failed with status code 404`。

**根因**：`cmd/server/main.go` 里只有 `NeedsSetup() == true` 分支（走 `runSetupServer()`）才 `setup.RegisterRoutes(r)`。config.yaml 已存在时走 `runMainServer()`，setup 路由不注册 → 前端 `/setup/test-db` → 404。

**解法**：让 `NeedsSetup()` 返回 true，即 `/app/data/config.yaml` **和** `/app/data/.installed` 都不存在。具体做法：
```bash
ssh vps "cd /opt/sub2api && mv app-data/config.yaml app-data/config.yaml.bak; \
  mv app-data/.installed app-data/.installed.bak 2>/dev/null; \
  docker restart sub2api"
```

---

## 五、当前状态 snapshot（2026-04-19）

```
Domain    https://ai.puro.im  (HTTP/2, Let's Encrypt via Caddy)
VPS       217.216.32.230 (Singapore, Ubuntu 22.04)
Image     sub2api:local (Drone CI-built, distroless + static Go binary)

Containers on VPS:
  sub2api          ← Drone 每次 push 重建
  sub2api-pg       postgres:15   (devops-net 无关，独立 sub2api-net)
  sub2api-redis    redis:7-alpine

Accounts:
  admin@puro.im (id=1, role=admin, balance=1e9)
  test_myopenai (OpenAI OAuth, group=test_codex, schedulable=true)

API keys:
  sk-d2132de2f0b4c1ab64ef7241a16d254cab483f1f8afd47ad4a89e39cf6e2345a
  (user=admin, group=test_codex)

Config:
  run_mode: standard
  server.port: 8080 (容器内)
  database.host: postgres (compose DNS)
  redis.host: redis (compose DNS)

CI:
  Drone job trigger = git push gitea main
  Gitea webhook → Drone webhook (active)
  最近一次 build: commit 9c34e961 ✓ (前端 ~90s + 后端 ~60s + deploy ~20s)
```

---

## 六、常用运维命令

### 日常
```bash
# 状态
ssh vps "cd /opt/sub2api && docker compose ps"

# 日志
ssh vps "docker logs sub2api --tail 100 -f"
ssh vps "tail -f /var/log/caddy/ai.puro.im.log"

# 强制重启 sub2api（不动 PG/Redis）
ssh vps "docker restart sub2api"

# 重载 Caddy
ssh vps "systemctl reload caddy"

# 清 Redis 缓存（改 DB 后要做）
ssh vps "docker exec sub2api-redis redis-cli FLUSHDB"
```

### 触发 CI 重建
```bash
git commit --allow-empty -m "ci: rebuild" && git push gitea main
# 跟 build 进度（可选）
ssh vps "docker ps --filter 'name=^drone-'"
```

### 数据备份
```bash
# PG dump
ssh vps "docker exec sub2api-pg pg_dump -U postgres sub2api" \
  > ~/backups/sub2api-$(date +%Y%m%d).sql

# 整个 /opt/sub2api/
ssh vps "tar -czf /tmp/sub2api-full-$(date +%Y%m%d).tgz -C /opt sub2api"
scp vps:/tmp/sub2api-full-*.tgz ~/backups/
```

### 查关键 DB 信息
```sql
-- 账号池
SELECT id, name, platform, type, status, schedulable, expires_at FROM accounts;

-- API keys
SELECT id, user_id, name, status, group_id, quota, quota_used FROM api_keys;

-- 用户 + 余额
SELECT id, email, role, balance FROM users;

-- 今日用量
SELECT model_name, SUM(total_tokens), SUM(total_cost)
  FROM usage_logs
 WHERE created_at > NOW() - INTERVAL '24 hours'
 GROUP BY model_name
 ORDER BY 2 DESC;
```

---

## 七、清理/从头重来

```bash
# 停 + 删所有容器
ssh vps "cd /opt/sub2api && docker compose down"

# 清数据（会彻底删 wizard 数据、用户、账号池！）
ssh vps "rm -rf /opt/sub2api/{app-data,pg-data,redis-data}"
ssh vps "mkdir -p /opt/sub2api/{app-data,pg-data,redis-data}"
ssh vps "chown -R 65532:65532 /opt/sub2api/app-data"

# 重新起（走 Wizard）
ssh vps "cd /opt/sub2api && docker compose up -d"

# Caddy 保留，不用动
```

**不清数据的"纯重建"**（CI 跑一遍等价）：
```bash
git commit --allow-empty -m "ci: rebuild" && git push gitea main
```

---

## 参考

- 本地开发：`LOCAL_SETUP_NOTES.md`
- CI 配置：`.drone.yml`、`.ci/Dockerfile`、`.ci/README.md`
- 上游：https://github.com/Wei-Shaw/sub2api
- Fork：https://git.puro.im/purovps/sub2api
