Declarative templates¶
A fluent DSL (the same as E2B v2's Template) that compiles to a
deterministic Dockerfile and zip, composed on top of an already published
rayito-base/rayito-base-caps image, and uploads them with
create/update-microvm-image. No control plane of its own: everything happens
on the client (research §3, docs/research/2026-10-e2b-out-of-scope.md).
Since 0.6.0.
Cost and activation
- Default: off. Without calling
Template.build()/build_in_background()(TypeScript:buildInBackground), the SDK does not build anylambda-microvms/S3/Logs client: the builder (Template(),copy(),pip_install(),dnf_install(),npm_install(),to_dockerfile()...) is pure. - Turns on:
Template.build(t, "my-template", bucket="...")/Template.build(t, "my-template", { bucket: "..." }). - Resources and AWS calls:
lambda:GetMicrovmImageVersion(read the base image and, at the end, poll the build),CreateMicrovmImage/UpdateMicrovmImage,GetMicrovmImage,ListMicrovmImageVersions;s3:GetObject(the base image's zip),HeadObject/PutObject(upload the new artifact, only if it does not already exist by its hash);logs:DescribeLogStreams/GetLogEventsonly if the build does not end inSUCCESSFUL+ACTIVE(to explain the failure). SeeAWS_API_NOTES.md§27. - Approximate cost (checked 2026-10-01): each new image version
costs snapshot storage, ≈ $0.04/week per version
(one-week minimum), the same as publishing with
rayito image publish; the build itself is not billed separately (no CodeBuild, option A of the research). Each context zip stays in your bucket underrayito/templates/(standard S3 storage): put a lifecycle rule on that prefix; no later build reads it again. - IAM:
RayitoTemplateBuilder(infra/templates.yaml) on whoever callsTemplate.build():UpdateMicrovmImage/GetMicrovmImage*/ListMicrovmImageVersionson the account's and region's images, with an explicitDenythat prevents updating the published base images (ProtectedImageNamePrefix,rayito-baseby default);CreateMicrovmImageon*, because AWS authorizes it on*and not on the new image's ARN, so it cannot be scoped by name (acreateon a name that already exists fails, so it does not overwrite a base either);lambda:PassNetworkConnectoron the AWS-managed connectors, whichcreate/updatepass even if you name none (AWS_API_NOTES.mdQ114);iam:PassRoleon the build role (the same onerayito image publishuses);s3:PutObject/GetObjectonrayito/templates/of the artifact bucket ands3:GetObjectonly onrayito/*of the base image bucket (by default, the same one; never the whole bucket, which may also hold the persistedHOMEs and the sandboxes' transfers); read access to the image's log group. Deliberately separate from any sandbox-launching policy. - How to turn it off: do not call
Template.build()/buildInBackground(). The old versions of an already built image are deleted withrayito image prune --image-name <name>(Templatedoes not delete them;prunekeeps at least one). The/rayito/<name>log group the build role creates on the first build is not deleted with the image either (delete-microvm-imagedoes not touch it, and neither the SDK nor the CLI deletes images): when retiring an image, delete it yourself withaws logs delete-log-group --log-group-name /rayito/<name>(logs:DeleteLogGroup) or set a retention on it.
Publish every protected name you will use first
Since CreateMicrovmImage cannot be scoped by name, whoever has
RayitoTemplateBuilder can create an image with a protected name
that does not yet exist in that account and region (for example a size
or -caps variant you have not published). If a service later asks for
that name (Sandbox.create(size=...) resolves it by suffix), it would run
the builder's code with that service's execution role. Publish with
rayito image publish, in every region you use, all the variants your
services might ask for.
When to use it¶
- You are migrating an agent that already uses E2B's
Template: the DSL is the same (fromBaseImage/from_base_imageinstead offromDockerImage,copy/pipInstall/setEnvs/workdir/setUser/setStartCmd). - You want your CI to build a new image from a declarative
Dockerfileinstead of maintaining therayito image publishpipeline by hand. - When not to: a single one-off change to an already published image —
rayito image publishis still the direct path.
Before you start: the IAM policy¶
Whoever calls Template.build() needs RayitoTemplateBuilder, the
templates component of rayito stack. The two
required ARNs come from your IAM stack (BuildRoleArn is an output of
infra/iam.yaml):
rayito stack deploy templates \
--param ArtifactBucketArn=arn:aws:s3:::amzn-s3-demo-bucket \
--param BuildRoleArn=arn:aws:iam::123456789012:role/<build-role>
rayito stack status templates # the policy's ARN, to attach it
BaseImageBucketArn is only needed if the base image's zip is in
another bucket; ProtectedImageNamePrefix (rayito-base by default) is the
prefix of the images the builder can never update.
Differences from E2B¶
- No layer cache between builds. Rayito has none (research
§3.4): each
Template.build()rebuilds everything that changed since the base image. A version already built with exactly the same artifact and the same configuration is reused without callingcreate/update-microvm-image;skip_cache()/skipCache()is equivalent toforce=True/force: trueand submits a new build anyway. - ARM64 only, CPU according to memory. The native API accepts
cpu_count=but ignores it; the E2B shim warns withRayitoCompatWarningif it is given (the CPU comes frommemory_mb, Q87).memory_mb/memoryMbis validated against the five supported sizes (RES-1); the E2B shim rounds up to the next size withRayitoCompatWarning. - The composed image inherits the base's configuration. Hooks, build
role, managed image and
additionalOsCapabilitiescome from therayito-base/rayito-base-capsversion you compose on: a template onrayito-base-capsstill has capabilities. - Names without
:tag. A template's name is the Lambda MicroVMs image's: 1-64 characters of[A-Za-z0-9_-](TemplateException/TemplateErrorotherwise). - Only
fromBaseImage()really composes.from_image/fromImage,from_template/fromTemplate,from_dockerfile/fromDockerfileandfrom_gcp_registry/fromGcpRegistryraiseUnimplementedError: injectingraydand its hooks into an external image does not yet have a supported path (raydonly exists already installed inside a publishedrayito-*image).apt_install/aptInstallalso raises:rayito-baseis Amazon Linux 2023 (dnf, notapt): usednf_install/dnfInstall. - No live streaming of the steps. Each step's log is reread when the build finishes (TPL-1/Q83: the log group receives BuildKit's full output all at once, not live), never during.
- E2B's tagging has no analogue.
alias_exists/assign_tags/remove_tags/get_tags(and their TS equivalents) raiseUnimplementedError:create/update-microvm-imageonly tags the whole image, not a specific version. set_start_cmd/setStartCmdneeds a base image withrayd0.6. TheStartSpecis baked into/etc/rayito/template.json; arayd0.6 or later reads it on startup, launchesstart_cmdas a managed process before the build's/ready, so the snapshot already carries it running (it shows up incommands.list()), and answers 503 on/readyuntilready_cmdexits with 0; if its deadline runs out,/readyfails and the build ends withBuildException(reason="ready_server_error"). A base image published with an earlierraydignores the file.- The
start_cmdis already running when/runarrives. Since it is in the snapshot, in each sandbox launched from the template it is thawed before the/runhook that installs the access token.raydrejects a/runthat comes from a sandbox process (any uid ≥ 1000) without consuming the startup's/run, so a malicious or compromisedstart_cmdcannot install its own token or leave the sandbox without one; only one that runs as root (user="root"withRAYITO_ALLOW_ROOT=1in the image) falls outside that protection (SECURITY.mdT2).
Build context¶
copy("app/", "/srv/app/") reads app/ relative to context_dir=/
contextDir (by default, the current directory) and respects its
.dockerignore. A path that leaves the context (../secret, or a src
that is a symbolic link pointing outside) gives
BuildException(reason="context_path_outside"). Inside a copied directory,
symbolic links (to a file or a directory) are never
followed, as in Docker and in both SDKs: a config ->
~/.aws/credentials inside app/ does not end up in the artifact uploaded to
S3 or in the image, where the sandbox code could read it. If
you need that content, copy it as a real file inside the context.
Files go under __rayito_context/ inside the zip: they never replace
the composed Dockerfile or the base image's rayd binary.
.dockerignore¶
Both SDKs follow Docker's .dockerignore semantics (with the
same test vectors):
- Each pattern is anchored at the context root:
README.mddoes not excludedocs/README.md, and/build,buildandbuild/are the same. *and?do not cross/:*.pyconly excludes those at the root; for any depth,**/*.pyc.**is zero or more directories:**/.envexcludes the root's.envand those of any subdirectory (the idiomdocker initgenerates).- A pattern that matches a directory excludes everything inside it, and
the last matching pattern wins:
node_modulesfollowed by!node_modules/keep.jsletskeep.jsthrough.
If the context is going to package something that usually carries secrets (.env,
.env.*, .git/, .aws/, .ssh/, *.pem, *.key), the SDK warns with
the paths (never the content) before uploading anything: in the image, any
sandbox code could read it. To make the warning stop a build in CI (the match is
against the text of the SDK's warning, which is in Spanish):
import { Template } from "rayito";
process.on("warning", (warning) => {
if (warning.name === "RayitoContextWarning") {
console.error(warning.message);
process.exit(1);
}
});
const t = new Template().fromBaseImage().copy(".", "/app");
await Template.build(t, "my-template", { bucket: "my-artifact-bucket" });
Behavior change in 0.6.x
Before, * crossed / (*.pyc also excluded sub/x.pyc) and **/X
did not match at the root. If your .dockerignore relied on the former,
add **/ in front of the pattern.
Errors¶
reason |
What happened |
|---|---|
None + step/command/exit_code/log_tail |
a RUN of the composed Dockerfile exited with an error |
ready_client_error / ready_server_error |
the process behind /ready answered 4xx/5xx during the build (Q85) |
build_quota |
10 builds are already running in this process, or AWS rejected the account's eleventh (Q83) |
aws_error |
AWS rejected the build for another reason; the message and the cause carry only the sanitized code and message (without the canonical string of a signature error) |
build_timeout |
the build did not finish within timeout; it continues in AWS and get_build_status() queries it |
context_path_missing / context_path_outside |
a copy() names something that does not exist, or outside the context |
base_image_not_s3 / base_image_missing_artifact / base_image_missing_entrypoint |
the base image is not a rayito-* image published with rayito image publish |
Messages name the template you passed, never an ARN or AWS's unsanitized free text.
Install packages¶
Three shortcuts add a RUN step to the template's Dockerfile, like
pip_install()/pipInstall(). Names are shell-escaped and an empty
list, an empty name or one starting with - raises
InvalidArgumentException/InvalidArgumentError before any call
to AWS.
| Python | TypeScript | What it does |
|---|---|---|
dnf_install(packages) |
dnfInstall(packages) |
dnf install -y --setopt=install_weak_deps=0 ... && dnf clean all (Amazon Linux 2023) |
npm_install(packages=None, g=False) |
npmInstall(packages?, { g: false }) |
npm install --no-audit --no-fund [-g] [...]; without packages it runs a plain npm install in the WORKDIR (the package.json copied earlier). Same signature as E2B |
pip_install(packages) |
pipInstall(packages) |
python3 -m pip install ... |
dnf_install and npm_install(g=True) always run as root, as in E2B:
if a previous set_user() left another user, the step is wrapped in
USER root and goes back to that user afterwards. A local npm_install keeps
the current user and writes to the WORKDIR.
rayito-base does not bring Node.js or npm. Install them first with
dnf_install(["nodejs20", "nodejs20-npm"]): if npm is missing, the step fails with
code 127 and a message that explains it, instead of a cryptic
npm: not found.
Measured on the base image
The names come from installing on the base image pinned by digest
(docker run, 2026-10-14): nodejs20 brings Node 20.20.2 and nodejs20-npm
npm 10.8.2, with node and npm on the PATH. The bare nodejs package
is Node 18, out of support. The dnf flag is
install_weak_deps=0: this dnf rejects False.
apt_install/aptInstall still raises UnimplementedError, and its message
points to dnf_install/dnfInstall.
Quick example¶
from rayito import Sandbox, Template, wait_for_port
t = (
Template()
.from_base_image("rayito-base") # (1)!
.pip_install(["pandas==2.2.3"])
.copy("app/", "/srv/app/")
.set_envs({"MODE": "prod"})
.set_start_cmd("python3 -m http.server 8000", wait_for_port(8000))
)
info = Template.build(t, "my-template", bucket="my-artifact-bucket") # (2)!
print(info.template_id) # arn:aws:lambda:...:microvm-image:my-template
with Sandbox.create(info.template_id) as sbx:
sbx.commands.run("curl -s localhost:8000")
- Pure: it does not call AWS until
Template.build(). - Resolves the latest
ACTIVEversion ofrayito-base, composes the Dockerfile, uploads the zip and waits for the build to finish.
import asyncio
from rayito import AsyncTemplate, Template, wait_for_port
async def main() -> None:
t = Template().from_base_image().set_start_cmd(
"python3 -m http.server 8000", wait_for_port(8000)
)
info = await AsyncTemplate.build(t, "my-template", bucket="my-artifact-bucket")
print(info.template_id)
asyncio.run(main())
import { Sandbox, Template, waitForPort } from "rayito";
const t = new Template()
.fromBaseImage("rayito-base")
.pipInstall(["pandas==2.2.3"])
.copy("app/", "/srv/app/")
.setEnvs({ MODE: "prod" })
.setStartCmd("python3 -m http.server 8000", waitForPort(8000));
const info = await Template.build(t, "my-template", { bucket: "my-artifact-bucket" });
console.log(info.templateId);
await using sbx = await Sandbox.create({ template: info.templateId });
from rayito.e2b import E2B, Template
client = E2B(region="us-east-1", bucket="my-artifact-bucket") # (1)!
t = Template().from_base_image().pip_install(["pandas==2.2.3"])
info = client.Template.build(t, alias="my-template", memory_mb=2048, skip_cache=False)
bucket=is a Rayito extension; it is also valid on each call.
In TypeScript, with E2B JS's options shape:
Template.build() options¶
| Python | TypeScript | Default | What it does |
|---|---|---|---|
bucket |
bucket |
— (required) | artifact bucket where the zip is uploaded, under rayito/templates/ |
memory_mb |
memoryMb |
2048 |
image size, one of 512/1024/2048/4096/8192 |
force |
force |
False |
builds even if a version with the same artifact already exists (skip_cache()) |
timeout (s) |
timeoutMs |
1800 s | maximum wait for the build (build() only); when it runs out, BuildException(reason="build_timeout") |
base_image_version |
baseImageVersion |
the latest ACTIVE |
version of the base image you compose on |
on_build_logs |
onBuildLogs |
— | receives the build log line by line, at the end (build() only) |
context_dir |
contextDir |
the current directory | root of copy() paths |
region, session |
region, credentials |
the default session | where and with which credentials |
cpu_count, build_role_arn |
— | — | accepted and ignored: the CPU comes from memory_mb and the build role is the base image's |
Template.exists(name) tells you whether the image already exists, and
t.to_dockerfile()/t.to_json() (toDockerfile()/toJSON()) show what
is going to be built without calling AWS. With the CLI, rayito template build
reads a .py file that defines a variable template.
A build that fails¶
import { BuildError, Template } from "rayito";
const t = new Template().fromBaseImage().pipInstall(["this-package-does-not-exist"]);
try {
await Template.build(t, "my-broken-template", { bucket: "my-artifact-bucket" });
} catch (error) {
if (error instanceof BuildError) {
console.log(error.step, error.command, error.exitCode); // 1 'RUN pip install ...' 1
}
}
Background build and logs¶
Template.build() waits for the build to finish (at most timeout, 30
minutes by default). To avoid blocking, build_in_background()
(TypeScript: buildInBackground()) returns as soon as AWS accepts the build
a BuildHandle, which get_build_status() queries whenever you want, also
from another process:
import time
from rayito import Template
t = Template().from_base_image().pip_install(["pandas==2.2.3"])
handle = Template.build_in_background(t, "my-template", bucket="my-artifact-bucket")
status = Template.get_build_status(handle)
while status.state == "IN_PROGRESS":
time.sleep(15)
status = Template.get_build_status(handle)
if status.info is not None:
print(status.info.template_id) # SUCCESSFUL
else:
print(status.error_message) # FAILED
import { setTimeout as sleep } from "node:timers/promises";
import { Template } from "rayito";
const t = new Template().fromBaseImage().pipInstall(["pandas==2.2.3"]);
const handle = await Template.buildInBackground(t, "my-template", {
bucket: "my-artifact-bucket",
});
let status = await Template.getBuildStatus(handle);
while (status.state === "IN_PROGRESS") {
await sleep(15_000);
status = await Template.getBuildStatus(handle);
}
console.log(status.info?.templateId ?? status.errorMessage);
BuildKit's log arrives when the build finishes, never live (the image's
log group receives it all at once, Q83). To receive it line by line
from Template.build(), pass on_build_logs (TypeScript:
onBuildLogs); without it, the SDK does not read the log group except to explain
a failure:
In TypeScript, onBuildLogs and the explanation of a failed build read the
log with the optional peer @aws-sdk/client-cloudwatch-logs
(pnpm add @aws-sdk/client-cloudwatch-logs); without it installed, the build
works the same, but onBuildLogs receives no lines and
BuildError arrives without step/command/logTail.
From E2B's Template to Rayito¶
The shim (rayito.e2b / rayito/e2b) accepts E2B's build signature and
translates it to the native one; the DSL is the same class in both.
| E2B | Native Rayito | Note |
|---|---|---|
Template().from_image("python:3.12"), from_template, from_dockerfile, from_gcp_registry |
Template().from_base_image("rayito-base") |
it only composes on a published rayito-* image; those four sources raise UnimplementedError |
copy, run_cmd, pip_install, npm_install(packages=None, g=False), set_envs, set_user, skip_cache |
the same methods | skip_cache() is equivalent to force=True; npm_install needs Node.js in the image (see above) |
set_workdir(path) |
workdir(path) |
— |
apt_install([...]) |
dnf_install([...]) |
rayito-base is Amazon Linux 2023; apt_install raises UnimplementedError and points to dnf_install |
set_start_cmd(cmd, wait_for_port(8000)) |
the same, also wait_for_url/wait_for_process/wait_for_file |
needs a base image with rayd 0.6 |
Template.build(t, alias="x", cpu_count=, memory_mb=, skip_cache=, on_build_logs=) |
Template.build(t, "x", bucket=, memory_mb=, force=, on_build_logs=) |
the shim asks for bucket in the call or in E2B(bucket=...); memory_mb is rounded up to the next size and cpu_count is ignored, both with RayitoCompatWarning |
Template.build_in_background(...) + Template.get_build_status(...) |
the same, with a BuildHandle |
— |
Template.exists(name) |
the same | — |
alias_exists, assign_tags, remove_tags, get_tags |
— | raise UnimplementedError: Lambda MicroVMs tags the whole image, not a version |
TemplateException, BuildException |
the same classes | BuildException carries reason, step, command, exit_code and log_tail |
Reference¶
rayito.Template/rayito.AsyncTemplateAWS_API_NOTES.md§27 (build logs and thecodeArtifactcontract)ARCHITECTURE.mdADR-022