Back to Microsandbox

Customization

docs/sandboxes/customization.mdx

0.4.410.6 KB
Original Source

Before a sandbox starts doing real work, you can prepare it three ways: scripts that bundle reusable commands, patches that modify the rootfs before the VM boots, and init handoff that lets you run a real init system (systemd, OpenRC, s6) as PID 1 instead of microsandbox's minimal agent. All three are defined at creation time and keep the base image untouched.

Scripts

Scripts are files mounted at /.msb/scripts/ inside the sandbox. The directory is on PATH, so each script is callable by name through exec() or shell().

It provides a clean way to bundle setup procedures or entry points with a sandbox without baking them into the image.

<CodeGroup> ```rust Rust use indoc::indoc; use microsandbox::Sandbox;

let sb = Sandbox::builder("worker") .image("ubuntu") .script("setup", indoc! {" #!/bin/bash apt-get update && apt-get install -y python3 curl "}) .script("start", indoc! {" #!/bin/bash exec python3 /app/main.py "}) .create() .await?;

sb.shell("setup").await?; let output = sb.shell("start").await?;


```typescript TypeScript
import { Sandbox } from "microsandbox";

await using sb = await Sandbox.builder("worker")
    .image("ubuntu")
    .script("setup", "#!/bin/bash\napt-get update && apt-get install -y python3 curl")
    .script("run",   "#!/bin/bash\nexec python3 /app/main.py")
    .create();

await sb.shell("setup");
const output = await sb.shell("run");
python
from microsandbox import Sandbox

sb = await Sandbox.create(
    "worker",
    image="ubuntu",
    scripts={
        "setup": "#!/bin/bash\napt-get update && apt-get install -y python3 curl",
        "start": "#!/bin/bash\nexec python3 /app/main.py",
    },
)

await sb.shell("setup")
output = await sb.shell("start")
</CodeGroup>

Patches

Patches modify the rootfs before the VM boots. Write config files, copy directories from the host, create symlinks, append to existing files, remove things you don't need. The base image stays untouched since patches are written to the writable layer on top.

Patches are applied in order and work with OCI images and bind-mounted rootfs. They're not supported with disk image roots (QCOW2, Raw).

<Tip> By default, patching a path that already exists in the image will error. Pass `replace: true` on the operation to allow it. `Mkdir` and `Remove` are idempotent and won't error either way. </Tip> <CodeGroup> ```rust Rust use microsandbox::Sandbox;

let sb = Sandbox::builder("worker") .image("alpine") .patch(|p| p .text("/etc/greeting.txt", "Hello from a patched rootfs!\n", None, false) .text("/etc/motd", "Custom message of the day.\n", None, true) // replace existing .mkdir("/app", Some(0o755)) .text("/app/config.json", r#"{"debug": true}"#, Some(0o644), false) .copy_file("./cert.pem", "/etc/ssl/cert.pem", None, false) .append("/etc/hosts", "127.0.0.1 myapp.local\n") ) .create() .await?;


```typescript TypeScript
import { Sandbox } from "microsandbox";

await using sb = await Sandbox.builder("worker")
    .image("alpine")
    .patch((p) => p
        .text("/etc/greeting.txt", "Hello from a patched rootfs!\n")
        .text("/etc/motd", "Custom message of the day.\n", { replace: true })
        .mkdir("/app", { mode: 0o755 })
        .text("/app/config.json", '{"debug": true}', { mode: 0o644 })
        .copyFile("./cert.pem", "/etc/ssl/cert.pem")
        .append("/etc/hosts", "127.0.0.1 myapp.local\n"),
    )
    .create();
python
from microsandbox import Patch, Sandbox

sb = await Sandbox.create(
    "worker",
    image="alpine",
    patches=[
        Patch.text("/etc/greeting.txt", "Hello from a patched rootfs!\n"),
        Patch.text("/etc/motd", "Custom message of the day.\n", replace=True),
        Patch.mkdir("/app", mode=0o755),
        Patch.text("/app/config.json", '{"debug": true}', mode=0o644),
        Patch.copy_file("./cert.pem", "/etc/ssl/cert.pem"),
        Patch.append("/etc/hosts", "127.0.0.1 myapp.local\n"),
    ],
)
</CodeGroup>

Available operations

The patch builder is invoked through SandboxBuilder.patch(p => ...). Each method appends an operation; calls are chainable.

MethodDescription
text(path, content, opts?)Write text content to a file
file(path, bytes, opts?)Write raw bytes to a file
mkdir(path, opts?)Create a directory (idempotent)
append(path, content)Append content to an existing file
copyFile(src, dst, opts?)Copy a file from the host into the rootfs
copyDir(src, dst, opts?)Recursively copy a directory from the host
symlink(target, link, opts?)Create a symlink
remove(path)Delete a file or directory (idempotent)

opts accepts { mode?: number; replace?: boolean } for text / file / copyFile, { replace?: boolean } for copyDir / symlink, and { mode?: number } for mkdir.

Init handoff

By default the microsandbox agent runs as PID 1 inside the guest — small, fast, and minimal. For workloads that expect a real init (systemd, OpenRC, s6, runit) — long-lived daemons, system service tests, dbus-using tools — you can hand PID 1 over to the init binary of your choice. The agent does the boot-time setup (mount filesystems, configure network, prepare runtime dirs), then forks. The parent execs your init and becomes PID 1; the agent continues as a normal child process serving host requests over the same channel.

The simplest entry point is auto, which asks the agent to probe a small list of well-known paths inside the guest rootfs (/sbin/init, /lib/systemd/systemd, /usr/lib/systemd/systemd) and pick the first one that exists. If you need an exact path (e.g. for reproducible CI), pass an absolute path instead.

<CodeGroup> ```bash CLI msb run jrei/systemd-debian:12 \ -m 1G -c 2 \ --init auto \ -- bash ```
rust
use microsandbox::Sandbox;

let sb = Sandbox::builder("worker")
    .image("jrei/systemd-debian:12")
    .memory(1024)
    .cpus(2)
    .init("auto")
    .create()
    .await?;
typescript
import { MiB, Sandbox } from "microsandbox";

await using sb = await Sandbox.builder("worker")
    .image("jrei/systemd-debian:12")
    .memory(MiB(1024))
    .cpus(2)
    .init("auto")
    .create();
python
from microsandbox import Sandbox

sb = await Sandbox.create(
    "worker",
    image="jrei/systemd-debian:12",
    memory=1024,
    cpus=2,
    init="auto",
)
</CodeGroup>

To verify the handoff worked, check /proc/1/comm inside the sandbox — it should print the init's name (systemd, init, etc.):

bash
$ msb run jrei/systemd-debian:12 --init auto -- cat /proc/1/comm
systemd

If --init=auto can't find anything in its candidate list, agentd fails boot with a clear error in kernel.log listing every path it checked. Switch to an explicit path (--init=/lib/systemd/systemd) when you know exactly where the init lives, or follow the image-picking guidance below to choose an image that actually ships one.

Argv and env

Pass extra argv to the init via init_with (Rust/TypeScript) or by giving init= an InitConfig (Python). On the CLI, repeat --init-arg once per entry and --init-env KEY=VAL for env vars. Argv defaults to [<cmd>] when no --init-arg is given; env is merged on top of the inherited environment.

<CodeGroup> ```bash CLI msb run jrei/systemd-debian:12 \ --init /lib/systemd/systemd \ --init-arg --unit=multi-user.target \ --init-env container=microsandbox \ -- bash ```
rust
let sb = Sandbox::builder("worker")
    .image("jrei/systemd-debian:12")
    .init_with("/lib/systemd/systemd", |i| i
        .args(["--unit=multi-user.target"])
        .env("container", "microsandbox"))
    .create()
    .await?;
typescript
await using sb = await Sandbox.builder("worker")
    .image("jrei/systemd-debian:12")
    .initWith("/lib/systemd/systemd", (i) => i
        .args(["--unit=multi-user.target"])
        .env("container", "microsandbox"))
    .create();
python
from microsandbox import InitConfig, Sandbox

sb = await Sandbox.create(
    "worker",
    image="jrei/systemd-debian:12",
    init=InitConfig(
        cmd="/lib/systemd/systemd",
        args=("--unit=multi-user.target",),
        env={"container": "microsandbox"},
    ),
)
</CodeGroup>

Picking an image

Most slim Docker base images (debian:bookworm-slim, ubuntu:24.04, python:3.12-slim) are stripped of init binaries — they're built for "one process per container" and don't ship systemd at all. If you point --init at a path the image doesn't contain, the agent's pre-flight check fails boot with a clear error in the kernel log, no kernel panic.

Two ways to get an image with an init:

Build a small custom image:

dockerfile
# Dockerfile.systemd
FROM debian:bookworm
RUN apt-get update \
    && apt-get install -y --no-install-recommends systemd \
    && rm -rf /var/lib/apt/lists/*
bash
docker buildx build -t local-systemd:debian -f Dockerfile.systemd .
msb run local-systemd:debian --init /lib/systemd/systemd -- bash

Use a community-built systemd image (e.g. jrei/systemd-debian:12, jrei/systemd-ubuntu:22.04). These work out of the box but are published by community maintainers, not the distros themselves — vet them like any other third-party image before running real workloads.

For a sanity check that doesn't need systemd, alpine:3.20 ships BusyBox at /sbin/init, which is enough to exercise the handoff mechanics:

bash
msb run alpine:3.20 --init /sbin/init -- sh -c "cat /proc/1/comm"
# busybox

Shutdown semantics

In default (no-handoff) mode, microsandbox shuts the guest down by remounting root read-only and calling reboot(RB_POWER_OFF) — typically <100 ms. With handoff, the agent isn't PID 1 anymore, so it asks your init to shut down via SIGRTMIN+4 (systemd's poweroff signal) with a SIGTERM fallback. Your init then runs its own teardown — stop services, unmount, halt — which is slower:

InitTypical shutdown
BusyBox / s6 / runit<100 ms
OpenRC50–500 ms
systemd1–5 s

This is the price of a real init. If your test harness measures end-to-end sandbox lifecycle and you're comparing to a no-handoff baseline, account for this.

Init and entrypoint

--init and --entrypoint are orthogonal. --init controls PID 1 — what runs the system. --entrypoint (and the trailing -- cmd) controls what your workload runs. They can be combined: boot systemd as PID 1 and have microsandbox exec calls land your shell, scripts, or app inside the systemd-managed environment.