Skip to content

[core][sandbox] Support multi-uid user namespaces for network="public" sandboxes - #65823

Open
xyuzh wants to merge 1 commit into
ray-project:masterfrom
xyuzh:sandbox-multi-uid
Open

[core][sandbox] Support multi-uid user namespaces for network="public" sandboxes#65823
xyuzh wants to merge 1 commit into
ray-project:masterfrom
xyuzh:sandbox-multi-uid

Conversation

@xyuzh

@xyuzh xyuzh commented Sep 1, 2026

Copy link
Copy Markdown
Member

Description

Rootless-style sandboxes map the whole container to one host uid: every file reads as root, chown to any other user fails with EINVAL, and ownership baked into image layers (postfix's 0700 uid-101 spool, mailman's uid-38 data) flattens away, so workloads that spread ownership across users fail.

#65820 has merged; this is a single commit on top of master.

network="public" sandboxes now map subordinate id ranges into their user namespace, the rootless-Podman model:

  • Mapping. The namespace holder starts unmapped and the setuid newuidmap/newgidmap helpers write 0 <worker-id> 1 plus 1 <subbase> <count> before pasta and runsc join as mapped root. detect_idmap() probes the node once per process and degrades to the single-uid mapping (warn-once) when the uidmap helpers, /etc/subuid ranges, or no_new_privs make the mapping impossible; on nodes whose setuid bits are stripped it falls back to privileged map-file writes via sudo. RAY_SANDBOX_SINGLE_UID=1 forces single-uid.
  • Image-baked ownership. Extraction records each layer's non-root owners (whiteout-aware) into an .ownership.json sidecar next to the worker-owned rootfs/, which single-uid sandboxes keep using unchanged. Multi-uid sandboxes mount a per-node <image>.idmap variant: a cp -a of that rootfs made inside an ephemeral mapped user namespace with the sidecar's owners applied (chown, then the mode restored so setuid/setgid bits survive). No archive is written; the variant is rebuilt when the image is re-pulled or the mapping changes. The .extracted marker is versioned (ownership-v2), so legacy caches re-pull once, and that re-pull keeps the pins of sandboxes already running on the old extraction.
  • Cleanup. Subordinate-owned trees can only be deleted as mapped root, so cache eviction removes the .idmap variant through a mapped namespace (and counts it toward RAY_SANDBOX_IMAGE_CACHE_MAX_BYTES), and sandbox teardown does the same for files a sandbox chowned under its workdir bind.

Traversing another user's 0700 directory as root additionally needs CAP_DAC_OVERRIDE, which Docker's default capability set carries and the bare runsc spec default does not; documented in troubleshooting. The HTTP service's best-effort /etc/subuid seeding at boot moves to #65633 with the rest of that layer.

Related issues

Follow-up to #65820. Related to #65633.

Additional information

Tested with TEST_SANDBOX=1 in a privileged rayproject/ray:nightly-py312 container on arm64 as the non-root ray user, with uidmap installed, ray:100000:65536 in /etc/subuid and /etc/subgid, and pasta built from source: runtime chown to arbitrary uids inside the sandbox and host-visibly on a workdir bind at subuid_base + uid; image-baked ownership (0700 uid-101 dir, 02710 setgid dir) surviving into the sandbox from a local tar image; the mailman-shaped adduser + chown -R + setgid inheritance flow; and a network="none" sandbox unaffected by an existing .idmap variant. The netns suite (same-port binds, egress and DNS, teardown, failed create) still passes alongside. Argv-level unit tests pin the multi-uid holder script, and the cache tests cover ownership recording across layers, sidecar and marker writing, stale-marker re-pull, owner application with mode restore, and eviction.

@xyuzh
xyuzh requested review from a team and andrewsykim as code owners September 1, 2026 00:54

@gemini-code-assist gemini-code-assist Bot left a comment

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.

Code Review

This pull request introduces an experimental REST API service for Ray Sandbox, allowing users to manage sandboxes externally via a FastAPI application on Ray Serve. It also adds support for private network namespaces bridged by pasta (passt) and multi-uid user-namespace mapping to preserve file ownership within sandboxes. The review feedback highlights two critical issues: a missing json import in image_utils.py that will cause a runtime NameError, and a potential unhandled OSError in idmap.py if subprocess.Popen fails outside of the try block.

Comment thread python/ray/experimental/sandbox/_internal/image_utils.py
Comment thread python/ray/experimental/sandbox/_internal/idmap.py Outdated
Comment thread python/ray/experimental/sandbox/backend/gvisor.py
Comment thread python/ray/experimental/sandbox/_internal/image_utils.py
@ray-gardener ray-gardener Bot added the core Issues that should be addressed in Ray Core label Sep 1, 2026
@xyuzh
xyuzh force-pushed the sandbox-multi-uid branch from d633a27 to f2f0c1b Compare September 1, 2026 07:00
Comment thread python/ray/experimental/sandbox/_internal/idmap.py Outdated

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale Bugbot comment from a previous run.

Comment thread python/ray/experimental/sandbox/tests/conftest.py
@xyuzh
xyuzh requested a review from a team as a code owner September 1, 2026 22:43
@xyuzh xyuzh added the go add ONLY when ready to merge, run all tests label Sep 2, 2026
@xyuzh
xyuzh requested a review from a team as a code owner September 3, 2026 23:09
pcmoritz pushed a commit that referenced this pull request Sep 6, 2026
…ns via pasta (#65820)

## Description

`network="public"` sandboxes currently run with runsc `--network=host`
in the Ray worker's own network namespace: every sandbox on a node
shares one port space, so concurrent workloads that bind a fixed port
collide and can reach each other's listeners. The concrete failure is
terminal-bench's QEMU tasks (`qemu-startup`, `qemu-alpine-ssh`), which
start QEMU with `hostfwd=tcp::2222-:22` and then SSH to `localhost:2222`
from inside the same sandbox. Under co-tenancy the second bind gets
`EADDRINUSE`, and a verifier can connect to a *different* sandbox's
guest.

This PR gives each `public` sandbox a private user+network namespace
pair bridged by pasta (passt) user-mode networking, the rootless-Podman
topology:

- a tiny holder process (`unshare --user --map-root-user --net`) pins
the namespaces for the sandbox's lifetime;
- `pasta` attaches from the pod side (`--netns/--userns
/proc/$PID/ns/*`) and runs in the **foreground** inside the sandbox's
process group, so teardown's `killpg` takes it with the rest of the
tree. `-t/-u/-T/-U none --no-map-gw` make it egress-only: in-sandbox
binds are never republished on the pod, pod-local services are
unreachable from the sandbox loopback, and there is no inbound path;
- `runsc run` executes inside via `nsenter` as mapped root. `--rootless`
is dropped because nesting a second userns breaks the gofer's `/proc`
magic-link derefs; since rootless mode is also what tolerated cgroup
permission failures, the wrapper forces `--ignore-cgroups` for rootless
configs. runsc still gets `--network=host`, but "host" is now private to
the sandbox. Mount and pid namespaces stay shared, so the bundle and
control sockets under `--root` keep working for pod-side
`state`/`exec`/`kill`/`delete`.

### What `public` does and does not isolate

`public` isolates sandboxes from each other and from the node's own
services. It does **not** isolate them from the network the node sits
on: pasta relays every outbound connection through the pod's own sockets
and has no destination filter, so a `public` sandbox can reach other Ray
nodes (including the head node's GCS and dashboard ports), other pods,
and any internal service the node can reach. The docs now say this
explicitly and keep `none` as the recommendation for untrusted code.
Closing that gap needs egress policy outside pasta: a node-level
netfilter rule set (which needs `CAP_NET_ADMIN` in the pod netns), or a
second, intermediate user+network namespace we own and can firewall with
nftables before handing traffic to the pod-side pasta. That is a
follow-up, not part of this PR.

### Why not `pasta [flags] runsc ...`

pasta can spawn a command in namespaces it creates itself, which would
collapse the holder, pidfile, and nsenter into one wrapper. Prototyped
in a privileged container (non-root, pasta from source, `pasta <flags>
--foreground -- runsc ... run ...`): the command runs as uid 0 with a
fixed `0 <uid> 1` map inside new user, net, **pid, mount, ipc, and uts**
namespaces. runsc boots fine, but the pod side loses control of it:
`runsc exec` fails with `waiting on pid 2: sandbox is not running`
because the state file records the inner pid, and `runsc state` silently
reports `running` whenever some unrelated pod process happens to have
that pid. Every control call would have to be wrapped in `nsenter -U -n
-p -m -t <child>` (that does work), and the single-uid map rules out the
multi-uid mapping #65823 needs. The holder + attach shape keeps pid and
mount namespaces shared for exactly that reason; with pasta in the
foreground it costs one extra `sleep` process.

Requires `pasta` and `nsenter` on nodes for `public` sandboxes. Docs
updated (requirements, mode table with a warning admonition, install
snippets, troubleshooting). Per-exec `user` and `write_file(append=)`
moved to #65942 per review.

## Related issues

Related to #65633. Per-exec user support split into #65942.

## Additional information

Tested with `TEST_SANDBOX=1` in a privileged
`rayproject/ray:nightly-py312` container on arm64 as the non-root `ray`
user, with pasta built from source: two concurrent `public` sandboxes
both bind `0.0.0.0:2222` and each reaches its own listener on
`127.0.0.1:2222`; the worker namespace shows nothing on 2222; no address
names one sandbox from another; egress and generated-resolv.conf DNS
work; `delete_sandbox` and the create-failure path leave no pasta
process behind (the tests diff the set of running pasta pids). The exact
pasta flag list, the `--foreground`/pidfile gate, and the forced
`--ignore-cgroups` are pinned by argv-level unit tests that run without
runsc or pasta.

```
TEST_SANDBOX=1 pytest ray/experimental/sandbox/tests/test_gvisor_backend.py -k "netns or build_run_command or requires_pasta"
10 passed
```

---------

Signed-off-by: xyuzh <xinyzng@gmail.com>
@xyuzh
xyuzh force-pushed the sandbox-multi-uid branch from 0a1a4e5 to a0cb869 Compare September 6, 2026 05:23
@xyuzh

xyuzh commented Sep 6, 2026

Copy link
Copy Markdown
Member Author

Rebuilt on the current #65820 head (which now includes master with #65748) as a single commit, a0cb869. Two things changed beyond conflict resolution:

  • [core][sandbox] Bound the image cache: LRU eviction and opt-in tarball #65748 removed the uncompressed image tarball, which this PR used as the ownership-true source for the <image>.idmap variant. The variant is now built without it: a cp -a of the worker-owned rootfs/ inside a mapped user namespace, followed by applying the owners recorded in the .ownership.json sidecar (chown, then the mode restored so setuid/setgid bits survive). No archive is written anymore.
  • Cache eviction counts the .idmap variant toward the cap and removes it through a mapped namespace; sandbox teardown removes subordinate-owned files under the workdir bind the same way, so the "delete leaks subordinate-owned files" finding is closed in code.

Verified in a privileged container with uidmap and ray:100000:65536 subid ranges: the four multi-uid runtime tests (runtime chown, image-baked ownership, mailman-mini, none-mode unaffected) pass alongside the netns suite, 27 tests in total.

…" sandboxes

Rootless-style sandboxes previously mapped the whole container to one
host uid: every file read as root, chown to any other user failed with
EINVAL, and ownership baked into image layers (postfix's 0700 uid-101
spool, mailman's uid-38 data) flattened away, failing workloads that
spread ownership across users.

network="public" sandboxes now map subordinate id ranges into their user
namespace, the rootless-Podman model: the holder starts unmapped and the
setuid newuidmap/newgidmap helpers write "0 <worker-id> 1" plus
"1 <subbase> <count>" before pasta and runsc join as mapped root.
detect_idmap() degrades to the single-uid mapping (warn-once) when the
uidmap helpers, /etc/subuid ranges, or no_new_privs make the mapping
impossible; RAY_SANDBOX_SINGLE_UID=1 forces that fallback.

Image-baked ownership survives through the cache: extraction records
each layer's non-root owners (whiteout-aware) into an .ownership.json
sidecar next to the worker-owned rootfs, and multi-uid sandboxes mount a
per-node "<image>.idmap" variant: a copy of that rootfs made inside an
ephemeral mapped user namespace with the sidecar's owners applied
(chown, then the mode restored so setuid/setgid bits survive). The
.extracted marker is versioned (ownership-v2), so legacy caches re-pull
once, keeping the pins of sandboxes already running on them. Cache
eviction counts the variant toward the cap and removes it through a
mapped namespace, and sandbox teardown removes subordinate-owned files
the same way.

Traversing another user's 0700 directory as root additionally needs
CAP_DAC_OVERRIDE, which Docker's default capability set carries and the
bare runsc-spec default does not; documented in troubleshooting.

Signed-off-by: xyuzh <xinyzng@gmail.com>
(cherry picked from commit a0cb869)
@xyuzh
xyuzh force-pushed the sandbox-multi-uid branch from a0cb869 to f8bb2b2 Compare September 6, 2026 05:24
@xyuzh

xyuzh commented Sep 6, 2026

Copy link
Copy Markdown
Member Author

#65820 landed on master while this was being rebuilt, so the branch is now a single commit directly on master (f8bb2b2). Same tree as a0cb869; only the base changed.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.

Fix All in Cursor

Reviewed by Cursor Bugbot for commit f8bb2b2. Configure here.

and (member.uid or member.gid)
and not member.islnk()
):
ownership[name] = (member.uid, member.gid)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Ownership map ignores later root owners

High Severity

A later layer that ships a path as root does not clear a prior non-root record, so apply_ownership still chowns that path. Replacing a directory with a file, or the reverse, also leaves child sidecar entries. Multi-uid sandboxes then get image-baked owners from an earlier layer instead of the final one.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f8bb2b2. Configure here.

except (RuntimeError, subprocess.TimeoutExpired) as err:
raise SandboxCreationError(
f"idmapped rootfs build failed for '{image}': {err}"
) from err

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Idmap build leaks unreclaimable temp trees

High Severity

ensure_idmapped_rootfs removes any existing .idmap variant, then on TimeoutExpired or RuntimeError leaves the mapped cp -a temp tree in place. Eviction skips names containing .tmp., and the worker cannot delete subordinate-owned files, so each failed build leaks a full rootfs copy. The copy is also bounded by the sandbox creation timeout, which defaults to 30s.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f8bb2b2. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

core Issues that should be addressed in Ray Core go add ONLY when ready to merge, run all tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant