跳到主要内容

代码沙箱部署

Aivory 的 Python 代码执行能力由一个独立的沙箱服务提供。本页讲清它的 sidecar 架构、compose 中每个关键配置项的含义、安全边界,以及如何把沙箱独立部署到另一台机器。终端用户视角的使用说明见Python 沙箱指南

架构:sidecar 控制面 + 一次性会话容器

沙箱由两个镜像组成:

镜像角色
ghcr.io/hjxwz123/aivory-sandbox-sidecar控制服务(sidecar):接收后端的 HTTP 请求,驱动 Docker daemon 创建/回收会话容器
ghcr.io/hjxwz123/aivory-sandbox运行时镜像:每个会话一个锁定后的容器,真正执行用户代码
┌──────────┐ POST /sessions /exec /files ┌───────────────┐ docker exec ┌────────────────┐
│ app 后端 │ ────────────────────────────► │ sidecar 控制面 │ ────────────► │ 会话容器 │
└──────────┘ SANDBOX_BASE_URL └───────────────┘ │ aivory-sandbox │
└────────────────┘

sidecar 本身不执行任何用户代码,它通过挂载的 /var/run/docker.sock 操作宿主机 Docker daemon,为每个会话拉起一个兄弟容器(sibling container)。会话内的 /workspace 在同一会话的多次执行之间保留,pip 安装的包、生成的文件都还在,行为对齐 ChatGPT Code Interpreter。

Docker-in-Docker 的实现方式

默认方案是把宿主机的 /var/run/docker.sock 挂进 sidecar 容器,会话容器实际由宿主机 daemon 创建。如果你的环境不允许挂载宿主 socket,也可以给 sidecar 配一个独立的 dind(docker:dind)daemon,代价是多一层嵌套与存储开销。无论哪种方式,sidecar 都必须能访问一个 Docker daemon,这是硬性要求。

运行时镜像内置了数据科学栈(numpy、pandas、scipy、scikit-learn、matplotlib、seaborn、plotly 等)、文档处理库(python-pptx、python-docx、openpyxl、reportlab、weasyprint 等)以及 Noto Sans CJK 字体,matplotlib 已预配置中文字体,图表里的中文不会显示成方框。

内置沙箱:默认零配置

生产 compose 栈已捆绑 sandbox 服务,docker compose up -d 一条命令连沙箱一起拉起,不需要任何额外配置:

  • 不发布任何宿主端口:只有 app 通过私有 internal 网络访问它(SANDBOX_BASE_URL=http://sandbox:8000),公网永远摸不到。
  • 共享内部 API key:appsandbox 两个服务读同一个 SANDBOX_API_KEY(默认 aivory-bundled-sandbox)。因为沙箱不对外暴露,这个默认值可用;但只要你打算把沙箱端口暴露给任何其它网络,必须换成强随机值。
  • 启动即拉镜像:SANDBOX_PULL_ON_START=1 让 sidecar 冷启动时先拉取运行时镜像,健康检查的 start_period: 120s 就是为这次拉取预留的。
  • 软依赖:appdepends_on 沙箱。沙箱不可用时只有代码执行功能报错,应用其余部分照常工作。

compose 关键配置项逐个讲

以下均为 compose 中 sandbox 服务的环境变量,列出的是生产 compose 的默认值:

变量默认值说明
SANDBOX_API_KEYaivory-bundled-sandboxBearer 鉴权 key,必须与 app 侧一致。sidecar 无 key 拒绝启动,校验使用常量时间比较
SANDBOX_IMAGEghcr.io/hjxwz123/aivory-sandbox:latest每会话运行时镜像
SANDBOX_PULL_ON_START1启动时预拉运行时镜像,保证第一次执行不失败
SANDBOX_NETWORKnone会话容器网络。none 完全断网;设为 bridge 才能联网(如运行时 pip install)
SANDBOX_MEMORY2g单个会话容器内存上限
SANDBOX_CPUS1单个会话容器 CPU 上限
SANDBOX_MAX_SESSIONS16同时存活的会话容器数量上限
SANDBOX_EXEC_TIMEOUT_CAP_MS600000单次执行时长的运维硬上限(10 分钟)
SANDBOX_IDLE_TTL_CAP_SECONDS86400空闲回收窗口的运维硬上限(24 小时)
SANDBOX_READ_ONLY_ROOTFS1会话容器根文件系统只读,防磁盘填满攻击
SANDBOX_WORKSPACE_SIZE512m只读模式下 /workspace 可写 tmpfs 的大小
SANDBOX_LOCAL_STORAGE_DIR/var/lib/aivory/sandbox-archives本地工作区归档目录,配合持久卷实现零配置持久化

SANDBOX_API_KEY:与 app 共享的鉴权凭证

sidecar 暴露的是"驱动宿主机 Docker"的能力,等价于宿主机 RCE,因此鉴权是强制的:key 为空时 sidecar 直接拒绝启动(唯一例外是显式设置 SANDBOX_ALLOW_NO_AUTH=1,仅限可信的本机开发环境)。app 服务与 sandbox 服务必须配置同一个值。生成强 key:

openssl rand -hex 24

SANDBOX_NETWORK:默认断网,联网有代价

默认 none,会话容器完全没有网络,用户代码既不能 pip install 也不能发任何请求。改成 bridge 后代码可以联网,但要清楚代价:

打开 bridge 网络前想清楚

联网的沙箱意味着用户代码可以把会话里的数据(上传的文件、生成的中间结果)发往任意外部地址,也可以主动扫描、攻击你内网中沙箱容器可达的服务。除非明确需要运行时安装包或抓取外部数据,保持 none

资源上限:MEMORY / CPUS / MAX_SESSIONS

SANDBOX_MEMORY(默认 2g)和 SANDBOX_CPUS(默认 1)限制单个会话容器;SANDBOX_MAX_SESSIONS(默认 16)限制同时存活的会话总数。按最坏情况估算宿主机容量:MAX_SESSIONS x MEMORY 是会话容器可能占用的内存总量。另有 SANDBOX_MAX_CONCURRENT_EXECS(默认 4)限制全局并发执行数,SANDBOX_PIDS_LIMIT(默认 256)防 fork bomb。

sidecar 控制面自己也被 compose 限制在 mem_limit: 1g / pids_limit: 512。归档时它会在内存中缓冲最大 200MiB 的工作区 tar 包(SANDBOX_MAX_ARCHIVE_BYTES),mem_limit 要保持在这个值之上留有余量。

两个"硬上限":EXEC_TIMEOUT_CAP_MS 与 IDLE_TTL_CAP_SECONDS

这两个变量是运维层的天花板,与管理后台的可调设置配合工作:

  • 管理员在后台设置的单次执行超时(sandbox_exec_timeout_sec)会被钳制到 SANDBOX_EXEC_TIMEOUT_CAP_MS(默认 600000ms,即 10 分钟)以内。
  • 管理员设置的空闲回收窗口(sandbox_idle_ttl_sec)会被钳制到 SANDBOX_IDLE_TTL_CAP_SECONDS(默认 86400s,即 24 小时)以内。后台未下发时,sidecar 使用兜底值 30 分钟回收空闲会话。

也就是说:管理员可以在天花板之下随意调短,但永远越不过运维在 compose 里定的上限。想全局收紧,改这两个环境变量即可。

磁盘防线:READ_ONLY_ROOTFS / WORKSPACE_SIZE

SANDBOX_READ_ONLY_ROOTFS=1(默认开启)让会话容器的根文件系统只读,只有三个位置可写,且都是有大小上限的 tmpfs:

路径大小来源默认
/workspaceSANDBOX_WORKSPACE_SIZE512m
/tmpSANDBOX_TMPFS_SIZE256m
用户 $HOMESANDBOX_TMPFS_SIZE256m

因此单个会话无论怎么写文件都不可能填满宿主机磁盘。用户上传的文件放在 /workspace/uploads/,代码产物写入 /workspace/outputs/,都受 WORKSPACE_SIZE 约束。需要更大的工作区(如处理大数据集)时调大 SANDBOX_WORKSPACE_SIZE,注意 tmpfs 占用的是内存。

SANDBOX_LOCAL_STORAGE_DIR:工作区归档持久化

会话容器被回收(空闲超时或显式销毁)时,sidecar 会把 /workspace 打成 tar 归档存起来;同一个对话下次执行代码时自动恢复。归档以对话 id 为键,所以即使会话容器换了一茬,工作区内容也能跨回收存续。

SANDBOX_LOCAL_STORAGE_DIR 指定本地归档目录,compose 已把它挂到名为 sandbox-archives 的持久卷上,实现零配置持久化,不需要 S3/OSS/MinIO。注意:

  • 该变量只能由运维通过环境变量设置,永远不接受来自请求或管理后台的值(sidecar 以 root 运行并持有 docker.sock,允许远端指定写入路径等于开放宿主机写入面)。
  • 留空则本地归档不生效,回收即丢失(reaped = gone)。
  • 仅适用于单节点:普通 Docker 卷不跨副本共享,多副本部署必须改用 S3/OSS 后端。
  • 归档是 best-effort:超过 200MiB 的工作区会跳过归档并记日志,归档/恢复失败不会导致执行请求失败。

管理后台可调项

除了 compose 环境变量,部分行为可在管理后台的站点设置中在线调整,无需重启:

后台设置作用约束
单次执行超时(sandbox_exec_timeout_sec)每次代码执行的时长上限SANDBOX_EXEC_TIMEOUT_CAP_MS 钳制
空闲回收时间(sandbox_idle_ttl_sec)会话空闲多久后回收容器SANDBOX_IDLE_TTL_CAP_SECONDS 钳制
存储后端(storage_provider)工作区归档存到哪local(默认,即上文本地卷)/ s3 / aliyun_oss
沙箱地址与 key(sandbox_base_url / sandbox_api_key)覆盖环境变量指向的沙箱留空则环境变量生效

存储后端选 s3 时填入 endpoint、bucket 与凭据即可;MinIO 及任意 S3 兼容服务同样选 s3,填自定义 endpoint 后会自动切换为 path-style 寻址 + SigV4 签名,无需额外开关。

安全边界

沙箱采用容器级隔离,每个会话容器都带满一套锁定项:

  • 非 root 运行,--cap-drop ALL,--security-opt no-new-privileges
  • 默认无网络(--network none)。
  • 只读根文件系统 + 有上限的 tmpfs(见上文),外加 memory/cpu/pids/nofile 限制。
  • 同一会话内的执行串行,全局并发受限;stdout/stderr 截断到 32KB;产物单文件 20MB、单次最多 20 个、总计 50MB。
  • 可选:通过 SANDBOX_SECCOMP_PROFILE 为会话容器固定 seccomp profile(路径需在 sidecar 容器内可读),进一步收窄内核攻击面。
docker.sock 等价于宿主机 root

架构上 sidecar 必须能驱动 Docker daemon,而 /var/run/docker.sock 在宿主机上是 root 等价物:任何能访问 sidecar 服务的人都等于拿到了宿主机。三条纪律:

  1. 永远不要把 sidecar 端口暴露到公网。内置部署已经做到(无 ports 发布);独立部署时绑定内网接口或走 VPN。
  2. SANDBOX_API_KEY 必须是强随机值(内置栈的私网默认值除外)。
  3. 生产环境建议在裸 socket 前放一个 docker-socket-proxy,只放行 container create/start/exec/kill/inspect 与 image pull,其余 API 全部拒绝,然后让 sidecar 走 DOCKER_HOST: tcp://socket-proxy:2375 而不挂载裸 socket。

这是容器级隔离,足以支撑单机自部署,但不是 gVisor/microVM 级别。sidecar 的 HTTP 协议是稳定契约,更高安全要求的部署可以把执行后端替换为 gVisor、Firecracker 等,而 app 侧无需任何改动。

另外,sidecar 重启后会自动发现带 aivory.sandbox=1 标签的存量容器,继续追踪并按 TTL 回收,不会留下孤儿容器。

独立部署:沙箱放到另一台机器

代码执行是 CPU/内存密集型负载,把沙箱拆到独立机器可以避免它与主应用抢资源。沙箱服务有独立的公开仓库与镜像,拉取即用,无需构建:

1. 在沙箱机上部署

git clone https://github.com/hjxwz123/aivory-sandbox.git
cd aivory-sandbox

export OWNER=hjxwz123
export SANDBOX_API_KEY=$(openssl rand -hex 24)
printf 'SANDBOX_API_KEY=%s\n' "$SANDBOX_API_KEY" # 保存这个值

docker compose pull
docker compose up -d

独立 compose 会把 sidecar 发布在宿主机 48217 端口(容器内仍是 8000)。验证:

curl -H "Authorization: Bearer $SANDBOX_API_KEY" http://localhost:48217/healthz

返回 {ok, docker, image} 即就绪。

2. 让主应用指过去

在主应用的 .env 中修改两个变量,并从主栈中移除(或不再启动)内置的 sandbox 服务:

SANDBOX_BASE_URL=http://<沙箱机内网地址>:48217
SANDBOX_API_KEY=<第 1 步生成的同一个值>

也可以不改环境变量,直接在管理后台填 sandbox_base_url / sandbox_api_key,后台值优先于环境变量。

换机必换强 key,端口只走内网

内置栈的默认 key(aivory-bundled-sandbox)只在"沙箱无端口发布、仅私网可达"的前提下才可接受。一旦沙箱监听了真实网卡端口,任何拿到 key 的人都能驱动那台宿主机的 Docker。独立部署必须:用 openssl rand -hex 24 生成新 key;用防火墙/安全组把 48217 限制为仅主应用服务器可访问;两台机器之间尽量走内网或 VPN 链路。

故障排查

现象排查方向
第一次执行代码就失败冷启动时运行时镜像还没拉完,看 docker compose logs sandbox;健康检查预留了 120s 的 start_period
健康检查一直 unhealthysidecar 连不上 Docker daemon,确认 /var/run/docker.sock 挂载存在且宿主 daemon 正常
sidecar 启动即退出SANDBOX_API_KEY 为空,sidecar 设计为无 key 拒绝启动
代码里 pip install 失败默认 SANDBOX_NETWORK=none 无网络,评估风险后改 bridge
写文件报磁盘满撞到 /workspace 的 tmpfs 上限,调大 SANDBOX_WORKSPACE_SIZE
回收后工作区丢失SANDBOX_LOCAL_STORAGE_DIR 未设置或对应卷未挂载;或后台 storage_provider 被清空
应用其它功能受影响不应发生:app 对沙箱是软依赖,只有代码执行功能会报错;若整站异常,问题在别处