89fe54cc07
* 新增 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>
373 lines
16 KiB
Markdown
373 lines
16 KiB
Markdown
# 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 更硬。
|
||
|
||
**gVisor(Google)**
|
||
- 用户态实现一个沙箱内核(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) → 创建 Pod(gVisor runtime, restricted PSS, NetworkPolicy 已挂)
|
||
get(sandbox_id) → 返回与运行中 Pod 通信的客户端(kubectl exec / WebSocket)
|
||
release(sandbox_id) → delete Pod(emptyDir 自动回收)
|
||
"""
|
||
```
|
||
|
||
替换 `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.254(cloud 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-fc(premium)
|
||
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 controller(policy-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 neighbor(CPU 共享导致侧信道) | 高敏租户走专属 nodepool(taints/tolerations) |
|
||
| Pod prewarm 池资源浪费 | 按租户活跃度动态调节池大小;闲置超过阈值缩到 0 |
|
||
| 镜像供应链攻击 | Cosign 强制 + SBOM + 漏洞扫描 |
|
||
|
||
---
|
||
|
||
## 8. 推翻条件
|
||
|
||
切换到 **per-tenant Firecracker(默认)** 当且仅当:
|
||
|
||
1. 实测 gVisor 在某条关键 syscall 上有不可绕过的兼容性问题(且 sandbox 镜像无法预装替代品)
|
||
2. 拿到合同要求"强物理隔离"的监管客户,付费档位要求覆盖运维成本
|
||
3. 出现一次容器逃逸 PoC 影响多租户
|
||
|
||
切换到 **共享 Docker(极端简化)** 当且仅当:
|
||
|
||
- 公司决定退回单租户产品形态——多租户上线后基本不应回退
|
||
|
||
---
|
||
|
||
## 9. 默认假设
|
||
|
||
| 项 | 默认 |
|
||
|---|---|
|
||
| 集群 | EKS / ACK / GKE,K8s 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 强制 |
|