Persistence¶
A sandbox lives at most 8 h and kill() erases everything inside. Persistence
saves the user's HOME (/home/user) to your S3 bucket and restores it in
the next sandbox: files survive the 8 h wall and kill(); kernel
variables, processes and PTYs do not.
It is done by rayd, the VM's agent, as root and with the execution
role read through IMDSv2: the sandbox code (uid 1000) still cannot reach
IMDS on the rayito-base-caps image, and nothing goes through your
machine.
Pause or persist?
For waits of less than 8 h from create(),
pause the sandbox: it also keeps memory,
processes and the kernel. Persistence is for what goes beyond: a
sandbox that has to survive kill() or the 8 h cap, like a
conversation with an agent that can sit
idle for more than 8 h (point 2 of
Which one do I use?): its
HOME, with the agent's sessions, comes back with the same persist=.
Quick example¶
You need the rayito-base-caps image, an execution role with access to the
bucket (infra/iam.yaml with PersistenceBucket, see IAM and bucket)
and its ARN in RAYITO_EXECUTION_ROLE_ARN:
import os
from rayito import S3Prefix, Sandbox
role = os.environ["RAYITO_EXECUTION_ROLE_ARN"]
home = S3Prefix("amzn-s3-demo-bucket", prefix="rayito-home", name="agent-7")
with Sandbox.create("rayito-base-caps", execution_role_arn=role, persist=home) as sbx:
print(sbx.last_restore) # None the first time; afterwards, what was restored
sbx.files.write("/home/user/notes.txt", "hello")
sbx.checkpoint_files() # (1)!
with Sandbox.create("rayito-base-caps", execution_role_arn=role, persist=home) as again:
print(again.files.read("/home/user/notes.txt")) # "hello"
nxt = again.reincarnate() # (2)!
nxt.kill()
s3://amzn-s3-demo-bucket/rayito-home/agent-7/home.tar.gz- A fresh 8 h with the same
HOME; the old sandbox is killed.
import asyncio
import os
from rayito import AsyncSandbox, S3Prefix
async def main() -> None:
role = os.environ["RAYITO_EXECUTION_ROLE_ARN"]
home = S3Prefix("amzn-s3-demo-bucket", prefix="rayito-home", name="agent-7")
async with await AsyncSandbox.create("rayito-base-caps", execution_role_arn=role, persist=home) as sbx:
await sbx.files.write("/home/user/notes.txt", "hello")
await sbx.checkpoint_files()
async with await AsyncSandbox.create("rayito-base-caps", execution_role_arn=role, persist=home) as again:
print(await again.files.read("/home/user/notes.txt")) # "hello"
asyncio.run(main())
import { S3Prefix, Sandbox } from "rayito";
const executionRoleArn = process.env.RAYITO_EXECUTION_ROLE_ARN ?? "";
const persist = new S3Prefix({ bucket: "amzn-s3-demo-bucket", prefix: "rayito-home", name: "agent-7" });
{
await using sbx = await Sandbox.create({ template: "rayito-base-caps", executionRoleArn, persist });
console.log(sbx.lastRestore); // undefined the first time
await sbx.files.write("/home/user/notes.txt", "hello");
await sbx.checkpointFiles();
}
await using again = await Sandbox.create({ template: "rayito-base-caps", executionRoleArn, persist });
console.log(await again.files.read("/home/user/notes.txt")); // "hello"
const next = await again.reincarnate(); // a fresh 8 h with the same HOME
await next.kill();
S3Prefix¶
S3Prefix(bucket, prefix="rayito", name=None, region=None) names where a
persisted HOME lives: s3://bucket/prefix/name/.
prefixis the operator's base, the one that scopes the execution role's IAM policy: it has to match the deployment'sPersistencePrefix(infra/iam.yaml,rayito-homeby default) or everycheckpoint_files()answersPersistenceException(code="permission_denied"). The SDK's defaultprefix="rayito"is not suitable for a real deployment:rayito/is the namespace of the image artifacts (rayito/images/*) and the*of an IAM policy crosses/, so a prefix whose first segment israyitoputs the persistedHOMEand the zips images are built from under the same permission. Always pass aprefix=whose first segment is notrayito(the template also adds an explicitDenyon<ArtifactBucket>/rayito/*, but it only covers that bucket).nameidentifies a specific home.Sandbox.create(persist=)sets it to thesandbox_idif empty; for two lives to share the same home, pass it yourself.connect(persist=)andrestore_files(source=)require it.sbx.persistreturns the boundS3Prefix(with itsname) andS3Prefix.uriits fulls3://.regiononly if the bucket is not in the sandbox's region (by default the VM'sAWS_REGION).- It is validated on the client with the same rules
raydapplies: bucket of 3 to 63 characters[a-z0-9.-], prefix of up to 900 bytes with no leading or trailing/, no//,.or.., only S3's safe set (A-Za-z0-9!_.*'()-/).
The prefix separates tenants when the sandbox binds it. With
create(persist=), the SDK sends in the runHookPayload the bucket and the
prefix (the base, not the name: the default name is the sandbox_id,
which does not exist yet at launch) and rayd rejects with
PersistenceException(code="permission_denied") any Checkpoint or
Restore outside <bucket>/<prefix>, before touching S3, even if the
execution role reaches the whole bucket. For tenants that do not trust each
other a prefix per tenant under the deployment's PersistencePrefix is
enough (prefix="rayito-home/acme"); a name per tenant does not separate
them. Within its base, a sandbox can read and write any name: a
restore_files(source=) from another name of the same base works, and
from another base it answers permission_denied.
Limits of the binding:
- It is set by
createand does not change for the whole life of the sandbox:connect(persist=)only changes the client's default destination. - A sandbox created without
persist=, or with an SDK or araydearlier than 0.7.0, binds nothing:raydaccepts any valid destination and the only limit is how far its execution role reaches. Do not give a sandbox withoutpersist=a role that reaches the persistence prefix, or use a role per tenant. - The manifest's
sandbox_idremains informational.
Under the prefix there are two objects: home.tar.gz (POSIX/GNU tar
compressed with gzip level 1) and manifest.json (the archive's sha256,
sizes, counts, sandbox_id, agent version, exclusion list). The manifest is
written after the archive: if it exists, the archive is complete.
checkpoint_files and restore_files¶
| Python | TypeScript | Default | What it does |
|---|---|---|---|
checkpoint_files(target=) |
checkpointFiles({ target }) |
sbx.persist |
where to write the archive |
exclude= |
exclude |
() |
paths relative to HOME that are not archived (below) |
restore_files(source=) |
restoreFiles({ source }) |
sbx.persist |
where to restore from |
timeout= |
timeoutMs |
600 s / 600,000 ms | deadline of the operation (TimeoutException) |
on_progress= |
onProgress |
— | receives CheckpointProgress (files_done, bytes_read, bytes_uploaded) or RestoreProgress (files_done, bytes_downloaded) |
user= |
user |
the sandbox user | whose HOME it is; "root" is permission_denied |
They return a CheckpointResult / RestoreResult with bucket,
key_prefix, uri, files, archive_bytes, sha256, skipped and
duration in seconds (TypeScript: keyPrefix, archiveBytes,
durationMs…), plus bytes_read or bytes_written. They also work on a
connect(persist=) handle or with explicit target=/source=, without
create(persist=).
import os
from rayito import S3Prefix, Sandbox
role = os.environ["RAYITO_EXECUTION_ROLE_ARN"]
home = S3Prefix("amzn-s3-demo-bucket", prefix="rayito-home", name="agent-7")
with Sandbox.create("rayito-base-caps", execution_role_arn=role, persist=home) as sbx:
result = sbx.checkpoint_files(
exclude=["data/raw"],
on_progress=lambda p: print(p.files_done, p.bytes_uploaded),
)
print(result.uri, result.files, result.archive_bytes)
sbx.restore_files() # (1)!
- Extracts the last checkpoint again over the
HOME.
import asyncio
import os
from rayito import AsyncSandbox, S3Prefix
async def main() -> None:
role = os.environ["RAYITO_EXECUTION_ROLE_ARN"]
home = S3Prefix("amzn-s3-demo-bucket", prefix="rayito-home", name="agent-7")
async with await AsyncSandbox.create("rayito-base-caps", execution_role_arn=role, persist=home) as sbx:
result = await sbx.checkpoint_files(exclude=["data/raw"])
print(result.uri, result.files)
restored = await sbx.restore_files()
print(restored.bytes_written)
asyncio.run(main())
import { S3Prefix, Sandbox } from "rayito";
const executionRoleArn = process.env.RAYITO_EXECUTION_ROLE_ARN ?? "";
const persist = new S3Prefix({ bucket: "amzn-s3-demo-bucket", prefix: "rayito-home", name: "agent-7" });
await using sbx = await Sandbox.create({ template: "rayito-base-caps", executionRoleArn, persist });
const result = await sbx.checkpointFiles({
exclude: ["data/raw"],
onProgress: (p) => console.log(p.filesDone, p.bytesUploaded),
});
console.log(result.uri, result.files, result.archiveBytes);
await sbx.restoreFiles();
What is archived and what is not¶
checkpoint_files() packs the HOME of the user making the request (never
/root, whatever RAYITO_ALLOW_ROOT says):
- regular files (mode
0o7777, mtime), directories and symlinks with their target as is (they are never resolved or followed; a linked directory is not descended into); - fixed exclusion list, always: the components
.cache,__pycache__,.ipynb_checkpointsand.rayito-tmp-*at any depth, and.local/share/jupyter/runtime,.ipython/profile_default/history.sqlite(and its-journal); exclude: up to 64 paths relative toHOME, no globs, by whole components (data/rawexcludesdata/raw/…but notdata/raw2);- skipped and counted in
skipped: sockets, FIFOs, devices, what the user cannot read and what is on another filesystem (a tmpfs mounted inside theHOME). Hard links travel as independent files.
The read runs under the user's identity, so a file the user cannot read is
not read by rayd as root. A file that changes while being archived comes
out truncated or padded with zeros up to the lstat size: pause your
work before a checkpoint if you need a consistent copy. Only one
checkpoint or restore at a time per sandbox
(PersistenceException(code="failed_precondition")).
What a restore does¶
restore_files() downloads the manifest (NotFoundException if there is
none) and extracts the archive over the HOME:
- existing files are overwritten, directories are merged and a symlink occupying the path is deleted first (never followed);
- only regular files, directories and symlinks get in; hard links, devices,
FIFOs and sockets are skipped; an entry with
.., absolute, or whose parent leaves theHOMEthrough a symlink aborts the restore (invalid_argument); - the setuid/setgid/sticky bits are dropped (
0o777) and everything created belongs to the user; - the archive's sha256 is checked at the end (
internal,el checksum del archivo no coincide, that is, "the archive checksum does not match").
A restore that fails halfway leaves the HOME partially restored;
recovery is kill() + create(persist=). create(persist=) with a given
name restores automatically (sbx.last_restore) and, if the restore
fails for something other than "there is no checkpoint", closes and
terminates the sandbox as a readiness failure (except with
keep_on_failure).
reincarnate(): beyond max_lifetime¶
sbx.reincarnate(exclude=(), persist_timeout=600) (TypeScript:
sbx.reincarnate({ exclude, persistTimeoutMs })) does, in this order:
checkpoint_files() → Sandbox.create(**same launch options,
persist=sbx.persist) (which restores) → kill() of the old sandbox, and
returns the new one. The new one has a fresh 8 h, another sandbox_id,
another access token (unless the original was explicit) and the same
metadata.
The options that end up in ConfigureSandbox are also reapplied:
mounts=, volumes=, events=, telemetry= and gateways= go back into
the successor's single Configure by the same path as in create(),
resolved with the new sandbox's facts:
| Option | What the successor does |
|---|---|
mounts= |
mounts each bucket again and reincarnate() does not return until all are mounted |
volumes= (experimental) |
sends the section again and resolves the EFS mount targets' IPs again |
events= |
derives a new k_sbx key from the new sandbox_id (it never reuses the original's) |
telemetry= |
resolves the ARN and the image version and the guest's memory again |
gateways= |
rereads each header from Secrets Manager; it never forwards a value already read |
size= is not repeated: the size is already in the ARN of the image being
relaunched. If any section fails in the successor, it is terminated (except
with keep_on_failure) and the old sandbox stays alive, like any other
create() failure.
If create() fails, the old sandbox stays alive and the exception carries
a note with the uri of the already-completed checkpoint. Only on a
create(persist=) sandbox: a connect() handle does not know the launch.
set_timeout() (native and in rayito.e2b) moves the logical deadline
that rayd enforces, but never beyond max_lifetime (the platform cap,
≤ 8 h, fixed after create()): past that cap, reincarnate() remains the
only path, with the files and without the memory.
IAM and bucket¶
infra/iam.yaml accepts PersistenceBucket and PersistencePrefix; with
the bucket defined, the execution role receives exactly:
- Effect: Allow
Action: [s3:PutObject, s3:GetObject, s3:AbortMultipartUpload]
Resource: arn:aws:s3:::<bucket>/<prefix>/*
- Effect: Allow
Action: s3:ListBucket
Resource: arn:aws:s3:::<bucket>
Condition: { StringLike: { s3:prefix: "<prefix>/*" } }
No s3:DeleteObject (the role never deletes) and no
s3:ListMultipartUploadParts. s3:ListBucket exists only so that a
missing key answers 404 NoSuchKey instead of 403, which
NotFoundException depends on.
Operating tips: the bucket in the same region as the image (or pass
region=); SSE-S3 by default is enough (SSE-KMS requires adding
kms:Decrypt and kms:GenerateDataKey to the role: it is not in the
template); add an AbortIncompleteMultipartUpload lifecycle rule at 1 day
on the prefix (a VM killed mid-checkpoint can leave orphaned parts); with
an egress connector of your own (infra/egress-connector.yaml) S3 needs a
gateway endpoint or a NAT. rayd sends no encryption parameters, storage
class or ACL: the bucket's defaults apply (AWS_API_NOTES.md §17 lists
each operation and parameter).
Errors¶
| Situation | Python | TypeScript |
|---|---|---|
persist= without execution_role_arn, invalid exclude, S3Prefix without name |
InvalidArgumentException (before touching AWS) |
InvalidArgumentError |
no execution role, AccessDenied, expired credentials, user="root" |
PersistenceException(code="permission_denied") |
PersistenceError permission_denied |
no checkpoint under the prefix (explicit restore_files) |
NotFoundException |
NotFoundError |
| another operation in progress, unknown region | PersistenceException(code="failed_precondition") |
failed_precondition |
| network, S3 5xx, tar/gzip, checksum, disk full | PersistenceException(code="internal") |
internal |
stream cut midway (/suspend, proxy) |
PersistenceException(code="interrupted"): repeat the operation, the SDK does not resume it |
interrupted |
image with a rayd earlier than 0.2.0 |
PersistenceException(code="unimplemented") with the instruction to republish |
unimplemented |
deadline (timeout, 600 s by default) |
TimeoutException |
TimeoutError |
Cost and measured numbers¶
A checkpoint moves the HOME's tar.gz from the VM to S3 (data transfer
through the INTERNET_EGRESS connector + PutObject in 8 MiB parts) and a
restore brings it back; storage and requests are S3's
(Amazon S3 pricing). Persistence is
only activated with persist= or with an explicit call to
checkpoint_files()/restore_files(): without them there is no call to
S3. The rayd binary grows because of the TLS client (rustls +
aws-lc-rs) and the S3 SDK: 4,700,984 → 12,524,384 B (+7.8 MB,
×2.66); the clean ARM64 build goes from 106 s to 222 s on the development
machine.
Measured on 2026-09-17 against real AWS (tests/e2e/test_m7_persistence.py,
rayito-base-caps 7.0, bucket in the same region, 50 MB of random data in
21 entries; AWS_API_NOTES.md Q53/Q54):
| Operation | Agent | Wall (client) | Throughput |
|---|---|---|---|
first checkpoint_files() (TLS/S3 code not yet paged in) |
1.50 s | 1.67 s | 31.4 MB/s |
second checkpoint_files() |
1.40 s | 1.50 s | 35.0 MB/s |
restore_files() inside create(persist=) |
0.67 s | 7.35 s with the create |
78.0 MB/s |
full reincarnate() |
— | 8.85 s | — |
restore_files(source=) without a checkpoint → NotFoundException |
— | 0.11 s | — |
checkpoint_files() without an execution role → permission_denied |
— | 1.35 s | — |
The 50 MB random archive weighs 52,479,326 B (gzip level 1 does not
compress noise; a HOME of text and notebooks does shrink). imds_blocked
stayed true on the caps image throughout the cycle: persistence does not
open IMDS to the sandbox code. It was not measured at 2 GB.