Files
ZY-Agent/docs/multi-tenant-redesign/01-redesign/adr-005-storage-topology.zh-CN.md
T
1445043649 dce5e9598b docs(multi-tenant): add ADRs and phase-0 plan for multi-tenant redesign
Add 7 ADRs and a phase-0 plan covering the multi-tenant redesign of
DeerFlow, plus an architecture-overview snapshot of the current state.

ADRs:
- 001 data isolation: row-level tenant_id + Postgres RLS, including
  LangGraph-owned checkpoint tables (subquery RLS or column upgrade path).
- 002 sandbox isolation: K8s namespace + gVisor + NetworkPolicy default-
  deny, threat model and pod spec defaults.
- 003 LLM key & billing: hybrid platform/BYO with pessimistic reservation
  to handle the "ghost token" overflow on the last call, plus a usage
  category split for memory/title/summarization charges.
- 004 RBAC: two-level (owner/admin/member), JWT-with-role + 30s LRU
  cache for reads, strict DB lookup for sensitive writes, token_version
  bump as the single revocation path.
- 005 storage topology: Postgres (structured) + S3-compatible object
  store (large objects) + emptyDir (ephemeral); explicit treatment of
  the extensions_config.json migration's downstream effects.
- 006 runtime & channel tenancy: per-tenant MCP cache, dual-track skills
  loader, sandbox provider routing by namespace, internal LLM call
  billing, IM channel-to-tenant binding model.
- 007 routing & frontend: path-slug URL form, JWT-only API auth,
  TenantProvider, hard-reload tenant switch, Better Auth integration.

These docs are decision records; no code changes are included.
2026-05-08 23:45:33 +08:00

403 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ADR-005 · 存储拓扑与持久化策略
| 项目 | 内容 |
|---|---|
| 状态 | 草稿(Draft |
| 决策日期 | TBD |
| 决策者 | 架构 + 后端 lead + SRE |
| 关联 ADR | ADR-001 数据隔离、ADR-002 沙箱隔离、ADR-006 运行时与渠道 |
---
## 1. 背景
DeerFlow 当前所有持久化数据都在容器/主机的本地文件系统下(`{base_dir}/...`,默认 `./.deer-flow`,可用 `DEER_FLOW_HOME` 覆盖)。这套布局是为"单机/单副本本地开发"设计的——多租户上线后会出现三类问题:
1. **多副本不一致**K8s 横向扩展后,副本 A 写的 `memory.json` 副本 B 看不到。
2. **容器重建丢数据**:沙箱 pod 销毁、Gateway pod 重启即丢失自定义 skill / agent / memory / 上传 / 产物。
3. **没有备份/灾备**:磁盘损坏即客户数据全失。
现状盘点(详见 `paths.py``skills/storage/`):
| 数据 | 现位置 | 容器重建会丢 |
|---|---|---|
| 公共 skills | repo 内 `skills/public/`(镜像里) | 否 |
| 自定义 skills(用户安装) | `skills/custom/`**全局共享**,非 per-user | **是** |
| 自定义 agentSOUL.md + config.yaml | `{base_dir}/users/{uid}/agents/{name}/` | **是** |
| 长期记忆 | `{base_dir}/users/{uid}/memory.json` | **是** |
| 用户上传 | `{base_dir}/users/{uid}/threads/{tid}/user-data/uploads/` | **是** |
| Agent 工作区(草稿) | `.../user-data/workspace/` | 是(可接受) |
| Agent 产物 | `.../user-data/outputs/` | **是** |
| 配置开关 | `extensions_config.json`(仓库根,全局) | **是** |
| 会话/运行/反馈 | SQLAlchemy 表(DB | 否 |
| 用户/认证 | `users` 表(DB | 否 |
---
## 2. 决策
**采用三层存储拓扑**:结构化数据进 Postgres,二进制大对象进对象存储,沙箱本地只保留运行期临时区。**沙箱 pod 因此是真正无状态的**——可被任意调度、滚动升级、销毁重建。
```
┌─────────────────────────────────────────────────────────────┐
│ Postgres (RLS) │
│ ┌────────────────┐ ┌──────────────────┐ │
│ │ users │ │ agent_configs │ ← SOUL.md 入库 │
│ │ tenants │ │ memory_facts │ ← memory.json 入库│
│ │ memberships │ │ memory_context │ │
│ │ threads_meta │ │ tenant_secrets │ ← API key 加密入库│
│ │ runs │ │ tenant_skill_state│ │
│ │ run_events │ │ tenant_mcp_configs│ │
│ │ feedback │ │ tenant_quotas │ │
│ └────────────────┘ └──────────────────┘ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 对象存储(S3 / OSS / MinIO
│ deerflow-data/{tenant_id}/... │
│ uploads/{thread_id}/... ← 客户上传 │
│ threads/{thread_id}/outputs/...← agent 产物 │
│ deerflow-skills/{tenant_id}/ │
│ {skill_name}-{version}.skill ← 技能包源 │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ 沙箱 Pod 本地(容器 emptyDir / 临时卷) │
│ /mnt/user-data/workspace/ ← 中间草稿,run 结束清掉 │
│ /mnt/user-data/uploads/ ← 启动时从 S3 拉,按需 │
│ /mnt/user-data/outputs/ ← 写完后同步到 S3 │
│ /mnt/skills/ ← 启动时按租户 enabled 列表拉│
└─────────────────────────────────────────────────────────────┘
```
### 2.1 各类数据的归属
**A. 进 Postgres(结构化、要查询、要并发更新、不大)**
| 数据 | 表 | 备注 |
|---|---|---|
| 自定义 agent 的 `SOUL.md` + `config.yaml` | `agent_configs(tenant_id, user_id, agent_name, soul_md TEXT, config_yaml TEXT, version, updated_at)` | 文本不大;要并发改、版本回滚 |
| memory.json 中的事实 | `memory_facts(tenant_id, user_id, fact_id, content, category, confidence, created_at, source)` | 要按 confidence/category 查询、去重、流式追加 |
| memory.json 中的上下文 | `memory_context(tenant_id, user_id, work_context, personal_context, top_of_mind, ...)` | 1 行 / (tenant, user) |
| skill 启用状态 | `tenant_skill_state(tenant_id, skill_name, enabled, source)` | 替代全局 `extensions_config.json` |
| MCP 配置 | `tenant_mcp_configs(tenant_id, server_name, transport, url, encrypted_config)` | 替代全局 `extensions_config.json` |
| Tenant secretsLLM key 等) | `tenant_secrets(tenant_id, key, encrypted_value)` | KMS 加密 |
**B. 进对象存储(大、二进制、写一次读多次、版本化天然)**
| 数据 | Object key | 备注 |
|---|---|---|
| 用户上传文件 | `tenants/{tid}/uploads/{thread_id}/{filename}` | presigned PUT 直传,沙箱按需拉 |
| Agent 产物 | `tenants/{tid}/threads/{thread_id}/outputs/{path}` | `present_files` 触发上传 |
| 技能包 `.skill` | `tenants/{tid}/skills/{skill_name}/{version}.skill` | SHA256 校验,做版本控制 |
| 公共技能包(平台) | `platform/skills/{skill_name}/{version}.skill` | 跨租户共享 |
**C. 不持久化(彻底丢弃)**
| 数据 | 为什么 |
|---|---|
| 沙箱 `workspace/` 中间产物 | agent 的"草稿纸"——半成品脚本、临时调试输出。强行同步浪费带宽 + 增加爆炸半径。让 agent 主动 `present_files` 到 outputs 才同步 |
| 解压后的 skill 目录 | 当本地缓存(LRU)。启动时从 S3 拉 .skill 包解压;缓存命中则跳过 |
| Gateway 进程的本地缓存(mtime 失效那些) | 进程级,不需要持久化 |
---
## 3. 备选方案
### A. 全部进 PVC(共享文件存储 RWX,例如 EFS / NFS
**拒绝。** 看似最小改动,但:
- 读写竞态依然存在(DeerFlow 的 atomic rename 在 NFS 上行为不一致)
- 大量小文件读写性能差(memory.json 每次写需要 fsync
- 备份策略复杂(NFS 快照 vs 增量备份)
- 退订清理很慢(递归删大量小文件)
### B. 全部进对象存储(连 metadata 都用 S3
**拒绝。** 用对象存储模拟文件系统:
- 强一致性差(多数对象存储是 read-after-write,没有 CAS
- 对小文件高频写延迟太高(每次写 50–200ms)
- 没有事务,跨对象一致性靠应用层补
- 列表操作慢(list-objects 是分页拉取)
### C. 分层:DB(结构化)+ S3(二进制)+ 临时区(中间产物)
**采纳。** 各取所长,与 ADR-001(行级隔离 + RLS)天然契合,并让沙箱 pod 真正无状态。
---
## 4. ObjectStorage 接口草稿
抽象层放在 `backend/packages/harness/deerflow/storage/`。所有调用方写抽象接口,**不直接 import boto3**。
```python
# packages/harness/deerflow/storage/protocol.py
from collections.abc import AsyncIterator
from datetime import datetime
from typing import Protocol, runtime_checkable
from pydantic import BaseModel
class ObjectMetadata(BaseModel):
key: str
size: int
etag: str
content_type: str
last_modified: datetime
metadata: dict[str, str] = {} # 自定义元数据(x-amz-meta-*
class ObjectNotFound(Exception):
"""对象不存在(幂等删除/读取时由实现转换为此异常或返回 None)。"""
@runtime_checkable
class ObjectStorage(Protocol):
"""租户感知的对象存储抽象。
实现:
- LocalObjectStorage 开发/单元测试用,落本地目录
- S3ObjectStorage AWS S3 / 兼容接口(OSS / OBS / R2
- MinIOObjectStorage 自部署 MinIOS3 协议)
Key 命名规范(强约束,便于按 prefix 退订清理):
tenants/{tenant_id}/...
platform/... ← 跨租户共享资源(公共技能包)
"""
# ── 同步 / 异步统一为 async(实现里用 aioboto3 / 自托管 thread pool ──
async def put(
self,
key: str,
data: bytes,
*,
content_type: str = "application/octet-stream",
metadata: dict[str, str] | None = None,
) -> ObjectMetadata: ...
async def put_stream(
self,
key: str,
stream: AsyncIterator[bytes],
*,
content_type: str = "application/octet-stream",
content_length: int | None = None,
metadata: dict[str, str] | None = None,
) -> ObjectMetadata: ...
async def get(self, key: str) -> bytes: ...
async def get_stream(self, key: str) -> AsyncIterator[bytes]: ...
async def stat(self, key: str) -> ObjectMetadata | None:
"""读取元数据;不存在返回 None(不抛异常)。"""
async def exists(self, key: str) -> bool: ...
async def delete(self, key: str) -> None:
"""幂等删除——不存在视为成功。"""
async def delete_prefix(self, prefix: str) -> int:
"""按 prefix 批量删除,返回删除数量。
重要:仅供 tenant 退订 / GC 任务使用。生产实现应:
- 校验 prefix 必须以 ``tenants/{tenant_id}/`` 开头
- 限制最大并发与最大删除数(避免一次误删全库)
- 异步分批,超时可继续
"""
async def list(
self,
prefix: str,
*,
limit: int = 1000,
continuation_token: str | None = None,
) -> tuple[list[ObjectMetadata], str | None]:
"""分页列出。第二个返回值是下一页 token,None 表示结束。"""
async def copy(self, src_key: str, dst_key: str) -> ObjectMetadata: ...
async def presigned_put_url(
self,
key: str,
*,
expires_in: int = 3600,
content_type: str = "application/octet-stream",
max_size_bytes: int | None = None,
) -> str:
"""给客户端生成预签名上传 URL。max_size_bytes 用于 S3 POST policy。"""
async def presigned_get_url(
self,
key: str,
*,
expires_in: int = 3600,
content_disposition: str | None = None,
) -> str:
"""给客户端生成预签名下载 URL。
重要:对 HTML/SVG 等活动内容,调用方必须传入
content_disposition='attachment; filename="..."'
以保留当前 Gateway artifacts 路由的 XSS 防护策略。
"""
```
### 4.1 调用边界
```python
# 应用层只 import 抽象,不知道底下是 S3 还是 local
from deerflow.storage import ObjectStorage, get_storage
storage: ObjectStorage = get_storage() # 单例工厂,按 config.yaml 选实现
# 上传:从 sandbox outputs 同步到 S3
await storage.put_stream(
f"tenants/{tid}/threads/{thread_id}/outputs/{filename}",
stream=open_async(local_path),
content_type=guessed_mime,
)
# 退订:清理整个租户(GC 任务)
await storage.delete_prefix(f"tenants/{tid}/")
# 给前端 presigned upload
url = await storage.presigned_put_url(
f"tenants/{tid}/uploads/{thread_id}/{filename}",
expires_in=900,
content_type=mime,
max_size_bytes=100 * 1024 * 1024,
)
```
### 4.2 配置
```yaml
# config.yaml
storage:
use: deerflow.storage.s3:S3ObjectStorage # 反射加载,沿用现有模式
bucket: deerflow-data
region: ap-northeast-1
endpoint_url: null # MinIO 时填自部署地址
access_key_id: $S3_ACCESS_KEY_ID
secret_access_key: $S3_SECRET_ACCESS_KEY
encryption: SSE-KMS # SSE-S3 / SSE-KMS / null
kms_key_id: $S3_KMS_KEY_ID # 用 KMS 时填
```
---
## 5. 落地改造点(实施清单)
按优先级排序,每条对应第 1 阶段或第 2 阶段的一个 PR:
### 第 1 阶段(必须)
1. **抽象 `ObjectStorage` 接口 + `LocalObjectStorage` 实现** — 走通端到端,开发/测试用本地目录跑,不阻塞迁移
2. **memory.json 迁库**
- 新增 `memory_facts` / `memory_context`
- 改写 `agents/memory/storage.py` 用 repository
- 移除 30s debounce 队列的"合并写"逻辑(DB 层不需要了,保留外部 LLM 抽取的 debounce
- 数据迁移脚本:扫现有 `memory.json` 文件 → 入库 → 校验 → 删源文件
3. **agent SOUL/config 迁库**
- 新增 `agent_configs` + `agent_config_history`
- 改写 `setup_agent` / `update_agent` 工具用 repository
- 数据迁移脚本:扫 `users/{uid}/agents/` → 入库
4. **`extensions_config.json` 拆库**——这条比看起来大,连锁影响展开如下:
**a. 新表与仓储**
- `tenant_skill_state(tenant_id, skill_name, enabled, source, version, updated_at)`
- `tenant_mcp_configs(tenant_id, server_name, transport, url, encrypted_config, updated_at)`
- 各自配 repository(仿 `persistence/user/` 的 ContextVar AUTO 模式)
**b. 替换文件 mtime 热重载机制**
- 现状:`get_app_config()` + 多处 `_is_cache_stale()`(如 `mcp/cache.py:31`)靠文件 mtime 判失效
- 改造:
- `extensions_config.py` 保留为"平台默认开关"的载体(公司 ship 的默认值)
- 运行时配置全部走 DB;mtime 失效信号改为 `tenant_*_configs.updated_at`DB 单调递增)
- 缓存层从模块级单例 → per-tenant LRU(详见 ADR-006 §2.2 / §2.3
**c. Gateway 路由改写**
- `app/gateway/routers/skills.py``PUT /skills/{name}/enable``POST /skills/install` → 全部改为按当前 tenant_id 写 `tenant_skill_state` / 上传 S3
- `app/gateway/routers/mcp.py``PUT /mcp/servers/{name}` → 改为按当前 tenant_id 写 `tenant_mcp_configs`,写完调 `TenantMCPCache.invalidate(tenant_id)`
- 鉴权:admin/owner 才能改(参照 ADR-004 §5.4 `strict=True`
**d. AppConfig 反射链不动**
- `config.yaml` 里的 model / sandbox / channels 等仍是平台级配置,不动
- 只有"per-tenant 可定制的开关"挪到 DBskill enabled、MCP server、tenant secrets
- 旧的 `extensions_config.json` 在迁移完成后**删文件 + 删读取代码**,CI 加禁字典阻止再被引用
**e. 数据迁移**
- 写一次性脚本:扫现有 `extensions_config.json` → 写入 "legacy_tenant" 的 `tenant_skill_state` / `tenant_mcp_configs`
- 校验所有租户 enabled list 与现状一致后,删除文件
- 自部署单机用户:first-run upgrade 时自动跑此脚本
**f. 老用户/单机模式兼容**
- 单机部署 = 1 租户("default");体验上不应感知"DB 化"——配置改完仍立即生效
- 解决路径:tenant_*_configs 写入后,立即 invalidate 进程内 cache(同一个进程,没问题)
- 多 pod 部署:靠 `updated_at` 版本号在请求路径上自动同步,无需广播
**估工**:原 ADR-005 列了 1 条 bullet 偏乐观;这块包含 c/d/e/f 四子项,**整体约 M 偏 L**(一周量级),不是 S。
### 第 1 阶段 / 第 2 阶段交界
5. **`S3ObjectStorage` 实现** — 用 aioboto3,覆盖 protocol 全部方法
6. **上传文件改 presigned 直传**
- 前端拿 presigned PUT URL → 直传 S3
- 后端收 "upload complete" 通知 → 异步触发 markitdown 转换 worker
- 沙箱启动时按需 lazy 拉取(不预拉所有上传)
7. **agent 产物 outputs 同步**
- `present_files` 工具:上传 outputs 到 S3 + 返回 presigned GET URL(带 `Content-Disposition: attachment` 给 HTML/SVG
- sandbox 销毁时本地清理(emptyDir 自然回收)
8. **技能包二进制迁 S3**
- `POST /api/skills/install`:把 .skill 上传到 S3(带 SHA256 metadata),不再解压到本地全局目录
- 启动时按 tenant skill list 从 S3 拉 + 校验 + 解压到 LRU 缓存
### 第 2 阶段(建议)
9. **退订 GC**tenant 标记 deleted 后,30 天定时任务跑 `delete_prefix(f"tenants/{tid}/")` + DB cascade delete
10. **跨区域复制 / CDN**:按客户分布加 region replica 或 CloudFront / OSS 加速域名
11. **审计接入**:每次 storage 操作(put/delete/presigned)记审计日志,带 (tenant_id, user_id, key, op, request_id)
---
## 6. 一致性与失败模式
| 场景 | 行为 | 备注 |
|---|---|---|
| Run 中途崩溃,outputs 没上传完 | DB 里 run 标记为 failed;下次重试 / 用户重新发起 | outputs 是"声明产物",丢失中间状态可接受 |
| S3 上传成功但 DB 写失败 | 后台 GC 扫"无 DB 引用的 S3 对象"清理 | 标准的 sweep 模式 |
| DB 写成功但 S3 上传失败 | 应用层重试;持续失败则把 run 标 failed 并告警 | 不允许 DB 引用一个不存在的 S3 对象 |
| 客户上传到 presigned URL 但没通知后端 | 后台 sweeper 扫"上传超时未关联 thread 的对象"清理 | TTL 1h |
| Memory 抽取异步任务卡住 | DB 层不会有半成品(事务 commit 才落库) | 保留之前的可观测性 |
---
## 7. 风险与推翻条件
**风险:**
- S3 调用延迟(上传/下载几十 MB 文件耗时,影响 sandbox 冷启动)→ 用 region 同区 + 内网 endpoint 缓解;首启延迟拿 LRU 命中率监控
- presigned URL 泄露(被截获后任何人可上传/下载)→ TTL 短(≤1h)+ `max_size_bytes` 限制 + content-type 限制
- 退订时 `delete_prefix` 误伤 → 强制 prefix 校验 + dry-run 模式 + 二次确认
**推翻条件:**
1. 实测 sandbox 冷启动 P99 > 30s 且无法靠 LRU 缓存解决 → 改用 PVC 存技能包 + 共享只读卷
2. 客户合规要求物理隔离(监管类客户) → 切 ADR-001 的 per-tenant DB 方案,对象存储也切 per-tenant bucketvs prefix
3. 团队规模不足以维护 S3 + Postgres 两套基础设施 → 退回 PVC + 单一存储(接受单副本部署)
---
## 8. 默认假设
如无相反证据,按此推进:
| 决策项 | 默认值 |
|---|---|
| 对象存储实现 | S3 兼容接口(生产用 AWS S3 / 阿里 OSS,自部署用 MinIO |
| 加密 | SSE-KMS,每个租户一个 KMS key alias(可选,起步用 SSE-S3 |
| Bucket 布局 | 单 bucket + tenant prefix`tenants/{tid}/...` |
| Presigned URL TTL | 上传 15 min / 下载 1 h |
| LRU 缓存大小 | 沙箱 pod 本地 5 GB(按 image size 调整) |
| 退订保留期 | 软删除 30 天后 GC |
| 技能包大小上限 | 50 MB |
| 单上传文件上限 | 100 MBpresigned policy 强制) |