Files
ZY-Agent/docs/multi-tenant-redesign/01-redesign/adr-002-sandbox-isolation.zh-CN.md
T
1445043649 89fe54cc07 docs(multi-tenant): 加入汇总索引、跨文档一致性修订、Postgres 切换前移到 Stage 0
* 新增 README.zh-CN.md 汇总索引:ADR 状态表 + Stage 0-4 业务目标 / 技术路径 /
  验证方式 + Stage↔ADR 对照矩阵 + 不可逆决策一览 + 用语映射 + FAQ + 阅读路径
* 新增 workspace-schema-design.zh-CN.md(Stage 0 schema 锁定版)一并入库
* 7 份 ADR 顶部加"代码命名"映射行(tenant_id ↔ workspace_id)
* ADR-002 §1 加分期落地提示,明确 K8s 推迟到 Stage 3
* ADR-005 §5"第 1/2 阶段"补出与 rollout Stage 2/3 的映射
* ADR-007 §4 加 /api/v1/ 反向链接;§8 加 tid → wid 字段名映射
* headless-api §0/§7 把"SaaS + on-prem 双主线"改为"SaaS 主线、schema 兼容 on-prem"
* phased-rollout 去除重复的"Go/No-Go 进入 Stage 2"段

Postgres 切换从 Stage 1 提前到 Stage 0:Stage 0 已要 ALTER 4 张表加
workspace_id,先 SQLite 再 PG 是纯返工;Stage 0 没有生产数据,迁移阻力最小。
同步调整 phased-rollout / headless-api / phase-0-plan / workspace-schema-design /
README 中的时间盒(Stage 0: 3-4→4-5 周;Stage 1: 10-15→8-13 周)、不可逆决策
清单、PR 顺序、轨道前置依赖。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:39:58 +08:00

373 lines
16 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-002 · 沙箱隔离模型
| 项目 | 内容 |
|---|---|
| 状态 | 草稿(Draft |
| 决策日期 | TBD |
| 决策者 | 安全 + 架构 + SRE |
| 关联 ADR | ADR-001 数据隔离、ADR-005 存储拓扑 |
| 关联审计 | [adr-vs-code-audit](./adr-vs-code-audit.zh-CN.md) — 注意:现有 `AioSandboxProvider` 出网/资源/cosign 缺位;K8sSandboxProvider 几乎从零开工(实际工作量大于本 ADR §5 估算) |
| 代码命名 | 本 ADR 写 `tenant_id` / `tenant-{tenant_id}` namespace,落代码统一读作 `workspace_id` / `ws-{workspace_id}`(详 [workspace-schema-design §1](./workspace-schema-design.zh-CN.md#1-命名约定--workspace-vs-tenant) |
---
## 0. 概念前提
本 ADR 反复出现 **K8s Namespace + gVisor/Kata 运行时 + NetworkPolicy** 三件套——它们在不同层把租户的代码运行环境关起来,缺一不可。先用一段话讲清楚是什么、防什么、不防什么,再读后面的决策细节会顺很多。
### 0.1 K8s Namespace —— 资源/视图隔离
Kubernetes 的逻辑分区。一个集群里跑多租户,每租户分一个 namespace(如 `tenant-acme``tenant-bigco`),其中的 Pod / Service / Secret / ConfigMap 互相看不见。配套:
- **ResourceQuota**:限制 namespace 总用量(CPU、内存、Pod 数、存储)
- **LimitRange**:单 Pod 兜底(默认 request/limit、单 Pod 上限)
- **RBAC**:把租户管理员权限只绑到自己的 namespace
⚠️ **不是安全边界**。namespace 只让你"看不到",不是"碰不到"。两租户的 Pod 若都跑在默认 `runc` 上、共享同一个 Linux 内核,**任何一个内核 0day 都能让 A 容器逃逸到宿主,进而看到 B 容器**。所以需要下一层。
> 类比:办公楼里不同公司的门禁卡。同事进不了别人的工位,但墙不会自己变厚。
### 0.2 gVisor / Kata —— 内核级隔离
把容器从宿主内核上"再隔一层"。DeerFlow 沙箱要跑租户上传的任意 Python 代码,必须比 runc 更硬。
**gVisorGoogle**
- 用户态实现一个沙箱内核(Sentry),拦截容器所有 syscall 自己模拟,再用极少几个 syscall 跟真内核打交道
- 攻击面:先攻破 Sentry,再攻破真内核——多一道
- 代价:每个 syscall 中转,IO 密集型 workload 慢 ~10-30%
- 集成:Pod spec 写 `runtimeClassName: gvisor`
**Kata Containers**
- 给每个 Pod 起一个轻量虚拟机(QEMU 或 Firecracker 后端),容器跑在 VM 内独立内核里
- 隔离强度 ≈ 真 VM,启动几百毫秒
- 代价:每 Pod 多占 ~50-150 MB 内存、冷启动比 gVisor 慢一点
- 集成:`runtimeClassName: kata-qemu` / `kata-fc`
> 本 ADR 取舍:默认 **gVisor**(性价比平衡),监管/付费档位切 **Kata-Firecracker**(接近 VM 强度,单独定价)。配套 Cosign 镜像签名 + 只读根文件系统 + drop ALL caps 是纵深防御。
### 0.3 NetworkPolicy —— 出/入流量白名单
K8s 原生防火墙,按 namespace / Pod label 控制谁能跟谁通信。**默认拒绝 + 显式放行**是标准姿势:
```yaml
spec:
podSelector: {} # 命中 namespace 内所有 Pod
policyTypes: [Egress]
egress: [] # 空白名单 = 全部禁止
```
为什么对 DeerFlow 是关键——租户代码可能尝试访问:
| 目标 | 风险 |
|---|---|
| `169.254.169.254`(云元数据) | 偷 IAM 凭据、节点 token,直接拿下集群 |
| 平台内网 DB / Redis | 横向打到其他租户的数据 |
| 其他租户的 namespace IP | 跨租户监听/嗅探 |
| 互联网 C2 服务器 | 数据外泄、挖矿、僵尸网络 |
默认全部拒绝后,仅放行:DNS(CoreDNS)+ 出口走 **Egress Gateway**Envoy/Squid 做域名白名单,允许 `api.openai.com``pypi.org` 等,拒绝其余)。
> 执行靠 CNI 插件(Calico / Cilium)。Cilium 还支持 L7 策略(如"允许 GET /v1/chat/completions、禁 POST /admin"),是更强的备选。
### 0.4 三件套合起来看
```
租户的 Python 代码
┌────────────────────────────────────────┐
│ Pod (tenant-acme namespace) │ ← K8s Namespace:逻辑隔离 + 配额
│ ├─ runtimeClassName: gvisor │ ← gVisor:内核级攻击面隔离
│ ├─ readOnlyRootFilesystem │
│ └─ capabilities.drop: ["ALL"] │
└────────────────────────────────────────┘
│ egress
┌────────────────────────────────────────┐
│ NetworkPolicy: default-deny │ ← NetworkPolicy:网络层白名单
│ → 仅允许 Egress Gateway / DNS │
└────────────────────────────────────────┘
Egress Gateway(域名白名单)
```
- 没有 namespace:租户互相能看见对方的资源对象
- 没有 gVisor:一个内核 0day 全集群陪葬
- 没有 NetworkPolicy:租户代码 `curl 169.254.169.254` 就能拿走节点凭据
三层都套上,才是本 ADR 想要的"对抗任意租户代码"的最低防御姿势。
---
## 1. 背景
> **分期落地提示**:本 ADR 描述的 K8s + gVisor + NetworkPolicy 全套架构是**目标态**。按 [phased-rollout-by-scale](../02-rollout/phased-rollout-by-scale.zh-CN.md) 实际落地节奏:
> - **Stage 1**:仅做 §3 威胁模型的"出网默认禁 + cgroup CPU/memory 限额",落到现有 `AioSandboxProvider` 上(轻量补丁版)。**不上 K8s**。
> - **Stage 3**:才换 `K8sSandboxProvider`,引入 namespace + gVisor + NetworkPolicy + Pod Security Standard 全套(§5 全文落地)。
> - **Stage 4 / premium**:按合同切 Kata-Firecracker 或独立 nodepool。
>
> 读 §2~§9 时把它当作"Stage 3 完成态"Stage 1 落地时只摘 §3 出网/资源那两行就好。
沙箱是多租户里**爆炸半径最大**的组件:客户的 agent 可以跑任意 bash 命令、读写文件、调用 MCP 工具。如果隔离不够强,一个客户能:
- **读到其他客户的数据**(容器逃逸 / 共享卷误用)
- **薅云元数据**`curl http://169.254.169.254/...` 偷 IAM 凭证)
- **横向移动**(同 namespace 的其他容器、同节点的 hostNetwork
- **耗尽资源**(fork bomb、无限循环、磁盘填满)
DeerFlow 现状有两个 sandbox provider
| Provider | 强度 | 多租户可用 |
|---|---|---|
| `LocalSandboxProvider` | bash/fs **直接落主机**,零隔离 | ❌ 绝对不能用 |
| `AioSandboxProvider` | Docker 容器(社区实现,`packages/harness/deerflow/community/aio_sandbox/` | ⚠️ 当前配置不够 |
`AioSandboxProvider` 起点不错(每 thread 一个容器、虚拟路径翻译已有),但默认配置缺少多租户必需的几条隔离:默认出网未禁、CPU/内存 limits 未强制、根文件系统未只读、镜像未签名校验。
---
## 2. 决策
**采用 K8s + 强隔离运行时(gVisor 或 Kata Containers+ NetworkPolicy 默认禁出网 + per-tenant Namespace。**
| 层 | 作用 |
|---|---|
| **K8s Namespace per tenant** | 资源逻辑隔离;NetworkPolicy 起效边界 |
| **gVisor (runsc) 运行时** | 用户态系统调用拦截,容器逃逸到宿主难度大幅提升;性能损失 ~5-15%(多数 agent 任务可接受) |
| **NetworkPolicy 默认 DENY** | 出网白名单:只允许到 LLM endpoint、配置的 MCP servers、搜索 API |
| **ResourceQuota + LimitRange** | per-namespace CPU/内存上限;单 pod CPU/内存上限 |
| **Pod Security Standard: restricted** | 禁 root、禁 privileged、只读根文件系统、drop ALL caps |
| **emptyDir 临时卷** | 数据靠 ADR-005 同步对象存储,pod 销毁即清 |
| **镜像签名校验**Cosign | 启动 pod 前验证 sandbox 镜像签名,防供应链攻击 |
**Premium 客户档位**:在此之上再加 per-tenant **物理节点池** + **Firecracker microVM**kata-fc),把"租户 X 的沙箱永远不和别人共享物理节点"做到 SLA 里。
---
## 3. 威胁模型
| 攻击场景 | 共享 Docker(现状) | per-tenant K8s NS + gVisor(决策) | per-tenant Firecracker |
|---|---|---|---|
| 容器逃逸到宿主 | 全员沦陷 | 单租户沦陷(gVisor 大幅降低成功率) | 单租户沦陷(VM 边界,逃逸难度极高) |
| 容器间横向移动(同节点) | 可行 | NetworkPolicy 禁止 + namespace 隔离 | 不可能(独立 VM |
| 出网到云元数据 169.254.169.254 | 默认可行 | NetworkPolicy 禁 + IMDSv2 强制 token | 同左 |
| 出网到内部服务(DB / 内网) | 可行 | NetworkPolicy 禁 + egress gateway 白名单 | 同左 |
| 侧信道(CPU 缓存 / Spectre | 可行 | 减弱(gVisor 隔离系统调用,但 CPU 共享仍有风险) | 显著减弱(独立 VM、独立 vCPU) |
| 资源耗尽(fork bomb / OOM | 影响同节点全部容器 | LimitRange 强制 cgroup;超限 OOMKill | VM 内独立调度 |
| 持久化攻击(写定时任务) | 可写主机 cron | 只读根文件系统 + ephemeral pod 重建即清 | 同左 |
| 提权 | 看 Docker 配置(默认 root | restricted PSS 禁 root、禁 capabilities | 同左 |
| 镜像被替换(供应链) | 不校验 | Cosign 验证签名 + admission controller 拦截 | 同左 |
---
## 4. 备选方案与拒绝理由
### A. 共享 Docker(保留 AioSandboxProvider 当前模式)
**拒绝。** 即使加固到极致,根本问题仍在:
- 所有租户共享 dockerd / containerd,一次容器逃逸 = 全员沦陷
- Docker 的 NetworkPolicy 等价物(user-defined network)粒度粗
- 资源限制依赖 cgroup v1/v2 配置一致,运维难统一
### B. per-tenant Firecracker microVM(默认)
**拒绝(作为默认)。** 隔离最强但成本最高:
- 冷启动比 K8s pod 慢 2-5×(500ms vs 几秒)
- 运维需要专门团队(kata-fc / cloud-hypervisor 都不是开箱即用)
- AWS / 阿里云的部分托管 K8s 不直接支持 Firecracker,需要自建 nodepool
**保留作为 premium 档位**:把 Firecracker 当付费 SLA 卖给监管类客户。
### C. 共享 K8s Namespace + 仅靠 NetworkPolicy + cgroup
**拒绝。** namespace 不分隔意味着:
- pod-to-pod 通信默认开放(NetworkPolicy 是白名单制,漏一条就全开)
- ServiceAccount 共享,越权读 secret 风险大
- ResourceQuota 是 namespace 级别,没法精细分配到租户
---
## 5. 落地影响
### 5.1 新建 `K8sSandboxProvider`
```python
# packages/harness/deerflow/sandbox/k8s/provider.py
class K8sSandboxProvider(SandboxProvider):
"""每 thread 一个 Pod,按 tenant 落到对应 Namespace。
生命周期:
acquire(thread_id) → 创建 PodgVisor runtime, restricted PSS, NetworkPolicy 已挂)
get(sandbox_id) → 返回与运行中 Pod 通信的客户端(kubectl exec / WebSocket
release(sandbox_id) → delete PodemptyDir 自动回收)
"""
```
替换 `LocalSandboxProvider`(开发/测试用)和 `AioSandboxProvider`(保留作为单机部署 fallback)。
### 5.2 K8s 资源(每租户 Namespace 一份)
```yaml
# tenant onboarding 时自动渲染、apply
apiVersion: v1
kind: Namespace
metadata:
name: tenant-{tenant_id}
labels:
pod-security.kubernetes.io/enforce: restricted
deerflow.io/tenant-id: {tenant_id}
---
apiVersion: v1
kind: ResourceQuota
metadata:
namespace: tenant-{tenant_id}
spec:
hard:
cpu: "16"
memory: 32Gi
pods: "20"
requests.storage: 100Gi
---
apiVersion: v1
kind: LimitRange
metadata:
namespace: tenant-{tenant_id}
spec:
limits:
- type: Container
default: { cpu: "500m", memory: 1Gi }
defaultRequest: { cpu: "100m", memory: 256Mi }
max: { cpu: "2", memory: 4Gi }
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: deny-all-egress
namespace: tenant-{tenant_id}
spec:
podSelector: {}
policyTypes: [Egress]
egress: # 仅允许下面这些
- to:
- namespaceSelector: { matchLabels: { name: kube-system } }
podSelector: { matchLabels: { k8s-app: kube-dns } }
ports: [{ protocol: UDP, port: 53 }]
- to: # 通过 egress gateway 出外网
- podSelector: { matchLabels: { app: deerflow-egress-gateway } }
```
### 5.3 Egress gateway
放一个集中的出口代理(envoy / squid)做:
- LLM endpoint 白名单(OpenAI / Anthropic / vLLM 内网)
- MCP server 白名单(per-tenant 启用列表)
- 搜索 API 白名单(Tavily / Jina / Brave / DuckDuckGo
- **黑名单**169.254.169.254cloud metadata)、10.0.0.0/8 / 172.16.0.0/12 / 192.168.0.0/16(内部网络,除非白名单)
- 全量审计(按租户记录每次出网)
### 5.4 Pod Spec 关键字段
```yaml
spec:
runtimeClassName: gvisor # 或 kata-fcpremium
automountServiceAccountToken: false # 沙箱不该有 SA token
securityContext:
runAsNonRoot: true
runAsUser: 65532
seccompProfile:
type: RuntimeDefault
containers:
- name: sandbox
image: registry.deerflow.io/sandbox:v2.3.4@sha256:... # 强制 digest pin
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: { drop: [ALL] }
volumeMounts:
- { name: workspace, mountPath: /mnt/user-data }
- { name: skills, mountPath: /mnt/skills, readOnly: true }
volumes:
- { name: workspace, emptyDir: { sizeLimit: 10Gi } }
- { name: skills, emptyDir: { sizeLimit: 5Gi } } # 启动时从 S3 拉
```
### 5.5 镜像签名
- CI 用 cosign sign 给 sandbox 镜像签名
- K8s admission controllerpolicy-controller / Kyverno)启动 pod 前验证 cosign signature
- 拒绝任何未签名 / 签名不匹配的镜像
### 5.6 SandboxAuditMiddleware 增强
现有 `SandboxAuditMiddleware` 记录了工具调用,多租户必须:
- 每条审计带 `(tenant_id, user_id, thread_id, tool_name, args_hash)`
- 异步推到独立审计存储(不与业务库共用)
- 保留至少 90 天(合规要求常见值)
---
## 6. 性能影响
| 指标 | 估计 |
|---|---|
| Pod 冷启动(gVisor + 镜像 pre-warm | 1.5-3s |
| Pod 冷启动(Firecracker | 3-8s |
| Bash 命令延迟(vs 宿主) | gVisor +5-15%Firecracker +10-30% |
| 文件 I/O(小文件) | gVisor 显著慢(系统调用拦截);用 emptyDir tmpfs 缓解 |
| 网络(egress gateway | +1-3ms 单跳 |
**冷启动是最敏感的指标**——靠两条路径优化:
1. **Pod prewarm**:每个 namespace 维护 N 个空闲 pod 池(`PodReadinessProbe` 通过即可服用,按需绑定 thread)
2. **镜像层缓存**:每节点预拉镜像(DaemonSet image-puller
---
## 7. 风险与缓解
| 风险 | 缓解 |
|---|---|
| gVisor 与某些 syscall 不兼容(agent bash 跑不动某些工具) | sandbox 镜像里预装常用工具;CI 跑兼容性测试集 |
| 出网白名单维护负担 | Per-tenant MCP/搜索配置自动生成 NetworkPolicy;运维 admin UI 一键加 |
| Firecracker 运维复杂 | 仅作为 premium 档位,不强制全量上 |
| 节点 noisy neighborCPU 共享导致侧信道) | 高敏租户走专属 nodepooltaints/tolerations |
| Pod prewarm 池资源浪费 | 按租户活跃度动态调节池大小;闲置超过阈值缩到 0 |
| 镜像供应链攻击 | Cosign 强制 + SBOM + 漏洞扫描 |
---
## 8. 推翻条件
切换到 **per-tenant Firecracker(默认)** 当且仅当:
1. 实测 gVisor 在某条关键 syscall 上有不可绕过的兼容性问题(且 sandbox 镜像无法预装替代品)
2. 拿到合同要求"强物理隔离"的监管客户,付费档位要求覆盖运维成本
3. 出现一次容器逃逸 PoC 影响多租户
切换到 **共享 Docker(极端简化)** 当且仅当:
- 公司决定退回单租户产品形态——多租户上线后基本不应回退
---
## 9. 默认假设
| 项 | 默认 |
|---|---|
| 集群 | EKS / ACK / GKEK8s 1.29+ |
| 沙箱 runtime | gVisor (runsc) |
| Premium runtime | Kata Containers + Firecracker |
| Pod 隔离粒度 | per-thread(不复用) |
| 冷启动 SLO | P50 < 2s, P99 < 5s |
| Pod CPU/Mem 上限 | 2 CPU / 4 GiB(单 pod |
| Namespace 配额 | 16 CPU / 32 GiB / 20 pods(按 plan 调) |
| Egress 白名单数量 | <30 个域名 / 租户 |
| 审计保留期 | 90 天热 + 1 年冷 |
| 镜像签名 | cosign + Kyverno 强制 |