# 部署

[English](deployment.md) | [简体中文](deployment.zh-CN.md)

## 本地二进制

```bash
cargo build --workspace --release
./target/release/protoflux --config /path/to/server.toml serve
```

## Docker

### 构建镜像

默认构建与宿主机相同的架构：

```bash
docker build -t protoflux .
```

#### OpenTelemetry 构建

如需包含 OpenTelemetry 分布式追踪支持，使用 `otel` feature flag 构建：

```bash
docker build --build-arg BUILD_FEATURES="--features otel" -t protoflux:otel .
```

这会将 OTLP gRPC 导出器（通过 tonic）编译到二进制中。不包含 feature flag 时，OTel 相关配置字段（`otel_endpoint`、`otel_service_name`）会被忽略。此 feature 由于 gRPC/protobuf 依赖链会增加约 15MB 的二进制大小。

> **注意**：默认 Dockerfile 的 `cargo build` 命令不包含 `--features otel`。如需启用，修改 Dockerfile 中的 `RUN cargo build` 行为：`RUN cargo build --release --bin protoflux --features otel`

#### 交叉架构构建

使用 `--platform` 构建其他架构（需要 [buildx](https://docs.docker.com/build/architecture/)）：

```bash
# x86_64 / amd64
docker buildx build --platform linux/amd64 -t protoflux:amd64 --load .

# ARM64（Apple Silicon、AWS Graviton、树莓派）
docker buildx build --platform linux/arm64 -t protoflux:arm64 --load .

# 多架构镜像（推送到仓库，不能用 --load）
docker buildx build --platform linux/amd64,linux/arm64 -t registry.example.com/protoflux:latest --push .
```

构建过程会将控制台前端资源（`console/`）嵌入到二进制文件中，运行时镜像不包含源码。

### 快速启动

镜像内置 `server.docker.toml`，所有配置字段都通过 `${ENV_VAR}` 引用环境变量，每个变量都有默认值。

```bash
# 最简启动（使用全部默认值）
docker run --rm -p 7890:7890 protoflux
```

> **注意**：Dockerfile 设置了 `ENV PORT=7890`，与代码默认值一致。按需映射主机端口（`-p 7890:7890`）。

### 使用 PostgreSQL 存储（推荐）

```bash
docker run --rm -p 7890:7890 \
  -e POSTGRES_URL="postgresql://user:password@pg-host:5432/protoflux" \
  -e CONSOLE_ENABLED=true \
  -e CONSOLE_PASSWORD="your-secret-password" \
  protoflux
```

### Docker Compose

```yaml
version: "3.8"

services:
  gateway:
    image: protoflux:latest
    ports:
      - "7890:7890"
    environment:
      POSTGRES_URL: "postgresql://postgres:password@db:5432/protoflux"
      CONSOLE_ENABLED: true
      CONSOLE_PASSWORD: "my-console-password"
      LOG_LEVEL: info
    depends_on:
      - db

  db:
    image: postgres:17-alpine
    environment:
      POSTGRES_DB: protoflux
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: password
    volumes:
      - pgdata:/var/lib/postgresql/data
    ports:
      - "5432:5432"

volumes:
  pgdata:
```

### 自定义配置文件

如果需要更灵活的配置（如自定义 CORS 规则、配置上游 Provider），可以挂载配置文件：

```bash
docker run --rm -p 7890:7890 \
  -v $(pwd)/server.docker.toml:/etc/protoflux/server.toml \
  -e POSTGRES_URL="postgresql://..." \
  protoflux
```

### 环境变量

> **注意**：下面的环境变量之所以有效，是因为 `server.docker.toml` 对每个字段使用了 `${VAR}` 语法。如果你挂载了硬编码值的自定义 TOML，请在该文件中用 `${VAR}` 引用相应环境变量——网关只展开 TOML 中出现的 `${VAR}` 引用，不存在带前缀的环境变量覆盖层。详见[配置来源](configuration.zh-CN.md#配置来源)。

所有配置字段均可通过环境变量覆盖：

#### 服务端

| 变量 | 默认值 | 说明 |
|------|--------|------|
| `HOST` | `0.0.0.0` | 监听地址 |
| `PORT` | `7890` | 监听端口（代码默认和 Dockerfile 默认均为 `7890`） |
| `MAX_REQUEST_SIZE_MB` | `50` | 最大请求体大小（MB） |
| `WORKER_THREADS` | `4` | Tokio worker 线程数 |
| `UPSTREAM_IDLE_CONNECTIONS` | `32` | 每个上游主机的空闲连接池大小 |
| `UPSTREAM_USER_AGENT` | `Protoflux` | 上游请求 User-Agent |
| `STORAGE_MODE` | `postgresql` | 存储后端：`sqlite` 或 `postgresql`（代码默认 `sqlite`；Dockerfile 覆盖为 `postgresql`） |
| `POSTGRES_URL` | *(空)* | PostgreSQL 连接 URL |
| `SQLITE_PATH` | `/var/lib/protoflux/protoflux.sqlite` | SQLite 文件路径 |
| `REDIS_URL` | *(空)* | Redis 连接 URL（分布式状态共享） |
| `REDIS_KEY_PREFIX` | `protoflux:` | Redis key 前缀（多集群共享同一 Redis 时区分） |
| `SCRIPT_MAX_OPERATIONS` | `2000` | JavaScript最大操作数 |
| `METRICS_AUTH_TOKEN` | *(空)* | `/metrics` 端点的 Bearer Token（空 = 端点禁用，返回 403） |
| `REQUEST_TIMEOUT_SECS` | *(空)* | 全局请求超时时间（秒），默认 120，SSE 流式请求豁免 |
| `GRACEFUL_SHUTDOWN_TIMEOUT_SECS` | *(空)* | 优雅关闭期间等待进行中请求完成的最大秒数，默认 30 |
| `PRE_STOP_DELAY_SECS` | *(空)* | 收到 SIGTERM 后休眠秒数再停止（用于 K8s endpoint 摘除，默认 15） |
| `IP_RATE_LIMIT_RPM` | *(空)* | 每 IP 限速（请求/分钟，滑动窗口） |
| `TRUSTED_PROXIES` | *(空)* | 受信任代理 CIDR（逗号分隔），用于解析 X-Forwarded-For |

#### 流式（SSE）

`[streaming]` 段字段的环境变量覆盖（字段含义见 [配置参考](configuration.zh-CN.md#流式配置)）：

| 变量 | 默认值 | 说明 |
|------|--------|------|
| `STREAMING_KEEPALIVE_SECONDS` | `15` | SSE keep-alive 心跳间隔（秒），网关按此间隔发 `:keep-alive` 注释防代理/浏览器超时断开 |
| `STREAMING_BOOTSTRAP_RETRIES` | `1` | 首个字节前的重试次数（首个 SSE 数据块发送前失败才重试，发过后不再重试） |
| `STREAMING_LOG_TIMEOUT_SECS` | `3600` | SSE 流式日志后台任务超时（秒），流异常终止时释放僵尸任务 |
| `STREAM_IDLE_TIMEOUT_SECS` | `600` | 上游空闲超时（秒）：上游连续 N 秒不发任何数据则关流，**覆盖首字节等待与流中途停顿**（不只首 token）。设为 0 禁用。须小于 `upstream_read_timeout_secs` 以让网关层先触发（代码默认 `300`；Dockerfile 覆盖为 `600`） |
| `MAX_STREAM_DURATION_SECS` | `3600` | 单个流式响应最大总时长（秒），超时无条件关流。设为 0 禁用 |

#### 内存

| 变量 | 默认值 | 说明 |
|------|--------|------|
| `MEMORY_SOFT_LIMIT_MB` | `0` | 内存上限（MB，= Critical 硬拒线，ADR-005）。`0` = 自动探测（cgroup/ECS Container Metadata/`/proc/meminfo`/`sysctl`，limit − headroom）。手动设置覆盖自动探测 |
| `MEMORY_QUEUE_TIMEOUT_SECS` | `300` | 请求在内存压力队列中的最大等待时间（秒），超时返回 429 |
| `SPILL_WRITE_CONCURRENCY` | `4` | 并发 spill 写文件数（spawn_blocking slot，每 slot ~2.3MB） |
| `MEMORY_CONSUME_MAX_CONCURRENT` | `100` | Warning/Pressure/Reclaim 回灌速率（从 spill 读回内存的并发数） |
| `MEMORY_CONSUME_CRITICAL_MAX_CONCURRENT` | `2` | Critical 回灌速率（内存耗尽时仅少量 body 回灌） |
| `MEMORY_HOLD_SECS` | `5` | 水位去抖保持时间（秒）——RSS 跌回 Warning 以下后门控保持 N 秒再解除，防振荡 |
| `MEMORY_RATE_ESCALATE_MB` | `40` | 每 tick MB 增长阈值——单 tick RSS 增长超此值时提前升级水位，比绝对阈值更快捕捉突发 |
| `MEMORY_ADMISSION_ENABLED` | `true` | 启用前馈准入（ADR-005）：请求放行前按实时 RSS + in-flight body 预算 + Content-Length 逐请求决策，超 admit 线落盘排队，堵死 Normal 直通在 watchdog 下一 tick 前的突发盲区。`false` = 回退旧反馈式 |
| `MEMORY_ADMISSION_THRESHOLD_PCT` | `70` | 前馈 admit 线 = cap × pct / 100（默认与 Warning 水位 70% 对齐） |
| `MEMORY_ADMISSION_FRESH_READ_PCT` | `70` | 接近 admit 线时同步重读 RSS 的阈值（RSS 快照最多 ~1s 陈旧） |
| `MEMORY_FORCE_SPILL_BODY_MB` | `0` | 强制落盘阈值（MB，`0` = 禁用）——body 超此大小无条件落盘，兜底一次性 consume 读回成本 |
| `MEMORY_ADMISSION_HANDLER_PERMIT` | `30` | 单 handler worst-case 内存（MB，默认 30）。`handler_permits_max = budget / (permit × 1MB)`。覆盖 body + response + reasoning + serde 开销。必须 > 0 |

#### Web 控制台

| 变量 | 默认值 | 说明 |
|------|--------|------|
| `CONSOLE_ENABLED` | `false` | 是否启用控制台 |
| `CONSOLE_PASSWORD` | *(空)* | 控制台登录密码（`enabled=true` 时必需） |
| `CONSOLE_SECRET_KEY` | *(空)* | Bearer token 直接访问控制台 API（留空表示不启用） |
| `CONSOLE_ALLOW_REMOTE` | `true` | 允许非本地 IP 访问控制台 |
| `CONSOLE_MAX_FAILURES` | `5` | 最大失败登录次数后封禁 IP |
| `CONSOLE_BAN_DURATION` | `300` | IP 封禁时长（秒） |
| `CONSOLE_IP_BAN_ENABLED` | `true` | 是否启用 IP 封禁 |
| `CONSOLE_SESSION_AUTO_RENEW` | `true` | 会话自动续期 |

#### 日志

| 变量 | 默认值 | 说明 |
|------|--------|------|
| `LOG_LEVEL` | `info` | 日志级别：`trace`/`debug`/`info`/`warn`/`error` |
| `LOG_FORMAT` | `json` | 日志格式：`json`/`compact`/`pretty` |
| `LOG_QUEUE_DIR` | *(空)* | 日志磁盘队列目录 |
| `LOG_MAX_BODY_SIZE_MB` | `25` | 最大 body 捕获大小（MB） |
| `LOG_PERSIST_REQUEST_LOGS` | `true` | 是否将请求日志写入数据库（设为 `false` 可禁用 DB 持久化，日志仍通过 SSE 流式传输到控制台） |
| `LOG_RETENTION_DAYS` | `7` | 自动删除超过 N 天的日志（PostgreSQL：DROP 过期分区；SQLite：DELETE —— 为避免全库锁不执行 VACUUM，需缩小文件请手动运行）。`0` = 禁用（外部管理） |
| `OTEL_ENDPOINT` | *(空)* | OpenTelemetry OTLP gRPC 端点（需要 `--features otel` 构建） |
| `OTEL_SERVICE_NAME` | `protoflux` | OpenTelemetry 追踪中的服务名称 |

#### 流式传输

| 变量 | 默认值 | 说明 |
|------|--------|------|
| `STREAMING_KEEPALIVE_SECONDS` | `15` | SSE 心跳间隔（秒） |
| `STREAMING_BOOTSTRAP_RETRIES` | `1` | 首个 token 前的上游重试次数 |
| `STREAMING_LOG_TIMEOUT_SECS` | `3600` | 流式日志僵尸任务超时（秒） |

#### 限流

| 变量 | 默认值 | 说明 |
|------|--------|------|
| `RATE_LIMIT_ENABLED` | `false` | 是否启用限流 |
| `RATE_LIMIT_RPM` | `120` | 每用户每分钟最大请求数 |
| `RATE_LIMIT_WINDOW_SECS` | `60` | 滑动窗口大小（秒） |

#### 重试

| 变量 | 默认值 | 说明 |
|------|--------|------|
| `MAX_UPSTREAM_RETRIES` | `3` | 上游重试最大次数 |
| `MAX_KEY_ROTATIONS` | `0` | 最大 key 轮换次数（0=无限） |

#### 路由

| 变量 | 默认值 | 说明 |
|------|--------|------|
| `LB_AFFINITY_TTL_SECS` | `300` | LB entry 粘性绑定 TTL（秒） |
| `LOAD_WINDOW_SECS` | `60` | 负载计算滑动窗口（秒） |
| `KEY_BINDING_TTL_SECS` | `1800` | 上游多 key 黏性绑定空闲 TTL（秒，0=永久） |

### 类型自动推断

当 TOML 中某个字段的值完全是一个 `${VAR}` 引用时，环境变量的值会自动推断类型（详见[环境变量](configuration.zh-CN.md#路径-1var-文件内展开)）：

- `""`（空）→ `null` → 用于 `Option<T>` 可选字段，如 `secret_key`、`postgres_url`（留空表示不启用）
- `"7890"` → 整数 → 可配置 `port`、`max_failures` 等
- `"true"` → 布尔 → 可配置 `enabled`、`allow_remote` 等
- `"50.0"` → 浮点数 → 可配置 `max_request_size_mb` 等
- 其他 → 字符串 → 可配置 `host`、`storage_mode` 等

```toml
# server.docker.toml 示例
port = "${PORT}"           # ENV PORT=9000 → 整数 9000
enabled = "${CONSOLE_ENABLED}"  # ENV ...=true → 布尔 true
```

## 持久化数据

### 存储后端

Protoflux 支持两种存储后端用于请求统计和配置持久化：

| 后端 | 使用场景 | 连接方式 |
|------|---------|---------|
| SQLite（代码默认） | 单实例部署 | 通过 `sqlite_path` 指定文件路径 |
| PostgreSQL（Docker 默认） | 多实例容器化部署 | 通过 `postgres_url` 指定连接地址 |

**SQLite**（单实例）：
```toml
[server]
storage_mode = "sqlite"
sqlite_path = "/var/lib/protoflux/protoflux.sqlite"
```

Docker 持久化 SQLite 文件：
```bash
docker run --rm -p 7890:7890 \
  -e STORAGE_MODE=sqlite \
  -v protoflux-data:/var/lib/protoflux \
  protoflux
```

**PostgreSQL**（多实例）：
```toml
[server]
storage_mode = "postgresql"
postgres_url = "postgresql://user:password@pg-host:5432/protoflux?sslmode=require"
```

TLS 由 `sslmode` 查询参数控制：
- `sslmode=disable` — 不使用 TLS（默认，本地开发）
- `sslmode=require` — 使用 TLS 但不验证证书
- `sslmode=verify-full` — 使用 TLS 并完整验证证书

使用 PostgreSQL 时，表结构通过迁移自动创建。多个网关实例写入同一数据库时通过 `ON CONFLICT DO UPDATE` 自动聚合统计。配置变更通过 30 秒轮询同步（连接失败时指数退避）。

### Redis 分布式状态（多实例部署）

对于运行多个网关实例的场景，Redis 提供跨实例的运行时状态共享：

| 组件 | 共享内容 | 效果 |
|------|---------|------|
| Session | 登录会话 | 用户在实例 A 登录后，实例 B 仍保持登录 |
| Rate Limit | 限流计数 | 用户无法通过在多实例间轮询绕过限流 |
| Log Broadcast | 日志流 | Console SSE 可以看到所有实例的请求日志 |
| IP Ban | IP 封禁 | 实例 A 触发的 IP 封禁在实例 B 同样生效 |

**不配置 `redis_url` 时，所有状态纯内存存储（零额外开销）。**

```toml
[server]
storage_mode = "postgresql"
postgres_url = "postgresql://user:password@pg-host:5432/protoflux"
# Redis URL 格式：redis://[:密码@]主机[:端口][/数据库编号]
# 默认 db 0；使用 /1、/2 等选择不同数据库
redis_url = "redis://redis-host:6379/1"
redis_key_prefix = "protoflux:"
```

Redis 连接失败时自动降级到内存模式，不会阻止网关启动。

**多实例 Docker Compose 示例：**

```yaml
version: "3.8"

services:
  gateway-a:
    image: protoflux:latest
    ports:
      - "7890:7890"
    environment:
      POSTGRES_URL: "postgresql://postgres:password@db:5432/protoflux"
      REDIS_URL: "redis://redis:6379"
      CONSOLE_ENABLED: true
      CONSOLE_PASSWORD: "my-console-password"
    depends_on:
      - db
      - redis

  gateway-b:
    image: protoflux:latest
    ports:
      - "7891:7890"
    environment:
      POSTGRES_URL: "postgresql://postgres:password@db:5432/protoflux"
      REDIS_URL: "redis://redis:6379"
      CONSOLE_ENABLED: true
      CONSOLE_PASSWORD: "my-console-password"
    depends_on:
      - db
      - redis

  db:
    image: postgres:17-alpine
    environment:
      POSTGRES_DB: protoflux
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: password
    volumes:
      - pgdata:/var/lib/postgresql/data

  redis:
    image: redis:7-alpine
    volumes:
      - redisdata:/data

volumes:
  pgdata:
  redisdata:
```

两个实例共享同一 PostgreSQL（业务配置+统计）和 Redis（运行时状态）。可以在实例 A 登录 Console，在实例 B 的 SSE 日志流中看到实例 A 的请求。

#### Redis 数据结构说明

| 组件 | Redis 数据类型 | Key 格式 | 说明 |
|------|---------------|---------|------|
| Session | String (JSON) | `{prefix}session:{token}` | 24h TTL 自动过期，revoke_all 通过 Set 索引批量删除 |
| Session Index | Set | `{prefix}session_index` | 用于 revoke_all 时遍历所有 token |
| Rate Limit | Hash | `{prefix}ratelimit:{key}` | `{count, window_start}`，Lua 脚本原子操作 |
| IP Fail Count | Hash | `{prefix}ip_fail:{ip}` | `{count, last_failure}`，ban_duration 后自动重置 |
| IP Ban List | Sorted Set | `{prefix}banned_ips` | score=过期时间戳，`ZREMRANGEBYSCORE` 清理过期项 |

#### 统一事件广播架构（Redis Pub/Sub）

Protoflux 使用泛型 `EventBus<T>` trait（`protoflux_core::broadcast`）解耦事件生产者与传输实现。两种事件类型共用该 trait，但**传输实现不同**：

| 事件类型 | Channel | 用途 |
|---|---|---|
| `LogEvent` | `{prefix}logs` | 请求日志 → Console SSE 日志流 |
| `McpNotification` | `{prefix}mcp_notifications` | 工具列表变更 → MCP SSE 客户端 |

两种事件的传输特性差异显著：`McpNotification` 是低频工具列表变更信号，走内存 `tokio::sync::broadcast` channel + Redis PUBLISH；`LogEvent` 是高吞吐请求日志，走**磁盘 `FileLogQueue`**（持久化供 DB drain + 本地 SSE）。下方流程图描述的是 MCP 的单 channel fan-out；`LogEvent` 在 Redis 模式下使用**双队列**变体（见下文「LogEvent 防回环机制」）。

两种后端均遵循相同的 **Redis Pub/Sub + Fan-out 模式**：

```
实例 A                          Redis                          实例 B
───────────────────    ┌──────────────────┐    ───────────────────
事件 ──► publish() ──► PUBLISH channel    │    事件 ──► publish()
                       │                  │
                  ┌────► SUBSCRIBE  ◄─────┘
                  │    │ protoflux:*      │
            ┌─────┘    └──────────────────┘    ┌─────┐
            ▼                                  ▼     ▼
      pubsub_loop() (后台任务)          SSE客户端1  SSE客户端2
            │
            ▼
      本地 broadcast::channel (fan-out)
            │
            ▼
      本地 SSE 客户端
```

- **publish()** — 将事件 JSON 通过 `PUBLISH` 发送到 Redis Pub/Sub channel
- **publish_local()** — 仅发送给本地订阅者，不经过 Redis PUBLISH（MCP 通知的防环机制）
- **pubsub_loop()** — 每个实例启动一个后台任务，使用**独立的 Redis 连接**做 `SUBSCRIBE`，收到消息后转发到本地 `tokio::sync::broadcast`
- **subscribe()** — SSE 客户端订阅本地 broadcast（零 Redis 开销），一个 Redis SUB 连接服务所有本地客户端
- **断线自动重连** — Redis 连接断开后 2s 自动重试，循环直到恢复
- **超时检测** — 60s 无消息超时检测静默断开的 TCP 连接
- **fail-open** — Console 未打开时（`subscriber_count() == 0`），跳过请求 body 捕获以减少开销

**MCP 防环机制**：实例 A 添加 MCP server 后，向 Redis 发布 `notifications/tools/list_changed`。实例 B 的 `pubsub_loop` 收到消息后触发 `rebuild_indexes(local_only=true)`，重建本地工具缓存并通过 `publish_local()` 通知本地 SSE 客户端——不会重新发布回 Redis。

**LogEvent 防回环机制（双队列）**：与 MCP 的 `publish_local()` 防环不同，`LogEvent` 通过**拆分两个磁盘队列**切断 Redis pubsub 回环（`RedisLogBroadcast`）：

| 队列 | 写入来源 | 消费者 | 作用 |
|---|---|---|---|
| `drain_queue` | **仅本实例 `publish()` 直接写**（远端 pubsub 事件绝不写）| 本地 DB drain 任务 | 落库——这是本实例事件写入 DB 的**唯一路径**（per-instance ownership，无 N× 重复落库）|
| `live_queue` | 本实例 `publish()` + Redis pubsub 收到的远端事件 | 本地 SSE 订阅者 | 即时 SSE（不等 Redis 往返）|

- **Memory 模式（单实例）**：只有一个 `FileLogQueue`，DB drain + SSE 共同消费——无回环问题，无需拆分。
- **Redis 模式（多实例）**：拆 `drain_queue` + `live_queue`。Redis pubsub 收到的远端事件**只写 `live_queue`**，**绝不写 `drain_queue`**——否则每个实例都会 drain 全 N 个实例的事件，产生 N-1 个空事务、cursor 滞后、磁盘满。
- **序列化复用**：`publish()` 把 `LogEvent` 序列化一次，复用同一份字节写入 `drain_queue` + `live_queue` + Redis `PUBLISH`（三目的地一次序列化）。跨实例广播时 body 不再内联进事件——`Ref` 体在 publish 前物化进 Redis 热层缓存（压缩），事件只带 `GlobalRef` 指针 + preview（KB 级），故序列化字节与 body 大小解耦（详见架构文档「跨实例 body：分层存储」）。`drain_queue` 先写（DB 持久化优先），StoreFull 向上抛做背压；`live_queue` / Redis PUBLISH 失败只 warn 不 fail（DB 行已持久）。

> **注意**：Pub/Sub channel 不是 Redis key，不会出现在 `KEYS` 中。
> 如需调试，可以 `redis-cli SUBSCRIBE {prefix}logs` 或 `redis-cli SUBSCRIBE {prefix}mcp_notifications` 实时查看消息。
> Console 日志页面需要先打开以激活订阅，否则 gateway 会跳过 body capture。

#### 受管后台任务清单

Console 概览页实例卡片的 `R{N}` 标签 = `InstanceTaskSummary.running`（受管 task 中 `TaskState::Running` 的计数）。受管 task 总数取决于 storage 模式：

| Task | spawn 点 | Memory | Redis |
|---|---|---|---|
| `config_reload`（统一热重载 loop） | lifecycle.rs | ✅ | ✅ |
| `housekeeping` | app_builder.rs | ✅ | ✅ |
| `price_refresh`（Tier-2 远程定价拉取 + refresh） | app_builder.rs | ✅ | ✅ |
| `mcp_background_sync`（周期 dirty-flag 索引重建安全网） | app_builder.rs | ✅ | ✅ |
| `request_log_subscriber`¹ | request_log_subscriber.rs | ✅ | ✅ |
| `stats_flush`² | stats/mod.rs | ✅ | ✅ |
| `log_queue_flush_cleanup` | provider.rs | ✅ | — |
| `redis_drain_queue_flush_cleanup` | provider.rs | — | ✅ |
| `redis_live_queue_flush_cleanup` | provider.rs | — | ✅ |
| `redis_log_broadcast`（log pubsub） | broadcast.rs | — | ✅ |
| `redis_mcp_broadcast`（MCP pubsub） | mcp_broadcast.rs | — | ✅ |
| `redis_config_broadcast`（配置重载信号 pubsub） | config_broadcast.rs | — | ✅ |
| `redis_instance_broadcast`（实例生命周期 pubsub） | instance_broadcast.rs | — | ✅ |
| `instance_health_reporter` | provider.rs | — | ✅ |
| `instance_event_probe`（消费实例广播入 live registry） | provider.rs | — | ✅ |
| **合计** | | **R7** | **R14** |

¹ 仅当 `request_log_repo: Some`（DB 持久化开启）spawn  ² 仅当 `stats_backend: Some`（统计开启）spawn

R14 − R7 = 7 个差异 task 全是 Redis 跨实例协调所需：双 queue flush 对（drain + live，比 Memory 单 queue 净 +1）+ log/MCP/config/instance Pub/Sub 广播 + instance_health_reporter + instance_event_probe（消费 instance 广播）。若 Redis 模式却显示 R7，则某 Redis task 已 Stopped/Panicked——查 `/console/api/observability` 的 `task_infos` 定位非 Running 项。

### 请求日志保留策略

`request_logs` 表存储每个请求的审计数据，包括捕获的 body（最多 4 个 text 列 × `max_body_size_mb`）。如不定期清理，表会无限增长：

| 流量     | 平均 body | 日增长   | 30 天大小 |
|----------|----------|---------|----------|
| 100 rps  | 10 KB    | ~80 GB  | ~2.4 TB  |
| 10 rps   | 1 KB     | ~800 MB | ~24 GB   |
| 1 rps    | 100 B    | ~8 MB   | ~240 MB  |

#### 数据模型

`LogEvent` 是贯穿整个请求日志管线的统一数据结构：SSE 广播到 Console UI → 磁盘队列（`FileLogQueue`）→ 数据库持久化。实时流式推送、磁盘缓冲和数据库存储使用同一个 `LogEvent` 结构，无中间转换层。低频/可变字段（请求头、负载均衡追踪、限流信息、缓存标记等）打包进单个 `extensions` JSONB 列，新增字段无需修改数据库 schema。

#### PostgreSQL：按日分区

`request_logs` 表按天分区（`PARTITION BY RANGE (occurred_at)`）。每天的数据独立存放在一个分区中 — 保留策略通过 `DROP` 过期分区实现，而不是 `DELETE` 行。**所有分区边界均为 UTC。**

```
request_logs (父表)
├── request_logs_2026_06_22   (1 天)
├── request_logs_2026_06_23   (1 天)
├── ...
└── request_logs_2026_06_29   (1 天)
```

**为什么用分区而不是 DELETE：**

| | DELETE | DROP 分区 |
|---|---|---|
| 锁 | 行级锁，阻塞并发写入 | DDL，无行锁 |
| 速度 | 百万行需数分钟到数小时 | 瞬间完成 |
| 死元组 | 产生死元组，需要 VACUUM | 无 |
| 磁盘回收 | VACUUM 后才回收 | 立即回收 |

**自动分区创建与保留策略**（无需 cron 或手动操作）：

应用通过 drain loop 自动管理分区生命周期：

- **创建**：启动时及此后每小时，`ensure_partitions()` 确保今天 + 未来 3 天的分区存在。使用 `CREATE TABLE IF NOT EXISTS`，多实例并发部署安全。失败时（如数据库短暂不可用）重试间隔缩短为 60 秒，成功后恢复正常 1 小时间隔。

- **保留策略**：同一小时 tick 中，`drop_expired()` 删除超过 `LOG_RETENTION_DAYS`（默认 7 天）的分区。使用 `DROP TABLE IF EXISTS` — DDL 操作，瞬间完成，无行锁，无需 VACUUM。设为 `0` 禁用自动保留，改为外部管理。

仅在 `LOG_PERSIST_REQUEST_LOGS=true` 时运行。

**手动创建分区**（参考 — 通常自动完成）：

```sql
-- 创建指定分区（幂等）
CREATE TABLE IF NOT EXISTS request_logs_2026_06_30
    PARTITION OF request_logs
    FOR VALUES FROM ('2026-06-30') TO ('2026-07-01');
```

**删除过期分区**（如保留 7 天）：

```sql
-- 列出所有分区
SELECT inhrelid::regclass AS partition_name
FROM pg_inherits
WHERE inhparent = 'request_logs'::regclass
ORDER BY 1;

-- 删除 7 天前的分区
DROP TABLE IF EXISTS request_logs_2026_06_22;
```

**外部 cron**（仅在 `LOG_RETENTION_DAYS=0` 时需要）：

```bash
#!/bin/bash
# /etc/cron.d/protoflux-partition-manager
# 每天 UTC 00:05 — 删除过期分区
# （仅在禁用自动保留策略时需要）

RETENTION_DAYS=7
DB_NAME=protoflux

CUTOFF=$(date -u -d "-${RETENTION_DAYS} days" +%Y-%m-%d)
psql -d "$DB_NAME" -t -c \
  "SELECT inhrelid::regclass FROM pg_inherits WHERE inhparent = 'request_logs'::regclass" |
while read -r partition; do
  part_date=$(echo "$partition" | grep -oP '\d{4}_\d{2}_\d{2}' | tr '_' '-')
  if [ -n "$part_date" ] && [[ "$part_date" < "$CUTOFF" ]]; then
    psql -d "$DB_NAME" -c "DROP TABLE IF EXISTS ${partition};"
    echo "已删除过期分区: ${partition}"
  fi
done
```

#### SQLite

SQLite 不支持表分区。单实例部署下 `request_logs` 是普通表，清理需手动执行：

```bash
# 手动清理（按需执行）
sqlite3 /path/to/protoflux.sqlite \
    "DELETE FROM request_logs WHERE occurred_at < datetime('now', '-7 days');" \
    "VACUUM;"
```

流量较大的生产环境建议使用 PostgreSQL。

#### 保留周期建议

| 使用场景 | 建议保留周期 |
|---------|------------|
| 调试 / 事件响应 | 3–7 天 |
| 用量分析 / 计费 | 30–90 天 |
| 合规 / 审计追踪 | 1–7 年（不含 body 列） |

对于长期保留，建议在删除分区前将数据导出到冷存储（S3/GCS），并从归档中移除 body 列以降低成本：

```sql
COPY (
    SELECT request_id, occurred_at, model, upstream, status_code,
           latency_ms, input_tokens, output_tokens, access_key
    FROM request_logs
    WHERE occurred_at < now() - interval '7 days'
) TO '/tmp/request_logs_archive.csv' WITH CSV HEADER;
```

## 反向代理

Protoflux **不支持**原生 TLS。HTTPS 需要前置反向代理：

- TLS 终止（Nginx、Caddy、Traefik 或云负载均衡器）
- 管理面的网络访问限制
- 限流和请求大小控制

## Kubernetes 部署

Protoflux 为生产级 Kubernetes 部署而设计，完整支持健康探针、优雅关闭和可观测性。

### Deployment 清单

```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: protoflux
  labels:
    app: protoflux
spec:
  replicas: 2
  selector:
    matchLabels:
      app: protoflux
  template:
    metadata:
      labels:
        app: protoflux
      annotations:
        prometheus.io/scrape: "true"
        prometheus.io/port: "7890"
        prometheus.io/path: "/metrics"
    spec:
      terminationGracePeriodSeconds: 60
      containers:
        - name: protoflux
          image: protoflux:latest
          ports:
            - name: http
              containerPort: 7890
          env:
            # 必需
            - name: ENCRYPTION_KEY
              valueFrom:
                secretKeyRef:
                  name: protoflux-secrets
                  key: encryption-key
            - name: POSTGRES_URL
              valueFrom:
                secretKeyRef:
                  name: protoflux-secrets
                  key: postgres-url

            # 控制台
            - name: CONSOLE_ENABLED
              value: "true"
            - name: CONSOLE_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: protoflux-secrets
                  key: console-password

            # 可观测性
            - name: METRICS_AUTH_TOKEN
              valueFrom:
                secretKeyRef:
                  name: protoflux-secrets
                  key: metrics-token
            - name: LOG_FORMAT
              value: "json"

            # 弹性
            - name: REQUEST_TIMEOUT_SECS
              value: "120"
            - name: GRACEFUL_SHUTDOWN_TIMEOUT_SECS
              value: "30"
            - name: PRE_STOP_DELAY_SECS
              value: "15"
            - name: IP_RATE_LIMIT_RPM
              value: "120"

          # 存活探针：进程是否存活？
          livenessProbe:
            httpGet:
              path: /healthz
              port: http
            initialDelaySeconds: 10
            periodSeconds: 10
            timeoutSeconds: 5
            failureThreshold: 3

          # 就绪探针：能否接收流量？
          readinessProbe:
            httpGet:
              path: /ready
              port: http
            initialDelaySeconds: 5
            periodSeconds: 5
            timeoutSeconds: 3
            failureThreshold: 3

          # 启动探针：给予初始化时间
          startupProbe:
            httpGet:
              path: /healthz
              port: http
            initialDelaySeconds: 5
            periodSeconds: 5
            failureThreshold: 12

          resources:
            requests:
              cpu: "500m"
              memory: "256Mi"
            limits:
              cpu: "2"
              memory: "1Gi"
```

### Service

```yaml
apiVersion: v1
kind: Service
metadata:
  name: protoflux
spec:
  selector:
    app: protoflux
  ports:
    - name: http
      port: 7890
      targetPort: http
  type: ClusterIP
```

### 健康探针

Protoflux 提供两个健康检查端点：

| 端点 | 用途 | 响应 |
|------|------|------|
| `/healthz`, `/health` | 存活 — 进程是否存活？ | 始终 `200 OK` |
| `/ready` | 就绪 — 能否接收流量？ | DB 可访问且未排空时返回 `200`；否则返回 `503` |

**存活探针**检测死锁或挂起的进程。使用 `/healthz` 并配合宽松的 `failureThreshold` — 重启 Pod 代价高昂（连接排空、重新调度）。

**就绪探针**控制 Pod 是否接收流量。使用 `/ready` — 当返回 `503` 时，Kubernetes 会自动将 Pod 从 Service endpoints 中摘除。探针检查：
1. 排空模式（drain mode）未激活（优雅关闭预停阶段置位）
2. 存储后端（SQLite/PostgreSQL）是否可访问

零上游配置刻意不使就绪探针失败：`upstreams` 是跨实例共享的全局业务配置，瞬时空状态（重载窗口、运维清空）若触发就绪检查，会让所有实例同时 503 并摘除整个 target group。零上游在请求层解析为 `NoCredentials` → `503`，错误归属请求层而非就绪探针。

### 优雅关闭与 Pre-Stop Drain

当 Kubernetes 终止 Pod（缩容、滚动更新、节点驱逐）时，关闭流程如下：

```
收到 SIGTERM
  → /ready 返回 503（drain 模式激活）
  → 休眠 pre_stop_delay_secs（默认 15s）
     ↳ kube-proxy 从 Service endpoints 中摘除 Pod
     ↳ 进行中的请求继续处理
  → 停止接受新连接
  → 等待 graceful_shutdown_timeout_secs（默认 30s）
     ↳ 进行中的请求自然完成
  → 退出
```

**为什么需要 pre-stop 延迟？** Kubernetes 发送 `SIGTERM` 和更新 iptables/endpoints 是并发进行的。存在一个短暂窗口期，新请求仍可能被路由到正在终止的 Pod。`pre_stop_delay_secs`（默认 15s）确保：
1. Pod 立即在 `/ready` 上返回 `503`
2. Kubernetes 有时间传播 endpoint 摘除
3. 进行中的请求在服务器停止前完成

设置 `terminationGracePeriodSeconds` ≥ `pre_stop_delay_secs` + `graceful_shutdown_timeout_secs` + 缓冲（推荐：60s）。

### Prometheus 监控

通过 Pod 注解或 ServiceMonitor 添加 Prometheus 抓取：

**Pod 注解**（上面已使用）：
```yaml
annotations:
  prometheus.io/scrape: "true"
  prometheus.io/port: "7890"
  prometheus.io/path: "/metrics"
```

**ServiceMonitor**（Prometheus Operator）：
```yaml
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
  name: protoflux
spec:
  selector:
    matchLabels:
      app: protoflux
  endpoints:
    - port: http
      path: /metrics
      interval: 15s
      bearerTokenSecret:
        name: protoflux-secrets
        key: metrics-token
```

> **安全**：设置 `METRICS_AUTH_TOKEN` 以要求 `/metrics` 端点进行 Bearer Token 认证。未设置时，端点返回 `403 Forbidden`。

## 运维基线

- 为数据面开启 `api_keys`
- 网关暴露在公网时将 `web_console.allow_remote` 设为 `false`
- 没有网络层隔离时不要直接暴露 `/console`
