P3-2 baseline 落库从路由下沉到服务层(predict_baseline 内直接落库),
删除路由层 _persist_baseline,三种模式统一 result.prediction_id,对外 JSON 不变。
P3-1 MatchPredictPanel.PredictionPanel 拆为 OutcomePanel/AgentsPanel/ReasoningPanel
三个子组件,本文件保留 PredictModal/PredictProgress/Spinner,对外导出路径不变。
P3-3 docs 加 ⚠️ 多 worker 陷阱红字 + STRICT_SINGLE_WORKER 环境变量(启动期强制拒绝多 worker)。
P3-4 docs 新增「同站部署 vs 跨站 CSRF」节。
312 lines
12 KiB
Markdown
312 lines
12 KiB
Markdown
# 06 · 部署
|
||
|
||
## 前置要求
|
||
|
||
- Docker & Docker Compose
|
||
- bzzoiro API Key(必填)
|
||
- LLM API Key(必填,OpenAI / Deepseek / 兼容接口)
|
||
|
||
## Docker Compose 部署(推荐)
|
||
|
||
```bash
|
||
# 1. 配置环境变量
|
||
cp .env.example .env
|
||
# 编辑 .env: 填 LLM_API_KEY / BZZOIRO_KEY
|
||
|
||
# 2. 启动(自动执行数据库迁移)
|
||
docker compose up -d --build
|
||
|
||
# 3. 验证
|
||
curl http://localhost:8000/health
|
||
```
|
||
|
||
`docker-compose.yml` 包含 3 个服务:
|
||
|
||
| 服务 | 端口 | 说明 |
|
||
|---|---|---|
|
||
| `postgres` | 5433 | PostgreSQL 16 |
|
||
| `api` | 8000 | FastAPI 应用(启动时自动执行 `alembic upgrade head`) |
|
||
| `frontend` | 3000 | React 前端(多阶段构建,nginx 服务静态文件) |
|
||
|
||
数据卷 `pgdata` 持久化数据库,重启不丢数据。
|
||
|
||
> **注意**: `api` 服务启动时会先执行 `alembic upgrade head` 迁移数据库,再启动 uvicorn。
|
||
> 容器内数据库连接自动使用 `postgres` 服务名(通过 compose `environment` 覆盖 `.env` 中的 `DB_HOST`)。
|
||
|
||
## 生产上线检查清单
|
||
|
||
公网上线前逐项勾选。第 1–4 项由 `src/core/security_check.py` 在 `APP_ENV=production`
|
||
启动时**强制校验**,不满足直接拒绝启动(开发环境仅告警);管理鉴权另有请求期 fail-closed(503)。
|
||
|
||
- [ ] **1. `APP_ENV=production`** — 安全校验、Cookie `Secure`、管理端点 fail-closed 均以它为总开关
|
||
- [ ] **2. `SECRET_KEY` 强随机** — 用 `openssl rand -base64 32` 生成;禁止弱值/短值(弱值黑名单会拒绝启动,含把生成指令原样粘进去的情况)
|
||
- [ ] **3. 管理鉴权至少其一** — `ADMIN_PASSWORD` 或 `ADMIN_API_KEY`(后台改过密码后以数据库哈希优先);两者皆空时管理接口 503
|
||
- [ ] **4. 数据库强密码** — 禁止 `football:football` 等示例弱密码(启动校验会拒绝);compose 的 `POSTGRES_PASSWORD` 必填,缺失时容器拒绝启动
|
||
- [ ] **5. HTTPS** — 由反代(Nginx/Caddy)终结 TLS;`APP_ENV=production` 下会话 Cookie 自动 `Secure`(且 HttpOnly + SameSite=Lax)
|
||
- [ ] **6. 反代信任头** — `TRUST_PROXY_HEADERS=True`,且**仅可信反代可达 API**;反代需设置 `X-Forwarded-For`(`$proxy_add_x_forwarded_for`)与 `X-Real-IP`,否则限流/日志按反代 IP 计数
|
||
- [ ] **7. 限流前置到网关** — 推荐 Nginx `limit_req`(配置见[安全与限流](#安全与限流));应用内限流与 KeyRing 为**单进程内存实现**,多 worker 各自独立计数会把实际配额放大 N 倍(启动时会打印一次性告警)
|
||
- [ ] **8. uvicorn 单 worker** — compose/Dockerfile 默认单 worker,保持即可;需横向扩容时先在网关统一限流,再起多实例(每实例仍单 worker)
|
||
|
||
> ⚠️ **多 worker 陷阱**:应用内限流(`_RateLimiter`)与 KeyRing 均为**进程内纯内存状态**,多 worker 部署(如 `uvicorn --workers 4`)时各进程**各自独立计数、互不共享**——实际限流配额会被放大 N 倍、KeyRing 限流状态也不同步。
|
||
> 若确需多 worker,必须前置 Nginx/网关做**全局限流**(见[安全与限流](#安全与限流)),并设环境变量 `STRICT_SINGLE_WORKER=True`(见下)在启动期强制拒绝多 worker,避免静默配额漂移。
|
||
- [ ] **9. 启动后健康检查** — `curl /health` 返回 200(存活);`curl /health/ready` 返回 200(就绪,校验数据库连通,不可达时 503)
|
||
- [ ] **10. 数据库迁移** — compose/Dockerfile 启动命令已内置 `alembic upgrade head && uvicorn …`,升级镜像重启即自动迁移,无需手动执行
|
||
|
||
## 本地开发部署
|
||
|
||
```bash
|
||
# 1. 安装依赖
|
||
pip install -e ".[dev]"
|
||
|
||
# 2. 启动 PostgreSQL(单独)
|
||
docker run -d --name profeto-pg \
|
||
-e POSTGRES_USER=football -e POSTGRES_PASSWORD=football -e POSTGRES_DB=football \
|
||
-p 5432:5432 postgres:16-alpine
|
||
|
||
# 3. 配置 .env
|
||
cp .env.example .env
|
||
|
||
# 4. 建表
|
||
alembic upgrade head
|
||
|
||
# 5. 启动 API
|
||
uvicorn src.api.app:app --reload
|
||
|
||
# 6. 启动前端(另一个终端)
|
||
cd frontend && npm install && npm run dev
|
||
```
|
||
|
||
访问:
|
||
- API 文档: http://localhost:8000/docs
|
||
- 前端界面: http://localhost:5173
|
||
|
||
## 环境变量
|
||
|
||
| 变量 | 必需 | 默认值 | 说明 |
|
||
|---|---|---|---|
|
||
| `APP_ENV` | ❌ | `development` | `production` / `development` |
|
||
| `LOG_LEVEL` | ❌ | `INFO` | 日志级别 |
|
||
| `API_PORT` | ❌ | `8000` | API 服务端口映射 |
|
||
| `FRONTEND_PORT` | ❌ | `3000` | 前端服务端口映射 |
|
||
| `POSTGRES_USER` | ✅ | — | PostgreSQL 用户名 |
|
||
| `POSTGRES_PASSWORD` | ✅ | — | PostgreSQL 密码 |
|
||
| `POSTGRES_DB` | ❌ | `football` | PostgreSQL 数据库名 |
|
||
| `POSTGRES_PORT` | ❌ | `5433` | PostgreSQL 端口映射 |
|
||
| `DATABASE_URL` | ✅ | — | PostgreSQL 连接 URL(Docker 内会被覆盖) |
|
||
| `LLM_PROVIDER` | ❌ | `openai` | 提供商名(仅标记) |
|
||
| `LLM_API_KEY` | ✅ | — | API Key |
|
||
| `LLM_BASE_URL` | ❌ | `https://api.openai.com/v1` | 接口地址(Ollama/Deepseek 用) |
|
||
| `LLM_MODEL` | ❌ | `gpt-4o` | 默认模型 |
|
||
| `LLM_TIMEOUT` | ❌ | `60` | 单次调用超时(秒) |
|
||
| `LLM_SPECIALIST_MODEL` | ❌ | — | 专家模型(回落 `LLM_MODEL`) |
|
||
| `LLM_AGGREGATOR_MODEL` | ❌ | — | 终裁模型(回落 `LLM_MODEL`) |
|
||
| `BZZOIRO_KEY` | ✅ | — | bzzoiro 数据源 Key(唯一数据源) |
|
||
| `CORS_ORIGINS` | ❌ | `http://localhost:5173,...` | 允许的跨域来源 |
|
||
| `STRICT_SINGLE_WORKER` | ❌ | `False` | `True` 时若以多 worker 启动则拒绝(防限流配额漂移) |
|
||
| `SECRET_KEY` | ❌ | — | 加密主密钥(生产环境必填) |
|
||
| `ADMIN_PASSWORD` | ❌ | — | 管理后台密码(留空=不启用) |
|
||
| `ADMIN_API_KEY` | ❌ | — | 机器/脚本调用的 API Key |
|
||
|
||
## 同站部署 vs 跨站 CSRF
|
||
|
||
Profeto 管理鉴权使用 **HttpOnly Cookie 会话**(登录后服务端写入),`allow_credentials=True` 的 CORS 配置允许浏览器跨域携带 Cookie——这也引入了 CSRF 面。部署拓扑决定风险等级:
|
||
|
||
**同站部署(推荐)**: 前端与 API 同域(反代把 `/` 与 `/api` 都转发到同一后端,或同源端口)。
|
||
- 浏览器视为 **same-origin**,CORS 不触发;`SameSite=Lax` 会话 Cookie 天然阻断跨站请求携带。
|
||
- 风险最低。`CORS_ORIGINS` 可设为空或同域来源,仅作兜底。
|
||
|
||
**跨站部署**: 前端与 API 不同域(如前端 `app.example.com`、API `api.example.com`,或开发时 `localhost:3000` → `localhost:8000`)。
|
||
- 必须把 API 域名列入 `CORS_ORIGINS`,且 `allow_credentials=True` 才能携带 Cookie。
|
||
- 此时任何被允许域下的页面都能构造带 Cookie 的请求 → **CSRF 面**:
|
||
- 状态变更接口(采集/回测/改密等写操作)要求**管理员 Cookie + 同域**,攻击者无法从第三方站点读取 Cookie,但可构造跨域表单/请求——`SameSite=Lax` 会阻断跨站 POST 表单提交(顶级导航 GET 仍放行),这是当前主要防线。
|
||
- `GET /api/v1/admin/*` 只读接口受 `SameSite=Lax` 下顶级导航可能被利用,但攻击者无法读取响应(CORS 不匹配时浏览器拦截)。
|
||
- **加固建议**:
|
||
1. 反代层加 `Origin`/`Referer` 校验,仅放行 `CORS_ORIGINS` 列表中的来源(即便 FastAPI CORS 已通过,反代校验是多一层纵深)。
|
||
2. 写操作要求自定义请求头(如 `X-Requested-With: XMLHttpRequest`),第三方站点无法在无预检下添加自定义头,天然阻断简单跨站 POST。
|
||
3. 生产强制 HTTPS(`APP_ENV=production` 下 Cookie 自动 `Secure`),防中间人窃 Cookie。
|
||
|
||
## LLM 提供商配置示例
|
||
|
||
### OpenAI
|
||
```bash
|
||
LLM_API_KEY=sk-xxxx
|
||
LLM_BASE_URL=https://api.openai.com/v1
|
||
LLM_MODEL=gpt-4o
|
||
```
|
||
|
||
### Deepseek
|
||
```bash
|
||
LLM_API_KEY=sk-xxxx
|
||
LLM_BASE_URL=https://api.deepseek.com/v1
|
||
LLM_MODEL=deepseek-chat
|
||
```
|
||
|
||
### Ollama(本地)
|
||
```bash
|
||
LLM_API_KEY=ollama
|
||
LLM_BASE_URL=http://localhost:11434/v1
|
||
LLM_MODEL=llama3.1
|
||
```
|
||
|
||
### 分档配置(专家用便宜模型)
|
||
```bash
|
||
LLM_MODEL=gpt-4o
|
||
LLM_SPECIALIST_MODEL=gpt-4o-mini
|
||
LLM_AGGREGATOR_MODEL=gpt-4o
|
||
```
|
||
|
||
## 数据库迁移
|
||
|
||
Alembic 管理 schema 变更:
|
||
|
||
```bash
|
||
# 查看当前版本
|
||
alembic current
|
||
|
||
# 升级到最新
|
||
alembic upgrade head
|
||
|
||
# 回退一级
|
||
alembic downgrade -1
|
||
|
||
# 生成新迁移(改 models.py 后)
|
||
alembic revision --autogenerate -m "描述"
|
||
|
||
# 空迁移(手动写 SQL)
|
||
alembic revision -m "描述"
|
||
```
|
||
|
||
**Docker Compose 自动迁移**: `api` 容器启动时会自动执行 `alembic upgrade head`,
|
||
无需手动运行。本地开发时需手动执行迁移。
|
||
|
||
已有迁移:
|
||
- `0001_initial`: 初始 5 张表
|
||
- `0002_agent_outputs`: predictions 加 `mode` + `agent_outputs`
|
||
- `0003_injuries`: 增加 injuries 表
|
||
- `0004_snapshot_and_constraints`: 增加约束
|
||
- `0005_prediction_status_and_stats_provenance`: 增加时间语义
|
||
- `0006-0012`: 后续 schema 调整、约束命名对齐、partial unique index 等
|
||
|
||
## 备份与恢复
|
||
|
||
```bash
|
||
# 备份
|
||
docker exec profeto-postgres pg_dump -U football football > backup.sql
|
||
|
||
# 恢复
|
||
cat backup.sql | docker exec -i profeto-postgres psql -U football football
|
||
```
|
||
|
||
## 监控
|
||
|
||
### 健康检查
|
||
|
||
| 端点 | 含义 | HTTP 状态码 |
|
||
|---|---|---|
|
||
| `/health` | 存活检查(liveness) | 始终 200(进程在跑即活) |
|
||
| `/health/ready` | 就绪检查(readiness) | DB 可达 200,不可达 **503** |
|
||
|
||
### 探针配置
|
||
|
||
#### Docker Compose
|
||
|
||
`docker-compose.yml` 已为 `api` 服务配置 readiness:
|
||
|
||
```yaml
|
||
healthcheck:
|
||
test: ["CMD-SHELL", "curl -sf http://localhost:8000/health/ready || exit 1"]
|
||
interval: 10s
|
||
timeout: 5s
|
||
retries: 3
|
||
start_period: 10s
|
||
```
|
||
|
||
**要点**:必须指向 `/health/ready` 而非 `/health`——后者始终 200,在数据库故障时仍会接收流量,导致请求全部失败。
|
||
|
||
#### Kubernetes
|
||
|
||
```yaml
|
||
livenessProbe:
|
||
httpGet:
|
||
path: /health
|
||
port: 8000
|
||
initialDelaySeconds: 5
|
||
periodSeconds: 15
|
||
readinessProbe:
|
||
httpGet:
|
||
path: /health/ready
|
||
port: 8000
|
||
initialDelaySeconds: 10
|
||
periodSeconds: 10
|
||
failureThreshold: 3
|
||
```
|
||
|
||
**两探针必须区分**:
|
||
- `livenessProbe` 用 `/health`:仅在进程死锁/崩溃时重启,避免误杀。
|
||
- `readinessProbe` 用 `/health/ready`:DB 不可用时停止转发流量,恢复后自动切回。
|
||
|
||
#### 验证
|
||
|
||
```bash
|
||
# 宿主机直接运行(经本地 8000 端口)
|
||
python3 tests/test_health_ready.py
|
||
```
|
||
|
||
### 评估
|
||
|
||
## 安全与限流
|
||
|
||
### 内存限流(按进程)
|
||
|
||
`/api/v1/predict` 与登录防爆破均使用**进程内内存**计数:
|
||
|
||
| 机制 | 位置 | 局限 |
|
||
|------|------|------|
|
||
| `/predict` 限流 | `_RateLimiter`(内存) | 每 worker 独立计数,不共享 |
|
||
| 登录防爆破 | `_fail_times`(内存) | 同上 |
|
||
|
||
**多 worker 部署时**(如 `uvicorn --workers 4`),每进程各自计数,实际限额为 `N × 单进程限制`。
|
||
|
||
### 公网部署建议
|
||
|
||
```
|
||
┌─────────┐ ┌──────────┐ ┌──────────┐
|
||
│ Client │────▶│ Nginx │────▶│ API │
|
||
│ │ │ 限流层 │ │ 内存限流 │
|
||
└─────────┘ └──────────┘ └──────────┘
|
||
```
|
||
|
||
**推荐配置**:
|
||
|
||
1. **Nginx 层限流**(第一道防线):
|
||
```nginx
|
||
limit_req_zone $binary_remote_addr zone=predict:10m rate=10r/m;
|
||
location /api/v1/predict {
|
||
limit_req zone=predict burst=20 nodelay;
|
||
proxy_pass http://api:8000;
|
||
}
|
||
```
|
||
|
||
2. **TRUST_PROXY_HEADERS=True** 时必须由可信反代设置 `X-Forwarded-For`:
|
||
```nginx
|
||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||
```
|
||
- 若为 `False`: 仅用 `request.client.host`,忽略 `X-Forwarded-For`,防伪造
|
||
- 若为 `True`: 解析 `X-Forwarded-For` 第一个 IP,反代后方可信
|
||
|
||
3. **生产环境必须配置管理鉴权**:
|
||
```bash
|
||
APP_ENV=production
|
||
REQUIRE_ADMIN_AUTH=True
|
||
ADMIN_PASSWORD=your_secure_password
|
||
```
|
||
未配置时 `/admin` 等管理接口返回 503。
|
||
|
||
### 参数调优
|
||
|
||
| 参数 | 默认 | 说明 |
|
||
|------|------|------|
|
||
| `_predict_limiter.max_requests` | 10 | 每分钟每 IP 最大请求数 |
|
||
| `_predict_limiter.window_seconds` | 60 | 滑动窗口时长 |
|
||
| `ADMIN_SESSION_TTL_HOURS` | 168 | 管理会话有效期(天) |
|