Hub Python Library documentation
底层沙箱
底层沙箱
本指南解释了 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 上开源。
身份验证是无状态的
两个独立的层保护沙箱:
- 代理网关。Jobs 代理仅转发携带具有该作业命名空间读取权限的 HF 令牌的请求。互联网上的随机用户无法访问该 URL。
- 应用层网关。
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 无法
bindTCP 端口,因此沙箱之间不存在本地主机服务(出站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 代理传输。
专用沙盒:
| metric | value |
|---|---|
冷启动(create() 返回,服务器开始响应) | ~5.8s 中位数 |
run() 往返 | p50 ~110ms(代理 RTT 下限约为 ~105ms;客户端开销 ≈ 0) |
| 文件传输(并行范围,>8 MiB) | ~340 MiB/s 下载,~441 MiB/s 上传 |
池(共享/主机模式):
| N 个沙箱 | 主机(50 台/主机) | provision + 创建所有 | 在所有位置执行 | 全部终止 | 总计 |
|---|---|---|---|---|---|
| 100 | 2 | 6.1s | 1.5s | 0.6s | 8.2s |
| 1000 | 20 | 7.4s | 4.2s | 4.2s | 15.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,服务器端权威容量,无本地状态 | 快速同用户扇出;并发下正确;可在任意位置重新连接 |