Ray Sandboxes#

Ray Sandboxes use gVisor to provide lightweight, kernel-isolated execution environments for running untrusted code and agent tool calls safely on Ray clusters.

Warning

Ray Sandboxes (ray.experimental.sandbox) is an alpha library. The API can change or disappear in any release before it graduates to stable.

Background#

The ability to sandbox model-generated code is critical for agentic reinforcement learning (RL) and large language model (LLM) agents. Executing untrusted code directly in Ray worker processes or host environments introduces security and stability risks. Ray Sandboxes solve this challenge by running lightweight, kernel-isolated sandboxes directly on Ray worker nodes using gVisor (runsc). Scale and manage sandbox environments with familiar Ray concepts and primitives.

What is gVisor?#

gVisor is an open-source application kernel written in Go that provides lightweight, defense-in-depth isolation for containers. Developed by Google, gVisor implements a substantial portion of the Linux system call interface in user space, acting as an isolation barrier between untrusted applications and the host operating system kernel.

Unlike standard container runtimes such as Docker or runc, where containers share the host Linux kernel directly, gVisor intercepts system calls made by containerized processes before they reach the host. gVisor is daemonless and runs as a non-privileged user, so you can deploy and manage it on top of existing container orchestrators such as Kubernetes.

Why gVisor?#

Untrusted code interacts with gVisor’s user-space kernel rather than the host Linux kernel, which shrinks the attack surface for host kernel vulnerabilities and container breakout exploits. gVisor also runs entirely in user space, without host root privileges, the Docker daemon, or nested virtualization hardware extensions, so it runs inside existing Kubernetes Ray worker Pods and cloud container environments.

The runtime cost is low next to full virtual machines (VMs) and MicroVMs, which boot a guest OS kernel and manage heavy disk images. A gVisor sandbox boots in tens of milliseconds, adds minimal memory overhead, and uses near-zero idle CPU, so Ray worker nodes can densely pack hundreds of concurrent sandboxes alongside standard Ray tasks and actors and sustain the high-frequency execution loops that RL rollouts and agent tool calls need.

Requirements#

Ray Sandboxes need the following on every Ray node that runs a sandbox:

  • Linux: x86_64 or arm64.

  • gVisor (runsc): Install the runsc binary on worker nodes and make it reachable from the system $PATH.

  • Ray: version 2.58.0 or later, which includes the ray.experimental.sandbox package.

  • erofs-utils: mkfs.erofs 1.7 or later on the $PATH. Ray caches each image as an EROFS file that gVisor mounts inside its own kernel, so files in the sandbox keep the image’s real owners and chown works for any uid, with no privileges or id mappings on the node. Sandbox creation fails without it.

  • slirp4netns (network="public" only): The slirp4netns binary on the $PATH, plus /dev/net/tun in the worker’s environment. slirp4netns bridges each sandbox’s private network namespace to the node.

To install runsc on a Linux worker node, see the gVisor installation guide. gVisor’s prebuilt runsc only supports 4 KiB pages, so on nodes whose kernel uses 64 KiB pages, build it from source with --define=pagesize=64k. slirp4netns ships as a package on Debian, Ubuntu, and Fedora, or as a static build for x86_64 and aarch64. On Ubuntu 24.04 or Debian 13 with a kernel page size of 4 KiB, install the erofs-utils package from the distribution’s repositories. On Ubuntu 22.04, this package is too old, so you need to build a release from the erofs-utils repository instead. Install autoconf, automake, libtool, pkg-config, liblz4-dev, and uuid-dev, then run ./autogen.sh && ./configure --disable-fuse && make && make install. On nodes whose kernel uses 64 KiB pages, you need to build from source in all cases (regardless of distribution). Follow the instructions above, but additionally pass MAX_BLOCK_SIZE=65536 to ./configure. This is required because runsc only accepts EROFS images whose block size is a multiple of the node’s page size, and distro packages build with 4 KiB blocks.

For example, the following Dockerfile adds all three tools to a Ray image for nodes with 4 KiB pages. Ray’s images are based on Ubuntu 22.04, whose erofs-utils is too old, so it builds erofs-utils from source. It does so in a separate stage, so the build tools stay out of the final image. On an Ubuntu 24.04 or Debian 13 base image, apt-get install erofs-utils instead:

ARG GVISOR_VERSION=20260921.0
ARG SLIRP4NETNS_VERSION=1.3.5
ARG EROFS_UTILS_VERSION=1.9.4

FROM rayproject/ray:latest AS build
ARG GVISOR_VERSION
ARG SLIRP4NETNS_VERSION
ARG EROFS_UTILS_VERSION

USER root
RUN apt-get update \
    && apt-get install -y --no-install-recommends autoconf automake bzip2 \
        ca-certificates curl gcc libtool liblz4-dev make pkg-config uuid-dev

# gVisor, with the gvisor-bin/ helpers that must stay next to runsc
RUN mkdir -p /out/bin \
    && curl -fsSL "https://storage.googleapis.com/gvisor/releases/release/${GVISOR_VERSION}/$(uname -m)/gvisor.tar.bz2" \
        | tar -xj -C /out/bin

# slirp4netns, for network="public"
RUN curl -fsSL -o /out/bin/slirp4netns \
        "https://github.com/rootless-containers/slirp4netns/releases/download/v${SLIRP4NETNS_VERSION}/slirp4netns-$(uname -m)" \
    && chmod a+rx /out/bin/slirp4netns

# erofs-utils
RUN curl -fsSL "https://github.com/erofs/erofs-utils/archive/refs/tags/v${EROFS_UTILS_VERSION}.tar.gz" \
        | tar -xz -C /tmp \
    && cd "/tmp/erofs-utils-${EROFS_UTILS_VERSION}" \
    && ./autogen.sh \
    && ./configure --disable-fuse --prefix=/out \
    && make -j"$(nproc)" \
    && make install

FROM rayproject/ray:latest
COPY --from=build /out/bin/ /usr/local/bin/

For nodes with 64 KiB pages, the following Dockerfile builds both runsc and erofs-utils from source. As before, it builds them in a separate stage, so the build tools stay out of the final image. PAGE_SIZE is the page size of the nodes the image runs on, and defaults to 65536. To set it explicitly, pass it as a build argument, for example --build-arg PAGE_SIZE=4096:

ARG GVISOR_VERSION=20260921.0
ARG SLIRP4NETNS_VERSION=1.3.5
ARG EROFS_UTILS_VERSION=1.9.4
ARG PAGE_SIZE=65536

FROM rayproject/ray:latest AS build
ARG GVISOR_VERSION
ARG SLIRP4NETNS_VERSION
ARG EROFS_UTILS_VERSION
ARG PAGE_SIZE

USER root
RUN apt-get update \
    && apt-get install -y --no-install-recommends autoconf automake \
        build-essential bzip2 ca-certificates clang curl git \
        g++-aarch64-linux-gnu g++-x86-64-linux-gnu gcc-aarch64-linux-gnu \
        gcc-x86-64-linux-gnu libbpf-dev liblz4-dev libtool pkg-config python3 \
        uuid-dev

# gVisor, with the gvisor-bin/ helpers that must stay next to runsc
RUN curl -fsSL -o /usr/local/bin/bazelisk \
        "https://github.com/bazelbuild/bazelisk/releases/download/v1.29.0/bazelisk-linux-$(dpkg --print-architecture)" \
    && chmod +x /usr/local/bin/bazelisk \
    && git clone --depth 1 --branch "release-${GVISOR_VERSION}" \
        https://github.com/google/gvisor.git /tmp/gvisor \
    && cd /tmp/gvisor \
    && bazelisk build -c opt --define=pagesize="$((PAGE_SIZE / 1024))k" \
        //debian:gvisor-release-tar-bz2 \
    && mkdir -p /out/bin \
    && tar -xjf bazel-bin/debian/gvisor.tar.bz2 -C /out/bin

# slirp4netns, for network="public"
RUN curl -fsSL -o /out/bin/slirp4netns \
        "https://github.com/rootless-containers/slirp4netns/releases/download/v${SLIRP4NETNS_VERSION}/slirp4netns-$(uname -m)" \
    && chmod a+rx /out/bin/slirp4netns

# erofs-utils
RUN curl -fsSL "https://github.com/erofs/erofs-utils/archive/refs/tags/v${EROFS_UTILS_VERSION}.tar.gz" \
        | tar -xz -C /tmp \
    && cd "/tmp/erofs-utils-${EROFS_UTILS_VERSION}" \
    && ./autogen.sh \
    && ./configure --disable-fuse --prefix=/out MAX_BLOCK_SIZE="$PAGE_SIZE" \
    && make -j"$(nproc)" \
    && make install

FROM rayproject/ray:latest
COPY --from=build /out/bin/ /usr/local/bin/

Usage patterns and examples#

Create a basic sandbox and run a command#

Use sandbox.create() to start an isolated environment from any container image. The function returns a Ray ActorHandle representing the sandbox actor.

import ray
from ray.experimental import sandbox

ray.init()

# Create a sandbox with 1 CPU core and 512 MiB RAM
sb = sandbox.create(
    image="python:3.10-slim",
    cpu=1.0,
    memory="512Mi",
    workdir="/workspace",
    timeout_seconds=30.0,
)

# Execute untrusted Python code inside the sandbox
result = ray.get(
    sb.exec.remote("python3 -c 'import sys; print(\"Hello from sandboxed Python:\", sys.version)'")
)

print(f"Exit Code: {result.exit_code}")
print(f"Stdout: {result.stdout.strip()}")
print(f"Execution Duration: {result.duration_ms:.2f} ms")

# Clean up sandbox resources
ray.get(sb.delete.remote())

Read, write, upload, and download files#

Write source files directly into the sandbox, or upload local files from the host before execution. By default, the root filesystem is read-only and the configured workdir, such as /workspace, is the writable scratch space.

import textwrap
import ray
from ray.experimental import sandbox

ray.init()

sb = sandbox.create(
    image="python:3.10-slim",
    workdir="/workspace",
    memory="1Gi",
)

# 1. Write untrusted model-generated script into the sandbox
code = textwrap.dedent("""\
    def fibonacci(n):
        a, b = 0, 1
        for _ in range(n):
            a, b = b, a + b
        return a

    with open('/workspace/output.txt', 'w') as f:
        f.write(f"fib(30) = {fibonacci(30)}")
""")
ray.get(sb.write_file.remote("/workspace/solution.py", code))

# 2. Execute the script inside the sandbox
exec_res = ray.get(sb.exec.remote("python3 /workspace/solution.py"))
print("Execution returncode:", exec_res.exit_code)

# 3. Read generated output file back to the host
output_bytes = ray.get(sb.read_file.remote("/workspace/output.txt"))
print("Result:", output_bytes.decode("utf-8"))

# 4. Alternatively, use upload_file and download_file for host files
# ray.get(sb.upload_file.remote("local_input.json", "/workspace/input.json"))
# ray.get(sb.download_file.remote("/workspace/output.txt", "local_output.txt"))

ray.get(sb.delete.remote())

Schedule a Sandbox actor with custom resources#

Because Sandbox is a standard Ray actor, you can instantiate it directly with Ray actor scheduling options such as num_cpus, memory, and custom accelerator or placement constraints.

import ray
from ray.experimental.sandbox import Sandbox

ray.init()

# Instantiate Sandbox actor with Ray Core resource placement options
sandbox_actor = Sandbox.options(
    num_cpus=2.0,
    memory=2 * 1024 * 1024 * 1024,  # 2 GiB
).remote(
    image="python:3.10-slim",
    workdir="/workspace",
    ttl_seconds=600,  # Automatically terminate after 10 minutes
)

# Run command with a per-command execution timeout
result = ray.get(
    sandbox_actor.exec.remote(
        "python3 -c 'import os; print(\"Worker PID:\", os.getpid())'",
        timeout=5.0,  # 5 second execution timeout
    )
)

print(result.stdout)
ray.get(sandbox_actor.delete.remote())

Manage sandboxes inside custom actors with SandboxRuntime#

If you’re building custom RL environment actors or specialized rollout workers, embed SandboxRuntime directly inside your custom actors for fine-grained sandbox lifecycle control:

import ray
from ray.experimental.sandbox.runtime import SandboxRuntime

@ray.remote
class SandboxPool:
    def __init__(self, size: int = 3, image: str = "python:3.10-slim"):
        self.runtime = SandboxRuntime()
        self.sandboxes = [
            self.runtime.create(image=image, memory="512Mi")
            for _ in range(size)
        ]

    def run_command(self, index: int, command: str):
        return self.runtime.exec(self.sandboxes[index], command)

    def close(self):
        for sb_id in self.sandboxes:
            self.runtime.delete(sb_id)

# Deploy an actor managing a pool of local sandboxes
pool = SandboxPool.remote(size=3)
result = ray.get(pool.run_command.remote(0, "python3 -c 'print(\"Hello from pool!\")'"))
print(result.stdout)
ray.get(pool.close.remote())

Pass custom OCI configurations to gVisor#

For advanced workloads, you might need to configure low-level runtime options such as custom host mounts, Linux capabilities, or custom network and DNS settings. Use the _oci_spec_transform_fn parameter to inspect and modify the generated Open Container Initiative (OCI) runtime specification dictionary before Ray passes it to gVisor (runsc).

Note

_oci_spec_transform_fn is an experimental hook for advanced use cases. The Ray project is designing first-class configuration APIs for Ray Sandboxes, such as higher-level volume mount and capability abstractions, and this hook is likely to change once those land. To help shape them, open an issue describing your use case.

The _oci_spec_transform_fn callable receives the fully generated OCI specification dictionary. It can mutate the dictionary in place or return a modified one. Common use cases include the following:

  • Host mounts: Mount host directories, read-only datasets, or model weights into the sandbox container.

  • Namespace and mount details: Configure namespace or mount behavior that the first-class options don’t cover.

Internet access, DNS, and Linux capabilities each have a first-class option: network, dns, and capabilities. Pass capabilities=[] to run with no capabilities at all. Reserve the hook for network or capability configurations those options don’t reach. See Networking and DNS.

import ray
from ray.experimental import sandbox

ray.init()


def configure_oci_spec(spec: dict) -> dict:
    # Add a host bind mount (e.g., read-only dataset or cache directory)
    spec.setdefault("mounts", []).append(
        {
            "destination": "/mnt/dataset",
            "source": "/path/to/host/dataset",
            "type": "bind",
            "options": ["rbind", "ro"],
        }
    )

    return spec


# Pass the transformation hook when creating the sandbox
sb = sandbox.create(
    image="python:3.10-slim",
    workdir="/workspace",
    _oci_spec_transform_fn=configure_oci_spec,
)

# Execute commands within the customized sandbox
result = ray.get(
    sb.exec.remote(
        "python3 -c 'print(\"Sandbox initialized with custom OCI configuration!\")'"
    )
)
print(result.stdout)

# Clean up resources
ray.get(sb.delete.remote())

Container images#

Sandboxes boot from OCI container images. The image manager pulls an image straight from the registry’s HTTP API (anonymously, with no Docker daemon and no credentials), flattens its layers, and caches the result under /tmp/ray/sandbox/images on the node for reuse by subsequent sandboxes on that node using the same image. The cached root filesystem is a single EROFS image, built with mkfs.erofs, that gVisor mounts inside the Sentry, which keeps the image’s file ownership intact. Sandboxes with write access to the filesystem get their own private writable overlay on top of the cached root filesystem. One consequence: a readonly=True sandbox with an explicit workdir runs on a private writable overlay, because runsc drops the rootfs overlay for read-only roots and can’t create the workdir mount point in an immutable image; its writes are discarded with the sandbox. A cache left by an earlier Ray version, which extracted images into directories, is rebuilt on the next pull.

Bound the image cache#

The cache is bounded so that a node that runs many distinct images doesn’t fill its disk. Before each pull, Ray evicts the least recently extracted images until the cache fits under the cap. Images that a running sandbox uses are never evicted. The cap defaults to half of the filesystem that holds the cache. Set RAY_SANDBOX_IMAGE_CACHE_MAX_BYTES on worker nodes to choose a cap in bytes, or set it to 0 to disable eviction.

Route Docker Hub pulls through a mirror#

Because image pulls are anonymous, every node pulling from Docker Hub consumes the anonymous pull-rate limit and downloads the image over the WAN. In a large cluster, concurrent pulls of multi-GB images can quickly hit the rate limit or saturate network bandwidth, causing image pulls to fail or become slow.

Set RAY_SANDBOX_REGISTRY_MIRROR to route Docker Hub pulls through a registry mirror. Ray rewrites only Docker Hub image references. Pulls from other registries, such as GHCR or a private registry, are left unchanged.

The value is host[:port][/repo-prefix]. Ray prepends the repository prefix to the repository path, which is the form pull-through caches expect:

Mirror

Example value

python:3.10-slim resolves to

ECR pull-through cache

<acct>.dkr.ecr.<region>.amazonaws.com/dockerhub

<acct>.dkr.ecr.<region>.amazonaws.com/dockerhub/library/python

Artifact Registry remote repository

<region>-docker.pkg.dev/<project>/<repo>

<region>-docker.pkg.dev/<project>/<repo>/library/python

In-cluster registry:2 proxy

http://registry.default.svc.cluster.local:5000

http://registry.default.svc.cluster.local:5000/library/python

Keep the following in mind:

  • A bare host means HTTPS. Write an explicit http:// prefix for a plain-HTTP mirror, which an in-cluster registry:2 proxy typically is.

  • The mirror is authoritative. Unlike Docker’s registry-mirrors behavior, Ray does not fall back to Docker Hub. If the mirror is unreachable or does not contain the image, the pull fails.

  • The mirror must allow anonymous pulls. Ray talks to a mirror exactly as it talks to any registry, over the same anonymous bearer-token flow. If your mirror normally requires authentication, expose it to Ray through network-level access instead, such as a VPC endpoint or cluster-internal service.

Networking and DNS#

Sandboxes support four network modes. The default is none, which follows the safe-defaults principle. Use public when a sandbox needs internet access.

Mode

Network access

/etc/resolv.conf

Security property

none (default)

None

untouched

No egress. This is the recommended setting for untrusted code.

public

Internet egress from a network namespace private to the sandbox, bridged by slirp4netns

Generated from dns (default 8.8.8.8, 1.1.1.1), mounted read-only

Ports and loopback are per-sandbox: a bind on 0.0.0.0 can’t collide with, be reached by, or reach other sandboxes or node-local services, and there’s no inbound path from the node or cluster. The sandbox inherits nothing from the host’s resolver configuration. The sandbox can still reach any network address the node can reach, including other Ray nodes and internal services. The sandbox’s own address is 198.18.0.100 (RFC 2544 benchmarking space, chosen not to overlap pod or service ranges). Requires slirp4netns on the node.

host

Full host network identity

Host’s own file, mounted read-only (dns= overrides it)

Strictly more permissive than public. The sandbox can reach anything the node can reach, including internal networks and node-local services.

sandbox

gVisor netstack

untouched

Requires rootless=False. runsc doesn’t support the sandbox netstack in rootless mode.

Warning

public isolates sandboxes from each other and from the node’s own services, not from the network the node sits on. slirp4netns relays every outbound connection through the node, so a public sandbox can reach other Ray nodes, including the head node’s GCS and dashboard ports, other Kubernetes Pods, and any internal service the node can reach. Use network="none" for untrusted code.

To give a sandbox internet access, use network="public". Pair it with DOCKER_DEFAULT_CAPABILITIES so standard images behave the way they do under Docker, because apt-get, tar ownership restore, and similar operations all need those capabilities:

from ray.experimental import sandbox
from ray.experimental.sandbox import DOCKER_DEFAULT_CAPABILITIES

sb = sandbox.create(
    image="python:3.10-slim",
    network="public",
    capabilities=DOCKER_DEFAULT_CAPABILITIES,
    readonly=False,
)

DNS in locked-down networks#

Some virtual private clouds (VPCs) block outbound port 53 to public resolvers, so the default public DNS settings can’t resolve queries. Pass your internal resolver instead with network="public", dns=["10.0.0.2"]. If that isn’t an option, fall back to network="host", which uses the host’s /etc/resolv.conf, at the cost of full host network identity. Configure anything beyond that through the OCI spec. See Pass custom OCI configurations to gVisor.

Architecture#

The Ray Sandboxes subsystem has the following layers:

+-------------------------------------------------------------------+
|               Ray Application / RL Framework                      |
|           (e.g., veRL, SkyRL, RL Rollout Workers, Agents)         |
+-------------------------------------------------------------------+
                                  |
                                  v
+-------------------------------------------------------------------+
|                      ray.experimental.sandbox                     |
|           (High-level create() API & Sandbox Ray Actor)           |
+-------------------------------------------------------------------+
                                  |
                                  v
+-------------------------------------------------------------------+
|                  ray.experimental.sandbox.runtime                 |
|                      SandboxRuntime Interface                     |
+-------------------------------------------------------------------+
                                  |
                                  v
+-------------------------------------------------------------------+
|                 ray.experimental.sandbox.backend                  |
|               GVisorSandboxBackend (runsc OCI)                    |
+-------------------------------------------------------------------+
                                  |
                                  v
+-------------------------------------------------------------------+
|                        Ray Worker Node                            |
|   +-----------------------+       +-----------------------+       |
|   |  gVisor Sandbox 1     |       |  gVisor Sandbox 2     |       |
|   | (python:3.10-slim)    |       | (busybox:latest)      |       |
|   |   CPU: 0.5, Mem: 256M |       |   CPU: 1.0, Mem: 512M |       |
|   +-----------------------+       +-----------------------+       |
+-------------------------------------------------------------------+

Core components#

  • High-level helper (create()): Spawns a Ray actor that encapsulates the sandbox lifecycle and returns an ActorHandle.

  • Sandbox actor (Sandbox): A Ray actor that serves as a proxy to forward command execution and file I/O to the isolated sandbox instance while managing the scheduling and lifecycle of the sandbox.

  • Sandbox runtime (SandboxRuntime): A low-level abstraction that manages the lifecycle of local sandboxes, image pulling and caching, and interactions with the execution backend.

  • gVisor backend (ray.experimental.sandbox.backend.GVisorSandboxBackend): Executes commands and isolates processes through gVisor’s OCI runtime (runsc).

  • Image manager (ray.experimental.sandbox.image_manager.ImageManager): Automatically pulls container images from sources such as Docker Hub, GHCR, or local tar archives, extracts root filesystems into /tmp/ray/sandbox/images, and builds OCI config.json runtime specifications.

Security and isolation model#

Ray Sandboxes implement multi-layered defense-in-depth isolation:

  • System call interception: gVisor’s Sentry application kernel intercepts system calls in user space, isolating untrusted code from the host Linux kernel.

  • Read-only root filesystem: Ray mounts base container filesystems read-only (readonly=True) with an isolated copy-on-write overlay directory per sandbox.

  • Restricted working directory: Only the explicit workdir, such as /workspace, is mounted read-write for application artifacts.

  • Network containment: By default, network="none" disables all outbound network interfaces, which prevents untrusted code from making external API calls or scanning the internal cluster network. When internet access is needed, network="public" grants egress without handing over the host’s resolver configuration or network identity; see Networking and DNS.

  • Resource quotas: cgroups enforce CPU quotas and memory limits, which prevents CPU starvation and out-of-memory (OOM) conditions from affecting other Ray actors.

HTTP API service#

Ray Sandbox ships an experimental REST API service so you can manage sandboxes from outside the Ray cluster with nothing but an HTTP client and a bearer token. The service is a FastAPI app on Ray Serve (ray.experimental.sandbox.http). Each sandbox is held by a named, detached actor, so the service itself is stateless and its replicas can scale or restart without losing sandboxes.

Image pulls and commands can far outlive an HTTP request and the load balancer in front of a deployed service, so creation and execution are asynchronous. POST returns immediately and clients poll, optionally long-polling with wait_seconds for up to 30 seconds per request.

Endpoints#

All endpoints sit under /api/v1. Except for GET /health, they require Authorization: Bearer <token> when a token is configured.

Method and path

Description

GET /health

Liveness probe. Never requires auth.

POST /sandboxes

Create a sandbox. Returns 202 with status: pending. Poll until running or error. Send a client_token to make creation idempotent, so a retry returns 200 with the existing sandbox.

GET /sandboxes?label=k=v

List sandboxes, optionally filtered by labels.

GET /sandboxes/{id}?wait_seconds=N

Sandbox status. Long-polls while it boots.

DELETE /sandboxes/{id}

Terminate the sandbox and its actor. Idempotent from any state. Answers terminating instead of terminated when the actor is still being scheduled or busy tearing down. It finishes and exits on its own.

POST /sandboxes/{id}/execs

Start a command. Returns 202 with an exec_id, or 409 while the sandbox isn’t running. A string command runs under the sandbox’s shell, /bin/bash by default and configurable per sandbox and per exec via shell. A list runs argv-style.

GET /sandboxes/{id}/execs/{exec_id}?wait_seconds=N

Exec status and result: running, completed with exit_code, stdout, and stderr, timeout, or error. Output is capped per stream by max_output_bytes with a loud truncation marker.

PUT /sandboxes/{id}/files?path=/abs/path

Write the raw request body to a file in the sandbox. Returns 413 above max_file_bytes, and 409 write_failed when the sandbox can’t write the path, such as a directory or a read-only root filesystem. Pass append=true to extend the file, which lets clients chunk large uploads under proxy body-size limits.

GET /sandboxes/{id}/files?path=/abs/path

Read a file from the sandbox as application/octet-stream.

Errors use a JSON envelope of the form {"error": {"code": "...", "message": "..."}}. The codes are 401 unauthorized, 404 sandbox_not_found, 404 exec_not_found, 404 file_not_found, 409 conflict, 409 unschedulable, 409 write_failed, 400 invalid_request, 413 payload_too_large, 503 sandbox_unavailable for an actor that’s briefly unreachable, such as during a restart, and FastAPI’s native 422 for schema violations. The full OpenAPI schema is served at /openapi.json.

Keep this server behavior in mind:

  • TTL: Every sandbox gets a TTL that reclaims both the sandbox and its hosting actor. Request it with ttl_seconds, capped and defaulted by the server’s max_ttl_seconds.

  • Resources: resources separates cluster reservations from in-sandbox cgroup caps. cpu_request, memory_request_mb, and custom Ray resources reserve cluster capacity, and custom resources such as {"gvisor": 1} pin sandboxes to runsc-equipped nodes. cpu_limit and memory_limit_mb become cgroup caps. Requests default to the limits.

  • Capabilities: By default sandboxes get Docker’s default Linux capability set so images behave the way they do under Docker. Ray’s own default is far narrower and breaks apt-get and tar. The sets are written exactly, so capabilities: [] runs the sandbox with no capabilities at all.

  • Network modes: These are the Python API’s modes, which Ray validates: none (the default), public for egress with generated DNS that dns overrides, host, and sandbox. See Networking and DNS.

Self-hosted quickstart#

On a Linux machine or cluster with runsc on PATH:

pip install "ray[serve]"
export RAY_SANDBOX_API_TOKEN=dev-token   # Optional. Unset disables app-level auth.
serve run ray.experimental.sandbox.http.app:build_app
curl -s -H "Authorization: Bearer dev-token" \
  -H "Content-Type: application/json" \
  -d '{"image": "busybox:latest", "readonly": false, "shell": "/bin/sh"}' \
  http://localhost:8000/api/v1/sandboxes

Builder arguments configure the server. See ray.experimental.sandbox.http.schemas.SandboxAPISettings for the full list. For example:

serve run ray.experimental.sandbox.http.app:build_app max_ttl_seconds=86400 num_replicas=2

Deploying as an Anyscale service#

Build a cluster image whose worker nodes have runsc:

FROM anyscale/ray:2.58.0-py312
RUN ARCH=$(uname -m | sed 's/arm64/aarch64/') && \
    curl -fsSL -o /usr/local/bin/runsc \
      "https://storage.googleapis.com/gvisor/releases/release/latest/${ARCH}/runsc" && \
    chmod +x /usr/local/bin/runsc

Then deploy the builder as the service’s application:

# service.yaml
name: ray-sandbox-api
image_uri: <your-registry>/ray-sandbox-api:latest
applications:
  - name: sandbox-api
    import_path: ray.experimental.sandbox.http.app:build_app
    args:
      max_ttl_seconds: 86400
anyscale service deploy -f service.yaml

Anyscale services require their own bearer token at the platform edge, so leave RAY_SANDBOX_API_TOKEN unset and hand clients the service’s base URL and token. Consumers such as the Harbor ray-sandbox environment take exactly that pair as RAY_SANDBOX_API_URL and RAY_SANDBOX_API_KEY.

Local development loop on macOS#

runsc is Linux-only. Develop against the service in a privileged container:

docker run --privileged -p 8000:8000 \
  -v ~/path/to/ray/python/ray/experimental/sandbox:/overlay:ro \
  rayproject/ray:nightly-py312 bash -lc '
    pip install "ray[serve]" &&
    SITE=$(python -c "import ray, os; print(os.path.dirname(ray.__file__))") &&
    cp -r /overlay/* "$SITE/experimental/sandbox/" &&
    ARCH=$(uname -m | sed "s/arm64/aarch64/") &&
    curl -fsSL -o /usr/local/bin/runsc "https://storage.googleapis.com/gvisor/releases/release/latest/${ARCH}/runsc" &&
    chmod +x /usr/local/bin/runsc &&
    RAY_SANDBOX_API_TOKEN=dev-token serve run --host 0.0.0.0 ray.experimental.sandbox.http.app:build_app'

gRPC facade for third-party sandbox clients#

ray.experimental.sandbox.http.grpc_facade serves the same detached sandbox actors over gRPC. It implements the subset of a third-party sandbox SDK’s control-plane and command-router services that the SDK’s Sandbox API uses, so you can point an unmodified client at a Ray cluster to create sandboxes, run commands, and use the client’s filesystem API.

The facade requires grpclib and ray[default], not the Serve extra. Run it on a node that can reach the cluster and hand clients the URL it advertises:

pip install grpclib
python -m ray.experimental.sandbox.http.grpc_facade \
  --host 0.0.0.0 --port 50051 --advertise-url http://<facade-host>:50051

Keep these limits in mind:

  • Images: The facade runs prebuilt registry images only. It rejects image definitions that need a server-side build step.

  • Names: Sandbox names are scoped to the client app. Creating a sandbox under a live name returns the existing sandbox.

  • State: The facade keeps exec state in memory, so run one facade process per cluster.

  • Network: The facade doesn’t enforce network allowlists. It grants open egress instead.

API reference#

For detailed signatures, parameters, and return types, see Sandbox API.

Troubleshooting#

  • runsc not found in $PATH: Verify that gVisor’s runsc binary is installed on all Ray worker nodes and sits in a directory on the system $PATH, such as /usr/local/bin/runsc.

  • gVisor container failed to start: WARNING: host page size mismatch - running on non-4K host: The node’s kernel uses 64 KiB pages, and gVisor’s prebuilt runsc only supports 4 KiB pages. Build runsc from source with --define=pagesize=64k (see Requirements).

  • mkfs.erofs not found or too old: Sandbox creation fails with an error naming erofs-utils 1.7. Install erofs-utils 1.7 or later on every worker node; Ubuntu 22.04’s packaged 1.4 predates the --tar option Ray relies on.

  • mkfs.erofs failed: ... invalid block size 65536: The node’s kernel uses 64 KiB pages, and its erofs-utils package can only build 4 KiB blocks. Build erofs-utils from source with ./configure MAX_BLOCK_SIZE=65536 (see Requirements).

  • cgroup or permission errors: In containerized environments such as Kubernetes without root permissions, keep the default rootless=True. Where cgroups are restricted, set RAY_SANDBOX_IGNORE_CGROUPS=1.

  • Node disk filling up with images: The image cache is capped at half of its filesystem by default. Lower the cap with RAY_SANDBOX_IMAGE_CACHE_MAX_BYTES (bytes) on worker nodes, or move the cache to a larger volume. Images that running sandboxes use are never evicted, so many concurrent sandboxes on distinct large images still need that much disk.

  • Image pull failures: Verify that the node can reach the container registry, such as Docker Hub or GHCR, or pre-populate the image cache directory at /tmp/ray/sandbox/images. When many nodes pull large images at once, Docker Hub’s anonymous rate limits are a likely cause; see Route Docker Hub pulls through a mirror.

  • slirp4netns not found for network="public": Install the slirp4netns package (or a static build) on worker nodes.

  • public sandboxes fail to start with a tap or namespace error: slirp4netns needs /dev/net/tun in the worker’s environment and a seccomp policy that allows unprivileged user+network namespace creation (unshare -Un true must succeed as the Ray user). The slirp4netns error appears in the sandbox’s runsc.stderr.log and in the creation error message.

Next steps#