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

16 KiB
Raw Blame History

ADR-002 · 沙箱隔离模型

项目 内容
状态 草稿(Draft
决策日期 TBD
决策者 安全 + 架构 + SRE
关联 ADR ADR-001 数据隔离、ADR-005 存储拓扑
关联审计 adr-vs-code-audit — 注意:现有 AioSandboxProvider 出网/资源/cosign 缺位;K8sSandboxProvider 几乎从零开工(实际工作量大于本 ADR §5 估算)
代码命名 本 ADR 写 tenant_id / tenant-{tenant_id} namespace,落代码统一读作 workspace_id / ws-{workspace_id}(详 workspace-schema-design §1

0. 概念前提

本 ADR 反复出现 K8s Namespace + gVisor/Kata 运行时 + NetworkPolicy 三件套——它们在不同层把租户的代码运行环境关起来,缺一不可。先用一段话讲清楚是什么、防什么、不防什么,再读后面的决策细节会顺很多。

0.1 K8s Namespace —— 资源/视图隔离

Kubernetes 的逻辑分区。一个集群里跑多租户,每租户分一个 namespace(如 tenant-acmetenant-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 控制谁能跟谁通信。默认拒绝 + 显式放行是标准姿势:

spec:
  podSelector: {}        # 命中 namespace 内所有 Pod
  policyTypes: [Egress]
  egress: []             # 空白名单 = 全部禁止

为什么对 DeerFlow 是关键——租户代码可能尝试访问:

目标 风险
169.254.169.254(云元数据) 偷 IAM 凭据、节点 token,直接拿下集群
平台内网 DB / Redis 横向打到其他租户的数据
其他租户的 namespace IP 跨租户监听/嗅探
互联网 C2 服务器 数据外泄、挖矿、僵尸网络

默认全部拒绝后,仅放行:DNS(CoreDNS)+ 出口走 Egress GatewayEnvoy/Squid 做域名白名单,允许 api.openai.compypi.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 实际落地节奏:

  • 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 microVMkata-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

# 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 一份)

# 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 关键字段

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 强制