Hub Python Library documentation

底层沙箱

You are viewing main version, which requires installation from source. If you'd like regular pip install, checkout the latest stable version (v2.4.0).
Hugging Face's logo
Join the Hugging Face community

and get access to the augmented documentation experience

to get started

底层沙箱

本指南解释了 Sandboxes 的内部工作原理,以及它们为何采用当前的构建方式及其局限性。如果您只是想使用沙箱,Sandboxes 指南 就足够了;如果您希望了解其机制、评估信任模型或调试某些问题,请继续阅读。

Sandbox API 目前处于实验阶段。其 API 和行为可能会在不另行通知的情况下发生变化。

不存在“沙箱服务”

首先要理解的一点是,并没有专门的沙箱后端。沙箱只是一个运行着单个小型静态二进制文件 sbx-server 的 HF Job(即虚拟机),该二进制文件通过 HTTP 进行通信。客户端通过 Jobs 代理(即 Job 暴露的 *.hf.jobs URL)与该服务器通信。其余所有内容——身份验证、服务发现、将多个沙箱打包到一个 Job 中——都是基于现有的 Jobs 原语构建的:标签、环境变量和密钥。

这种“无需新增基础设施”的设计,正是沙箱能够在任意 Docker 镜像中运行,并免费继承 Jobs 的计费、硬件规格和命名空间权限的原因。

启动服务器

在任务启动时,该任务的命令是一个小型的 /bin/sh -c 脚本,用于获取 sbx-server 二进制文件,赋予其可执行权限,并 exec 执行它。该二进制文件是一个约 640KB 的静态 musl 构建版本,没有任何运行时依赖项,因此可以在任何 x86_64 Linux 镜像中运行。

有几个值得指出的决策:

  • 下载,并带有挂载回退机制。 快速路径使用 wget 或 curl(所有常见的 base image 都自带其中之一)从 HF CDN 下载二进制文件,速度快且免费。作为安全网,服务器的 Hub 仓库也会作为卷挂载到每个作业上:如果镜像中既没有 wget 也没有 curl,脚本会改为从该挂载点复制二进制文件。挂载是透明的——除非实际读取,否则不产生任何开销——但读取它会使冷启动增加约 2-3 秒,因此它不是默认方式。对镜像的唯一硬性要求是 /bin/sh。
  • 手写 HTTP/1.1 服务器,不依赖任何框架。实时输出流要求在生成每个数据块时立即将其刷新发送。常见的极简 Rust HTTP 服务器(例如 tiny_http)会缓冲分块响应,直到整个响应完成,这会破坏流式传输。因此,该服务器手动实现了 HTTP/1.1:针对 exec 使用 NDJSON 事件流,针对文件使用原始请求体,并对每个数据块执行显式刷新。
  • 端口 49983。服务器监听一个刻意选用的不常用端口,以便将常见的开发端口(3000、8000、8080 等)留给您的代码使用。

该服务器在 github.com/huggingface/sandbox-server 上开源。

身份验证是无状态的

两个独立的层保护沙箱:

  1. 代理网关。Jobs 代理仅转发携带具有该作业命名空间读取权限的 HF 令牌的请求。互联网上的随机用户无法访问该 URL。
  2. 应用层网关。 sbx-server 还会在每个请求(/health 除外)中检查 X-Sandbox-Token 请求头。这是一种纵深防御机制:即使只读命名空间成员能够访问代理,也无法执行命令。

沙箱令牌是派生的,而非存储的:

nonce  = random 128-bit hex                       # stored in the job label "hf-sandbox-nonce"
token  = HMAC-SHA256(key=your_hf_token, msg="hf-sandbox:" + nonce)

nonce 是公开的,HMAC 密钥即您的 HF token,因此任何持有该 token 的机器都可以重新计算沙箱 token——这正是重连能够保持无状态的原因。这也意味着该 token 是从您的凭据派生出的能力,而非对调用方的身份验证:服务器验证的是调用方是否知晓该 token,而非其身份。

每个沙箱作业还带有两个用于发现的稳定标签 — hf-sandbox=1(所有作业均包含)以及 hf-sandbox-mode=dedicated 或 hf-sandbox-mode=pool — 因此您可以在服务端列出或筛选它们,例如 hf jobs ps --label hf-sandbox=1。

该令牌通过 Job secret 传递到服务器。客户端会根据标签中的公开 nonce 按需重新推导该令牌。这带来了一些有益的效果:

  • 无状态重连。 Sandbox.connect(id) 可在任何持有相同 HF token 的机器上运行——从标签中读取 nonce,重新计算 token。无需本地文件,也无需复制任何状态。
  • HF token 不会作为环境变量或作业密钥传递给沙箱(除非您通过 forward_hf_token=True 选择启用)。这并非保证您的凭据绝对无法被访问的硬性承诺:监听沙箱端口的进程是镜像启动时最先运行的进程,因此不受信任的镜像可能能够观察客户端发送的请求——包括其 Authorization 请求头。应将可从沙箱访问的凭据视为可能已暴露给该沙箱。

令牌范围

每个 Job 会铸造一个 nonce,因此派生出的 token 是 job 凭证。在专用模式下,job 即沙箱,因此两者一致。在池化模式下,job 是主机,而一台主机承载多个沙箱——因此池化沙箱会获得一个属于它自己的、范围更窄的第二凭证:

credential如何推导授权
专用沙箱令牌作业标签中的 HMAC(hf_token, nonce)该沙箱(即整个任务)
池主机令牌来自主机作业标签的HMAC(hf_token, nonce)池管理:在该主机上创建、列出和删除沙箱,并恢复其令牌
池化沙箱令牌由主机服务器为每个沙箱生成的随机 256 位那个沙箱——不是同级,也不是池

客户端会自动使用较窄的那个:pool.create() 在创建响应中接收它,Sandbox.connect("<host>.<id>") 使用主机令牌恢复它,而 proxy_headers 则提供沙箱的令牌而非主机的令牌(在您读取时解析 HF 承载令牌,因此长期有效的句柄不会提供过期的令牌)——这些标头通常最终会出现在浏览器或 WebSocket 客户端中,因此它们应仅授予对单个沙箱的访问权限,而不授予其他任何权限。

这对泄露意味着什么:池化沙箱令牌的泄露会危及该沙箱。主机令牌的泄露会危及主机——包括其上当前及未来的所有沙箱,以及其管理路由——因此应将其视为池的管理员凭证。您命名空间中的成员持有不同的 HF 令牌,无法推导出您的令牌,但请参阅已知限制,了解令牌如何可能被传递到错误的位置。

客户端要求用于池化操作的沙箱范围令牌不能为空,并且会拒绝未提供该令牌的主机。它绝不会使用主机管理凭证作为替代。在使用此 API 之前,请升级客户端并回收较旧的池主机。

专用沙箱(Sandbox.create)

直接模型:每个沙箱一个 Job。Job 是一个真实的虚拟机,因此提供了最强的隔离(虚拟机级别),支持包括 GPU 在内的任意硬件配置,并且是处理互不信任代码的正确选择。其 API 路由位于 /v1/*,文件路径在容器文件系统上是绝对路径。kill() 仅取消该 Job。

专用沙箱为你提供的边界位于 Job 与其外部的一切 之间。在虚拟机内部,你的代码和 sbx-server 都在同一个容器中以 root 身份运行:sbx-server 不会对你执行的命令进行沙箱隔离,它只是通过 HTTP 暴露该命令。因此,一旦你不信任的代码在专用沙箱中执行过,就应将整个虚拟机——包括其控制平面——视为属于该代码:它可以终止或替换服务器,而你在该沙箱中后续执行的一切操作都将受其支配。请为每个不受信任的工作负载使用一个专用沙箱,并在完成后使用 kill() 将其终止,而不是在信任边界之间复用同一个沙箱。

成本就体现在图中:每个沙箱都要支付约 6 秒的完整虚拟机冷启动时间,并按整台机器计费。对于单个沙箱或 GPU 工作负载,这正是您所需要的。但对于 100 到 1000 个短时的 CPU 任务来说,这样做是浪费的——而这正是资源池存在的意义。

Pools:在一个 Job 中拥有多个沙箱(SandboxPool)

池(Pools)适用于同一信任边界内的工作负载。 池化沙箱是共享虚拟机内的一个 uid 加上一组 Landlock 规则集,而非独立的虚拟机,其宿主机运行着一个特权控制平面,所有沙箱都会与其通信。使用池可以低成本地并行运行您自己的代码。对于相互不信任的工作负载——即您不希望其读取其他沙箱文件的情况——请使用 Sandbox.create(),它将为每个工作负载提供独立的虚拟机。有关具体的局限性,请参阅 已知限制。

典型的强化学习 rollout 或工具执行沙箱仅需几 MB 内存和一个核心,运行几秒钟。为每个沙箱支付 2 vCPU 虚拟机并承受 6 秒的冷启动时间——甚至触发 1000 个虚拟机的调度突发——是一种错误的权衡。因此,SandboxPool 将单个 Job 作为主机运行,并在其中复用多个沙箱。

池化沙箱不是嵌套的虚拟机或容器。它是经典的 Unix 多用户原语:

  • 一个专用的 uid(≥ 20000),
  • 一个由该 uid 拥有的私有 0700 主目录,
  • commands exec‘d as that uid with a scrubbed environment (env_clear, so the host’s secrets never leak in), NO_NEW_PRIVS, per-process rlimits, and a per-sandbox Landlock ruleset.

因此,创建沙箱就是 mkdir + chown + build ruleset ≈ 1ms 服务端操作——无需启动第二个虚拟机。客户端可见的唯一延迟是代理往返时间。

池化沙箱的公开 ID 为 <host_job_id>.<local_id>,因此 connect/exec/kill 与专用沙箱一样以无状态方式工作。对池化沙箱调用 kill() 会向其宿主发送 DELETE 请求(释放一个槽位);宿主将继续运行。

池中的隔离:uid + Landlock

这是池设计的核心所在,因此有必要精确说明哪些部分是隔离的,哪些不是。

标准的 Job 在用户命名空间内以 root 身份运行,该命名空间仅映射 uid 0..65535,启用了 seccomp 过滤器,且不具备 CAP_SYS_ADMIN / CAP_NET_ADMIN / CAP_NET_RAW 权限。这排除了常用的重量级隔离工具:无法创建嵌套命名空间,无法创建新挂载点,也无法进行 cgroup 委托(unshare、mount、向 /sys/fs/cgroup/... 写入均会失败)。内核提供的是 Landlock(ABI 6),这是一个 Linux 安全模块,允许任何非特权进程限制自身及其子进程——这正是我们所需的每个沙箱的边界。对于每个沙箱,服务器都会构建一个规则集;exec 子进程会降级到沙箱的 uid/gid,并在运行命令前应用 NO_NEW_PRIVS、landlock_restrict_self 以及 rlimits。

将不同的 uid(自主访问控制)与 Landlock 结合使用,其设计和测试旨在提供:

  • ✅ A 无法读取其他进程的 environ,从而阻止了沙箱之间对 HF 和沙箱令牌的直接访问。
  • ✅ A 无法对 B 的进程执行 SIGKILL / ptrace / 读取其内存,无法 setuid 到 B,也无法读取 B 的 0700 主目录。
  • ✅ 拒绝访问 /tmp 和 /dev/shm — 每个沙箱都通过 Landlock 限制在其自身的 home 目录内(其 TMPDIR 指向 $HOME 内部)。
  • ✅ A 无法bind TCP 端口,因此沙箱之间不存在本地主机服务(出站connect仍然被允许,因此互联网可以正常工作)。
  • ✅ 跨沙箱的抽象 Unix 套接字已被阻止(LANDLOCK_SCOPED_ABSTRACT_UNIX_SOCKET;仅通过 uid 隔离 无法 阻止这些套接字)。

这些控制措施用于约束工作负载。根控制平面仍是一个独立的信任边界:其主机模式下的文件操作会拒绝符号链接,其套接字代理会固定套接字 inode 并检查对端 uid,且专用路由在主机模式下不可用。剩余的共享通道在已知限制中进行了说明。

为什么这不能替代虚拟机。Landlock 和 uid 隔离旨在用于同一信任边界内的工作负载。由于池化沙箱共享内核、虚拟机和控制平面,无法保证能防御所有跨沙箱攻击;资源以及部分进程列表元数据也仍然共享。对于互不信任的代码,或对于 GPU,请使用 Sandbox.create(),它将为每个沙箱提供独立的虚拟机。

池中的文件模型

由于池化沙箱中唯一可写的区域是其受 Landlock 限制的主目录(该目录同时也是其默认工作目录),文件 API 会将所有路径的根定位到该主目录:files.write("data/in.txt", ...) 会写入 $HOME/data/in.txt,开头的 / 被视为相对于主目录的路径,而 .. 无法逃逸出该目录。通过 API 写入的文件会被 chown 到沙箱的 uid,以便沙箱自身的代码能够读取它们。这提供了一个清晰的“以沙箱为根的文件系统”模型,与沙箱内部代码实际可触及的范围完全一致——这与专用沙箱不同,在专用沙箱中,路径是容器文件系统上的绝对路径。

路径会先进行词法规范化,然后相对于一个已打开的主目录描述符进行解析,并且对每个路径组件都拒绝符号链接。文件 API 不会跟随符号链接,包括同一主目录内的链接;沙箱代码仍可在其 Landlock 规则下使用符号链接。

Pools 没有权威的本地状态

Pool 并非有意设计为本地配置文件。Pool 即其正在运行的主机 Job 集合,这些 Job 共享 hf-sandbox-pool=<id> 标签。这使 Pool 与沙箱 API 的其余部分保持一致(所有内容均可通过标签发现,并可在任意机器上重新附加),也意味着一旦其最后一台主机消失,Pool 便不复存在。

  • 主机在其作业环境变量中承载池的配置(镜像、规格、sandboxes_per_host、空闲超时)——标签仅用于过滤。当客户端需要启动一个重复的主机时,它会从一个正在运行的主机中读取该配置(inspect_job),从而确保池中的所有主机保持一致,而无需中央记录。
  • 环境变量和密钥是按沙箱隔离的,在创建时传入——绝不在池级别设置。池化沙箱的环境变量在其生命周期内保存在宿主服务器的内存中(每次执行命令时都会重新应用);它们永远不会写入磁盘,也不会出现在任何 Job 的元数据中。池化沙箱没有加密密钥通道——请使用 env,并将这些值视为“非静态存储,但也未加密”。对于需要加密存储的值,请使用专用沙箱的 secrets。
  • 容量由服务器端权威管理。当创建数量超过 sandboxes_per_host 时,主机将拒绝创建请求(返回 {"rejected": N});客户端会将超出部分打包到另一台主机上,或启动一个副本。这确保了即使多个进程并发地向同一资源池创建资源,打包过程依然精确无误。
  • 空闲驱逐分为两级。每个沙箱在自身 idle_timeout 时间内无活动后会被驱逐(除非仍有正在运行的进程);当一台主机在主机空闲超时时间内没有任何沙箱时,它将自行关闭——即使所有客户端都消失,这也是一道计费兜底措施。

尽力而为的缓存可让 create --pool 保持快速

缺乏权威的本地状态对正确性很有利,但会增加延迟。一个冷启动的 hf sandbox create --pool <id>(一个全新的 CLI 进程)在创建沙箱之前,否则必须通过网络重新发现所有信息:list_jobs 扫描命名空间 → inspect_job 检查每个主机以重建其 URL 和 nonce → GET /v1/sandboxes 查看每个沙箱的填充程度 → 最后 POST 创建。每次调用都会产生多次纯粹开销的网络往返。

位于 $HF_HOME/sandbox/pools/<context>/<pool-id>.json 的尽力而为缓存可消除该开销。在任何创建/预热操作之后,进程会记录池配置以及每个主机的代理 URL、认证 nonce 和最近一次观察到的空闲槽位。下一个进程将直接从该文件重建主机传输(无需 HTTP),并直接执行 POST。

<context> 是端点、凭据以及条目所写入的命名空间的摘要——凭据以指纹形式呈现;您的令牌本身永远不会写入缓存。目录名和文件都包含该摘要,如果读取时这三者中有任何一项不匹配,则视为普通的缓存未命中,因此同一台机器上的其他用户、其他端点,或 connect(pool, namespace="other-org") 将走冷路径,而不是使用为其他目的缓存的主机。文件创建权限为 0600,目录为 0700。

缓存的用途与限制:

  • 对容量不具有权威性。 作业内的服务器始终保持权威性,因此过时的 live 计数只会导致一次无效请求,而绝不会造成主机装箱错误。
  • 不信任主机所在的地址。 缓存的 URL 仅在其与同一条目所命名的作业的 HTTPS jobs-proxy URL 一致时才会被使用,因此缓存文件无法决定您的 HF bearer 和 sandbox token 的发送目标;任何命名了其他内容的条目都将从文件中丢弃。此外,条目超过 15 分钟后也不会再单独被采信:inspect_job 必须确认该作业仍在运行、仍标记为属于此池,并且仍位于该 URL 上,之后才会构建传输。
  • 因其形状而不可信。类型和范围会在读取时进行检查,因此手动编辑、截断或采用字符串类型的文件会导致缓存未命中,而不是在create()执行过程中出现错误。
  • 自愈。 已消失的缓存主机会在首次请求失败时被丢弃,并从文件中清除;创建操作会透明地回退到标签发现。
  • 并发安全。写入操作在文件锁(以 job_id 为键)下合并并原子提交,因此并行的 create 进程不会相互覆盖,读取方也永远不会看到半写入的文件。
  • 可丢弃的。删除它只会导致缓存未命中:冷路径会执行,一切功能依然正常。它绝不会在多台机器之间共享。

有两点需要注意。请将 $HF_HOME 视为敏感信息:其中一条记录仍然是一个可复用的主机 URL 加上其令牌所派生的公开 nonce。虽然在其他凭据下写入的文件会被忽略,但在 您 的凭据下写入的文件会在上述 15 分钟窗口内被计入。此外,由于命名空间是键的一部分,在显式 namespace= 下缓存的池只有在传入相同的 namespace= 时才能再次找到——hf sandbox create --pool <id> 需要与创建该池时使用的 --namespace 相同,否则它将走冷路径并在您自己的命名空间中查找。

已知限制

池适用于同一信任边界内的工作负载。对于您不信任的代码,请使用专用沙箱。 以下检查可降低特定风险;它们不会将池化沙箱转变为独立的虚拟机。

威胁模型概览

Actor专用池
匿名用户Jobs 代理需要一个具有命名空间读取权限的 HF 令牌。相同。
具有读取权限的命名空间成员可以访问代理和公共健康检查,但 API 操作需要沙箱令牌。相同。
可以创建作业的命名空间成员无法从其他用户的 nonce 推导出其令牌。可以复制池标签,但默认采用操作会检查 Jobs API 的发起者。adopt_hosts="namespace" 表示选择信任其他创建者。
在沙箱中运行的代码与虚拟机内的服务器共享根目录;请勿跨信任边界重复使用该虚拟机。受 uid 和 Landlock 限制,但共享内核、虚拟机和特权控制平面。
您的客户端保存 HF 和专用沙箱的凭据。保存主机管理凭据以及每个沙箱的独立权限。

隔离和资源缺口

  • 主机发现不等于证明。 默认的 adopt_hosts="own" 会检查发起者、镜像、flavor、命令以及暴露的 URL。它不会对正在运行的二进制文件进行证明,也不会使可变镜像变得可信。仅在你信任命名空间成员的情况下,才使用 adopt_hosts="namespace"。
  • 主机凭据仍然具有强大权限。它们用于管理资源池并恢复每个沙箱的令牌。此客户端绝不会用主机凭据来替代缺失的作用域令牌。由此版本启动的主机运行的是 sbx-server 0.7.0,该版本会在作用域路由上拒绝主机凭据;而由旧版客户端启动的主机则会保留其服务器(及其主机令牌兼容性),直到被回收。
  • 共享通道仍然存在。出站 TCP、对控制服务器的回环访问、UDP、内核 IPC 以及可读的进程列表元数据均未被隔离。系统目录和部分设备节点仍可访问。GPU 池隔离尚未经过测试;对于 GPU,请使用专用模式。
  • 限制并非聚合配额。 进程级 rlimit 和有界输出队列可降低资源放大效应。它们不会在沙箱之间划分 CPU 份额、总内存、磁盘空间、inode 或网络容量。目录分页可限制响应大小,但仍会在服务器上实例化该目录。API 文件写入并非磁盘配额,且专用模式下对特殊文件的写入可能会阻塞。
  • 可以显式地削弱隔离。主机模式默认要求 Landlock ABI 6,并会拒绝失败的规则集。降低 SBX_MIN_LANDLOCK_ABI 或使用服务器的 --allow-unconfined 开发标志会接受更少的保证;请检查经过身份验证的 /health 以获取有效的 ABI 和功能。
  • 代理连接仅进行一次身份验证。在首次请求之后,代理会将字节复制到该后端,直到遇到 EOF。该连接上后续的 HTTP 请求不会独立进行身份验证或路由。WebSocket/SSE 兼容性并不能确保对上游连接复用的安全性。

生命周期和凭据

  • 分离的后代进程可能在进程终止或超时后继续存活。 信号会发送给整个进程组;调用了 setsid() 的后代进程会脱离该进程组。要结束整个工作负载,请删除池化沙箱或终止专用 Job。
  • max_hosts 是尽力而为的。 不同的客户端可能会统计到相同的主机,并各自部署更多实例。要实现严格的共享上限,需要后端准入控制或幂等性机制。
  • Teardown 可能会失败。 主机取消失败时会抛出异常并保留缓存记录。close() 会限制其对进行中创建操作的等待时间;如果某个创建操作超出了该等待时间,可能会留下一个可计费的 Job。请使用 Jobs API 或 CLI 来核对正在运行的资源。
  • Bearer 轮换不会轮换沙箱功能。代理身份验证会针对每个请求解析当前的 HF 令牌,但无状态重连会从确切的原始 Bearer 值派生主机令牌。轮换该值可能会阻止与现有主机重新连接。
  • 本地缓存作为受信任输入保留 15 分钟。 会检查其 URL 和安全上下文,但它没有完整性 MAC;请保护 $HF_HOME 免受不受信任代码的写入。
  • 摘要固定(digest pin)并非签名的来源证明。 客户端仅验证某一个特定的服务器二进制文件。发布和更新该固定值仍属于发布流程的一部分;工作流的清单(manifest)并非密码学证明。显式的 SBX_ALLOW_UNVERIFIED_SERVER=1 逃生机制仅在两个哈希工具均缺失时跳过验证。

未认证的 /health 仅暴露存活状态和协议信息。获取详细元数据需要主机凭据。沙箱 ID 和令牌需要内核随机源;UID 复用遵循已验证的进程和主目录清理。

性能

所有数值均基于 cpu-basic 上的真实 HF Jobs 测量,客户端位于笔记本电脑上,所有流量均通过 Jobs 代理传输。

专用沙盒:

metricvalue
冷启动(create() 返回,服务器开始响应)~5.8s 中位数
run() 往返p50 ~110ms(代理 RTT 下限约为 ~105ms;客户端开销 ≈ 0)
文件传输(并行范围,>8 MiB)~340 MiB/s 下载,~441 MiB/s 上传

池(共享/主机模式):

N 个沙箱主机(50 台/主机)provision + 创建所有在所有位置执行全部终止总计
10026.1s1.5s0.6s8.2s
1000207.4s4.2s4.2s15.8s

创建、执行并销毁 1000 个沙箱耗时约 16 秒,其成本大致相当于一次主机冷启动(约 6 秒)在所有沙箱上的摊销——总计约 $0.0009(20 × cpu-basic),而若为每个沙箱创建一个 Job,则需约 $0.06 并引发 1000 个 VM 的调度突发。服务端的创建/执行/删除操作各耗时约 1 毫秒;预算完全由网络往返时间构成。

设计决策,回顾

决策为什么
基于任务构建,无需新服务继承计费、硬件和权限;可在任意镜像中运行
在启动时下载的静态 Rust 二进制文件无需 Python/pip;冷启动时间约 6 秒,而基于 pip 的引导过程需要 30–90 秒
手工编写的 HTTP/1.1最小化框架会缓冲分块响应,从而破坏实时流式传输(已验证)
无状态 HMAC 身份验证可从任何位置重新连接;使用派生令牌而非 HF 令牌本身(按任务限定范围——参见 令牌范围)
run() 在退出码非零时抛出异常(设置 check=False 可禁用此行为)“运行代码,查看错误”循环的最佳开发体验(E2B 风格)
idle_timeout 看门狗,而非客户端清理持久沙箱是一项功能;泄漏的沙箱仍会终止
Pools = uid + Landlock,服务器端权威容量,无本地状态快速同用户扇出;并发下正确;可在任意位置重新连接
Update on GitHub