Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions doc/source/ray-core/sandboxes.md
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,108 @@ Ray Sandboxes implement multi-layered defense-in-depth isolation:
* **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](#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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe named detached actors are currently not as scalable as normal actors (e.g. each actor creation has to wait for a roundtrip to the GCS to make sure the names are unique), it would be great to check with @edoakes or @MengjinYan about that and what we should do (it it is indeed a problem)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why make sandboxes detached actors instead of replicas? We can treat sandboxes as multiplex ids in serve to do multiplex aware routing, but we can certainly have each serve replica own multiple sandbox actors that can be suspended resumed etc. i think this will be a more scalable system.


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. |
Comment thread
xyuzh marked this conversation as resolved.
| `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. |
Comment thread
xyuzh marked this conversation as resolved.
| `DELETE /sandboxes/{id}` | Terminate the sandbox and its actor. Idempotent from any state. |
| `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`. 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`, `400 invalid_request`, `413 payload_too_large`, and FastAPI's native `422` for schema violations. The full OpenAPI schema is served at `/openapi.json`.

Server behavior worth knowing:

* **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**: The modes are the Python API's, validated by Ray: `none` (the default), `public` for egress with generated DNS that `dns` overrides, `host`, and `sandbox`. See [Networking and DNS](#networking-and-dns).

### Self-hosted quickstart

On a Linux machine or cluster with `runsc` on `PATH`:

```bash
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
```

```bash
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:

```bash
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`:

```dockerfile
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:

```yaml
# 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
```

```bash
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](https://harborframework.com) `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:

```bash
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'
```

## API reference

For detailed signatures, parameters, and return types, see {ref}`ray-sandbox-ref`.
Expand Down
7 changes: 6 additions & 1 deletion python/ray/experimental/sandbox/backend/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,14 +107,19 @@ def exec_command(

@abstractmethod
def write_file(
self, sandbox_id: str, path: str, content: Union[str, bytes]
self,
sandbox_id: str,
path: str,
content: Union[str, bytes],
append: bool = False,
) -> None:
"""Write content to a file inside the sandbox.

Args:
sandbox_id: Unique string identifier of the sandbox.
path: Target file path inside the sandbox environment.
content: Text string or raw bytes to write.
append: Append to the file instead of truncating it.
"""
pass

Expand Down
52 changes: 49 additions & 3 deletions python/ray/experimental/sandbox/backend/gvisor.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import subprocess
import time
import uuid
from pathlib import Path
from typing import Callable, Dict, List, Optional, Union

from ray.experimental.sandbox.backend.base import (
Expand Down Expand Up @@ -215,6 +216,42 @@ def delete_sandbox(self, sandbox_id: str) -> None:

shutil.rmtree(root_dir, ignore_errors=True)

def _resolve_exec_user(self, user: str, image: str) -> str:
"""Turn a user name or uid[:gid] into runsc exec's numeric form.

runsc only accepts numeric ids; names are resolved against the
image's own /etc/passwd (and the login group via /etc/group).

Args:
user: Numeric uid, "uid:gid", or a user name from the image.
image: The sandbox's image, locating the extracted rootfs.

Returns:
A "uid" or "uid:gid" string runsc accepts.

Raises:
SandboxExecError: When a named user is not in the image's
/etc/passwd.
"""
head = user.split(":", 1)[0]
if head.isdigit():
return user
rootfs = os.path.join(self._image_manager.get_image_dir(image), "rootfs")
try:
passwd = Path(os.path.join(rootfs, "etc", "passwd")).read_text(
encoding="utf-8", errors="replace"
)
except OSError:
passwd = ""
for line in passwd.splitlines():
parts = line.split(":")
if len(parts) >= 4 and parts[0] == user:
return f"{parts[2]}:{parts[3]}"
raise SandboxExecError(
f"user {user!r} not found in the image's /etc/passwd; "
"pass a numeric uid or uid:gid instead"
)

def exec_command(
self,
sandbox_id: str,
Expand All @@ -223,6 +260,7 @@ def exec_command(
cwd: Optional[str] = None,
env: Optional[Dict[str, str]] = None,
shell: Optional[str] = None,
user: Optional[str] = None,
) -> ExecResult:
"""Execute a process inside the running gVisor sandbox instance via runsc exec."""
meta = self._get_metadata_or_raise(sandbox_id)
Expand All @@ -237,6 +275,8 @@ def exec_command(
# Production execution against running container via `runsc exec`
runsc_args = self._runsc_base_args(config)
runsc_args.extend(["exec", "-cwd", exec_cwd])
if user is not None:
runsc_args.extend(["-user", self._resolve_exec_user(user, config.image)])
if env:
for k, v in env.items():
runsc_args.extend(["-env", f"{k}={v}"])
Expand Down Expand Up @@ -278,9 +318,13 @@ def exec_command(
raise SandboxExecError(f"gVisor exec failed: {err}") from err

def write_file(
self, sandbox_id: str, path: str, content: Union[str, bytes]
self,
sandbox_id: str,
path: str,
content: Union[str, bytes],
append: bool = False,
) -> None:
"""Write content to a file inside the local gVisor sandbox directory."""
"""Write (or append) content to a file inside the sandbox."""
meta = self._get_metadata_or_raise(sandbox_id)
config: SandboxConfig = meta["config"]

Expand All @@ -294,7 +338,9 @@ def write_file(
sandbox_id,
"/bin/sh",
"-c",
'mkdir -p "$(dirname "$1")" && cat > "$1"',
'mkdir -p "$(dirname "$1")" && cat >> "$1"'
if append
else 'mkdir -p "$(dirname "$1")" && cat > "$1"',
"--",
path,
]
Expand Down
44 changes: 44 additions & 0 deletions python/ray/experimental/sandbox/http/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
load("@rules_python//python:defs.bzl", "py_library")
load("//bazel:python.bzl", "py_test_module_list")

py_library(
name = "sandbox_http_lib",
srcs = glob(
["**/*.py"],
exclude = ["tests/**/*.py"],
),
visibility = [
"//python/ray/experimental/sandbox/http:__pkg__",
"//python/ray/experimental/sandbox/http:__subpackages__",
],
deps = [
"//python/ray/experimental/sandbox:sandbox_lib",
],
)

py_library(
name = "conftest",
srcs = ["tests/conftest.py"],
deps = [
"//python/ray/tests:conftest",
],
)

py_test_module_list(
size = "medium",
files = glob(
["tests/**/*.py"],
exclude = [
"tests/conftest.py",
"tests/__init__.py",
],
),
tags = [
"sandbox_tests",
"team:core",
],
deps = [
":conftest",
":sandbox_http_lib",
],
)
35 changes: 35 additions & 0 deletions python/ray/experimental/sandbox/http/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""HTTP API service for Ray Sandbox.

This subpackage exposes ``ray.experimental.sandbox`` over a versioned REST
API (``/api/v1``) served by Ray Serve, so sandboxes can be managed from
outside the Ray cluster with nothing but an HTTP client and a bearer token.

Importing this package requires the Serve extra
(``pip install "ray[serve]"``); the base ``ray.experimental.sandbox``
package deliberately never imports it.
"""

from ray.experimental.sandbox.http.app import build_app, create_app
from ray.experimental.sandbox.http.host import SandboxHost
from ray.experimental.sandbox.http.schemas import (
DOCKER_DEFAULT_CAPABILITIES,
CreateSandboxRequest,
ExecInfo,
ResourceSpec,
SandboxAPISettings,
SandboxInfo,
StartExecRequest,
)

__all__ = [
"build_app",
"create_app",
"SandboxHost",
"DOCKER_DEFAULT_CAPABILITIES",
"CreateSandboxRequest",
"ExecInfo",
"ResourceSpec",
"SandboxAPISettings",
"SandboxInfo",
"StartExecRequest",
]
Loading
Loading